Compare commits

...

41 Commits

Author SHA1 Message Date
whoisdomi 799c5effeb Low Speed Twitches 2026-11-09 11:23:17 -06:00
whoisdomi 18e52b36d1 Force Stop Improvements
1. Rolling stops fixed: a latched sign survives the light clearing, and the stop target can't jump farther away.

2. Sign over a long road: a sign can latch even when the model sees far ahead, once Force Stop is already slowing.

3. Vision distance: sign distance comes from the box size, averaged with the dashboard's 27 m.

4. Sign behind a car: if you're stopped behind a car at a sign, it stops at the line once that car leaves.

5. False early sign: an early dashboard sign is ignored if stopping for it would need hard braking.
2026-11-09 11:01:43 -06:00
firestar5683 e1158eb847 Require cruise for automatic lane changes 2026-11-09 10:28:56 -06:00
firestar5683 e2a3802722 Preserve manual tuning descriptions while parked 2026-11-09 16:05:56 +00:00
firestar5683 e0f8b7a118 Preserve Panda power-save state during CAN error recovery 2026-11-09 10:03:08 -06:00
firestar5683 febb4ebdb2 Isolate Model Runtime Caches By User 2026-11-09 15:45:03 +00:00
Dom c6cdc7fa73 Keep FLM proposal regressions within the test file size gate 2026-11-09 03:48:01 -06:00
firestar5683 a9427aba54 Use consistent score units for automatic lateral proposals 2026-11-09 08:52:59 +00:00
firestar5683 13f914a54f Include short turns in automatic lateral proposals 2026-11-09 07:45:00 +00:00
firestar5683 adcf6ca32d Keep all vision worker threads on their owned cores 2026-11-09 00:55:01 -06:00
firestar5683 cf916938dd Retry transient Actionlint download failures 2026-11-09 00:31:41 -06:00
firestar5683 1272c27b8d Restore source-bound classic HKG advisory forwarding 2026-11-09 00:23:41 -06:00
firestar5683 f711833ba1 Preserve classic HKG lateral intent through stock pedal source gaps 2026-11-09 00:07:04 -06:00
Dom 68305fa238 Separate coordinate proposal tests and fix assertion spelling 2026-11-08 23:49:59 -06:00
firestar5683 fc1bdaae7d Consider all thirty lateral knobs from route symptoms 2026-11-09 05:29:58 +00:00
Dom 1b797b7359 Preserve Experimental preference during stock ACC fallback
Keep the full-time Experimental choice across temporary startup and cached UI longitudinal unavailability. Runtime authority and SafeMode checks are unchanged.

Cover stock-to-qualified restart, both native settings layouts, unsupported runtime states and explicit mode changes.
2026-11-08 21:29:22 -06:00
Dom 795799f4ab Split offroad native-output tests below source size limit 2026-11-08 21:14:49 -06:00
firestar5683 aae3767d89 Replay unchanged native limits before judging a learned tune 2026-11-09 02:24:36 +00:00
firestar5683 22308dac24 Restore selfdrived executable mode 2026-11-08 18:49:06 -06:00
firestar5683 2cbbbca5e2 Preserve MQB lateral intent through crawl and brake joins 2026-11-08 18:42:31 -06:00
firestar5683 8525214a37 Select feasible steering recommendations before validation 2026-11-09 00:19:14 +00:00
firestar5683 4f78a28b2e Keep CI fixtures aligned with actual controller configuration 2026-11-08 17:37:57 -06:00
firestar5683 f4d3b646f4 Enable openpilot explicitly in FLM source-renewal fixture 2026-11-08 16:36:44 -06:00
firestar5683 496e72ed00 Repair inherited CI contracts and build matching Panda artifacts 2026-11-08 16:08:45 -06:00
firestar5683 9be62987b5 Avoid shadowing Panda heartbeat state in MQB AOL 2026-11-08 15:55:59 -06:00
firestar5683 d39ea5382c Prevent false Ford recovery releases and steering refusals 2026-11-08 15:39:55 -06:00
firestar5683 349dde99db Fit actuator-visible steering recommendations before validation 2026-11-08 21:17:25 +00:00
firestar5683 0c9037b243 Cover first-press MQB guards in unittest safety discovery 2026-11-08 15:17:14 -06:00
firestar5683 8626158c5d Restore the rolling stopped-lead guard for MQB Alpha Long 2026-11-08 15:15:13 -06:00
firestar5683 0ae8a445bf Smooth Turn Assist transitions around straight steering requests 2026-11-08 15:11:32 -06:00
firestar5683 746e0da673 Build matching Panda firmware for first-press MQB engagement 2026-11-08 15:06:08 -06:00
firestar5683 1d5508b19d Accept the first MQB SET or RES before the host joins 2026-11-08 15:02:23 -06:00
firestar5683 1e0e06eef4 Preserve Subaru AOL intent through EPS brake event joins 2026-11-08 15:02:04 -06:00
firestar5683 c22785a22f Yield HKG direct-angle fault recovery until driver release 2026-11-08 14:53:53 -06:00
firestar5683 0c37a00b1c Preserve validated AOL metadata during source gaps 2026-11-08 14:45:53 -06:00
firestar5683 495fa11cf6 Keep HKG AOL armed when brake health precedes permission updates 2026-11-08 14:29:17 -06:00
firestar5683 27cc914f54 Track small Subaru Crosstrek angle requests at low speed 2026-11-08 14:25:59 -06:00
firestar5683 52775f1f54 Keep GM AOL armed below the cruise engagement minimum 2026-11-08 14:15:30 -06:00
Dom 9251f9b918 Place C4 startup date beside version and branch 2026-11-08 13:15:14 -06:00
firestar5683 3bdcfe7f52 Build matching Tesla and Palisade AOL firmware 2026-11-08 13:14:14 -06:00
firestar5683 9bbf79766a Preserve Tesla screen intent and Palisade Cancel behavior 2026-11-08 00:08:13 -06:00
155 changed files with 3982 additions and 302 deletions
+2
View File
@@ -62,6 +62,8 @@ jobs:
run: >-
tools/op.sh test
openpilot/starpilot/flm/tests/test_offroad_backend.py
openpilot/starpilot/flm/tests/test_offroad_coordinate_policy.py
openpilot/starpilot/flm/tests/test_offroad_equal_native_output.py
--json-output ci-results/flm-offroad.json
- name: Test completed-drive and steering-result presentation
timeout-minutes: 1
@@ -118,6 +118,7 @@ class CarController(CarControllerBase):
self.angle_vm = VehicleModel(CP) if CP.flags & HyundaiFlags.CANFD_ANGLE_STEERING else None
self.apply_angle_last = 0.0
self.angle_gain_last = 0.0
self.angle_fault_driver_override = False
self.angle_tx_recovery = (AngleTxRecovery(0xCB, self.CAN.ECAN)
if pe_long_qualified(CP) or ev9_long_qualified(CP) else AngleTxRecovery(0x110, self.CAN.ACAN)
if CP.carFingerprint in (CAR.HYUNDAI_IONIQ_5_PE, CAR.KIA_EV9) and
@@ -436,6 +437,8 @@ class CarController(CarControllerBase):
# steering control
if ev9_long:
self.angle_fault_driver_override = bool(CS.out.steeringPressed and (
self.angle_fault_driver_override or CS.out.steerFaultTemporary or CS.out.steerFaultPermanent))
drive = CS.out.gearShifter == structs.CarState.GearShifter.drive
measured = CS.angle_steering_angle
if self.angle_tx_recovery is not None and self.angle_tx_recovery.take_rejection(self.frame):
@@ -450,7 +453,8 @@ class CarController(CarControllerBase):
ev9_lateral_allowed = lateral_request_allowed(CC, CS.out, CS.angle_steering_fault,
independent=(ev9_long_aol_qualified(self.CP, marked_only=True) or
pe_long_aol_qualified(self.CP, marked_only=True)))
active = bool(ev9_lateral_allowed and CC.latActive and abs(measured) <= bound and abs(self.apply_angle_last) <= bound)
active = bool(ev9_lateral_allowed and CC.latActive and not self.angle_fault_driver_override and
abs(measured) <= bound and abs(self.apply_angle_last) <= bound)
self.ev9_angle_filter.update_alpha(float(np.interp(CS.out.vEgo, [5.0, 10.0, 20.0], [0.2, 0.1, 0.0])))
desired = self.ev9_angle_filter.update(float(np.clip(CC.actuators.steeringAngleDeg,
-(self.params.ANGLE_LIMITS.STEER_ANGLE_MAX if pe_long else EV9_OP_LONG_ANGLE_TARGET_MAX),
+2 -1
View File
@@ -348,9 +348,10 @@ class CarState(CarStateBase):
self.lkas11 = copy.copy(cp_cam.vl["LKAS11"])
self.clu11 = copy.copy(cp.vl["CLU11"])
from opendbc.car.hyundai.classic_long_aol import independent_buttons_qualified
from opendbc.car.hyundai.blended_stock_aol import qualified_alpha
self.ev6_aol_samples = ()
self.ev6_aol_source_healthy = False
if independent_buttons_qualified(self.CP):
if independent_buttons_qualified(self.CP) or qualified_alpha(self.CP, marked_only=True):
from opendbc.can.parser import MAX_BAD_COUNTER
source = cp.message_states.get(cp.dbc.name_to_msg['CLU11'].address)
self.ev6_aol_sample_stamp_ns = int(source.timestamps[-1]) if source is not None and source.timestamps else 0
@@ -58,6 +58,8 @@ def test_actual_controller_fault_and_full_lateral_withdrawal_restart_gain_from_z
cs = ci.CS
cs.out.vEgoRaw = cs.out.vEgo = 5.
cs.out.steeringAngleDeg = cs.angle_steering_angle = 20.
cs.out.steeringPressed = False
cs.out.steeringTorque = 0.
command = structs.CarControl(enabled=True, latActive=True)
command.actuators.steeringAngleDeg = 50.
ci.CC.apply_angle_last = 20.
@@ -124,3 +126,59 @@ def test_long_controller_consumes_current_rejection_without_retaining_filtered_h
_, frames = ci.apply(command.as_reader(), now[0])
assert abs(ci.CC.apply_angle_last + 20.) <= 5.
assert ci.CC.angle_gain_last == .004
@pytest.mark.parametrize('identity', (CAR.HYUNDAI_IONIQ_5_PE, CAR.KIA_EV9))
@pytest.mark.parametrize('long', (True,))
@pytest.mark.parametrize('aol', (False, True))
def test_fault_recovery_yields_until_driver_releases_steering(identity, long, aol):
ci, _ = controller(identity, long, aol)
cs = ci.CS
cs.out.vEgoRaw = cs.out.vEgo = 3.
cs.out.steeringAngleDeg = cs.angle_steering_angle = 175.
cs.out.steeringPressed = True
cs.out.steeringTorque = 500.
command = structs.CarControl(enabled=not aol, latActive=True)
command.actuators.steeringAngleDeg = 360.
ci.CC.apply_angle_last = 175.
ci.CC.angle_gain_last = .1
address, gain_index = (0xCB, 6) if long else (0x110, 12)
active_index, active_mask = (3, 15) if long else (9, 3)
for frame in range(80):
cs.out.steerFaultTemporary = cs.angle_steering_fault = frame % 40 < 16
cs.out.steeringAngleDeg = cs.angle_steering_angle = 175. - frame * 3.
ci.CC.frame = frame + 21
_, output = ci.apply(command.as_reader(), 200_000_000 + frame * 10_000_000)
payload = next((data for addr, data, _ in output if addr == address), None)
if payload is not None:
assert (payload[active_index] >> 4 & active_mask) == 1, 'FAULT_RECOVERY_FIGHTS_DRIVER'
assert payload[gain_index] == 0
assert ci.CC.apply_angle_last == pytest.approx(cs.out.steeringAngleDeg)
cs.out.steerFaultTemporary = cs.angle_steering_fault = False
cs.out.steeringPressed = False
cs.out.steeringTorque = 0.
ci.CC.frame += 1
_, output = ci.apply(command.as_reader(), 1_000_000_000)
payload = next(data for addr, data, _ in output if addr == address)
assert (payload[active_index] >> 4 & active_mask) == 2
assert payload[gain_index] == 1
assert abs(ci.CC.apply_angle_last - cs.out.steeringAngleDeg) <= 5.
@pytest.mark.parametrize('identity', (CAR.HYUNDAI_IONIQ_5_PE, CAR.KIA_EV9))
@pytest.mark.parametrize('long', (False, True))
def test_driver_pressure_without_fault_keeps_existing_angle_blending(identity, long):
ci, _ = controller(identity, long, True)
cs = ci.CS
cs.out.vEgoRaw = cs.out.vEgo = 3.
cs.out.steeringAngleDeg = cs.angle_steering_angle = 20.
cs.out.steeringPressed = True
cs.out.steeringTorque = 500.
command = structs.CarControl(enabled=False, latActive=True)
command.actuators.steeringAngleDeg = 40.
ci.CC.apply_angle_last = 20.
ci.CC.frame = 21
_, output = ci.apply(command.as_reader(), 200_000_000)
address, active_index, active_mask = (0xCB, 3, 15) if long else (0x110, 9, 3)
payload = next(data for addr, data, _ in output if addr == address)
assert (payload[active_index] >> 4 & active_mask) == 2
@@ -1,14 +1,14 @@
"""StarPilot adapter for jacobw's jul-angle-based Crosstrek control.
Source: jacobwaller/opendbc 8acbeccdab45a47aa7af170d2d17b61f0eb5e4f0.
Low-speed filtering: lukasloetkolben; target-first limiting: elkoled.
Original low-speed filtering: lukasloetkolben; target-first limiting: elkoled.
Jacob Waller integrated the implementation and corrected its VM limit type.
Keep StarPilot's model, stricter rate, and withdrawal guards. See CREDITS.md.
"""
import numpy as np
from opendbc.car import rate_limit
from opendbc.car.lateral import apply_center_deadzone, get_max_angle_delta_vm, get_max_angle_vm
from opendbc.car.lateral import get_max_angle_delta_vm, get_max_angle_vm
MAX_ANGLE = 190.0
@@ -16,9 +16,6 @@ MAX_ANGLE = 190.0
def limit_angle(target, previous, speed, measured, active, limits, model):
if not active:
return float(np.clip(measured, -MAX_ANGLE, MAX_ANGLE)), False
if speed < 10.0:
deadzone = float(np.interp(speed, [2.0, 10.0], [6.0, 3.0]))
target = previous + apply_center_deadzone(target - previous, deadzone)
speed = max(speed, 1.0)
maximum = min(MAX_ANGLE, get_max_angle_vm(speed, model, limits))
delta = min(get_max_angle_delta_vm(speed, model, limits), limits.ANGLE_LIMITS.MAX_ANGLE_RATE)
@@ -95,5 +95,5 @@ class TestIndependentAngleEnvelope(unittest.TestCase):
self.assertTrue(decoded['LKAS_Request'])
if car == CAR.SUBARU_CROSSTREK_2025:
# The EPS bound applies to transmitted commands, not measured-angle admission.
# Preserve the zero command history: this target is inside the low-speed deadzone.
self.assertEqual(decoded['LKAS_Output'], 0.)
# Follow the small target without jumping to the measured angle.
self.assertAlmostEqual(decoded['LKAS_Output'], -1.45)
@@ -12,19 +12,17 @@ from opendbc.safety.tests.libsafety import libsafety_py
from openpilot.starpilot.car.subaru.tests.caller_helpers import CompleteCan
@pytest.mark.parametrize('speed,deadzone', ((1.0, 6.0), (2.0, 6.0), (6.0, 4.5), (9.0, 3.375)))
@pytest.mark.parametrize('speed', (1.0, 2.0, 6.0, 9.0))
@pytest.mark.parametrize('sign', (-1, 1))
def test_real_controller_suppresses_small_low_speed_changes(speed, deadzone, sign):
def test_real_controller_tracks_small_low_speed_changes(speed, sign):
_, controller, command, state = setup_controller(CAR.SUBARU_CROSSTREK_2025)
state.out.vEgoRaw = speed
command.actuators.steeringAngleDeg = sign * (deadzone - .01)
_, decoded = step(controller, command, state)
assert decoded['LKAS_Request'] == 1
assert decoded['LKAS_Output'] == 0
command.actuators.steeringAngleDeg = sign * (deadzone + .01)
_, decoded = step(controller, command, state)
assert decoded['LKAS_Request'] == 1
assert 0 < sign * decoded['LKAS_Output'] <= 1.0
command.actuators.steeringAngleDeg = sign * 2.0
for _ in range(20):
_, decoded = step(controller, command, state)
assert decoded['LKAS_Request'] == 1
assert sign * decoded['LKAS_Output'] > 0
assert decoded['LKAS_Output'] == pytest.approx(sign * 2.0, abs=.01)
def test_target_first_limits_recover_to_moving_acceleration_bound():
+2 -1
View File
@@ -9,7 +9,8 @@ from opendbc.car.tesla.values import CAR, DBC, CANBUS, GEAR_MAP, STEER_THRESHOLD
class TeslaScreenCANParser(CANParser):
def __init__(self):
super().__init__('tesla_model3_vehicle', [('UI_status2', 0)], CANBUS.vehicle)
# Screen gestures are optional; their silence is not a control-bus fault.
super().__init__('tesla_model3_vehicle', [('UI_status2', float('nan'))], CANBUS.vehicle)
def update(self, strings, sendcan=False):
if strings and not isinstance(strings[0], list | tuple):
@@ -53,7 +53,7 @@ def test_actual_automatic_ap1_vin_fw_can_and_factory_use_normal_wiring(alpha, re
assert ci.CP.carFw and all(fw.brand == 'tesla' and fw.bus == 0 for fw in ci.CP.carFw)
assert any(fw.fwVersion == HW1_FIRMWARE for fw in ci.CP.carFw)
assert ci.CP.safetyConfigs[0].safetyParam == 16 + int(alpha)
assert ci.CP.openpilotLongitudinalControl == alpha and ci.CP.pcmCruise != alpha
assert ci.CP.openpilotLongitudinalControl == alpha and ci.CP.pcmCruise
@pytest.mark.parametrize('source', list(HW1_NORMAL_BUS0_LAYOUT))
@@ -80,3 +80,36 @@ def test_literal_optional_screen_frames_baseline_edge_and_exact_dlc():
(structs.CarState.ButtonEvent.Type.lkas, True)]
parser.update([(1_030_000_000, [(frame[0], frame[1][:-1], 1), (frame[0], frame[1], 0)])])
assert cs.update_screen_button(parser) == []
@pytest.mark.parametrize('screen_seen', [False, True])
def test_optional_screen_silence_does_not_report_vehicle_bus_loss(screen_seen):
parser = TeslaScreenCANParser()
if screen_seen:
parser.update([(1_000_000_000, [(0x3DF, bytes(8), CANBUS.vehicle)])])
for stamp in (1_500_000_001, 12_000_000_000):
parser.update([(stamp, [(0x111, bytes(8), CANBUS.vehicle)])])
assert not parser.bus_timeout
assert parser.can_valid
assert parser.vl_all['UI_status2']['UI_activeTouchPoints'] == []
@pytest.mark.parametrize('missing_bus', [None, CANBUS.party, CANBUS.autopilot_party])
def test_screen_silence_keeps_required_vehicle_bus_timeouts(missing_bus):
cp = screen_params()
apply_screen_button(cp, True, False)
ci = CarInterface(cp)
packer = CANPacker('tesla_model3_party')
messages = {
CANBUS.party: ['DI_speed', 'DI_systemStatus', 'ESP_B', 'EPAS3S_sysStatus', 'ESP_status', 'UI_warning', 'DI_state'],
CANBUS.autopilot_party: ['SCCM_steeringAngleSensor', 'DAS_status', 'DAS_control', 'DAS_steeringControl', 'DAS_settings'],
}
for index in range(150):
frames = [packer.make_can_msg(name, bus, {}) for bus, names in messages.items()
if index < 50 or bus != missing_bus for name in names]
if index == 0:
frames.append((0x3DF, bytes(8), CANBUS.vehicle))
result = ci.update([(1_000_000_000 + index * 10_000_000, frames)])
assert result.canTimeout is (missing_bus is not None)
if missing_bus is None:
assert result.canValid
@@ -231,12 +231,15 @@ typedef void (*rx_hook)(const CANPacket_t *msg);
typedef bool (*tx_hook)(const CANPacket_t *msg); // returns true if the message is allowed
typedef bool (*fwd_hook)(int bus_num, int addr); // returns true if the message should be blocked from forwarding
typedef bool (*fwd_packet_hook)(const CANPacket_t *msg);
typedef struct {
safety_hook_init init;
rx_hook rx;
rx_hook optional_rx;
tx_hook tx;
fwd_hook fwd;
fwd_packet_hook fwd_packet;
get_checksum_t get_checksum;
compute_checksum_t compute_checksum;
get_counter_t get_counter;
@@ -367,6 +370,7 @@ extern uint16_t current_safety_param;
extern safety_config current_safety_config;
int safety_fwd_hook(int bus_num, int addr);
int safety_fwd_packet_hook(const CANPacket_t *msg);
int set_safety_hooks(uint16_t mode, uint16_t param);
extern const safety_hooks body_hooks;
@@ -970,6 +970,7 @@ static void hyundai_optional_rx_hook(const CANPacket_t *msg) {
}
const safety_hooks hyundai_hooks = {
.fwd_packet = hyundai_daw_fwd,
.fwd = blended_aol_fwd,
.init = hyundai_init,
.optional_rx = hyundai_optional_rx_hook,
@@ -981,6 +982,7 @@ const safety_hooks hyundai_hooks = {
};
const safety_hooks hyundai_legacy_hooks = {
.fwd_packet = hyundai_daw_fwd,
.init = hyundai_legacy_init,
.optional_rx = hyundai_optional_rx_hook,
.rx = hyundai_rx_hook,
@@ -158,10 +158,29 @@ static void blended_aol_rx(const CANPacket_t *msg) {
bool pressed = false;
if ((msg->bus == pt) && (msg->addr == 0x4F1U) && (GET_LEN(msg) == 4U)) {
if ((msg->data[0] & 7U) == 4U) {
blended_aol_clear();
// Mixed LONG Pause/Resume still clears ordinary/LONG authority in the
// cruise hook. Retain only an already claimed, current lateral token.
// CANCEL cannot arm, renew a request/gesture lease, or clear an inhibit.
const bool source_current = (blended_aol_token_source < 3U) &&
(safety_get_ts_elapsed(now, blended_aol_source_ts[blended_aol_token_source]) <= 300000U);
const bool retain_lateral = blended_aol_long && !GET_BIT(msg, 3U) &&
blended_aol_token && blended_aol_claimed && blended_aol_session && heartbeat_engaged &&
blended_aol_main_seen && blended_aol_main &&
(safety_get_ts_elapsed(now, blended_aol_main_ts) <= 100000U) && source_current &&
blended_aol_request_seen && ((aol_host_axis_mask & 1U) != 0U) &&
(safety_get_ts_elapsed(now, aol_host_request_ts) <= AOL_HOST_REQUEST_TIMEOUT_US) &&
aol_rx_healthy() && !relay_malfunction && !safety_rx_checks_invalid;
if (!retain_lateral) {
blended_aol_clear();
}
// A simultaneous MAIN is not an arming gesture. The advancing physical
// source remains observable without changing its token or host lease.
blended_aol_pressed[0] = GET_BIT(msg, 3U);
blended_aol_source_ts[0] = now;
} else {
source = 0U;
pressed = GET_BIT(msg, 3U);
}
source = 0U;
pressed = GET_BIT(msg, 3U);
} else if ((msg->bus == pt) && (msg->addr == 0x391U) && (GET_LEN(msg) == 8U)) {
source = 1U;
pressed = GET_BIT(msg, 4U);
@@ -14,6 +14,7 @@ static uint32_t hyundai_daw_tx_ts;
static bool hyundai_daw_seen;
static bool hyundai_daw_counter_seen;
static bool hyundai_daw_sent;
static uint8_t hyundai_daw_last_classic_tx[6];
static void hyundai_daw_init(bool canfd, bool enabled) {
hyundai_daw_addr = enabled ? (canfd ? 0x11AU : 0x53EU) : 0U;
@@ -96,10 +97,31 @@ static bool hyundai_daw_tx(const CANPacket_t *msg) {
if (valid) {
hyundai_daw_sent = true;
hyundai_daw_tx_ts = now;
if (!canfd) {
(void)memcpy(hyundai_daw_last_classic_tx, msg->data, 6U);
}
}
return valid;
}
static bool hyundai_daw_fwd(const CANPacket_t *msg) {
const uint32_t now = microsecond_timer_get();
bool blocked = (msg->bus == 2U) && (msg->addr == 0x53EU) && (GET_LEN(msg) == 6U) && (hyundai_daw_addr == 0x53EU) &&
(hyundai_daw_len == 6U) && hyundai_daw_seen && hyundai_daw_sent &&
!relay_malfunction && !safety_rx_checks_invalid &&
(safety_get_ts_elapsed(now, hyundai_daw_rx_ts) <= 100000U) &&
(safety_get_ts_elapsed(now, hyundai_daw_tx_ts) <= 100000U);
if (blocked) {
const uint8_t status = msg->data[5] & 7U;
blocked = (status >= 1U) && (status <= 3U);
for (unsigned int i = 0U; i < 6U; i++) {
const uint8_t mask = (i == 5U) ? 0xF8U : 0xFFU;
blocked &= (msg->data[i] & mask) == (hyundai_daw_last_classic_tx[i] & mask);
}
}
return blocked;
}
static safety_config hyundai_daw_configure(safety_config ret, bool canfd, bool enabled) {
hyundai_daw_init(canfd, false);
if (enabled && (ret.tx_msgs_len > 0) && (ret.tx_msgs_len < 64)) {
@@ -13,6 +13,7 @@ static bool tesla_screen_belt_latched = false;
static bool tesla_screen_eps_ready = false;
static bool tesla_screen_session = false;
static bool tesla_screen_autopark = false;
static bool tesla_screen_acc_fault = false;
static int tesla_screen_override = 0;
static void tesla_screen_clear(void) {
@@ -32,6 +33,7 @@ static void tesla_screen_reset(void) {
tesla_screen_eps_ready = false;
tesla_screen_session = false;
tesla_screen_autopark = false;
tesla_screen_acc_fault = false;
}
static uint8_t tesla_screen_request(void) {
@@ -54,7 +56,7 @@ static uint8_t tesla_screen_request(void) {
static bool tesla_screen_physical_ready(void) {
return tesla_screen_drive && tesla_screen_occupant_ready && tesla_screen_eps_ready &&
!steering_disengage && !tesla_screen_autopark;
!steering_disengage && !tesla_screen_autopark && !tesla_screen_acc_fault;
}
static uint8_t tesla_screen_permission(void) {
@@ -108,14 +110,17 @@ static void tesla_screen_rx(const CANPacket_t *msg, bool autopark) {
}
if (msg_matches(msg, 0x286U, 0U, 8U)) {
const unsigned int state = (msg->data[1] >> 4U) & 7U;
tesla_screen_acc_fault = state == 5U;
const bool cruise = ((state == 2U) || (state == 3U) || (state == 4U) || (state == 6U) || (state == 7U)) && !tesla_screen_autopark;
const bool main_available = ((state == 1U) || cruise) && !tesla_screen_autopark;
if (cruise && !tesla_screen_cruise) {
tesla_screen_override = 0;
}
if (tesla_screen_main && !main_available && !brake_pressed) {
// Stock availability cannot revoke an explicit screen selection.
if (tesla_screen_main && !main_available && !brake_pressed && (tesla_screen_override != 1)) {
tesla_screen_override = -1;
}
if (tesla_screen_acc_fault) { tesla_screen_clear(); }
tesla_screen_main = main_available;
tesla_screen_cruise = cruise;
}
@@ -4,6 +4,9 @@ static bool vw_aol_enabled = false;
static bool vw_aol_longitudinal = false;
static bool vw_aol_long_armed = false;
static uint8_t vw_aol_buttons = 0U;
static bool vw_aol_pending = false;
static bool vw_aol_session = false;
static uint32_t vw_aol_grant_ts = 0U;
static bool vw_aol_manual = false;
static bool vw_aol_direct = false;
static bool vw_aol_direct_counter_seen = false;
@@ -27,6 +30,8 @@ static void vw_aol_clear(void) {
vw_aol_token = false;
vw_aol_long_armed = false;
vw_aol_buttons = 0U;
vw_aol_pending = false;
vw_aol_session = false;
vw_aol_main_neutral = false;
vw_aol_owned = false;
for (unsigned int i = 0U; i < 3U; i++) {
@@ -36,6 +41,7 @@ static void vw_aol_clear(void) {
static void vw_aol_reset(void) {
vw_aol_clear();
vw_aol_grant_ts = 0U;
vw_aol_enabled = false;
vw_aol_initial_main = true;
vw_aol_longitudinal = false;
@@ -77,12 +83,23 @@ static void vw_aol_request(uint8_t mask) {
static uint8_t vw_aol_request_mask(void) {
const bool expired = vw_aol_requested &&
(safety_get_ts_elapsed(microsecond_timer_get(), aol_host_request_ts) > AOL_HOST_REQUEST_TIMEOUT_US);
const uint32_t grant_age = safety_get_ts_elapsed(microsecond_timer_get(), vw_aol_grant_ts);
const bool pending_expired = vw_aol_pending && (grant_age > AOL_HOST_REQUEST_TIMEOUT_US);
const bool vw_controls_heartbeat_lost = !heartbeat_engaged && (!vw_aol_longitudinal || vw_aol_session);
uint8_t request = 0U;
if (!vw_aol_enabled || relay_malfunction || expired || safety_rx_checks_invalid ||
!heartbeat_engaged || !aol_rx_healthy() || !vw_aol_current()) {
vw_controls_heartbeat_lost || pending_expired || !aol_rx_healthy() || !vw_aol_current()) {
vw_aol_clear();
} else if (heartbeat_engaged) {
// A physical release may precede the host join; it cannot authorize output.
const bool acknowledged = !vw_aol_pending || (((aol_host_axis_mask & 2U) != 0U) &&
(safety_get_ts_elapsed(microsecond_timer_get(), aol_host_request_ts) < grant_age));
if (acknowledged) {
vw_aol_pending = false;
vw_aol_session = true;
request = aol_host_axis_mask;
}
} else {
request = aol_host_axis_mask;
}
return request;
}
@@ -161,14 +178,15 @@ static void vw_aol_rx(const CANPacket_t *msg) {
vw_aol_cruise_engaged = engaged;
vw_aol_seen |= 1U;
vw_aol_main_ts = microsecond_timer_get();
if (!heartbeat_engaged || !aol_rx_healthy() || !vw_aol_current() || safety_rx_checks_invalid) {
if ((!heartbeat_engaged && (!vw_aol_longitudinal || vw_aol_session)) ||
!aol_rx_healthy() || !vw_aol_current() || safety_rx_checks_invalid) {
vw_aol_clear();
} else if (!acc_main_on) {
vw_aol_initial_main = false;
vw_aol_clear();
// Only a valid OFF observation may prepare a subsequent main-ON edge.
vw_aol_main_neutral = ((msg->data[3] & 7U) < 2U);
} else if (vw_aol_initial_main && vw_aol_gear_drive && vw_aol_eps_ready && !brake_pressed) {
} else if (heartbeat_engaged && vw_aol_initial_main && vw_aol_gear_drive && vw_aol_eps_ready && !brake_pressed) {
// Fresh complete physical MAIN-on startup grants LAT only. It never restores a cleared drive session.
vw_aol_initial_main = false;
vw_aol_token = true;
@@ -186,15 +204,19 @@ static void vw_aol_rx(const CANPacket_t *msg) {
vw_aol_initial_main = false;
vw_aol_clear();
} else if (vw_aol_longitudinal) {
const bool healthy = heartbeat_engaged && aol_rx_healthy() && vw_aol_current() && !safety_rx_checks_invalid;
const bool healthy = (heartbeat_engaged || !vw_aol_session) && !relay_malfunction && aol_rx_healthy() && vw_aol_current() && !safety_rx_checks_invalid;
const uint8_t buttons = (GET_BIT(msg, 16U) ? 1U : 0U) | (GET_BIT(msg, 19U) ? 2U : 0U);
if (!healthy) {
vw_aol_clear();
} else if (buttons == 0U) {
if (((vw_aol_buttons & 3U) != 0U) && controls_allowed && acc_main_on &&
vw_aol_gear_drive && vw_aol_eps_ready && (!brake_pressed || (!vw_aol_manual && !vehicle_moving))) {
vw_aol_initial_main = false;
vw_aol_token = true;
vw_aol_long_armed = true;
vw_aol_pending = !heartbeat_engaged;
vw_aol_session = heartbeat_engaged;
vw_aol_grant_ts = microsecond_timer_get();
}
vw_aol_buttons = 4U;
} else if ((vw_aol_buttons & 4U) != 0U) {
+8
View File
@@ -358,6 +358,14 @@ int safety_fwd_hook(int bus_num, int addr) {
return blocked ? -1 : destination_bus;
}
int safety_fwd_packet_hook(const CANPacket_t *msg) {
int destination_bus = safety_fwd_hook(msg->bus, msg->addr);
if ((destination_bus >= 0) && (current_hooks->fwd_packet != NULL) && current_hooks->fwd_packet(msg)) {
destination_bus = -1;
}
return destination_bus;
}
// 1Hz safety function called by main. Now just a check for lagging safety messages
void safety_tick(void) {
const uint8_t MAX_MISSED_MSGS = 10U;
@@ -10,6 +10,7 @@ SAFETY_UNUSED(heartbeat_engaged);
SAFETY_UNUSED(safety_rx_hook);
SAFETY_UNUSED(safety_tx_hook);
SAFETY_UNUSED(safety_fwd_hook);
SAFETY_UNUSED(safety_fwd_packet_hook);
SAFETY_UNUSED(safety_tick);
SAFETY_UNUSED(set_safety_hooks);
// Exported to Panda's USB status handler and the native safety test library.
@@ -313,8 +313,8 @@ class TestHyundaiBlendedAlphaAol(unittest.TestCase):
self.assertEqual(self.request(1), 0)
self.assertFalse(self.safety.get_controls_allowed())
def test_main_off_cancel_and_heartbeat_loss_revoke_physical_token(self):
for cause in ('main_off', 'cancel', 'heartbeat'):
def test_main_off_and_heartbeat_loss_revoke_physical_token(self):
for cause in ('main_off', 'heartbeat'):
self.now = 1_000_000
self.counter = 0
self.reset()
@@ -325,13 +325,98 @@ class TestHyundaiBlendedAlphaAol(unittest.TestCase):
continue
if cause == 'main_off':
self.feed(available=False)
elif cause == 'cancel':
self.feed(button=4)
else:
self.safety.set_aol_test_heartbeat(False)
self.assertEqual(self.request(1), 0)
self.assertFalse(self.safety.get_controls_allowed())
def test_cancel_retains_only_existing_claimed_lateral_and_stops_long(self):
for word in (0x2004, 0x2014):
for held in (7, 23): # Both reported short and longer Pause/Resume holds.
self.counter, self.now = 0, 1_000_000
self.reset(word)
self.warm()
self.feed(lda=True)
self.assertEqual(self.request(1), 0 if self.release else 1)
if self.release:
continue
self.feed(button=2)
self.feed()
self.assertTrue(self.safety.get_controls_allowed())
self.assertEqual(self.request(3), 3)
for _ in range(held):
self.feed(button=4)
self.assertFalse(self.safety.get_controls_allowed())
self.assertEqual(self.request(1), 1)
self.feed()
self.assertFalse(self.safety.get_controls_allowed())
self.assertEqual(self.request(1), 1)
if self.bus:
self.primary_counter = 0
steering = TestHyundaiBlendedAlpha.primary(self, 1, True)
else:
from opendbc.car.hyundai.hyundaican import hyundai_checksum
def steering_checksum(message):
address, data, bus = message
return address, bytes([hyundai_checksum(data[1:8])]) + data[1:8], bus
steering = self.packer.make_can_msg_safety('LKAS11', 0,
{'CF_Lkas_MsgCount': 0, 'CR_Lkas_StrToqReq': 1, 'CF_Lkas_ActToi': 1}, fix_checksum=steering_checksum)
self.assertTrue(self.safety.safety_tx_hook(steering), 'Only retained LAT can authorize steering')
accel = self.packer.make_can_msg_safety('SCC11', self.bus, {'aReqRaw': .1})
self.assertFalse(self.safety.safety_tx_hook(accel))
self.assertEqual(self.request(0), 0) # Configured cancellation remains authoritative.
def test_cancel_cannot_arm_or_restore_expired_or_withdrawn_authority(self):
for word in (0x2004, 0x2014):
for cause in ('cold', 'unclaimed', 'withdrawn', 'stale_request', 'heartbeat', 'main_off', 'relay'):
with self.subTest(word=word, cause=cause):
self.counter, self.now = 0, 1_000_000
self.reset(word)
self.warm()
if cause != 'cold':
self.feed(lda=True)
if cause not in ('cold', 'unclaimed'):
self.assertEqual(self.request(1), 0 if self.release else 1)
if self.release:
continue
if cause == 'withdrawn':
self.request(0)
elif cause == 'stale_request':
for _ in range(32):
self.feed() # Fresh main and token source, without a host renewal.
elif cause == 'heartbeat':
self.safety.set_aol_test_heartbeat(False)
elif cause == 'relay':
self.safety.set_relay_malfunction(True)
self.feed(button=4, available=cause != 'main_off')
self.assertEqual(self.request(1), 0, cause)
def test_cancel_cannot_restore_expired_source_simultaneous_main_or_bad_rx(self):
for word in (0x2004, 0x2014):
for cause in ('source', 'simultaneous_main', 'bad_rx'):
with self.subTest(word=word, cause=cause):
self.counter, self.now = 0, 1_000_000
self.reset(word)
self.warm()
self.feed(lda=True)
self.assertEqual(self.request(1), 0 if self.release else 1)
if self.release:
continue
if cause == 'source':
for _ in range(32):
self.feed(omit='BCM_PO_11')
self.safety.aol_set_host_request(1) # Fresh request, expired physical LDA source.
elif cause == 'bad_rx':
invalid = self.packer.make_can_msg_safety('TCS13', self.bus, {'AliveCounterTCS': self.counter % 8}, fix_checksum=checksum)
invalid[0].data[6] ^= 1
self.assertFalse(self.safety.safety_rx_hook(invalid))
packet = self.packer.make_can_msg_safety('CLU11', self.bus,
{'CF_Clu_CruiseSwState': 4, 'CF_Clu_CruiseSwMain': int(cause == 'simultaneous_main'),
'CF_Clu_AliveCnt1': self.counter % 16})
self.assertTrue(self.safety.safety_rx_hook(packet))
self.assertEqual(self.request(1), 0, cause)
def test_cluster_lda_source_needs_its_own_neutral_edge(self):
self.reset()
self.warm()
@@ -136,3 +136,75 @@ def test_one_genuine_counter_predecessor_cannot_hide_newer_hands_off_request():
owner.update([(BASE + 30_000_000, [(0x11A, bytes(hands_off), 1)])])
_, current_output, _ = advisory_candidate(owner, BASE + 30_000_000)
assert safety.safety_tx_hook(packet(0x11A, current_output, 1))
@pytest.mark.parametrize('mode,word', [(structs.CarParams.SafetyModel.hyundai, 0),
(structs.CarParams.SafetyModel.hyundai, 0xC02), (structs.CarParams.SafetyModel.hyundaiLegacy, 0)])
@pytest.mark.parametrize('status', range(8))
def test_classic_advisory_forwarding_uses_only_current_accepted_replacement(mode, word, status):
safety, _ = setup(False, word)
assert safety.set_safety_hooks(mode, word) == 0
safety.init_tests()
safety.set_timer(1_000_000)
owner = DawSource(params(False))
original = source(False, 0)
incoming = packet(0x53E, original, 2)
assert safety.safety_fwd_packet_hook(incoming) == 0
safety.safety_rx_hook(incoming)
owner.update([(BASE, [(0x53E, original, 2)])])
addr, output, bus = advisory_candidate(owner, BASE)
assert safety.safety_tx_hook(packet(addr, output, bus))
safety.set_timer(1_010_000)
incoming = packet(0x53E, source(False, status), 2)
expected = -1 if 1 <= status <= 3 else 0
# Both Panda drivers forward before their RX hook sees the current packet.
assert safety.safety_fwd_packet_hook(incoming) == expected
safety.safety_rx_hook(incoming)
assert safety.safety_fwd_packet_hook(incoming) == expected
assert safety.safety_fwd_packet_hook(packet(0x53E, source(False, status), 0)) == 2
safety.set_timer(1_100_001)
assert safety.safety_fwd_packet_hook(incoming) == 0
@pytest.mark.parametrize('change', ['opaque', 'length', 'reset', 'no_replacement', 'expired_source', 'relay', 'hands_off', 'unknown'])
def test_classic_advisory_forwarding_fails_open_without_exact_live_replacement(change):
safety, _ = setup(False, 0xC02)
owner = DawSource(params(False))
original = source(False, 1)
safety.safety_rx_hook(packet(0x53E, original, 2))
owner.update([(BASE, [(0x53E, original, 2)])])
addr, output, bus = advisory_candidate(owner, BASE)
if change != 'no_replacement':
assert safety.safety_tx_hook(packet(addr, output, bus))
safety.set_timer(1_010_000)
incoming = bytearray(source(False, 1))
if change == 'opaque':
incoming[0] ^= 1
elif change == 'length':
incoming.pop()
elif change == 'reset':
setup(False, 0xC02)
elif change == 'expired_source':
safety.set_timer(1_100_001)
elif change == 'relay':
safety.set_relay_malfunction(True)
elif change in ('hands_off', 'unknown'):
incoming = source(False, 4 if change == 'hands_off' else 7)
message = packet(0x53E, bytes(incoming), 2)
expected = -1 if change == 'relay' else 0
assert safety.safety_fwd_packet_hook(message) == expected
safety.safety_rx_hook(message)
assert safety.safety_fwd_packet_hook(message) == expected
def test_packet_forwarding_preserves_existing_other_profile_rules():
safety, _ = setup(False, 0)
data = source(False, 1)
for mode, word in ((structs.CarParams.SafetyModel.hyundaiCanfd, 0),
(structs.CarParams.SafetyModel.toyota, 0),
(structs.CarParams.SafetyModel.hyundai, 0x8800)):
assert safety.set_safety_hooks(mode, word) == 0
safety.init_tests()
for bus in range(3):
for addr in (0x53E, 0x340, 0x4F1, 0x421, 0x110, 0x11A):
assert safety.safety_fwd_packet_hook(packet(addr, data, bus)) == safety.safety_fwd_hook(bus, addr)
@@ -33,7 +33,8 @@ class TestTeslaScreenButton(unittest.TestCase):
('DAS_steeringControl', 2, {'DAS_steeringControlType': stock_type}),
('DI_speed', 0, {'DI_vehicleSpeed': 0}),
('ESP_B', 0, {'ESP_vehicleSpeed': 0, 'ESP_wheelSpeedsQF': 1, 'ESP_vehicleStandstillSts': 1}),
('EPAS3S_sysStatus', 0, {'EPAS3S_internalSAS': 0, 'EPAS3S_eacStatus': eps, 'EPAS3S_handsOnLevel': hands}),
('EPAS3S_sysStatus', 0, {'EPAS3S_internalSAS': 0, 'EPAS3S_eacStatus': eps, 'EPAS3S_handsOnLevel': hands,
'EPAS3S_torsionBarTorque': 0}),
('DI_systemStatus', 0, {'DI_gear': gear, 'DI_accelPedalPos': 0}),
('ESP_status', 0, {'ESP_driverBrakeApply': 2 if brake else 1}),
('UI_warning', 0, {'anyDoorOpen': int(door), 'buckleStatus': int(belt)}),
@@ -106,6 +107,64 @@ class TestTeslaScreenButton(unittest.TestCase):
self.assertEqual(self.permission(), 0)
self.arm()
def test_availability_loss_preserves_only_explicit_screen_arm(self):
tx_packer = CANPackerSafety('tesla_model3_party')
for word in (512, 768, 1536, 1792):
with self.subTest(word=word):
self.start(word)
self.arm()
self.touch(0)
for state in (1, 0, 1, 0, 1):
self.feed(state=state)
self.assertEqual(self.permission(3), 1)
positive = tx_packer.make_can_msg_safety('DAS_control', 0, {'DAS_accelMin': 1, 'DAS_accelMax': 1})
self.assertFalse(self.lib.safety_tx_hook(positive))
self.touch(3)
self.assertEqual(self.permission(), 0)
for state in (0, 1, 0, 1):
self.feed(state=state)
self.assertEqual(self.permission(), 0)
self.start(word)
self.feed(state=1)
self.assertEqual(self.permission(), 1)
self.feed(state=0)
self.assertEqual(self.permission(), 0)
self.feed(state=1)
self.assertEqual(self.permission(), 0)
def test_cruise_fault_clears_screen_arm_and_blocks_gestures(self):
for word in (512, 768, 1536, 1792):
with self.subTest(word=word):
self.start(word)
self.arm()
self.feed(state=1)
self.feed(state=5)
self.assertEqual(self.permission(), 0)
self.touch(0)
self.touch(3)
self.assertEqual(self.permission(), 0)
for state in (0, 1):
self.feed(state=state)
self.assertEqual(self.permission(), 0)
self.arm()
def test_availability_loss_cannot_hide_physical_fault_or_brake(self):
for word in (512, 768, 1536, 1792):
faults = [{'door': True}, {'eps': 3}, {'hands': 3}, {'gear': 0}, {'autopark': 3}]
if word & 1024:
faults.append({'brake': True})
for fault in faults:
with self.subTest(word=word, fault=fault):
self.start(word)
self.arm()
self.feed(state=1)
self.feed(state=0, **fault)
self.assertEqual(self.permission(), 0)
for state in (0, 1, 0, 1):
self.feed(state=state)
self.assertEqual(self.permission(), 0)
def test_brake_disengagement_cannot_be_undone_by_availability(self):
self.start(1536)
self.arm()
@@ -0,0 +1,109 @@
"""A physical SET/RES release precedes the cold host join; output still needs a new acknowledgement."""
import unittest
import pytest
from opendbc.safety.tests.libsafety import libsafety_py
from opendbc.safety.tests import test_volkswagen_stock_aol as stock
from opendbc.car import structs
class Owner(stock.TestVolkswagenStockAol):
direct = False
def packet(self, name, fields, bus=0):
if self.direct and name == 'Gateway_73':
name, fields = 'Motor_EV_01', {'MO_Waehlpos': 5 if fields['GE_Fahrstufe'] == 8 else 3}
return super().packet(name, fields, bus)
def setup(word, *, brake=False):
debug = libsafety_py.libsafety.set_safety_hooks(structs.CarParams.SafetyModel.allOutput, 0) == 0
o = Owner()
o.direct = word == 41
o.configure(word)
o.manual = word == 25
o.safety.set_aol_test_heartbeat(False)
for _ in range(12):
o.feed(moving=not brake, brake=brake)
o.safety.aol_set_host_request(0)
return o, debug
def press(o, button, brake):
assert o.safety.safety_rx_hook(o.packet('GRA_ACC_01', {button: 1}))
o.feed(moving=not brake, brake=brake)
o.safety.aol_set_host_request(0)
assert o.safety.aol_get_permission_mask() == 0
assert not o.safety.safety_tx_hook(o.packet('ACC_06', {'ACC_Sollbeschleunigung_02': 0.5}))
@pytest.mark.parametrize('word', (9, 25, 41))
@pytest.mark.parametrize('button', ('GRA_Tip_Setzen', 'GRA_Tip_Wiederaufnahme'))
@pytest.mark.parametrize('brake', (False, True))
def test_first_press_before_heartbeat_needs_fresh_host_join(word, button, brake):
o, debug = setup(word, brake=brake)
try:
press(o, button, brake)
o.safety.set_aol_test_heartbeat(True)
assert o.safety.aol_get_permission_mask() == 0
o.time += 10_000
o.safety.set_timer(o.time)
o.safety.aol_set_host_request(3)
if brake:
assert o.safety.aol_get_permission_mask() == 0
o.feed(moving=False)
o.safety.aol_set_host_request(3)
expected = (2 if brake else 3) if debug and not (brake and word == 25) else 0
assert o.safety.aol_get_permission_mask() == expected, 'FIRST_PHYSICAL_RELEASE_LOST_BEFORE_HOST_JOIN'
o.safety.set_aol_test_heartbeat(False)
assert o.safety.aol_get_permission_mask() == 0
o.safety.set_aol_test_heartbeat(True)
o.feed(moving=not brake)
o.safety.aol_set_host_request(3)
assert o.safety.aol_get_permission_mask() == 0
finally:
o.tearDown()
@pytest.mark.parametrize('word', (9, 25, 41))
@pytest.mark.parametrize('fault', ('timeout', 'cancel', 'main', 'reverse', 'permanent', 'relay', 'crc', 'brake'))
def test_pending_physical_release_cannot_survive_fault_or_expiry(word, fault):
o, _ = setup(word)
try:
press(o, 'GRA_Tip_Setzen', False)
if fault == 'timeout':
o.time += 300_001
o.safety.set_timer(o.time)
elif fault == 'relay':
o.safety.set_relay_malfunction(True)
elif fault == 'crc':
bad = o.packet('TSK_06', {'TSK_Status': 2})
bad[0].data[0] ^= 255
assert not o.safety.safety_rx_hook(bad)
else:
o.feed(
**{'cancel': {'cancel': True}, 'main': {'main': False}, 'reverse': {'gear': False}, 'permanent': {'permanent': True}, 'brake': {'brake': True}}[fault]
)
assert o.safety.aol_get_permission_mask() == 0
o.safety.set_relay_malfunction(False)
o.safety.set_aol_test_heartbeat(True)
for _ in range(12):
o.feed()
o.safety.aol_set_host_request(3)
assert o.safety.aol_get_permission_mask() & 2 == 0
finally:
o.tearDown()
class TestVolkswagenFirstPress(unittest.TestCase):
def test_first_physical_release_before_host_join(self):
for word in (9, 25, 41):
for button in ('GRA_Tip_Setzen', 'GRA_Tip_Wiederaufnahme'):
for brake in (False, True):
test_first_press_before_heartbeat_needs_fresh_host_join(word, button, brake)
def test_pending_release_native_vetoes(self):
for word in (9, 25, 41):
for fault in ('timeout', 'cancel', 'main', 'reverse', 'permanent', 'relay', 'crc', 'brake'):
test_pending_physical_release_cannot_survive_fault_or_expiry(word, fault)
+4 -2
View File
@@ -60,6 +60,7 @@ from opendbc.car.ford.aol import (
temporary_restriction as ford_temporary_restriction,
)
from opendbc.car.hyundai.canfd_angle_aol import native_observation, temporary_restriction, qualified as qualified_angle_aol
from opendbc.car.hyundai.blended_stock_aol import qualified_alpha as qualified_blended_alpha_aol
from opendbc.car.hyundai.canfd_owner import config_index as hyundai_canfd_config_index
from opendbc.car.hyundai.captured_adrv_startup import CapturedADRVStartup
from openpilot.starpilot.car.nissan.aol import (
@@ -615,9 +616,10 @@ class Car:
self.aol_card_intent, cancel_source_ns=cancel_source_ns, now_ns=now_ns,
producer=getattr(self, 'slc_producer_session', ''))
cancel_feedback = self.aol_transport_feedback
if (hkg_cancel_retained and self.CP.carFingerprint in (HYUNDAI_CAR.KIA_EV9, HYUNDAI_CAR.HYUNDAI_IONIQ_5_PE) and
if (hkg_cancel_retained and (self.CP.carFingerprint in (HYUNDAI_CAR.KIA_EV9, HYUNDAI_CAR.HYUNDAI_IONIQ_5_PE) or
qualified_blended_alpha_aol(self.CP, marked_only=True)) and
cancel_feedback is not None and not cancel_feedback.longitudinal_cancel_ns):
# The pure guard proved this exact CCNC raw CANCEL/FULL join. Bind
# The pure guard proved this exact raw CANCEL/FULL join. Bind
# its first local receipt for the existing bounded withdrawal handshake;
# held samples never renew it or any source/native/output deadline.
if cancel_source_ns > cancel_feedback.last_cancel_source_ns:
+9 -6
View File
@@ -478,22 +478,25 @@ class Controls:
new_desired_curvature = model_v2.action.desiredCurvature if CC.latActive else self.curvature
assist = self.turn_assist_enabled()
rolling = math.isfinite(CS.vEgo) and CS.vEgo > max(0.1 * CV.MPH_TO_MS, self.CP.minSteerSpeed)
if (
assist_ready = (
assist
and rolling
and self.sm.all_checks(['modelV2'])
and CC.latActive
and not CS.standstill
and not CS.steerFaultTemporary
and not CS.steerFaultPermanent
and CS.gearShifter in (car.CarState.GearShifter.drive, car.CarState.GearShifter.low)
):
)
if assist_ready and rolling and not CS.standstill:
self.model_turn_assist.curvature = self.curvature
self.model_turn_assist.twitch_guard_remaining = update_twitch_guard(self.model_turn_assist.twitch_guard_remaining, CS.vEgo, CS.standstill)
self.model_turn_assist.twitch_guard_armed = update_twitch_guard(self.model_turn_assist.twitch_guard_armed, CS.vEgo, CS.standstill)
new_desired_curvature = self.model_turn_assist.update(CS, model_v2, CC, new_desired_curvature)
elif assist_ready:
self.model_turn_assist.curvature = self.curvature
new_desired_curvature = self.model_turn_assist.update_stopped(CS, new_desired_curvature)
self.model_turn_assist.twitch_guard_armed = update_twitch_guard(self.model_turn_assist.twitch_guard_armed, CS.vEgo, CS.standstill)
elif getattr(self, 'model_turn_assist', None) is not None:
self.model_turn_assist.reset()
self.model_turn_assist.twitch_guard_remaining = update_twitch_guard(0.0, CS.vEgo, CS.standstill)
self.model_turn_assist.twitch_guard_armed = update_twitch_guard(False, CS.vEgo, CS.standstill)
lane_base_curvature = new_desired_curvature
self.lane_centering_applied = 0.0
self.lane_centering_inactive_reason = 'inactive'
@@ -37,6 +37,7 @@ from openpilot.starpilot.longitudinal.lead_takeoff import LeadTakeoff, project a
from openpilot.starpilot.longitudinal.lead_behavior import adjust as adjust_lead_behavior
from openpilot.starpilot.longitudinal.hkg_vision_stop import owner_for as vision_stop_owner
from openpilot.starpilot.car.ford.stopped_lead import owner_for as ford_stop_owner
from openpilot.starpilot.car.volkswagen.stopped_lead import owner_for as vw_stop_owner
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import STOP_DISTANCE
from openpilot.starpilot.longitudinal.lead_approach import LeadApproach, LeadApproachKey, project as project_approach
from openpilot.starpilot.lateral.lane_change_preferences import LaneChangePolicy
@@ -132,7 +133,7 @@ class LongitudinalPlanner:
self.lead_takeoff = LeadTakeoff()
self.vehicle_far_follow = far_follow_owner(CP, dt)
self.vision_stop_buffer = vision_stop_owner(CP)
self.ford_stop_approach = ford_stop_owner(CP, STOP_DISTANCE - .5)
self.ford_stop_approach = ford_stop_owner(CP, STOP_DISTANCE - .5) or vw_stop_owner(CP, STOP_DISTANCE - .5)
self.preap_adaptive = preap_adaptive_owner(CP)
def update(self, sm, *, cruise_ceiling: CruiseCeiling | None = None,
+5 -2
View File
@@ -41,7 +41,9 @@ from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
from openpilot.common.transformations.model import get_warp_matrix
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper
from openpilot.starpilot.lateral.lane_change_preferences import LiveLaneChangePolicy
from openpilot.starpilot.lateral.auto_lane_change import ClockEpochGuard, auto_evidence, paired_clocks_ns, session_policy, steering_capable
from openpilot.starpilot.lateral.auto_lane_change import (
ClockEpochGuard, auto_evidence, auto_trigger_engaged, paired_clocks_ns, session_policy, steering_capable,
)
from openpilot.starpilot.lateral.lane_change_status_wire import (
Direction as StatusDirection, LaneChangeStatus, Phase as StatusPhase, encode_optional as encode_lane_status,
)
@@ -649,7 +651,8 @@ def main(demo=False):
model_valid=bool(modelv2_send.valid), vehicle_capable=auto_vehicle_capable))
engaged = bool(sm['carControl'].latActive)
DH.update(car_state, sm['carControl'].latActive, lane_change_prob,
auto_evidence=auto_ready, engaged=engaged,
auto_evidence=auto_ready, engaged=auto_trigger_engaged(sm, runtime_lane_policy,
openpilot_longitudinal=bool(CP.openpilotLongitudinalControl), now_mono_ns=mono_now_ns),
navigation_turn=matching_turn_signal(sm, mono_now_ns, supported=turn_supported))
modelv2_send.modelV2.meta.laneChangeState = DH.lane_change_state
modelv2_send.modelV2.meta.laneChangeDirection = DH.lane_change_direction
+15 -10
View File
@@ -215,9 +215,8 @@ class SelfdriveD:
car_recognized = self.CP.brand != 'mock'
# Capability changes affect runtime authority, never the saved Alpha Long choice.
if not self.CP.openpilotLongitudinalControl:
self.params.remove("ExperimentalMode")
# A stock-ACC startup can be temporary. Preserve saved driving choices;
# experimental_requested still gates their use on current longitudinal capability.
self.CS_prev = car.CarState.new_message()
self.AM = AlertManager()
@@ -1275,7 +1274,8 @@ class SelfdriveD:
not 0 < intent.carStateLogMonoTime <= now_ns or now_ns - intent.carStateLogMonoTime > INTENT_MAX_AGE_NS or
intent.carStateLogMonoTime <= anchor[2].carStateLogMonoTime or
intent.producerSessionId != anchor[0][4] or companion is None or companion.integrity_epoch != anchor[0][5] or
(self.events.contains(ET.USER_DISABLE) and not stock_lateral_role(self, CS, native, intent, allow_independent_gas=True)) or
(self.events.contains(ET.USER_DISABLE) and not stock_lateral_role(self, CS, native, intent,
allow_independent_brake=True, allow_independent_gas=True)) or
self.events.contains(ET.IMMEDIATE_DISABLE) or
native is None or native is not getattr(self.aol_transport_pause, 'observed_native', None) or
native.pandaSerial != anchor[1].pandaSerial or native.observedMonoTime < anchor[1].observedMonoTime or
@@ -1458,12 +1458,13 @@ class SelfdriveD:
if user_disable:
from opendbc.car.hyundai.canfd_angle_aol import qualified as angle_qualified
from opendbc.car.hyundai.canfd_stock_aol import qualified as stock_canfd_qualified
from opendbc.car.hyundai.classic_scc_aol import qualified as stock_classic_qualified
from openpilot.starpilot.car.toyota.aol import qualified as toyota_qualified
if (toyota_qualified(self.CP, marked_only=True) and CS.standstill or
angle_qualified(self.CP, marked_only=True) or
self.CP.brand == 'hyundai' and len(self.CP.safetyConfigs) == 1 and
stock_canfd_qualified(self.CP, marked_only=True) or
(stock_canfd_qualified(self.CP, marked_only=True) or stock_classic_qualified(self.CP, marked_only=True)) or
self.CP.brand == 'gm' and qualified_gm(self.CP) and self.CP.pcmCruise and not self.CP.openpilotLongitudinalControl):
native = current_native(self.sm, self.CP, now_ns=now_ns, axis_session_id=self.aol_session_id)
if stock_lateral_role(self, CS, native, intent, allow_independent_cancel=True,
@@ -1780,12 +1781,13 @@ class SelfdriveD:
def _decide_aol_axes(self, CS, intent, native, *, now_ns=0, ordinary_lag=False):
from openpilot.starpilot.car.gm.aol import physical_cancel_lateral_allowed, stock_ascm_independent_lateral, cc_independent_lateral_below_engage
from openpilot.starpilot.car.gm.aol import physical_cancel_lateral_allowed, stock_ascm_independent_lateral, independent_lateral_below_engage
from openpilot.starpilot.car.hyundai.aol import physical_cancel_lateral_allowed as hkg_cancel_lateral_allowed
from openpilot.starpilot.car.mazda.aol import physical_cancel_lateral_allowed as mazda_cancel_lateral_allowed
from openpilot.starpilot.car.ford.aol import physical_cancel_lateral_allowed as ford_cancel_lateral_allowed
from openpilot.starpilot.car.subaru.aol import sng_standstill_lateral_inhibit
from openpilot.starpilot.car.volkswagen.aol import lateral_output_inhibited as volkswagen_lateral_output_inhibited
from openpilot.starpilot.car.volkswagen.aol import (lateral_output_inhibited as volkswagen_lateral_output_inhibited,
below_engage_lateral_allowed as volkswagen_below_engage_lateral_allowed)
from openpilot.starpilot.aol.dm import lateral_event_context
from openpilot.starpilot.car.toyota.legacy_lateral import below_engage_lateral_allowed as toyota_below_engage_lateral_allowed
retained_dm_lateral, lateral_events = lateral_event_context(self, CS, intent, native, now_ns, ordinary_lag)
@@ -1819,12 +1821,13 @@ class SelfdriveD:
cruise_main_required=getattr(self, 'aol_cruise_main_required', True),
allow_below_engage_speed=bool((is_volt_ascm_longitudinal(self.CP) or
stock_ascm_independent_lateral(self.CP) or
cc_independent_lateral_below_engage(self.CP, intent,
independent_lateral_below_engage(self.CP, intent,
enabled=self.enabled, active=self.active)) and
not self.CP.flags & GMFlags.PEDAL_LONG) or
toyota_below_engage_lateral_allowed(self.CP, CS, intent, native,
source_ns=getattr(self, 'aol_car_state_log_ns', 0), now_ns=now_ns,
axis_session_id=getattr(self, 'aol_session_id', ''))),
axis_session_id=getattr(self, 'aol_session_id', '')) or
volkswagen_below_engage_lateral_allowed(self.CP, CS, intent)),
immediate_disable=self.events.contains(ET.IMMEDIATE_DISABLE),
dm_lockout=bool(not self.sm.all_checks(['driverMonitoringState']) or
not retained_dm_lateral and (self.sm['driverMonitoringState'].lockout or
@@ -1880,7 +1883,9 @@ class SelfdriveD:
ordinary_ford = bool(self.CP.brand == 'ford' and self.CP.openpilotLongitudinalControl and self.CP.safetyConfigs and
ford_native_accepts_cp(self.CP, int(car.CarParams.SafetyModel.ford), self.CP.safetyConfigs[-1].safetyParam))
# Driver steering does not invalidate a still-current native LAT grant.
steering_override_allowed = ordinary_ford or self.CP.brand == 'hyundai'
from opendbc.car.volkswagen.long_aol import qualified as volkswagen_long_qualified
steering_override_allowed = (ordinary_ford or self.CP.brand == 'hyundai' or
volkswagen_long_qualified(self.CP, marked_only=True))
proof = getattr(self, 'aol_published_input_proof', None)
prior = getattr(self, 'aol_published_input_native', None)
anchor = getattr(self, 'aol_ordinary_source_anchor', None)
@@ -189,11 +189,9 @@ class TogglesLayout(Widget):
self._toggles["ExperimentalMode"].set_description(e2e_description)
self._long_personality_setting.action_item.set_enabled(True)
else:
# no long for now
# Unavailable now, but retain the saved choice for a qualified drive.
self._toggles["ExperimentalMode"].action_item.set_enabled(False)
self._toggles["ExperimentalMode"].action_item.set_state(False)
self._long_personality_setting.action_item.set_enabled(False)
self._params.remove("ExperimentalMode")
unavailable = tr("Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control.")
@@ -136,11 +136,9 @@ class TogglesLayoutMici(NavScroller):
self._experimental_btn.set_visible(True)
self._personality_toggle.set_visible(True)
else:
# no long for now
# Unavailable now, but retain the saved choice for a qualified drive.
self._experimental_btn.set_visible(False)
self._experimental_btn.set_checked(False)
self._personality_toggle.set_visible(False)
ui_state.params.remove("ExperimentalMode")
# Refresh toggles from params to mirror external changes
for key, item in self._refresh_toggles:
+4 -3
View File
@@ -141,10 +141,11 @@ def disarming_fault(events, CS, *, temporary_ui_process_failure: bool = False,
class AolCardIntent:
def __init__(self, settings: AolSettings, *, explicit_latch: bool = False, lkas_button_supported: bool = True,
independent_cruise_buttons: bool = False):
independent_cruise_buttons: bool = False, preserve_unassigned_cancel: bool = False):
self.settings = settings
self.explicit_latch = explicit_latch
self.independent_cruise_buttons = independent_cruise_buttons
self.preserve_unassigned_cancel = preserve_unassigned_cancel
self.lkas_button_supported = lkas_button_supported
self.allowed_latch = False
# Observational only: records the exact branch that changed True to False.
@@ -341,12 +342,12 @@ class AolCardIntent:
(not independent_buttons or int(ButtonType.mainCruise) not in consumed_buttons)) or
(self._lkas_held and lkas_toggle and
(not independent_buttons or int(ButtonType.lkas) not in consumed_buttons)))
if not previously_held and gesture_held and (not independent_buttons or not cancel_pressed):
if not previously_held and gesture_held and (not (independent_buttons or self.preserve_unassigned_cancel) or not cancel_pressed):
self._last_latch_edge_ns = now_ns
if cancel_pressed:
# An unassigned cruise CANCEL cannot toggle, arm or renew independent
# lateral. Keep the existing withdrawal for configured CANCEL actions.
if not self.independent_cruise_buttons or any(self.settings.cancel_actions):
if not (self.independent_cruise_buttons or self.preserve_unassigned_cancel) or any(self.settings.cancel_actions):
self._record_latch_clear('cancel_press')
self.allowed_latch = False
elif not self._gesture_neutral_seen:
+1 -1
View File
@@ -121,7 +121,7 @@ class IntentCompanion:
verified = ((stamp, *raw_verified) if raw_current else self.verified_sm_projection)
if (verified is not None and verified[0] == stamp and verified[2] == self.integrity_epoch and
verified[3] == self.session and sm.valid.get(INTENT_SERVICE, False) and
sm.alive.get(INTENT_SERVICE, False) and 0 < stamp <= now_ns <= stamp + SAFETY_MAX_AGE_NS and
0 < stamp <= now_ns <= min(stamp + SAFETY_MAX_AGE_NS, verified[1].validUntilMonoTime) and
(stamp < source_ns or (source_ns == 0 or source_ns == stamp) and
0 < stamp <= now_ns <= stamp + SAFETY_MAX_AGE_NS) and
now_ns - stamp > INTENT_MAX_AGE_NS and decode_intent(sm[INTENT_SERVICE]) == verified[1]):
+21 -9
View File
@@ -15,6 +15,10 @@ AlertLevel = log.DriverMonitoringState.AlertLevel
def stock_lateral_role(self, CS, native, intent=None, *, allow_independent_cancel=False, allow_independent_brake=False, allow_independent_gas=False, now_ns=0):
"""Current stock USER role while an established independent LAT owner remains."""
from opendbc.car.hyundai.classic_scc_aol import qualified as classic_stock_qualified
hkg_stock = bool(self.CP.brand == 'hyundai' and classic_stock_qualified(self.CP, marked_only=True))
main_off_role = bool(hkg_stock and not getattr(self, 'aol_cruise_main_required', True) and
not CS.cruiseState.available)
cancel_role = False
if allow_independent_cancel:
from opendbc.car.hyundai.canfd_angle_aol import qualified as angle_qualified
@@ -33,11 +37,13 @@ def stock_lateral_role(self, CS, native, intent=None, *, allow_independent_cance
settings = getattr(self, 'aol_settings', None)
previous = getattr(self, 'aol_axis_decision', None)
brake_role = held_lateral_pedal(self, CS) or bool(settings is not None and settings.enabled and CS.brakePressed and not CS.regenBraking and
previous is not None and previous.lateral_active and previous.native_acknowledged and not previous.longitudinal_active and
previous is not None and (previous.lateral_active or hkg_stock and
getattr(self, 'aol_fault_reason', 'none') == 'availabilityPause' and previous.desired_lateral) and
previous.native_acknowledged and not previous.longitudinal_active and
(toyota_qualified(self.CP, marked_only=True) and CS.standstill and not CS.cruiseState.enabled and
CS.gearShifter == car.CarState.GearShifter.drive and isfinite(CS.vEgoRaw) and isfinite(CS.vEgo) or
CS.vEgo >= settings.pause_brake_mps and command_allowed(self.CP, CS) or
len(self.CP.safetyConfigs) == 1 and stock_canfd_qualified(self.CP, marked_only=True) and
len(self.CP.safetyConfigs) == 1 and (stock_canfd_qualified(self.CP, marked_only=True) or hkg_stock) and
CS.gearShifter == car.CarState.GearShifter.drive and isfinite(CS.vEgoRaw) and isfinite(CS.vEgo) and
(settings.pause_brake_mps <= 0 or CS.vEgo >= settings.pause_brake_mps or CS.standstill)))
gas_role = False
@@ -50,11 +56,11 @@ def stock_lateral_role(self, CS, native, intent=None, *, allow_independent_cance
from opendbc.car.subaru.stock_aol import qualified as subaru_qualified
gas_role = bool((self.CP.brand == 'hyundai' and len(self.CP.safetyConfigs) == 1 and
stock_canfd_qualified(self.CP, marked_only=True) or self.CP.brand == 'gm' and qualified_gm(self.CP) and self.CP.pcmCruise and
(stock_canfd_qualified(self.CP, marked_only=True) or hkg_stock) or self.CP.brand == 'gm' and qualified_gm(self.CP) and self.CP.pcmCruise and
not self.CP.openpilotLongitudinalControl or
self.CP.brand == 'subaru' and subaru_qualified(self.CP, marked_only=True)) and settings is not None and settings.enabled and
CS.gasPressed and not CS.brakePressed and not CS.regenBraking and
previous is not None and (previous.lateral_active or self.CP.brand in ('gm', 'subaru') and
previous is not None and (previous.lateral_active or (self.CP.brand in ('gm', 'subaru') or hkg_stock) and
getattr(self, 'aol_fault_reason', 'none') == 'availabilityPause' and previous.desired_lateral) and
previous.native_acknowledged and not previous.longitudinal_active and
(CS.gearShifter == car.CarState.GearShifter.drive or self.CP.brand == 'gm' and
@@ -63,7 +69,7 @@ def stock_lateral_role(self, CS, native, intent=None, *, allow_independent_cance
stock_user = stock_lateral_user_event(self.CP)
users = [name for name in self.events.names if ET.USER_DISABLE in rules.get(name, {})]
if stock_user is None or not users or any((name != stock_user and not (cancel_role and name == EventName.buttonCancel) and
not (brake_role and name == EventName.pedalPressed)) or
not (brake_role and name == EventName.pedalPressed) and not (main_off_role and name == EventName.wrongCarMode)) or
ET.SOFT_DISABLE in rules.get(name, {}) or ET.IMMEDIATE_DISABLE in rules.get(name, {}) for name in users):
return False
anchor = getattr(self, 'aol_ordinary_source_anchor', None)
@@ -77,7 +83,8 @@ def stock_lateral_role(self, CS, native, intent=None, *, allow_independent_cance
self.sm['driverMonitoringState'].lockout or self.sm['driverMonitoringState'].alwaysOnLockout or
not stock_lateral_off(self.CP, CS, native, allow_independent_cancel=cancel_role,
allow_independent_brake=brake_role,
allow_independent_gas=gas_role or brake_role and self.CP.brand == 'toyota' and CS.gasPressed)):
allow_independent_gas=gas_role or brake_role and self.CP.brand == 'toyota' and CS.gasPressed,
allow_independent_main=main_off_role)):
return False
accepted = intent if intent is not None else anchor[2]
companion = getattr(self, 'aol_intent_companion', None)
@@ -134,7 +141,11 @@ def ordinary_source_availability(self, CS, now_ns, native=None, *, replay: bool,
from openpilot.starpilot.car.gm.aol import held_gm_lateral_pedal
from openpilot.starpilot.car.toyota.aol import held_lateral_pedal
toyota_pedal = held_lateral_pedal(self, CS)
held_pedal = held_gm_lateral_pedal(self, CS) or toyota_pedal
hkg_brake = False
if self.CP.brand == 'hyundai' and CS.brakePressed and anchor is not None and CS is anchor[4]:
native = native or native_provider(self.sm, self.CP, now_ns=now_ns, axis_session_id=self.aol_session_id)
hkg_brake = stock_lateral_role(self, CS, native, allow_independent_brake=True)
held_pedal = held_gm_lateral_pedal(self, CS) or toyota_pedal or hkg_brake
if held_pedal:
events = [event for event in events if not (event.name == EventName.pedalPressed and
event.userDisable and not event.softDisable and not event.immediateDisable)]
@@ -147,8 +158,9 @@ def ordinary_source_availability(self, CS, now_ns, native=None, *, replay: bool,
if stock_lateral_role(self, CS, native, allow_independent_brake=held_pedal, allow_independent_gas=True):
stock_gas = bool(CS.gasPressed and anchor is not None and anchor[4].gasPressed)
stock_user = stock_lateral_user_event(self.CP)
events = [event for event in events if not (event.name == stock_user and event.userDisable and
not event.softDisable and not event.immediateDisable)]
events = [event for event in events if not ((event.name == stock_user or
self.CP.brand == 'hyundai' and not CS.cruiseState.available and event.name == EventName.wrongCarMode) and
event.userDisable and not event.softDisable and not event.immediateDisable)]
if (replay or not self.aol_replay or not self.axis_transport_required or anchor is None or
not self.initialized or self.aol_authority_lost or
getattr(self, 'aol_availability_long_inhibit', False) and stock_user is None or
@@ -59,10 +59,14 @@ def temporary_steering_intent_current(self, sm, cp, cs, axis, stamp, now_ns, *,
not axis.desiredLongitudinal and not axis.longitudinalActive and not cs.steeringDisengage and
cs.cruiseState.available and not cs.cruiseState.enabled and stock_user is not None and
any(event.name == stock_user and event.userDisable for event in sm['onroadEvents']))
from openpilot.starpilot.car.subaru.aol import temporary_eps_brake_recovery
subaru_brake = bool(stock_eps_user and temporary_eps_brake_recovery(cp, cs))
# Seatbelt blocks normal LONG, not admitted LAT intent. Keep warnings/native/output checks.
events = tuple(event for event in sm['onroadEvents'] if not (
stock_eps_user and event.name == stock_user and event.userDisable and
not event.softDisable and not event.immediateDisable) and not (
subaru_brake and event.name == log.OnroadEvent.EventName.pedalPressed and event.userDisable and
not event.softDisable and not event.immediateDisable) and not (
self.lateral_only_baseline and cs.seatbeltUnlatched and not axis.desiredLongitudinal and
not axis.longitudinalActive and event.name == log.OnroadEvent.EventName.seatbeltNotLatched and
event.softDisable and event.noEntry and not event.immediateDisable and not event.userDisable))
@@ -0,0 +1,98 @@
"""An aged conflating cache is metadata, never a new raw integrity violation."""
from contextlib import ExitStack
from dataclasses import replace
from unittest.mock import patch
import pytest
from opendbc.car.gm.tests.test_bolt_pedal import params
from opendbc.car.gm.values import CAR
from openpilot.cereal import messaging
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.selfdrived.events import EventName
from openpilot.starpilot.aol.intent_companion import IntentCompanion
from openpilot.starpilot.aol.wire import decode_intent, encode_intent
from openpilot.starpilot.aol.tests import test_ioniq_availability as fixture
from openpilot.starpilot.aol.tests.test_input_deadline_continuity import intent
BASE = 10_000_000_000
SERVICE = 'aolIntentWire'
def drain(companion, message):
with patch('openpilot.starpilot.aol.intent_companion.messaging.recv_one_or_none', side_effect=[message.as_reader(), None]):
assert companion.drain()
@pytest.mark.parametrize('bad', ['none', 'cached_invalid', 'cached_tampered', 'raw_invalid', 'expired', 'short_lease'])
def test_aged_known_sm_does_not_poison_valid_raw_successor_or_grant_output(bad):
with OpenpilotPrefix():
sm = messaging.SubMaster([SERVICE])
companion = IntentCompanion(sock=object())
original = intent(BASE, 1)
if bad == 'short_lease':
original.aolIntentWire = encode_intent(replace(decode_intent(bytes(original.aolIntentWire)),
validUntilMonoTime=BASE + 50_000_000))
sm.update_msgs(BASE / 1e9, [original.as_reader()])
drain(companion, original)
assert companion.current(sm, source_ns=BASE, now_ns=BASE + 1_000_000, drain=False) is not None
# A normal source wait prunes the output cache, retaining only its original metadata proof.
assert companion.current(sm, source_ns=0, now_ns=BASE + 60_000_000, drain=False) is None
assert BASE not in companion.samples
epoch = companion.integrity_epoch
now = BASE + (210_000_000 if bad == 'expired' else 110_000_000)
successor = intent(now - 1_000_000, 2)
if bad == 'raw_invalid':
successor.valid = False
drain(companion, successor)
sm.alive[SERVICE] = False
if bad == 'cached_invalid':
sm.valid[SERVICE] = False
elif bad == 'cached_tampered':
changed = intent(BASE, 1, latched=False)
sm.update_msgs(now / 1e9, [changed.as_reader()])
sm.alive[SERVICE] = False
assert companion.current(sm, source_ns=0, now_ns=now, drain=False) is None
assert companion.current(sm, source_ns=successor.logMonoTime, now_ns=now, drain=False) is None
if bad == 'none':
assert companion.latest is not None
assert companion.integrity_epoch == epoch and companion.latest[1]
sm.update_msgs(now / 1e9, [successor.as_reader()])
assert companion.current(sm, source_ns=successor.logMonoTime, now_ns=now, drain=False) is not None
else:
assert companion.integrity_epoch > epoch
def test_actual_bolt_source_wait_survives_raw_successor_before_conflated_cache_recovers():
with OpenpilotPrefix(), ExitStack() as cleanup:
case = fixture.IoniqAvailabilityTests(methodName='runTest')
cleanup.callback(case.doCleanups)
cp = params(CAR.CHEVROLET_BOLT_CC_2018_2021, pedal=True, camera=True)
sd, controls, step = case._authority_callers(False, cp=cp, genuine_events=True)
for tick in (0, 1):
axis, _, command = step(tick, native_request_long=False, panda_controls_allowed=False)
assert axis.lateralActive and command.latActive and not command.longActive
old = intent(BASE + 10_000_000, 2)
for tick in (4, 7):
axis, _, command = step(tick, offset_ns=1_000_000 if tick == 4 else 0, timeout=True, state_stamp=BASE + 10_000_000,
intent_message=old, native_request_long=False, panda_controls_allowed=False)
assert str(axis.faultReason) == 'availabilityPause'
assert not axis.lateralActive and not command.latActive and not command.longActive
epoch = sd.aol_intent_companion.integrity_epoch
original_update = sd.sm.update_msgs
def cached_poll(now, messages):
original_update(now, [m for m in messages if m.which() != SERVICE])
sd.sm.alive[SERVICE] = False
successor = intent(BASE + 110_000_000, 12)
with patch.object(sd.sm, 'update_msgs', side_effect=cached_poll):
axis, state, command = step(11, timeout=True, state_stamp=BASE + 10_000_000,
intent_message=successor, native_request_long=False, panda_controls_allowed=False)
assert sd.aol_intent_companion.integrity_epoch == epoch
assert str(axis.faultReason) == 'availabilityPause' and axis.desiredLateral and axis.nativeAcknowledged
assert not axis.lateralActive and not axis.longitudinalActive and axis.sourceCarStateMonoTime == 0
assert not command.latActive and not command.longActive and not state.enabled
assert EventName.controlsMismatch not in sd.events.names and not sd.aol_authority_lost
axis, _, command = step(12, native_request_long=False, panda_controls_allowed=False)
assert axis.lateralActive and command.latActive and not command.longActive
assert EventName.controlsMismatch not in sd.events.names
@@ -21,6 +21,59 @@ IDENTITIES = (CAR.HYUNDAI_PALISADE, CAR.HYUNDAI_SONATA, CAR.HYUNDAI_SONATA_HYBRI
CAR.HYUNDAI_ELANTRA_2021, CAR.HYUNDAI_KONA_EV, 'palisade_hdai', 'palisade_hdaii')
def brake_health_before_permission_echo(card, cs, now):
native = withdrawal(card, cs, now, controls=True)
native = native_change(card.sm, native, longitudinalAllowed=True)
change(card.sm, 'pandaStates', lambda pandas: setattr(pandas[0], 'controlsAllowed', False))
card.sm.logMonoTime['pandaStates'] = now + 1_000_000
return native
@pytest.mark.parametrize('identity', IDENTITIES)
def test_current_brake_health_precedes_old_full_permission_echo(identity):
card, cs = armed_card(identity)
now = NOW + 10_000_000
cs.brakePressed = True
native = brake_health_before_permission_echo(card, cs, now)
fault = card.aol_disarming_fault(cs, now, now + 2_000_000)
card.aol_card_intent.update(cs, fault_active=fault, now_ns=now + 2_000_000)
assert not fault and card.aol_card_intent.allowed_latch, 'BRAKE_HEALTH_FIRST_ERASED_AOL'
result = axes(card, cs, native, now + 2_000_000)
assert result.lateral_active and not result.longitudinal_active
@pytest.mark.parametrize('fault', ('equal_health', 'older_health', 'not_braking', 'gas', 'regen',
'rx', 'heartbeat', 'native_fault', 'latest_fault', 'lat_denied',
'expired', 'producer', 'epoch', 'source', 'unlatched'))
def test_brake_health_first_never_admits_unproven_or_faulted_lateral(fault):
card, cs = armed_card(CAR.HYUNDAI_ELANTRA_2021)
now = NOW + 10_000_000
cs.brakePressed = True
native = brake_health_before_permission_echo(card, cs, now)
if fault in ('equal_health', 'older_health'):
card.sm.logMonoTime['pandaStates'] = now - int(fault == 'older_health')
elif fault in ('not_braking', 'gas', 'regen'):
setattr(cs, {'not_braking':'brakePressed', 'gas':'gasPressed', 'regen':'regenBraking'}[fault], fault != 'not_braking')
elif fault in ('rx', 'heartbeat', 'native_fault'):
field, value = {'rx': ('safetyRxChecksInvalid', True), 'heartbeat': ('heartbeatLost', True), 'native_fault': ('faults', 1)}[fault]
native_change(card.sm, native, pandaInventory=(replace(native.pandaInventory[0], **{field: value}),))
elif fault == 'latest_fault':
change(card.sm, 'pandaStates', lambda pandas: setattr(pandas[0], 'faults', ['relayMalfunction']))
elif fault == 'lat_denied':
native_change(card.sm, native, lateralAllowed=False)
elif fault == 'expired':
native_change(card.sm, native, observedMonoTime=now - 200_000_001, validUntilMonoTime=now - 1)
elif fault in ('producer', 'epoch', 'source'):
field, value = {'producer': ('intentProducerSessionId', 'foreign'), 'epoch': ('intentIntegrityEpoch', 99),
'source': ('sourceCarStateMonoTime', now - 30_000_001)}[fault]
change(card.sm, 'aolAxisState', lambda axis: setattr(axis, field, value))
else:
card.aol_card_intent.allowed_latch = False
rejected = card.aol_disarming_fault(cs, now, now + 2_000_000)
card.aol_card_intent.update(cs, fault_active=rejected, now_ns=now + 2_000_000)
assert not card.aol_card_intent.allowed_latch
def profile(identity):
if identity in ('palisade_hdai', 'palisade_hdaii'):
from opendbc.car.hyundai.tests.test_palisade_2023 import params as mixed_params
@@ -0,0 +1,60 @@
"""Cruise's engagement floor cannot clear independent GM steering intent."""
from contextlib import ExitStack
import pytest
from opendbc.car.gm.tests.test_ascm_intercept import params
from opendbc.car.gm.values import CAR
from openpilot.cereal import messaging
from openpilot.common.params import Params
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.selfdrived.events import EventName
from openpilot.starpilot.aol.intent import read_settings
from openpilot.starpilot.aol.tests import test_gm as gm_fixture, test_ioniq_availability as fixture
from openpilot.starpilot.aol.wire import IntentState, encode_intent
from openpilot.starpilot.audio.tests.test_axis_alerts import AudioFixture, AudibleAlert
@pytest.mark.parametrize('candidate', [CAR.CHEVROLET_BLAZER, CAR.CHEVROLET_TRAVERSE, CAR.CHEVROLET_MALIBU_ASCM])
@pytest.mark.parametrize('braking', [False, True])
def test_acc_cruise_floor_does_not_clear_main_intent_or_chime_on_pedals(candidate, braking):
with OpenpilotPrefix(), ExitStack() as cleanup:
saved = Params()
saved.put_bool('AlwaysOnLateral', True, block=True)
card = gm_fixture.TestGmAol.card(params(candidate, sascm=True, alpha=True), saved)
cleanup.callback(card.vehicle_startup.close)
card.slc_producer_session = 'authority-card'
card.aol_card_intent.allowed_latch = True
case = fixture.IoniqAvailabilityTests(methodName='runTest')
cleanup.callback(case.doCleanups)
sd, _, step = case._authority_callers(False, cp=card.CP.as_reader().as_builder(), genuine_events=True)
sd.aol_settings = read_settings(sd.params)
audio = AudioFixture()
sounds = []
for tick, speed in enumerate((5., 4., 3.2, 2., 1.5, 1.3, .5, 0., .5, 1.3, 2., 3.2, 4.)):
now = 10_000_000_000 + tick * 10_000_000
cs = fixture.car_state(brake=braking and tick < 8)
cs.vEgo = cs.vEgoRaw = speed
cs.standstill = speed == 0.
cs.cruiseState.enabled = False
axis, host, control = step(tick, observed_state=cs, gas=tick >= 8,
latched=card.aol_card_intent.allowed_latch, native_request_long=False, panda_controls_allowed=False)
assert not host.enabled and not control.longActive and not axis.longitudinalActive
assert axis.desiredLateral and axis.lateralActive and axis.nativeAcknowledged
assert bool(control.latActive) == (speed >= card.CP.minSteerSpeed)
below_cruise_minimum = speed < card.CP.minEnableSpeed and not (cs.brakePressed and cs.standstill)
assert (EventName.belowEngageSpeed in sd.events.names) == below_cruise_minimum
frames = [sd.pm.messages[n] for n in ('aolAxisState', 'onroadEvents', 'selfdriveState')]
for service in ('pandaStates', 'aolSafetyWire', 'deviceState'):
msg = messaging.new_message(service, len(sd.sm[service]) if service in ('pandaStates', 'aolSafetyWire') else None,
valid=sd.sm.valid[service], logMonoTime=sd.sm.logMonoTime[service])
setattr(msg, service, sd.sm[service])
frames.append(msg.as_reader())
card.sm.update_msgs(now / 1e9, frames)
rejected = card.aol_disarming_fault(sd.CS_prev, int(card.sm.logMonoTime['onroadEvents']), now)
card.aol_card_intent.update(sd.CS_prev, fault_active=rejected, now_ns=now)
assert not rejected and card.aol_card_intent.allowed_latch
wire = messaging.new_message('aolIntentWire', 0, valid=True, logMonoTime=now)
wire.aolIntentWire = encode_intent(IntentState('authority-card', tick+1, now, now, now+200_000_000,
True, False, False, True, card.aol_card_intent.allowed_latch))
sounds.append(audio.publish(intent_message=wire, enabled=False, native=host.alertSound))
assert sounds == [AudibleAlert.none] * len(sounds)
@@ -16,7 +16,7 @@ from openpilot.starpilot.aol.runtime import decide_axes
from openpilot.starpilot.aol.tests.test_gm_parked_startup import _exercise_parked_main
from openpilot.starpilot.aol.tests.test_gm_startup_main import cases
from openpilot.starpilot.aol.wire import IntentState
from openpilot.starpilot.car.gm.aol import cc_independent_lateral_below_engage
from openpilot.starpilot.car.gm.aol import independent_lateral_below_engage
from openpilot.starpilot.nostalgia import aol_no_entry
@@ -35,7 +35,7 @@ def test_actual_bolt_main_park_then_low_speed_drive_without_main_edge(main_actio
@pytest.mark.parametrize('identity', [CAR.CHEVROLET_BOLT_CC_2017, CAR.CHEVROLET_BOLT_CC_2018_2021, CAR.CHEVROLET_BOLT_CC_2022_2023])
def test_exact_cc_long_factory_owners(identity):
cp, intent = setup(identity)
assert cc_independent_lateral_below_engage(cp, intent, enabled=False, active=False)
assert independent_lateral_below_engage(cp, intent, enabled=False, active=False)
@pytest.mark.parametrize('enabled,active', [(False, False), (True, False), (False, True), (True, True)])
@@ -44,7 +44,7 @@ def test_minimum_exemption_cannot_engage_ordinary_long(enabled, active, long_gra
cp, intent = setup()
cs = car.CarState(canValid=True, gearShifter='drive', vEgo=4.8, standstill=False)
cs.cruiseState.available = True
allowed = cc_independent_lateral_below_engage(cp, intent, enabled=enabled, active=active)
allowed = independent_lateral_below_engage(cp, intent, enabled=enabled, active=active)
no_entry = aol_no_entry([EventName.belowEngageSpeed], cs, paddle_only_cancel=False, allow_below_engage_speed=allowed)
native = SimpleNamespace(requestedLateral=True, lateralAllowed=True, requestedLongitudinal=long_grant, longitudinalAllowed=long_grant)
decision = decide_axes(
@@ -68,22 +68,22 @@ def test_minimum_exemption_cannot_engage_ordinary_long(enabled, active, long_gra
@pytest.mark.parametrize('field', ['settingsQualified', 'allowedLatch', 'lateralArmed'])
def test_current_armed_intent_required(field):
cp, intent = setup()
assert not cc_independent_lateral_below_engage(cp, replace(intent, **{field: False}), enabled=False, active=False)
assert not independent_lateral_below_engage(cp, replace(intent, **{field: False}), enabled=False, active=False)
def test_lateral_pause_and_missing_intent_remain_denied():
cp, intent = setup()
assert not cc_independent_lateral_below_engage(cp, None, enabled=False, active=False)
assert not cc_independent_lateral_below_engage(cp, replace(intent, pauseLateral=True), enabled=False, active=False)
assert cc_independent_lateral_below_engage(cp, replace(intent, pauseLongitudinal=True), enabled=False, active=False)
assert not independent_lateral_below_engage(cp, None, enabled=False, active=False)
assert not independent_lateral_below_engage(cp, replace(intent, pauseLateral=True), enabled=False, active=False)
assert independent_lateral_below_engage(cp, replace(intent, pauseLongitudinal=True), enabled=False, active=False)
@pytest.mark.parametrize('index', [0, 1, 3, 4, 5, 6, 7])
def test_other_factory_profiles_keep_existing_admission(index):
def test_factory_profiles_use_independent_steering_minimum(index):
_, intent = setup()
cp = list(cases())[index]
cp.alternativeExperience = 32
assert not cc_independent_lateral_below_engage(cp, intent, enabled=False, active=False)
assert independent_lateral_below_engage(cp, intent, enabled=False, active=False)
@pytest.mark.parametrize(
@@ -93,7 +93,7 @@ def test_other_factory_profiles_keep_existing_admission(index):
def test_changed_configuration_is_not_a_cc_long_owner(field, value):
cp, intent = setup()
setattr(cp, field, value)
assert not cc_independent_lateral_below_engage(cp, intent, enabled=False, active=False)
assert not independent_lateral_below_engage(cp, intent, enabled=False, active=False)
@pytest.mark.parametrize('event', [EventName.buttonCancel, EventName.doorOpen, EventName.controlsMismatch, EventName.commIssue, EventName.steerUnavailable])
@@ -101,5 +101,5 @@ def test_only_cruise_speed_event_is_removed(event):
cp, intent = setup()
cs = car.CarState(canValid=True, gearShifter='drive', vEgo=4.8)
cs.cruiseState.available = True
allowed = cc_independent_lateral_below_engage(cp, intent, enabled=False, active=False)
allowed = independent_lateral_below_engage(cp, intent, enabled=False, active=False)
assert aol_no_entry([EventName.belowEngageSpeed, event], cs, paddle_only_cancel=False, allow_below_engage_speed=allowed) == (event != EventName.doorOpen)
@@ -18,7 +18,7 @@ from openpilot.common.prefix import OpenpilotPrefix
from openpilot.starpilot.aol.intent import read_settings
from openpilot.starpilot.aol.tests import test_gm_parked_startup as parked, test_ioniq_availability as fixture
from openpilot.starpilot.aol.tests.test_gm_cc_lateral_minimum import setup
from openpilot.starpilot.car.gm.aol import cc_independent_lateral_below_engage
from openpilot.starpilot.car.gm.aol import independent_lateral_below_engage
def cc_params(candidate):
@@ -31,16 +31,16 @@ def cc_params(candidate):
def test_all_exact_ordinary_cc_owners_keep_independent_steering_minimum(candidate):
cp = cc_params(candidate)
_, intent = setup()
assert cc_independent_lateral_below_engage(cp, intent, enabled=False, active=False)
assert independent_lateral_below_engage(cp, intent, enabled=False, active=False)
for enabled, active in ((True, True), (True, False), (False, True)):
assert not cc_independent_lateral_below_engage(cp, intent, enabled=enabled, active=active)
assert not independent_lateral_below_engage(cp, intent, enabled=enabled, active=active)
for key in ('settingsQualified', 'allowedLatch', 'lateralArmed'):
assert not cc_independent_lateral_below_engage(cp, replace(intent, **{key: False}), enabled=False, active=False)
assert not cc_independent_lateral_below_engage(cp, replace(intent, pauseLateral=True), enabled=False, active=False)
assert not independent_lateral_below_engage(cp, replace(intent, **{key: False}), enabled=False, active=False)
assert not independent_lateral_below_engage(cp, replace(intent, pauseLateral=True), enabled=False, active=False)
for flag in (GMFlags.PEDAL_LONG, GMFlags.NO_CAMERA, 1 << 30):
denied = cp.as_reader().as_builder()
denied.flags |= int(flag)
assert not cc_independent_lateral_below_engage(denied, intent, enabled=False, active=False)
assert not independent_lateral_below_engage(denied, intent, enabled=False, active=False)
@pytest.mark.parametrize('main_action', [0, 9])
@@ -0,0 +1,74 @@
"""Classic stock ACC user roles do not revoke an independent lateral owner."""
from contextlib import ExitStack
import pytest
from openpilot.cereal import log, messaging
from openpilot.common.prefix import OpenpilotPrefix
from opendbc.car.hyundai.values import CAR
from openpilot.starpilot.aol.intent import read_settings
from openpilot.starpilot.aol.tests import test_ioniq_availability as fixture
from openpilot.starpilot.aol.tests.test_classic_scc_profiles import params
from openpilot.starpilot.aol.tests.test_ordinary_source_availability import settings
BASE = 10_000_000_000
def sample(stamp, pedal):
message = messaging.new_message('carState', valid=True, logMonoTime=stamp)
state = fixture.car_state(brake=pedal == 'brake')
state.gasPressed = pedal == 'gas'
state.cruiseState.available = state.cruiseState.enabled = False
message.carState = state
return message
@pytest.mark.parametrize('identity', [CAR.HYUNDAI_SONATA_HYBRID, CAR.HYUNDAI_SONATA, CAR.HYUNDAI_ELANTRA_2021])
@pytest.mark.parametrize('pedal', ['brake', 'gas'])
@pytest.mark.parametrize('fresh', [False, True])
def test_stock_hkg_main_off_pedal_source_join_preserves_only_valid_lateral(identity, pedal, fresh):
case = fixture.IoniqAvailabilityTests(methodName='runTest')
with ExitStack() as cleanup:
cleanup.callback(case.doCleanups)
cleanup.enter_context(OpenpilotPrefix())
sd, controls, drive, card, receive = case._ioniq_availability_callers(False, cp=params(identity, 0x391), cold_start=True)
sd.params.put('PauseAOLOnBrake', 0.0, block=True)
sd.aol_settings = read_settings(sd.params)
for tick in (0, 1, 2):
stamp = BASE + tick * 10_000_000
state = sample(stamp, pedal)
axis, _, command = drive(tick, car_state_message=state, offset_ns=5_000_000 if tick == 2 else 0,
long_allowed=False, **settings('lateral_only', stamp, tick + 1))
assert axis.lateralActive and command.latActive and not command.longActive
receive(stamp + (5_000_000 if tick == 2 else 0), axis=sd.pm.messages['aolAxisState'],
event=sd.pm.messages['onroadEvents'], state=state.carState, preserve_cruise_state=True)
assert card.aol_card_intent.allowed_latch
original = axis.to_dict()
stamp = BASE + (37_000_000 if fresh else 20_000_000)
state = sample(stamp, pedal)
axis, _, command = drive(6, car_state_message=state, timeout=not fresh, offset_ns=5_000_000,
long_allowed=False, **settings('lateral_only', stamp, 4 if fresh else 3))
assert log.OnroadEvent.EventName.controlsMismatch not in sd.events.names, 'STOCK_HKG_HELD_PEDAL_MISMATCH'
assert not sd.aol_authority_lost and not command.longActive
if fresh:
assert axis.lateralActive and command.latActive
assert axis.sourceCarStateMonoTime == stamp
assert axis.validUntilMonoTime != original['validUntilMonoTime']
else:
assert str(axis.faultReason) == 'availabilityPause' and axis.desiredLateral
assert not axis.lateralActive and not command.latActive and axis.sourceCarStateMonoTime == 0
receive(BASE + 65_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'], state=state.carState, preserve_cruise_state=True)
assert card.aol_card_intent.allowed_latch, 'STOCK_HKG_HELD_PEDAL_ERASED_LATCH'
if not fresh:
axis, _, command = drive(7, car_state_message=state, timeout=True, long_allowed=False,
**settings('lateral_only', stamp, 3))
assert str(axis.faultReason) == 'availabilityPause' and axis.desiredLateral
assert not axis.lateralActive and not command.latActive and not command.longActive
assert not sd.aol_authority_lost and log.OnroadEvent.EventName.controlsMismatch not in sd.events.names
receive(BASE + 70_000_000, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'],
state=state.carState, preserve_cruise_state=True)
assert card.aol_card_intent.allowed_latch
stamp = BASE + 80_000_000
state = sample(stamp, pedal)
axis, _, command = drive(8, car_state_message=state, long_allowed=False,
**settings('lateral_only', stamp, 5))
assert axis.lateralActive and command.latActive and not command.longActive
@@ -75,12 +75,12 @@ def cleanup(cp):
def test_actual_cleanup_preserves_request_without_enabling_long():
cp = stock(True)
assert cleanup(cp) == ['ExperimentalMode']
assert cleanup(cp) == []
cp.passive = True
cp.dashcamOnly = True
assert not qualified_ioniq6(cp) and not ioniq6_settings_capable(cp)
assert cleanup(cp) == ['ExperimentalMode']
assert cleanup(cp) == []
cp.carFingerprint = CAR.HYUNDAI_IONIQ_5
assert cleanup(cp) == ['ExperimentalMode']
assert cleanup(cp) == []
cp.openpilotLongitudinalControl = True
assert cleanup(cp) == []
@@ -378,7 +378,7 @@ class IoniqAvailabilityTests(unittest.TestCase):
cs.buttonEvents = []
self.assertEqual(card.aol_card_intent.allowed_latch, initially_armed)
def receive(now, *, axis=None, event=None, state=None):
def receive(now, *, axis=None, event=None, state=None, preserve_cruise_state=False):
frames = []
for service in ('pandaStates', 'aolSafetyWire', 'deviceState', 'carControl'):
message = messaging.new_message(service, len(sd.sm[service]) if service == 'pandaStates' else
@@ -391,7 +391,8 @@ class IoniqAvailabilityTests(unittest.TestCase):
observed = state if state is not None else car_state()
if stock_angle and state is None:
observed.vEgoRaw = observed.vEgo
observed.cruiseState.available = True
if not preserve_cruise_state:
observed.cruiseState.available = True
card.CC_prev = card.sm['carControl']
with mock.patch('openpilot.selfdrive.car.card.messaging.drain_sock_raw', return_value=[b'fixture']), \
mock.patch('openpilot.selfdrive.car.card.can_capnp_to_list', return_value=[]), \
@@ -114,7 +114,7 @@ def test_meb_full_engagement_brake_reverse_drive_actual_callers(identity, stock,
# Explicit ambient producers added since the original shared caller fixture.
# Keep production SubMaster health/frequency checks; never ignore a required service.
from openpilot.cereal.services import SERVICE_LIST
ambient_services = ('alertDebug', 'lateralManeuverPlan', 'laneChangeAssistWire',
ambient_services = ('laneChangeAssistWire',
'starpilotLongitudinalPlan', 'slcState', 'gpsLocation')
original_sd_update = sd.sm.update_msgs
ambient_due = {}
@@ -31,7 +31,7 @@ from openpilot.starpilot.aol.tests.stock_lateral_test_helpers import authority_c
@pytest.mark.parametrize('fixed', (False,))
def test_mqb_full_engagement_brake_reverse_drive_actual_callers(gearbox, alpha, offset, fixed, engage_on_set,
stop_and_resume=False, candidate=CAR.VOLKSWAGEN_GOLF_MK7,
brake_held_engagement=False):
brake_held_engagement=False, creep_start=False, early_brake=False):
with ExitStack() as cleanup:
cleanup.enter_context(OpenpilotPrefix())
cleanup.enter_context(mock.patch.dict('os.environ', {'SIMULATION': '0', 'AOL_REPLAY_RUNTIME': '1'}))
@@ -116,7 +116,7 @@ def test_mqb_full_engagement_brake_reverse_drive_actual_callers(gearbox, alpha,
# Explicit ambient producers added since the original shared caller fixture.
# Keep production SubMaster health/frequency checks; never ignore a required service.
from openpilot.cereal.services import SERVICE_LIST
ambient_services = ('alertDebug', 'lateralManeuverPlan', 'laneChangeAssistWire',
ambient_services = ('laneChangeAssistWire',
'starpilotLongitudinalPlan', 'slcState', 'gpsLocation')
original_sd_update = sd.sm.update_msgs
ambient_due = {}
@@ -156,6 +156,8 @@ def test_mqb_full_engagement_brake_reverse_drive_actual_callers(gearbox, alpha,
for tick in range(120):
now = 10_000_000_000 + tick * 10_000_000
native.set_timer(1_000_000 + tick * 10_000)
if creep_start and tick >= 12:
native.set_aol_test_heartbeat(bool(card.aol_card_intent.allowed_latch or request))
if quiet is not None:
quiet.set_timer(1_000_000 + tick * 10_000)
quiet.safety_tick()
@@ -163,12 +165,12 @@ def test_mqb_full_engagement_brake_reverse_drive_actual_callers(gearbox, alpha,
assert not quiet.get_controls_allowed()
assert native.get_current_safety_mode() != quiet.get_current_safety_mode()
brake = 65 <= tick < 75 or (brake_held_engagement and tick < 29)
brake = (60 if early_brake else 65) <= tick < 75 or (brake_held_engagement and tick < 29)
reverse = 85 <= tick < 95
ordinary = engage_on_set and 21 <= tick < 65
overrides = {
'TSK_06': {'TSK_Status': 3 if ordinary and not alpha else 2},
'LH_EPS_03': {'EPS_HCA_Status': 3, 'EPS_Lenkmoment': 0},
'LH_EPS_03': {'EPS_HCA_Status': 3, 'EPS_Lenkmoment': 100 if early_brake and 52 <= tick <= 61 else 0},
'Motor_20': {'MO_Fahrpedalrohwert_01': 0},
'Motor_14': {'MO_Fahrer_bremst': int(brake), 'MO_Kuppl_schalter': 0},
'ESP_05': {'ESP_Fahrer_bremst': int(brake)},
@@ -176,9 +178,10 @@ def test_mqb_full_engagement_brake_reverse_drive_actual_callers(gearbox, alpha,
'Gateway_72': {'BCM1_Rueckfahrlicht_Schalter': int(reverse)},
'Motor_EV_01': {'MO_Waehlpos': 3 if reverse else 5},
'ESP_19': dict.fromkeys(('ESP_VL_Radgeschw_02', 'ESP_VR_Radgeschw_02', 'ESP_HL_Radgeschw_02', 'ESP_HR_Radgeschw_02'),
(72 if tick < 12 else 0 if tick < 20 else 1 if tick < 24 else 2 if tick < 46 else 72) if creep_start else
0 if (stop_and_resume and 45 <= tick < 56) or (brake_held_engagement and tick < 30) else 72),
'Airbag_02': {'AB_Gurtschloss_FA': 0 if 105 <= tick < 111 else 3},
'GRA_ACC_01': {'GRA_Hauptschalter': 1, 'GRA_Tip_Setzen': int(engage_on_set and tick == 20)},
'GRA_ACC_01': {'GRA_Hauptschalter': 1, 'GRA_Tip_Setzen': int(engage_on_set and (tick in (20, 50) if creep_start else tick == 20))},
'ACC_02': {'ACC_Gesetzte_Zeitluecke': 1},
}
incoming = []
@@ -196,6 +199,9 @@ def test_mqb_full_engagement_brake_reverse_drive_actual_callers(gearbox, alpha,
native.safety_tick()
native.aol_set_host_request(request)
permission = native.aol_get_permission_mask()
if early_brake and tick == 60:
incoming = [(address, data, bus) for address, data, bus in incoming if address not in (0x106, 0x3BE)]
assert not native.get_controls_allowed() and not permission & 2
original_message = messaging.new_message
def clocked_card_message(*args, original_message=original_message, now=now, **kwargs):
message = original_message(*args, **kwargs)
@@ -218,6 +224,11 @@ def test_mqb_full_engagement_brake_reverse_drive_actual_callers(gearbox, alpha,
allowed=bool(permission & 1), long_allowed=bool(permission & 2),
native_request_lat=bool(request & 1), native_request_long=bool(request & 2),
panda_controls_allowed=native.get_controls_allowed())
if early_brake and tick == 60:
assert not state.brakePressed and state.steeringPressed
assert log.OnroadEvent.EventName.controlsMismatch not in sd.events.names, 'VW_EARLY_BRAKE_STEERING_MISMATCH'
assert not command.longActive
assert card.aol_card_intent.allowed_latch
if brake_held_engagement and 22 <= tick < 29:
assert state.standstill and state.brakePressed
assert selfdrive.state == log.SelfdriveState.OpenpilotState.preEnabled
@@ -262,6 +273,9 @@ def test_mqb_full_engagement_brake_reverse_drive_actual_callers(gearbox, alpha,
emitted.extend(sent)
lat_commands.append(command.latActive)
long_commands.append(command.longActive)
if creep_start:
assert any(lat_commands[35:45]), 'AOL must request and obtain native steering below the manual long-enable speed'
assert not any(long_commands[:46]), 'Creep AOL must not enable longitudinal below its independent minimum'
assert any(lat_commands[35:60])
assert any(lat_commands[76:84]) # braking released long, not qualified lateral intent
assert not any(lat_commands[86:95])
@@ -270,7 +284,7 @@ def test_mqb_full_engagement_brake_reverse_drive_actual_callers(gearbox, alpha,
assert not any(long_commands[66:]) # no automatic longitudinal restoration
assert emitted
if alpha and engage_on_set:
assert any(long_commands[35:60])
assert any(long_commands[50:65] if creep_start else long_commands[35:60])
else:
assert not any(long_commands)
@@ -287,3 +301,50 @@ def test_mqb_brake_held_engagement_waits_for_release_actual_callers(gearbox):
test_mqb_full_engagement_brake_reverse_drive_actual_callers(
gearbox, True, 0, False, True, brake_held_engagement=True,
candidate=CAR.SEAT_ATECA_MK1 if gearbox == 'automatic' else CAR.VOLKSWAGEN_GOLF_MK7)
@pytest.mark.parametrize('offset,fixed', [(0, False), (0, True), (4, False), (4, True)])
def test_mqb_manual_alpha_stopped_creep_preserves_native_arm_and_fresh_speed_engagement(offset, fixed):
test_mqb_full_engagement_brake_reverse_drive_actual_callers(
'manual', True, offset, fixed, True, creep_start=True)
@pytest.mark.parametrize('veto', ('none', 'can', 'timeout', 'temporary', 'permanent', 'main', 'reverse', 'latch', 'pause', 'automatic', 'stock'))
def test_manual_cruise_minimum_exception_retains_physical_and_intent_vetoes(veto):
from types import SimpleNamespace
from openpilot.starpilot.car.volkswagen.aol import below_engage_lateral_allowed
from openpilot.starpilot.aol.vehicle import policy_for
fp = {bus: {} for bus in range(8)}
fp[1][0xFD] = 8
cp = CarInterface.get_params(CAR.VOLKSWAGEN_GOLF_MK7, fp, [], True, False, False)
policy = policy_for(cp)
cp.safetyConfigs[-1].safetyParam |= policy.safety_param_addition
cp.alternativeExperience |= policy.alternative_experience_addition
state = structs.CarState(canValid=True, gearShifter=structs.CarState.GearShifter.drive)
state.cruiseState.available = True
intent = SimpleNamespace(allowedLatch=True, pauseLateral=False)
if veto in ('can', 'timeout', 'temporary', 'permanent'):
setattr(state, {'can': 'canValid', 'timeout': 'canTimeout', 'temporary': 'steerFaultTemporary',
'permanent': 'steerFaultPermanent'}[veto], veto != 'can')
elif veto == 'main':
state.cruiseState.available = False
elif veto == 'reverse':
state.gearShifter = structs.CarState.GearShifter.reverse
elif veto == 'latch':
intent.allowedLatch = False
elif veto == 'pause':
intent.pauseLateral = True
elif veto == 'automatic':
cp.transmissionType = structs.CarParams.TransmissionType.automatic
cp.safetyConfigs[-1].safetyParam = 9
elif veto == 'stock':
cp.openpilotLongitudinalControl, cp.pcmCruise = False, True
cp.safetyConfigs[-1].safetyParam = 24
assert below_engage_lateral_allowed(cp, state, intent) is (veto == 'none')
@pytest.mark.parametrize('gearbox', ('automatic', 'manual', 'direct'))
def test_native_brake_precedes_parsed_pedal_during_driver_steering(gearbox):
test_mqb_full_engagement_brake_reverse_drive_actual_callers(
gearbox, True, 0, False, True, early_brake=True,
candidate=CAR.SEAT_ATECA_MK1 if gearbox == 'automatic' else CAR.VOLKSWAGEN_GOLF_MK7)
@@ -0,0 +1,214 @@
"""Mixed Palisade Pause/Resume retains only existing, current lateral authority."""
from dataclasses import replace
from types import SimpleNamespace
from typing import cast
import pytest
from opendbc.car import structs
from openpilot.cereal import log
from openpilot.selfdrive.selfdrived.events import Events
from openpilot.selfdrive.selfdrived.selfdrived import SelfdriveD
from openpilot.starpilot.aol.intent import AolSettings, AOL_TOGGLE
from openpilot.starpilot.aol.runtime import AxisDecision
from openpilot.starpilot.aol.wire import IntentState, SafetyState, PandaSlot
from openpilot.starpilot.aol.tests.test_classic_brake_withdrawal import profile
from openpilot.starpilot.car.hyundai.aol import create_intent
class Messages(dict):
ready = True
def all_checks(self, services=None):
return self.ready
@pytest.mark.parametrize('identity', ('palisade_hdai', 'palisade_hdaii'))
@pytest.mark.parametrize('case', ['retain', 'prior_off', 'settings_off', 'configured_cancel', 'intent_off', 'stale_source',
'native_denied', 'native_expired', 'wrong_session', 'wrong_inventory', 'hard_event',
'model', 'dm', 'reverse', 'steer_fault'])
def test_actual_selfdrived_cancel_keeps_only_current_owned_lateral(identity, case):
now = 4_000_000_000
cp = profile(identity)
config = cp.safetyConfigs[0]
model, word = int(config.safetyModel.raw), int(config.safetyParam)
intent = IntentState('card', 1, now, now, now + 200_000_000, True, False, False, True, True)
slot = PandaSlot(0, 'panda', model, word, cp.alternativeExperience)
inventory = (slot,)
native = SafetyState(1, True, now, now + 200_000_000, model, word, True, False, True, False,
'panda', 'axis', now, inventory)
cs = structs.CarState(canValid=True, gearShifter='drive', vEgo=10.)
cs.cruiseState.available = True
cs.buttonEvents = [structs.CarState.ButtonEvent(type='cancel', pressed=True)]
sm = Messages(driverMonitoringState=SimpleNamespace(alertLevel=0, lockout=False, alwaysOnLockout=False),
extrinsicsCalibration=SimpleNamespace(calStatus=log.ExtrinsicsCalibration.Status.calibrated))
sd = SimpleNamespace(CP=cp, active=False, enabled=False, initialized=True, sm=sm,
aol_settings=AolSettings(True, 0., AOL_TOGGLE, 0, (0,0,0), (0,0,0)),
aol_axis_decision=AxisDecision(lateral_active=True, native_acknowledged=True),
aol_car_state_log_ns=now, aol_session_id='axis', aol_dm_lateral_inhibit=False,
nostalgia_paddle_cancel=False, events=Events())
sd.events.add(log.OnroadEvent.EventName.buttonCancel)
if case == 'prior_off':
sd.aol_axis_decision = AxisDecision()
elif case == 'settings_off':
sd.aol_settings = replace(sd.aol_settings, enabled=False)
elif case == 'configured_cancel':
sd.aol_settings = replace(sd.aol_settings, cancel_actions=(3,0,0))
elif case == 'intent_off':
intent = replace(intent, allowedLatch=False)
elif case == 'stale_source':
sd.aol_car_state_log_ns = now - 30_000_001
intent = replace(intent, carStateLogMonoTime=sd.aol_car_state_log_ns)
elif case == 'native_denied':
native = replace(native, lateralAllowed=False)
elif case == 'native_expired':
native = replace(native, validUntilMonoTime=now-1)
elif case == 'wrong_session':
native = replace(native, axisSessionId='other')
elif case == 'wrong_inventory':
native = replace(native, pandaInventory=(*inventory[:-1], replace(slot, safetyParam=word ^ 0x80)))
elif case == 'hard_event':
sd.events.add(log.OnroadEvent.EventName.controlsMismatch)
elif case == 'model':
sm.ready = False
elif case == 'dm':
sm['driverMonitoringState'].lockout = True
elif case == 'reverse':
cs.gearShifter = 'reverse'
elif case == 'steer_fault':
cs.steerFaultPermanent = True
result = SelfdriveD._decide_aol_axes(cast(SelfdriveD, sd), cs, intent, native, now_ns=now)
assert result.lateral_active == (case == 'retain')
assert not result.longitudinal_active
assert log.OnroadEvent.EventName.buttonCancel in sd.events.names
@pytest.mark.parametrize('identity', ('palisade_hdai', 'palisade_hdaii'))
@pytest.mark.parametrize('duration', (61_047_724, 221_004_624))
@pytest.mark.parametrize('cancel_actions', ((0, 0, 0), (3, 0, 0), (0, 3, 0), (0, 0, 3)))
def test_host_cancel_hold_keeps_only_unassigned_preexisting_latch(identity, duration, cancel_actions):
cp = profile(identity)
settings = AolSettings(True, 0., AOL_TOGGLE, 0, (0, 0, 0), cancel_actions)
owner = create_intent(cp, settings)
cs = structs.CarState(canValid=True, gearShifter='drive')
cs.cruiseState.available = True
owner.update(cs, now_ns=1_000_000_000)
cs.buttonEvents = [structs.CarState.ButtonEvent(type='lkas', pressed=True)]
owner.update(cs, now_ns=1_010_000_000)
assert owner.allowed_latch
edge, arm = owner._last_latch_edge_ns, owner.physical_arm_ns
cs.buttonEvents = [structs.CarState.ButtonEvent(type='lkas', pressed=False)]
owner.update(cs, now_ns=1_020_000_000)
for stamp, events in ((1_030_000_000, [('cancel', True)]),
(1_040_000_000, []), (1_030_000_000 + duration, [('cancel', False)])):
cs.buttonEvents = [structs.CarState.ButtonEvent(type=kind, pressed=pressed) for kind, pressed in events]
owner.update(cs, now_ns=stamp)
assert owner.allowed_latch == (not any(cancel_actions))
assert (owner._last_latch_edge_ns, owner.physical_arm_ns) == (edge, arm)
assert not owner.independent_cruise_buttons # MAIN/LKAS semantics are not changed.
@pytest.mark.parametrize('identity', ('palisade_hdai', 'palisade_hdaii'))
@pytest.mark.parametrize('simultaneous', (None, 'lkas', 'mainCruise'))
def test_cold_cancel_never_arms_or_renews_gesture(identity, simultaneous):
owner = create_intent(profile(identity), AolSettings(True, 0., AOL_TOGGLE, 0, (0,0,0), (0,0,0)))
cs = structs.CarState(canValid=True, gearShifter='drive')
cs.cruiseState.available = True
owner.update(cs, now_ns=1_000_000_000)
buttons = [structs.CarState.ButtonEvent(type='cancel', pressed=True)]
if simultaneous:
buttons.append(structs.CarState.ButtonEvent(type=simultaneous, pressed=True))
cs.buttonEvents = buttons
owner.update(cs, now_ns=1_010_000_000)
assert not owner.allowed_latch
assert owner._last_latch_edge_ns == owner.physical_arm_ns == 0
@pytest.mark.parametrize('identity', ('palisade_hdai', 'palisade_hdaii'))
def test_card_retains_original_cancel_receipt_until_lateral_only_ack(identity):
"""Real Card caller, synthetic transport; parser receipt is tested separately."""
from unittest.mock import Mock, patch
from opendbc.car.hyundai.blended_longitudinal import BlendedStartup, candidate_from_stock
from opendbc.car.hyundai.tests.test_palisade_2023 import params as mixed_params
from openpilot.common.params import Params
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.starpilot.aol.tests.test_ioniq_availability import IoniqAvailabilityTests, car_state
case = IoniqAvailabilityTests(methodName='runTest')
with OpenpilotPrefix():
try:
stock = mixed_params('hdaii' if identity == 'palisade_hdaii' else 'hdai')
cp = candidate_from_stock(stock, alpha_requested=True, native_qualified=True)
startup = BlendedStartup(stock, cp.as_reader().as_builder(), (Mock(), Mock()))
startup.prepare(admission=lambda: True)
case.addCleanup(startup.close)
Params().put('LKASButtonControl', AOL_TOGGLE, block=True)
sd, _, drive, card, receive = case._ioniq_availability_callers(True, cp=cp, startup_owner=startup)
sd.aol_settings = card.aol_settings
source = 10_020_000_000
cs = car_state()
cs.cruiseState.available = True
cs.buttonEvents = [structs.CarState.ButtonEvent(type='cancel', pressed=True)]
# Native withdrawal arrives before the next SelfdriveD publication.
drive(2, native_request_lat=True, native_request_long=True, long_allowed=False,
panda_controls_allowed=False)
old_axis = sd.pm.messages['aolAxisState'].as_builder()
old_axis.logMonoTime = old_axis.aolAxisState.observedMonoTime = source - 1_000_000
old_axis.aolAxisState.sourceCarStateMonoTime = source - 1_000_000
old_axis.aolAxisState.validUntilMonoTime = source + 29_000_000
old_axis.aolAxisState.mode = 'combined'
old_axis.aolAxisState.desiredLongitudinal = old_axis.aolAxisState.longitudinalActive = True
with patch.object(card, 'aol_cancel_source', return_value=source):
assert receive(source, axis=old_axis.as_reader(), state=cs)
assert card.aol_transport_feedback.longitudinal_cancel_ns == source
# No second physical press, no newer axis yet, now the cancellation event.
event = log.Event.new_message(logMonoTime=source)
event.init('onroadEvents', 1)
event.onroadEvents[0].name = 'buttonCancel'
event.onroadEvents[0].userDisable = True
event.onroadEvents[0].noEntry = True
cs.buttonEvents = []
with patch.object(card, 'aol_cancel_source', return_value=0):
assert receive(source + 10_000_000, event=event.as_reader(), state=cs)
assert card.aol_transport_feedback.longitudinal_cancel_ns == source
assert not card.aol_transport_feedback.failed
# SelfdriveD receives the same physical press; its actual LAT-only output
# and the native LAT-only request close the bounded withdrawal handshake.
cancel = car_state()
cancel.buttonEvents = [structs.CarState.ButtonEvent(type='cancel', pressed=True)]
axis, state, command = drive(4, observed_state=cancel, native_request_lat=True,
native_request_long=False, long_allowed=False, panda_controls_allowed=False)
assert not state.enabled and axis.lateralActive and command.latActive
assert not axis.longitudinalActive and not command.longActive
with patch.object(card, 'aol_cancel_source', return_value=0):
assert receive(source + 20_000_000, axis=sd.pm.messages['aolAxisState'],
event=sd.pm.messages['onroadEvents'], state=cs)
assert card.aol_card_intent.allowed_latch
assert card.aol_transport_feedback.longitudinal_cancel_ns == 0
finally:
case.doCleanups()
@pytest.mark.parametrize('identity', ('palisade_hdai', 'palisade_hdaii'))
@pytest.mark.parametrize('fault', ('none', 'wrong_bus', 'stale', 'unmarked', 'stock'))
def test_cancel_source_is_bound_to_real_mixed_parser_receipt(identity, fault):
from opendbc.can import CANPacker
from opendbc.car import Bus
from opendbc.car.hyundai.interface import CarInterface
from openpilot.selfdrive.car.card import Car
cp = profile(identity)
if fault == 'unmarked':
cp.alternativeExperience = 0
elif fault == 'stock':
cp.openpilotLongitudinalControl, cp.pcmCruise = False, True
cp.safetyConfigs[0].safetyParam &= ~4
ci = CarInterface(cp)
parser = ci.can_parsers[Bus.pt]
packer = CANPacker(parser.dbc.name)
for tick in range(15):
now = 2_000_000_000 + tick * 20_000_000
packet = packer.make_can_msg('CLU11', 2 if fault == 'wrong_bus' else parser.bus,
{'CF_Clu_CruiseSwState': 4 if tick == 14 else 0, 'CF_Clu_AliveCnt1': tick})
cs = ci.update([(now, [packet])])
if fault == 'wrong_bus':
cs.buttonEvents = [structs.CarState.ButtonEvent(type='cancel', pressed=True)]
received = now - 30_000_001 if fault == 'stale' else now
caller = SimpleNamespace(CI=ci, _aol_can_observation=(cs, received, now, now))
assert Car.aol_cancel_source(cast(Car, caller), cs, now) == (now if fault == 'none' else 0)
@@ -63,11 +63,18 @@ def test_native_eps_denial_retains_intent_without_renewing_permission(gas, withd
'door', 'sensors', 'disengage', 'reverse', 'acc_fault', 'native_invalid', 'native_fault', 'native_heartbeat',
'wrong_session', 'wrong_producer', 'wrong_epoch', 'expired', 'warning_stale', 'native_stale',
'native_missing', 'request_off', 'long_request', 'hard_event', 'other_user'))
def test_eps_retention_does_not_mask_independent_vetoes(fault):
@pytest.mark.parametrize('brake', (False, True))
def test_eps_retention_does_not_mask_independent_vetoes(fault, brake):
cp, state, feedback = baseline()
state.steerFaultTemporary = True
state.brakePressed = brake
now = NOW + 10_000_000
sm, native = sample(cp, now, now, 2, allowed=False)
if brake:
event = sm.messages['onroadEvents'].as_builder()
event.onroadEvents = [{'name': 'pcmDisable', 'userDisable': True},
{'name': 'pedalPressed', 'userDisable': True}]
sm.messages['onroadEvents'] = event.as_reader()
physical = {'no_eps': ('steerFaultTemporary', False), 'permanent_eps': ('steerFaultPermanent', True),
'invalid_can': ('canValid', False), 'timeout': ('canTimeout', True), 'door': ('doorOpen', True),
'sensors': ('vehicleSensorsInvalid', True), 'disengage': ('steeringDisengage', True), 'acc_fault': ('accFaulted', True)}
@@ -202,3 +209,28 @@ def test_continuous_warning_keeps_intent_but_gap_or_stale_source_cannot_renew_it
if stale_source:
change(sm, 'aolAxisState', lambda axis: setattr(axis, 'sourceCarStateMonoTime', NOW))
assert feedback.observe(sm, cp, state, now, latched=True, intent_producer_session=PRODUCER)
@pytest.mark.parametrize('axis_first', (False, True))
def test_brake_warning_event_change_preserves_existing_eps_intent(axis_first):
cp, state, feedback = baseline()
state.steerFaultTemporary = state.brakePressed = True
before = leases(feedback)
warning = NOW + 10_000_000
for sequence in range(2, 10):
now = NOW + (sequence - 1) * 10_000_000
event_stamp = warning if sequence < 6 else NOW + 50_000_000
axis_stamp = warning if sequence < 6 or sequence == 6 and not axis_first else NOW + 50_000_000
sm, native = sample(cp, now, now, sequence, active=False, requested=sequence == 2, allowed=False, event_stamp=event_stamp)
change(sm, 'aolAxisState', lambda axis, stamp=axis_stamp: setattr(axis, 'faultEventMonoTime', stamp))
event = sm.messages['onroadEvents'].as_builder()
event.onroadEvents = [{'name': 'pcmDisable', 'userDisable': True},
{'name': 'pedalPressed', 'userDisable': True},
{'name': 'steerTempUnavailableSilent', 'warning': True}]
if sequence == 6 and axis_first:
event.logMonoTime = warning
sm.logMonoTime['onroadEvents'] = warning
sm.messages['onroadEvents'] = event.as_reader()
assert not feedback.observe(sm, cp, state, now, latched=True, intent_producer_session=PRODUCER)
assert not feedback.failed and leases(feedback) == before
assert not native.lateralAllowed and not native.longitudinalAllowed
@@ -122,6 +122,19 @@ class TestHighlanderAol(unittest.TestCase):
self._assert_card_full_axis_transport(car, True, prepared_cp=cp,
filter_disabled=disabled, late_disable=late)
def test_continental_radar_metadata_keeps_exact_stock_owner(self):
for car in (CAR.TOYOTA_CHR_TSS2, CAR.TOYOTA_RAV4_TSS2_2022):
for alpha in (False, True):
with self.subTest(car=car, alpha=alpha):
cp = CarInterface.get_params(car, gen_empty_fingerprint(), [], alpha, False, False)
cp.flags |= int(ToyotaFlags.ARS510_RADAR)
expected = car == CAR.TOYOTA_RAV4_TSS2_2022 and not alpha
self.assertEqual(qualified(cp), expected)
cp.alternativeExperience = 32
self.assertEqual(qualified(cp, marked_only=True), expected)
cp.flags |= int(ToyotaFlags.ARS510_FUSED)
self.assertFalse(qualified(cp))
def test_radar_torque_factory_stock_and_alpha_transport(self):
from opendbc.car.toyota.carcontroller import CarController
from opendbc.car.toyota.carstate import CarState
@@ -9,7 +9,7 @@ from openpilot.cereal import log
from openpilot.selfdrive.car.car_events import CarEvents
from openpilot.starpilot.aol.tests.test_gm_cc_lateral_minimum import setup
from openpilot.starpilot.aol.tests.test_gm_parked_startup import _exercise_parked_main
from openpilot.starpilot.car.gm.aol import cc_independent_lateral_below_engage
from openpilot.starpilot.car.gm.aol import independent_lateral_below_engage
def params(f1):
@@ -24,9 +24,9 @@ def test_volt_cc_steering_minimum_keeps_cruise_minimum(f1):
_, intent = setup()
assert cp.minSteerSpeed == pytest.approx(7 * CV.MPH_TO_MS)
assert cp.minEnableSpeed == pytest.approx(24 * CV.MPH_TO_MS)
assert cc_independent_lateral_below_engage(cp, intent, enabled=False, active=False)
assert not cc_independent_lateral_below_engage(cp, replace(intent, allowedLatch=False), enabled=False, active=False)
assert not cc_independent_lateral_below_engage(cp, intent, enabled=True, active=True)
assert independent_lateral_below_engage(cp, intent, enabled=False, active=False)
assert not independent_lateral_below_engage(cp, replace(intent, allowedLatch=False), enabled=False, active=False)
assert not independent_lateral_below_engage(cp, intent, enabled=True, active=True)
@pytest.mark.parametrize('f1', [False, True])
+17 -8
View File
@@ -114,7 +114,7 @@ def stock_lateral_user_event(cp):
def stock_lateral_off(cp, cs, native, *, allow_independent_cancel=False, allow_independent_brake=False,
allow_independent_gas=False):
allow_independent_gas=False, allow_independent_main=False):
from openpilot.starpilot.nostalgia import physical_cancel
from opendbc.car.hyundai.canfd_angle_aol import qualified as angle_qualified
@@ -127,18 +127,20 @@ def stock_lateral_off(cp, cs, native, *, allow_independent_cancel=False, allow_i
stock_gas = bool(cp.brand == 'gm' and qualified_gm(cp) and cp.pcmCruise and not cp.openpilotLongitudinalControl or
cp.brand == 'subaru' and subaru_qualified(cp, marked_only=True))
from opendbc.car.hyundai.classic_scc_aol import qualified as classic_stock_qualified
classic_stock = classic_stock_qualified(cp, marked_only=True)
independent_angle = angle_qualified(cp, marked_only=True)
independent_brake = independent_angle or (allow_independent_brake and (
toyota_qualified(cp, marked_only=True) and not cs.cruiseState.enabled or
cp.brand == 'hyundai' and len(cp.safetyConfigs) == 1 and stock_canfd_qualified(cp, marked_only=True)))
return bool(cp.pcmCruise and cs.cruiseState.available and
cp.brand == 'hyundai' and len(cp.safetyConfigs) == 1 and (stock_canfd_qualified(cp, marked_only=True) or classic_stock)))
return bool(cp.pcmCruise and (cs.cruiseState.available or allow_independent_main and classic_stock) and
(not cs.cruiseState.enabled or independent_brake and allow_independent_brake and cs.brakePressed) and
cs.canValid and not cs.canTimeout and not cs.steerFaultPermanent and not cs.steerFaultTemporary and
not cs.accFaulted and not cs.doorOpen and not cs.seatbeltUnlatched and
(not cs.brakePressed or independent_brake and allow_independent_brake) and
not cs.regenBraking and (not cs.gasPressed or independent_angle or
allow_independent_gas and (stock_gas or toyota_qualified(cp, marked_only=True) or
cp.brand == 'hyundai' and stock_canfd_qualified(cp, marked_only=True))) and
cp.brand == 'hyundai' and (stock_canfd_qualified(cp, marked_only=True) or classic_stock))) and
(not physical_cancel(cs, cp) or independent_angle and allow_independent_cancel) and
native is not None and native.requestedLateral and native.lateralAllowed and not native.requestedLongitudinal)
@@ -362,6 +364,7 @@ class TransportPauseFeedback:
early_long = gm or cp.brand == 'hyundai' and early_native_long_withdrawal_supported(cp) or ev9 or ford_withdrawal or ford_long
from openpilot.starpilot.car.gm.aol import f1_conventional_lateral_owner, gm_physical_pedal_withdrawal
gm_pedal = gm_physical_pedal_withdrawal(cp, cs) and not f1_conventional_lateral_owner(cp)
classic_brake = classic_brake_withdrawal(cp, cs)
event_ns = int(sm.logMonoTime['onroadEvents'])
main_off_recovery = bool(0 < event_ns <= self.ccnc_main_off_source_ns <= now_ns and
now_ns - self.ccnc_main_off_source_ns <= AXIS_MAX_AGE_NS)
@@ -386,7 +389,7 @@ class TransportPauseFeedback:
now_ns - int(sm.logMonoTime['onroadEvents']) > 1_500_000_000 or
any(event.immediateDisable or event.softDisable or
event.userDisable and not (ev9_main_off and event.name == log.OnroadEvent.EventName.wrongCarMode or
(gm_pedal or classic_brake_withdrawal(cp, cs)) and event.name == log.OnroadEvent.EventName.pedalPressed or
(gm_pedal or classic_brake) and event.name == log.OnroadEvent.EventName.pedalPressed or
ford_withdrawal and event.name in (log.OnroadEvent.EventName.pedalPressed, log.OnroadEvent.EventName.pcmDisable,
log.OnroadEvent.EventName.buttonCancel))
for event in sm['onroadEvents'])):
@@ -394,13 +397,13 @@ class TransportPauseFeedback:
native = independent_lateral_health(sm, cp, cs, self.session, now_ns)
# Separate health may report brake withdrawal before the matching axis status.
# Retain only the existing LAT intent; LONG still follows its physical pedal.
ford_brake_health = bool(ford_withdrawal and cs.brakePressed and not cs.gasPressed and
brake_health = bool((ford_withdrawal or classic_brake) and cs.brakePressed and not cs.gasPressed and
native is not None and native.sourcePandaStatesMonoTime > 0 and
native.sourcePandaStatesMonoTime < int(sm.logMonoTime['pandaStates']) and
all(not slot.controlsAllowed for slot, config in zip(sm['pandaStates'], cp.safetyConfigs, strict=True)
if str(config.safetyModel) not in ('silent', 'noOutput')))
if (native is None or not native.pandaInventory or not native.requestedLongitudinal or
native.longitudinalAllowed and not ford_brake_health or
native.longitudinalAllowed and not brake_health or
native.observedMonoTime < self.lateral_native_ns or
(native.pandaSerial, tuple(slot.hardwareSerial for slot in native.pandaInventory)) != self.lateral_inventory):
return None
@@ -572,6 +575,11 @@ class TransportPauseFeedback:
not axis.desiredLongitudinal and not axis.longitudinalActive and
all(event.name in (stock_user, log.OnroadEvent.EventName.pedalPressed) and
not event.softDisable and not event.immediateDisable for event in users))
from openpilot.starpilot.car.subaru.aol import temporary_eps_brake_recovery as subaru_eps_brake_recovery
stock_temporary_user |= bool(eps_recovery and self.lateral_only_baseline and subaru_eps_brake_recovery(cp, cs) and users and
not axis.desiredLongitudinal and not axis.longitudinalActive and
all(event.name in (stock_user, log.OnroadEvent.EventName.pedalPressed) and
not event.softDisable and not event.immediateDisable for event in users))
from openpilot.starpilot.car.gm.aol import f1_conventional_lateral_owner, gm_physical_pedal_withdrawal
# Receipt-bound GM owns F1 physical withdrawal.
pedal = bool(not f1_conventional_lateral_owner(cp) and
@@ -805,11 +813,12 @@ class TransportPauseFeedback:
def _longitudinal_cancel_current(self, sm, cp, cs, axis, now_ns, *, latched, cancel_source_ns, withdrawal_required=True) -> SafetyState | None:
from opendbc.car.hyundai.canfd_stock_aol import qualified_long as qualified
from openpilot.starpilot.car.hyundai.aol import qualified_ccnc_long_aol as ev9_qualified
from opendbc.car.hyundai.blended_stock_aol import qualified_alpha as mixed_qualified
from opendbc.car.structs import car
if (not latched or self.failed or self.paused or self.pending_event or
not self.session or str(axis.sessionId) != self.session or
not (qualified(cp, marked_only=True) or ev9_qualified(cp, marked_only=True)) or
not (qualified(cp, marked_only=True) or ev9_qualified(cp, marked_only=True) or mixed_qualified(cp, marked_only=True)) or
not self.lateral_baseline_ns or not 0 <= now_ns - self.lateral_baseline_ns <= AXIS_MAX_AGE_NS or
str(axis.faultReason) != 'none' or not axis.nativeAcknowledged or
not axis.desiredLateral or not axis.lateralActive or cs.accFaulted or
+17 -5
View File
@@ -210,11 +210,23 @@ class FordBluePilotController(BluePilotLateral):
if self.starpilot_selected:
angle = cast("StarPilotFordController", self.angle)
angle.native_curvature_bounds = None
if self.native_pinion_model is not None and self.measured_seen and CS.out.vEgoRaw > 10.:
# Pinion native limits use 150 CAN curvature units, without learned geometry.
error = 150 * .00002
low = max(-max(self.measured_history) * .00002 - error, measured - CarControllerParams.CURVATURE_ERROR)
high = min(-min(self.measured_history) * .00002 + error, measured + CarControllerParams.CURVATURE_ERROR)
if self.measured_seen and CS.out.vEgoRaw > 10.:
if self.native_pinion_model is not None:
# Pinion native limits use 150 CAN curvature units, without learned geometry.
error = 150 * .00002
low = -max(self.measured_history) * .00002 - error
high = -min(self.measured_history) * .00002 + error
else:
# An old yaw extreme can expire before the next steering packet consumes its metadata.
history = list(self.measured_history)
sample_step = max((abs(b - a) for a, b in zip(history[:-1], history[1:], strict=True)), default=0)
# Native RX may advance one sample before the host receives the same batch.
error = max(0., CarControllerParams.CURVATURE_ERROR - (sample_step + 1) * .00002)
current_native = -self.measured_history[-1] * .00002
low = current_native - error
high = current_native + error
low = max(low, measured - CarControllerParams.CURVATURE_ERROR)
high = min(high, measured + CarControllerParams.CURVATURE_ERROR)
# Tighten the host envelope only where both measurements agree on a command.
if low <= high:
angle.native_curvature_bounds = (low, high)
@@ -57,6 +57,7 @@ _STALL_HOLD_S = 0.5 # accumulated clip-binding time before a pulse fire
_STALL_BLIP_FRAMES = 6 # mode-0 pulse length (6 frames @ 20 Hz = 300 ms)
_STALL_COOLDOWN_S = 2.0 # re-arm delay after a pulse (release ramp + PSCM response time)
_STALL_MAX_BLIPS = 3 # give up on a stuck episode; devLim telemetry keeps recording the stall
_STALL_PROGRESS_EPS = 0.00002 # one native CAN curvature unit
# The pulse releases steering for 300 ms; never fire it in a curve.
_BLIP_MAX_PATH_ANGLE = 0.10 # rad
@@ -138,6 +139,7 @@ class StarPilotFordController:
self.stall_blip_frames_left = 0 # remaining pulse frames; > 0 -> mode 0 on the wire
self.stall_blip_cooldown_s = 0.0 # re-arm delay after a pulse
self.stall_blip_count = 0 # pulses fired this stall episode
self.stall_measured_last: float | None = None
self.angle_stall_blip_active = False
def update_angle_params(self, params):
@@ -203,6 +205,7 @@ class StarPilotFordController:
self.stall_blip_frames_left = 0
self.stall_blip_cooldown_s = 0.0
self.stall_blip_count = 0
self.stall_measured_last = None
self.angle_stall_blip_active = False
self.shared.precision_type = 1
return LateralResult(
@@ -241,6 +244,7 @@ class StarPilotFordController:
self.stall_blip_frames_left = 0
self.stall_blip_cooldown_s = 0.0
self.stall_blip_count = 0
self.stall_measured_last = None
self.angle_stall_blip_active = False
self.shared.precision_type = 1
return LateralResult(
@@ -253,6 +257,17 @@ class StarPilotFordController:
lateralUncertainty=0.0,
)
progress = 0.0 if self.stall_measured_last is None else measured - self.stall_measured_last
steering_responding = (abs(progress) > _STALL_PROGRESS_EPS and
progress * (float(actuators.curvature) - measured) > 0.0)
self.stall_measured_last = measured
if steering_responding:
# A clipped tracking correction is not a stall while the car is responding.
self.stall_blip_hold_s = 0.0
if self.stall_blip_frames_left > 0 and not self.angle_stall_blip_active:
self.stall_blip_frames_left = 0
self.stall_blip_count = max(0, self.stall_blip_count - 1)
# Stall-blip pulse in progress: hold lateral inactive (mode 0, all-zero signals -- the same
# wire pattern as the human-turn override, no ford.h involvement) for _STALL_BLIP_FRAMES so the
# PSCM drops its post-override attenuation, then release; path_angle ramps back in from zero
@@ -439,7 +454,8 @@ class StarPilotFordController:
and abs(_stall_gap) > _STALL_GAP_MIN
and abs(desired_curvature) > abs(current_curvature))
if _stalled:
if self.shared.bp_curvature_deviation_limited and self.stall_blip_cooldown_s <= 0.0:
if (self.shared.bp_curvature_deviation_limited and self.stall_blip_cooldown_s <= 0.0 and
not steering_responding):
self.stall_blip_hold_s += _STEER_DT
if (self.stall_blip_hold_s >= _STALL_HOLD_S and self.stall_blip_count < _STALL_MAX_BLIPS
and abs(self.shared.path_angle_last) < _BLIP_MAX_PATH_ANGLE):
+3 -4
View File
@@ -28,11 +28,10 @@ def native_accepts_cp(cp, model: int, param: int) -> bool:
native_profile_supported(model, param))
def cc_independent_lateral_below_engage(cp, intent, *, enabled, active):
"""An armed conventional-cruise LAT request uses its steering speed gate."""
from opendbc.car.gm.values import is_bolt_cc_profile, is_volt_cc_profile, is_ordinary_cc_profile
def independent_lateral_below_engage(cp, intent, *, enabled, active):
"""An armed independent GM LAT request uses its steering speed gate."""
return bool(not enabled and not active and qualified_gm(cp) and aol_enabled(cp) and
(is_bolt_cc_profile(cp) or is_volt_cc_profile(cp) or is_ordinary_cc_profile(cp)) and
not cp.flags & GMFlags.PEDAL_LONG and
intent is not None and intent.settingsQualified and
intent.allowedLatch and intent.lateralArmed and not intent.pauseLateral)
+5 -3
View File
@@ -388,7 +388,8 @@ def physical_cancel_lateral_allowed(CP, CS, intent, native, *, settings, previou
not any(e.type == car.CarState.ButtonEvent.Type.cancel for e in CS.buttonEvents)):
return False
ev6_long = CP.carFingerprint == HyundaiCAR.KIA_EV6 and qualified_canfd_long(CP, marked_only=True)
return bool((ev6_long or independent_cruise_buttons_supported(CP, marked_only=True)) and
return bool((ev6_long or qualified_blended_alpha(CP, marked_only=True) or
independent_cruise_buttons_supported(CP, marked_only=True)) and
previous is not None and previous.lateral_active and previous.native_acknowledged and
intent is not None and intent.settingsQualified and intent.allowedLatch and intent.lateralArmed and
not intent.pauseLateral and intent.producerSessionId and intent.carStateLogMonoTime == source_ns and
@@ -427,9 +428,10 @@ def retained_shared_long_cancel_intent(CP, CS, sm, feedback, owner, *, cancel_so
from openpilot.starpilot.car.hyundai.ev6_intent import Ev6CardIntent
ev6_long = bool(isinstance(owner, Ev6CardIntent) and owner._buttons_current and
owner.physical.authorized and owner._last_latch_edge_ns > 0)
if (not (ev6_long or qualified_sonata_independent(CP) or qualified_ccnc_long_aol(CP, marked_only=True) or
mixed_long = qualified_blended_alpha(CP, marked_only=True)
if (not (ev6_long or mixed_long or qualified_sonata_independent(CP) or qualified_ccnc_long_aol(CP, marked_only=True) or
CP.carFingerprint in INDEPENDENT_LONG_BUTTON_CARS and qualified_canfd_long(CP, marked_only=True)) or
owner is None or not owner.explicit_latch or not (owner.independent_cruise_buttons or ev6_long) or
owner is None or not owner.explicit_latch or not (owner.independent_cruise_buttons or ev6_long or mixed_long) or
not owner.allowed_latch or owner.pause_lateral or owner.pause_longitudinal or owner._fault_inhibit):
return False
settings = owner.settings
@@ -1,4 +1,5 @@
from opendbc.car.hyundai.classic_long_aol import independent_buttons_qualified
from opendbc.car.hyundai.blended_stock_aol import qualified_alpha
from openpilot.starpilot.aol.intent import AolCardIntent
@@ -7,7 +8,8 @@ class ClassicLongCardIntent(AolCardIntent):
def __init__(self, cp, settings):
super().__init__(settings, explicit_latch=True, independent_cruise_buttons=(
independent_buttons_qualified(cp, marked_only=False)))
independent_buttons_qualified(cp, marked_only=False)),
preserve_unassigned_cancel=qualified_alpha(cp))
self.requires_fault_observation = True
self.observe_stock_engagement = True
+9
View File
@@ -97,3 +97,12 @@ def sng_standstill_lateral_inhibit(cp, state):
# All stock Subaru native profiles require movement. Withdraw the request before its expected denial,
# preserving ordinary stock ACC without granting either active axis.
return bool(qualified(cp, marked_only=True) and state.standstill)
def temporary_eps_brake_recovery(cp, state):
return bool(qualified(cp, marked_only=True) and state.canValid and not state.canTimeout and
not state.steerFaultPermanent and not state.accFaulted and not state.doorOpen and
not state.vehicleSensorsInvalid and not state.steeringDisengage and not state.buttonEvents and
state.cruiseState.available and not state.cruiseState.enabled and
state.gearShifter == structs.CarState.GearShifter.drive and
state.brakePressed and not state.regenBraking and not state.gasPressed)
@@ -25,7 +25,7 @@ from openpilot.starpilot.car.subaru.tests.native_fixture import subaru_native_pa
def test_actual_full_engage_brake_reverse_temporary_recovery_mainoff(candidate, subaru_native_pair, tmp_path, aol=True, manual=False, stop_resume=False,
hard_stop_native_first=False, main_only=False, preferences=None, firmware=None,
deadline_gap=False, fixed_angle_handoff=False, host_first_stop=False,
source_outage=False, vm_angle_handoff=0):
source_outage=False, vm_angle_handoff=0, crawl=False, eps_brake_join=False):
angle_handoff = fixed_angle_handoff or vm_angle_handoff
fw = firmware_records(candidate, FW_VERSIONS, 'subaru') if firmware is None else firmware
rows = []
@@ -68,10 +68,11 @@ def test_actual_full_engage_brake_reverse_temporary_recovery_mainoff(candidate,
gap_tick = 75 if main_only else 210
last_card_published = {}
pending_host_can = []
pending_eps_event = None
for elapsed, tick in enumerate(ticks):
now = base + elapsed * 10_000_000 + (22_504_479 if deadline_gap and elapsed >= gap_tick else 0)
cruise = not main_only and (30 <= tick < 110 or tick >= 410)
brake = 90 <= tick < 110
brake = 90 <= tick < 110 or eps_brake_join and 225 <= tick < 270
reverse = 150 <= tick < 185
temporary = 225 <= tick < 260
main = not 310 <= tick < 350 and (tick >= 30 or not main_only)
@@ -83,10 +84,11 @@ def test_actual_full_engage_brake_reverse_temporary_recovery_mainoff(candidate,
'Steering_Torque': {'Steer_Warning': int(temporary),
'Steer_Torque_Sensor': (250 if 70 <= tick < 76 else
80 if vm_angle_handoff and 76 <= tick < 90 else
143 if 76 <= tick < 90 else 0) if angle_handoff else 0},
143 if 76 <= tick < 90 else 0) if angle_handoff else 94 if eps_brake_join and 235 <= tick < 237 else 0},
'Steering_2': {'Steering_Angle': (vm_angle_handoff * (5.65 if tick < 78 else 5.60) if vm_angle_handoff else
(5.65 if tick < 78 else 5.61) if fixed_angle_handoff else 0)},
'Wheel_Speeds': dict.fromkeys(('FL', 'FR', 'RL', 'RR'), 0 if stopped else 98.2 if vm_angle_handoff else 121.5 if fixed_angle_handoff else 54),
'Wheel_Speeds': dict.fromkeys(('FL', 'FR', 'RL', 'RR'),
0 if stopped else 98.2 if vm_angle_handoff else 121.5 if fixed_angle_handoff else 7.2 if crawl else 54),
'Brake_Status': {'Brake': int(brake)},
'CruiseControl': {'Cruise_On': int(main), 'Cruise_Activated': int(cruise)},
'ES_Status': {'Cruise_Activated': int(cruise)},
@@ -120,7 +122,17 @@ def test_actual_full_engage_brake_reverse_temporary_recovery_mainoff(candidate,
peers.append(panda.as_reader())
if 'carControl' in latest_controls:
peers.append(latest_controls['carControl'])
peers.extend(driven_new[s] for s in ('onroadEvents', 'selfdriveState', 'aolAxisState') if s in driven_new and s in card.sm.data)
if pending_eps_event is not None:
peers.append(pending_eps_event)
pending_eps_event = None
for service in ('onroadEvents', 'selfdriveState', 'aolAxisState'):
if service not in driven_new or service not in card.sm.data:
continue
if eps_brake_join and service == 'onroadEvents' and tick == 236:
# Deliver the existing event one Card cycle after its axis update.
pending_eps_event = driven_new[service]
else:
peers.append(driven_new[service])
if receipt is not None and (tick % 10 == 1 or hard_stop_receipt and not host_first_stop or host_first_stop and elapsed == 127):
peers.append(receipt.as_reader())
card.sm.update_msgs((now + 1000) / 1e9, peers)
@@ -219,6 +231,9 @@ def test_actual_full_engage_brake_reverse_temporary_recovery_mainoff(candidate,
request = bool(decoder.vl[steering_name]['LKAS_Request'])
output = decoder.vl[steering_name]['LKAS_Output']
decoded_steering.append({'request': request, 'output': output, 'nativeAccepted': accepted})
if crawl and 95 <= tick < 105:
assert aol and main_only and cs.brakePressed and not command.enabled
assert request and accepted, ('CRAWL_AOL_BRAKE_WITHDREW', tick, decoded_steering)
if not stopped and (65 <= tick < 90 and not (source_outage and 75 <= tick < 84) or
aol and (first_resume or 205 <= tick < 220 or 280 <= tick < 300)):
if angle_handoff and 72 <= tick < 78:
@@ -429,3 +444,13 @@ def test_actual_held_gas_source_outage_withdraws_and_recovers_subaru(candidate,
def test_actual_vm_angle_driver_handoff_keeps_native_rate_limit(candidate, direction, subaru_native_pair, tmp_path):
test_actual_full_engage_brake_reverse_temporary_recovery_mainoff(
candidate, subaru_native_pair, tmp_path, vm_angle_handoff=direction)
def test_actual_crawl_lateral_only_tracks_small_targets(subaru_native_pair, tmp_path):
test_actual_full_engage_brake_reverse_temporary_recovery_mainoff(
CAR.SUBARU_CROSSTREK_2025, subaru_native_pair, tmp_path, main_only=True, crawl=True)
def test_actual_eps_brake_event_join_preserves_armed_intent(subaru_native_pair, tmp_path):
test_actual_full_engage_brake_reverse_temporary_recovery_mainoff(
CAR.SUBARU_IMPREZA_2020, subaru_native_pair, tmp_path, main_only=True, eps_brake_join=True)
@@ -21,7 +21,9 @@ class TeslaScreenCardIntent(AolCardIntent):
super().update(filtered.as_reader(), **kwargs)
engaged = bool(CS.cruiseState.enabled)
available = bool(CS.cruiseState.available)
if (engaged and not self.previous_enabled) or (self.previous_available and not available and not CS.brakePressed):
# Stock cruise availability does not revoke an explicit screen selection.
if (engaged and not self.previous_enabled) or (self.previous_available and not available and not CS.brakePressed and
self.screen_override is not True):
self.screen_override = None if engaged and not self.previous_enabled else False
self.brake_blocked = False
self.pause_lateral = False
@@ -181,3 +181,52 @@ def test_availability_off_still_allows_a_new_ordinary_cruise_engagement():
assert not owner.allowed_latch
owner.update(state(enabled=True).as_reader(), now_ns=50)
assert owner.allowed_latch and owner.screen_override is None
@pytest.mark.parametrize('brake_disengage', [False, True])
def test_stock_cruise_availability_cannot_erase_existing_screen_arm(brake_disengage):
owner = intent(brake_disengage)
owner.update(state(available=False, press=False).as_reader(), now_ns=10)
owner.update(state(available=False, press=True).as_reader(), now_ns=20)
owner.update(state(available=True, press=False).as_reader(), now_ns=30)
assert owner.screen_override is True
for index, available in enumerate((False, True, False, True)):
cs = state(available=available)
cs.gasPressed = bool(index % 2)
owner.update(cs.as_reader(), now_ns=40 + index)
assert owner.allowed_latch and owner.screen_override is True
owner.update(state(press=True).as_reader(), now_ns=50)
assert not owner.allowed_latch and owner.pause_lateral
owner.update(state(available=False, press=False).as_reader(), now_ns=60)
owner.update(state(available=True).as_reader(), now_ns=70)
assert not owner.allowed_latch
def test_cruise_baseline_availability_loss_does_not_create_screen_arm():
owner = intent()
owner.update(state().as_reader(), now_ns=10)
assert owner.allowed_latch and owner.screen_override is None
owner.update(state(available=False).as_reader(), now_ns=20)
assert not owner.allowed_latch and owner.screen_override is False
owner.update(state().as_reader(), now_ns=30)
assert not owner.allowed_latch
@pytest.mark.parametrize('fault', ['canTimeout', 'doorOpen', 'steerFaultPermanent', 'steeringDisengage', 'accFaulted', 'cancel', 'brake'])
def test_availability_loss_cannot_hide_fault_cancel_or_configured_brake(fault):
owner = intent(brake=fault == 'brake')
owner.update(state(available=False, press=False).as_reader(), now_ns=10)
owner.update(state(available=False, press=True).as_reader(), now_ns=20)
owner.update(state(press=False).as_reader(), now_ns=30)
cs = state(available=False)
if fault == 'cancel':
cs.buttonEvents = [structs.CarState.ButtonEvent(type=ButtonType.cancel, pressed=True)]
elif fault == 'brake':
cs.brakePressed = True
else:
setattr(cs, fault, True)
owner.update(cs.as_reader(), now_ns=40)
assert not owner.allowed_latch
for index, available in enumerate((False, True, False, True)):
owner.update(state(available=available).as_reader(), now_ns=50 + index)
assert not owner.allowed_latch
@@ -0,0 +1,101 @@
"""Independent screen arming through real parser, intent, native and axis callers."""
from types import SimpleNamespace
from typing import cast
import pytest
from opendbc.can import CANPacker
from opendbc.car.tesla.interface import CarInterface
from opendbc.car.tesla.screen_button import apply_screen_button
from opendbc.car.tesla.tests.test_screen_button import screen_params
from opendbc.safety.tests.libsafety import libsafety_py
from openpilot.cereal import log
from openpilot.selfdrive.selfdrived.events import Events
from openpilot.selfdrive.selfdrived.selfdrived import SelfdriveD
from openpilot.starpilot.aol.intent import AolSettings
from openpilot.starpilot.aol.runtime import AxisDecision
from openpilot.starpilot.aol.wire import IntentState, PandaSlot, SafetyState
from openpilot.starpilot.car.tesla.screen_intent import TeslaScreenCardIntent
class HealthyPeers(dict):
def all_checks(self, services):
return all(service in self for service in services)
@pytest.mark.parametrize('cooperative', [False, True])
@pytest.mark.parametrize('brake_disengage', [False, True])
def test_explicit_screen_axis_survives_sparse_touch_and_stock_availability(cooperative, brake_disengage):
cp = screen_params()
apply_screen_button(cp, True, brake_disengage)
cp.safetyConfigs[0].safetyParam |= 256 * cooperative
cp.alternativeExperience = 32
ci = CarInterface(cp)
owner = TeslaScreenCardIntent(cp, AolSettings(True, 0., 0, 0, (0, 0, 0), (0, 0, 0)))
packer = CANPacker('tesla_model3_party')
peers = HealthyPeers(modelV2=SimpleNamespace(),
extrinsicsCalibration=SimpleNamespace(calStatus=log.ExtrinsicsCalibration.Status.calibrated),
driverMonitoringState=SimpleNamespace(lockout=False, alwaysOnLockout=False, alertLevel=0))
sd = SimpleNamespace(CP=cp, active=False, enabled=False, initialized=True, sm=peers, aol_settings=owner.settings,
aol_axis_decision=AxisDecision(), aol_car_state_log_ns=0, aol_session_id='screen-test',
aol_dm_lateral_inhibit=False, nostalgia_paddle_cancel=False, aol_cruise_main_required=False,
events=Events())
native = libsafety_py.libsafety
native.init_tests()
native.set_alternative_experience(32)
native.set_safety_hooks(10, cp.safetyConfigs[0].safetyParam)
native.set_aol_test_heartbeat(True)
try:
for tick in range(250):
now = 1_000_000_000 + tick * 10_000_000
native.set_timer(now // 1000)
stock_state = 1 if 40 <= tick < 80 or 140 <= tick < 180 else 0
messages: tuple[tuple[str, int, dict[str, float]], ...] = (
('DAS_control', 2, {}), ('DAS_steeringControl', 2, {}),
('SCCM_steeringAngleSensor', 2, {}), ('DAS_status', 2, {}), ('DAS_settings', 2, {}),
('DI_speed', 0, {'DI_vehicleSpeed': 0}),
('ESP_B', 0, {'ESP_vehicleSpeed': 0, 'ESP_wheelSpeedsQF': 1, 'ESP_vehicleStandstillSts': 1}),
('EPAS3S_sysStatus', 0, {'EPAS3S_internalSAS': 0, 'EPAS3S_eacStatus': 1,
'EPAS3S_handsOnLevel': 3 if tick >= 240 else 0, 'EPAS3S_torsionBarTorque': 0}),
('DI_systemStatus', 0, {'DI_gear': 4, 'DI_accelPedalPos': 20 if tick % 30 < 15 else 0}),
('ESP_status', 0, {'ESP_driverBrakeApply': 2 if not brake_disengage and 100 <= tick < 120 else 1}),
('UI_warning', 0, {'buckleStatus': 1}),
('DI_state', 0, {'DI_cruiseState': stock_state}),
)
frames = [packer.make_can_msg(name, bus, values) for name, bus, values in messages]
if tick in (20, 21):
touch = bytearray(8)
touch[3] = 3 if tick == 21 else 0
frames.append((0x3DF, bytes(touch), 1))
for address, data, bus in frames:
assert native.safety_rx_hook(libsafety_py.make_CANPacket(address, bus, data))
native.safety_tick()
cs = ci.update([(now, frames)]).as_reader()
if tick > 1:
assert cs.canValid and not cs.canTimeout
owner.update(cs, now_ns=now)
allowed, pause, long_pause = owner.output(cs)
intention = IntentState('screen-card', tick + 1, now, now, now + 30_000_000, allowed, pause, long_pause, True, allowed)
permission = native.aol_get_permission_mask()
request = native.aol_get_request_mask()
safety = SafetyState(1, True, now, now + 200_000_000, 10, cp.safetyConfigs[0].safetyParam,
bool(permission & 1), bool(permission & 2), bool(request & 1), bool(request & 2),
'test-panda', 'screen-test', now,
(PandaSlot(0, 'test-panda', 10, cp.safetyConfigs[0].safetyParam, 32, bool(native.get_controls_allowed())),))
sd.aol_car_state_log_ns = now
sd.events = Events()
sd.events.add(log.OnroadEvent.EventName.pcmDisable)
if not cs.cruiseState.available:
sd.events.add(log.OnroadEvent.EventName.wrongCarMode)
decision = SelfdriveD._decide_aol_axes(cast(SelfdriveD, sd), cs, intention, safety, now_ns=now)
sd.aol_axis_decision = decision
native.aol_set_host_request(int(decision.desired_lateral))
assert not decision.longitudinal_active
if 22 <= tick < 240:
assert decision.lateral_active
elif tick >= 240:
assert not decision.lateral_active
finally:
native.init_tests()
native.set_alternative_experience(0)
native.set_safety_hooks(0, 0)
+2
View File
@@ -180,6 +180,8 @@ def qualified(cp, *, marked_only=False):
if hold:
return False
allowed_flags = required_flags | ToyotaFlags.HYBRID | ToyotaFlags.HAS_BSM
if cp.carFingerprint == CAR.TOYOTA_RAV4_TSS2_2022 and not cp.openpilotLongitudinalControl:
allowed_flags |= ToyotaFlags.ARS510_RADAR
if cp.flags & required_flags != required_flags or int(cp.flags) & ~int(allowed_flags | ToyotaFlags.AUTO_BRAKE_HOLD):
return False
config = cp.safetyConfigs[0]
@@ -16,7 +16,8 @@ def test_2022_filter_factory_preserves_current_tune(alpha):
cp = factory(alpha)
plain = CarInterface.get_params(CAR.TOYOTA_RAV4_TSS2_2022, gen_empty_fingerprint(), [], alpha, False, False)
expected = plain.to_dict()
expected['flags'] = (expected['flags'] & ~int(ToyotaFlags.DISABLE_RADAR)) | int(ToyotaFlags.RADAR_CAN_FILTER)
expected['flags'] = (expected['flags'] & ~int(ToyotaFlags.DISABLE_RADAR)) | int(ToyotaFlags.RADAR_CAN_FILTER | ToyotaFlags.ARS510_RADAR)
expected['radarUnavailable'] = False
expected['safetyConfigs'][0]['safetyParam'] = 0x1049 if alpha else 0x1249
assert cp.to_dict() == expected
assert radar_filter_qualified(cp)
+12 -1
View File
@@ -20,10 +20,21 @@ def lateral_output_inhibited(cp, state):
def temporary_restriction(cp, state):
gears = structs.CarState.GearShifter
return bool(state is not None and qualified(cp, marked_only=True) and state.canValid and not state.canTimeout and
not state.steerFaultPermanent and (state.steerFaultTemporary or (state.standstill and not cp.steerAtStandstill) or
not state.steerFaultPermanent and (state.steerFaultTemporary or lateral_output_inhibited(cp, state) or
state.gearShifter not in (gears.drive, gears.sport, gears.eco, gears.manumatic)))
def below_engage_lateral_allowed(cp, state, intent):
"""A manual cruise minimum does not disarm already armed lateral intent."""
return bool(long_qualified(cp, marked_only=True) and
cp.transmissionType == structs.CarParams.TransmissionType.manual and
state is not None and state.canValid and not state.canTimeout and
not state.steerFaultPermanent and not state.steerFaultTemporary and state.cruiseState.available and
state.gearShifter in (structs.CarState.GearShifter.drive, structs.CarState.GearShifter.sport,
structs.CarState.GearShifter.eco, structs.CarState.GearShifter.manumatic) and
intent is not None and intent.allowedLatch and not intent.pauseLateral)
def policy_for(cp):
if not qualified(cp):
return AolVehiclePolicy()
@@ -0,0 +1,11 @@
"""Dom's rolling stopped-lead guard for admitted automatic MQB Alpha Long."""
from opendbc.car import structs
from opendbc.car.volkswagen.long_aol import qualified
from openpilot.starpilot.car.ford.stopped_lead import RollingStoppedLead
def owner_for(cp, stop_distance):
if (qualified(cp, marked_only=True) and cp.transmissionType in
(structs.CarParams.TransmissionType.automatic, structs.CarParams.TransmissionType.direct)):
return RollingStoppedLead(stop_distance)
return None
+20 -10
View File
@@ -8,6 +8,9 @@ from openpilot.starpilot.flm.log_decode import decode_full_rlog_segment
from openpilot.starpilot.galaxy.drive_analysis import _closed_source
TRAINING_FRACTIONS = (1.0, 0.8, 0.6, 0.4, 0.2, 0.1, 0.05)
def qualify(assessment, group, cohort, values, root, *, permitted, require_change=True):
roles = cohort.get('qualification_roles', cohort)
ids = tuple(roles['training']['windows']) + tuple(roles['heldout']['windows'])
@@ -36,15 +39,12 @@ def fit_training_step(assessment, group, cohort, proposal, previous, root, *, pe
if set(previous) != set(proposal['values']):
raise ValueError('Exact original full-map prior required')
attempts = []
fractions = (1.0, 0.8, 0.6, 0.4, 0.2)
if proposal.get('policy') == 'original_stable_recommended_bounded_route_v1':
fractions += (0.1, 0.05)
for fraction in fractions:
for fraction in TRAINING_FRACTIONS:
values = (
dict(proposal['values']) if fraction == 1 else {name: previous[name] + fraction * (value - previous[name]) for name, value in proposal['values'].items()}
)
checked = _original_results(assessment, original, train, values, root, permitted=permitted)
feasible = len(checked) == len(train) and all(row['qualified'] for row in checked)
feasible = len(checked) == len(train) and all(row['qualified'] for row in checked) and bool(_changed_windows(checked))
attempts.append({'fraction': fraction, 'feasible': feasible, 'original_training_windows': checked})
if feasible:
fitted = dict(proposal, values=values, delta=proposal['delta'] * fraction)
@@ -104,13 +104,17 @@ def fit_independent_route(assessment, group, cohort, prior, proposal, previous,
stable = proposal.get('policy') == 'original_stable_recommended_bounded_route_v1'
if stable and (scope is None or not 3 <= len(prior.profile_windows) <= 12):
raise ValueError('Independent original Stable profile inputs and current scope required')
fractions = (1.0, 0.8, 0.6, 0.4, 0.2) + ((0.1, 0.05) if stable else ())
for fraction in fractions:
for fraction in TRAINING_FRACTIONS:
values = (
dict(proposal['values']) if fraction == 1 else {name: previous[name] + fraction * (value - previous[name]) for name, value in proposal['values'].items()}
)
checked = _original_results(assessment, original, train, values, root, permitted=permitted)
feasible = len(checked) == len(train) and {r['window'] for r in checked} == set(train) and all(r['qualified'] for r in checked)
feasible = (
len(checked) == len(train)
and {r['window'] for r in checked} == set(train)
and all(r['qualified'] for r in checked)
and bool(_changed_windows(checked))
)
attempts.append({'fraction': fraction, 'feasible': feasible, 'original_training_windows': checked})
if not feasible:
continue
@@ -125,6 +129,8 @@ def fit_independent_route(assessment, group, cohort, prior, proposal, previous,
selector = StableRouteSelector(prior, root, previous, scope, permitted=permitted)
replicated, profile_proof = selector.profile(tuple(w['identity'] for w in prior.profile_windows), cohort['cohort'])
if proposal.get('coordinate_policy') == 'symptom_bound_all30_v1':
replicated = next((p for p in profile_proof.get('coordinate_candidates', []) if p['parameter'] == proposal['parameter']), None)
supported = independent_profile_support({'values': values}, replicated, previous)
proof['independent_tracking'] = {'qualified': supported, 'profile': profile_proof, 'replication': replicated}
else:
@@ -178,8 +184,8 @@ def fit_independent_route(assessment, group, cohort, prior, proposal, previous,
return None, proof, None
def _changed_in_each(results, roles):
changed = {
def _changed_windows(results):
return {
r['window']
for r in results
if r.get('numerical', {}).get('qualified')
@@ -188,6 +194,10 @@ def _changed_in_each(results, roles):
and r['registered_encoder'].get('qualified')
and r['registered_encoder'].get('candidate_changed_emissions', 0) > 0
}
def _changed_in_each(results, roles):
changed = _changed_windows(results)
return all(changed.intersection(roles[role]['windows']) for role in ('training', 'heldout'))
@@ -278,16 +278,17 @@ def check_original_native(cp, controls, outputs, commands, *, require_change=Tru
for output in originals.values():
history.verify(by_control[output.control_ns], output)
before = history.rollout(baseline, start, end)
after = history.rollout(candidate, start, end)
if set(before) != set(originals) or any(before[stamp] != delivered(originals[stamp]) for stamp in before):
return {'qualified': False, 'reason': 'original_encoder_reconstruction_mismatch'}
after = history.rollout(candidate, start, end, allow_unclipped_equal_output=True)
except (AttributeError, KeyError, TypeError, ValueError, OverflowError) as error:
return {'qualified': False, 'reason': 'original_encoder_or_candidate_quality_refused', 'detail': str(error)[:160]}
if set(before) != set(originals) or any(before[stamp] != delivered(originals[stamp]) for stamp in before):
return {'qualified': False, 'reason': 'original_encoder_reconstruction_mismatch'}
changed = sum(before[k] != after[k] for k in before)
return {
'qualified': bool(changed or not require_change),
'reason': 'registered_encoder_trial_only' if require_change else 'original_registered_encoder_observed',
'original_decoded_emissions': len(originals),
'limiter_policy': 'same_state_equal_output_unclipped_v1',
'candidate_changed_emissions': changed,
'physical_improvement_proven': False,
'hardware_current_permission': False,
@@ -0,0 +1,122 @@
"""Symptom-bound candidates in the controller's existing 30-coordinate units."""
import math
from statistics import median
from openpilot.starpilot.flm.canonical_surface_adapter import NAMES, DEADBANDS, RAW_DOMAINS, WINDOWS
from openpilot.starpilot.flm.offroad_store import MAX_ROUTE_STEP
from openpilot.starpilot.flm.offroad_surface import route_bounds, route_values
def recorded_observation(bucket, direction, peak_score, speed_mps):
"""Put the pinned classifier's unlike score units on one severity scale.
Turn/tracking scores are acceleration errors; chatter already uses ratios.
These are the existing classifier thresholds, not new event acceptance
limits. Classification, independent support and native checks are unchanged.
"""
if (
type(peak_score) not in (int, float) or not math.isfinite(peak_score) or peak_score < 0
or type(speed_mps) not in (int, float) or not math.isfinite(speed_mps) or speed_mps < 0
):
raise ValueError('Actual finite classifier score and speed required')
scale = {
'understeer': 0.20, 'oversteer': 0.20,
'late_turn_in': 0.16, 'early_turn_in': 0.16,
'unwind_too_slow': 0.14, 'unwind_too_fast': 0.14,
'low_speed_unwillingness': 0.18,
}.get(bucket, 1.0)
return {'bucket': bucket, 'direction': direction, 'severity': peak_score / scale, 'speed_mps': speed_mps,
'raw_peak_score': peak_score, 'score_scale': scale, 'severity_units': 'classifier_threshold_multiple'}
def _symptom_steps(summary, previous):
bucket, side = summary['bucket'], summary['direction']
speed = summary.get('speed_mps')
severity = summary['severity']
if (
type(speed) not in (int, float) or not math.isfinite(speed) or speed < 0
or type(severity) not in (int, float) or not math.isfinite(severity) or severity <= 0
):
return {}
strength = 0.005 * min(severity, 1.2)
steps = {}
if bucket == 'center_chatter':
steps['highway_center_taper_max' if speed >= 25 else 'center_taper_max'] = strength
knots = (0, 5, 10, 15, 25)
for index, name in enumerate(DEADBANDS):
if (index == 0 and speed < knots[1] or index == 4 and speed > knots[3]
or 0 < index < 4 and knots[index - 1] < speed < knots[index + 1]):
steps[name] = strength
return steps
if side not in ('left', 'right'):
return {}
if bucket in ('understeer', 'oversteer'):
steps['ff_gain_' + side] = strength * (1 if bucket == 'understeer' else -1)
if bucket in ('late_turn_in', 'early_turn_in', 'low_speed_unwillingness'):
increase = 1 if bucket != 'early_turn_in' else -1
steps['turn_in_threshold_reduction_' + side] = strength * increase
steps['turn_in_boost_' + side] = strength * increase
if speed < 6:
steps['crawl_turn_in_ff_boost_' + side] = strength * increase
if bucket == 'low_speed_unwillingness':
steps['low_speed_angle_assist_max_torque'] = strength
steps['curvy_turn_in_trim_' + side] = -strength * increase
if previous['curvy_turn_in_trim_' + side] > 0:
_window_steps(steps, previous, 'curvy_turn_in_trim_speed', speed, -increase * strength)
if bucket in ('unwind_too_slow', 'unwind_too_fast'):
increase = 1 if bucket == 'unwind_too_slow' else -1
if speed >= 23.2:
steps['unwind_threshold_increase_' + side] = strength * increase
for prefix in ('unwind_taper', 'curvy_unwind_floor_relief', 'curvy_unwind_extra_reduction'):
steps[prefix + '_' + side] = strength * increase
if previous['curvy_unwind_floor_relief_' + side] > 0 or previous['curvy_unwind_extra_reduction_' + side] > 0:
_window_steps(steps, previous, 'curvy_speed', speed, increase * strength)
return steps
def _window_steps(steps, previous, prefix, speed, expansion):
originals = {name: value for name, value, _ in RAW_DOMAINS}
edges = {edge: originals[prefix + '_' + edge] * (1 + previous[prefix + '_' + edge]) for edge in ('min', 'max')}
edge = min(edges, key=lambda key: abs(speed - edges[key]))
if abs(speed - edges[edge]) <= 3:
steps[prefix + '_' + edge] = expansion * (-1 if edge == 'min' else 1)
def coordinate_candidates(observations, previous):
"""Freeze one-coordinate alternatives from at least three original windows.
Deadbands/assist remain physical-normalized coordinates, and speed-window
steps remain relative coordinates. Only the actual replay hooks convert them
to vehicle degrees/torque/m/s and enforce the unchanged original hard limits.
"""
previous = route_values(previous)
votes = {}
for window, summary in observations:
for name, step in _symptom_steps(summary, previous).items():
votes.setdefault(name, {}).setdefault(window, []).append(step)
candidates = []
for name in NAMES:
windows = votes.get(name, {})
all_steps = [step for steps in windows.values() for step in steps]
if len(windows) < 3 or not all_steps or min(all_steps) * max(all_steps) <= 0:
continue
step = median(median(steps) for steps in windows.values())
low, high = route_bounds(name)
target = round(max(low, min(high, previous[name] + max(-MAX_ROUTE_STEP, min(MAX_ROUTE_STEP, step)))), 12)
if target == previous[name]:
continue
changed = dict(previous, **{name: target})
candidates.append({
'values': changed,
'parameter': 'stable:' + name,
'delta': target - previous[name],
'policy': 'original_stable_recommended_bounded_route_v1',
'coordinate_policy': 'symptom_bound_all30_v1',
'original_windows': sorted(windows),
'coordinate_units': (
'relative_speed_window' if name in WINDOWS else
'physical_normalized' if name in DEADBANDS or name == 'low_speed_angle_assist_max_torque' else 'amplitude'
),
})
return candidates
@@ -8,6 +8,7 @@ atomic source-bound save and next-drive comparison/rollback.
import hashlib
import math
from pathlib import Path
from statistics import fmean
from openpilot.starpilot.flm.offroad_stable_policy import (
FLMSample,
@@ -52,7 +53,7 @@ class StableRouteSelector:
if type(assessment) is CompletedAssessment:
groups = []
for group in assessment.stable_groups:
for group in (*assessment.stable_groups, *assessment.groups):
ids = {
wid for c in group['cohorts'] if all(role in c for role in ('training', 'heldout')) for role in ('training', 'heldout') for wid in c[role]['windows']
}
@@ -217,12 +218,20 @@ class StableRouteSelector:
current = {'FLMActiveOverrides': {}}
selected = []
unavailable = []
observations = []
from openpilot.starpilot.flm.offroad_coordinate_policy import coordinate_candidates, recorded_observation
for key in windows:
values = rows[key]
assert values is not None
for run in cadenced_runs(values):
summaries, _ = classify_current_stable(list(run))
for summary in summaries:
for event in summary['events']:
event_samples = run[event['startIdx']:event['endIdx'] + 1]
observations.append((key, recorded_observation(
summary['bucket'], event['direction'], event['peakScore'], fmean(sample.v_ego for sample in event_samples),
)))
# Capability-based exclusions affect proposals only. No scored
# window is discarded; ALL original spans go to the native checker.
if summary['bucket'] == 'saturation_limited':
@@ -243,6 +252,7 @@ class StableRouteSelector:
if recommended is not None:
recommended['frictionCurveUnits'] = 'relative_actual_selected_threshold'
report = {'original_windows': windows, 'supported_dimensions': len(selected), 'unavailable_dimensions': unavailable, 'profile': recommended}
report['coordinate_candidates'] = coordinate_candidates(observations, self.previous)
if recommended is None:
mapped = None
else:
@@ -268,20 +278,46 @@ class StableRouteSelector:
all_ids = ids['training'] + ids['heldout']
if any(key not in rows or rows[key] is None for key in all_ids):
return None, {'reason': 'original_stable_input_fields_missing', 'original_windows': all_ids}
mapped, reports = {}, {}
for role, windows in ids.items():
mapped[role], reports[role] = self.profile(windows, cohort['cohort'])
training, heldout = mapped['training'], mapped['heldout']
training, training_report = self.profile(ids['training'], cohort['cohort'])
selection = 'training_values_only_no_heldout_resizing'
if training is not None:
from openpilot.starpilot.flm.offroad_surface import CURVE_NAMES
if any(training['values'][name] != self.previous[name] for name in CURVE_NAMES):
# Quieting is one trial; unrelated turn changes need their own evidence.
values = dict(self.previous)
for name in CURVE_NAMES:
if training['values'][name] != self.previous[name]:
values[name] = round(self.previous[name] + 0.6 * (training['values'][name] - self.previous[name]), 12)
training = dict(
training,
values=values,
changed_parameters=tuple(name for name in CURVE_NAMES if values[name] != self.previous[name]),
maximum_coordinate_step=max(abs(values[name] - self.previous[name]) for name in CURVE_NAMES),
bounded_profile_scale=training['bounded_profile_scale'] * 0.6,
)
selection = 'training_only_conservative_friction_trial_before_heldout'
# These alternatives are frozen before held-out summaries are read.
coordinate_training = training_report.get('coordinate_candidates', [])
heldout, heldout_report = self.profile(ids['heldout'], cohort['cohort'])
independent = {p['parameter']: p for p in heldout_report.get('coordinate_candidates', [])}
coordinate_supported = [p for p in coordinate_training if independent_profile_support(p, independent.get(p['parameter']), self.previous)]
# A one-coordinate recipe plus the recorded prior is exact; avoid repeating
# thirty complete maps in both role reports and exhausting the cache bound.
for report in (training_report, heldout_report):
report['coordinate_candidates'] = [{k: v for k, v in p.items() if k != 'values'} for p in report.get('coordinate_candidates', [])]
agrees = independent_profile_support(training, heldout, self.previous)
proof = {
'source_policy_commit': ORIGINAL_COMMIT,
'source_classifier_commit': CURRENT_CLASSIFIER_SOURCE,
'policy': 'original_stable_recommended_bounded_route_v1',
'reason': 'independent_support_for_frozen_training_profile' if agrees else 'heldout_profile_does_not_support_training_step',
'selection': 'training_values_only_no_heldout_resizing',
'roles': reports,
'selection': selection,
'frozen_training_profile': training,
'roles': {'training': training_report, 'heldout': heldout_report},
'all_original_windows_still_required': True,
'physical_improvement_proven': False,
'coordinate_candidates': coordinate_supported,
}
if not agrees:
return None, proof
+75 -9
View File
@@ -249,6 +249,15 @@ def input_coverage(cohort):
return cohort['whole_windows'] * min(references) ** 2
def _input_spans(group, cohort, ids):
name = 'qualification_original_windows' if 'qualification_roles' in cohort else 'selected_original_windows'
return {
(row['segment'], row['start_ns'], row['end_ns'])
for row in group.get(name, ())
if row['identity'] in ids and all(key in row for key in ('segment', 'start_ns', 'end_ns'))
}
class OffroadWorker:
def __init__(
self,
@@ -448,7 +457,18 @@ class OffroadWorker:
choices.append((group, cohort, proposal))
# Freeze all proposal sources before any controller/native fit. One
# marginal tracking proposal must not hide the Recommended profile.
if type(assessment) is CompletedAssessment and assessment.stable_groups and self.checker is qualify and scope.controller_domain == 'torque':
short_groups = tuple(
group for group in assessment.groups
if any(
c['cohort'].rsplit(':', 1)[-1] in ('steady', 'turn_in', 'unwind')
and all(3 <= len(c.get(role, {}).get('windows', ())) <= 12 for role in ('training', 'heldout'))
for c in group['cohorts']
)
) if type(assessment) is CompletedAssessment else ()
if (
type(assessment) is CompletedAssessment and (assessment.stable_groups or short_groups)
and self.checker is qualify and scope.controller_domain == 'torque'
):
from openpilot.starpilot.flm.offroad_stable_bridge import StableRouteSelector
selector = StableRouteSelector(assessment, self.root, current, scope, permitted=self.permitted)
@@ -470,11 +490,37 @@ class OffroadWorker:
report['stable_profile_reports'].append({'context': group['original_context_sha256'], 'cohort': cohort['cohort'], **proof})
if proposal is not None:
choices.append((group, {**cohort, 'stable_profile_proof': proof}, proposal))
# Reference input energy, followed by fixed coverage/cohort/context ties. A failed
# original window does not cause a search for another favorable candidate.
for alternative in proof.get('coordinate_candidates', []):
if proposal is None or alternative['values'] != proposal['values']:
choices.append((group, {**cohort, 'stable_profile_proof': proof}, alternative))
# Turn entry/exit need the existing short controller windows, not a
# six-second steady interval. Keep their original partitions and all
# native checks; legacy quieting profiles still use stable windows.
report['short_turn_profile_reports'] = []
for group in short_groups:
metadata = group['original_metadata']
if _recorded_values(metadata) != current or metadata['controller_mode'] != scope.controller_mode:
continue
contract = recorded_source_contract(assessment.cp, metadata, scope, self.params)
if contract is None:
continue
for cohort in group['cohorts']:
if cohort['cohort'].rsplit(':', 1)[-1] not in ('steady', 'turn_in', 'unwind'):
continue
if not all(role in cohort for role in ('training', 'heldout')):
continue
roles = cohort.get('qualification_roles', cohort)
if any(not 3 <= len(roles[role]['windows']) <= 12 for role in ('training', 'heldout')):
continue
_, proof = selector.propose(group, cohort)
report['short_turn_profile_reports'].append({'context': group['original_context_sha256'], 'cohort': cohort['cohort'], **proof})
for alternative in proof.get('coordinate_candidates', []):
choices.append((group, {**cohort, 'stable_profile_proof': proof}, alternative))
# Freeze input-only ranking; training may select a candidate, validation may not.
choices.sort(key=lambda v: (-input_coverage(v[1]), -v[1]['whole_windows'], v[1]['cohort'], v[0]['original_context_sha256']))
if choices:
group, cohort, proposal = choices[0]
training_seen = set()
training_spans = set()
for group, cohort, proposal in choices:
selected_profile_windows = cohort['whole_windows']
offered = {'values': proposal['values'], 'parameter': proposal['parameter'], 'delta': proposal['delta']}
blocked = False
@@ -483,6 +529,7 @@ class OffroadWorker:
blocked = trial['parameter'] == proposal['parameter'] and trial['delta'] == proposal['delta']
if blocked:
reason = 'same_rejected_change_requires_different_evidence_or_reset'
continue
else:
feasible = True
independent_checks = None
@@ -492,13 +539,23 @@ class OffroadWorker:
independent = None
stable = proposal.get('policy') == 'original_stable_recommended_bounded_route_v1'
provider = self.stable_prior_provider if stable else self.prior_provider
if provider is None and self.assessor is analyze_latest:
from openpilot.starpilot.flm.offroad_prior_inputs import prior_originals, prior_stable_originals
provider = prior_stable_originals if stable else prior_originals
independent = None if provider is None else provider(assessment, group, cohort, self.root, permitted=self.permitted)
if independent is not None and row is not None and independent.receipt.source_sha256 in row['route_sha256s']:
independent = None
roles = cohort.get('qualification_roles', cohort)
training = tuple(roles['training']['windows'])
validation = tuple(roles['heldout']['windows'])
if independent is not None:
training += validation
validation = tuple(w['identity'] for w in independent.windows)
validation_spans = _input_spans(group, cohort, validation) if independent is None else ()
if training_seen.intersection(validation) or any(
a[0] == b[0] and max(a[1], b[1]) <= min(a[2], b[2]) for a in training_spans for b in validation_spans
):
reason = 'candidate_validation_already_used_for_training'
continue
training_seen.update(training)
training_spans.update(_input_spans(group, cohort, training))
if independent is None:
fitted, fit_proof = fit_training_step(assessment, group, cohort, proposal, current, self.root, permitted=self.permitted)
else:
@@ -508,6 +565,11 @@ class OffroadWorker:
assessment, group, cohort, independent, proposal, current, self.root, permitted=self.permitted, scope=scope
)
report['native_training_feasibility'] = fit_proof
report.setdefault('native_candidate_training', []).append(
{'parameter': proposal['parameter'], 'cohort': cohort['cohort'], 'feasibility': fit_proof}
)
if fitted is None and (independent_checks is not None or fit_proof.get('validation_evaluations', 0)):
raise ValueError('A validation result cannot select another candidate')
feasible = fitted is not None
if fitted is not None:
proposal = fitted
@@ -517,6 +579,9 @@ class OffroadWorker:
feasible = not (trial['parameter'] == proposal['parameter'] and trial['delta'] == proposal['delta'])
if not feasible:
reason = 'no_new_native_feasible_training_step'
if independent_checks is not None:
break
continue
else:
checked = (
independent_checks
@@ -582,6 +647,7 @@ class OffroadWorker:
reason = 'bounded_trial_configured_for_next_drive'
else:
reason = checked.get('reason', 'original_controller_or_registered_encoder_check_refused')
break
if not saved and offered is not None and offered['parameter'].startswith('stable:'):
profile_suggestions = [
{'parameter': name, 'delta': value - current[name], 'whole_windows': selected_profile_windows, 'qualification': 'controller_native_checks_not_passed'}
@@ -39,7 +39,7 @@ CONTEXT = ('a' * 64, 'b' * 64, 'c' * 64, 0)
def raw_job(cp, runtime, params_store, *, native_refinements=False):
factory = SelectedControllerFactory(cp, 'standard')
factory = SelectedControllerFactory(cp, 'standard', turn_assist=runtime.descriptor.turn_assist)
ci = interfaces[cp.carFingerprint](cp)
ci.update([])
assert install_observer(ci)
@@ -169,7 +169,7 @@ def raw_job(cp, runtime, params_store, *, native_refinements=False):
source_epoch=source.source_epoch, source_receipt_ns=source.observed_ns, controller_mode='standard',
policy=runtime.descriptor.policy, baseline_revision=runtime.baseline_revision,
baseline_values=(baseline_values(controller) if native_refinements else runtime.initial_values),
controller_revision=runtime.descriptor.controller_revision,
controller_revision=runtime.descriptor.controller_revision, turn_assist=runtime.descriptor.turn_assist,
replay_snapshot=checkpoint, replay_inputs=inputs, snapshot_sequence=sequence)
applied = AppliedEvidence(stamp, stamp, stamp + 1_000_000, 'normalized_torque', float(output.torque),
True, True, False, False, False, allowed, witness, ci.CC.frame, stamp + 1_000_000)
@@ -42,6 +42,7 @@ def fixed_controls(*, factor=True, friction=True, legacy=False, learning=True):
'AOL_REPLAY_RUNTIME': '0', 'LANE_CENTERING_REPLAY_RUNTIME': '0'}),
mock.patch('openpilot.selfdrive.controls.controlsd.messaging.PubMaster')):
params = Params()
params.put_bool('OpenpilotEnabledToggle', True, block=True)
selected = published_cp(cp, params)
params.put('CarParams', selected.to_bytes(), block=True)
params.put(KEY, replace_preference(None, cp, learning), block=True)
@@ -26,7 +26,7 @@ CONTEXT = ('a' * 64, 'b' * 64, 'c' * 64, 0)
def actual_completion_trace(cp, runtime, params_store, failure):
factory = SelectedControllerFactory(cp, 'standard')
factory = SelectedControllerFactory(cp, 'standard', turn_assist=runtime.descriptor.turn_assist)
ci = interfaces[cp.carFingerprint](cp)
ci.update([])
assert install_observer(ci)
@@ -139,7 +139,7 @@ def actual_completion_trace(cp, runtime, params_store, failure):
stage_schema_revision=REVISION, stage_values=values, applied_coordinates=(0.0, 0.0),
source_epoch=source.source_epoch, source_receipt_ns=source.observed_ns, controller_mode='standard',
policy=runtime.descriptor.policy, baseline_revision=runtime.baseline_revision,
baseline_values=runtime.initial_values, controller_revision=runtime.descriptor.controller_revision,
baseline_values=runtime.initial_values, controller_revision=runtime.descriptor.controller_revision, turn_assist=runtime.descriptor.turn_assist,
replay_snapshot=checkpoint, replay_inputs=inputs, snapshot_sequence=sequence)
applied = AppliedEvidence(stamp, stamp, stamp + 1_000_000, 'normalized_torque', float(output.torque),
True, True, False, False, False, allowed, witness, ci.CC.frame, stamp + 1_000_000)
@@ -1296,6 +1296,132 @@ class TestOffroadRouteCoverage(unittest.TestCase):
class TestOffroadTrackingPolicy(unittest.TestCase):
def test_current_disjoint_windows_do_not_trigger_an_older_drive_rescan(self):
from types import SimpleNamespace
from openpilot.starpilot.flm import offroad_worker as module
from openpilot.starpilot.flm.offroad_processor import CompletedAssessment
from openpilot.starpilot.flm.offroad_route import RouteReceipt
# Dispatch-only fixture: training refuses, so no accepted tune is fabricated.
roles = trial()['before']
proposal = {'parameter': 'center_taper_max', 'delta': 0.005, 'values': values(0.005)}
cohort = {'cohort': 'declared-current-drive', 'whole_windows': 6, 'qualification_roles': roles,
'training': dict(roles['training'], reference_rms=0.5), 'heldout': dict(roles['heldout'], reference_rms=0.5), 'proposal': proposal}
metadata = {'route_values': tuple(values().items()), 'route_revision': REVISION, 'controller_mode': 'starpilot', 'route_scope': scope().key()}
group = {'original_metadata': metadata, 'original_context_sha256': 'f' * 64, 'cohorts': [cohort]}
cp = SimpleNamespace(carFingerprint='declared-current-drive')
receipt = RouteReceipt('declared-current-drive', 'd' * 64, (), 'a' * 64, 1, 2, 3, ())
inventory = {'routeId': receipt.route_id}
assessment = CompletedAssessment(receipt, (group,), {}, cp, (), inventory)
calls = []
def assessed(params, root, previous, *, permitted):
previous(cp)
return assessment
def refuse_training(actual, original, selected, proposed, previous, root, *, permitted):
self.assertIs(actual, assessment)
self.assertEqual(selected['qualification_roles'], roles)
self.assertEqual(proposed, proposal)
self.assertTrue(permitted())
calls.append('current_drive_training')
return None, {'declared_training_refusal': True}
def refused_check(*args, **kwargs):
self.fail('A refused training step cannot enter validation')
with tempfile.TemporaryDirectory() as directory:
params = Params(directory)
with (
patch.object(module, 'latest_route', return_value=inventory),
patch.object(module, 'analyze_latest', new=assessed),
patch.object(module, 'qualify', new=refused_check),
patch('openpilot.starpilot.flm.offroad_checks.fit_training_step', new=refuse_training),
patch('openpilot.starpilot.flm.offroad_prior_inputs.prior_originals', side_effect=AssertionError('Unexpected older drive rescan')),
patch('openpilot.starpilot.flm.offroad_prior_inputs.prior_stable_originals', side_effect=AssertionError('Unexpected older drive rescan')),
):
worker = module.OffroadWorker(params, directory, Path(directory) / 'cache', permitted=lambda: True,
assessor=assessed, checker=refused_check, scope_for=lambda cp, params: scope())
result = worker.run_once()
self.assertEqual(calls, ['current_drive_training'])
self.assertFalse(result['configuration_changed'])
self.assertEqual(result['reason'], 'no_new_native_feasible_training_step')
self.assertEqual(read_saved(params, KEY, MAX_BYTES), (None, True))
def test_quieting_trial_is_frozen_before_unrelated_heldout_turn_disagreement(self):
import copy
from openpilot.starpilot.flm.offroad_stable_bridge import StableRouteSelector
# Declared proposal-policy regression, using the retained profile's numeric shape.
# Actual controller/native qualification and automatic storage are not mocked.
roles = trial()['before']
cohort = {'cohort': '2:left:steady', **roles}
original = values()
original.update(friction_threshold_mid=0.004625, friction_threshold_fast=0.0061875, friction_threshold_highway=0.0061875, unwind_taper_left=0.01)
training = {
'values': original,
'changed_parameters': tuple(name for name, value in original.items() if value),
'maximum_coordinate_step': 0.01,
'bounded_profile_scale': 0.625,
}
independent = values()
independent.update(friction_threshold_mid=0.0071, friction_threshold_fast=0.0095, friction_threshold_highway=0.0095)
frozen = copy.deepcopy(training)
for unsupported in (None, 0.0, -0.0095, 0.0001):
with self.subTest(unsupported_primary=unsupported):
selector = object.__new__(StableRouteSelector)
selector.previous = values()
selector.rows = dict.fromkeys(roles['training']['windows'] + roles['heldout']['windows'], [])
events = []
heldout_values = dict(independent)
if unsupported is not None:
heldout_values['friction_threshold_fast'] = unsupported
def declared_profile(windows, selected_cohort, events=events, heldout_values=heldout_values):
self.assertEqual(selected_cohort, cohort['cohort'])
if windows == tuple(roles['training']['windows']):
events.append('training')
return copy.deepcopy(frozen), {'declared_role': 'training'}
self.assertEqual(windows, tuple(roles['heldout']['windows']))
events.append('heldout')
return {'values': heldout_values}, {'declared_role': 'heldout'}
with patch.object(selector, 'profile', side_effect=declared_profile):
proposed, proof = selector.propose({}, cohort)
self.assertEqual(events, ['training', 'heldout'])
self.assertEqual(proof['selection'], 'training_only_conservative_friction_trial_before_heldout')
expected = values()
expected.update(friction_threshold_mid=0.002775, friction_threshold_fast=0.0037125, friction_threshold_highway=0.0037125)
self.assertEqual(proof['frozen_training_profile']['values'], expected)
self.assertEqual(training, frozen)
if unsupported is None:
self.assertIsNotNone(proposed)
assert proposed is not None
self.assertEqual(proposed['values'], expected)
self.assertEqual(proposed['values']['unwind_taper_left'], 0.0)
self.assertEqual(set(proof['frozen_training_profile']['changed_parameters']), set(CURVE_NAMES[2:]))
self.assertTrue(proof['all_original_windows_still_required'])
else:
self.assertIsNone(proposed, 'Missing, reversed or weaker primary evidence must still refuse; no resizing or fallback')
def test_turn_only_training_profile_keeps_original_values(self):
from openpilot.starpilot.flm.offroad_stable_bridge import StableRouteSelector
roles = trial()['before']
mapped = values()
mapped['unwind_taper_left'] = 0.005
profile = {'values': mapped, 'changed_parameters': ('unwind_taper_left',), 'maximum_coordinate_step': 0.005, 'bounded_profile_scale': 1.0}
selector = object.__new__(StableRouteSelector)
selector.previous = values()
selector.rows = dict.fromkeys(roles['training']['windows'] + roles['heldout']['windows'], [])
with patch.object(selector, 'profile', return_value=(profile, {'declared_proposal_only': True})):
proposed, proof = selector.propose({}, {'cohort': '2:left:unwind', **roles})
self.assertIsNotNone(proposed)
assert proposed is not None
self.assertEqual(proposed['values'], mapped)
self.assertEqual(proposed['delta'], 0.005)
self.assertEqual(proof['selection'], 'training_values_only_no_heldout_resizing')
def test_heldout_profile_supports_frozen_training_step_without_identical_floats(self):
import copy
from openpilot.starpilot.flm.offroad_stable_bridge import independent_profile_support
@@ -1779,17 +1905,19 @@ class TestOffroadTrackingPolicy(unittest.TestCase):
'declared', 'lateral_acceleration', '2:both:center', 1.0, 0, 0.0, (0.9, 1.1), (0, 0), (-0.1, 0.1), 1.0, 0.1, (), 'f' * 64, 1.0, 0.1, 0.1
)
calls = []
native_boundary = 0.004
emitted_changes = 1
def recorded(actual, source, selected, trial_values, root, *, permitted):
self.assertTrue(permitted())
calls.append((actual is prior, tuple(selected), trial_values['center_taper_max']))
okay = actual is not prior and trial_values['center_taper_max'] <= 0.004
okay = actual is not prior and trial_values['center_taper_max'] <= native_boundary
return [
{
'window': wid,
'qualified': okay,
'numerical': {'qualified': True, 'changed_frames': 1},
'registered_encoder': {'qualified': okay, 'candidate_changed_emissions': 1},
'registered_encoder': {'qualified': okay, 'candidate_changed_emissions': emitted_changes},
}
for wid in selected
]
@@ -1823,6 +1951,39 @@ class TestOffroadTrackingPolicy(unittest.TestCase):
from openpilot.starpilot.flm.offroad_worker import qualification_summary
self.assertEqual(qualification_summary(result), {'passed': 6, 'required': 12, 'refusals': [('independent_native_validation_not_run', 6)]})
native_boundary = proposed['delta'] * 0.05
calls.clear()
with (
patch('openpilot.starpilot.flm.runtime_adapter_registry.car_params_digest', return_value='a' * 64),
patch.object(checks, '_original_results', new=recorded),
patch('openpilot.starpilot.flm.offroad_tracking.estimate', return_value=observed),
patch('openpilot.starpilot.flm.offroad_tracking.suggest', return_value=replication),
):
fitted, proof, result = checks.fit_independent_route(current, group, cohort, prior, proposed, values(), '.', permitted=lambda: True)
assert fitted is not None and result is not None
self.assertEqual(proof['selected_fraction'], 0.05)
self.assertEqual(proof['validation_evaluations'], 1)
self.assertFalse(result['qualified'])
self.assertEqual([ids for _, ids, _ in calls], [ids] * 7 + [valid_ids])
self.assertEqual(calls[-1], (True, valid_ids, native_boundary))
self.assertEqual(fitted['values']['center_taper_max'], native_boundary)
emitted_changes = 0
calls.clear()
with (
patch('openpilot.starpilot.flm.runtime_adapter_registry.car_params_digest', return_value='a' * 64),
patch.object(checks, '_original_results', new=recorded),
patch('openpilot.starpilot.flm.offroad_tracking.estimate', return_value=observed) as independent_estimate,
patch('openpilot.starpilot.flm.offroad_tracking.suggest', return_value=replication),
):
fitted, proof, result = checks.fit_independent_route(current, group, cohort, prior, proposed, values(), '.', permitted=lambda: True)
self.assertIsNone(fitted)
self.assertIsNone(result)
self.assertEqual(proof['validation_evaluations'], 0)
self.assertEqual([row[1] for row in calls], [ids] * 7)
self.assertTrue(all(not row[0] for row in calls))
independent_estimate.assert_not_called()
with patch('openpilot.starpilot.flm.runtime_adapter_registry.car_params_digest', return_value='0' * 64), self.assertRaises(ValueError):
checks.fit_independent_route(current, group, cohort, prior, proposed, values(), '.', permitted=lambda: True)
@@ -1939,7 +2100,12 @@ class TestOffroadTrackingPolicy(unittest.TestCase):
amount = trial_values['center_taper_max']
calls.append((tuple(ids), amount))
self.assertTrue(set(ids) in (set(train), set(train + heldout)))
return [{'window': wid, 'qualified': amount <= 0.004 and wid not in heldout} for wid in ids]
return [
{'window': wid, 'qualified': amount <= 0.004 and wid not in heldout,
'numerical': {'qualified': True, 'changed_frames': 1},
'registered_encoder': {'qualified': amount <= 0.004 and wid not in heldout, 'candidate_changed_emissions': 1}}
for wid in ids
]
with tempfile.TemporaryDirectory() as directory:
params = Params(directory)
@@ -1968,9 +2134,118 @@ class TestOffroadTrackingPolicy(unittest.TestCase):
with patch('openpilot.starpilot.flm.offroad_checks._original_results', new=infeasible):
fitted, proof = fit_training_step(assessment, group, cohort, proposal, values(), '.', permitted=lambda: True)
self.assertIsNone(fitted)
self.assertEqual(calls, [train] * 5)
self.assertEqual(calls, [train] * 7)
self.assertIsNone(proof['selected_fraction'])
def test_training_can_select_an_alternative_but_validation_never_can(self):
import hashlib
from types import SimpleNamespace
from openpilot.starpilot.flm.offroad_processor import CompletedAssessment
from openpilot.starpilot.flm.offroad_route import RouteReceipt
from openpilot.starpilot.flm.offroad_worker import OffroadWorker
# Declared dispatch responses only; the final validation always refuses.
for scenario in ('first_refuses', 'zero_native_change', 'heldout_refuses', 'overlap', 'all_refuse'):
with self.subTest(scenario=scenario), tempfile.TemporaryDirectory() as directory:
names = ('center_taper_max', 'turn_in_boost_left', 'unwind_taper_right')
roles = [
{role: {'windows': tuple(hashlib.sha256(f'{index}:{role}:{n}'.encode()).hexdigest() for n in range(3))}
for role in ('training', 'heldout')}
for index in range(3)
]
if scenario == 'overlap':
roles[1]['heldout']['windows'] = (roles[0]['training']['windows'][0], *roles[1]['heldout']['windows'][1:])
cohorts = [
{'cohort': f'declared-{index}', 'whole_windows': 6, 'qualification_roles': roles[index],
'training': {'reference_rms': 3 - index}, 'heldout': {'reference_rms': 3 - index},
'proposal': {'parameter': name, 'delta': 0.005, 'values': dict(values(), **{name: 0.005})}}
for index, name in enumerate(names)
]
metadata = {'route_values': tuple(values().items()), 'route_revision': REVISION,
'controller_mode': 'starpilot', 'route_scope': scope().key()}
group = {'original_metadata': metadata, 'original_context_sha256': 'f' * 64,
'cohorts': cohorts, 'qualification_original_windows': []}
cp = SimpleNamespace(carFingerprint='declared-candidate-selection-only')
receipt = RouteReceipt('declared-candidates', 'd' * 64, (), 'a' * 64, 1, 2, 3, ())
inventory = {'routeId': receipt.route_id}
assessment = CompletedAssessment(receipt, (group,), {}, cp, (), inventory)
calls = []
def checked(actual, original, ids, trial_values, root, *, permitted, assessment=assessment, names=names, roles=roles, calls=calls, scenario=scenario):
self.assertIs(actual, assessment)
self.assertTrue(permitted())
index = next(i for i, name in enumerate(names) if trial_values[name])
train, heldout = (roles[index][role]['windows'] for role in ('training', 'heldout'))
validation = tuple(ids) == train + heldout
self.assertIn(tuple(ids), (train, train + heldout))
calls.append((index, validation, tuple(ids)))
feasible = scenario != 'all_refuse' and (index > 0 or scenario == 'heldout_refuses')
native_change = int(feasible)
if scenario == 'zero_native_change' and index == 0:
feasible = True
return [
{'window': wid, 'qualified': feasible and not validation,
'numerical': {'qualified': True, 'changed_frames': 50},
'registered_encoder': {'qualified': feasible and not validation, 'candidate_changed_emissions': native_change}}
for wid in ids
]
def assessed(params, root, previous, *, permitted, cp=cp, assessment=assessment):
previous(cp)
return assessment
params = Params(directory)
with (
patch('openpilot.starpilot.flm.offroad_worker.latest_route', return_value=inventory),
patch('openpilot.starpilot.flm.offroad_checks._original_results', new=checked),
):
worker = OffroadWorker(params, directory, Path(directory) / 'cache', permitted=lambda: True,
assessor=assessed, scope_for=lambda cp, params: scope())
result = worker.run_once()
self.assertFalse(result['configuration_changed'])
self.assertEqual(read_saved(params, KEY, MAX_BYTES), (None, True))
if scenario == 'all_refuse':
self.assertEqual([i for i, _, _ in calls], [0] * 7 + [1] * 7 + [2] * 7)
self.assertFalse(any(validation for _, validation, _ in calls))
self.assertEqual(result['reason'], 'no_new_native_feasible_training_step')
else:
selected = 0 if scenario == 'heldout_refuses' else 2 if scenario == 'overlap' else 1
self.assertEqual([i for i, _, _ in calls], ([] if selected == 0 else [0] * 7) + [selected] * 2)
self.assertEqual([(i, validation) for i, validation, _ in calls if validation], [(selected, True)])
self.assertEqual(result['reason'], 'original_controller_or_registered_encoder_check_refused')
report = worker.cache.get(result['assessment_sha256'])['assessment']
self.assertEqual(len(report['native_candidate_training']), 1 if selected == 0 else 2)
validation_ids = set(roles[selected]['heldout']['windows'])
self.assertTrue(all(not validation_ids.intersection(ids) for _, validation, ids in calls if not validation))
def test_zero_native_change_cannot_consume_independent_validation(self):
from types import SimpleNamespace
from openpilot.starpilot.flm.offroad_checks import fit_training_step
train = tuple(str(i) * 64 for i in range(1, 4))
heldout = tuple(str(i) * 64 for i in range(4, 7))
cohort = {'qualification_roles': {'training': {'windows': train}, 'heldout': {'windows': heldout}}}
proposal = {'values': values(0.005), 'delta': 0.005}
calls = []
def unchanged(actual, original, ids, trial_values, root, *, permitted):
self.assertTrue(permitted())
calls.append(tuple(ids))
return [
{'window': wid, 'qualified': True,
'numerical': {'qualified': True, 'changed_frames': 50},
'registered_encoder': {'qualified': True, 'candidate_changed_emissions': 0}}
for wid in ids
]
with patch('openpilot.starpilot.flm.offroad_checks._original_results', new=unchanged):
fitted, proof = fit_training_step(SimpleNamespace(), {'qualification_original_windows': []}, cohort, proposal, values(), '.', permitted=lambda: True)
self.assertIsNone(fitted)
self.assertIsNone(proof['selected_fraction'])
self.assertEqual(calls, [train] * 7)
self.assertTrue(all(not row['feasible'] for row in proof['attempts']))
self.assertEqual(proof['heldout_windows_not_evaluated'], heldout)
def test_recommended_profile_can_fit_below_coarse_native_quantization_boundary(self):
from types import SimpleNamespace
from openpilot.starpilot.flm.offroad_checks import fit_training_step, qualify
@@ -1984,10 +2259,8 @@ class TestOffroadTrackingPolicy(unittest.TestCase):
desired = values()
desired['friction_threshold_fast'] = 0.0099
desired['friction_threshold_highway'] = 0.0099
proposal = {'values': desired, 'delta': 0.0099, 'policy': 'original_stable_recommended_bounded_route_v1'}
assessment = SimpleNamespace()
calls = []
boundary = desired['friction_threshold_fast'] * 0.1
def checked(actual, original, ids, trial_values, root, *, permitted):
self.assertIs(actual, assessment)
@@ -1996,22 +2269,32 @@ class TestOffroadTrackingPolicy(unittest.TestCase):
amount = trial_values['friction_threshold_fast']
self.assertEqual(amount, trial_values['friction_threshold_highway'])
calls.append((tuple(ids), amount))
return [{'window': wid, 'qualified': amount <= boundary and wid not in heldout} for wid in ids]
return [
{'window': wid, 'qualified': amount <= boundary and wid not in heldout,
'numerical': {'qualified': True, 'changed_frames': 1},
'registered_encoder': {'qualified': amount <= boundary and wid not in heldout, 'candidate_changed_emissions': 1}}
for wid in ids
]
with patch('openpilot.starpilot.flm.offroad_checks._original_results', new=checked):
fitted, proof = fit_training_step(assessment, group, cohort, proposal, previous, '.', permitted=lambda: True)
assert fitted is not None
frozen = dict(fitted['values'])
self.assertEqual(proof['selected_fraction'], 0.1)
self.assertEqual([ids for ids, _ in calls], [train] * 6)
self.assertEqual(frozen['friction_threshold_fast'], boundary)
self.assertEqual(previous, values())
self.assertEqual(desired['friction_threshold_fast'], 0.0099)
result = qualify(assessment, group, cohort, frozen, '.', permitted=lambda: True)
self.assertFalse(result['qualified'])
self.assertEqual(calls[-1], (train + heldout, boundary))
self.assertEqual(len(calls), 7)
self.assertEqual(fitted['values'], frozen)
for policy in ('observed_tracking_v1', 'original_stable_recommended_bounded_route_v1'):
for fraction, training_count in ((0.1, 6), (0.05, 7)):
proposal = {'values': desired, 'delta': 0.0099, 'policy': policy}
boundary = desired['friction_threshold_fast'] * fraction
calls.clear()
with patch('openpilot.starpilot.flm.offroad_checks._original_results', new=checked):
fitted, proof = fit_training_step(assessment, group, cohort, proposal, previous, '.', permitted=lambda: True)
assert fitted is not None
frozen = dict(fitted['values'])
self.assertEqual(proof['selected_fraction'], fraction)
self.assertEqual([ids for ids, _ in calls], [train] * training_count)
self.assertEqual(frozen['friction_threshold_fast'], boundary)
self.assertEqual(previous, values())
self.assertEqual(desired['friction_threshold_fast'], 0.0099)
result = qualify(assessment, group, cohort, frozen, '.', permitted=lambda: True)
self.assertFalse(result['qualified'])
self.assertEqual(calls[-1], (train + heldout, boundary))
self.assertEqual(len(calls), training_count + 1)
self.assertEqual(fitted['values'], frozen)
@staticmethod
def windows(prefix, gain, amplitude=1.0):
@@ -0,0 +1,285 @@
"""Coordinate proposal contracts using declared unit fixtures."""
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from openpilot.common.params import Params
from openpilot.starpilot.flm.canonical_surface_adapter import NAMES
from openpilot.starpilot.flm.offroad_runtime import RouteEffects
from openpilot.starpilot.flm.offroad_store import KEY, MAX_BYTES, RouteStorage, startup_row
from openpilot.starpilot.flm.offroad_surface import REVISION, ROUTE_NAMES, RouteSurfaceEffects
from openpilot.starpilot.flm.tests.test_offroad_backend import scope, trial, values
from openpilot.starpilot.flm.torque_stages import StageFrame
from openpilot.starpilot.saved_source import read_saved
class TestAllThirtyCoordinateProposals(unittest.TestCase):
@staticmethod
def observations(bucket, speed, side='left', severity=1.0):
return [(str(index), {'bucket': bucket, 'direction': side, 'severity': severity, 'speed_mps': speed}) for index in range(3)]
def test_all_thirty_have_symptom_bound_candidates_that_store_load_and_change_their_actual_hook(self):
from openpilot.starpilot.flm.offroad_coordinate_policy import coordinate_candidates
from openpilot.starpilot.flm.offroad_surface import route_bounds
# Declared symptoms, not measured road improvement. Nonzero prior trims make
# their window edges meaningful; zero-amplitude windows must not be moved.
previous = dict.fromkeys(ROUTE_NAMES, 0.01)
cases: list[tuple[str, float]] = [('center_chatter', speed) for speed in (2, 7, 12, 20, 30)]
buckets = ('understeer', 'oversteer', 'late_turn_in', 'early_turn_in', 'low_speed_unwillingness', 'unwind_too_slow', 'unwind_too_fast')
cases += [(bucket, speed) for bucket in buckets for speed in (2, 7.2, 11.5, 20.5, 21.5, 26)]
candidates = {}
for bucket, speed in cases:
for side in ('left', 'right'):
for candidate in coordinate_candidates(self.observations(bucket, speed, side), previous):
candidates.setdefault(candidate['parameter'].removeprefix('stable:'), candidate)
self.assertEqual(set(candidates), set(NAMES), 'A declared 30-knob learner must have a reachable proposal for every knob')
for name, candidate in candidates.items():
assert candidate is not None
with self.subTest(name=name), tempfile.TemporaryDirectory() as directory:
changed = {key for key in ROUTE_NAMES if candidate['values'][key] != previous[key]}
self.assertEqual(changed, {name})
self.assertLessEqual(abs(candidate['delta']), 0.01)
low, high = route_bounds(name)
self.assertTrue(low <= candidate['values'][name] <= high)
params = Params(directory)
row = {'scope': scope().document(), 'values': candidate['values'], 'previous': previous, 'route_sha256s': ['d' * 64],
'source_start_ns': 1, 'source_end_ns': 2, 'completed_wall_ns': 3, 'analysis_sha256': 'e' * 64,
'sequence': 1, 'state': 'ready_for_next_drive'}
initial = dict(row, values=previous, previous=values())
self.assertTrue(RouteStorage(params).save(initial, expected=None, authorized=lambda: True))
baseline_raw, readable = read_saved(params, KEY, MAX_BYTES)
self.assertTrue(readable)
row.update(sequence=2, route_sha256s=['f' * 64], source_start_ns=4, source_end_ns=5, completed_wall_ns=6)
self.assertTrue(RouteStorage(params).save(row, expected=baseline_raw, authorized=lambda: True))
restored, _ = startup_row(params, scope())
self.assertIsNotNone(restored)
assert restored is not None
self.assertEqual(restored['values'], candidate['values'])
fresh = RouteEffects(scope(), tuple((key, restored['values'][key]) for key in ROUTE_NAMES), (), previous=tuple(previous.items()))
self.assertEqual(fresh.status, 'configured_pending_actual_stage')
self.assertEqual(fresh.admitted_ns, 0, 'Stored coordinates cannot supply live authority')
before = RouteSurfaceEffects({NAMES[0]: previous[NAMES[0]]}, scope().baseline_revision, accumulated=previous)
after = fresh.source
differences = []
stamp = 0
for speed in (2, 5, 10, 15, 21, 26, 30):
for requested in (-1.5, -0.05, 0.05, 1.5):
for jerk in (-1.0, 1.0):
stamp += 1
output = []
for effect in (before, after):
effect.begin_frame(stamp)
frame = StageFrame(speed, requested, jerk, 0.3, 0.3, True, False, False)
output.append((
*effect.selected_stage(frame, original_anchor=effect.original_effect_anchor),
effect.selected_deadzone(speed, 0.0, 0.1, 0.1, requested, original_anchor=effect.original_effect_anchor),
effect.selected_output(speed, requested, 30.0, 5.0, -0.2, 0.1, original_anchor=effect.original_effect_anchor),
))
differences.append(any(abs(a - b) > 1e-12 for a, b in zip(*output, strict=True)))
self.assertTrue(any(differences), 'The proposed knob must affect its actual existing selected hook')
def test_missing_conflicting_or_unsupported_evidence_cannot_generate_changes(self):
from openpilot.starpilot.flm.offroad_coordinate_policy import coordinate_candidates
previous = values()
source = self.observations('understeer', 15)
self.assertEqual(coordinate_candidates(source[:2], previous), [])
self.assertEqual(coordinate_candidates(source + self.observations('oversteer', 15), previous), [])
for bucket in ('model_limited', 'saturation_limited', 'unknown'):
self.assertEqual(coordinate_candidates(self.observations(bucket, 15), previous), [])
for bad in (None, True, float('nan'), float('inf'), -1):
invalid = self.observations('understeer', 15)
for _, summary in invalid:
summary['speed_mps'] = bad
self.assertEqual(coordinate_candidates(invalid, previous), [])
self.assertEqual(previous, values())
def test_speed_knots_side_phase_and_relative_units_are_preserved(self):
from openpilot.starpilot.flm.offroad_coordinate_policy import coordinate_candidates
previous = values()
for speed, expected in ((2, {'crawl', 'low'}), (5, {'low'}), (10, {'mid'}), (15, {'fast'}), (30, {'highway'})):
proposed = coordinate_candidates(self.observations('center_chatter', speed), previous)
deadbands = {p['parameter'].split('_')[2] for p in proposed if 'center_deadband_' in p['parameter']}
self.assertEqual(deadbands, expected)
self.assertTrue(all(p['coordinate_units'] == 'physical_normalized' for p in proposed if 'center_deadband_' in p['parameter']))
proposed = coordinate_candidates(self.observations('early_turn_in', 11.5, 'right'), previous)
self.assertFalse(any('left' in p['parameter'] or 'speed_min' in p['parameter'] or 'speed_max' in p['parameter'] for p in proposed))
self.assertTrue(any(p['parameter'] == 'stable:curvy_turn_in_trim_right' and p['delta'] > 0 for p in proposed))
previous['curvy_turn_in_trim_right'] = 0.02
expanded = coordinate_candidates(self.observations('early_turn_in', 11.5, 'right'), previous)
edge = next(p for p in expanded if p['parameter'] == 'stable:curvy_turn_in_trim_speed_min')
self.assertLess(edge['delta'], 0, 'Earlier trim onset reduces an observed early turn-in')
self.assertEqual(edge['coordinate_units'], 'relative_speed_window')
self.assertFalse(any('speed_min' in p['parameter'] for p in coordinate_candidates(self.observations('early_turn_in', 30, 'right'), previous)))
def test_coordinate_alternatives_freeze_before_heldout_and_require_independent_support(self):
from openpilot.starpilot.flm.offroad_coordinate_policy import coordinate_candidates
from openpilot.starpilot.flm.offroad_stable_bridge import StableRouteSelector
roles = trial()['before']
selector = object.__new__(StableRouteSelector)
selector.previous = values()
selector.rows = dict.fromkeys(roles['training']['windows'] + roles['heldout']['windows'], [])
training = coordinate_candidates(self.observations('low_speed_unwillingness', 2), values())
frozen = json.loads(json.dumps(training))
for heldout in (training, [], coordinate_candidates(self.observations('early_turn_in', 2), values())):
events = []
def profile(windows, cohort, events=events, heldout=heldout):
role = 'training' if windows == tuple(roles['training']['windows']) else 'heldout'
events.append(role)
return None, {'coordinate_candidates': training if role == 'training' else heldout}
with patch.object(selector, 'profile', new=profile):
legacy, proof = selector.propose({}, {'cohort': 'declared', **roles})
self.assertIsNone(legacy)
self.assertEqual(events, ['training', 'heldout'])
self.assertEqual(proof['coordinate_candidates'], training if heldout == training else [])
self.assertEqual(training, frozen, 'Heldout must not resize or replace the frozen proposals')
class TestShortTurnProposalCoverage(unittest.TestCase):
def test_selector_retains_both_original_short_and_stable_partitions(self):
from types import SimpleNamespace
from openpilot.starpilot.flm.offroad_processor import CompletedAssessment
from openpilot.starpilot.flm.offroad_route import RouteReceipt
from openpilot.starpilot.flm.offroad_stable_bridge import StableRouteSelector
roles = trial()['before']
ids = roles['training']['windows'] + roles['heldout']['windows']
metadata = {'domain': 'lateral_acceleration', 'route_values': tuple(values().items()), 'route_revision': REVISION}
def group(prefix):
windows = tuple(prefix + wid[1:] for wid in ids)
return {
'original_metadata': metadata,
'cohorts': [{'training': {'windows': windows[:3]}, 'heldout': {'windows': windows[3:]}}],
'selected_original_windows': [{'identity': wid, 'segment': 0, 'start_ns': i * 10**9, 'end_ns': (i + 1) * 10**9} for i, wid in enumerate(windows)],
}
short, stable = group('a'), group('b')
cp = SimpleNamespace(brand='gm', lateralTuning=SimpleNamespace(which=lambda: 'torque'))
receipt = RouteReceipt('declared-window-reader', 'd' * 64, (), 'a' * 64, 1, 2, 3, ())
assessment = CompletedAssessment(receipt, (short,), {}, cp, (), {}, stable_groups=(stable,))
selector = StableRouteSelector(assessment, '.', values(), scope(), permitted=lambda: True)
self.assertEqual(set(selector.spans), {w['identity'] for g in (short, stable) for w in g['selected_original_windows']})
self.assertEqual(len(selector.spans), 12)
def dispatch(self, *, training_count=3, overlap=False):
from types import SimpleNamespace
from openpilot.starpilot.flm import offroad_worker as module
from openpilot.starpilot.flm.offroad_processor import CompletedAssessment
from openpilot.starpilot.flm.offroad_route import RouteReceipt
events = []
ids = tuple(letter * 64 for letter in 'abcdef')
roles = {'training': {'windows': ids[:training_count], 'reference_rms': 0.1}, 'heldout': {'windows': ids[3:], 'reference_rms': 0.1}}
metadata = {'route_values': tuple(values().items()), 'route_revision': REVISION, 'controller_mode': 'starpilot', 'route_scope': scope().key()}
short = {'cohort': '2:left:turn_in', 'whole_windows': 6, 'proposal': None, **roles}
original = [{'identity': wid, 'segment': 0, 'start_ns': (100 + i * 10) * 10**9, 'end_ns': (102 + i * 10) * 10**9} for i, wid in enumerate(ids)]
group = {'original_metadata': metadata, 'original_context_sha256': 'f' * 64, 'cohorts': [short], 'selected_original_windows': original}
selected = values()
selected['turn_in_boost_left'] = 0.005
proposal = {'parameter': 'stable:turn_in_boost_left', 'delta': 0.005, 'values': selected,
'policy': 'original_stable_recommended_bounded_route_v1', 'coordinate_policy': 'symptom_bound_all30_v1'}
stable_groups = ()
if overlap:
long_ids = tuple(str(i) * 64 for i in range(1, 7))
long_roles = {'training': {'windows': long_ids[:3], 'reference_rms': 0.6}, 'heldout': {'windows': long_ids[3:], 'reference_rms': 0.6}}
long_cohort = {'cohort': '2:both:center', 'whole_windows': 6, **long_roles}
long_original = [{'identity': wid, 'segment': 0, 'start_ns': i * 10**10, 'end_ns': i * 10**10 + 6 * 10**9} for i, wid in enumerate(long_ids)]
stable_groups = (dict(group, cohorts=[long_cohort], selected_original_windows=long_original),)
for i in range(3):
start = long_original[i]['start_ns']
assert isinstance(start, int)
original[3 + i].update(start_ns=start + 10**9, end_ns=start + 3 * 10**9)
cp = SimpleNamespace(carFingerprint='declared-short-turn-dispatch')
receipt = RouteReceipt('declared-short-turn', 'd' * 64, (), 'a' * 64, 1, 2, 3, ())
assessment = CompletedAssessment(receipt, (group,), {}, cp, (), {'routeId': receipt.route_id}, stable_groups=stable_groups)
class DeclaredSelector:
def __init__(self, *args, **kwargs):
pass
def propose(self, actual_group, cohort):
events.append(('profile', cohort['cohort']))
if cohort['cohort'].endswith('center'):
return proposal, {'coordinate_candidates': []}
return None, {'coordinate_candidates': [proposal]}
def assessed(params, root, previous, *, permitted):
previous(cp)
return assessment
def training(actual, actual_group, cohort, selected_proposal, previous, root, *, permitted):
events.append(('training', cohort['cohort']))
if overlap:
self.assertTrue(cohort['cohort'].endswith('center'), 'Overlapping short held-out input must never enter another training attempt')
return None, {'selected_fraction': None, 'attempts': []}
self.assertIs(actual, assessment)
self.assertIs(actual_group, group)
self.assertEqual(tuple(cohort['training']['windows']), ids[:3])
self.assertEqual(tuple(cohort['heldout']['windows']), ids[3:])
return selected_proposal, {'declared_dispatch_only': True}
def refused(actual, actual_group, cohort, candidate_values, root, *, permitted):
events.append(('native_refusal', cohort['cohort']))
self.assertEqual(candidate_values, selected)
return {'qualified': False, 'windows': [], 'reason': 'declared_native_refusal'}
with tempfile.TemporaryDirectory() as directory:
params = Params(directory)
with (
patch.object(module, 'latest_route', return_value=assessment.source_inventory),
patch.object(module, 'qualify', new=refused),
patch('openpilot.starpilot.flm.offroad_stable_bridge.StableRouteSelector', new=DeclaredSelector),
patch('openpilot.starpilot.flm.offroad_checks.fit_training_step', new=training),
):
result = module.OffroadWorker(params, directory, Path(directory) / 'cache', permitted=lambda: True,
assessor=assessed, checker=refused, scope_for=lambda cp, params: scope()).run_once()
self.assertFalse(result['configuration_changed'])
self.assertIsNone(params.get(KEY), 'A proposal or mocked native refusal cannot save an automatic tune')
return events
def test_short_turn_reaches_original_native_refusal_only_with_both_full_partitions(self):
self.assertEqual(self.dispatch(training_count=2), [])
self.assertEqual(self.dispatch(), [('profile', '2:left:turn_in'), ('training', '2:left:turn_in'), ('native_refusal', '2:left:turn_in')])
def test_different_window_ids_cannot_hide_training_validation_time_overlap(self):
self.assertEqual(self.dispatch(overlap=True), [('profile', '2:both:center'), ('profile', '2:left:turn_in'), ('training', '2:both:center')])
class TestRecordedProposalScoreUnits(unittest.TestCase):
def test_real_classifier_score_keeps_measurement_units_and_uses_dimensionless_strength(self):
from openpilot.starpilot.flm.offroad_stable_policy import FLMSample, classify_current_stable
from openpilot.starpilot.flm.offroad_coordinate_policy import recorded_observation, coordinate_candidates
# Declared signal, not a road or acceptance claim. Use the actual classifier
# so its m/s^2 peak cannot be mistaken for an already-normalized severity.
samples = [FLMSample('declared-score-units', 0, i / 10, 20.0, True, False, False,
0.75, 1.0, 0.0, -0.25, 0.0, 0.0, 0.0, 0.0, 0.0, 0.2, 5.0, 0.0, 0.0, 0.0, 0.0) for i in range(60)]
summaries, _ = classify_current_stable(samples)
event = next(s for s in summaries if s['bucket'] == 'understeer')['events'][0]
self.assertEqual(event['peakScore'], 0.25)
observed = recorded_observation('understeer', event['direction'], event['peakScore'], 20.0)
self.assertEqual(observed['raw_peak_score'], 0.25)
self.assertEqual(observed['severity'], 1.25)
candidates = coordinate_candidates([(str(i), observed) for i in range(3)], values())
self.assertEqual(len(candidates), 1)
self.assertEqual(candidates[0]['parameter'], 'stable:ff_gain_left')
self.assertAlmostEqual(candidates[0]['delta'], 0.006)
self.assertLessEqual(abs(candidates[0]['delta']), 0.01)
chatter = recorded_observation('center_chatter', 'center', 0.8, 25.0)
self.assertEqual(chatter['severity'], 0.8, 'Already normalized chatter must keep its original strength')
for score in (float('nan'), float('inf'), -1, True):
with self.subTest(score=score), self.assertRaises(ValueError):
recorded_observation('understeer', 'left', score, 20.0)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,98 @@
"""Unchanged native output contracts using declared unit fixtures."""
from dataclasses import replace
import unittest
class TestOffroadEqualNativeOutput(unittest.TestCase):
@staticmethod
def boundary(platform, *, unwind=False):
from openpilot.starpilot.flm.tests.test_steering_projection import make_interface, control, apply, settle, install_observer, check_packet
from openpilot.starpilot.flm.torque_emission_replay import Emission
ci = make_interface(platform)
assert install_observer(ci)
initial = settle(ci, control(0.0))[-1][2]
limits = dict(initial.limits)
step = limits['STEER_DELTA_DOWN' if unwind else 'STEER_DELTA_UP']
if unwind:
settle(ci, control(4 * step / initial.effective_max))
target = (3 if unwind else 2) * step
for _ in range(24):
_, packets, witness = apply(ci, control(target / initial.effective_max))
if witness is None:
continue
if (witness.unlimited_raw == witness.final_raw == target and
witness.final_raw - witness.previous_torque == (-step if unwind else step)):
check_packet(ci, packets, witness)
stamp = 10_000_000_000 + witness.frame * 10_000_000
return Emission(stamp, stamp, stamp + 1_000_000, witness, 'a' * 64, 'b' * 64, 'c' * 64,
True, True, False, False, False, False, False, 0, 0, '')
raise AssertionError('Actual registered encoder did not provide the unclipped rate boundary')
def test_exact_same_request_uses_candidate_native_prestate(self):
from opendbc.car.gm.values import CAR as GM
from opendbc.car.hyundai.values import CAR as HYUNDAI
from opendbc.car.toyota.values import CAR as TOYOTA
from openpilot.starpilot.flm.torque_emission_replay import EncoderBranch
for platform in (GM.GMC_ACADIA, HYUNDAI.HYUNDAI_IONIQ_6, TOYOTA.TOYOTA_COROLLA_TSS2):
with self.subTest(platform=platform):
event = self.boundary(platform)
w = event.witness
original = EncoderBranch()
self.assertEqual(original.step(event, w.requested_torque, actual=True), w.final_raw / w.effective_max)
strict = EncoderBranch()
strict.previous = w.previous_torque - 1
with self.assertRaisesRegex(ValueError, 'limiter/support'):
strict.step(event, w.requested_torque)
candidate = EncoderBranch(allow_unclipped_equal_output=True)
candidate.previous = w.previous_torque - 1
self.assertEqual(candidate.step(event, w.requested_torque), (w.final_raw - 1) / w.effective_max)
self.assertEqual(candidate.previous, w.final_raw - 1)
self.assertFalse(event.limited)
self.assertEqual(event.witness, w)
def test_native_unwind_cap_is_not_an_unsafe_request(self):
from opendbc.car.hyundai.values import CAR
from openpilot.starpilot.flm.torque_emission_replay import EncoderBranch
event = self.boundary(CAR.HYUNDAI_IONIQ_6, unwind=True)
w = event.witness
request = (w.unlimited_raw - 1) / w.effective_max
with self.assertRaisesRegex(ValueError, 'limiter/support'):
EncoderBranch().step(event, request)
branch = EncoderBranch(allow_unclipped_equal_output=True)
self.assertEqual(branch.step(event, request), w.final_raw / w.effective_max)
self.assertEqual(branch.previous, w.final_raw)
for unsafe in ((w.unlimited_raw + 1) / w.effective_max, -w.requested_torque):
candidate = EncoderBranch(allow_unclipped_equal_output=True)
candidate.previous = w.previous_torque + 100
with self.assertRaises(ValueError):
candidate.step(event, unsafe)
def test_equal_output_does_not_relax_original_or_legacy_quality(self):
from opendbc.car.gm.values import CAR as GM
from opendbc.car.hyundai.values import CAR as HYUNDAI
from opendbc.car.toyota.values import CAR as TOYOTA
from openpilot.starpilot.flm.torque_emission_replay import EncoderBranch
from openpilot.starpilot.flm.tests import test_state_aligned_encoder as legacy
event = self.boundary(HYUNDAI.HYUNDAI_IONIQ_6)
branch = EncoderBranch(allow_unclipped_equal_output=True)
branch.previous = event.witness.previous_torque - 1
with self.assertRaisesRegex(ValueError, 'actual previous'):
branch.step(event, event.witness.requested_torque, actual=True)
clipped = legacy.captured_event(TOYOTA.TOYOTA_COROLLA_TSS2)
with self.assertRaisesRegex(ValueError, 'limiter/support'):
EncoderBranch(allow_unclipped_equal_output=True).step(replace(clipped, limited=False), clipped.witness.requested_torque)
for platform in (TOYOTA.TOYOTA_COROLLA_TSS2, GM.GMC_ACADIA):
legacy.test_registered_measured_driver_same_request_shadow_and_increased_demand(platform)
legacy.test_common_state_unlimited_reference_recorded_opposite_and_original_unlimited_vetoes()
legacy.test_unsupported_paired_projection_fails_without_advancing_candidate_native_state()
legacy.test_honda_normalized_pre_lookup_comparison_and_modified_filter_shadow_is_pure()
legacy.test_honda_nonfinite_reference_and_paired_original_pressed_fail_closed()
if __name__ == '__main__':
unittest.main()
@@ -22,6 +22,7 @@ BASE = FIXTURE_BASE + 14_000_000_000
def test_parent_renews_actual_source_and_retains_real_collector_block(tmp_path, boundary, change, queued_calibration=True):
cp = interfaces[CAR.TOYOTA_COROLLA_TSS2].get_non_essential_params(CAR.TOYOTA_COROLLA_TSS2)
params = Params(str(tmp_path))
params.put_bool('OpenpilotEnabledToggle', True, block=True)
current = [BASE + 490_000_000]
reads, instances = [], []
metadata_reconciled = [False]
@@ -86,7 +86,7 @@ def computed_registered_fit(cp, runtime, observed_ns, *, mode="standard", baseli
model = fit_physical(runtime.identity, 'normalized_torque', context_id, tuple(rows), lag_ns=0,
min_excitation=0.05, max_rmse=1e-9, now_ns=observed_ns)
assert model is not None
factory = SelectedControllerFactory(cp, mode)
factory = SelectedControllerFactory(cp, mode, turn_assist=runtime.descriptor.turn_assist)
engine = ControllerReplay(factory, objective, identity=runtime.identity,
baseline_revision=runtime.baseline_revision, model_revision='known-plant-v1')
ci = interfaces[cp.carFingerprint](cp)
@@ -102,7 +102,8 @@ def exact_local_emission(witness, reference_raw, candidate_raw, recorded_debt, r
class EncoderBranch:
"""Own previous raw/normalized torque and modified-Civic state per branch."""
def __init__(self, *, quality_contract=RECORDED_DEBT, identification_policy=STRICT_IDENTIFICATION):
def __init__(self, *, quality_contract=RECORDED_DEBT, identification_policy=STRICT_IDENTIFICATION, allow_unclipped_equal_output=False):
self.allow_unclipped_equal_output = allow_unclipped_equal_output
self.quality_contract = clipping_quality(quality_contract)
self.identification_policy = validate_identification_policy(identification_policy)
if (self.identification_policy == NATIVE_SHADOW_IDENTIFICATION and
@@ -198,6 +199,28 @@ class EncoderBranch:
admitted = exact_local_emission(witness, reference_raw, raw, original_demand, reference_demand, candidate_demand)
if not shadow and (not original_demand or not actual and not admitted):
raise ValueError('candidate encoder clipping regression')
if (raw is None and self.allow_unclipped_equal_output and not actual and not shadow and not event.limited and
witness.mapping_kind == 'round' and not witness.shape and witness.steer_requested and
witness.unlimited_raw == witness.final_raw and witness.limiter_kind in ('driver', 'measured')):
from opendbc.car.steering_projection import project_request
from opendbc.car.lateral import apply_driver_steer_torque_limits, apply_meas_steer_torque_limits
requested_raw = project_request(witness, request)
reference_raw = project_request(witness, witness.requested_torque)
# Replaying a different history can engage a rate limiter that was idle
# on the original drive. BOTH requests must produce the EXACT same native
# output from this SAME candidate prestate, with no larger request.
if (reference_raw == witness.unlimited_raw and requested_raw is not None and reference_raw is not None and
(requested_raw == reference_raw or requested_raw * reference_raw > 0 and abs(requested_raw) < abs(reference_raw))):
params = SimpleNamespace(STEER_MAX=witness.effective_max, **dict(witness.limits))
if witness.limiter_kind == 'driver':
candidate_output = apply_driver_steer_torque_limits(requested_raw, self.previous, witness.measured_torque, params, int(witness.effective_max))
reference_output = apply_driver_steer_torque_limits(reference_raw, self.previous, witness.measured_torque, params, int(witness.effective_max))
else:
candidate_output = apply_meas_steer_torque_limits(requested_raw, self.previous, witness.measured_torque, params)
reference_output = apply_meas_steer_torque_limits(reference_raw, self.previous, witness.measured_torque, params)
if candidate_output == reference_output:
raw = candidate_output
if raw is None and (event.limited or shadow) and witness.limiter_kind in ('driver', 'measured'):
from opendbc.car.steering_projection import project_request
from opendbc.car.lateral import apply_driver_steer_torque_limits, apply_meas_steer_torque_limits
@@ -454,9 +477,10 @@ class EmissionHistory:
return base, features
def rollout(self, requests, start_ns, end_ns, windows=(), *, quality_contract=RECORDED_DEBT,
identification_policy=STRICT_IDENTIFICATION, feedback_reported=None):
identification_policy=STRICT_IDENTIFICATION, feedback_reported=None, allow_unclipped_equal_output=False):
"""Advance on EVERY actual emission, including unscored warmup emissions."""
branch = EncoderBranch(quality_contract=quality_contract, identification_policy=identification_policy)
branch = EncoderBranch(quality_contract=quality_contract, identification_policy=identification_policy,
allow_unclipped_equal_output=allow_unclipped_equal_output)
if feedback_reported is not None and (type(feedback_reported) is not dict or feedback_reported):
raise ValueError('empty private candidate reported CO sink required')
values = {}
@@ -469,7 +493,8 @@ class EmissionHistory:
raise ValueError('missing consumed controller source')
window = next((i for i, (a, b) in enumerate(windows) if a <= event.control_ns <= b), 0)
if window != last_window:
branch = EncoderBranch(quality_contract=quality_contract, identification_policy=identification_policy)
branch = EncoderBranch(quality_contract=quality_contract, identification_policy=identification_policy,
allow_unclipped_equal_output=allow_unclipped_equal_output)
previous = None
self.decode(event)
value = branch.step(event, requests[event.control_ns])
@@ -18,6 +18,9 @@ DESCRIPTIONS = {
'lane_change:pace': 'Sets how quickly steering moves into the adjacent lane. Higher values change lanes more quickly; lower values make steering gentler.',
'lane_change:speed': 'Sets the lowest speed at which lane-change assistance can begin.',
'lane_change:one': 'Allows one assisted lane change until you release the turn signal.',
'lane_change:require_cruise': ('Limits nudgeless lane changes to when factory cruise or openpilot speed control is engaged. ' +
'With cruise off, use a wheel nudge. If cruise engages after signaling, release and reapply the signal. ' +
'Off also allows automatic changes during lateral-only driving; lane and blindspot checks still apply.'),
'lane_change:auto': 'Starts a lane change after the signal delay without a steering nudge, when the adjacent lane and blind spot are clear.',
'lane_change:delay': 'Sets the waiting time after signaling before an automatic lane change can begin.',
'lane_change:width': 'Sets the minimum detected width of the adjacent lane for an automatic lane change.',
@@ -169,6 +169,7 @@
"lane_change:enabled",
"lane_change:one",
"lane_change:pace",
"lane_change:require_cruise",
"lane_change:reset",
"lane_change:speed",
"lane_change:turn_signal_guidance",
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'
import { compile } from '../web/vendor/vue/vue.esm-browser.js'
import { FlmPage } from '../web/js/flm.js'
import { GalaxySettingRow } from '../web/js/galaxy-setting-row.js'
import { SURFACE_NAMES, PID_UNSUPPORTED, validTrainingResults, validTrackingResults, trackingView, surfaceRows, measurementText, validTrackingValues, trackingCoordinates, validTrackingPlot, trackingPlotView, sourceSeconds, tunerStatus } from '../web/js/flm-training-results.js'
const coordinates = amount => SURFACE_NAMES.map((name, i) => [name, i === 0 ? amount : 0])
@@ -263,7 +264,8 @@ assert.equal(ctx.recordedTrackingChart.valid,true)
assert(ctx.recordedTrackingChart.paths.every(path=>path.length===1),'Both original series render')
const measuredText=text(render.call(ctx,ctx,[]))
assert(measuredText.includes('Recorded baseline') && measuredText.includes(measurementText(.12)))
assert(measuredText.includes('Saved adjustments') && measuredText.includes('Awaiting next-drive measurement'))
assert(measuredText.includes('Saved adjustments') && measuredText.includes('No pending trial reported'))
assert(!measuredText.includes('Awaiting next-drive measurement'), 'Recorded analysis without a saved trial must not invent a pending trial')
assert(measuredText.includes('No interpolation or delay alignment'))
assert(measuredText.includes('22 / 24 original controller and encoder windows passed'))
assert(measuredText.includes('candidate triggered an encoder limiter'))
@@ -285,8 +287,56 @@ assert.equal(ctx.offroad.observed.units,'degrees','PID observations retain their
ctx.report.current.details['Recorded steering response']=measuredWindow
ctx.report.current.details['Saved route configuration state']='ready_for_next_drive'
assert.equal(ctx.state.title,'Tune saved for the next drive')
assert(text(render.call(ctx,ctx,[])).includes('Awaiting next-drive measurement'), 'A genuinely reported saved trial awaits the next drive')
assert.equal(ctx.currentApplied,false,'Next-drive saving never fabricates live application')
ctx.now=3000
assert.equal(ctx.reportFresh,false)
assert.notEqual(ctx.state.title,'Tune saved for the next drive','Expired report cannot assert the current saved tune')
console.log('Actual parked Vue render exposes all five route candidates, distinguishes saving/application and expires stale status')
// Declared UI states only: never enable driving edits or contact a device.
// Render BOTH the parent and the actual setting-row template so a parent prop
// override cannot silently remove help while the slider remains editable.
const settingRender = compile(GalaxySettingRow.template, { onError: e => { throw e }, decodeEntities: v => v.replaceAll('&amp;', '&') })
const vnodeRows = vnode => Array.isArray(vnode) ? vnode.flatMap(vnodeRows)
: vnode && typeof vnode === 'object' ? [vnode, ...vnodeRows(vnode.children)] : []
const manualContext = { ...FlmPage.data(), mode: 'local', view: 'tune', group: 'balance', unauthorized() {} }
for (const [name, fn] of Object.entries(FlmPage.methods)) manualContext[name] = fn.bind(manualContext)
for (const [name, get] of Object.entries(FlmPage.computed)) Object.defineProperty(manualContext, name, { get: () => get.call(manualContext) })
const manualHelp = 'Sets the extra planned steering effort in left curves, before tracking-error corrections. Higher adds more effort; lower adds less.'
for (const state of [{ parked: true }, { parked: false }, { parked: true }, { parked: true, error: 'Connection lost' }]) {
const calls = []
manualContext.manualFeed = { active: true, async previewValue(index, value) { calls.push([index, value]) } }
manualContext.manualState = { status: 'ready', pending: null, error: state.error || '', data: { rows: [{
coordinate: 'ff_gain_left', label: 'Steering gain', reason: state.parked ? manualHelp : manualHelp + ' Parked only.',
available: state.parked, action: true, value: 0.5, defaultValue: 0, resetAvailable: state.parked,
minimum: -25, maximum: 25, step: 0.25, unit: '%',
}] } }
const parentRows = vnodeRows(render.call(manualContext, manualContext, [])).filter(node => node.props?.row?.coordinate === 'ff_gain_left')
assert.equal(parentRows.length, 1)
const props = parentRows[0].props
assert.equal(props.row.reason, manualContext.manualState.data.rows[0].reason, 'Available manual sliders must retain their descriptions')
const defaults = Object.fromEntries(Object.entries(GalaxySettingRow.props)
.filter(([, prop]) => Object.hasOwn(prop, 'default')).map(([name, prop]) => [name, prop.default]))
const settingContext = { ...defaults, ...GalaxySettingRow.data(), ...props,
saveValue: props.saveValue || props['save-value'] }
assert.equal(typeof settingContext.saveValue, 'function')
for (const [name, fn] of Object.entries(GalaxySettingRow.methods)) settingContext[name] = fn.bind(settingContext)
for (const [name, get] of Object.entries(GalaxySettingRow.computed)) Object.defineProperty(settingContext, name, { get: () => get.call(settingContext) })
const rowTree = settingRender.call(settingContext, settingContext, [])
assert(text(rowTree).includes(manualHelp), 'Help must render when parked, driving, and after returning to park')
if (!state.parked) assert(text(rowTree).includes('Parked only.'), 'The driving restriction must remain visible')
const input = vnodeRows(rowTree).find(node => node.type === 'input' && node.props?.type === 'range')
const reset = vnodeRows(rowTree).find(node => node.type === 'button' && node.props?.['aria-label']?.startsWith('Reset '))
assert(input && reset)
const locked = !state.parked || !!state.error
assert.equal(!!input.props.disabled, locked, 'Descriptions must not change the driving/error slider lockout')
assert.equal(!!reset.props.disabled, locked, 'Descriptions must not change the reset lockout')
await settingContext.commit(1)
assert.equal(calls.length, locked ? 0 : 1, 'Locked slider events must not reach settings preview/save')
if (locked) {
assert.equal(await manualContext.saveManual(0, 1), false, 'The parent must also reject direct locked saves')
assert.equal(calls.length, 0)
} else assert.deepEqual(calls, [[0, 1]])
}
console.log('Actual FLM and setting-row Vue renders preserve parked/driving descriptions and reject locked saves')
@@ -1,5 +1,5 @@
import assert from "node:assert/strict"
import { validTrainingResults, trainingSummary, detailText, offroadView, tunerStatus } from "../web/js/flm-training-results.js"
import { validTrainingResults, trainingSummary, detailText, offroadView, tunerStatus, SURFACE_NAMES, FRICTION_NAMES, surfaceRows, offroadCoordinateEvidence } from "../web/js/flm-training-results.js"
const report = () => ({ version: 1, current: { fresh: true, ageSeconds: .1, enabled: true,
status: "collecting", reason: "independent_evidence", progress: { samples: 140, blocks: 3, requiredBlocks: 16 },
@@ -82,3 +82,41 @@ for (const mutate of [v => v.current.details['Recorded route suggestions'][0][1]
const malformed = structuredClone(completed); mutate(malformed); assert.equal(offroadView(malformed), null)
}
console.log('Completed-drive recommendations, parked analysis, saved-versus-applied state and malformed candidate refusal passed')
// Reproduce the user's observed combination: three saved curve changes, two
// UNSAVED scalar recommendations, no live application and empty event history.
const reconciled = structuredClone(completed)
Object.assign(reconciled.current.details, {
'Saved route configuration state': 'ready_for_next_drive', 'Saved route adjustment count': 3,
'Full surface coordinates': SURFACE_NAMES.map(name => [name, 0]),
'Saved friction-curve adjustments': FRICTION_NAMES.map((name, index) => [name, [0, 0, .002775, .0037125, .0037125][index]]),
'Controller and encoder check windows': [24, 24], 'Automatic tune refusal reasons': [],
'Recorded route suggestions': [['turn_in_boost_left', .005, 13, 'controller_native_checks_not_passed'],
['unwind_taper_right', .005, 6, 'controller_native_checks_not_passed']],
})
assert(validTrainingResults(reconciled))
const observedState = offroadView(reconciled)
assert.equal(observedState.savedCount, 3)
assert.equal(Object.values(observedState.savedValues).filter(value => value !== 0).length, 3)
assert.equal(observedState.savedValues.friction_threshold_mid, .002775)
assert.equal(observedState.savedValues.friction_threshold_highway, .0037125)
assert.equal(observedState.savedValues.turn_in_boost_left, 0, 'A recommendation must not overwrite the saved map')
assert.equal(observedState.suggestions[0].delta, .005)
assert.equal(surfaceRows(reconciled, false).find(row => row.name === 'turn_in_boost_left').recommendations.length, 1)
assert(surfaceRows(reconciled, false).every(row => row.pending === null && row.applied === null))
assert.match(offroadCoordinateEvidence(reconciled, 'turn_in_boost_left'), /recommendation: change \+0.005; not saved/)
assert.doesNotMatch(offroadCoordinateEvidence(reconciled, 'turn_in_boost_left'), /applied now|Auto Learn saved/)
assert.match(offroadCoordinateEvidence(reconciled, 'friction_threshold_mid'), /Auto Learn saved: \+0.002775 for the next drive/)
assert.equal(reconciled.history.length, 0, 'The completed assessment must not fabricate history events')
assert.equal(reconciled.current.application.applied, false)
const noStore = structuredClone(reconciled)
delete noStore.current.details['Saved route configuration state']
assert.equal(offroadView(noStore).savedValues, null, 'An unqualified proposal must never be labeled a saved tune')
const badCurve = structuredClone(reconciled)
badCurve.current.details['Saved friction-curve adjustments'][0][1] = NaN
assert.equal(offroadView(badCurve).savedValues.friction_threshold_crawl, undefined)
const adjustedSlider = structuredClone(reconciled)
adjustedSlider.current.details['Full surface coordinates'][0][1] = .005
assert.match(offroadCoordinateEvidence(adjustedSlider, SURFACE_NAMES[0]), /Auto Learn saved: \+0.005 for the next drive/)
assert(surfaceRows(adjustedSlider, false).every(row => row.applied === null))
console.log('Saved curve and slider adjustments, unsaved recommendations, absent history and current application reconcile without writes')
@@ -135,12 +135,33 @@ export function surfaceRows(report, live = false) {
const values = validTrackingValues(report?.current?.trackingValues) ? report.current.trackingValues : null
const accepted = values ? trackingCoordinates(values.accepted) : null
const pending = values && live && report.current?.tracking?.bindingCurrent === true ? trackingCoordinates(values.pending) : null
const route = offroadView(report)
return SURFACE_NAMES.map(name => ({ name, baseline: 0, accepted: accepted?.[name] ?? null, pending: pending?.[name] ?? null, learned: learned?.[name] ?? null,
saved: route?.savedValues?.[name] ?? null, recommendations: route?.suggestions.filter(row => row.parameter === name) || [],
applied: applied?.[name] ?? null, pidSupported: !PID_UNSUPPORTED.includes(name) }))
}
export const coordinateText = value => value === null ? "Not reported" :
value === 0 ? "0" : Math.abs(value) < 1e-6 ? value.toExponential(3) : (value > 0 ? "+" : "") + value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "")
export const FRICTION_NAMES = Object.freeze(['friction_threshold_crawl', 'friction_threshold_low', 'friction_threshold_mid',
'friction_threshold_fast', 'friction_threshold_highway'])
const frictionCoordinates = rows => Array.isArray(rows) && rows.length === 5 && rows.every((row, i) =>
Array.isArray(row) && row.length === 2 && row[0] === FRICTION_NAMES[i] && typeof row[1] === 'number' &&
Number.isFinite(row[1]) && Math.abs(row[1]) <= .25) ? Object.fromEntries(rows) : null
export function offroadCoordinateEvidence(report, name, live = false) {
const route = offroadView(report), row = surfaceRows(report, live).find(row => row.name === name)
const statements = []
if (row?.applied !== null && row?.applied !== undefined && row.applied !== 0)
statements.push(`Auto Learn applied now: ${coordinateText(row.applied)}.`)
const saved = route?.savedValues?.[name]
if (saved !== undefined && saved !== 0)
statements.push(`Auto Learn saved: ${coordinateText(saved)}${route.configurationState === 'ready_for_next_drive' ? ' for the next drive' : ''}.`)
for (const suggestion of route?.suggestions || []) if (suggestion.parameter === name)
statements.push(`Previous-drive recommendation: change ${coordinateText(suggestion.delta)}; not saved.`)
return statements.join(' ')
}
export function validLearningPreference(value) {
return value !== null && typeof value === "object" && !Array.isArray(value) && value.version === 1 &&
typeof value.valid === "boolean" && [true, false, null].includes(value.enabled) &&
@@ -219,9 +240,13 @@ export function offroadView(report) {
const checks = Array.isArray(checked) && checked.length === 2 && checked.every(stamp) && checked[0] <= checked[1] && checked[1] <= 36 &&
Array.isArray(refused) && refused.length <= 36 && refused.every(r => Array.isArray(r) && r.length === 2 && text(r[0]) && stamp(r[1])) ?
{ passed: checked[0], required: checked[1], refusals: refused.map(([reason, count]) => ({ reason, count })) } : null
const configurationState = details['Saved route configuration state'] ?? null
const stored = ['ready_for_next_drive', 'accepted', 'accepted_after_drive', 'rolled_back'].includes(configurationState)
const savedSurface = stored ? fullCoordinates(details['Full surface coordinates']) : null
const savedFriction = stored ? frictionCoordinates(details['Saved friction-curve adjustments']) : null
return { route: details['Completed drive analyzed'], samples: details['Recorded steering observations'],
reason: details['Analysis decision'], saved: details['Automatic configuration changed'] === true,
configurationState: details['Saved route configuration state'] ?? null,
configurationState, savedValues: savedSurface ? { ...savedSurface, ...(savedFriction || {}) } : null,
observed, checks, savedCount: stamp(savedCount) && savedCount <= 35 ? savedCount : null,
suggestions: rows.map(([parameter, delta, windows, qualification]) => ({ parameter, delta, windows, qualification })),
moreSuggestions: details['More recorded suggestions omitted'] === true }
+19 -7
View File
@@ -6,10 +6,12 @@ import { GalaxySettingRow, settingControl } from "./galaxy-setting-row.js"
import { PollTimer, connectionError } from "./polling.js"
import { requestJson } from "./startup.js"
import { PlotGraph, PlotsPage, chartGeometry } from "./plots.js"
import { validTrainingResults, readable, detailText, surfaceRows, coordinateText, trackingView, measurementText, validTrackingValues, trackingPlotView, sourceSeconds, validLearningPreference, SURFACE_GROUPS, tunerStatus, learningReason, sourceCaptureCounts, historyTitle, offroadView } from "./flm-training-results.js"
import { validTrainingResults, readable, detailText, surfaceRows, coordinateText, trackingView, measurementText, validTrackingValues, trackingPlotView, sourceSeconds, validLearningPreference, SURFACE_GROUPS, tunerStatus, learningReason, sourceCaptureCounts, historyTitle, offroadView, FRICTION_NAMES, offroadCoordinateEvidence } from "./flm-training-results.js"
const VIEWS = ["overview", "tune", "activity", "plots"]
const PARAMETER_LABELS = {
friction_threshold_crawl: "Crawl friction threshold", friction_threshold_low: "Low-speed friction threshold",
friction_threshold_mid: "Mid-speed friction threshold", friction_threshold_fast: "Fast friction threshold", friction_threshold_highway: "Highway friction threshold",
ff_gain_left: "Left steering gain", ff_gain_right: "Right steering gain",
turn_in_boost_left: "Left turn-in boost", turn_in_boost_right: "Right turn-in boost",
unwind_taper_left: "Left unwind taper", unwind_taper_right: "Right unwind taper",
@@ -74,6 +76,8 @@ export const FlmPage = {
measurements() { return this.tracking ? this.trackingProgress?.capturedPoses ?? null : this.current?.progress?.samples ?? null },
trackingPlot() { return trackingPlotView(this.report, this.now - this.receivedAt) },
offroad() { return offroadView(this.report) },
savedAdjustments() { return Object.entries(this.offroad?.savedValues || {}).filter(([, value]) => value !== 0).map(([name, value]) => ({ name, value })) },
savedFriction() { return this.savedAdjustments.filter(row => FRICTION_NAMES.includes(row.name)) },
recordedTrackingChart() { return chartGeometry(this.offroad?.observed?.points || [], ['desired', 'actual']) },
pairedTrackingChart() { return chartGeometry(this.trackingPlot?.points || [], ["desiredForceAccel", "measuredForceAccel"]) },
pairedErrorChart() { return chartGeometry(this.trackingPlot?.points || [], ["errorForceAccel"]) },
@@ -100,10 +104,16 @@ export const FlmPage = {
},
methods: {
readable, detailText, coordinateText, measurementText, sourceSeconds, learningReason, historyTitle,
manualEvidence(name) { return offroadCoordinateEvidence(this.report, name, this.live) },
detailLabel(key) { return ['Fit attempts', 'Fit results', 'Fit in progress', 'Submitted fit jobs (reporter lifetime)'].includes(key) ? 'Legacy fitter · ' + key : readable(key) },
amount(value) { return value === null || value === undefined ? "—" : value.toLocaleString() },
parameterLabel(name) { return PARAMETER_LABELS[name] || readable(name) },
groupChanges(group) {
if (this.offroad) {
const saved = this.rows.filter(row => group.names.includes(row.name) && row.saved !== null && row.saved !== 0).length
const suggested = this.rows.filter(row => group.names.includes(row.name) && row.recommendations.length).length
return [saved ? `${saved} saved` : '', suggested ? `${suggested} recommendations` : ''].filter(Boolean).join(' · ') || (this.offroad.savedValues ? 'No saved adjustment' : 'Not reported')
}
if (!this.trackingValues) return "Not reported"
const count = this.rows.filter(row => group.names.includes(row.name) && row.accepted !== null && row.accepted !== 0).length
return count ? `${count} accepted` : "Baseline"
@@ -192,8 +202,8 @@ export const FlmPage = {
<section class="gx-card gx-tuner__section"><div class="gx-tuner__section-heading"><div><p class="gx-tuner__eyebrow">{{ offroad ? 'RECORDED-DRIVE ANALYSIS' : live ? 'THE CURRENT EXPERIMENT' : 'LAST REPORTED EXPERIMENT' }}</p><h3>{{ offroad ? 'Next-drive tuning' : tracking?.acknowledged && tracking.status === 'trial_active' ? 'Trial in progress' : 'Trial results' }}</h3></div></div>
<p v-if="tracking">{{ learningReason(tracking.reason) }}</p><p v-else-if="offroad">{{ learningReason(offroad.reason) }}</p><p v-else>Waiting for driving samples. Auto Learn compares steering error before and during a trial.</p>
<template v-if="offroad?.checks"><p>{{ offroad.checks.passed }} / {{ offroad.checks.required }} original controller and encoder windows passed.</p><p v-for="(refusal, index) in offroad.checks.refusals" :key="index">{{ refusal.count }} window(s): {{ learningReason(refusal.reason) }}</p></template>
<template v-if="offroad"><ul v-if="offroad.suggestions.length"><li v-for="(suggestion, index) in offroad.suggestions" :key="index"><strong>{{ parameterLabel(suggestion.parameter) }} {{ coordinateText(suggestion.delta) }}</strong> · {{ amount(suggestion.windows) }} recorded windows · not approved for automatic saving</li></ul><p v-if="offroad.moreSuggestions" class="gx-tuner__caption">Additional candidates are retained in the device's full assessment.</p><p class="gx-tuner__caption">{{ offroad.configurationState === 'ready_for_next_drive' ? 'A separate qualified tune is saved for the next drive. Current application still requires controller evidence.' : 'Your saved tune is unchanged unless the controller and steering-output checks pass.' }}</p></template>
<div class="gx-tuner__comparison"><div><span>{{ offroad ? 'Recorded baseline' : 'Before' }}</span><strong>{{ measurementText(offroad?.observed?.errorRms ?? tracking?.baselineErrorRms ?? null) }}</strong></div><i class="bi bi-arrow-right" aria-hidden="true"></i><div><span>Trial</span><strong>{{ offroad ? 'Awaiting next-drive measurement' : measurementText(tracking?.futureErrorRms ?? null) }}</strong></div></div><p class="gx-tuner__caption">Tracking error RMS · {{ offroad?.observed?.units ?? 'm/s²' }} · lower is better</p>
<template v-if="offroad"><h4 v-if="savedAdjustments.length">Saved Auto Learn adjustments</h4><ul v-if="savedAdjustments.length"><li v-for="row in savedAdjustments" :key="row.name"><strong>{{ parameterLabel(row.name) }} {{ coordinateText(row.value) }}</strong> · {{ offroad.configurationState === 'ready_for_next_drive' ? 'saved for next drive' : 'saved configuration' }}</li></ul><h4 v-if="offroad.suggestions.length">Other recorded candidates · not saved</h4><ul v-if="offroad.suggestions.length"><li v-for="(suggestion, index) in offroad.suggestions" :key="index"><strong>{{ parameterLabel(suggestion.parameter) }} change {{ coordinateText(suggestion.delta) }}</strong> · {{ amount(suggestion.windows) }} recorded windows · not approved for automatic saving</li></ul><p v-if="offroad.moreSuggestions" class="gx-tuner__caption">Additional candidates are retained in the device's full assessment.</p><p class="gx-tuner__caption">Saved values and unsaved candidates are separate. Current application requires fresh controller evidence.</p></template>
<div class="gx-tuner__comparison"><div><span>{{ offroad ? 'Recorded baseline' : 'Before' }}</span><strong>{{ measurementText(offroad?.observed?.errorRms ?? tracking?.baselineErrorRms ?? null) }}</strong></div><i class="bi bi-arrow-right" aria-hidden="true"></i><div><span>Trial</span><strong>{{ offroad ? offroad.configurationState === 'ready_for_next_drive' ? 'Awaiting next-drive measurement' : 'No pending trial reported' : measurementText(tracking?.futureErrorRms ?? null) }}</strong></div></div><p class="gx-tuner__caption">Tracking error RMS · {{ offroad?.observed?.units ?? 'm/s²' }} · lower is better</p>
<p v-if="improvement !== null" class="gx-tuner__observed">{{ Math.abs(improvement).toFixed(1) }}% {{ improvement >= 0 ? 'lower' : 'higher' }} observed error<small>{{ state.key === 'confirmed' ? 'Comparison passed; controller acknowledged' : 'Observation so far, not a confirmed improvement' }}</small></p>
<div v-if="quota" class="gx-tuner__quota"><div><span>Comparable trial windows</span><strong>{{ quota.completed }} / {{ quota.required }}</strong></div><div class="gx-tuner__progress" role="progressbar" aria-label="Comparable trial windows" :aria-valuenow="quota.completed" :aria-valuemax="quota.required" aria-valuemin="0"><span :style="{ width: quota.percent + '%' }"></span></div></div>
<p class="gx-tuner__caption">Changes that make steering worse are rolled back. Accepted and saved values are shown separately.</p>
@@ -201,25 +211,27 @@ export const FlmPage = {
</section>
<section v-if="view === 'tune'" id="tuner-panel-tune" role="tabpanel" aria-labelledby="tuner-tab-tune" class="gx-tuner__panel">
<div class="gx-tuner__section-heading"><div><h3>Your tune</h3><p>Thirty steering parameters, grouped by behavior.</p></div><span class="gx-tuner__pill">Manual + Auto Learn</span></div>
<section v-if="savedFriction.length" class="gx-card gx-tuner__section"><h4>Auto Learn friction adjustments</h4><p class="gx-tuner__caption">These five-point curve adjustments are separate from the thirty manual sliders.</p><ul><li v-for="row in savedFriction" :key="row.name">{{ parameterLabel(row.name) }} <strong>{{ coordinateText(row.value) }}</strong> · {{ offroad.configurationState === 'ready_for_next_drive' ? 'saved for next drive' : 'saved configuration' }}</li></ul></section>
<div class="gx-tuner__tune-layout"><nav class="gx-tuner__groups" aria-label="Steering parameter groups"><button v-for="item in groups" :key="item.id" type="button" :aria-pressed="group === item.id" @click="group = item.id"><i :class="['bi', item.icon]" aria-hidden="true"></i><span>{{ item.label }}<small>{{ item.names.length }} parameters · {{ groupChanges(item) }}</small></span><i class="bi bi-chevron-right" aria-hidden="true"></i></button></nav>
<section class="gx-card gx-tuner__section gx-tuner__parameters"><div class="gx-tuner__section-heading"><div><h3>{{ selectedGroup.label }}</h3><p>{{ selectedGroup.description }}</p></div></div>
<section class="gx-tuner__manual" aria-label="Manual tuning"><h4>Manual tune</h4><p class="gx-tuner__caption">Parked edits apply to the next drive. Auto Learn pauses while manual overrides are set. Zero removes an override.</p>
<GxNotice v-if="manualState.error" tone="danger">{{ manualState.error }}</GxNotice>
<p v-if="!manualState.data" class="gx-tuner__caption">{{ manualState.status === 'loading' ? 'Loading saved manual tune…' : 'Manual tuning unavailable. Reconnecting…' }}</p>
<p v-else-if="!manualRows.length" class="gx-tuner__caption">{{ manualState.data.rows[0]?.reason || 'Manual tuning is unavailable for this controller.' }}</p>
<GalaxySettingRow v-for="{ row, index } in manualRows" :key="row.coordinate" :row="{ ...row, label: parameterLabel(row.coordinate), reason: row.available ? '' : row.reason }" :index="index" :busy="manualBusy" :disabled="!!manualState.error" :save-value="saveManual" @reset-default="index => manualFeed.resetDefault(index)" />
<template v-for="{ row, index } in manualRows" :key="row.coordinate"><GalaxySettingRow :row="{ ...row, label: parameterLabel(row.coordinate) }" :index="index" :busy="manualBusy" :disabled="!!manualState.error" :save-value="saveManual" @reset-default="index => manualFeed.resetDefault(index)" /><p v-if="manualEvidence(row.coordinate)" class="gx-tuner__caption">{{ manualEvidence(row.coordinate) }} Manual override shown above is separate.</p></template>
<GalaxySettingRow v-for="{ row, index } in manualActions" :key="index" :row="row" :index="index" :busy="manualBusy" :disabled="!!manualState.error" :save-value="saveManual" @review="reviewManualReset" />
</section>
<h4>Auto Learn evidence</h4><p class="gx-tuner__caption">These reported results are separate from your manual settings.</p>
<div class="gx-tuner__parameter-head" aria-hidden="true"><span>Parameter</span><span>{{ trackingValues ? 'Accepted' : 'Proposal' }}</span><span>Trial</span><span>Applied now</span></div>
<div v-for="row in groupRows" :key="row.name" class="gx-tuner__parameter"><div><h4>{{ parameterLabel(row.name) }}</h4><small v-if="!row.pidSupported">Torque only · PID unsupported</small></div><div><span class="gx-tuner__mobile-label">{{ trackingValues ? 'Accepted' : 'Proposal' }}</span><strong>{{ coordinateText(trackingValues ? row.accepted : row.learned) }}</strong></div><div><span class="gx-tuner__mobile-label">Trial</span><strong :class="{ 'gx-tuner__pending': row.pending !== null && row.pending !== row.accepted }">{{ coordinateText(row.pending) }}</strong></div><div><span class="gx-tuner__mobile-label">Applied now</span><strong :class="{ 'gx-tuner__applied': row.applied !== null && row.applied !== 0 }">{{ coordinateText(row.applied) }}</strong></div></div>
<div class="gx-tuner__parameter-head" aria-hidden="true"><span>Parameter</span><span>{{ offroad ? 'Saved' : trackingValues ? 'Accepted' : 'Proposal' }}</span><span>{{ offroad ? 'Recommendation · not saved' : 'Trial' }}</span><span>Applied now</span></div>
<div v-for="row in groupRows" :key="row.name" class="gx-tuner__parameter"><div><h4>{{ parameterLabel(row.name) }}</h4><small v-if="!row.pidSupported">Torque only · PID unsupported</small></div><div><span class="gx-tuner__mobile-label">{{ offroad ? 'Saved' : trackingValues ? 'Accepted' : 'Proposal' }}</span><strong>{{ coordinateText(offroad ? row.saved : trackingValues ? row.accepted : row.learned) }}</strong></div><div><span class="gx-tuner__mobile-label">{{ offroad ? 'Recommendation · not saved' : 'Trial' }}</span><template v-if="offroad"><strong v-for="(suggestion, index) in row.recommendations" :key="index">change {{ coordinateText(suggestion.delta) }}</strong><strong v-if="!row.recommendations.length">None reported</strong></template><strong v-else :class="{ 'gx-tuner__pending': row.pending !== null && row.pending !== row.accepted }">{{ coordinateText(row.pending) }}</strong></div><div><span class="gx-tuner__mobile-label">Applied now</span><strong :class="{ 'gx-tuner__applied': row.applied !== null && row.applied !== 0 }">{{ coordinateText(row.applied) }}</strong></div></div>
<details class="gx-tuner__disclosure"><summary>How to read these values</summary><p>Zero is the original baseline. These are correction coordinates, not raw degrees or torque. Speed-window coordinates scale the original speed range; deadband and angle assistance use the controller's physical normalizer. A trial is an offer, not proof of application. All 30 stages exist for torque; 26 exist for torque-actuated PID.</p></details>
</section></div>
</section>
<section v-show="view === 'plots'" id="tuner-panel-plots" role="tabpanel" aria-labelledby="tuner-tab-plots" class="gx-tuner__panel"><PlotsPage :mode="mode" :unauthorized="unauthorized" :active="view === 'plots'" embedded /></section>
<section v-if="view === 'activity'" id="tuner-panel-activity" role="tabpanel" aria-labelledby="tuner-tab-activity" class="gx-tuner__panel"><section class="gx-card gx-tuner__section"><div class="gx-tuner__section-heading"><div><p class="gx-tuner__eyebrow">LEARNING ACTIVITY</p><h3>What the tuner found</h3></div><span class="gx-tuner__pill">Reported observations</span></div>
<template v-if="offroad"><h4>Completed drive {{ offroad.route }}</h4><p>{{ learningReason(offroad.reason) }}</p><p v-if="offroad.checks">{{ offroad.checks.passed }} / {{ offroad.checks.required }} controller and encoder windows passed.</p><ul v-if="savedAdjustments.length"><li v-for="row in savedAdjustments" :key="row.name">{{ parameterLabel(row.name) }} {{ coordinateText(row.value) }} · {{ offroad.configurationState === 'ready_for_next_drive' ? 'saved for next drive; awaiting measurement' : 'saved configuration' }}</li></ul><ul v-if="offroad.suggestions.length"><li v-for="(suggestion, index) in offroad.suggestions" :key="index">{{ parameterLabel(suggestion.parameter) }} change {{ coordinateText(suggestion.delta) }} · recommendation not saved</li></ul><p class="gx-tuner__caption">Recorded-drive assessment. Current application and road improvement require separate measurements.</p></template>
<ol v-if="historyRows.length" class="gx-tuner__timeline"><li v-for="(row, index) in historyRows" :key="index"><span class="gx-tuner__timeline-dot" :class="{ 'gx-tuner__timeline-dot--applied': row.applied }"></span><div><strong>{{ historyTitle(row.status) }}</strong><p>{{ learningReason(row.reason) }}</p><small>{{ row.time }}<template v-if="row.applied"> · Applied at that time</template></small></div></li></ol>
<div v-else class="gx-tuner__empty"><i class="bi bi-clock-history" aria-hidden="true"></i><h4>No findings reported yet</h4><p>This will show real collection, trial and rollback events as the learner reports them.</p></div>
<div v-else-if="!offroad" class="gx-tuner__empty"><i class="bi bi-clock-history" aria-hidden="true"></i><h4>No findings reported yet</h4><p>This will show real collection, trial and rollback events as the learner reports them.</p></div>
<button v-if="(report?.history.length || 0) > 6" type="button" class="gx-btn gx-btn--tonal" @click="showAllHistory = !showAllHistory">{{ showAllHistory ? 'Show recent activity' : 'Show all reported activity' }}</button>
</section>
<details class="gx-card gx-tuner__section gx-tuner__disclosure"><summary>Diagnostics &amp; source details</summary><dl class="gx-tuner__diagnostics"><template v-if="tracking"><dt>Live learner state</dt><dd>{{ tracking.status }}</dd><dt>Live learner reason</dt><dd>{{ tracking.reason }}</dd></template><template v-if="trackingProgress"><dt>Paired inputs retained</dt><dd>{{ amount(trackingProgress.readyPairs) }}</dd></template><dt>Legacy collector state</dt><dd>{{ readable(current?.status) }}</dd><dt>Legacy collector reason</dt><dd>{{ readable(current?.reason) }}</dd><dt>Legacy retained blocks</dt><dd>{{ amount(current?.progress?.blocks) }}</dd><template v-if="current?.fit"><dt>Latest legacy fit</dt><dd>{{ readable(current.fit.status) }} · {{ readable(current.fit.reason) }}</dd><dt>Training / held-out windows</dt><dd>{{ amount(current.fit.trainWindows) }} / {{ amount(current.fit.heldoutWindows) }}</dd></template><template v-if="losses"><dt>Baseline / candidate loss</dt><dd>{{ losses.baseline.toPrecision(5) }} / {{ losses.learned.toPrecision(5) }}</dd></template><template v-for="[key, value] in details" :key="key"><dt>{{ detailLabel(key) }}</dt><dd>{{ detailText(value) }}</dd></template></dl>
@@ -85,6 +85,21 @@ def _fresh_submaster(sm, service: str, now_mono_ns: int, limit_ns: int) -> bool:
return False
def auto_trigger_engaged(sm, policy: LaneChangePolicy, *, openpilot_longitudinal: bool, now_mono_ns: int) -> bool:
try:
control = sm["carControl"]
if not control.latActive:
return False
if not policy.auto_requires_cruise:
return True
if not (_fresh_submaster(sm, "carState", now_mono_ns, CAR_MAX_AGE_NS) and
_fresh_submaster(sm, "carControl", now_mono_ns, CONTROL_MAX_AGE_NS)):
return False
return bool(control.enabled if openpilot_longitudinal else sm["carState"].cruiseState.enabled)
except (AttributeError, KeyError, TypeError, ValueError, OverflowError):
return False
def current_calibration(sm, now_mono_ns: int) -> bool:
if not _fresh_submaster(sm, "extrinsicsCalibration", now_mono_ns, CALIBRATION_MAX_AGE_NS):
return False
+17 -13
View File
@@ -681,6 +681,7 @@ def get_ioniq_6_highway_transition_output_taper_scale(desired_lateral_accel: flo
def get_ioniq_6_low_speed_angle_assist_torque(desired_angle_deg: float, actual_angle_deg: float,
current_output_torque: float, v_ego: float) -> float:
angle_error = desired_angle_deg - actual_angle_deg
turn_in_output = None
if desired_angle_deg * angle_error > 0.0:
speed_weight = _ioniq_6_sigmoid((IONIQ_6_LOW_SPEED_ANGLE_ASSIST_SPEED - max(v_ego, 0.0)) /
IONIQ_6_LOW_SPEED_ANGLE_ASSIST_SPEED_WIDTH)
@@ -701,15 +702,16 @@ def get_ioniq_6_low_speed_angle_assist_torque(desired_angle_deg: float, actual_a
-angle_error,
)
if abs(assist_torque) < 1e-4:
return current_output_torque
if current_output_torque * assist_torque >= 0.0:
turn_in_output = current_output_torque
elif current_output_torque * assist_torque >= 0.0:
add_scale = float(np.interp(abs(current_output_torque),
IONIQ_6_LOW_SPEED_ANGLE_ASSIST_ADD_BP,
IONIQ_6_LOW_SPEED_ANGLE_ASSIST_ADD_V))
return clip_scalar(current_output_torque + (assist_torque * add_scale), -1.0, 1.0)
return clip_scalar(current_output_torque + assist_torque, -1.0, 1.0)
turn_in_output = clip_scalar(current_output_torque + (assist_torque * add_scale), -1.0, 1.0)
else:
turn_in_output = clip_scalar(current_output_torque + assist_torque, -1.0, 1.0)
if abs(desired_angle_deg) >= IONIQ_6_LOW_SPEED_ANGLE_ASSIST_DESIRED_ANGLE_WIDTH:
return turn_in_output
speed_weight = _ioniq_6_sigmoid((IONIQ_6_LOW_SPEED_UNWIND_ASSIST_SPEED - max(v_ego, 0.0)) /
IONIQ_6_LOW_SPEED_UNWIND_ASSIST_SPEED_WIDTH)
@@ -719,13 +721,15 @@ def get_ioniq_6_low_speed_angle_assist_torque(desired_angle_deg: float, actual_a
actual_angle_weight = _ioniq_6_sigmoid((abs(actual_angle_deg) - IONIQ_6_LOW_SPEED_UNWIND_ASSIST_ACTUAL_ANGLE) /
IONIQ_6_LOW_SPEED_UNWIND_ASSIST_ACTUAL_ANGLE_WIDTH)
assist_torque = math.copysign(IONIQ_6_LOW_SPEED_UNWIND_ASSIST_MAX_TORQUE * speed_weight * error_weight * actual_angle_weight, -angle_error)
if abs(assist_torque) < 1e-4:
return current_output_torque
if current_output_torque * assist_torque >= 0.0:
assist_torque *= IONIQ_6_LOW_SPEED_UNWIND_ASSIST_BLEND
return clip_scalar(current_output_torque + assist_torque, -1.0, 1.0)
opposition = max(-current_output_torque * math.copysign(1.0, assist_torque), 0.0)
assist_torque *= float(np.interp(opposition, [0.0, IONIQ_6_LOW_SPEED_ANGLE_ASSIST_ADD_BP[1]],
[IONIQ_6_LOW_SPEED_UNWIND_ASSIST_BLEND, 1.0]))
unwind_output = (current_output_torque if abs(assist_torque) < 1e-4 else
clip_scalar(current_output_torque + assist_torque, -1.0, 1.0))
if turn_in_output is None:
return unwind_output
turn_in_weight = abs(desired_angle_deg) / IONIQ_6_LOW_SPEED_ANGLE_ASSIST_DESIRED_ANGLE_WIDTH
return unwind_output + (turn_in_output - unwind_output) * turn_in_weight
# Buffer curvature and scale at the current speed to avoid pull-away unwind
@@ -29,6 +29,7 @@ class LaneChangePolicy:
close_gap_seconds: float = 0.75
duration_s: float = 3.0 + 25.0 / 9.0
turn_signal_guidance: bool = True
auto_requires_cruise: bool = False
@dataclass(frozen=True)
@@ -60,19 +61,21 @@ def decode(raw: bytes) -> LaneChangePolicy | None:
return None
version = data.get("version")
fields = {"version", "enabled", "minimumSpeedMps", "onePerSignal"}
if version in (2, 3, 4, 5):
if version in (2, 3, 4, 5, 6):
fields |= {"autoLaneChange", "autoDelayS", "minimumLaneWidthM"}
if version in (3, 4, 5):
if version in (3, 4, 5, 6):
fields |= {"closeGap", "closeGapSeconds"}
if version in (4, 5):
if version in (4, 5, 6):
fields |= {"durationS"}
if version == 5:
if version in (5, 6):
fields |= {"turnSignalGuidance"}
if version == 6:
fields |= {"autoRequiresCruise"}
if set(data) != fields:
return None
enabled = data["enabled"]
speed, once = data["minimumSpeedMps"], data["onePerSignal"]
if (type(version) is not int or version not in (1, 2, 3, 4, 5) or type(enabled) is not bool or type(once) is not bool or
if (type(version) is not int or version not in (1, 2, 3, 4, 5, 6) or type(enabled) is not bool or type(once) is not bool or
type(speed) not in (int, float)):
return None
try:
@@ -107,9 +110,10 @@ def decode(raw: bytes) -> LaneChangePolicy | None:
if type(duration) not in (int, float) or not math.isfinite(duration) or not 3.0 <= duration <= 8.0:
return None
guidance = data.get("turnSignalGuidance", True)
if type(guidance) is not bool:
requires_cruise = data.get("autoRequiresCruise", False)
if type(guidance) is not bool or type(requires_cruise) is not bool:
return None
return LaneChangePolicy(enabled, finite_speed, once, auto, delay, width, close_gap, seconds, float(duration), guidance)
return LaneChangePolicy(enabled, finite_speed, once, auto, delay, width, close_gap, seconds, float(duration), guidance, requires_cruise)
def to_value(policy: LaneChangePolicy) -> dict[str, int | bool | float]:
@@ -140,12 +144,13 @@ def to_value(policy: LaneChangePolicy) -> dict[str, int | bool | float]:
raise ValueError("invalid lane-change close gap")
if type(policy.duration_s) not in (int, float) or not math.isfinite(policy.duration_s) or not 3.0 <= policy.duration_s <= 8.0:
raise ValueError("invalid lane-change duration")
if type(policy.turn_signal_guidance) is not bool:
if type(policy.turn_signal_guidance) is not bool or type(policy.auto_requires_cruise) is not bool:
raise ValueError("invalid turn signal guidance")
return {"version": 5, "enabled": policy.enabled, "minimumSpeedMps": finite_speed,
return {"version": 6, "enabled": policy.enabled, "minimumSpeedMps": finite_speed,
"onePerSignal": policy.one_per_signal, "autoLaneChange": policy.auto_lane_change,
"autoDelayS": delay, "minimumLaneWidthM": width,
"closeGap": policy.close_gap, "closeGapSeconds": seconds, "durationS": policy.duration_s, "turnSignalGuidance": policy.turn_signal_guidance}
"closeGap": policy.close_gap, "closeGapSeconds": seconds, "durationS": policy.duration_s, "turnSignalGuidance": policy.turn_signal_guidance,
"autoRequiresCruise": policy.auto_requires_cruise}
def read_saved(params: Any) -> SavedLaneChange:
@@ -28,12 +28,17 @@ CURVATURE_HOLD_CONFIRM_MIN = 0.003
CURVATURE_HOLD_CONFIRM_SWEPT = 0.6
TWITCH_GUARD_MAX_SPEED = 4.0
TWITCH_GUARD_FADE_SPEED = 3.0
TWITCH_GUARD_DURATION = 1.5
TWITCH_GUARD_PLAN_RATIO = 4.0
TWITCH_GUARD_FLOOR = 0.002
TWITCH_GUARD_STRAIGHT_LO = 0.005
TWITCH_GUARD_STRAIGHT_HI = 0.014
TWITCH_GUARD_MIN_REACH = 12.0
STOP_HOLD_MAX_SPEED = 2.5
STOP_HOLD_DIST = 1.5
STOP_HOLD_RELEASE_DIST = 3.0
STOP_HOLD_MIN_DECEL = 0.3
STOP_HOLD_MAX_CURVATURE = 0.01
STOP_GUARD_DIST = 5.0
def _plan_circle_curvature(xs, ys, lookahead: float) -> float:
@@ -118,12 +123,18 @@ def limit_curvature_to_plan(model_v2, curvature: float, v_ego: float) -> float:
return curvature + (math.copysign(limit, curvature) - curvature) * fade
def update_twitch_guard(remaining: float, v_ego: float, standstill: bool) -> float:
if not (math.isfinite(remaining) and math.isfinite(v_ego)):
return 0.0
def update_twitch_guard(armed: bool, v_ego: float, standstill: bool) -> bool:
if not math.isfinite(v_ego):
return False
if standstill or abs(v_ego) <= 0.3:
return TWITCH_GUARD_DURATION
return max(remaining - DT_CTRL, 0.0)
return True
return armed and v_ego < TWITCH_GUARD_MAX_SPEED
def stopping_distance(v_ego: float, a_ego: float, max_speed: float = STOP_HOLD_MAX_SPEED) -> float:
if not (math.isfinite(v_ego) and math.isfinite(a_ego)) or v_ego >= max_speed:
return math.inf
return v_ego ** 2 / (2.0 * max(-a_ego, 0.01))
TURN_LEAD_T = 1.3
TURN_LEAD_MIN_M = 4.0
TURN_LEAD_MAX_M = 14.0
@@ -149,14 +160,46 @@ class ModelTurnAssist:
self.turn_hold_swept = 0.0
self.turn_hold_handoff_t = 0.0
self.turn_blinker_swept = 0.0
self.twitch_guard_remaining = 0.0
self.twitch_guard_armed = False
self.stop_hold_curvature = None
self.output_curvature = None
self.curvature = 0.0
self.turn_hold_done = False
def update_stopped(self, CS, new_desired_curvature):
if CS.leftBlinker or CS.rightBlinker or self.output_curvature is None:
self.reset()
return new_desired_curvature
if CS.steeringPressed:
self.output_curvature = self.curvature
return self.output_curvature
def update(self, CS, model_v2, CC, new_desired_curvature):
blinker_dir = float(CS.rightBlinker) - float(CS.leftBlinker)
if CC.latActive and self.twitch_guard_remaining > 0.0 and (blinker_dir == 0.0) and (self.turn_hold_curvature == 0.0):
new_desired_curvature = limit_curvature_to_plan(model_v2, new_desired_curvature, CS.vEgo)
stop_dist = stopping_distance(CS.vEgo, CS.aEgo)
entering = CS.aEgo < -STOP_HOLD_MIN_DECEL and stop_dist < STOP_HOLD_DIST and abs(self.curvature) < STOP_HOLD_MAX_CURVATURE
holding = self.stop_hold_curvature is not None and stop_dist < STOP_HOLD_RELEASE_DIST
if blinker_dir != 0.0:
self.twitch_guard_armed = False
elif (CS.aEgo < -STOP_HOLD_MIN_DECEL and abs(self.curvature) < TWITCH_GUARD_STRAIGHT_LO and
stopping_distance(CS.vEgo, CS.aEgo, TWITCH_GUARD_MAX_SPEED) < STOP_GUARD_DIST):
self.twitch_guard_armed = True
if CC.latActive and blinker_dir == 0.0 and self.turn_hold_curvature == 0.0 and (entering or holding):
if CS.steeringPressed:
self.stop_hold_curvature = self.curvature
elif self.stop_hold_curvature is None:
if self.output_curvature is not None:
self.stop_hold_curvature = self.output_curvature
elif self.twitch_guard_armed:
self.stop_hold_curvature = limit_curvature_to_plan(model_v2, new_desired_curvature, CS.vEgo)
else:
self.stop_hold_curvature = new_desired_curvature
new_desired_curvature = self.stop_hold_curvature
self.twitch_guard_armed = True
else:
self.stop_hold_curvature = None
if CC.latActive and self.twitch_guard_armed and (blinker_dir == 0.0) and (self.turn_hold_curvature == 0.0):
new_desired_curvature = limit_curvature_to_plan(model_v2, new_desired_curvature, CS.vEgo)
if blinker_dir == 0.0:
self.turn_blinker_swept = 0.0
else:
@@ -250,6 +293,7 @@ class ModelTurnAssist:
if CS.vEgo < CURVATURE_HOLD_RELEASE_SPEED and (not self.turn_hold_done) and (lead_curvature * blinker_dir > abs(self.turn_hold_curvature)):
held_mag = min(lead_curvature * blinker_dir, abs(self.turn_hold_curvature) + CURVATURE_HOLD_RATCHET_RATE * DT_CTRL)
self.turn_hold_curvature = math.copysign(held_mag, lead_curvature)
self.output_curvature = new_desired_curvature
return new_desired_curvature
@@ -250,9 +250,9 @@ class TestDisabledLearnerProcess(unittest.TestCase):
self.cp = car.CarParams.new_message(brand='hyundai', carFingerprint='HYUNDAI_IONIQ_6')
self.process = managed_processes['steerlearnerd']
def test_default_still_runs_onroad(self):
def test_default_still_runs_onroad_and_parked_analysis(self):
assert self.process.should_run(True, self.params, self.cp)
assert not self.process.should_run(False, self.params, self.cp)
assert self.process.should_run(False, self.params, self.cp)
assert self.process.restart_on_exit
def test_explicit_off_does_not_start_learner(self):
@@ -287,7 +287,7 @@ class TestDisabledLearnerProcess(unittest.TestCase):
from unittest.mock import patch
with patch('openpilot.starpilot.lateral.learning_preferences.read_preference',
side_effect=AssertionError('Offroad learner should not read settings')):
assert not self.process.should_run(False, self.params, self.cp)
assert self.process.should_run(False, self.params, self.cp)
class TestLearningProjectionPreference(unittest.TestCase):
@@ -42,3 +42,24 @@ def test_center_correction_retains_output_saturation_and_symmetry():
negative = assist(-actual - error, -actual, -torque, speed)
assert -1. <= positive <= 1.
assert positive == -negative
@pytest.mark.parametrize('speed', [0.5, 1., 3., 5.])
@pytest.mark.parametrize('actual', [-30., -15., 15., 30.])
@pytest.mark.parametrize('torque', [-.3, 0., .3])
def test_straight_request_transition_with_turned_wheel_is_continuous(speed, actual, torque):
epsilon = 1e-7
below = assist(-epsilon, actual, torque, speed)
center = assist(0., actual, torque, speed)
above = assist(epsilon, actual, torque, speed)
assert max(abs(below - center), abs(above - center)) < 1e-6, 'TURN_ASSIST_STRAIGHT_BRANCH_STEP'
@pytest.mark.parametrize('speed', [0.5, 1., 3., 5.])
@pytest.mark.parametrize('actual', [-30., -15., 15., 30.])
def test_unwind_pid_zero_transition_is_continuous(speed, actual):
epsilon = 1e-7
below = assist(0., actual, -epsilon, speed)
center = assist(0., actual, 0., speed)
above = assist(0., actual, epsilon, speed)
assert max(abs(below - center), abs(above - center)) < 1e-6, 'TURN_ASSIST_OUTPUT_BRANCH_STEP'
@@ -0,0 +1,134 @@
"""Actual lane-change state machine with current stock/OP cruise evidence."""
import json
import ast
from pathlib import Path
from dataclasses import replace
import unittest
from opendbc.car import structs
from openpilot.cereal import log
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper
from openpilot.starpilot.lateral.auto_lane_change import auto_trigger_engaged
from openpilot.starpilot.lateral.lane_change_preferences import LaneChangePolicy, decode, to_value
NOW = 10_000_000_000
class Messages:
def __init__(self, *, stock=False, normal=False):
self.car = structs.CarState.new_message(vEgo=15., canValid=True)
self.car.cruiseState.enabled = stock
self.car.cruiseState.available = True
self.control = structs.CarControl.new_message(latActive=True, enabled=normal)
self.seen = dict.fromkeys(('carState', 'carControl'), True)
self.alive, self.valid = self.seen.copy(), self.seen.copy()
self.logMonoTime = dict.fromkeys(self.seen, NOW - 10_000_000)
def __getitem__(self, name):
return {'carState': self.car, 'carControl': self.control}[name]
class CruiseGateTests(unittest.TestCase):
def setUp(self):
self.policy = LaneChangePolicy(auto_lane_change=True, auto_delay_s=0., auto_requires_cruise=True)
def gate(self, sm, op_long, policy=None):
return auto_trigger_engaged(sm, policy or self.policy, openpilot_longitudinal=op_long, now_mono_ns=NOW)
def test_stock_and_openpilot_engagement_have_independent_sources(self):
for op_long in (False, True):
for stock in (False, True):
for normal in (False, True):
sm = Messages(stock=stock, normal=normal)
sm.control.longActive = False
self.assertEqual(self.gate(sm, op_long), normal if op_long else stock)
def test_option_off_preserves_lateral_only_automatic_changes(self):
for op_long in (False, True):
sm = Messages()
self.assertTrue(self.gate(sm, op_long, replace(self.policy, auto_requires_cruise=False)))
sm.control.latActive = False
self.assertFalse(self.gate(sm, op_long, replace(self.policy, auto_requires_cruise=False)))
def test_missing_invalid_stale_or_future_evidence_never_qualifies(self):
for op_long in (False, True):
for service in ('carState', 'carControl'):
for field in ('seen', 'alive', 'valid', 'stale', 'future'):
sm = Messages(stock=True, normal=True)
if field in ('stale', 'future'):
sm.logMonoTime[service] = NOW - 100_000_000 if field == 'stale' else NOW + 1
else:
getattr(sm, field)[service] = False
self.assertFalse(self.gate(sm, op_long), (op_long, service, field))
def test_held_signal_cannot_start_when_cruise_engages_but_manual_nudge_works(self):
for op_long in (False, True):
self._exercise_signal(op_long)
def _exercise_signal(self, op_long):
sm = Messages()
helper = DesireHelper(self.policy)
def step():
helper.update(sm.car, True, 1., engaged=self.gate(sm, op_long), auto_evidence=True)
step()
sm.car.rightBlinker = True
step()
step()
self.assertEqual(helper.lane_change_state, log.LaneChangeState.preLaneChange)
sm.car.cruiseState.enabled = sm.control.enabled = True
for _ in range(5):
step()
self.assertEqual(helper.lane_change_state, log.LaneChangeState.preLaneChange)
sm.car.rightBlinker = False
step()
sm.car.rightBlinker = True
step()
step()
self.assertEqual(helper.lane_change_state, log.LaneChangeState.laneChangeStarting)
sm.car.cruiseState.enabled = sm.control.enabled = False
step()
self.assertEqual(helper.lane_change_state, log.LaneChangeState.laneChangeStarting)
helper = DesireHelper(self.policy)
sm.car.rightBlinker = False
step()
sm.car.rightBlinker = True
step()
sm.car.steeringPressed, sm.car.steeringTorque = True, -1.
step()
self.assertEqual(helper.lane_change_state, log.LaneChangeState.laneChangeStarting)
def test_blindspot_still_blocks_with_cruise_engaged(self):
sm = Messages(stock=True, normal=True)
helper = DesireHelper(self.policy)
helper.update(sm.car, True, 1., engaged=True, auto_evidence=True)
sm.car.rightBlinker, sm.car.rightBlindspot = True, True
for _ in range(4):
helper.update(sm.car, True, 1., engaged=self.gate(sm, True), auto_evidence=True)
self.assertEqual(helper.lane_change_state, log.LaneChangeState.preLaneChange)
def test_old_documents_keep_default_and_new_boolean_is_strict(self):
current = to_value(self.policy)
self.assertEqual(decode(json.dumps(current).encode()), self.policy)
old = {k: v for k, v in current.items() if k != 'autoRequiresCruise'}
old['version'] = 5
self.assertFalse(decode(json.dumps(old).encode()).auto_requires_cruise)
for bad in (1, 'true', None, [], {}):
self.assertIsNone(decode(json.dumps({**current, 'autoRequiresCruise': bad}).encode()))
with self.assertRaises(ValueError):
to_value(replace(self.policy, auto_requires_cruise=1))
def test_actual_modeld_passes_the_gate_into_the_existing_state_machine(self):
source = Path(__file__).resolve().parents[3] / 'selfdrive/modeld/modeld.py'
tree = ast.parse(source.read_text())
call = next(node for node in ast.walk(tree) if isinstance(node, ast.Call) and
isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name) and
node.func.value.id == 'DH' and node.func.attr == 'update')
expression = next(keyword.value for keyword in call.keywords if keyword.arg == 'engaged')
sm = Messages()
cp = structs.CarParams.new_message(openpilotLongitudinalControl=True)
scope = {'auto_trigger_engaged': auto_trigger_engaged, 'sm': sm, 'runtime_lane_policy': self.policy,
'CP': cp, 'mono_now_ns': NOW, 'engaged': True}
code = compile(ast.Expression(expression), str(source), 'eval')
self.assertFalse(eval(code, scope))
sm.control.enabled = True
self.assertTrue(eval(code, scope))
@@ -17,6 +17,7 @@ def test_duration_document_roundtrip_and_previous_versions():
old.pop('durationS')
assert decode(json.dumps(old).encode()) is None # A v3 document cannot contain a later field.
old.pop('turnSignalGuidance')
old.pop('autoRequiresCruise')
decoded = decode(json.dumps(old).encode())
assert decoded is not None
assert decoded.duration_s == LaneChangePolicy().duration_s
@@ -0,0 +1,129 @@
import unittest
from types import SimpleNamespace
from openpilot.starpilot.lateral.model_turn_assist import (
STOP_HOLD_MAX_SPEED, TWITCH_GUARD_FLOOR, TWITCH_GUARD_MAX_SPEED, ModelTurnAssist, update_twitch_guard,
)
STRAIGHT = SimpleNamespace(position=SimpleNamespace(x=[0., 1., 2., 3.], y=[0., 0., 0., 0.]),
meta=SimpleNamespace(laneChangeState=0))
LONG_STRAIGHT = SimpleNamespace(position=SimpleNamespace(x=[float(x) for x in range(0, 80, 4)], y=[0.] * 20),
meta=SimpleNamespace(laneChangeState=0))
def car(speed, accel, blinker=0, pressed=False):
return SimpleNamespace(vEgo=speed, aEgo=accel, rightBlinker=blinker == 1, leftBlinker=blinker == -1,
steeringPressed=pressed, steeringTorque=0.)
def step(assist, speed, accel, desired, active=True, model=STRAIGHT, **kwargs):
return assist.update(car(speed, accel, **kwargs), model, SimpleNamespace(latActive=active), desired)
class TestStopHold(unittest.TestCase):
def test_holds_command_through_final_metre_of_stop(self):
assist = ModelTurnAssist()
self.assertEqual(step(assist, 2.0, -2.0, .002), .002)
for speed, desired in ((1.5, .004), (1.0, .010), (.6, .014)):
self.assertEqual(step(assist, speed, -2.0, desired), .002)
def test_releases_when_car_stops_slowing(self):
assist = ModelTurnAssist()
step(assist, 1.0, -2.0, .001)
self.assertEqual(step(assist, 1.0, -.5, .01), .001)
self.assertEqual(step(assist, 1.0, 0., .01), .01)
self.assertIsNone(assist.stop_hold_curvature)
def test_does_not_engage_far_from_stop_or_above_speed(self):
for speed, accel in ((2.0, -.5), (1.0, -.2), (STOP_HOLD_MAX_SPEED, -5.), (1.0, 1.)):
with self.subTest(speed=speed, accel=accel):
assist = ModelTurnAssist()
self.assertEqual(step(assist, speed, accel, .01), .01)
self.assertIsNone(assist.stop_hold_curvature)
def test_blinker_or_inactive_never_holds(self):
for direction in (-1, 1):
assist = ModelTurnAssist()
step(assist, 1.0, -2.0, .001)
self.assertNotEqual(step(assist, 1.0, -2.0, direction * .02, blinker=direction), .001)
self.assertIsNone(assist.stop_hold_curvature)
assist = ModelTurnAssist()
self.assertEqual(step(assist, 1.0, -2.0, .01, active=False), .01)
self.assertIsNone(assist.stop_hold_curvature)
def test_driver_steering_moves_hold_to_measured_curvature(self):
assist = ModelTurnAssist()
step(assist, 1.0, -2.0, .001)
assist.curvature = -.003
self.assertEqual(step(assist, .8, -2.0, .015, pressed=True), -.003)
assist.curvature = .009
self.assertEqual(step(assist, .6, -2.0, .015), -.003)
def test_latches_guarded_command_not_raw_model(self):
assist = ModelTurnAssist()
assist.twitch_guard_armed = True
self.assertEqual(step(assist, 1.0, 0., .012, model=LONG_STRAIGHT), TWITCH_GUARD_FLOOR)
self.assertEqual(step(assist, .6, -2.0, .012, model=LONG_STRAIGHT), TWITCH_GUARD_FLOOR)
def test_does_not_freeze_a_turn_in_progress(self):
assist = ModelTurnAssist()
assist.curvature = .02
self.assertEqual(step(assist, 1.0, -2.0, .04), .04)
self.assertIsNone(assist.stop_hold_curvature)
class TestTwitchGuardArming(unittest.TestCase):
def test_armed_from_standstill_until_max_speed(self):
self.assertTrue(update_twitch_guard(False, 0., True))
self.assertTrue(update_twitch_guard(False, .3, False))
self.assertFalse(update_twitch_guard(False, 1., False))
for speed in (.5, 2., TWITCH_GUARD_MAX_SPEED - .01):
self.assertTrue(update_twitch_guard(True, speed, False))
self.assertFalse(update_twitch_guard(True, TWITCH_GUARD_MAX_SPEED, False))
self.assertFalse(update_twitch_guard(True, float('nan'), False))
def test_released_stop_hold_hands_off_to_guard(self):
assist = ModelTurnAssist()
step(assist, 1.0, -2.0, .001, model=LONG_STRAIGHT)
self.assertTrue(assist.twitch_guard_armed)
self.assertEqual(step(assist, 1.0, 0., .012, model=LONG_STRAIGHT), TWITCH_GUARD_FLOOR)
def test_unarmed_guard_leaves_creep_command(self):
assist = ModelTurnAssist()
self.assertEqual(step(assist, 1.0, 0., .012, model=LONG_STRAIGHT), .012)
def test_stop_approach_arms_guard_only_with_straight_wheel(self):
assist = ModelTurnAssist()
self.assertEqual(step(assist, 3.0, -1.5, .012, model=LONG_STRAIGHT), TWITCH_GUARD_FLOOR)
assist = ModelTurnAssist()
assist.curvature = .006
self.assertEqual(step(assist, 3.0, -1.5, .012, model=LONG_STRAIGHT), .012)
assist = ModelTurnAssist()
self.assertEqual(step(assist, 3.0, -.2, .012, model=LONG_STRAIGHT), .012)
def test_standstill_keeps_last_command_instead_of_raw_model(self):
assist = ModelTurnAssist()
assist.twitch_guard_armed = True
self.assertEqual(step(assist, 1.0, 0., .012, model=LONG_STRAIGHT), TWITCH_GUARD_FLOOR)
self.assertEqual(assist.update_stopped(car(0., 0.), .012), TWITCH_GUARD_FLOOR)
assist.curvature = -.004
self.assertEqual(assist.update_stopped(car(0., 0., pressed=True), .012), -.004)
self.assertEqual(assist.update_stopped(car(0., 0.), .012), -.004)
def test_standstill_passes_raw_model_with_blinker_or_no_history(self):
self.assertEqual(ModelTurnAssist().update_stopped(car(0., 0.), .012), .012)
assist = ModelTurnAssist()
step(assist, 1.0, 0., .003)
self.assertEqual(assist.update_stopped(car(0., 0., blinker=1), .012), .012)
self.assertIsNone(assist.output_curvature)
def test_blinker_disarms_guard(self):
assist = ModelTurnAssist()
assist.twitch_guard_armed = True
step(assist, 2.0, 0., .012, model=LONG_STRAIGHT, blinker=1)
self.assertFalse(assist.twitch_guard_armed)
self.assertEqual(step(assist, 2.0, 0., .012, model=LONG_STRAIGHT), .012)
if __name__ == '__main__':
unittest.main()
+79 -15
View File
@@ -10,8 +10,18 @@ STANDSTILL_CLEAR_NS = 750_000_000
SIGN_MAX_SPEED_MPS = 17.88
SIGN_MODEL_AGREE_M = 50.0
SIGN_SEED_M = 27.0
SIGN_ESTIMATE_HOLD_NS = 2_000_000_000
# Over a long model path a sign is only believed if stopping at its estimate would be a normal stop.
SIGN_LONG_PATH_MAX_DECEL = 2.5
SIGN_APPROACH_DECEL = 1.0
MODEL_APPROACH_DECEL = .65
# Stopped behind a car at a sign: the line is assumed one car length past that car's rear.
QUEUE_LEAD_MAX_M = 8.0
QUEUE_LEAD_LENGTH_M = 4.5
# A car still inside this margin past the line may just be moving up the queue, not through the intersection.
QUEUE_CLEAR_M = 5.0
QUEUE_SIGN_RECENT_NS = 10_000_000_000
QUEUE_MAX_SPEED_MPS = 6.0
@dataclass(frozen=True)
@@ -56,6 +66,8 @@ class StopFrame:
resume_requested: bool = False
perception_available: bool = True
sign: bool = False
dashboard_sign: bool = False
vision_distance_m: float | None = None
@dataclass(frozen=True)
@@ -96,6 +108,48 @@ class ForceStop:
self.previously_enabled = False
self.distance = 0.0
self.distance_cap = 0.0
self.queue_sign_ns = 0
self.queue_line = None
self._clear_sign_estimate()
def _clear_sign_estimate(self):
self.dash_distance = None
self.vision_distance = None
self.vision_samples = 0
self.sign_evidence_ns = 0
def _sign_estimate(self, frame: StopFrame, now: int, travel: float) -> float | None:
if self.sign_evidence_ns and now - self.sign_evidence_ns > SIGN_ESTIMATE_HOLD_NS:
self._clear_sign_estimate()
if self.dash_distance is not None:
self.dash_distance = max(0.0, self.dash_distance - travel)
if self.vision_distance is not None:
self.vision_distance = max(0.0, self.vision_distance - travel)
vision = frame.vision_distance_m
if vision is not None and math.isfinite(vision) and vision >= 0:
self.vision_samples += 1
self.vision_distance = vision if self.vision_distance is None else (
self.vision_distance + (vision - self.vision_distance) / self.vision_samples)
if frame.dashboard_sign or frame.sign and self.vision_distance is None:
self.dash_distance = SIGN_SEED_M if self.dash_distance is None else min(self.dash_distance, SIGN_SEED_M)
if frame.sign or vision is not None:
self.sign_evidence_ns = now
estimates = [value for value in (self.dash_distance, self.vision_distance) if value is not None]
return sum(estimates) / len(estimates) if estimates else None
def _queue_step(self, frame: StopFrame, now: int, travel: float, lead_blocked: bool):
if frame.sign and lead_blocked:
self.queue_sign_ns = now
queued_lead = frame.lead_present and frame.lead_speed_mps < 1.0 and frame.lead_distance_m < QUEUE_LEAD_MAX_M
arm = frame.standstill and self.queue_sign_ns and now - self.queue_sign_ns <= QUEUE_SIGN_RECENT_NS
if queued_lead and (arm or self.queue_line is not None):
self.queue_line = frame.lead_distance_m + QUEUE_LEAD_LENGTH_M
elif self.queue_line is not None:
self.queue_line -= travel
if self.queue_line is not None and (frame.pedal_override or frame.resume_requested or self.queue_line <= 0 or
frame.speed_mps > QUEUE_MAX_SPEED_MPS):
self.queue_line = None
self.queue_sign_ns = 0
def _clear_standstill(self):
self.standstill_since_ns = 0
@@ -121,7 +175,10 @@ class ForceStop:
self.reset()
return StopPlan()
available = frame.perception_available
if available and (frame.light or frame.raw_model_stopped) or frame.sign:
sign_estimate = self._sign_estimate(frame, now, max(0.0, speed) * dt)
lead = (frame.lead_present and frame.lead_distance_m < tune.lead_veto_m and frame.lead_speed_mps < speed + 2)
self._queue_step(frame, now, max(0.0, speed) * dt, lead or frame.tracking_lead)
if available and (frame.light or frame.raw_model_stopped) or frame.sign or self.queue_line is not None:
if not frame.standstill:
self.stop_seen_ns = now
if frame.standstill:
@@ -129,16 +186,21 @@ class ForceStop:
stop_then_turn = bool(self.stop_seen_ns and now - self.stop_seen_ns < 4_000_000_000)
turn = speed <= 18 * 0.44704 and frame.blinker and abs(frame.steering_angle_deg) >= 25 and not stop_then_turn
curved = available and abs(curvature) >= .003 and not stop_then_turn
lead = (frame.lead_present and frame.lead_distance_m < tune.lead_veto_m and frame.lead_speed_mps < speed + 2)
model_active = available and horizon < (108.0 if self.activation_gate and frame.light else 100.0)
self.activation_gate = model_active and frame.light
permitted = (available and now >= self.override_until_ns and not frame.driving_in_curve and not turn and
not frame.tracking_lead and not lead)
model_path = frame.light and model_active and permitted and not curved
sign_path = frame.sign and permitted and speed < SIGN_MAX_SPEED_MPS and horizon < SIGN_MODEL_AGREE_M
if sign_path:
plausible = sign_estimate is None or speed * speed <= 2 * SIGN_LONG_PATH_MAX_DECEL * max(sign_estimate, 1.0)
sign_path = (frame.sign and permitted and speed < SIGN_MAX_SPEED_MPS and
(horizon < SIGN_MODEL_AGREE_M or self.forcing and plausible))
line_blocked = (self.queue_line is not None and frame.lead_present and
frame.lead_distance_m < self.queue_line + QUEUE_CLEAR_M)
queue_path = (self.queue_line is not None and not line_blocked and not frame.standstill and available and
now >= self.override_until_ns and not frame.driving_in_curve and not turn)
if sign_path or queue_path:
self.sign_confirmed = True
active = model_path or sign_path
active = model_path or sign_path or queue_path
if model_path:
self.from_light = True
self.light_clear_ns = 0
@@ -177,7 +239,7 @@ class ForceStop:
if not available and not self.forcing:
self.timer = 0
elif active and not frame.standstill:
self.timer = min(2.0, self.timer + DT_MDL * (2 if frame.sign else 1))
self.timer = min(2.0, self.timer + DT_MDL * (2 if frame.sign or queue_path else 1))
elif turn and not self.forcing and not frame.standstill:
self.timer = 0
elif held:
@@ -187,7 +249,7 @@ class ForceStop:
else:
self.timer = max(0, self.timer - DT_MDL * .25)
force = self.timer >= .5 or self.forcing and not frame.standstill or held
light_cleared = (not held and self.forcing and self.from_light and not frame.standstill and
light_cleared = (not held and self.forcing and self.from_light and not frame.standstill and not self.sign_confirmed and
available and not frame.light and not frame.sign and go_scene)
low_speed_hold = (light_cleared and tune.low_speed_hold_mps is not None and self.entry_speed is not None and
speed <= tune.low_speed_hold_mps and speed < self.entry_speed - .25)
@@ -224,12 +286,12 @@ class ForceStop:
elif available:
self.distance = min(self.distance, horizon)
self.distance_cap = max(0.0, self.distance_cap - speed * dt)
self.distance = min(self.distance, self.distance_cap + 15 * min(self.distance_cap / 60, 1))
if frame.sign and available:
if horizon < SIGN_MODEL_AGREE_M:
self.distance = min(self.distance, SIGN_SEED_M)
if horizon < SIGN_SEED_M:
self.distance = horizon
cap = self.distance_cap + 15 * min(self.distance_cap / 60, 1)
self.distance = min(self.distance, cap)
if sign_estimate is not None and (self.sign_confirmed or frame.sign and available and horizon < SIGN_MODEL_AGREE_M):
self.distance = min(sign_estimate, cap)
if self.queue_line is not None and self.sign_confirmed:
self.distance = self.queue_line
effective = self.distance + offset + tune.distance_bias_m
decel = SIGN_APPROACH_DECEL if frame.sign else MODEL_APPROACH_DECEL
ceiling = math.sqrt(2 * decel * max(0, effective - tune.handoff_m))
@@ -245,10 +307,12 @@ class ForceStop:
if effective > tune.handoff_m:
ceiling = math.sqrt(2 * MODEL_APPROACH_DECEL * (effective - tune.handoff_m))
self.previously_enabled = frame.enabled
stop_length = self.distance if self.forcing and self.distance > tune.handoff_m else approach
stop_length = self.distance if self.forcing else approach
# The MPC obstacle includes its standing-distance term; adding it at the
# native boundary avoids moving the actual stop line by that buffer.
obstacle = stop_length * .93 + offset + tune.distance_bias_m if stop_length > tune.handoff_m else None
# The offset moves the handoff with the ceiling; otherwise a positive offset creeps with no stop target.
obstacle = (stop_length * .93 + offset + tune.distance_bias_m
if stop_length > 0 and stop_length + offset > tune.handoff_m else None)
return StopPlan(now, ceiling, obstacle, self.forcing, bool(self.forcing and ceiling is not None and ceiling <= .5),
self.distance if self.forcing else 0.0, approach,
tune.jerk_scale if self.forcing or approach > 0 else 1.0, bool(held and self.forcing and not overridden),
@@ -51,6 +51,7 @@ class ForceStopRuntime:
self.policy = ForceStop()
self.resume = StopResume()
self.vision_stop = StopConfirmation()
self.vision_ranged_ns = 0
self.light = StopLightDetector()
self.lead = LeadDetector()
self.plan = StopPlan()
@@ -66,6 +67,7 @@ class ForceStopRuntime:
def reset(self):
self.vision_stop.reset()
self.vision_ranged_ns = 0
self.resume.reset()
self.policy.reset()
self.light.reset()
@@ -184,14 +186,19 @@ class ForceStopRuntime:
return self.plan
vision_sign = self.vision_stop.observe(
read_stop_evidence(sm, now_ns=now_ns, now_boot_ns=now_boot_ns, drive_id=drive_id), now_ns=now_ns)
vision_fresh = bool(vision_sign and 0 <= now_ns - vision_sign <= CONTROL_STOP_MAX_AGE_NS)
vision_distance = None
if vision_fresh and vision_sign != self.vision_ranged_ns and self.vision_stop.distance_m is not None:
self.vision_ranged_ns = vision_sign
vision_distance = self.vision_stop.distance_m - float(car.vEgo) * (stamp - vision_sign) / 1e9
dashboard_sign = dashboard_stop_sign(sm, now_ns=now_ns, drive_id=drive_id)
frame = StopFrame(stamp, float(car.vEgo), horizon, light_context, horizon is not None and horizon < 50,
bool(model.action.shouldStop), bool(car.standstill), True, bool(car.gasPressed),
tracked is not None and tracked.tracked is True, bool(lead.present), float(lead.dRel), float(lead.vLead),
raw_curve[1] if raw_curve is not None else False, curvature[0] if curvature is not None else None,
bool(car.leftBlinker or car.rightBlinker), float(car.steeringAngleDeg), resume_requested=resume_requested,
perception_available=perception_available,
sign=dashboard_stop_sign(sm, now_ns=now_ns, drive_id=drive_id) or
bool(vision_sign and 0 <= now_ns - vision_sign <= CONTROL_STOP_MAX_AGE_NS))
perception_available=perception_available, sign=dashboard_sign or vision_fresh,
dashboard_sign=dashboard_sign, vision_distance_m=vision_distance)
self.plan = self.policy.step(frame, vehicle_tune(str(cp.carFingerprint)), self.offset)
if takeoff_enabled:
self.plan = replace(self.plan, takeoff_light_observed=takeoff_observed, takeoff_light=takeoff_light)
@@ -229,6 +229,138 @@ class ForceStopTests(unittest.TestCase):
plan = self.owner.step(frame(tick, speed_mps=0., horizon_m=190., light=False, standstill=True), self.tune)
self.assertFalse(plan.forcing)
def test_latched_sign_survives_light_and_sign_dropout(self):
for tick in range(15):
self.owner.step(frame(tick, speed_mps=8., horizon_m=45., sign=True), self.tune)
self.assertTrue(self.owner.sign_confirmed)
for tick in range(15, 45):
plan = self.owner.step(frame(tick, speed_mps=1., horizon_m=190., light=False), self.tune)
self.assertTrue(plan.forcing)
def test_sign_seed_never_moves_the_stop_farther(self):
for tick in range(50):
previous = self.owner.step(frame(tick, speed_mps=8., horizon_m=45., sign=True), self.tune)
self.assertLess(previous.tracked_distance_m, 20.)
plan = self.owner.step(frame(50, speed_mps=8., horizon_m=20., sign=True), self.tune)
self.assertLessEqual(plan.tracked_distance_m, previous.tracked_distance_m)
def test_sign_latches_and_seeds_over_long_path_once_light_committed(self):
for tick in range(15):
plan = self.owner.step(frame(tick, horizon_m=60.), self.tune)
self.assertTrue(plan.forcing)
self.assertGreater(plan.tracked_distance_m, 50.)
plan = self.owner.step(frame(15, horizon_m=60., sign=True), self.tune)
self.assertTrue(self.owner.sign_confirmed)
self.assertLessEqual(plan.tracked_distance_m, 27.)
plan = self.owner.step(frame(16, horizon_m=60., light=False), self.tune)
self.assertLess(plan.tracked_distance_m, 27.)
def test_long_path_sign_needing_a_hard_stop_is_ignored(self):
for tick in range(15):
plan = self.owner.step(frame(tick, speed_mps=13., horizon_m=90.), self.tune)
self.assertTrue(plan.forcing)
for tick in range(15, 55):
plan = self.owner.step(frame(tick, speed_mps=13., horizon_m=90., sign=True, dashboard_sign=True), self.tune)
self.assertFalse(self.owner.sign_confirmed)
self.assertGreater(plan.tracked_distance_m, 50.)
def test_long_path_sign_alone_does_not_commit(self):
for tick in range(20):
plan = self.owner.step(frame(tick, horizon_m=60., light=False, sign=True), self.tune)
self.assertFalse(plan.forcing)
self.assertFalse(self.owner.sign_confirmed)
def test_vision_distance_replaces_assumed_sign_distance(self):
for tick in range(15):
plan = self.owner.step(frame(tick, horizon_m=60.), self.tune)
plan = self.owner.step(frame(15, horizon_m=60., sign=True, vision_distance_m=35.), self.tune)
self.assertTrue(self.owner.sign_confirmed)
self.assertAlmostEqual(plan.tracked_distance_m, 35.)
plan = self.owner.step(frame(16, horizon_m=60., sign=True), self.tune)
self.assertAlmostEqual(plan.tracked_distance_m, 34.5)
plan = self.owner.step(frame(17, horizon_m=60., sign=True, vision_distance_m=31.), self.tune)
self.assertAlmostEqual(plan.tracked_distance_m, 32.5)
def test_dashboard_and_vision_distances_are_averaged(self):
for tick in range(15):
self.owner.step(frame(tick, horizon_m=60.), self.tune)
plan = self.owner.step(frame(15, horizon_m=60., sign=True, dashboard_sign=True), self.tune)
self.assertAlmostEqual(plan.tracked_distance_m, 27.)
plan = self.owner.step(frame(16, horizon_m=60., sign=True, dashboard_sign=True, vision_distance_m=35.), self.tune)
self.assertAlmostEqual(plan.tracked_distance_m, (26.5 + 35.) / 2)
def test_sign_distance_expires_without_evidence_and_never_jumps_back(self):
for tick in range(15):
self.owner.step(frame(tick, horizon_m=60.), self.tune)
self.owner.step(frame(15, horizon_m=60., sign=True, vision_distance_m=30.), self.tune)
for tick in range(16, 70):
plan = self.owner.step(frame(tick, speed_mps=4., horizon_m=60., light=True), self.tune)
self.assertLess(plan.tracked_distance_m, 30.)
self.assertIsNone(self.owner.vision_distance)
def queue_behind_lead(self, sign=True):
for tick in range(10):
plan = self.owner.step(frame(tick, speed_mps=8., horizon_m=45., light=False, sign=sign, lead_present=True,
lead_distance_m=20., lead_speed_mps=0.), self.tune)
self.assertFalse(plan.forcing)
for tick in range(10, 30):
plan = self.owner.step(frame(tick, speed_mps=0., horizon_m=5., light=False, standstill=True, lead_present=True,
lead_distance_m=5., lead_speed_mps=0.), self.tune)
self.assertFalse(plan.forcing)
def test_sign_behind_stopped_lead_stops_at_line_after_lead_leaves(self):
self.queue_behind_lead()
self.assertAlmostEqual(self.owner.queue_line, 9.5)
for tick in range(30, 35):
plan = self.owner.step(frame(tick, speed_mps=0., horizon_m=12., light=False, standstill=True, lead_present=True,
lead_distance_m=7., lead_speed_mps=3.), self.tune)
self.assertFalse(plan.forcing)
for tick in range(35, 45):
plan = self.owner.step(frame(tick, speed_mps=1., horizon_m=15., light=False), self.tune)
self.assertTrue(plan.forcing)
self.assertTrue(self.owner.sign_confirmed)
self.assertAlmostEqual(plan.tracked_distance_m, 9.5 - 10 * .05)
for tick in range(45, 100):
plan = self.owner.step(frame(tick, speed_mps=0., horizon_m=15., light=False, standstill=True), self.tune)
self.assertTrue(plan.manual_hold)
released = self.owner.step(frame(100, speed_mps=0., horizon_m=15., light=False, standstill=True,
pedal_override=True), self.tune)
self.assertFalse(released.manual_hold)
self.assertIsNone(self.owner.queue_line)
def test_lead_stop_without_sign_is_not_remembered(self):
self.queue_behind_lead(sign=False)
self.assertIsNone(self.owner.queue_line)
for tick in range(30, 50):
plan = self.owner.step(frame(tick, speed_mps=1., horizon_m=15., light=False), self.tune)
self.assertFalse(plan.forcing)
def test_lead_moving_up_the_queue_is_followed_not_stopped_for(self):
self.queue_behind_lead()
for tick in range(30, 60):
plan = self.owner.step(frame(tick, speed_mps=1., horizon_m=15., light=False, lead_present=True,
lead_distance_m=6., lead_speed_mps=1.5), self.tune)
self.assertFalse(plan.forcing)
plan = self.owner.step(frame(60, speed_mps=0., horizon_m=5., light=False, standstill=True, lead_present=True,
lead_distance_m=4., lead_speed_mps=0.), self.tune)
self.assertFalse(plan.forcing)
self.assertAlmostEqual(self.owner.queue_line, 8.5)
def test_driver_gas_while_queued_forgets_the_sign(self):
self.queue_behind_lead()
self.owner.step(frame(30, speed_mps=0., horizon_m=5., light=False, standstill=True, pedal_override=True), self.tune)
self.assertIsNone(self.owner.queue_line)
for tick in range(31, 50):
plan = self.owner.step(frame(tick, speed_mps=1., horizon_m=15., light=False), self.tune)
self.assertFalse(plan.forcing)
def test_positive_offset_keeps_stop_target_until_native_stop(self):
for tick in range(40):
plan = self.owner.step(frame(tick, horizon_m=12.), self.tune, 8)
if plan.forcing:
self.assertTrue(plan.should_stop or plan.obstacle_m is not None)
self.assertTrue(plan.should_stop)
def test_odometry_ceiling_bounds_reopened_model(self):
for tick in range(15):
self.owner.step(frame(tick), self.tune)
@@ -1,5 +1,7 @@
"""Load existing bank and stock artifacts with one explicitly selected runtime."""
import hashlib
import os
import stat
from pathlib import Path
from typing import TYPE_CHECKING, cast
@@ -20,6 +22,40 @@ _selection = None
_protection = None
def _inference_cache_root(role):
if role not in ('driving', 'monitoring'):
raise ValueError('Unsupported model runtime role')
root = Path('/data/starpilot-model-runtime')
leaf = root / LEGACY_REVISION / role / MANIFEST_SHA256
uid = os.geteuid()
trusted_parent = True
for current in (*reversed(root.parents), root, root / LEGACY_REVISION,
root / LEGACY_REVISION / role, leaf, leaf / 'xdg'):
try:
status = current.lstat()
except FileNotFoundError:
break
if stat.S_ISLNK(status.st_mode) or not stat.S_ISDIR(status.st_mode):
raise ValueError('Invalid runtime cache directory')
if current in root.parents:
trusted_parent = trusted_parent and status.st_uid in (0, uid) and not stat.S_IMODE(status.st_mode) & 0o022
if current.is_relative_to(root) and status.st_uid != uid:
if uid == 0:
raise ValueError('Root ownership mismatch requires an explicit private runtime cache')
if not trusted_parent:
raise ValueError('Untrusted parent for per-user runtime cache')
sibling = root.with_name(root.name + '-uid' + str(uid))
try:
owner = sibling.lstat()
except FileNotFoundError:
return sibling
if (not stat.S_ISDIR(owner.st_mode) or owner.st_uid != uid or
stat.S_IMODE(owner.st_mode) & 0o022):
raise ValueError('Invalid per-user runtime cache root')
return sibling
return root
def prepare(role):
global _selection
if _selection is not None and _selection.get('purpose') == 'compiler':
@@ -32,7 +68,7 @@ def prepare(role):
raise RuntimeError('A model child cannot change its execution role')
return _selection
_verify_policy()
cache = Path('/data/starpilot-model-runtime')
cache = _inference_cache_root(role)
_selection = activate(BUNDLE, MANIFEST_SHA256, LEGACY_REVISION, role, cache)
return _selection
@@ -1,5 +1,8 @@
"""Persistent startup cache selection; not GPU or road qualification."""
from pathlib import Path
import copy
import stat
from types import SimpleNamespace
from pathlib import Path, PurePosixPath
from unittest.mock import patch
import pytest
@@ -13,6 +16,7 @@ def test_inference_cache_survives_reboot_without_changing_role_isolation(role):
with (patch("openpilot.common.hardware.AGNOS", True),
patch.object(runtime, "_selection", None),
patch.object(runtime, "_verify_policy") as verify,
patch.object(runtime, "_inference_cache_root", return_value=Path("/data/starpilot-model-runtime")),
patch.object(runtime, "activate", return_value=selected) as activate):
assert runtime.prepare(role) is selected
verify.assert_called_once()
@@ -27,3 +31,92 @@ def test_host_does_not_create_device_cache():
patch.object(runtime, "_selection", None),
patch.object(runtime, "activate", side_effect=AssertionError("must not touch device cache"))):
assert runtime.prepare("driving") is None
class CachePath(PurePosixPath):
entries = {}
def lstat(self):
try:
uid, mode = self.entries[str(self)]
except KeyError:
raise FileNotFoundError(str(self)) from None
if isinstance(uid, BaseException):
raise uid
return SimpleNamespace(st_uid=uid, st_mode=mode)
@pytest.fixture
def cache_metadata():
root = CachePath("/data/starpilot-model-runtime")
revision = root / runtime.LEGACY_REVISION
CachePath.entries = {"/": (0, stat.S_IFDIR | 0o755), "/data": (1000, stat.S_IFDIR | 0o755)}
for role in ("driving", "monitoring"):
leaf = revision / role / runtime.MANIFEST_SHA256
for path in (root, revision, revision / role, leaf, leaf / "xdg"):
CachePath.entries[str(path)] = (1000, stat.S_IFDIR | 0o700)
def route(role="monitoring", uid=1000):
with patch.object(runtime, "Path", CachePath), patch.object(runtime.os, "geteuid", return_value=uid):
return runtime._inference_cache_root(role)
yield root, revision, route
CachePath.entries = {}
@pytest.mark.parametrize("role", ["driving", "monitoring"])
def test_owned_hot_cache_keeps_canonical_path_without_writes(cache_metadata, role):
root, _, route = cache_metadata
before = copy.deepcopy(CachePath.entries)
assert route(role) == root
assert CachePath.entries == before
def test_foreign_monitoring_cache_uses_only_fixed_uid_sibling(cache_metadata):
root, revision, route = cache_metadata
CachePath.entries[str(revision / "monitoring")] = (0, stat.S_IFDIR | 0o700)
before = copy.deepcopy(CachePath.entries)
assert route() == root.with_name(root.name + "-uid1000")
assert route("driving") == root
assert CachePath.entries == before
def test_root_mismatch_requires_explicit_private_cache(cache_metadata):
_, _, route = cache_metadata
before = copy.deepcopy(CachePath.entries)
with pytest.raises(ValueError, match="explicit private"):
route(uid=0)
assert CachePath.entries == before
@pytest.mark.parametrize("uid,mode", [(2000, 0o755), (0, 0o777), (1000, 0o775)])
def test_uid_fallback_rejects_untrusted_parent(cache_metadata, uid, mode):
_, revision, route = cache_metadata
CachePath.entries[str(revision / "monitoring")] = (0, stat.S_IFDIR | 0o700)
CachePath.entries["/data"] = (uid, stat.S_IFDIR | mode)
with pytest.raises(ValueError, match="Untrusted parent"):
route()
@pytest.mark.parametrize("mode", [stat.S_IFLNK | 0o777, stat.S_IFREG | 0o600])
def test_cache_route_rejects_symlink_or_nondirectory(cache_metadata, mode):
_, revision, route = cache_metadata
CachePath.entries[str(revision / "monitoring")] = (1000, mode)
with pytest.raises(ValueError, match="Invalid runtime cache directory"):
route()
@pytest.mark.parametrize("error", [PermissionError("denied"), OSError("unknown metadata")])
def test_cache_metadata_errors_propagate_without_fallback(cache_metadata, error):
root, _, route = cache_metadata
CachePath.entries[str(root)] = (error, 0)
with pytest.raises(type(error)):
route()
@pytest.mark.parametrize("uid,mode", [(0, stat.S_IFDIR | 0o700), (1000, stat.S_IFDIR | 0o777),
(1000, stat.S_IFLNK | 0o777)])
def test_existing_uid_sibling_must_be_private_and_owned(cache_metadata, uid, mode):
root, revision, route = cache_metadata
CachePath.entries[str(revision / "monitoring")] = (0, stat.S_IFDIR | 0o700)
CachePath.entries[str(root.with_name(root.name + "-uid1000"))] = (uid, mode)
with pytest.raises(ValueError, match="Invalid per-user"):
route()
@@ -58,9 +58,17 @@ def _configure_process() -> None:
def _place_inference(params) -> None:
from openpilot.common.hardware import PC
from openpilot.common.realtime import set_core_affinity
cores = list(VISION_WITH_VASM if vasm_enabled(params, development=True) else LITTLE_CORES)
try:
set_core_affinity(list(VISION_WITH_VASM if vasm_enabled(params, development=True) else LITTLE_CORES))
set_core_affinity(cores)
if not PC:
for tid in os.listdir('/proc/self/task'):
try:
os.sched_setaffinity(int(tid), cores)
except ProcessLookupError:
continue
except OSError:
pass
@@ -164,6 +172,7 @@ def main() -> None:
core = None
last_unavailable = 0.0
tasks = _requested_tasks(params)
_place_inference(params)
try:
if cv2 is not None:
cv2.setNumThreads(1)
@@ -2,12 +2,17 @@
from dataclasses import dataclass
import math
from openpilot.common.transformations.camera import DEVICE_CAMERAS
from openpilot.starpilot.speed_limits.vision.observation import MODEL_ID, MAX_PAIR_SKEW_NS
STOP_MAX_AGE_NS = 1_500_000_000
CONTROL_STOP_MAX_AGE_NS = 750_000_000
RECEIPT_MAX_AGE_NS = 1_000_000_000
STOP_CONFIRM_CONFIDENCE = .7
STOP_SIGN_HEIGHT_M = .762
# Fitted on 17 confirmed sign approaches: where the car stops relative to the box range.
STOP_RANGE_OFFSET_M = 2.0
ROAD_FOCAL_PX = {camera.narrow_road.size: camera.narrow_road.focal_length for camera in DEVICE_CAMERAS.values()}
@dataclass(frozen=True)
@@ -66,6 +71,16 @@ def read_stop_evidence(sm, *, now_ns: int, now_boot_ns: int, drive_id: int,
return None
def stop_distance(sample: StopEvidence, box: StopBox) -> float | None:
focal = ROAD_FOCAL_PX.get((sample.width, sample.height)) if sample.stream == 'road' else None
x1, y1, x2, y2 = box.bbox
if not focal or y2 <= y1:
return None
across = ((x1 + x2) / 2 - sample.width / 2) / focal
up = ((y1 + y2) / 2 - sample.height / 2) / focal
return focal * STOP_SIGN_HEIGHT_M / (y2 - y1) / math.sqrt(1 + across ** 2 + up ** 2) + STOP_RANGE_OFFSET_M
def _matches(a: StopBox, b: StopBox) -> bool:
ax, ay, ar, ab = a.bbox
bx, by, br, bb = b.bbox
@@ -83,6 +98,7 @@ class StopConfirmation:
self.previous: StopEvidence | None = None
self.confirmed_ns = 0
self.first_seen_ns = 0
self.distance_m: float | None = None
def observe(self, sample: StopEvidence | None, *, now_ns: int) -> int:
if sample is None or not 0 <= now_ns - sample.observed_ns < STOP_MAX_AGE_NS:
@@ -102,13 +118,15 @@ class StopConfirmation:
self.reset()
return 0
candidates = tuple(box for box in sample.boxes if box.confidence >= STOP_CONFIRM_CONFIDENCE)
coherent = (previous is not None and 100_000_000 <= sample.observed_ns - previous.observed_ns < STOP_MAX_AGE_NS and
any(_matches(old, new) for old in previous.boxes if old.confidence >= STOP_CONFIRM_CONFIDENCE for new in candidates))
matched = tuple(new for new in candidates if previous is not None and
100_000_000 <= sample.observed_ns - previous.observed_ns < STOP_MAX_AGE_NS and
any(_matches(old, new) for old in previous.boxes if old.confidence >= STOP_CONFIRM_CONFIDENCE))
if not candidates:
self.first_seen_ns = 0
elif not coherent:
elif not matched:
self.first_seen_ns = sample.observed_ns
self.previous = sample
self.confirmed_ns = (sample.observed_ns if candidates and coherent and
self.confirmed_ns = (sample.observed_ns if matched and
sample.observed_ns - self.first_seen_ns >= 500_000_000 else 0)
self.distance_m = stop_distance(sample, max(matched, key=lambda box: box.confidence)) if self.confirmed_ns else None
return self.confirmed_ns
@@ -41,11 +41,16 @@ class TestVisionProducer(unittest.TestCase):
self.addCleanup(patcher.stop)
def test_vision_avoids_vasm_core_when_both_enabled(self):
with patch.object(producer, 'vasm_enabled', side_effect=[True, False]), \
patch('openpilot.common.realtime.set_core_affinity') as affinity:
with patch('openpilot.common.realtime.set_core_affinity') as affinity, \
patch.object(producer, 'vasm_enabled', side_effect=[True, False]), \
patch('openpilot.common.hardware.PC', False), \
patch.object(producer.os, 'listdir', return_value=['100', '101']), \
patch.object(producer.os, 'sched_setaffinity') as threads:
place_inference(object())
place_inference(object())
self.assertEqual([call.args[0] for call in affinity.call_args_list], [[0, 1], [0, 1, 2]])
self.assertEqual([call.args for call in threads.call_args_list],
[(100, [0, 1]), (101, [0, 1]), (100, [0, 1, 2]), (101, [0, 1, 2])])
def clock_pairs(self, clocks, states=None):
pairs = iter(clocks)
@@ -662,7 +667,12 @@ class TestVisionProducer(unittest.TestCase):
self.assertEqual(messages[-1].stopSigns.frameId, 12)
def test_disabling_speed_in_the_live_producer_clears_retained_numbers(self):
core = producer.VisionModelCore(is_metric=False)
reader = Mock()
reader.getLayerId.return_value = 0
with patch.object(producer.VisionModelCore, '_load_net', side_effect=[None, reader]), \
patch.object(producer.VisionModelCore, '_validate_model_abi'):
core = producer.VisionModelCore(is_metric=False)
self.assertIsNotNone(core.classifier_net)
core.published_speed_limit_mph = 55
core.published_support_count = 3
core.last_published_support_at = 1.0
@@ -121,6 +121,21 @@ class TestStopObservation(unittest.TestCase):
owner.observe(evidence(), now_ns=BASE)
self.assertEqual(owner.observe(evidence(BASE + 700_000_000, frame=15, boxes=(box,)), now_ns=BASE + 800_000_000), 0)
def test_confirmed_road_box_carries_forward_distance(self):
box = StopBox(.875, (1214, 400, 1294, 480))
for stream, expected in (('road', 27.025), ('wideRoad', None)):
owner = StopConfirmation()
owner.observe(evidence(boxes=(box,), stream=stream, width=1928, height=1208), now_ns=BASE)
self.assertIsNone(owner.distance_m)
second = evidence(BASE + 700_000_000, frame=15, boxes=(box,), stream=stream, width=1928, height=1208)
self.assertEqual(owner.observe(second, now_ns=BASE + 800_000_000), second.observed_ns)
if expected is None:
self.assertIsNone(owner.distance_m)
else:
self.assertAlmostEqual(owner.distance_m, expected, places=2)
owner.observe(None, now_ns=BASE + 900_000_000)
self.assertIsNone(owner.distance_m)
def test_brief_warning_burst_cannot_confirm_but_sustained_sign_can(self):
owner = StopConfirmation()
for i in range(3):

Some files were not shown because too many files have changed in this diff Show More