From 3a8dbe75f174b43b0ee02eb9e56d9aaa59c79c48 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:47:35 -0500 Subject: [PATCH] Repair CI Contracts --- .../opendbc/car/honda/tests/test_stock_aol.py | 141 ++++--- opendbc_repo/opendbc/car/tests/routes.py | 3 + opendbc_repo/opendbc/car/tests/test_routes.py | 2 + .../tests/test_prepared_torque_ev_native.py | 33 +- openpilot/selfdrive/pandad/SConscript | 3 + .../pandad/tests/test_producer_sink.cc | 103 +++++ .../pandad/tests/test_producer_timing.cc | 89 ++++ .../flm/canonical_surface_adapter.py | 4 +- openpilot/starpilot/flm/controller_replay.py | 8 +- .../starpilot/flm/event_controller_replay.py | 24 +- openpilot/starpilot/flm/event_fit_factory.py | 13 +- openpilot/starpilot/flm/full_surface_store.py | 6 +- openpilot/starpilot/flm/fullrate_collector.py | 9 +- openpilot/starpilot/flm/learned_lifecycle.py | 7 +- openpilot/starpilot/flm/learner_factory.py | 13 +- openpilot/starpilot/flm/learning_wire.py | 33 +- openpilot/starpilot/flm/pid_stages.py | 2 + openpilot/starpilot/flm/projection_wire.py | 8 +- openpilot/starpilot/flm/restore_activation.py | 19 +- .../starpilot/flm/runtime_adapter_registry.py | 9 +- openpilot/starpilot/flm/selected_effects.py | 6 +- openpilot/starpilot/flm/steerlearnerd.py | 8 +- openpilot/starpilot/flm/tests/test_analyze.py | 55 --- .../flm/tests/test_canonical_cost.py | 5 +- .../flm/tests/test_event_admission_clock.py | 3 + .../tests/test_optional_learning_failures.py | 7 +- .../tests/test_production_event_contract.py | 2 +- .../flm/tests/test_snapshot_readiness.py | 11 +- .../starpilot/flm/tests/test_worker_limits.py | 21 - .../starpilot/flm/torque_emission_replay.py | 4 +- openpilot/starpilot/flm/training_report.py | 6 +- .../galaxy/tests/test_layout_preview_http.py | 9 +- .../galaxy/tests/test_onroad_layout.mjs | 10 +- .../galaxy/tests/test_saved_torque_page.py | 7 +- .../galaxy/web/js/onroad-layout-template.js | 392 +++++++++++++++++ .../starpilot/galaxy/web/js/onroad-layout.js | 394 +----------------- .../tests/test_bolt_manual_friction.py | 9 +- .../tests/test_native_inferred_torque.py | 3 +- .../lateral/tests/test_torque_learning.py | 28 +- .../lateral/tests/test_volt_torque_policy.py | 3 +- openpilot/starpilot/replay/owner.py | 6 +- .../starpilot/replay/tests/test_export.py | 12 +- pyproject.toml | 4 +- tools/ci/process_replay_coverage.py | 34 +- tools/ci/run_vehicle_tests.py | 1 + .../ci/tests/test_process_replay_coverage.py | 61 ++- 46 files changed, 1000 insertions(+), 630 deletions(-) create mode 100644 openpilot/selfdrive/pandad/tests/test_producer_sink.cc create mode 100644 openpilot/selfdrive/pandad/tests/test_producer_timing.cc delete mode 100644 openpilot/starpilot/flm/tests/test_analyze.py delete mode 100644 openpilot/starpilot/flm/tests/test_worker_limits.py create mode 100644 openpilot/starpilot/galaxy/web/js/onroad-layout-template.js diff --git a/opendbc_repo/opendbc/car/honda/tests/test_stock_aol.py b/opendbc_repo/opendbc/car/honda/tests/test_stock_aol.py index 2f950d72f2..38c691503c 100644 --- a/opendbc_repo/opendbc/car/honda/tests/test_stock_aol.py +++ b/opendbc_repo/opendbc/car/honda/tests/test_stock_aol.py @@ -157,12 +157,24 @@ def civic_engagement_owner(*, enabled=True, lkas_action=AOL_TOGGLE, main_action= cp.alternativeExperience = 32 assert cp.flags == 2060 and cp.safetyConfigs[0].safetyParam == 10 settings = AolSettings(enabled, 5., lkas_action, main_action, (0, 0, 0), (0, 0, 0)) - return cp, create_intent(cp, settings) + intent = create_intent(cp, settings) + intent.observe_full_engagement(state(), enabled=False, active=False, source_ns=1, + observation_valid=True, now_ns=1, fault_active=False) + return cp, intent + + +def update_engagement(intent, cs, *, now_ns, standard_enabled=False, standard_active=False, + fault_active=False, native_rejection_ns=0): + intent.update(cs, now_ns=now_ns, standard_enabled=standard_enabled, + fault_active=fault_active, native_rejection_ns=native_rejection_ns) + intent.observe_full_engagement(cs, enabled=standard_enabled or standard_active, active=standard_active, + source_ns=now_ns, observation_valid=True, now_ns=now_ns, + fault_active=fault_active, native_rejection_ns=native_rejection_ns) @pytest.mark.parametrize("main_action", (0, AOL_TOGGLE)) @pytest.mark.parametrize("paused,temporary", ((False, False), (True, False), (False, True))) -def test_active_engagement_arms_without_raw_cruise_and_preserves_output_pauses(main_action, paused, temporary): +def test_active_engagement_arms_without_raw_cruise_resumes_lateral_and_preserves_long_pause(main_action, paused, temporary): from openpilot.starpilot.aol.runtime import decide_axes _, intent = civic_engagement_owner(main_action=main_action) @@ -171,14 +183,14 @@ def test_active_engagement_arms_without_raw_cruise_and_preserves_output_pauses(m cs.gasPressed = True assert not cs.cruiseState.enabled # Non-PCM host engagement can precede ACC_STATUS. intent.pause_lateral = intent.pause_longitudinal = paused - intent.update(cs.as_reader(), now_ns=10, fault_active=False, standard_enabled=True) + update_engagement(intent, cs.as_reader(), now_ns=10, fault_active=False, standard_enabled=True) assert not intent.allowed_latch # Enabled/preEnabled alone is not the active edge. - intent.update(cs.as_reader(), now_ns=20, fault_active=False, standard_enabled=True, standard_active=True) + update_engagement(intent, cs.as_reader(), now_ns=20, fault_active=False, standard_enabled=True, standard_active=True) assert intent.allowed_latch - assert intent.pause_lateral is paused and intent.pause_longitudinal is paused + assert not intent.pause_lateral and intent.pause_longitudinal is paused cs.gasPressed = False cs.brakePressed = True - intent.update(cs.as_reader(), now_ns=30, fault_active=False, standard_active=False) + update_engagement(intent, cs.as_reader(), now_ns=30, fault_active=False, standard_active=False) assert intent.allowed_latch # Braking drops normal cruise, retaining AOL intent. allowed, pause_lat, pause_long = intent.output(cs.as_reader()) snapshot = SimpleNamespace(allowedLatch=allowed, pauseLateral=pause_lat, pauseLongitudinal=pause_long) @@ -188,7 +200,7 @@ def test_active_engagement_arms_without_raw_cruise_and_preserves_output_pauses(m car_state=cs.as_reader(), initialized=True, model_ready=True, no_entry=False, immediate_disable=False, dm_lockout=False, pause_brake_mps=5.) decision = decide_axes(native=native, **kwargs) - assert decision.lateral_active == (not paused and not temporary) + assert decision.lateral_active == (not temporary) assert not decision.longitudinal_active assert not decide_axes(native=None, **kwargs).lateral_active cs.vEgo = 4.9 @@ -198,12 +210,12 @@ def test_active_engagement_arms_without_raw_cruise_and_preserves_output_pauses(m assert not decide_axes(native=native, **kwargs).lateral_active -@pytest.mark.parametrize("blocked", ("disabled", "mapping", "main_off", "can_invalid", "timeout", +@pytest.mark.parametrize("blocked", ("disabled", "main_off", "can_invalid", "timeout", "fatal", "permanent", "acc_fault", "native_rejection", "park", "unknown", "reverse", "neutral")) def test_blocked_engagement_edge_is_consumed_without_rearm_on_held_active(blocked): - _, intent = civic_engagement_owner(enabled=blocked != "disabled", lkas_action=0 if blocked == "mapping" else AOL_TOGGLE) + _, intent = civic_engagement_owner(enabled=blocked != "disabled") cs = structs.CarState(**state().to_dict()) - intent.update(cs.as_reader(), now_ns=10, fault_active=False) + update_engagement(intent, cs.as_reader(), now_ns=10, fault_active=False) if blocked == "main_off": cs.cruiseState.available = False for case, field in (("can_invalid", "canValid"), ("timeout", "canTimeout"), @@ -212,14 +224,14 @@ def test_blocked_engagement_edge_is_consumed_without_rearm_on_held_active(blocke setattr(cs, field, case != "can_invalid") if blocked in ("park", "unknown", "reverse", "neutral"): cs.gearShifter = blocked - intent.update(cs.as_reader(), now_ns=20, standard_enabled=True, standard_active=True, + update_engagement(intent, cs.as_reader(), now_ns=20, standard_enabled=True, standard_active=True, fault_active=blocked == "fatal", native_rejection_ns=20 if blocked == "native_rejection" else 0) assert not intent.allowed_latch intent.settings = replace(intent.settings, enabled=True, lkas_action=AOL_TOGGLE) - intent.update(state(), now_ns=30, standard_enabled=True, standard_active=True, fault_active=False) + update_engagement(intent, state(), now_ns=30, standard_enabled=True, standard_active=True, fault_active=False) assert not intent.allowed_latch - intent.update(state(), now_ns=40, fault_active=False) - intent.update(state(), now_ns=50, standard_enabled=True, standard_active=True, fault_active=False) + update_engagement(intent, state(), now_ns=40, fault_active=False) + update_engagement(intent, state(), now_ns=50, standard_enabled=True, standard_active=True, fault_active=False) assert intent.allowed_latch @@ -227,42 +239,65 @@ def test_blocked_engagement_edge_is_consumed_without_rearm_on_held_active(blocke def test_manual_gesture_takes_priority_over_coincident_active_edge(button): _, intent = civic_engagement_owner(main_action=AOL_TOGGLE) cs = structs.CarState(**state().to_dict()) - intent.update(cs.as_reader(), now_ns=10, fault_active=False) + update_engagement(intent, cs.as_reader(), now_ns=10, fault_active=False) cs.buttonEvents = [structs.CarState.ButtonEvent(type=button, pressed=True)] - intent.update(cs.as_reader(), now_ns=20, standard_enabled=True, standard_active=True, fault_active=False) + update_engagement(intent, cs.as_reader(), now_ns=20, standard_enabled=True, standard_active=True, fault_active=False) assert not intent.allowed_latch cs.buttonEvents = [structs.CarState.ButtonEvent(type=button, pressed=False)] - intent.update(cs.as_reader(), now_ns=30, standard_enabled=True, standard_active=True, fault_active=False) + update_engagement(intent, cs.as_reader(), now_ns=30, standard_enabled=True, standard_active=True, fault_active=False) assert not intent.allowed_latch def test_manual_off_and_native_withdrawal_do_not_rearm_while_active_stays_high(): _, intent = civic_engagement_owner() - intent.update(state(), now_ns=10, fault_active=False) - intent.update(state(), now_ns=20, standard_enabled=True, standard_active=True, fault_active=False) + update_engagement(intent, state(), now_ns=10, fault_active=False) + update_engagement(intent, state(), now_ns=20, standard_enabled=True, standard_active=True, fault_active=False) assert intent.allowed_latch - intent.update(state(True), now_ns=30, standard_enabled=True, standard_active=True, fault_active=False) + update_engagement(intent, state(True), now_ns=30, standard_enabled=True, standard_active=True, fault_active=False) assert not intent.allowed_latch and intent.pause_lateral - intent.update(state(False), now_ns=40, standard_enabled=True, standard_active=True, fault_active=False) + update_engagement(intent, state(False), now_ns=40, standard_enabled=True, standard_active=True, fault_active=False) assert not intent.allowed_latch and intent.pause_lateral - intent.update(state(), now_ns=50, fault_active=False) - intent.update(state(), now_ns=60, standard_enabled=True, standard_active=True, fault_active=False) - assert intent.allowed_latch and intent.pause_lateral # A new SET edge keeps deliberate output pause. - intent.update(state(), now_ns=70, standard_enabled=True, standard_active=True, native_rejection_ns=70, fault_active=False) + update_engagement(intent, state(), now_ns=50, fault_active=False) + update_engagement(intent, state(), now_ns=60, standard_enabled=True, standard_active=True, fault_active=False) + assert intent.allowed_latch and not intent.pause_lateral # A new full SET resumes lateral output. + update_engagement(intent, state(), now_ns=70, standard_enabled=True, standard_active=True, native_rejection_ns=70, fault_active=False) assert not intent.allowed_latch - intent.update(state(), now_ns=80, standard_enabled=True, standard_active=True, fault_active=False) + update_engagement(intent, state(), now_ns=80, standard_enabled=True, standard_active=True, fault_active=False) assert not intent.allowed_latch @pytest.mark.parametrize("gear", ("drive", "sport", "low", "brake")) def test_supported_driving_gears_keep_active_engagement_intent(gear): _, intent = civic_engagement_owner() - intent.update(state(gear=gear), now_ns=10, fault_active=False) - intent.update(state(gear=gear), now_ns=20, standard_enabled=True, standard_active=True, fault_active=False) + update_engagement(intent, state(gear=gear), now_ns=10, fault_active=False) + update_engagement(intent, state(gear=gear), now_ns=20, standard_enabled=True, standard_active=True, fault_active=False) assert intent.allowed_latch -def test_actual_card_active_observation_requires_fresh_applied_command(monkeypatch): +@pytest.mark.parametrize("lkas_action", (0, AOL_TOGGLE)) +def test_full_engagement_does_not_require_a_configured_lkas_toggle(lkas_action): + _, intent = civic_engagement_owner(lkas_action=lkas_action) + update_engagement(intent, state(), now_ns=10, standard_enabled=True, standard_active=False) + assert not intent.allowed_latch + update_engagement(intent, state(), now_ns=20, standard_enabled=True, standard_active=True) + assert intent.allowed_latch + update_engagement(intent, state(), now_ns=30, standard_enabled=False, standard_active=False) + assert intent.allowed_latch + + +def test_starting_during_active_engagement_requires_a_fresh_disabled_epoch(): + cp, seeded = civic_engagement_owner() + intent = create_intent(cp, seeded.settings) + update_engagement(intent, state(), now_ns=10, standard_enabled=True, standard_active=True) + update_engagement(intent, state(), now_ns=20, standard_enabled=True, standard_active=True) + assert not intent.allowed_latch + update_engagement(intent, state(), now_ns=30) + update_engagement(intent, state(), now_ns=40, standard_enabled=True, standard_active=True) + assert intent.allowed_latch + + +def test_actual_card_active_observation_requires_fresh_selfdrive_state(monkeypatch): + from openpilot.cereal import log from openpilot.selfdrive.car import card as card_module from openpilot.selfdrive.car.card import Car from openpilot.starpilot.aol.intent import AolCardIntent @@ -279,11 +314,13 @@ def test_actual_card_active_observation_requires_fresh_applied_command(monkeypat cs = state() now = 1_000_000_000 control = structs.CarControl(enabled=False, latActive=False, longActive=False) - samples = Samples(carControl=control.as_reader(), onroadEvents=[]) - samples.valid = {"carControl": True, "onroadEvents": True} - samples.alive = {"carControl": True} + engagement = log.SelfdriveState.new_message(enabled=False, active=False) + samples = Samples(carControl=control.as_reader(), onroadEvents=[], selfdriveState=engagement.as_reader()) + samples.valid = {"carControl": True, "onroadEvents": True, "selfdriveState": True} + samples.alive = {"carControl": True, "selfdriveState": True} + samples.seen = {"selfdriveState": True} samples.updated = {"onroadEvents": True} - samples.logMonoTime = {"carControl": now, "onroadEvents": now} + samples.logMonoTime = {"carControl": now, "onroadEvents": now, "selfdriveState": now} host = Car.__new__(Car) host.CP, host.aol_card_intent = cp, intent host.CI = SimpleNamespace(update=lambda _: cs, CS=SimpleNamespace()) @@ -311,41 +348,45 @@ def test_actual_card_active_observation_requires_fresh_applied_command(monkeypat "fixture-panda", host.slc_producer_session) monkeypatch.setattr(card_module, "current_native", lambda *args, **kwargs: native) observed = [] - update = intent.update + observe = intent.observe_full_engagement def at_boundary(state, **kwargs): observed.append(kwargs) - update(state, **kwargs) + observe(state, **kwargs) raise BoundaryReached - monkeypatch.setattr(intent, "update", at_boundary) + monkeypatch.setattr(intent, "observe_full_engagement", at_boundary) - def tick(*, previous=False, enabled=True, lat=False, long=False, valid=True, alive=True, age=0): + def tick(*, enabled=True, active=False, valid=True, alive=True, seen=True, age=0, control_active=True): nonlocal now now += 10_000_000 - control.enabled, control.latActive, control.longActive = enabled, lat, long + previous_enabled = bool(control.enabled) + control.enabled, control.latActive, control.longActive = enabled, control_active, False + engagement.enabled, engagement.active = enabled, active samples["carControl"] = control.as_reader() - samples.valid["carControl"], samples.alive["carControl"] = valid, alive - samples.logMonoTime.update(carControl=now - age, onroadEvents=now) - host.CC_prev = structs.CarControl(enabled=previous).as_reader() + samples["selfdriveState"] = engagement.as_reader() + samples.valid["selfdriveState"], samples.alive["selfdriveState"] = valid, alive + samples.seen["selfdriveState"] = seen + samples.logMonoTime.update(carControl=now, onroadEvents=now, selfdriveState=now - age) + host.CC_prev = structs.CarControl(enabled=previous_enabled).as_reader() with pytest.raises(BoundaryReached): host.state_update() return observed[-1] - assert not tick(previous=True)["standard_active"] # PreEnabled: enabled with neither active axis. + assert not tick(enabled=False)["active"] + assert not tick()["active"] # PreEnabled: fresh actuator activity cannot invent ordinary active state. assert not intent.allowed_latch - for kwargs in ({"previous": False}, {"previous": True, "valid": False}, - {"previous": True, "alive": False}, {"previous": True, "age": 200_000_000}): - assert not tick(lat=True, **kwargs)["standard_active"] + for kwargs in ({"valid": False}, {"alive": False}, {"seen": False}, {"age": 200_000_000}, {"age": -1}): + assert not tick(active=True, **kwargs)["observation_valid"] assert not intent.allowed_latch - assert tick(previous=True, lat=True)["standard_active"] + assert tick(active=True)["observation_valid"] assert intent.allowed_latch and not cs.cruiseState.enabled intent.allowed_latch = False - tick(previous=True, lat=True) - assert not intent.allowed_latch # Repeating the same active level is not a new SET. + tick(active=True) + assert not intent.allowed_latch # Repeating the same enabled epoch is not a new SET. tick(enabled=False) - assert tick(previous=True, long=True)["standard_active"] - assert intent.allowed_latch # Actual normal long activity also proves active engagement. + assert tick(active=True, control_active=False)["active"] + assert intent.allowed_latch # Actual ordinary state proves engagement independently of actuator samples. # The actual Card caller consumes the acknowledged native permission. Its # immediate rejection boundary remains strict; no host grace is extended. diff --git a/opendbc_repo/opendbc/car/tests/routes.py b/opendbc_repo/opendbc/car/tests/routes.py index a1b2a9a5bc..3b162c137f 100644 --- a/opendbc_repo/opendbc/car/tests/routes.py +++ b/opendbc_repo/opendbc/car/tests/routes.py @@ -26,6 +26,9 @@ unrecorded_route_gaps = { GM.CHEVROLET_MALIBU_HYBRID_CC: "gm/tests/test_ordinary_cc.py", TOYOTA.TOYOTA_PRIUS_RETROFIT: "toyota/tests/test_retrofit.py", TESLA.TESLA_MODEL_S_HW1: "tesla/tests/test_hw1.py", + VOLVO.VOLVO_XC40_RECHARGE: "volvo/tests/test_cma.py", + VOLVO.VOLVO_S60_RECHARGE: "volvo/tests/test_cma.py", + VOLVO.POLESTAR_2: "volvo/tests/test_cma.py", } # FIXME: add routes for these cars diff --git a/opendbc_repo/opendbc/car/tests/test_routes.py b/opendbc_repo/opendbc/car/tests/test_routes.py index 0d0cb17071..6f8bfac9d7 100644 --- a/opendbc_repo/opendbc/car/tests/test_routes.py +++ b/opendbc_repo/opendbc/car/tests/test_routes.py @@ -6,6 +6,7 @@ from opendbc.car.tests.routes import non_tested_cars, routes, unrecorded_route_g from opendbc.car.gm.values import CAR as GM from opendbc.car.tesla.values import CAR as TESLA from opendbc.car.toyota.values import CAR as TOYOTA +from opendbc.car.volvo.values import CAR as VOLVO class TestRoutes(unittest.TestCase): @@ -19,6 +20,7 @@ class TestRoutes(unittest.TestCase): def test_explicit_unrecorded_configuration_gaps(self): self.assertEqual(set(unrecorded_route_gaps), { GM.CHEVROLET_VOLT_CC, GM.CHEVROLET_MALIBU_HYBRID_CC, TOYOTA.TOYOTA_PRIUS_RETROFIT, TESLA.TESLA_MODEL_S_HW1, + VOLVO.VOLVO_XC40_RECHARGE, VOLVO.VOLVO_S60_RECHARGE, VOLVO.POLESTAR_2, }) self.assertFalse(set(unrecorded_route_gaps) & {route.car_model for route in routes}) self.assertTrue(set(unrecorded_route_gaps) <= set(non_tested_cars)) diff --git a/opendbc_repo/opendbc/safety/tests/test_prepared_torque_ev_native.py b/opendbc_repo/opendbc/safety/tests/test_prepared_torque_ev_native.py index 5a9449bac6..ef5837f3f7 100644 --- a/opendbc_repo/opendbc/safety/tests/test_prepared_torque_ev_native.py +++ b/opendbc_repo/opendbc/safety/tests/test_prepared_torque_ev_native.py @@ -11,14 +11,14 @@ INVENTORY = ( ) -def run_context(identity, alternate, aol): +def run_context(identity, alternate, aol, *, cancel_assigned=False): from opendbc.can import CANPacker from opendbc.car import Bus, gen_empty_fingerprint, structs from opendbc.car.hyundai.interface import CarInterface from opendbc.car.hyundai.tests.test_torque_ev_startup import owner from opendbc.car.hyundai.values import DBC from opendbc.safety.tests.libsafety import libsafety_py - from openpilot.starpilot.aol.intent import AolSettings, AOL_TOGGLE + from openpilot.starpilot.aol.intent import AolSettings, AOL_TOGGLE, PAUSE_LATERAL from openpilot.starpilot.car.hyundai.aol import create_intent, policy_for from openpilot.starpilot.vehicle_startup import VehicleStartupOwner from openpilot.selfdrive.car.car_events import CarEvents @@ -47,7 +47,8 @@ def run_context(identity, alternate, aol): assert int(cp.safetyConfigs[0].safetyParam) == ((0x95 if alternate else 0x15) | (0x800 if aol else 0)) assert emitted and all(address == 0x730 and bus == 1 for address, _, bus in emitted) packer = CANPacker(DBC[identity][Bus.pt]) - intent = create_intent(cp, AolSettings(aol, 0., AOL_TOGGLE, AOL_TOGGLE, (0, 0, 0), (0, 0, 0))) + cancel_actions = (PAUSE_LATERAL, 0, 0) if cancel_assigned else (0, 0, 0) + intent = create_intent(cp, AolSettings(aol, 0., AOL_TOGGLE, AOL_TOGGLE, (0, 0, 0), cancel_actions)) safety = libsafety_py.libsafety safety.init_tests() safety.set_alternative_experience(int(cp.alternativeExperience)) @@ -62,10 +63,11 @@ def run_context(identity, alternate, aol): previous_control = structs.CarControl() active_long = False independent_lateral = False + pre_cancel_edge_ns = 0 try: for tick in range(340): now = epoch + tick * 10_000_000 - lda = tick in (40, 41, 150, 151, 300, 301) + lda = tick in (40, 41, 150, 151, 300, 301) or (aol and not cancel_assigned and tick in (160, 161)) set_pressed = tick in (80, 81, 180, 181, 212, 213, 304, 305) cancel = tick in (120, 121) gas = 200 <= tick < 210 @@ -151,8 +153,24 @@ def run_context(identity, alternate, aol): independent_lateral |= bool(cc.latActive and not cc.longActive) if 202 <= tick < 210 or 232 <= tick < 240: assert not cc.longActive, (tick, 'physical pedal must withdraw LONG') + if aol and tick == 118: + pre_cancel_edge_ns = intent._last_latch_edge_ns + assert cc.latActive and cc.longActive and pre_cancel_edge_ns > 0 if aol and 124 <= tick < 148: - assert not cc.latActive, (tick, 'cancel must revoke lateral') + assert not cc.longActive, (tick, 'cancel must revoke longitudinal') + if cancel_assigned: + assert not cc.latActive, (tick, 'cancel must revoke lateral') + assert not intent.allowed_latch and mask == 0 + else: + # Unassigned CANCEL retains only the already claimed lateral token. + assert cc.latActive and intent.allowed_latch and mask == 1 + assert intent._last_latch_edge_ns == pre_cancel_edge_ns, (tick, 'cancel cannot renew intent') + if aol and not cancel_assigned and 154 <= tick < 160: + assert not cc.latActive and intent.pause_lateral and not intent.allowed_latch + assert mask == 0, (tick, 'physical LDA off must withdraw native lateral') + if aol and not cancel_assigned and 164 <= tick < 178: + assert cc.latActive and not cc.longActive and mask == 1 + assert intent._last_latch_edge_ns > pre_cancel_edge_ns, (tick, 'fresh LDA must rearm') assert active_long, 'No physical SET LONG epoch' assert independent_lateral == aol, 'Independent physical LDA epoch mismatch' @@ -190,8 +208,9 @@ class TestPreparedTorqueEvNative(unittest.TestCase): for identity in (CAR.HYUNDAI_IONIQ_5, CAR.HYUNDAI_KONA_EV_2ND_GEN): for alternate in (False, True): for aol in (False, True): - with self.subTest(identity=identity, alternate=alternate, aol=aol): - run_context(identity, alternate, aol) + for cancel_assigned in ((False, True) if aol else (False,)): + with self.subTest(identity=identity, alternate=alternate, aol=aol, cancel_assigned=cancel_assigned): + run_context(identity, alternate, aol, cancel_assigned=cancel_assigned) def test_release_raw_long_preserves_stock_neutral_contract(self): from opendbc.can import CANPacker diff --git a/openpilot/selfdrive/pandad/SConscript b/openpilot/selfdrive/pandad/SConscript index 826aedb7eb..d31b5cdb41 100644 --- a/openpilot/selfdrive/pandad/SConscript +++ b/openpilot/selfdrive/pandad/SConscript @@ -7,6 +7,9 @@ if GetOption('extras') or any(target.startswith('openpilot/selfdrive/pandad/test env.Program('tests/test_panda_usb', ['tests/test_panda_usb.cc', usb_test], LIBS=[common, 'pthread']) env.Program('tests/test_aol_protocol', ['tests/test_aol_protocol.cc']) env.Program('tests/test_aol_wire', ['tests/test_aol_wire.cc'], LIBS=[messaging, 'capnp', 'kj']) + # Compose producer freshness, session negotiation and native ACK wire contracts. + env.Program('tests/test_producer_timing', ['tests/test_producer_timing.cc'], LIBS=[messaging, 'capnp', 'kj']) + env.Program('tests/test_producer_sink', ['tests/test_producer_sink.cc'], LIBS=[messaging, 'capnp', 'kj']) if arch != "Darwin": libs = [common, messaging, 'pthread', 'usb-1.0'] diff --git a/openpilot/selfdrive/pandad/tests/test_producer_sink.cc b/openpilot/selfdrive/pandad/tests/test_producer_sink.cc new file mode 100644 index 0000000000..e69ab38e87 --- /dev/null +++ b/openpilot/selfdrive/pandad/tests/test_producer_sink.cc @@ -0,0 +1,103 @@ +// Compose native status parsing/negotiation with the actual wire encoder. +// This is a header contract test, not a claim of pandad thread/IPC coverage. +#include "common/tests/native_test.h" +#include "selfdrive/pandad/aol_protocol.h" +#include "selfdrive/pandad/aol_wire.h" +#include "openpilot/starpilot/car/hyundai/aol_policy.h" + +namespace { +constexpr uint64_t NOW = 1000000000ULL; +const AolProfileRegistry REGISTRY{&HYUNDAI_AOL_PROFILE, 1U}; + +aol_safety_health_t status(uint8_t request = 0U, uint8_t permission = 0U) { + return {AOL_SAFETY_PROTOCOL_MAGIC, AOL_SAFETY_PROTOCOL_VERSION, request, permission, + HYUNDAI_AOL_PROFILE.mode, 0x815U, 1U}; +} + +std::optional parsed(const aol_safety_health_t &native) { + unsigned char bytes[sizeof(native)]; + std::memcpy(bytes, &native, sizeof(native)); + return parse_aol_status(bytes, sizeof(bytes)); +} + +void check_wire(const AolOutcome &outcome, const std::string &session, + uint8_t request, uint8_t permission) { + CHECK(outcome.compatible && outcome.status); + const auto &native = *outcome.status; + // Same outcome-to-fields projection as pandad.cc. Requested/allowed bits + // must originate in the observed ACK, never the desired axis/write plan. + AolSafetyWireFields fields{ + AOL_SAFETY_PROTOCOL_VERSION, outcome.compatible, NOW, NOW + 200000000ULL, + native.safety_mode, native.safety_param, + (native.permission_mask & 1U) != 0U, (native.permission_mask & 2U) != 0U, + (native.request_mask & 1U) != 0U, (native.request_mask & 2U) != 0U, + "owner", outcome.session, + {NOW + 9000000000ULL, {{0U, "owner", native.safety_mode, native.safety_param, 32U, false, false, false, 0U}}} + }; + auto encoded = encode_aol_safety_wire(fields); + CHECK(!encoded.empty() && encoded.size() <= 512U && encoded.size() % sizeof(capnp::word) == 0U); + auto aligned = kj::heapArray(encoded.size() / sizeof(capnp::word)); + std::memcpy(aligned.begin(), encoded.data(), encoded.size()); + capnp::FlatArrayMessageReader reader(aligned.asPtr()); + auto wire = reader.getRoot(); + CHECK(wire.getCompatible() && wire.getProtocolVersion() == AOL_SAFETY_PROTOCOL_VERSION); + CHECK(std::string(wire.getAxisSessionId().cStr()) == session); + CHECK(wire.getRequestedLateral() == ((request & 1U) != 0U)); + CHECK(wire.getRequestedLongitudinal() == ((request & 2U) != 0U)); + CHECK(wire.getLateralAllowed() == ((permission & 1U) != 0U)); + CHECK(wire.getLongitudinalAllowed() == ((permission & 2U) != 0U)); + CHECK(wire.getObservedMonoTime() == NOW && wire.getValidUntilMonoTime() == NOW + 200000000ULL); + CHECK(wire.getSourcePandaStatesMonoTime() == NOW + 9000000000ULL); + CHECK(wire.getPandaInventory().size() == 1U); + CHECK(wire.getPandaInventory()[0].getHardwareSerial() == "owner"); +} + +void test_native_ack_to_wire() { + AolAxisNegotiator negotiator(REGISTRY); + AolAxisInput axis{true, true, "axis-old", NOW, NOW + 30000000ULL, NOW, true, true}; + auto plan = negotiator.prepare(parsed(status(3U, 3U)), axis, NOW, HYUNDAI_AOL_PROFILE.mode); + CHECK(plan.capable && plan.request_mask == 0U); + auto outcome = negotiator.complete(plan, true, parsed(status())); + check_wire(outcome, "axis-old", 0U, 0U); + + plan = negotiator.prepare(parsed(status()), axis, NOW, HYUNDAI_AOL_PROFILE.mode); + CHECK(plan.request_mask == 3U); + // Native may acknowledge LAT before LONG. Serialize that exact observation. + outcome = negotiator.complete(plan, true, parsed(status(1U, 1U))); + check_wire(outcome, "axis-old", 1U, 1U); + plan = negotiator.prepare(parsed(status(1U, 1U)), axis, NOW, HYUNDAI_AOL_PROFILE.mode); + outcome = negotiator.complete(plan, true, parsed(status(3U, 1U))); + check_wire(outcome, "axis-old", 3U, 1U); // request is not permission + + axis.session = "axis-new"; + plan = negotiator.prepare(parsed(status(3U, 1U)), axis, NOW, HYUNDAI_AOL_PROFILE.mode); + CHECK(plan.request_mask == 0U); + outcome = negotiator.complete(plan, true, parsed(status(3U, 1U))); + check_wire(outcome, "", 3U, 1U); // delayed old ACK cannot bind the new session + plan = negotiator.prepare(parsed(status(3U, 1U)), axis, NOW, HYUNDAI_AOL_PROFILE.mode); + CHECK(plan.request_mask == 0U); + outcome = negotiator.complete(plan, true, parsed(status())); + check_wire(outcome, "axis-new", 0U, 0U); + plan = negotiator.prepare(parsed(status()), axis, NOW, HYUNDAI_AOL_PROFILE.mode); + CHECK(plan.request_mask == 3U); + outcome = negotiator.complete(plan, true, parsed(status(3U, 3U))); + check_wire(outcome, "axis-new", 3U, 3U); + + // Every failed completion requires a new zero ACK before another request. + for (unsigned failure = 0; failure < 4U; ++failure) { + plan = negotiator.prepare(parsed(status(3U, 3U)), axis, NOW, HYUNDAI_AOL_PROFILE.mode); + auto after = status(3U, 3U); + if (failure == 1U) after.magic = 0U; + if (failure == 2U) after.safety_param = 0U; + auto ack = failure == 3U ? std::optional{} : parsed(after); + outcome = negotiator.complete(plan, failure != 0U, ack); + CHECK(!outcome.compatible && !outcome.status && outcome.session.empty()); + plan = negotiator.prepare(parsed(status()), axis, NOW, HYUNDAI_AOL_PROFILE.mode); + CHECK(plan.request_mask == 0U); + outcome = negotiator.complete(plan, true, parsed(status())); + check_wire(outcome, "axis-new", 0U, 0U); + } +} +} // namespace + +int main() { return run_native_test(test_native_ack_to_wire); } diff --git a/openpilot/selfdrive/pandad/tests/test_producer_timing.cc b/openpilot/selfdrive/pandad/tests/test_producer_timing.cc new file mode 100644 index 0000000000..f5d144d3f2 --- /dev/null +++ b/openpilot/selfdrive/pandad/tests/test_producer_timing.cc @@ -0,0 +1,89 @@ +// Compose Card's real wire freshness predicate with native command negotiation. +// No scheduler, clock, transport or production publisher is replaced here. +#include "common/tests/native_test.h" +#include "selfdrive/pandad/aol_protocol.h" +#include "selfdrive/pandad/aol_wire.h" +#include "openpilot/starpilot/car/hyundai/aol_policy.h" + +namespace { +constexpr uint64_t NOW = 1000000000ULL; +constexpr uint64_t CARD = NOW - 10000000ULL; +constexpr uint64_t SOURCE = CARD - 40000000ULL; +const AolProfileRegistry REGISTRY{&HYUNDAI_AOL_PROFILE, 1U}; + +const aol_safety_health_t STATUS{AOL_SAFETY_PROTOCOL_MAGIC, AOL_SAFETY_PROTOCOL_VERSION, + 0U, 0U, HYUNDAI_AOL_PROFILE.mode, 0x815U, 1U}; + +void test_card_and_axis_leases() { + // A current Card arm can outlive an axis's short command lease, only in + // the axis session that has already completed its own native zero ACK. + for (unsigned mutation = 0; mutation < 10U; ++mutation) { + AolAxisNegotiator negotiator(REGISTRY); + AolAxisInput axis{true, true, "axis-1", NOW - 40000000ULL, NOW - 1U, + NOW - 40000000ULL, true, true}; + auto initial = axis; + initial.observed_ns = initial.message_ns = NOW; + initial.valid_until_ns = NOW + 30000000ULL; + auto zero = negotiator.prepare(STATUS, initial, NOW, HYUNDAI_AOL_PROFILE.mode); + CHECK(zero.capable && zero.request_mask == 0U); + CHECK(negotiator.complete(zero, true, STATUS).session == "axis-1"); + + capnp::MallocMessageBuilder builder; + auto intent = builder.initRoot(); + intent.setKind(2U); + intent.setVersion(1U); + intent.setProducerSessionId("card-1"); + intent.setSequence(1U); + intent.setCarStateLogMonoTime(CARD); + intent.setObservedMonoTime(CARD); + intent.setValidUntilMonoTime(NOW + 20000000ULL); + intent.setSettingsQualified(true); + intent.setLateralArmed(true); + uint64_t stamp = CARD; + uint64_t source = SOURCE; + std::string producer = "card-1"; + if (mutation == 1U) producer = "replacement-card"; + if (mutation == 2U) stamp = NOW - 30000001ULL; + if (mutation == 3U) intent.setSequence(0U); + if (mutation == 4U) intent.setSettingsQualified(false); + if (mutation == 5U) intent.setLateralArmed(false); + if (mutation == 6U) intent.setObservedMonoTime(CARD - 1U); + if (mutation == 7U) source = CARD - 200000001ULL; + if (mutation == 8U) axis.session = "replacement-axis"; + if (mutation == 9U) axis.alive_valid = false; + auto flat = capnp::messageToFlatArray(builder); + axis.retain_lateral_arm = aol_armed_intent(flat.asBytes(), stamp, source, NOW, false, producer); + CHECK(axis.retain_lateral_arm == (mutation == 0U || mutation >= 8U)); + const auto expired = negotiator.prepare(STATUS, axis, NOW, HYUNDAI_AOL_PROFILE.mode); + CHECK(expired.capable && expired.request_mask == 0U); + CHECK(expired.retain_lateral_arm == (mutation == 0U)); + // No intent flag or source/session mutation can resurrect either command. + CHECK((expired.request_mask & 3U) == 0U); + } + + // Boundary composition: Card and axis age limits are independent. A Card + // at exactly 30 ms retains an expired axis arm at exactly 200 ms; moving + // either clock one nanosecond past its own boundary removes retention. + for (unsigned boundary = 0; boundary < 3U; ++boundary) { + AolAxisNegotiator negotiator(REGISTRY); + AolAxisInput axis{true, true, "boundary", NOW, NOW + 1U, NOW, true, true}; + auto zero = negotiator.prepare(STATUS, axis, NOW, HYUNDAI_AOL_PROFILE.mode); + CHECK(negotiator.complete(zero, true, STATUS).session == "boundary"); + const uint64_t card_ns = NOW - 30000000ULL - (boundary == 1U ? 1U : 0U); + axis.observed_ns = axis.message_ns = NOW - AOL_AXIS_MESSAGE_MAX_AGE_NS - (boundary == 2U ? 1U : 0U); + axis.valid_until_ns = axis.observed_ns + 30000000ULL; + capnp::MallocMessageBuilder builder; + auto intent = builder.initRoot(); + intent.setKind(2U); intent.setVersion(1U); intent.setSequence(1U); + intent.setProducerSessionId("card-boundary"); + intent.setCarStateLogMonoTime(card_ns); intent.setObservedMonoTime(card_ns); + intent.setValidUntilMonoTime(NOW); intent.setSettingsQualified(true); intent.setLateralArmed(true); + auto flat = capnp::messageToFlatArray(builder); + axis.retain_lateral_arm = aol_armed_intent(flat.asBytes(), card_ns, axis.observed_ns, NOW, false, "card-boundary"); + auto plan = negotiator.prepare(STATUS, axis, NOW, HYUNDAI_AOL_PROFILE.mode); + CHECK(plan.request_mask == 0U && plan.retain_lateral_arm == (boundary == 0U)); + } +} +} // namespace + +int main() { return run_native_test(test_card_and_axis_leases); } diff --git a/openpilot/starpilot/flm/canonical_surface_adapter.py b/openpilot/starpilot/flm/canonical_surface_adapter.py index 1534f3517c..b4d6b08c5a 100644 --- a/openpilot/starpilot/flm/canonical_surface_adapter.py +++ b/openpilot/starpilot/flm/canonical_surface_adapter.py @@ -117,7 +117,7 @@ def canonical_names(names): _RAW_BY_NAME = MappingProxyType({name: (value, limits) for name, value, limits in RAW_DOMAINS}) -def _coordinate_bounds_static(name): +def _coordinate_bounds_static(name) -> tuple[float, float]: if name not in NAMES: raise ValueError('unknown canonical coordinate') original, (raw_low, raw_high) = _RAW_BY_NAME[name] @@ -134,7 +134,7 @@ def _coordinate_bounds_static(name): _COORDINATE_BOUNDS = MappingProxyType({name: _coordinate_bounds_static(name) for name in NAMES}) -def coordinate_bounds(name): +def coordinate_bounds(name) -> tuple[float, float]: if type(name) is not str or name not in _COORDINATE_BOUNDS: raise ValueError('unknown canonical coordinate') return _COORDINATE_BOUNDS[name] diff --git a/openpilot/starpilot/flm/controller_replay.py b/openpilot/starpilot/flm/controller_replay.py index b0eaa9c2b0..7d63ba956f 100644 --- a/openpilot/starpilot/flm/controller_replay.py +++ b/openpilot/starpilot/flm/controller_replay.py @@ -574,7 +574,7 @@ class ControllerReplay: self.recent_states = OrderedDict() self.cached_validation = None self.cached_key = None - self.replay_windows = () + self.replay_windows: tuple[tuple[int, int], ...] = () self.raw_mode = False self.actual_centre_friction_values = OrderedDict() from openpilot.starpilot.flm.torque_emission_replay import EmissionHistory @@ -1107,7 +1107,7 @@ class ControllerReplay: return None calibration_ids = set(model.train_blocks + model.heldout_blocks) gains = {'left': [], 'right': []} - points = {'left': 0, 'right': 0} + points: dict[str, float] = {'left': 0, 'right': 0} baseline_controller, baseline_vm = self.factory.create() candidate_controller, candidate_vm = self.factory.create() total_frames = 0 @@ -1369,11 +1369,11 @@ class ControllerReplay: except (AttributeError, KeyError, TypeError, ValueError, OverflowError): return False - def to_snapshot(self, blocks=None, *, event_windows=None): + def to_snapshot(self, blocks=None, *, event_windows: tuple[tuple[int, int], ...] | None = None): """Primitive child-fitter payload, outside all100Hz controller processes.""" from dataclasses import asdict - windows = [] + windows: list[tuple[int, int]] = [] if blocks is not None: if not 8 <= len(blocks) <= 24: raise ValueError('fit job maneuver bound') diff --git a/openpilot/starpilot/flm/event_controller_replay.py b/openpilot/starpilot/flm/event_controller_replay.py index de209b64f8..7882d12eaf 100644 --- a/openpilot/starpilot/flm/event_controller_replay.py +++ b/openpilot/starpilot/flm/event_controller_replay.py @@ -1,4 +1,6 @@ """Child-only canonical effect trials through complete selected controller/CAN history.""" + +from typing import cast from collections.abc import Mapping import math @@ -15,37 +17,39 @@ POLICY = 'event_effect_coordinate_v2' RESTORE_POLICY = 'event_effect_restore_v1' -class _LazyControllerRequests(Mapping): +class _LazyControllerRequests(Mapping[int, float]): """Only original native replay may pull this child-local controller iterator. Values are cached by their exact control timestamp. Native apply-order stays owned by EmissionHistory.rollout, including duplicate/older requested stamps. No result is successful until finish() exhausts all original controller guards. """ - def __init__(self, values, frames): - self.values, self.frames = values, frames + _values: dict[int, float] + + def __init__(self, values: dict[int, float], frames): + self._values, self.frames = values, frames self.finished = False def __contains__(self, stamp): - while stamp not in self.values and not self.finished: + while stamp not in self._values and not self.finished: try: next(self.frames) except StopIteration: self.finished = True - return stamp in self.values + return stamp in self._values def __getitem__(self, stamp): if stamp not in self: raise KeyError(stamp) - return self.values[stamp] + return self._values[stamp] def __iter__(self): self.finish() - return iter(self.values) + return iter(self._values) def __len__(self): self.finish() - return len(self.values) + return len(self._values) def close(self): self.frames.close() @@ -247,7 +251,7 @@ class EventControllerReplay: not restore_target and restore_omit_group is None and _prefix_context is not None) build_prefix = bool(prefix_eligible and _prefix_context[1] and not shadow and tuple(coordinates) == self.reference) certificate = self.__dict__.get('_EventControllerReplay__controller_prefix') if prefix_eligible and not build_prefix else None - if certificate is not None and certificate[0] != _prefix_context[0]: + if certificate is not None and certificate[0] != cast(tuple[object, bool], _prefix_context)[0]: certificate = None prefix_requests, prefix_saturated, prefix_states = {}, {}, {} @@ -359,7 +363,7 @@ class EventControllerReplay: # original final-window/tick guard must ALSO finish before any result exists. lazy_requests.finish() if build_prefix and prefix_requests and prefix_states: - self.__pending_controller_prefix = (_prefix_context[0], MappingProxyType(prefix_requests), + self.__pending_controller_prefix = (cast(tuple[object, bool], _prefix_context)[0], MappingProxyType(prefix_requests), MappingProxyType(prefix_saturated), MappingProxyType(prefix_states)) return requests, encoded, saturated finally: diff --git a/openpilot/starpilot/flm/event_fit_factory.py b/openpilot/starpilot/flm/event_fit_factory.py index 360b199af5..10178c4cf1 100644 --- a/openpilot/starpilot/flm/event_fit_factory.py +++ b/openpilot/starpilot/flm/event_fit_factory.py @@ -1,4 +1,8 @@ """Actual child dispatch for canonical full30 event trials, never a helper-only path.""" + +from typing import TypedDict, cast + + from openpilot.starpilot.flm.clipping_quality import event_clipping_quality from dataclasses import asdict import itertools @@ -14,6 +18,13 @@ from openpilot.starpilot.flm.torque_emission_replay import delivered from openpilot.starpilot.flm.torque_raw_fit import output_from_dict +class _EffectObjectiveFields(TypedDict): + group: str + names: tuple[str, ...] + anchor_digest: str + context_key: str + + def _identified(engine, raw): # Selection uses actual feature support ONLY, before any target/loss. A # globally excited column cannot qualify episodes that lack that column. @@ -76,7 +87,7 @@ def fit_event_snapshot(payload, factory, replay_payload): raise ValueError('whole-map activation is restore-only') if payload['event_version'] == 3 and (payload.get('event_operation') != 'trial' or envelope.operation != 'trial'): raise ValueError('declared activation requires original trial operation') - objective = EffectObjective(**{**payload['event_objective'], 'names': tuple(payload['event_objective']['names'])}) + objective = EffectObjective(**cast(_EffectObjectiveFields, {**payload['event_objective'], 'names': tuple(payload['event_objective']['names'])})) if (envelope.trial_names != objective.names or envelope.trial_anchor != objective.anchor_digest or envelope.context_key != objective.context_key or envelope.source_epoch != objective.context_key or envelope.selected_baseline_revision != replay_payload['baseline_revision']): diff --git a/openpilot/starpilot/flm/full_surface_store.py b/openpilot/starpilot/flm/full_surface_store.py index 216e483d86..15e271f615 100644 --- a/openpilot/starpilot/flm/full_surface_store.py +++ b/openpilot/starpilot/flm/full_surface_store.py @@ -5,6 +5,8 @@ updates use prior-full-hash CAS and native Params byte-exact commit. Never retur an active Profile, publication lease, native permission or restored fit receipt. """ +from typing import cast + from dataclasses import dataclass, replace import hashlib from openpilot.starpilot.flm.full_surface_profile import ( @@ -91,7 +93,7 @@ def remember_full(raw, profile, *, validation_sha256): # to full surface coordinates or treated as live/accepted new-policy evidence. if doc['version'] == 1: doc = {'version': 2, 'profiles': doc['profiles'], NAMESPACE: {}} - rows = doc[NAMESPACE] + rows = cast(dict[str, dict[str, object]], doc[NAMESPACE]) # Stable settings hashes are opaque: a different reset may also represent a # different saved configuration. Never evict another scope by guessing that # only reset changed; the fixed store bound fails closed when full. @@ -119,7 +121,7 @@ def remember_full(raw, profile, *, validation_sha256): raise ValueError('full profile store full') rows[envelope.scope()] = { 'current': envelope_dict(envelope), - 'previous': None if previous is None else {'envelope': envelope_dict(previous), 'validation': row['validation']}, + 'previous': None if previous is None else {'envelope': envelope_dict(previous), 'validation': cast(dict[str, object], row)['validation']}, 'validation': validation_sha256, } return encode(doc) diff --git a/openpilot/starpilot/flm/fullrate_collector.py b/openpilot/starpilot/flm/fullrate_collector.py index 8452c1c5d6..3b76bdc7f4 100644 --- a/openpilot/starpilot/flm/fullrate_collector.py +++ b/openpilot/starpilot/flm/fullrate_collector.py @@ -5,6 +5,8 @@ Inputs are decoded custom evidence produced by actual owners; no Params/default controller selection is inferred here. Domain adapters qualify response inverse. """ +from typing import TypeGuard, cast + from collections import Counter, OrderedDict, deque from openpilot.starpilot.flm.torque_eligibility import asynchronous_hint, bounded_context_output, exact_unclipped_pair, consumed_sources_current @@ -30,8 +32,8 @@ MAX_WIRE_BYTES = 32768 MAX_SNAPSHOT_WIRE_BYTES = 131072 -def finite(value): - return type(value) in (int, float) and math.isfinite(value) +def finite(value: object) -> TypeGuard[int | float]: + return (type(value) is int or type(value) is float) and math.isfinite(value) def replay_values(value, bound): @@ -596,7 +598,8 @@ class FullrateCollector: control = self.raw_points[0].control self.blocks.append( RawBlock( - control.identity, f'{self.session}:{self.block_sequence}', self.cohort, control.stage_revision, control.response_revision, tuple(self.raw_points) + control.identity, f'{self.session}:{self.block_sequence}', cast(str, self.cohort), + control.stage_revision, control.response_revision, tuple(self.raw_points) ) ) self.block_sequence += 1 diff --git a/openpilot/starpilot/flm/learned_lifecycle.py b/openpilot/starpilot/flm/learned_lifecycle.py index ba3b555992..1596a83647 100644 --- a/openpilot/starpilot/flm/learned_lifecycle.py +++ b/openpilot/starpilot/flm/learned_lifecycle.py @@ -1,5 +1,8 @@ """Bounded historical profile storage and current application display.""" +from collections.abc import Callable +from typing import cast + from openpilot.starpilot.flm.adaptive_profile import ApplicationOutcome import json import hashlib @@ -65,7 +68,7 @@ def decode(raw): or len(parameter) != 2 or any(type(x) is not str or not x or len(x) > 128 for x in parameter) or parameter[0] in names - or type(value) not in (int, float) + or (type(value) is not int and type(value) is not float) or not math.isfinite(value) ): raise ValueError('Invalid stored coordinate') @@ -239,7 +242,7 @@ class ProducerPersistence: return False def authorized(): - return current_sources() == captured and (full_binding is None or current_full_binding() == full_binding) + return current_sources() == captured and (full_binding is None or cast(Callable[[], object], current_full_binding)() == full_binding) committed = self.storage.save(profile, reset=reset, replay_validation=replay_validation, authorized=authorized, full_binding=full_binding) if committed: diff --git a/openpilot/starpilot/flm/learner_factory.py b/openpilot/starpilot/flm/learner_factory.py index dc3c2604fd..2862996e65 100644 --- a/openpilot/starpilot/flm/learner_factory.py +++ b/openpilot/starpilot/flm/learner_factory.py @@ -1,5 +1,8 @@ """Raw parent recordings, one bounded child fit, real selected-controller replay.""" +from typing import TypedDict, cast + + import base64 from openpilot.starpilot.flm.physical_response import request_force_accel from itertools import islice @@ -23,6 +26,14 @@ from openpilot.starpilot.flm.controller_replay import ( factory_from_snapshot, ) + +class _EffectObjectiveFields(TypedDict): + group: str + names: tuple[str, ...] + anchor_digest: str + context_key: str + + RAW_TORQUE = 'torque-selected-stage-snapshot-v1' FF_KEYS = tuple(('feedforward', speed, phase) for speed in ('low', 'high') for phase in ('steady', 'turn_in', 'unwind')) @@ -464,7 +475,7 @@ def fit_snapshot(snapshot): if event_job: from openpilot.starpilot.flm.event_objective import EffectObjective objective_row = payload['event_objective'] - event_objective = EffectObjective(**{**objective_row, 'names': tuple(objective_row['names'])}) + event_objective = EffectObjective(**cast(_EffectObjectiveFields, {**objective_row, 'names': tuple(objective_row['names'])})) # A fixed historical full-map restore is not a friction-only trial. It # must retain independent qualification for every active group, including # FF; choosing a friction protocol marker cannot bypass its noise gate. diff --git a/openpilot/starpilot/flm/learning_wire.py b/openpilot/starpilot/flm/learning_wire.py index d91478e13d..7059ae6739 100644 --- a/openpilot/starpilot/flm/learning_wire.py +++ b/openpilot/starpilot/flm/learning_wire.py @@ -1,5 +1,8 @@ """Typed custom child snapshots; legacy Event fields and linear fit unchanged.""" +from typing import TypedDict, cast + + from functools import lru_cache from capnp.lib.capnp import _DynamicStructReader @@ -7,6 +10,29 @@ from openpilot.cereal import custom, messaging from openpilot.starpilot.flm.adaptive_profile import Parameter, Profile, ReplayValidation, replay_accepts from openpilot.starpilot.flm.fullrate_collector import ControlEvidence, AppliedEvidence, identity, finite + +class _FullSurfaceFields(TypedDict): + version: int + manifest_sha256: str + full_original_anchor: str + trial_anchor: str + context_key: str + cp_sha256: str + controller_revision: str + source_epoch: str + reset_generation: str + selected_baseline_revision: str + prior_full_profile_sha256: str + full_profile_sha256: str + prior_coordinates: tuple[tuple[str, float], ...] + coordinates: tuple[tuple[str, float], ...] + trial_names: tuple[str, ...] + persistent_source_epoch: str + operation: str + history_full_profile_sha256: str + restore_transaction_sha256: str + + VERSION = 1 MAX_FIELDS = 8 MAX_IDS = 24 @@ -70,7 +96,8 @@ def _read_effect_trace(row): raise ValueError('effect trace original envelope') values=[tuple((str(v.name),float(v.value)) for v in getattr(row,n)) for n in ('before','target','applied')] return FullEffectTrace(1,envelope,int(row.controlMonoTime),int(row.previousMonoTime),bool(row.qualified), - *values,str(row.appliedFullProfileSha256)).validate() + *cast(tuple[tuple[tuple[str, float], ...], tuple[tuple[str, float], ...], tuple[tuple[str, float], ...]], values), + str(row.appliedFullProfileSha256)).validate() def write_control(parent, evidence): @@ -279,11 +306,11 @@ def _read_full_surface(row): try: if row.version not in (1, 2) or len(row.priorCoordinates) != 30 or len(row.coordinates) != 30 or not 1 <= len(row.trialNames) <= 4: return None - values = {field: str(getattr(row, wire)) for wire, field in _FULL_FIELDS} + values: dict[str, object] = {field: str(getattr(row, wire)) for wire, field in _FULL_FIELDS} values.update(version=int(row.version), prior_coordinates=tuple((str(point.name), float(point.value)) for point in row.priorCoordinates), coordinates=tuple((str(point.name), float(point.value)) for point in row.coordinates), trial_names=tuple(str(name) for name in row.trialNames)) - return FullSurfaceEnvelope(**values).validate() + return FullSurfaceEnvelope(**cast(_FullSurfaceFields, values)).validate() except (AttributeError, KeyError, TypeError, ValueError, OverflowError): return None diff --git a/openpilot/starpilot/flm/pid_stages.py b/openpilot/starpilot/flm/pid_stages.py index 8afa9a0a7c..03a4dbd212 100644 --- a/openpilot/starpilot/flm/pid_stages.py +++ b/openpilot/starpilot/flm/pid_stages.py @@ -109,6 +109,7 @@ def error_for_frame(parent, cs, baseline_error, *, active, limited): source = getattr(parent, 'automatic_flm_effects', None) if ( source is None + or observation is None or frame is None or not getattr(parent, 'automatic_pid_stage_admitted', False) or not frame.valid() @@ -222,6 +223,7 @@ def finish_frame(parent, cs, *, desired_angle_deg, actual_angle_deg, baseline_ou if ( source is None or not getattr(parent, 'automatic_pid_stage_admitted', False) + or observation is None or frame is None or not frame.valid() or not active diff --git a/openpilot/starpilot/flm/projection_wire.py b/openpilot/starpilot/flm/projection_wire.py index 1846917903..50e50738e2 100644 --- a/openpilot/starpilot/flm/projection_wire.py +++ b/openpilot/starpilot/flm/projection_wire.py @@ -1,5 +1,7 @@ """Exact bounded CarController emission metadata; no permission or plant claim.""" +from typing import cast + FIELDS = ( ('version', 'version'), ('family', 'family'), @@ -30,7 +32,7 @@ def write_projection(value): from opendbc.car.steering_projection import projection_to_dict from openpilot.starpilot.flm.learning_wire import _named_values - data = projection_to_dict(value) + data = cast(dict[str, object], projection_to_dict(value)) result = {wire: data[key] for wire, key in FIELDS} # Honda's normalized limiter can have no unlimited integer command: its # lookup encoder runs AFTER limiting. Preserve absence rather than inventing @@ -38,8 +40,8 @@ def write_projection(value): result['version'] = 2 result['unlimitedRawPresent'] = data['unlimited_raw'] is not None result['unlimitedRaw'] = 0 if data['unlimited_raw'] is None else data['unlimited_raw'] - result['limits'] = [{'name': name, 'value': amount} for name, amount in data['limits']] - result['shape'] = _named_values(tuple((str(index), item) for index, item in enumerate(data['shape']))) + result['limits'] = [{'name': name, 'value': amount} for name, amount in cast(tuple[tuple[str, float], ...], data['limits'])] + result['shape'] = _named_values(tuple((str(index), item) for index, item in enumerate(cast(tuple[float | bool, ...], data['shape'])))) return result diff --git a/openpilot/starpilot/flm/restore_activation.py b/openpilot/starpilot/flm/restore_activation.py index fc8d9aad10..3cffdc315b 100644 --- a/openpilot/starpilot/flm/restore_activation.py +++ b/openpilot/starpilot/flm/restore_activation.py @@ -1,4 +1,8 @@ """Restore-only fixed-map declaration; no finite differences or live authority.""" + +from typing import TypedDict, cast + + from dataclasses import asdict, dataclass import hashlib import json @@ -11,6 +15,19 @@ from openpilot.starpilot.flm.event_activation import EventActivationPlan, EXACT_ from openpilot.starpilot.flm.full_surface_profile import FullSurfaceEnvelope +class _ActivationFields(TypedDict): + version: str + identity: str + baseline_revision: str + source_epoch: str + physical_context: str + calibration_train_blocks: tuple[str, ...] + calibration_heldout_blocks: tuple[str, ...] + cutoff_ns: int + stage_blocks: tuple[str, ...] + windows: tuple[tuple[int, int, int, int], ...] + + def restore_envelope_digest(envelope): if type(envelope) is not FullSurfaceEnvelope or envelope.operation != 'restore' or envelope.version != 2: raise ValueError('typed full-map historical restore envelope') @@ -29,7 +46,7 @@ class WholeMapRestorePlan(EventActivationPlan): raise ValueError('whole-map restore plan version/envelope') # Reuse only the chronology/lineage validator; never decode this as a trial. fields = {name: getattr(self, name) for name in EventActivationPlan.__dataclass_fields__} - EventActivationPlan(**{**fields, 'version': EXACT_EMISSION_ACTIVATION_VERSION}).validate() + EventActivationPlan(**cast(_ActivationFields, {**fields, 'version': EXACT_EMISSION_ACTIVATION_VERSION})).validate() return self @property diff --git a/openpilot/starpilot/flm/runtime_adapter_registry.py b/openpilot/starpilot/flm/runtime_adapter_registry.py index 6f08e6779d..b90e2a4e54 100644 --- a/openpilot/starpilot/flm/runtime_adapter_registry.py +++ b/openpilot/starpilot/flm/runtime_adapter_registry.py @@ -1,5 +1,7 @@ """Shared actual-selected controller identities; physical fitting stays in learner.""" +from typing import cast + from dataclasses import dataclass, field, replace import hashlib import json @@ -24,11 +26,12 @@ def implementation_revision(controller_type, interface_type): values. Formatting changes conservatively discard stored priors too. """ here = Path(__file__).resolve().parent - controller_root = Path(importlib.import_module(controller_type.__module__).__file__).resolve().parent - brand_root = Path(importlib.import_module(interface_type.__module__).__file__).resolve().parent + controller_root = Path(cast(str, importlib.import_module(controller_type.__module__).__file__)).resolve().parent + brand_root = Path(cast(str, importlib.import_module(interface_type.__module__).__file__)).resolve().parent common_root = here.parents[1] / 'common' inherited_roots = tuple( - Path(importlib.import_module(base.__module__).__file__).resolve().parent for base in controller_type.__mro__ if base.__module__.startswith('openpilot.') + Path(cast(str, importlib.import_module(base.__module__).__file__)).resolve().parent + for base in controller_type.__mro__ if base.__module__.startswith('openpilot.') ) roots = (here, here.parent / 'lateral', controller_root, brand_root, brand_root.parent, common_root, *inherited_roots) files = tuple(sorted({path for root in roots for path in root.glob('*.py')})) diff --git a/openpilot/starpilot/flm/selected_effects.py b/openpilot/starpilot/flm/selected_effects.py index 2288ec6bc1..45ec236aac 100644 --- a/openpilot/starpilot/flm/selected_effects.py +++ b/openpilot/starpilot/flm/selected_effects.py @@ -5,6 +5,8 @@ No saved tune/PID/native writes; admission remains in the controls runtime. """ +from typing import cast + from dataclasses import dataclass import math from openpilot.starpilot.flm.adaptive_profile import ProfileOwner, ReplayValidation @@ -43,7 +45,7 @@ class EffectTrial: expected = original_anchor(self.selected_baseline_revision, names) if self.objective.anchor_digest != expected: raise ValueError('recentered or foreign effect objective') - payload = self.original_anchor + payload = cast(dict[str, object], self.original_anchor) fields = { 'version', 'manifest_sha256', @@ -71,7 +73,7 @@ class EffectTrial: or payload['selected_baseline_revision'] != self.selected_baseline_revision ): raise ValueError('effect full-profile context') - entries = payload['accumulated'] + entries = cast(tuple[tuple[str, float], ...], payload['accumulated']) if type(entries) is not tuple or len(entries) != len(NAMES) or tuple(n for n, _ in entries) != NAMES: raise ValueError('exact full30 prior profile required') accumulated = dict(entries) diff --git a/openpilot/starpilot/flm/steerlearnerd.py b/openpilot/starpilot/flm/steerlearnerd.py index c35411b752..296eaf373a 100644 --- a/openpilot/starpilot/flm/steerlearnerd.py +++ b/openpilot/starpilot/flm/steerlearnerd.py @@ -4,6 +4,10 @@ Actual controller baseline and independent physical model adapters are selected from received source-bound evidence, never another process's startup preference. """ +from collections.abc import Callable +from typing import cast +from openpilot.starpilot.flm.runtime_adapter_registry import SourceState + import os import uuid import time @@ -100,7 +104,7 @@ def run( *, clock=time.monotonic_ns, reporter=None, - publication_source_reader=None, + publication_source_reader: Callable[[int], SourceState | None] | None = None, ): from openpilot.starpilot.flm.native_health import current_native_health @@ -152,7 +156,7 @@ def run( sm.update(0) now = clock() reader = publication_source_reader if publication else source_reader - source = reader(now) + source = cast(Callable[[int], SourceState | None], reader)(now) now = clock() known = _source_config_known(source, now) if known and (not source.enabled or source.manual): diff --git a/openpilot/starpilot/flm/tests/test_analyze.py b/openpilot/starpilot/flm/tests/test_analyze.py deleted file mode 100644 index 9b70e5277c..0000000000 --- a/openpilot/starpilot/flm/tests/test_analyze.py +++ /dev/null @@ -1,55 +0,0 @@ -"""The desktop report joins real local-file admission, decompression and events.""" - -import json - -import pytest -import zstandard as zstd - -from openpilot.cereal import messaging -from openpilot.starpilot.flm import analyze -from openpilot.starpilot.flm.local_logs import LocalLogUnavailable - - -SEGMENT = "1234abcd--0123456789--0" - - -def test_desktop_report_for_missing_controller_is_honest_and_source_bound(tmp_path, monkeypatch, capsys): - monkeypatch.setattr(analyze, "PC", True) - folder = tmp_path / SEGMENT - folder.mkdir() - event = messaging.new_message("carState", valid=True) - event.logMonoTime = 100 - (folder / "rlog.zst").write_bytes(zstd.compress(event.to_bytes())) - assert analyze.main(["--log-root", str(tmp_path.resolve()), SEGMENT]) == 0 - report = json.loads(capsys.readouterr().out) - assert report["purpose"] == "offline_tracking_diagnostics" - assert report["vehicleQualification"] is False and report["tuneRecommendation"] is None - segment = report["segments"][0] - assert segment["analysis"]["status"] == "missing_car_params" - assert segment["analysis"]["mean_abs_error"] is None - assert len(segment["source"]["sha256"]) == 64 - assert segment["source"]["segmentName"] == SEGMENT - output = tmp_path / "report.json" - args = ["--log-root", str(tmp_path.resolve()), "--output", str(output), SEGMENT] - assert analyze.main(args) == 0 - assert json.loads(output.read_text()) == report - original = output.read_bytes() - assert analyze.main(args) == 1 - assert output.read_bytes() == original - - -def test_device_duplicate_and_failed_second_segment_never_emit_partial_report(tmp_path, monkeypatch, capsys): - monkeypatch.setattr(analyze, "PC", False) - with pytest.raises(ValueError, match="desktop"): - analyze.analyze_local(tmp_path.resolve(), [SEGMENT]) - monkeypatch.setattr(analyze, "PC", True) - with pytest.raises(ValueError, match="distinct"): - analyze.analyze_local(tmp_path.resolve(), [SEGMENT, SEGMENT]) - folder = tmp_path / SEGMENT - folder.mkdir() - event = messaging.new_message("carState", valid=True) - (folder / "rlog.zst").write_bytes(zstd.compress(event.to_bytes())) - assert analyze.main(["--log-root", str(tmp_path.resolve()), SEGMENT, "1234abcd--0123456789--1"]) == 1 - assert capsys.readouterr().out == "" - with pytest.raises(LocalLogUnavailable): - analyze.analyze_local(tmp_path.resolve(), [SEGMENT], cancelled=lambda: True) diff --git a/openpilot/starpilot/flm/tests/test_canonical_cost.py b/openpilot/starpilot/flm/tests/test_canonical_cost.py index a63b77031d..413f979722 100644 --- a/openpilot/starpilot/flm/tests/test_canonical_cost.py +++ b/openpilot/starpilot/flm/tests/test_canonical_cost.py @@ -1,5 +1,8 @@ """Immutable bounded caches preserve original digest and reject mutated maps.""" +from collections.abc import MutableMapping +from typing import cast + import hashlib from unittest.mock import patch import pytest @@ -75,7 +78,7 @@ def test_immutable_parameter_tuple_cache_and_fixed_bounds(): with pytest.raises((AttributeError, TypeError)): first[0].high = 0.9 with pytest.raises(TypeError): - a._COORDINATE_BOUNDS['center_taper_max'] = (0.0, 0.9) + cast(MutableMapping[str, tuple[float, float]], a._COORDINATE_BOUNDS)['center_taper_max'] = (0.0, 0.9) assert a.coordinate_bounds('center_taper_max') == (0.0, 0.18) diff --git a/openpilot/starpilot/flm/tests/test_event_admission_clock.py b/openpilot/starpilot/flm/tests/test_event_admission_clock.py index 0f3dccb470..7f9f91304e 100644 --- a/openpilot/starpilot/flm/tests/test_event_admission_clock.py +++ b/openpilot/starpilot/flm/tests/test_event_admission_clock.py @@ -46,6 +46,9 @@ class ClockedBoundaryAdapter(BoundaryAdapter, EventCollectionAdapter): raw_collection = False decisions = None + def physical_observable(self, row=None, *, value=None): + return super().physical_observable(row if value is None else value) + def current_admission(self, profile, control, output, context, *, now_ns=None): self.decisions = now_ns return BoundaryAdapter.current_admission(self, profile, control, output, context) diff --git a/openpilot/starpilot/flm/tests/test_optional_learning_failures.py b/openpilot/starpilot/flm/tests/test_optional_learning_failures.py index e100d9bb73..8ceec57bcf 100644 --- a/openpilot/starpilot/flm/tests/test_optional_learning_failures.py +++ b/openpilot/starpilot/flm/tests/test_optional_learning_failures.py @@ -1,5 +1,6 @@ """Root-only actual Card/Controls boundaries; no optional failure masks core errors.""" from types import SimpleNamespace +from typing import cast from unittest import mock import pytest @@ -49,17 +50,17 @@ def card_fixture(): sm.update_msgs(NOW / 1e9, [control.as_reader(), pandas.as_reader()]) card = Car.__new__(Car) card.CP, card.CI, card.sm = cp, ci, sm - card.pm = Sink() + cast(SimpleNamespace, card).pm = Sink() card.vehicle_startup = VehicleStartupOwner() card.ci_initialized, card.ioniq6_long_prearmed = True, False card.ioniq6_keepalive = None card.aol_replay = card.slc_replay = card.curve_replay = card.conditional_replay = False card.car_params_published = False card.can_rcv_cum_timeout_counter = 0 - card.rk = SimpleNamespace(remaining=0.0) + cast(SimpleNamespace, card).rk = SimpleNamespace(remaining=0.0) card.can_log_mono_time = NOW card.last_actuators_output = structs.CarControl.Actuators() - card.v_cruise_helper = SimpleNamespace(slc_cruise_change=None) + cast(SimpleNamespace, card).v_cruise_helper = SimpleNamespace(slc_cruise_change=None) card.car_gps_publisher = CarGpsPublisher() card.learning_stamp_observer = card.record_learning_send_stamp card.learning_car_state_source_ns = NOW - 1_000_000 diff --git a/openpilot/starpilot/flm/tests/test_production_event_contract.py b/openpilot/starpilot/flm/tests/test_production_event_contract.py index 06d595b352..b968a5c915 100644 --- a/openpilot/starpilot/flm/tests/test_production_event_contract.py +++ b/openpilot/starpilot/flm/tests/test_production_event_contract.py @@ -44,7 +44,7 @@ def capture(controls): startup_rejections += 1 continue return evidence, source, startup_rejections - pytest.fail('actual source-bound controller checkpoint was not captured') + raise pytest.fail.Exception('actual source-bound controller checkpoint was not captured') @pytest.mark.parametrize('platform,mode,domain', [ diff --git a/openpilot/starpilot/flm/tests/test_snapshot_readiness.py b/openpilot/starpilot/flm/tests/test_snapshot_readiness.py index ff0e43f905..e6b470705b 100644 --- a/openpilot/starpilot/flm/tests/test_snapshot_readiness.py +++ b/openpilot/starpilot/flm/tests/test_snapshot_readiness.py @@ -1,6 +1,7 @@ """Actual parent selection/collector/reporter; synthetic goals confer no fit proof.""" from collections import deque from types import SimpleNamespace +from typing import cast from unittest.mock import patch import pytest @@ -21,16 +22,16 @@ def actual_selection_collector(count, *, checkpoint=True): desired_physical_accel=goal) for tick, goal in enumerate(goals)) adapter = EventCollectionAdapter.__new__(EventCollectionAdapter) adapter.envelope = object() - adapter.event_objective = SimpleNamespace(context_key='context', required_cohorts=('whole',)) - adapter.objective = SimpleNamespace(required_cohorts=()) + cast(SimpleNamespace, adapter).event_objective = SimpleNamespace(context_key='context', required_cohorts=('whole',)) + cast(SimpleNamespace, adapter).objective = SimpleNamespace(required_cohorts=()) adapter.key = ('event', 'center') adapter.snapshot_readiness = None adapter.model = None - adapter.replay = SimpleNamespace(records=frames, replay_windows=(), _frame_context=lambda _frame: True, + cast(SimpleNamespace, adapter).replay = SimpleNamespace(records=frames, replay_windows=(), _frame_context=lambda _frame: True, checkpoints=((NOW, None),) if checkpoint else (), identity=control.identity, baseline_revision='baseline') collector = EventFullrateCollector(adapter) - collector.context = ('source',) - collector.physical_context = ('physical',) + cast(SimpleNamespace, collector).context = ('source',) + cast(SimpleNamespace, collector).physical_context = ('physical',) collector.ready.append((control, output)) points = tuple(RawPoint(frame.control_ns, frame.control_ns + 1, frame.control_ns + 2, control, output, {}, ('physical',)) for frame in frames[:-60]) diff --git a/openpilot/starpilot/flm/tests/test_worker_limits.py b/openpilot/starpilot/flm/tests/test_worker_limits.py deleted file mode 100644 index 8443d725ac..0000000000 --- a/openpilot/starpilot/flm/tests/test_worker_limits.py +++ /dev/null @@ -1,21 +0,0 @@ -from unittest.mock import patch - -from openpilot.starpilot.flm import operation_worker as worker - - -def test_unlimited_linux_limits_get_finite_ceiling(): - with patch.object(worker.sys, "platform", "linux"), patch.object(worker.os, "nice"), \ - patch.object(worker.resource, "getrlimit", return_value=(-1, -1)), \ - patch.object(worker.resource, "setrlimit") as set_limit: - worker._limits() - assert set_limit.call_args_list[0].args == (worker.resource.RLIMIT_AS, (worker.LINUX_ADDRESS_SPACE_BYTES, -1)) - assert set_limit.call_args_list[1].args == (worker.resource.RLIMIT_CPU, (worker.LINUX_CPU_SECONDS, -1)) - - -def test_existing_tighter_linux_limits_are_preserved(): - with patch.object(worker.sys, "platform", "linux"), patch.object(worker.os, "nice"), \ - patch.object(worker.resource, "getrlimit", side_effect=[(1000000, 2000000), (-1, 60)]), \ - patch.object(worker.resource, "setrlimit") as set_limit: - worker._limits() - assert set_limit.call_args_list[0].args == (worker.resource.RLIMIT_AS, (1000000, 2000000)) - assert set_limit.call_args_list[1].args == (worker.resource.RLIMIT_CPU, (60, 60)) diff --git a/openpilot/starpilot/flm/torque_emission_replay.py b/openpilot/starpilot/flm/torque_emission_replay.py index 99d5bfd241..a6daf1d8e9 100644 --- a/openpilot/starpilot/flm/torque_emission_replay.py +++ b/openpilot/starpilot/flm/torque_emission_replay.py @@ -4,6 +4,8 @@ Parent record() copies bounded typed metadata only. CAN decoding, encoder/filter steps and finite differences belong to the child or existing bounded4Hz probe. """ +from typing import cast + from collections import OrderedDict from dataclasses import dataclass, asdict from types import SimpleNamespace @@ -247,7 +249,7 @@ class EncoderBranch: if witness.shape: from opendbc.car.honda.modified_civic_steering import get_civic_bosch_modified_steering_pressed - lpf, previous, filtered, was_pressed, speed, pressed, driver = shape + lpf, previous, filtered, was_pressed, speed, pressed, driver = cast(tuple[float, float, float, bool, float, bool, float], shape) filtered, pressed = get_civic_bosch_modified_steering_pressed(pressed, driver, float32(request), filtered, was_pressed) if pressed: raise ValueError('candidate driver override') diff --git a/openpilot/starpilot/flm/training_report.py b/openpilot/starpilot/flm/training_report.py index be7e503923..0507414cc2 100644 --- a/openpilot/starpilot/flm/training_report.py +++ b/openpilot/starpilot/flm/training_report.py @@ -1,4 +1,6 @@ """Bounded read-only RAM telemetry; never restores a learned profile.""" + +from typing import cast import json import math import os @@ -229,8 +231,8 @@ class TrainingReporter: } if collector is not None and getattr(collector.adapter, 'event_collection', False): readiness_reader = getattr(collector, 'snapshot_readiness_report', None) - readiness = readiness_reader() if callable(readiness_reader) else {'reason': 'snapshot_not_attempted'} - stamp = readiness.pop('observedMonoTime', None) + readiness: dict[str, object] = readiness_reader() if callable(readiness_reader) else {'reason': 'snapshot_not_attempted'} + stamp = cast(int | None, readiness.pop('observedMonoTime', None)) readiness['ageSeconds'] = None if stamp is None else max(0., (now_ns - stamp) / 1e9) details.update({'Snapshot readiness': readiness, 'Snapshot selection attempts (collector lifetime)': getattr(collector, 'snapshot_attempts', 0), diff --git a/openpilot/starpilot/galaxy/tests/test_layout_preview_http.py b/openpilot/starpilot/galaxy/tests/test_layout_preview_http.py index 3814284d73..deff32054f 100644 --- a/openpilot/starpilot/galaxy/tests/test_layout_preview_http.py +++ b/openpilot/starpilot/galaxy/tests/test_layout_preview_http.py @@ -5,6 +5,7 @@ import tempfile import threading import time import unittest +from typing import TypedDict from openpilot.common.params import Params from openpilot.starpilot.galaxy.access import GalaxyAccessOwner @@ -15,6 +16,12 @@ from openpilot.starpilot.ui.onroad_customization import PARAM_KEY, default_docum from openpilot.starpilot.ui.tests.test_layout_preview_transport import png +class PreviewPayload(TypedDict): + document: dict + profile: str + scene: str + + class TestLayoutPreviewHttp(unittest.TestCase): def setUp(self): temp = tempfile.TemporaryDirectory() @@ -34,7 +41,7 @@ class TestLayoutPreviewHttp(unittest.TestCase): self.worker = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': .01}, daemon=True) self.worker.start() self.addCleanup(self.stop) - self.payload = {'document': default_document(), 'profile': 'large', 'scene': 'aol'} + self.payload: PreviewPayload = {'document': default_document(), 'profile': 'large', 'scene': 'aol'} def stop(self): self.server.shutdown() diff --git a/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs b/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs index 28f2b7d347..5e563d2fe7 100644 --- a/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs +++ b/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import * as documentHelpers from "../web/js/onroad-layout-document.js" import * as layoutModule from "../web/js/onroad-layout.js" +import { ONROAD_LAYOUT_TEMPLATE } from "../web/js/onroad-layout-template.js" import { readFileSync } from "node:fs" import { LayoutWidgetPreview, OnroadLayoutFeed, OnroadLayoutPage, validSnapshot, validDocument, clampPlacement, previewPoint, withAlpha, widgetPalette, withDisplay, layoutDocument, previewDocument } from "../web/js/onroad-layout.js" @@ -804,10 +805,13 @@ assert.ok(appSource.includes('@target="go($event ===')) assert.match(appSource, /goFromMenu\(path\)/) assert.match(appSource, /requestLeave\(proceed, presentation\)/) assert.match(appSource, /@click="goFromMenu\(item\.path\)"/) -assert.match(editorSource, / ({ ok: true, json: async () => url.endsWith("catalog.json") ? catalog : { schemaVersion: 1, monitor: "local" }, }) }) diff --git a/openpilot/starpilot/galaxy/tests/test_saved_torque_page.py b/openpilot/starpilot/galaxy/tests/test_saved_torque_page.py index 27c411aeec..0c4c846764 100644 --- a/openpilot/starpilot/galaxy/tests/test_saved_torque_page.py +++ b/openpilot/starpilot/galaxy/tests/test_saved_torque_page.py @@ -49,8 +49,9 @@ class TestSavedTorquePage(unittest.TestCase): learning = rows['Use StarPilot Auto Learn'] self.assertTrue(learning['available']) self.assertEqual(learning['value'], 'Off') - self.assertIn('StarPilot Controller uses its vehicle tune', learning['reason']) - self.assertIn('automatic learning is currently unavailable', learning['reason']) + self.assertTrue(learning['reason'].startswith( + 'StarPilot’s FLM tuning will attempt to automatically correct torque profiles as you drive.')) + self.assertIn('controller eligibility are initialized when driving processes start', learning['reason']) cp = self.context.sample().cp assert cp is not None self.assertFalse(learning_allowed(self.params, cp)) @@ -61,7 +62,7 @@ class TestSavedTorquePage(unittest.TestCase): self.assertTrue(preference.enabled) self.assertEqual(preference.source, 'scope') self.assertTrue(self.params.get_bool('ForceAutoTuneOff')) - self.assertFalse(learning_allowed(self.params, cp), 'Saving On does not admit the selected nonlinear controller') + self.assertTrue(learning_allowed(self.params, cp), 'Scoped On allows native refinement with either selected controller') page = self.page() index = next(i for i, row in enumerate(page['rows']) if row['label'] == 'Friction') diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout-template.js b/openpilot/starpilot/galaxy/web/js/onroad-layout-template.js new file mode 100644 index 0000000000..fb2f4e7e31 --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/onroad-layout-template.js @@ -0,0 +1,392 @@ +export const ONROAD_LAYOUT_TEMPLATE = ` +
+

{{ projection ? 'Android Auto Layout' : 'Colors & Layout' }}

+ +
+ Park the vehicle and reload to edit. If it stays unavailable, reload saved settings. +
+ +
+ +
+
+
+

Choose a widget to move it or change its colors. Edit the layout available on this comma.

Move widgets for the last connected Android Auto screen. The comma layout stays separate. Colors follow the comma theme. Changes apply on the next connection.

+

Last usable screen: {{ state.data.screen.width - state.data.screen.margin_width }} × {{ state.data.screen.height - state.data.screen.margin_height }} pixels.

+
+

{{ state.data.reason }}

+ Connect to local Galaxy to edit this device’s colors and layouts. + + +

Remove {{ profile.widgets[state.removeConfirm]?.label }}?

+

Remove this widget from the {{ projection ? 'Android Auto' : state.profile === 'large' ? 'Big' : 'Small' }} layout? You can add it again from Inactive Widgets.

+
+ + +
+
+ +

Leave without saving your changes?

+

Save your {{ projection ? 'Android Auto' : 'comma' }} layout before leaving, keep editing, or discard this draft.

+ {{ state.error }} +
+ + + +
+
+
` diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout.js b/openpilot/starpilot/galaxy/web/js/onroad-layout.js index 5f912e2818..0238a127a0 100644 --- a/openpilot/starpilot/galaxy/web/js/onroad-layout.js +++ b/openpilot/starpilot/galaxy/web/js/onroad-layout.js @@ -1,3 +1,4 @@ +import { ONROAD_LAYOUT_TEMPLATE } from "./onroad-layout-template.js" import { GxState } from "./state.js" import { guardUnload } from "./unload-guard.js" import { PollTimer, connectionError } from "./polling.js" @@ -1229,396 +1230,5 @@ export const OnroadLayoutPage = { return saved }, }, - template: ` -
-

{{ projection ? 'Android Auto Layout' : 'Colors & Layout' }}

- -
- Park the vehicle and reload to edit. If it stays unavailable, reload saved settings. -
- -
- -
-
-
-

Choose a widget to move it or change its colors. Edit the layout available on this comma.

Move widgets for the last connected Android Auto screen. The comma layout stays separate. Colors follow the comma theme. Changes apply on the next connection.

-

Last usable screen: {{ state.data.screen.width - state.data.screen.margin_width }} × {{ state.data.screen.height - state.data.screen.margin_height }} pixels.

-
-

{{ state.data.reason }}

- Connect to local Galaxy to edit this device’s colors and layouts. - - -

Remove {{ profile.widgets[state.removeConfirm]?.label }}?

-

Remove this widget from the {{ projection ? 'Android Auto' : state.profile === 'large' ? 'Big' : 'Small' }} layout? You can add it again from Inactive Widgets.

-
- - -
-
- -

Leave without saving your changes?

-

Save your {{ projection ? 'Android Auto' : 'comma' }} layout before leaving, keep editing, or discard this draft.

- {{ state.error }} -
- - - -
-
-
`, + template: ONROAD_LAYOUT_TEMPLATE, } diff --git a/openpilot/starpilot/lateral/tests/test_bolt_manual_friction.py b/openpilot/starpilot/lateral/tests/test_bolt_manual_friction.py index 9d75c41699..f5fef96f26 100644 --- a/openpilot/starpilot/lateral/tests/test_bolt_manual_friction.py +++ b/openpilot/starpilot/lateral/tests/test_bolt_manual_friction.py @@ -143,7 +143,8 @@ class TestBoltManualFriction(unittest.TestCase): controls = Controls() self.assertEqual(controls.LaC.controller_mode, mode) self.assertEqual(controls.torque_host is not None, configured) - self.assertEqual(controls.torque_learning_allowed, mode == ControllerMode.STANDARD) + # GM retains no native scalar estimator in either selected mode. + self.assertFalse(controls.torque_learning_allowed) def test_host_friction_ff_fixed_factor_offset_and_live_precedence(self): for identity in BOLT_GENERATIONS: @@ -165,7 +166,9 @@ class TestBoltManualFriction(unittest.TestCase): learned.logMonoTime['lateralTorqueParameters'] = stamp host.apply(controls.LaC, host.sample(learned, now_ns=stamp, lat_active=True)) self.assertAlmostEqual(controls.LaC.torque_params.friction, friction, places=6) - self.assertAlmostEqual(controls.LaC.torque_params.latAccelOffset, .02 if mode == ControllerMode.STANDARD else basis[1], places=6) + self.assertAlmostEqual(controls.LaC.torque_params.latAccelOffset, basis[1], places=6) + self.assertFalse(host.allow_learning) + self.assertEqual(host.selected.source.value, 'user') if mode == ControllerMode.STARPILOT: self.assertEqual(controls.LaC.torque_from_lateral_accel(1., controls.LaC.torque_params), controls.LaC.torque_from_lateral_accel(1., cp.lateralTuning.torque)) @@ -180,7 +183,7 @@ class TestBoltManualFriction(unittest.TestCase): learned.logMonoTime['lateralTorqueParameters'] = stamp host.apply(controls.LaC, host.sample(learned, now_ns=stamp, lat_active=True)) self.assertAlmostEqual(controls.LaC.torque_params.friction, basis[2], places=6) - self.assertEqual(host.selected.source.value, 'learned' if mode == ControllerMode.STANDARD else 'vehicle') + self.assertEqual(host.selected.source.value, 'vehicle') def test_unscoped_legacy_and_custom_factor_need_review(self): with OpenpilotPrefix(): diff --git a/openpilot/starpilot/lateral/tests/test_native_inferred_torque.py b/openpilot/starpilot/lateral/tests/test_native_inferred_torque.py index 37657aa5ec..215b53a8d5 100644 --- a/openpilot/starpilot/lateral/tests/test_native_inferred_torque.py +++ b/openpilot/starpilot/lateral/tests/test_native_inferred_torque.py @@ -90,7 +90,8 @@ class TestNativeInferredTorque(unittest.TestCase): selected = read_selection(params, cp) baseline = LatControlTorque(cp.as_reader(), ci, DT_CTRL, controller_mode=selected.mode) expected_learning = learning_allowed(params, cp, selection=selected) - self.assertEqual(expected_learning, mode == ControllerMode.STANDARD) + # Native refinement is available with either selected controller. + self.assertTrue(expected_learning) params.put('SteerLatAccel', float(cp.lateralTuning.torque.latAccelFactor), block=True) params.put('SteerFriction', float(cp.lateralTuning.torque.friction), block=True) self.assertFalse(runtime_enabled(cp, params)) diff --git a/openpilot/starpilot/lateral/tests/test_torque_learning.py b/openpilot/starpilot/lateral/tests/test_torque_learning.py index 0b40da6ecf..721c0f75fa 100644 --- a/openpilot/starpilot/lateral/tests/test_torque_learning.py +++ b/openpilot/starpilot/lateral/tests/test_torque_learning.py @@ -1,4 +1,4 @@ -"""Controller-specific learning is enforced by its producer and both consumers.""" +"""Native refinement respects saved OFF, manual precedence and startup latching.""" import os from pathlib import Path @@ -56,7 +56,8 @@ class TestTorqueLearning(unittest.TestCase): cp = cp_for(vehicle) params.remove(DOCUMENT_KEY) params.remove(LEARNING_OFF_KEY) - self.assertFalse(learning_allowed(params, cp)) + # Both selected modes support native refinement without manual edits. + self.assertTrue(learning_allowed(params, cp)) select(params, cp, ControllerMode.STANDARD) self.assertTrue(learning_allowed(params, cp)) for saved in (b'1', b'invalid', b''): @@ -69,7 +70,7 @@ class TestTorqueLearning(unittest.TestCase): self.assertFalse(learning_allowed(params, cp_for(vehicle))) self.assertFalse(learning_allowed(params, cp_for(TOYOTA.TOYOTA_RAV4_TSS2))) - def test_scoped_learning_choice_preserves_legacy_and_controller_compatibility(self): + def test_scoped_learning_choice_preserves_legacy_and_both_controller_modes(self): with OpenpilotPrefix(): params, cp = Params(), cp_for(HYUNDAI.HYUNDAI_IONIQ_6) params.put_bool(LEARNING_OFF_KEY, True, block=True) @@ -77,7 +78,7 @@ class TestTorqueLearning(unittest.TestCase): params.put(LEARNING_KEY, replace_preference(None, cp, True), block=True) self.assertTrue(read_preference(params, cp).enabled) self.assertEqual(read_preference(params, cp).source, 'scope') - self.assertFalse(learning_allowed(params, cp)) # Vehicle tune remains incompatible. + self.assertTrue(learning_allowed(params, cp)) # Scoped ON supports the vehicle controller too. select(params, cp, ControllerMode.STANDARD) self.assertTrue(learning_allowed(params, cp)) other = cp_for(TOYOTA.TOYOTA_COROLLA_TSS2) @@ -108,6 +109,9 @@ class TestTorqueLearning(unittest.TestCase): params.put('CarParams', cp.to_bytes(), block=True) cache = Path(params.get_param_path('LiveTorqueParameters')) cache.write_bytes(b'preserved previous learning') + # Disabled is an explicit saved user choice, independent of controller mode. + params.put_bool(LEARNING_OFF_KEY, True, block=True) + self.assertFalse(learning_allowed(params, cp)) with mock.patch.object(torqued, 'get_cache') as restore: estimator = torqued.TorqueEstimator(cp, allow_learning=learning_allowed(params, cp)) restore.assert_not_called() @@ -170,7 +174,7 @@ class TestTorqueLearning(unittest.TestCase): fit.assert_called_once() self.assertTrue(message.valid) - def test_direct_controller_consumer_rejects_learned_messages_and_latches_policy(self): + def test_direct_controller_accepts_native_refinement_in_both_modes_and_latches_policy(self): now = 1_000_000_000 def feed_learning(controls): @@ -196,11 +200,12 @@ class TestTorqueLearning(unittest.TestCase): feed_learning(controls) with mock.patch.object(controls.LaC, 'update_torque_parameters') as update: controls.state_control() - self.assertEqual(update.call_count, 0 if vehicle in VEHICLES else 1) + update.assert_called_once() if vehicle in VEHICLES: select(params, cp, ControllerMode.STANDARD) controls.state_control() - update.assert_not_called() + self.assertEqual(update.call_count, 2) + self.assertTrue(controls.torque_learning_allowed) self.assertEqual(controls.LaC.controller_mode, ControllerMode.STARPILOT) next_drive = Controls() self.assertTrue(next_drive.torque_learning_allowed) @@ -261,7 +266,7 @@ class TestTorqueLearning(unittest.TestCase): self.assertEqual(host._select(learned, now).source, TorqueSource.USER) self.assertFalse(TorqueHost(params, cp, allow_learning=learning_allowed(params, cp)).allow_learning) - def test_running_manual_host_latches_both_learning_directions_and_keeps_manual_edits(self): + def test_running_manual_host_keeps_native_disabled_after_manual_reset_and_keeps_edits(self): with OpenpilotPrefix(), mock.patch.dict(os.environ, ENV), \ mock.patch('openpilot.selfdrive.controls.controlsd.messaging.PubMaster'): params, cp = Params(), cp_for(HYUNDAI.HYUNDAI_IONIQ_6) @@ -270,12 +275,13 @@ class TestTorqueLearning(unittest.TestCase): select(params, cp, ControllerMode.STANDARD) params.put('SteerFriction', cp.lateralTuning.torque.friction * 1.2, block=True) controls = Controls() + self.assertFalse(controls.torque_learning_allowed) params.remove('SteerFriction') host, base = controls.torque_host, controls.torque_host.vehicle now = 1_000_000_000 learned = LearnedFrame(now, factor=base.lat_accel_factor * 1.2, offset=0.03, friction=base.friction * 1.1) host.sample(learned, now_ns=now, lat_active=True) - self.assertEqual(host.selected.source, TorqueSource.LEARNED) + self.assertEqual(host.selected.source, TorqueSource.VEHICLE) select(params, cp, ControllerMode.STARPILOT) params.put_bool(LEARNING_OFF_KEY, True, block=True) for tick in range(1, 102): @@ -283,14 +289,14 @@ class TestTorqueLearning(unittest.TestCase): learned.logMonoTime['lateralTorqueParameters'] = stamp host.sample(learned, now_ns=stamp, lat_active=True) self.assertEqual(controls.LaC.controller_mode, ControllerMode.STANDARD) - self.assertEqual(host.selected.source, TorqueSource.LEARNED) + self.assertEqual(host.selected.source, TorqueSource.VEHICLE) params.put('SteerLatAccel', base.lat_accel_factor * 1.1, block=True) assert host is not None host.last_refresh_ns = None host.sample(learned, now_ns=stamp + 10_000_000, lat_active=True) self.assertEqual(host.selected.source, TorqueSource.USER) self.assertAlmostEqual(host.selected.lat_accel_factor, base.lat_accel_factor * 1.1) - self.assertEqual(host.selected.friction, learned.state.frictionCoefficientFiltered) + self.assertEqual(host.selected.friction, base.friction) next_drive = Controls() disabled = next_drive.torque_host diff --git a/openpilot/starpilot/lateral/tests/test_volt_torque_policy.py b/openpilot/starpilot/lateral/tests/test_volt_torque_policy.py index 48a38d81db..99e3ff0742 100644 --- a/openpilot/starpilot/lateral/tests/test_volt_torque_policy.py +++ b/openpilot/starpilot/lateral/tests/test_volt_torque_policy.py @@ -114,7 +114,8 @@ class TestVoltTorquePolicy(unittest.TestCase): controls = Controls() self.assertEqual(controls.LaC.controller_mode, mode) self.assertEqual(selected_policy(controls.LaC) is not None, mode == ControllerMode.STARPILOT) - self.assertEqual(controls.torque_learning_allowed, mode == ControllerMode.STANDARD) + # Native scalar estimation remains outside the GM brand scope. + self.assertFalse(controls.torque_learning_allowed) real_update = controls.VM.update_params for speed, live_ratio in ((0., 15.7), (20., 15.7), (20., 15.7), (35., 16.2)): self.enterContext(patch.object(controls, 'sm', {'carState': SimpleNamespace(vEgo=speed), diff --git a/openpilot/starpilot/replay/owner.py b/openpilot/starpilot/replay/owner.py index e160c95540..85bf33d2f5 100644 --- a/openpilot/starpilot/replay/owner.py +++ b/openpilot/starpilot/replay/owner.py @@ -194,7 +194,7 @@ class ReplayOwner: result['export'] = {key: value for key, value in self.export.items() if key != 'expires'} result['export']['owned'] = client == self.export_client if 'expires' in self.export: - result['export']['expiresInSeconds'] = max(0., self.export['expires'] - self.clock()) + result['export']['expiresInSeconds'] = max(0., cast(float, self.export['expires']) - self.clock()) if not owned: for key in ('framePath', 'media', 'codec'): result.pop(key, None) @@ -202,9 +202,9 @@ class ReplayOwner: def _expire_export(self, *, replace=False): if (self.export is None or self.worker is not None and self.status.get('purpose') == 'export' or - not replace and self.clock() < self.export.get('expires', float('inf'))): + not replace and self.clock() < cast(float, self.export.get('expires', float('inf')))): return True - directory = self.root / 'exports' / self.export['id'] + directory = self.root / 'exports' / cast(str, self.export['id']) path = directory / 'replay.mp4' try: fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) diff --git a/openpilot/starpilot/replay/tests/test_export.py b/openpilot/starpilot/replay/tests/test_export.py index 6d392184e2..08438518a4 100644 --- a/openpilot/starpilot/replay/tests/test_export.py +++ b/openpilot/starpilot/replay/tests/test_export.py @@ -1,5 +1,6 @@ """Exports keep recorded coverage, remain private, and become ready only after cleanup.""" from types import SimpleNamespace +from typing import NoReturn import fcntl import json import os @@ -95,16 +96,21 @@ def test_partial_video_and_storage_shortage_cannot_be_finalized(tmp_path, packet assert not (tmp_path / 'export.mp4').exists() +def _reject_engine_resume(_command) -> NoReturn: + __tracebackhide__ = True + raise pytest.fail.Exception('an ended engine must not resume') + + def test_export_finishes_at_actual_engine_eof(tmp_path, packet): from openpilot.tools.replay.tests.test_machine import BINARY, Machine if not BINARY.is_file(): - pytest.skip('build the replay test binary first') + raise pytest.skip.Exception('build the replay test binary first') with Machine([(0, [.1, .2, .3], True)]) as engine: initial = engine.wait(lambda status: status['state'] == 'paused') engine.command_status('pause', 'a4', paused=False) ended = engine.wait(lambda status: status['state'] == 'ended') assert ended['position'] < ended['end'] - stopped = SimpleNamespace(command=lambda command: pytest.fail('an ended engine must not resume')) + stopped = SimpleNamespace(command=_reject_engine_resume) writer = ExportVideo(tmp_path, 32, 18, [(initial['start'], initial['end'])], 1024 ** 2) try: for position in (.1, .2, .3): @@ -119,7 +125,7 @@ def test_export_finishes_at_actual_engine_eof(tmp_path, packet): def test_early_engine_eof_cannot_hide_missing_export_coverage(tmp_path, packet): writer = ExportVideo(tmp_path, 32, 18, [(0, 2)], 1024 ** 2) - engine = SimpleNamespace(command=lambda command: pytest.fail('an ended engine must not resume')) + engine = SimpleNamespace(command=_reject_engine_resume) try: writer.frame(packet, position=0, keyframe=True) with pytest.raises(ValueError, match='end of the selected coverage'): diff --git a/pyproject.toml b/pyproject.toml index 6036c6c8ab..1a8b6ac4d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,10 +120,10 @@ allow-direct-references = true [tool.codespell] quiet-level = 3 # if you've got a short variable name that's getting flagged, add it here -ignore-words-list = "bu,ro,te,ue,alo,hda,ois,nam,nams,ned,som,parm,setts,inout,warmup,bumb,nd,sie,preints,whit,indexIn,ws,uint,grey,deque,stdio,amin,BA,LITE,atEnd,UIs,errorString,arange,FocusIn,od,tim,relA,hist,copyable,jupyter,thead,TGE,abl,lite,ser,assertIn,HomeState,cancelled,CAS,acknowledgement,writeable,noice,FoF,waitFor" +ignore-words-list = "bu,ro,te,ue,alo,hda,ois,nam,nams,ned,som,parm,setts,inout,warmup,bumb,nd,sie,preints,whit,indexIn,ws,uint,grey,deque,stdio,amin,BA,LITE,atEnd,UIs,errorString,arange,FocusIn,od,tim,relA,hist,copyable,jupyter,thead,TGE,abl,lite,ser,assertIn,HomeState,cancelled,CAS,acknowledgement,writeable,noice,FoF,waitFor,centre,centres" builtin = "clear,rare,informal,code,names,en-GB_to_en-US" skip = "openpilot/cereal/gen/cpp/*.capnp.c++, openpilot/cereal/gen/cpp/*.capnp.h, *.po, uv.lock, *.onnx, *.pem, */c_generated_code/*, docs/assets/*, openpilot/tools/plotjuggler/layouts/*, openpilot/selfdrive/assets/offroad/mici_fcc.html, openpilot/starpilot/galaxy/web/vendor/vue/vue.esm-browser.js, openpilot/starpilot/ui/assets/fonts/notices/Unifont-COPYING.txt" -ignore-regex = "scale\\.numer" +ignore-regex = "scale\\.numer" # codespell:ignore numer # https://docs.astral.sh/ruff/configuration/#using-pyprojecttoml [tool.ruff] diff --git a/tools/ci/process_replay_coverage.py b/tools/ci/process_replay_coverage.py index cb87db30f7..aac9957a14 100644 --- a/tools/ci/process_replay_coverage.py +++ b/tools/ci/process_replay_coverage.py @@ -8,10 +8,16 @@ REQUIRED_REPLAY_LABELS = frozenset({ "HYUNDAI", "HYUNDAI2", "TOYOTA", "TOYOTA3", "HONDA", "HONDA2", "CHRYSLER", "RAM", "SUBARU", "GM", "NISSAN", "VOLKSWAGEN", "MAZDA", "FORD", "RIVIAN", "TESLA", }) +VOLVO_UNRECORDED_PLATFORMS = frozenset({ + "VOLVO_V40", "VOLVO_XC40_RECHARGE", "VOLVO_S60_RECHARGE", "POLESTAR_2", +}) VOLVO_REQUIRED_TESTS = { - "interfaces": "opendbc_repo/opendbc/car/volvo/tests/test_c1.py", - "safety-debug": "opendbc_repo/opendbc/safety/tests/test_volvo_c1.py", - "safety-release": "opendbc_repo/opendbc/safety/tests/test_volvo_c1.py", + "interfaces": ("opendbc_repo/opendbc/car/volvo/tests/test_c1.py", + "opendbc_repo/opendbc/car/volvo/tests/test_cma.py"), + "safety-debug": ("opendbc_repo/opendbc/safety/tests/test_volvo_c1.py", + "opendbc_repo/opendbc/safety/tests/test_volvo_cma.py"), + "safety-release": ("opendbc_repo/opendbc/safety/tests/test_volvo_c1.py", + "opendbc_repo/opendbc/safety/tests/test_volvo_cma.py"), } @@ -25,20 +31,23 @@ def replay_coverage(active_platforms, labels, suite_targets, *, full_test): absent_labels = REQUIRED_REPLAY_LABELS - labels if absent_labels: errors.append(f"Required replay inputs missing: {sorted(absent_labels)}") - if set(active_platforms.get("volvo", ())) != {"VOLVO_V40"}: - errors.append("Volvo recording classification requires exactly the active VOLVO_V40 platform; review changed scope") + if set(active_platforms.get("volvo", ())) != VOLVO_UNRECORDED_PLATFORMS: + errors.append("Volvo recording classification requires exactly the four known active platforms; review changed scope") if "volvo" not in missing: errors.append("Volvo recording classification is stale: Volvo now has a recording or is no longer active") - for suite, path in VOLVO_REQUIRED_TESTS.items(): - if path not in suite_targets.get(suite, ()): - errors.append(f"Required Volvo source/native suite no longer selected: {suite}: {path}") + for suite, paths in VOLVO_REQUIRED_TESTS.items(): + for path in paths: + if path not in suite_targets.get(suite, ()): + errors.append(f"Required Volvo source/native suite no longer selected: {suite}: {path}") unaccounted = missing - {"volvo"} if unaccounted: errors.append(f"Active brands missing unaccounted replay inputs: {sorted(unaccounted)}") if "volvo" in missing: - gaps.append({"brand": "volvo", "platforms": ["VOLVO_V40"], "status": "unrecorded", - "reason": "No Volvo C1/V40 recording is available in the fixture catalog.", - "required_source_native_suites": VOLVO_REQUIRED_TESTS}) + for platform in sorted(set(active_platforms.get("volvo", ())) & VOLVO_UNRECORDED_PLATFORMS): + path_index = 0 if platform == "VOLVO_V40" else 1 + gaps.append({"brand": "volvo", "platforms": [platform], "status": "unrecorded", + "reason": f"No {platform} recording is available in the fixture catalog.", + "required_source_native_suites": {suite: paths[path_index] for suite, paths in VOLVO_REQUIRED_TESTS.items()}}) return {"schema_version": 1, "scope": "process_replay_input_inventory", "full_test": full_test, "recorded_input_labels": sorted(labels), "active_platforms": active_platforms, "unrecorded_gaps": gaps, "errors": errors, @@ -55,7 +64,8 @@ def write_coverage(report, destination, summary=None): output.write(f"Full input inventory: {report['full_test']}; recorded inputs: {len(report['recorded_input_labels'])}.\n\n") for gap in report["unrecorded_gaps"]: output.write(f"- {gap['brand']} / {', '.join(gap['platforms'])}: **unrecorded**. {gap['reason']}\n") - output.write(" Required independent source/native checks: interfaces, safety-debug, safety-release.\n") + for suite, path in gap["required_source_native_suites"].items(): + output.write(f" Required independent source/native check: {suite}: `{path}`.\n") for error in report["errors"]: output.write(f"- Coverage error: {error}\n") output.write(f"\n{report['evidence_limit']}\n") diff --git a/tools/ci/run_vehicle_tests.py b/tools/ci/run_vehicle_tests.py index b56aad7952..dae2a6c535 100644 --- a/tools/ci/run_vehicle_tests.py +++ b/tools/ci/run_vehicle_tests.py @@ -74,6 +74,7 @@ RELEASE_TARGETS = ( "opendbc_repo/opendbc/safety/tests/test_tesla.py::TestTeslaStockSafety", "opendbc_repo/opendbc/safety/tests/test_tesla_hw1.py", "opendbc_repo/opendbc/safety/tests/test_volvo_c1.py", + "opendbc_repo/opendbc/safety/tests/test_volvo_cma.py", "opendbc_repo/opendbc/safety/tests/test_mazda.py::TestMazdaSafety", "opendbc_repo/opendbc/safety/tests/test_toyota.py::TestToyotaStockLongitudinalTorque", "opendbc_repo/opendbc/safety/tests/test_toyota_auto_hold.py", diff --git a/tools/ci/tests/test_process_replay_coverage.py b/tools/ci/tests/test_process_replay_coverage.py index 603d548889..504f6eb7cb 100644 --- a/tools/ci/tests/test_process_replay_coverage.py +++ b/tools/ci/tests/test_process_replay_coverage.py @@ -3,14 +3,14 @@ import tempfile import unittest from pathlib import Path -from tools.ci.process_replay_coverage import REQUIRED_REPLAY_LABELS, VOLVO_REQUIRED_TESTS, replay_coverage, write_coverage +from tools.ci.process_replay_coverage import REQUIRED_REPLAY_LABELS, VOLVO_REQUIRED_TESTS, VOLVO_UNRECORDED_PLATFORMS, replay_coverage, write_coverage from tools.ci.run_vehicle_tests import ROOT, targets_for class TestProcessReplayCoverage(unittest.TestCase): def report(self, platforms=None, labels=None, targets=None): active = {label.lower(): [label] for label in REQUIRED_REPLAY_LABELS} - active["volvo"] = ["VOLVO_V40"] + active["volvo"] = sorted(VOLVO_UNRECORDED_PLATFORMS) return replay_coverage(active if platforms is None else platforms, REQUIRED_REPLAY_LABELS if labels is None else labels, {suite: targets_for(suite) for suite in VOLVO_REQUIRED_TESTS} if targets is None else targets, @@ -24,24 +24,60 @@ class TestProcessReplayCoverage(unittest.TestCase): self.assertTrue(self.report(labels=REQUIRED_REPLAY_LABELS - {label})["errors"]) def test_actual_vehicle_suite_targets_include_required_source_and_both_native_modes(self): - for suite, path in VOLVO_REQUIRED_TESTS.items(): - self.assertIn(path, targets_for(suite)) - module = ast.parse((ROOT / VOLVO_REQUIRED_TESTS["interfaces"]).read_text()) + for suite, paths in VOLVO_REQUIRED_TESTS.items(): + for path in paths: + with self.subTest(suite=suite, path=path): + self.assertIn(path, targets_for(suite)) + self.assertTrue((ROOT / path).is_file()) + module = ast.parse((ROOT / VOLVO_REQUIRED_TESTS["interfaces"][0]).read_text()) self.assertTrue(any(isinstance(node, ast.ClassDef) and node.name == "TestVolvoC1Host" for node in module.body)) def test_unknown_brand_changed_platform_and_obsolete_exception_fail(self): active = self.report()["active_platforms"] for change in ({**active, "new_brand": ["NEW_CAR"]}, - {**active, "volvo": ["VOLVO_V40", "VOLVO_NEW"]}, + {**active, "volvo": [*active["volvo"], "VOLVO_NEW"]}, {brand: ids for brand, ids in active.items() if brand != "volvo"}): self.assertTrue(self.report(platforms=change)["errors"]) self.assertTrue(self.report(labels=REQUIRED_REPLAY_LABELS | {"VOLVO"})["errors"]) def test_required_alternative_suite_removal_fails(self): targets = {suite: targets_for(suite) for suite in VOLVO_REQUIRED_TESTS} - for suite, path in VOLVO_REQUIRED_TESTS.items(): - removed = {**targets, suite: [target for target in targets[suite] if target != path]} - self.assertTrue(self.report(targets=removed)["errors"]) + for suite, paths in VOLVO_REQUIRED_TESTS.items(): + for path in paths: + with self.subTest(suite=suite, path=path): + removed = {**targets, suite: [target for target in targets[suite] if target != path]} + self.assertTrue(self.report(targets=removed)["errors"]) + + def test_frozen_platform_inventory_matches_current_registry_source(self): + expected = {"VOLVO_V40", "VOLVO_XC40_RECHARGE", "VOLVO_S60_RECHARGE", "POLESTAR_2"} + self.assertEqual(VOLVO_UNRECORDED_PLATFORMS, expected) + module = ast.parse((ROOT / "opendbc_repo/opendbc/car/volvo/values.py").read_text()) + car = next(node for node in module.body if isinstance(node, ast.ClassDef) and node.name == "CAR") + declared = {target.id for node in car.body if isinstance(node, ast.Assign) + for target in node.targets if isinstance(target, ast.Name)} + self.assertEqual(declared, expected) + + def test_each_platform_gap_names_its_independent_source_and_native_alternatives(self): + report = self.report() + gaps = report["unrecorded_gaps"] + self.assertEqual(len(gaps), 4) + self.assertEqual({gap["platforms"][0] for gap in gaps}, VOLVO_UNRECORDED_PLATFORMS) + for gap in gaps: + with self.subTest(platform=gap["platforms"]): + self.assertEqual(len(gap["platforms"]), 1) + self.assertEqual(gap["status"], "unrecorded") + index = 0 if gap["platforms"] == ["VOLVO_V40"] else 1 + self.assertEqual(gap["required_source_native_suites"], + {suite: paths[index] for suite, paths in VOLVO_REQUIRED_TESTS.items()}) + self.assertIn("independent execution results", report["evidence_limit"]) + self.assertIn("does not qualify the fleet", report["evidence_limit"]) + + def test_missing_known_platform_requires_scope_review(self): + active = self.report()["active_platforms"] + for platform in VOLVO_UNRECORDED_PLATFORMS: + with self.subTest(platform=platform): + changed = {**active, "volvo": [item for item in active["volvo"] if item != platform]} + self.assertTrue(self.report(platforms=changed)["errors"]) def test_artifact_and_summary_expose_gap_without_execution_claim(self): with tempfile.TemporaryDirectory() as temporary: @@ -49,5 +85,10 @@ class TestProcessReplayCoverage(unittest.TestCase): summary = Path(temporary) / "summary.md" write_coverage(self.report(), destination, summary) self.assertIn('"scope": "process_replay_input_inventory"', destination.read_text()) - self.assertIn("**unrecorded**", summary.read_text()) + self.assertEqual(summary.read_text().count("**unrecorded**"), 4) + for platform in VOLVO_UNRECORDED_PLATFORMS: + self.assertIn(platform, summary.read_text()) + for paths in VOLVO_REQUIRED_TESTS.values(): + for path in paths: + self.assertIn(path, summary.read_text()) self.assertIn("does not qualify the fleet", summary.read_text())