mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-11 06:53:50 +08:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 799c5effeb | |||
| 18e52b36d1 | |||
| e1158eb847 | |||
| e2a3802722 | |||
| e0f8b7a118 | |||
| febb4ebdb2 | |||
| c6cdc7fa73 | |||
| a9427aba54 | |||
| 13f914a54f | |||
| adcf6ca32d | |||
| cf916938dd | |||
| 1272c27b8d | |||
| f711833ba1 | |||
| 68305fa238 | |||
| fc1bdaae7d | |||
| 1b797b7359 | |||
| 795799f4ab | |||
| aae3767d89 | |||
| 22308dac24 | |||
| 2cbbbca5e2 | |||
| 8525214a37 | |||
| 4f78a28b2e | |||
| f4d3b646f4 | |||
| 496e72ed00 | |||
| 9be62987b5 | |||
| d39ea5382c | |||
| 349dde99db | |||
| 0c9037b243 | |||
| 8626158c5d | |||
| 0ae8a445bf | |||
| 746e0da673 | |||
| 1d5508b19d | |||
| 1e0e06eef4 | |||
| c22785a22f | |||
| 0c37a00b1c | |||
| 495fa11cf6 | |||
| 27cc914f54 | |||
| 52775f1f54 | |||
| 9251f9b918 | |||
| 3bdcfe7f52 | |||
| 9bbf79766a |
@@ -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),
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]):
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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('&', '&') })
|
||||
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 }
|
||||
|
||||
@@ -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 & 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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user