From b5ab54ab6d1c03e313a90e15c778b2321d6b7263 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:56:35 -0500 Subject: [PATCH] this is my laboratory --- common/params_keys.h | 4 +- opendbc_repo/opendbc/car/gm/gmcan.py | 40 +- opendbc_repo/opendbc/car/gm/tests/test_gm.py | 54 ++ .../opendbc/car/hyundai/carcontroller.py | 10 +- opendbc_repo/opendbc/car/hyundai/interface.py | 2 +- .../opendbc/car/hyundai/tests/test_hyundai.py | 36 +- .../opendbc/car/subaru/carcontroller.py | 62 +- opendbc_repo/opendbc/car/subaru/carstate.py | 12 +- opendbc_repo/opendbc/car/subaru/interface.py | 4 +- opendbc_repo/opendbc/car/subaru/subarucan.py | 25 - .../opendbc/car/subaru/tests/test_subaru.py | 105 ---- opendbc_repo/opendbc/car/subaru/values.py | 6 - opendbc_repo/opendbc/car/toyota/carstate.py | 6 +- opendbc_repo/opendbc/car/volvo/carstate.py | 3 - .../generator/subaru/subaru_global_2017.dbc | 5 - .../dbc/subaru_global_2017_generated.dbc | 5 - opendbc_repo/opendbc/safety/modes/subaru.h | 37 +- opendbc_repo/opendbc/safety/modes/volvo.h | 5 - .../opendbc/safety/tests/test_subaru.py | 15 - .../opendbc/safety/tests/test_volvo.py | 12 +- scripts/model_lab_chestnut_batch.py | 590 ++++++++++++++++++ scripts/model_source_map_v25.json | 26 +- selfdrive/car/cruise.py | 9 +- selfdrive/car/tests/test_cruise_speed.py | 44 ++ selfdrive/controls/lib/latcontrol_torque.py | 7 +- .../controls/lib/latcontrol_vehicle_tunes.py | 70 ++- selfdrive/controls/tests/test_latcontrol.py | 14 + selfdrive/modeld/modeld.py | 426 +++++++++++-- .../modeld/tests/test_model_laboratory.py | 209 +++++++ .../selfdrived/tests/test_state_machine.py | 10 + .../settings/starpilot/longitudinal.py | 10 +- .../settings/starpilot/system_settings.py | 103 ++- starpilot/assets/model_manager.py | 278 ++++++--- starpilot/assets/tests/test_model_pipeline.py | 104 +++ starpilot/car/ford/lateral.py | 43 +- starpilot/car/ford/tests/test_lateral.py | 76 ++- .../common/assets/device_settings_layout.json | 10 - starpilot/common/model_lab.py | 205 ++++++ starpilot/common/param_profiles.py | 245 ++++++++ starpilot/common/safe_mode.py | 3 +- starpilot/common/starpilot_variables.py | 6 +- starpilot/common/tests/test_model_lab.py | 160 +++++ starpilot/common/tests/test_param_profiles.py | 86 +++ starpilot/starpilot_process.py | 13 +- .../the_galaxy/assets/components/router.js | 2 + .../the_galaxy/assets/components/sidebar.js | 1 + .../components/tools/device_settings.js | 23 +- .../components/tools/model_laboratory.css | 268 ++++++++ .../components/tools/model_laboratory.js | 384 ++++++++++++ .../assets/components/tools/toggles.css | 40 ++ .../assets/components/tools/toggles.js | 82 +++ .../system/the_galaxy/assets/mobile/js/api.js | 8 + .../assets/mobile/js/components/AppShell.js | 3 + .../the_galaxy/assets/mobile/js/params.js | 4 +- .../assets/mobile/js/views/SystemTools.js | 70 ++- .../assets/mobile/js/views/ToolEmbed.js | 1 + .../assets/mobile/js/views/Tools.js | 1 + .../system/the_galaxy/templates/index.html | 1 + .../the_galaxy/tests/test_dashboard_stats.py | 201 ++++++ .../tests/test_device_settings_frontend.py | 8 + .../tests/test_frontend_module_graph.py | 27 + .../the_galaxy/tests/test_ui_vue_frontend.py | 8 +- starpilot/system/the_galaxy/the_galaxy.py | 288 ++++++++- system/webrtc/tests/test_webrtcd.py | 9 +- system/webrtc/webrtcd.py | 13 +- 65 files changed, 4053 insertions(+), 584 deletions(-) create mode 100644 scripts/model_lab_chestnut_batch.py create mode 100644 selfdrive/modeld/tests/test_model_laboratory.py create mode 100644 starpilot/common/model_lab.py create mode 100644 starpilot/common/param_profiles.py create mode 100644 starpilot/common/tests/test_model_lab.py create mode 100644 starpilot/common/tests/test_param_profiles.py create mode 100644 starpilot/system/the_galaxy/assets/components/tools/model_laboratory.css create mode 100644 starpilot/system/the_galaxy/assets/components/tools/model_laboratory.js diff --git a/common/params_keys.h b/common/params_keys.h index f874a3795..c03e5839c 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -510,6 +510,9 @@ inline static std::unordered_map keys = { {"ModeButtonControl", {PERSISTENT, INT, "0", "0", 2, SETTINGS_SIMPLE}}, {"ModelDownloadProgress", {CLEAR_ON_MANAGER_START, STRING, "", ""}}, {"ModelDrivesAndScores", {PERSISTENT, JSON, "{}", "{}"}}, + {"ModelLabConfig", {PERSISTENT, JSON, "{}", "{}"}}, + {"ModelLabModelToDownload", {CLEAR_ON_MANAGER_START, STRING, "", ""}}, + {"ModelLabRuntime", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, JSON, "{}", "{}"}}, {"ModelReleasedDates", {PERSISTENT, STRING, "", "", 1}}, {"ModelRandomizer", {PERSISTENT, BOOL, "0", "0", 2}}, {"LatSmoothSeconds", {PERSISTENT, FLOAT, "0.1", "0.1", 3}}, @@ -705,7 +708,6 @@ inline static std::unordered_map keys = { {"SubaruSNG", {PERSISTENT, BOOL, "1", "0", 2, SETTINGS_SIMPLE}}, {"SubaruSNGManualParkingBrake", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, {"SubaruStopStartOff", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, - {"SubaruAvhOnAtStartup", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, {"TacoTune", {PERSISTENT, BOOL, "0", "0", 2}}, {"TeslaCoopSteering", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, {"TestAlert", {CLEAR_ON_MANAGER_START, STRING, "", ""}}, diff --git a/opendbc_repo/opendbc/car/gm/gmcan.py b/opendbc_repo/opendbc/car/gm/gmcan.py index 3f8a672c9..6a13e83b3 100644 --- a/opendbc_repo/opendbc/car/gm/gmcan.py +++ b/opendbc_repo/opendbc/car/gm/gmcan.py @@ -28,6 +28,9 @@ BOLT_CC_BUTTON_CARS = { BOLT_CC_TARGET_DEADBAND_MPH = 0.75 BOLT_CC_REVERSE_CONFIRM_S = 0.6 BOLT_CC_DIRECTION_MEMORY_S = 1.5 +VOLT_CC_CARS = { + CAR.CHEVROLET_VOLT_CC, +} def malibu_phase_map_for_button(button): @@ -336,6 +339,28 @@ def stabilize_bolt_cc_button(controller, CP, requested_button): return requested_button +def _create_volt_cc_spam_command(CS, actuators, ms_convert): + accel = float(actuators.accel) + speed_setpoint = int(round(CS.out.cruiseState.speed * ms_convert)) + ego_speed = CS.out.vEgo * ms_convert + + if accel == 0.0: + return CruiseButtons.INIT, float("inf") + + if accel < 0.0: + if speed_setpoint > ego_speed + 3.0: + rate = 0.2 + else: + rate = max(1.0 / (-accel * ms_convert), 0.2) + return CruiseButtons.DECEL_SET, rate + + if speed_setpoint < ego_speed - 3.0: + rate = 0.2 + else: + rate = max(1.0 / (accel * ms_convert), 0.2) + return CruiseButtons.RES_ACCEL, rate + + def create_gm_cc_spam_command(packer, controller, CS, actuators, starpilot_toggles): accel = actuators.accel v_ego = CS.out.vEgo @@ -350,12 +375,15 @@ def create_gm_cc_spam_command(packer, controller, CS, actuators, starpilot_toggl target_deadband = BOLT_CC_TARGET_DEADBAND_MPH * (CV.MPH_TO_KPH if is_metric else 1.0) if bolt_cc else 0.0 comparison_setpoint = projected_setpoint if bolt_cc else desired_setpoint - if CS.CP.minEnableSpeed - (desired_setpoint / ms_convert) > 3.25: - cruise_btn = CruiseButtons.CANCEL - elif comparison_setpoint < speed_setpoint - target_deadband and speed_setpoint > CS.CP.minEnableSpeed * ms_convert + 1: - cruise_btn = CruiseButtons.DECEL_SET - elif comparison_setpoint > speed_setpoint + target_deadband: - cruise_btn = CruiseButtons.RES_ACCEL + if CS.CP.carFingerprint in VOLT_CC_CARS: + cruise_btn, rate = _create_volt_cc_spam_command(CS, actuators, ms_convert) + else: + if CS.CP.minEnableSpeed - (desired_setpoint / ms_convert) > 3.25: + cruise_btn = CruiseButtons.CANCEL + elif comparison_setpoint < speed_setpoint - target_deadband and speed_setpoint > CS.CP.minEnableSpeed * ms_convert + 1: + cruise_btn = CruiseButtons.DECEL_SET + elif comparison_setpoint > speed_setpoint + target_deadband: + cruise_btn = CruiseButtons.RES_ACCEL cruise_btn = stabilize_bolt_cc_button(controller, CS.CP, cruise_btn) if cruise_btn == CruiseButtons.CANCEL: diff --git a/opendbc_repo/opendbc/car/gm/tests/test_gm.py b/opendbc_repo/opendbc/car/gm/tests/test_gm.py index b8cf95273..1d0040bde 100644 --- a/opendbc_repo/opendbc/car/gm/tests/test_gm.py +++ b/opendbc_repo/opendbc/car/gm/tests/test_gm.py @@ -657,6 +657,60 @@ class TestGMCarController: assert [msg[2] for msg in msgs] == [0, 2] + def test_volt_cc_redneck_holds_setpoint_without_planner_acceleration(self): + packer = CANPacker(DBC[CAR.CHEVROLET_VOLT_CC][Bus.pt]) + controller = SimpleNamespace(frame=int(2.0 / DT_CTRL), last_button_frame=0, apply_speed=0, malibu_button_phase=0) + cs = SimpleNamespace( + CP=SimpleNamespace( + carFingerprint=CAR.CHEVROLET_VOLT_CC, + flags=GMFlags.NO_CAMERA.value, + networkLocation=structs.CarParams.NetworkLocation.gateway, + minEnableSpeed=0.0, + ), + buttons_counter=2, + out=SimpleNamespace( + vEgo=60.0 * CV.KPH_TO_MS, + cruiseState=SimpleNamespace(speed=60.0 * CV.KPH_TO_MS), + ), + ) + + msgs = gmcan.create_gm_cc_spam_command( + packer, controller, cs, SimpleNamespace(accel=0.0), SimpleNamespace(is_metric=True), + ) + + assert msgs == [] + assert controller.apply_speed == 60 + + def test_volt_cc_redneck_rate_limits_setpoint_changes_by_planner_acceleration(self): + packer = CANPacker(DBC[CAR.CHEVROLET_VOLT_CC][Bus.pt]) + controller = SimpleNamespace(frame=int(0.5 / DT_CTRL), last_button_frame=0, apply_speed=0, malibu_button_phase=0) + cs = SimpleNamespace( + CP=SimpleNamespace( + carFingerprint=CAR.CHEVROLET_VOLT_CC, + flags=GMFlags.NO_CAMERA.value, + networkLocation=structs.CarParams.NetworkLocation.gateway, + minEnableSpeed=0.0, + ), + buttons_counter=2, + out=SimpleNamespace( + vEgo=60.0 * CV.KPH_TO_MS, + cruiseState=SimpleNamespace(speed=60.0 * CV.KPH_TO_MS), + ), + ) + + msgs = gmcan.create_gm_cc_spam_command( + packer, controller, cs, SimpleNamespace(accel=0.5), SimpleNamespace(is_metric=True), + ) + + assert msgs == [] + + controller.frame = int(0.7 / DT_CTRL) + msgs = gmcan.create_gm_cc_spam_command( + packer, controller, cs, SimpleNamespace(accel=0.5), SimpleNamespace(is_metric=True), + ) + + assert len(msgs) == 1 + def test_volt_cc_no_camera_redneck_spam_stays_on_powertrain_bus(self): packer = CANPacker(DBC[CAR.CHEVROLET_VOLT_CC][Bus.pt]) controller = SimpleNamespace(frame=int(0.3 / DT_CTRL), last_button_frame=0, apply_speed=0, malibu_button_phase=0) diff --git a/opendbc_repo/opendbc/car/hyundai/carcontroller.py b/opendbc_repo/opendbc/car/hyundai/carcontroller.py index 22d15a9a7..ce2f3fae1 100644 --- a/opendbc_repo/opendbc/car/hyundai/carcontroller.py +++ b/opendbc_repo/opendbc/car/hyundai/carcontroller.py @@ -24,6 +24,9 @@ LongCtrlState = structs.CarControl.Actuators.LongControlState MAX_ANGLE = 85 MAX_ANGLE_FRAMES = 89 MAX_ANGLE_CONSECUTIVE_FRAMES = 2 + +CANCEL_BUTTON_DELAY_FRAMES = 10 + CANFD_BLINDSPOT_STATUS_STALE_NS = 200_000_000 CANFD_CAMERA_LEAD_STALE_NS = 300_000_000 CANFD_LEAD_MIN_DISTANCE = 0.1 @@ -454,6 +457,7 @@ class CarController(CarControllerBase): self.apply_angle_last = 0.0 self.car_fingerprint = CP.carFingerprint self.last_button_frame = 0 + self.cancel_counter = 0 self.redneck_button_frame = 0 self.ecu_disable_failed = False self._ecu_disable_checked = False @@ -717,6 +721,8 @@ class CarController(CarControllerBase): if self.CP.flags & HyundaiFlags.ENABLE_BLINKERS: can_sends.append(make_tester_present_msg(0x7b1, self.CAN.ECAN, suppress_response=True)) + self.cancel_counter = self.cancel_counter + 1 if CC.cruiseControl.cancel else 0 + # *** CAN/CAN FD specific *** if self.CP.flags & HyundaiFlags.CANFD: can_sends.extend(self.create_canfd_msgs(now_nanos, apply_steer_req, apply_torque, apply_angle, set_speed_in_units, accel, @@ -782,7 +788,7 @@ class CarController(CarControllerBase): # Button messages if not self.long_active_ecu: - if CC.cruiseControl.cancel: + if self.cancel_counter > CANCEL_BUTTON_DELAY_FRAMES: can_sends.append(hyundaican.create_clu11(self.packer, self.frame, CS.clu11, Buttons.CANCEL, self.CP)) elif CC.cruiseControl.resume: # send resume at a max freq of 10Hz @@ -1046,7 +1052,7 @@ class CarController(CarControllerBase): if self.CP.flags & HyundaiFlags.CANFD_ALT_BUTTONS: can_sends.append(hyundaicanfd.create_acc_cancel(self.packer, self.CP, self.CAN, CS.cruise_info)) self.last_button_frame = self.frame - else: + elif self.cancel_counter > CANCEL_BUTTON_DELAY_FRAMES: for _ in range(20): can_sends.append(hyundaicanfd.create_buttons(self.packer, self.CP, self.CAN, CS.buttons_counter + 1, Buttons.CANCEL)) self.last_button_frame = self.frame diff --git a/opendbc_repo/opendbc/car/hyundai/interface.py b/opendbc_repo/opendbc/car/hyundai/interface.py index b101b68f3..ac312fb92 100644 --- a/opendbc_repo/opendbc/car/hyundai/interface.py +++ b/opendbc_repo/opendbc/car/hyundai/interface.py @@ -48,7 +48,7 @@ def apply_platform_longitudinal_params(ret: structs.CarParams) -> None: def apply_kia_ev6_gt_line_longitudinal_params(ret: structs.CarParams) -> None: ret.startAccel = 1.4 - ret.longitudinalActuatorDelay = 0.35 + ret.longitudinalActuatorDelay = 0.5 ret.vEgoStarting = 0.5 diff --git a/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py b/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py index 0548750d4..22bf3ca56 100644 --- a/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py +++ b/opendbc_repo/opendbc/car/hyundai/tests/test_hyundai.py @@ -7,7 +7,7 @@ from opendbc.can import CANPacker, CANParser from opendbc.car import Bus, ButtonType, gen_empty_fingerprint, structs from opendbc.car.structs import CarControl, CarParams from opendbc.car.fw_versions import build_fw_dict, match_fw_to_car -from opendbc.car.hyundai.carcontroller import CarController, Ioniq6LongitudinalTuningState, GenesisG90LongitudinalTuningState, \ +from opendbc.car.hyundai.carcontroller import CarController, CANCEL_BUTTON_DELAY_FRAMES, Ioniq6LongitudinalTuningState, GenesisG90LongitudinalTuningState, \ EV9LongitudinalTuningState, update_ev9_longitudinal_tuning, \ BlindspotWarningState, update_blindspot_warning, \ reset_egmp_longitudinal_tuning, \ @@ -783,6 +783,36 @@ class TestHyundaiFingerprint: assert not any(addr == 0x340 for addr, _, _ in first) assert any(addr == 0x340 for addr, _, _ in second) + def test_stock_scc_cancel_waits_for_factory_disengagement(self): + CP = CarInterface.get_params(CAR.HYUNDAI_SANTA_FE_2022, gen_empty_fingerprint(), [], False, False, False, None) + controller = CarController(DBC[CP.carFingerprint], CP) + parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("LKAS11", 0), ("CLU11", 0)], 0) + + hud_control = SimpleNamespace( + visualAlert=CarControl.HUDControl.VisualAlert.none, + leftLaneVisible=True, + rightLaneVisible=True, + leftLaneDepart=False, + rightLaneDepart=False, + ) + CS = SimpleNamespace( + lkas11=parser.vl["LKAS11"], + clu11=parser.vl["CLU11"], + redneck_send_button=Buttons.NONE, + is_metric=False, + ) + CC = SimpleNamespace(enabled=False, cruiseControl=SimpleNamespace(cancel=True, resume=False)) + actuators = SimpleNamespace(longControlState=LongCtrlState.off) + + for counter in range(1, CANCEL_BUTTON_DELAY_FRAMES + 1): + controller.cancel_counter = counter + msgs = controller.create_can_msgs(True, 0, False, 0.0, 0.0, False, hud_control, actuators, CS, CC, 2, 2) + assert not any(addr == 0x4F1 for addr, _, _ in msgs) + + controller.cancel_counter = CANCEL_BUTTON_DELAY_FRAMES + 1 + msgs = controller.create_can_msgs(True, 0, False, 0.0, 0.0, False, hud_control, actuators, CS, CC, 2, 2) + assert any(addr == 0x4F1 for addr, _, _ in msgs) + @pytest.mark.parametrize("candidate", (CAR.HYUNDAI_ELANTRA_2024, CAR.HYUNDAI_ELANTRA_HEV_2024)) def test_hyundai_can_refresh_platforms_use_refresh_dbc_and_safety_param(self, candidate): CP = CarInterface.get_params(candidate, gen_empty_fingerprint(), [], False, False, False, None) @@ -1281,7 +1311,7 @@ class TestHyundaiFingerprint: assert CP.startAccel == pytest.approx(1.4) assert CP.vEgoStarting == pytest.approx(0.5) - assert CP.longitudinalActuatorDelay == pytest.approx(0.35) + assert CP.longitudinalActuatorDelay == pytest.approx(0.5) assert CP.vEgoStopping == pytest.approx(0.3) assert CP.stoppingDecelRate == pytest.approx(0.4) assert kia_ev6_gt_line_longitudinal_tuning(CP.carFingerprint, CP.carVin) @@ -1310,7 +1340,7 @@ class TestHyundaiFingerprint: assert CP.startAccel == pytest.approx(1.4) assert CP.vEgoStarting == pytest.approx(0.5) - assert CP.longitudinalActuatorDelay == pytest.approx(0.35) + assert CP.longitudinalActuatorDelay == pytest.approx(0.5) assert kia_ev6_gt_line_longitudinal_tuning(CP.carFingerprint, CP.carVin, testing_ground_active=True) assert not kia_ev6_gt_line_longitudinal_tuning(CAR.KIA_EV6_2025, CP.carVin, testing_ground_active=True) diff --git a/opendbc_repo/opendbc/car/subaru/carcontroller.py b/opendbc_repo/opendbc/car/subaru/carcontroller.py index 3815dfd77..bde3669b9 100644 --- a/opendbc_repo/opendbc/car/subaru/carcontroller.py +++ b/opendbc_repo/opendbc/car/subaru/carcontroller.py @@ -4,7 +4,7 @@ from opendbc.car import Bus, DT_CTRL, make_tester_present_msg, structs from opendbc.car.lateral import apply_driver_steer_torque_limits, apply_std_steer_angle_limits, apply_steer_angle_limits_vm, common_fault_avoidance from opendbc.car.interfaces import CarControllerBase from opendbc.car.subaru import subarucan -from opendbc.car.subaru.values import CAR, DBC, GLOBAL_ES_ADDR, SUBARU_AVH_CARS, SUBARU_STOP_START_CARS, CanBus, CarControllerParams, SubaruFlags +from opendbc.car.subaru.values import CAR, DBC, GLOBAL_ES_ADDR, SUBARU_STOP_START_CARS, CanBus, CarControllerParams, SubaruFlags from opendbc.car.vehicle_model import VehicleModel # FIXME: These limits aren't exact. The real limit is more than likely over a larger time period and @@ -37,9 +37,6 @@ _STOP_START_STARTUP_DELAY_FRAMES = 100 _STOP_START_STARTUP_DEADLINE_FRAMES = 1000 _STOP_START_PULSE_FRAMES = 30 _STOP_START_PULSE_PERIOD_FRAMES = 5 -_AVH_STARTUP_DELAY_FRAMES = _STOP_START_STARTUP_DELAY_FRAMES -_AVH_STARTUP_DEADLINE_FRAMES = _STOP_START_STARTUP_DEADLINE_FRAMES -_AVH_PULSE_MESSAGES = 15 # Match the native 10 Hz AVH frame for roughly 1.5 seconds def get_safety_CP(): @@ -90,10 +87,6 @@ class CarController(CarControllerBase): self.stop_start_initial_state = None self.stop_start_counter = 0 self.stop_start_acknowledged = False - self.avh_attempted = False - self.avh_request_started = False - self.avh_last_counter = None - self.avh_messages_sent = 0 def _stop_start_off_request(self, CC, CS, starpilot_toggles): """Send one bounded Subaru Stop/Start OFF request after ignition. @@ -150,55 +143,6 @@ class CarController(CarControllerBase): self.stop_start_counter = (self.stop_start_counter + 1) % 0x10 return msg - def _avh_on_request(self, CC, CS, starpilot_toggles): - """Send a bounded Subaru AVH ON pulse after ignition. - - The AVH button frame was identified on the 2025 Legacy only. Keep this - independent from Stop/Start so the existing Outback request is unchanged. - """ - if self.CP.carFingerprint not in SUBARU_AVH_CARS or \ - not getattr(starpilot_toggles, "subaru_avh_on", False) or self.avh_attempted: - return None - - if self.frame > _AVH_STARTUP_DEADLINE_FRAMES or getattr(CC, "enabled", False): - self.avh_attempted = True - return None - - if self.frame < _AVH_STARTUP_DELAY_FRAMES or not getattr(getattr(CS, "out", None), "canValid", True): - return None - - out = CS.out - if not getattr(out, "standstill", False) or out.gearShifter not in ( - structs.CarState.GearShifter.park, - structs.CarState.GearShifter.neutral, - ): - return None - - avh_msg = getattr(CS, "avh_msg", None) - avh_dat = getattr(CS, "avh_dat", None) - if not avh_msg or not avh_dat: - return None - - if not self.avh_request_started: - self.avh_request_started = True - self.avh_last_counter = int(avh_msg.get("COUNTER", 0)) % 0x10 - - if self.avh_messages_sent >= _AVH_PULSE_MESSAGES: - self.avh_attempted = True - return None - - counter = int(avh_msg.get("COUNTER", 0)) % 0x10 - if counter == self.avh_last_counter: - return None - - msg = subarucan.create_avh_control( - self.packer, avh_msg, raw_dat=avh_dat, - counter=counter, bus=CanBus.alt_for_cp(self.CP), - ) - self.avh_last_counter = counter - self.avh_messages_sent += 1 - return msg - def _reset_legacy_2025_handoff(self): self.driver_override = False self.angle_override_confirm_frames = 0 @@ -472,10 +416,6 @@ class CarController(CarControllerBase): if stop_start_msg is not None: can_sends.append(stop_start_msg) - avh_msg = self._avh_on_request(CC, CS, starpilot_toggles) - if avh_msg is not None: - can_sends.append(avh_msg) - # *** steering *** if (self.frame % self.p.STEER_STEP) == 0: if self.CP.flags & SubaruFlags.LKAS_ANGLE: diff --git a/opendbc_repo/opendbc/car/subaru/carstate.py b/opendbc_repo/opendbc/car/subaru/carstate.py index 985ef31f0..cee89d2df 100644 --- a/opendbc_repo/opendbc/car/subaru/carstate.py +++ b/opendbc_repo/opendbc/car/subaru/carstate.py @@ -4,7 +4,7 @@ from opendbc.can import CANDefine, CANParser from opendbc.car import Bus, structs from opendbc.car.common.conversions import Conversions as CV from opendbc.car.interfaces import CarStateBase -from opendbc.car.subaru.values import DBC, CanBus, SUBARU_AVH_CARS, SUBARU_STOP_START_CARS, SubaruFlags +from opendbc.car.subaru.values import DBC, CanBus, SUBARU_STOP_START_CARS, SubaruFlags from opendbc.car import CanSignalRateCalculator @@ -18,8 +18,6 @@ class CarState(CarStateBase): self.dashlights_msg = {} self.dashlights_dat = b"" self.stop_start_state = 0 - self.avh_msg = {} - self.avh_dat = b"" def update(self, can_parsers, starpilot_toggles) -> structs.CarState: cp = can_parsers[Bus.pt] @@ -35,11 +33,6 @@ class CarState(CarStateBase): self.dashlights_dat = stop_start_cp.vl_raw["Dashlights"] self.stop_start_state = stop_start_cp.vl["Engine_Stop_Start"]["STOP_START_STATE"] - if self.CP.carFingerprint in SUBARU_AVH_CARS: - avh_cp = cp_alt if self.CP.flags & SubaruFlags.GLOBAL_GEN2 else cp - self.avh_msg = copy.copy(avh_cp.vl["AVH"]) - self.avh_dat = avh_cp.vl_raw["AVH"] - throttle_msg = cp.vl["Throttle"] if not (self.CP.flags & SubaruFlags.HYBRID) else cp_alt.vl["Throttle_Hybrid"] ret.gasPressed = throttle_msg["Throttle_Pedal"] > 1e-5 if self.CP.flags & SubaruFlags.PREGLOBAL: @@ -163,11 +156,10 @@ class CarState(CarStateBase): @staticmethod def get_can_parsers(CP): - avh_messages = [("AVH", 0)] if CP.carFingerprint in SUBARU_AVH_CARS else [] parsers = { Bus.pt: CANParser(DBC[CP.carFingerprint][Bus.pt], [], CanBus.main_for_cp(CP)), Bus.cam: CANParser(DBC[CP.carFingerprint][Bus.pt], [], CanBus.camera), - Bus.alt: CANParser(DBC[CP.carFingerprint][Bus.pt], avh_messages, CanBus.alt_for_cp(CP)) + Bus.alt: CANParser(DBC[CP.carFingerprint][Bus.pt], [], CanBus.alt_for_cp(CP)) } if CP.flags & SubaruFlags.D_PLATFORM: parsers[Bus.main] = CANParser(DBC[CP.carFingerprint][Bus.pt], [], CanBus.main) diff --git a/opendbc_repo/opendbc/car/subaru/interface.py b/opendbc_repo/opendbc/car/subaru/interface.py index 35f04d2b3..741551a0a 100644 --- a/opendbc_repo/opendbc/car/subaru/interface.py +++ b/opendbc_repo/opendbc/car/subaru/interface.py @@ -3,7 +3,7 @@ from opendbc.car.disable_ecu import disable_ecu from opendbc.car.interfaces import CarInterfaceBase from opendbc.car.subaru.carcontroller import CarController from opendbc.car.subaru.carstate import CarState -from opendbc.car.subaru.values import CAR, CanBus, GLOBAL_ES_ADDR, SUBARU_AVH_CARS, SUBARU_STOP_START_CARS, SubaruFlags, SubaruSafetyFlags +from opendbc.car.subaru.values import CAR, CanBus, GLOBAL_ES_ADDR, SUBARU_STOP_START_CARS, SubaruFlags, SubaruSafetyFlags class CarInterface(CarInterfaceBase): @@ -42,8 +42,6 @@ class CarInterface(CarInterfaceBase): ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.D_PLATFORM_CAMERA.value if candidate in SUBARU_STOP_START_CARS: ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.STOP_START_BUTTON.value - if candidate in SUBARU_AVH_CARS: - ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.AVH_BUTTON.value if candidate in (CAR.SUBARU_LEGACY_2025, CAR.SUBARU_ASCENT_2023): ret.safetyConfigs[0].safetyParam |= SubaruSafetyFlags.FIXED_ANGLE_LIMITS.value diff --git a/opendbc_repo/opendbc/car/subaru/subarucan.py b/opendbc_repo/opendbc/car/subaru/subarucan.py index 6e53ccbc9..1d2d04d2e 100644 --- a/opendbc_repo/opendbc/car/subaru/subarucan.py +++ b/opendbc_repo/opendbc/car/subaru/subarucan.py @@ -208,31 +208,6 @@ def create_stop_start_control(packer, dashlights_msg, raw_dat=None, counter=None return packer.make_can_msg("Dashlights", bus, values) -def create_avh_control(packer, avh_msg, raw_dat=None, counter=None, bus=CanBus.alt): - """Create the supported Subaru Legacy AVH ON request. - - AVH is carried in the live 0x32b frame. Preserve the other bytes and update - only the rolling counter, AVH bit, and Subaru additive checksum. - """ - if raw_dat: - dat = bytearray(raw_dat) - if len(dat) != 8: - raise ValueError(f"AVH frame must be 8 bytes, got {len(dat)}") - if counter is None: - counter = (int(avh_msg.get("COUNTER", 0)) + 1) % 0x10 - dat[1] = (dat[1] & 0xF0) | (counter % 0x10) - dat[5] |= 0x20 # AVH, big-endian bit 45 - dat[0] = ((0x32B & 0xFF) + ((0x32B >> 8) & 0xFF) + sum(dat[1:])) & 0xFF - return 0x32B, bytes(dat), bus - - values = dict(avh_msg) - if counter is None: - counter = (int(values.get("COUNTER", 0)) + 1) % 0x10 - values["COUNTER"] = counter % 0x10 - values["AVH"] = 1 - return packer.make_can_msg("AVH", bus, values) - - def create_es_brake(packer, frame, es_brake_msg, long_enabled, long_active, brake_value, bus=CanBus.main): values = {s: es_brake_msg[s] for s in [ "CHECKSUM", diff --git a/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py b/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py index 752b8b277..d6ce08e54 100644 --- a/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py +++ b/opendbc_repo/opendbc/car/subaru/tests/test_subaru.py @@ -194,7 +194,6 @@ def test_outback_2023_uses_d_platform_bus_layout(): assert CP.flags & SubaruFlags.D_PLATFORM assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.D_PLATFORM assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.STOP_START_BUTTON - assert not (CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.AVH_BUTTON) assert not (CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.LEGACY_2025_ANGLE_LIMITS) assert CanBus.main_for_cp(CP) == CanBus.alt assert CanBus.angle_for_cp(CP) == CanBus.main @@ -225,21 +224,6 @@ def test_stop_start_inputs_are_captured_for_supported_models(platform): assert car_state.stop_start_state == 3 -def test_avh_inputs_are_captured_for_legacy_2025(): - CP = CarInterface.get_non_essential_params(CAR.SUBARU_LEGACY_2025) - car_state = CarState(CP, None) - parsers = car_state.get_can_parsers(CP) - raw_avh = bytes.fromhex("230f1c4208800000") - parsers[Bus.alt].vl["AVH"]["COUNTER"] = 15 - parsers[Bus.alt].vl["AVH"]["AVH"] = 0 - parsers[Bus.alt].vl_raw["AVH"] = raw_avh - - car_state.update(parsers, SimpleNamespace(subaru_sng=False)) - - assert car_state.avh_msg["COUNTER"] == 15 - assert car_state.avh_dat == raw_avh - - @pytest.mark.parametrize("platform, expected_bus, start_frame", [ (CAR.SUBARU_OUTBACK_2023, CanBus.alt, 101), (CAR.SUBARU_LEGACY_2025, CanBus.alt, 401), @@ -292,94 +276,6 @@ def test_stop_start_request_is_bounded_and_uses_live_dashlights(platform, expect assert controller.stop_start_acknowledged -def test_avh_request_sets_observed_bit_and_pulses_at_native_rate(): - CP = CarInterface.get_non_essential_params(CAR.SUBARU_LEGACY_2025) - controller = CarController({}, CP) - controller.frame = 101 - - class TestActuators: - steeringAngleDeg = 0.0 - - def as_builder(self): - return SimpleNamespace(steeringAngleDeg=self.steeringAngleDeg) - - CC = SimpleNamespace( - enabled=False, - latActive=False, - longActive=False, - actuators=TestActuators(), - hudControl=SimpleNamespace(leadVisible=False), - cruiseControl=SimpleNamespace(cancel=False), - ) - CS = SimpleNamespace( - canValid=True, - avh_msg={"COUNTER": 15, "AVH": 0}, - avh_dat=bytes.fromhex("230f1c4208800000"), - out=SimpleNamespace( - standstill=True, - gearShifter=structs.CarState.GearShifter.park, - vEgoRaw=0.0, - steeringAngleDeg=0.0, - ), - ) - toggles = SimpleNamespace(subaru_stop_start_off=False, subaru_avh_on=True, subaru_sng=False) - - # Start the request from the current live counter. AVH is a native 10 Hz - # frame, so the controller waits for each next live counter before sending - # its matching button frame. - _, can_sends = controller.update(CC, CS, 0, toggles) - avh_msgs = [msg for msg in can_sends if msg[0] == 0x32b] - assert not avh_msgs - - CS.avh_msg["COUNTER"] = 0 - CS.avh_dat = bytes.fromhex("14001c4208800000") - controller.frame = 103 - _, can_sends = controller.update(CC, CS, 0, toggles) - avh_msgs = [msg for msg in can_sends if msg[0] == 0x32b] - assert avh_msgs == [(0x32b, bytes.fromhex("34001c4208a00000"), CanBus.alt)] - - parser = CANParser(DBC[CP.carFingerprint][Bus.pt], [("AVH", 0)], CanBus.alt) - parser.update([(CanBus.alt, avh_msgs)]) - assert parser.vl["AVH"]["AVH"] == 1 - assert parser.vl["AVH"]["COUNTER"] == 0 - - controller.frame = 104 - _, can_sends = controller.update(CC, CS, 0, toggles) - assert not any(msg[0] == 0x32b for msg in can_sends) - - avh_msgs = [] - for counter in range(1, 15): - CS.avh_msg["COUNTER"] = counter - raw_dat = bytearray.fromhex("14001c4208800000") - raw_dat[1] = counter - raw_dat[0] = ((0x32B & 0xFF) + ((0x32B >> 8) & 0xFF) + sum(raw_dat[1:])) & 0xFF - CS.avh_dat = bytes(raw_dat) - controller.frame = 103 + (counter * 10) - _, can_sends = controller.update(CC, CS, 0, toggles) - sent = [msg for msg in can_sends if msg[0] == 0x32b] - assert len(sent) == 1 - avh_msgs.extend(sent) - - assert len(avh_msgs) == 14 - assert [msg[1][1] & 0x0F for msg in avh_msgs] == list(range(1, 15)) - assert all(msg[1][5] & 0x20 for msg in avh_msgs) - assert not controller.avh_attempted - - CS.avh_msg["COUNTER"] = 15 - CS.avh_dat = bytes.fromhex("230f1c4208800000") - controller.frame = 253 - _, can_sends = controller.update(CC, CS, 0, toggles) - assert not any(msg[0] == 0x32b for msg in can_sends) - assert controller.avh_attempted - - CS.avh_msg["COUNTER"] = 0 - CS.avh_dat = bytes.fromhex("14001c4208800000") - controller.frame = 131 - _, can_sends = controller.update(CC, CS, 0, toggles) - assert not any(msg[0] == 0x32b for msg in can_sends) - assert controller.avh_attempted - - def test_legacy_2025_uses_gen2_angle_bus_layout(): CP = CarInterface.get_non_essential_params(CAR.SUBARU_LEGACY_2025) parsers = CarState.get_can_parsers(CP) @@ -391,7 +287,6 @@ def test_legacy_2025_uses_gen2_angle_bus_layout(): assert not (CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.D_PLATFORM_CAMERA) assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.FIXED_ANGLE_LIMITS assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.STOP_START_BUTTON - assert CP.safetyConfigs[0].safetyParam & SubaruSafetyFlags.AVH_BUTTON assert CanBus.main_for_cp(CP) == CanBus.main assert CanBus.angle_for_cp(CP) == CanBus.main assert parsers[Bus.pt].bus == CanBus.main diff --git a/opendbc_repo/opendbc/car/subaru/values.py b/opendbc_repo/opendbc/car/subaru/values.py index c7caa7d69..4bf94c610 100644 --- a/opendbc_repo/opendbc/car/subaru/values.py +++ b/opendbc_repo/opendbc/car/subaru/values.py @@ -89,7 +89,6 @@ class SubaruSafetyFlags(IntFlag): D_PLATFORM_CAMERA = 64 FIXED_ANGLE_LIMITS = 128 STOP_START_BUTTON = 256 - AVH_BUTTON = 512 LEGACY_2025_ANGLE_LIMITS = FIXED_ANGLE_LIMITS @@ -276,11 +275,6 @@ SUBARU_STOP_START_CARS = ( CAR.SUBARU_LEGACY_2025, ) -SUBARU_AVH_CARS = ( - CAR.SUBARU_LEGACY_2025, -) - - SUBARU_VERSION_REQUEST = bytes([uds.SERVICE_TYPE.READ_DATA_BY_IDENTIFIER]) + \ p16(uds.DATA_IDENTIFIER_TYPE.APPLICATION_DATA_IDENTIFICATION) SUBARU_VERSION_RESPONSE = bytes([uds.SERVICE_TYPE.READ_DATA_BY_IDENTIFIER + 0x40]) + \ diff --git a/opendbc_repo/opendbc/car/toyota/carstate.py b/opendbc_repo/opendbc/car/toyota/carstate.py index bff965f5a..635e29695 100644 --- a/opendbc_repo/opendbc/car/toyota/carstate.py +++ b/opendbc_repo/opendbc/car/toyota/carstate.py @@ -75,6 +75,7 @@ class CarState(CarStateBase): self.distance_button = 0 self.pcm_follow_distance = 0 + self.pcm_acc_status = 0 self.acc_type = 1 self.lkas_hud = {} @@ -208,6 +209,7 @@ class CarState(CarStateBase): if self.CP.openpilotLongitudinalControl: ret.accFaulted = ret.accFaulted or cp.vl["PCM_CRUISE_2"]["LOW_SPEED_LOCKOUT"] == 2 + prev_pcm_acc_status = self.pcm_acc_status self.pcm_acc_status = cp.vl["PCM_CRUISE"]["CRUISE_STATE"] if self.CP.carFingerprint not in (NO_STOP_TIMER_CAR - TSS2_CAR): # ignore standstill state in certain vehicles, since pcm allows to restart with just an acceleration request @@ -264,8 +266,8 @@ class CarState(CarStateBase): buttonEvents += create_button_events(self.distance_button, prev_distance_button, {1: ButtonType.gapAdjustCruise}) buttonEvents += [ - *create_button_events(self.pcm_acc_status == 9, False, {1: ButtonType.accelCruise}), - *create_button_events(self.pcm_acc_status == 10, False, {1: ButtonType.decelCruise}), + *create_button_events(self.pcm_acc_status == 9, prev_pcm_acc_status == 9, {1: ButtonType.accelCruise}), + *create_button_events(self.pcm_acc_status == 10, prev_pcm_acc_status == 10, {1: ButtonType.decelCruise}), ] fp_ret.dashboardSpeedLimit = calculate_speed_limit(cp_cam) diff --git a/opendbc_repo/opendbc/car/volvo/carstate.py b/opendbc_repo/opendbc/car/volvo/carstate.py index cab17ee8e..9198e480b 100644 --- a/opendbc_repo/opendbc/car/volvo/carstate.py +++ b/opendbc_repo/opendbc/car/volvo/carstate.py @@ -11,7 +11,6 @@ TransmissionType = structs.CarParams.TransmissionType # Must match VOLVO_SPEED_TO_MS in opendbc/safety/modes/volvo.h. SPEED_TO_MS = 0.003977 STEERING_PRESSED_THRESHOLD = 2 -STEERING_DISENGAGE_THRESHOLD = 5 class CarState(CarStateBase): @@ -75,11 +74,9 @@ class CarState(CarStateBase): ret.steeringAngleDeg = cp_party.vl['PSCM']['PSCM_ANGLE_SENSOR'] # openpilot expects a negative value for a right turn #ret.steeringAngleDeg = cp_party.vl['SAS']['SAS_ANGLE_SENSOR'] - # Driver steering torque feedback (used for driver override detection) ret.steeringTorque = -cp_party.vl['DRIVER_INPUT']['STEERING_DRIVER_INPUT'] # Car right turn is negative, openpilot right turn is positive driver_input = abs(cp_party.vl['DRIVER_INPUT']['STEERING_DRIVER_INPUT']) ret.steeringPressed = driver_input > STEERING_PRESSED_THRESHOLD - ret.steeringDisengage = driver_input > STEERING_DISENGAGE_THRESHOLD # EPS status - placeholder until actual signal is found self.eps_active = True # Assume EPS is active for now diff --git a/opendbc_repo/opendbc/dbc/generator/subaru/subaru_global_2017.dbc b/opendbc_repo/opendbc/dbc/generator/subaru/subaru_global_2017.dbc index ce7001f60..83a36ff8f 100644 --- a/opendbc_repo/opendbc/dbc/generator/subaru/subaru_global_2017.dbc +++ b/opendbc_repo/opendbc/dbc/generator/subaru/subaru_global_2017.dbc @@ -1,10 +1,5 @@ CM_ "IMPORT _subaru_global.dbc"; -BO_ 811 AVH: 8 XXX - SG_ CHECKSUM : 0|8@1+ (1,0) [0|255] "" XXX - SG_ COUNTER : 8|4@1+ (1,0) [0|15] "" XXX - SG_ AVH : 45|1@0+ (1,0) [0|1] "" XXX - BO_ 72 Transmission: 8 XXX SG_ CHECKSUM : 0|8@1+ (1,0) [0|255] "" XXX SG_ COUNTER : 8|4@1+ (1,0) [0|15] "" XXX diff --git a/opendbc_repo/opendbc/dbc/subaru_global_2017_generated.dbc b/opendbc_repo/opendbc/dbc/subaru_global_2017_generated.dbc index 7ec223270..9732feae1 100644 --- a/opendbc_repo/opendbc/dbc/subaru_global_2017_generated.dbc +++ b/opendbc_repo/opendbc/dbc/subaru_global_2017_generated.dbc @@ -307,11 +307,6 @@ VAL_ 544 AEB_Status 12 "AEB related" 8 "AEB actuation" 4 "AEB related" 0 "No AEB CM_ "subaru_global_2017.dbc starts here"; -BO_ 811 AVH: 8 XXX - SG_ CHECKSUM : 0|8@1+ (1,0) [0|255] "" XXX - SG_ COUNTER : 8|4@1+ (1,0) [0|15] "" XXX - SG_ AVH : 45|1@0+ (1,0) [0|1] "" XXX - BO_ 72 Transmission: 8 XXX SG_ CHECKSUM : 0|8@1+ (1,0) [0|255] "" XXX SG_ COUNTER : 8|4@1+ (1,0) [0|15] "" XXX diff --git a/opendbc_repo/opendbc/safety/modes/subaru.h b/opendbc_repo/opendbc/safety/modes/subaru.h index 726212f46..ba5f05845 100644 --- a/opendbc_repo/opendbc/safety/modes/subaru.h +++ b/opendbc_repo/opendbc/safety/modes/subaru.h @@ -42,7 +42,6 @@ #define MSG_SUBARU_ES_STATIC_1 0x22aU #define MSG_SUBARU_ES_STATIC_2 0x325U #define MSG_SUBARU_Dashlights 0x390U -#define MSG_SUBARU_AVH 0x32bU #define SUBARU_MAIN_BUS 0U #define SUBARU_ALT_BUS 1U @@ -66,13 +65,6 @@ #define SUBARU_STOP_START_TX_MSGS(bus) \ {MSG_SUBARU_Dashlights, bus, 8, .check_relay = false}, \ -#define SUBARU_AVH_TX_MSGS(bus) \ - {MSG_SUBARU_AVH, bus, 8, .check_relay = false}, \ - -#define SUBARU_STOP_START_AVH_TX_MSGS(bus) \ - SUBARU_STOP_START_TX_MSGS(bus) \ - SUBARU_AVH_TX_MSGS(bus) - #define SUBARU_COMMON_LONG_TX_MSGS(alt_bus) \ {MSG_SUBARU_ES_Distance, alt_bus, 8, .check_relay = true}, \ {MSG_SUBARU_ES_Brake, alt_bus, 8, .check_relay = true}, \ @@ -121,7 +113,6 @@ static bool subaru_lkas_angle = false; static bool subaru_d_platform = false; static bool subaru_fixed_angle_limits = false; static bool subaru_stop_start_button = false; -static bool subaru_avh_button = false; static uint32_t subaru_get_checksum(const CANPacket_t *msg) { return (uint8_t)msg->data[0]; @@ -306,13 +297,6 @@ static bool subaru_tx_hook(const CANPacket_t *msg) { violation |= subaru_get_checksum(msg) != subaru_compute_checksum(msg); } - if (msg->addr == MSG_SUBARU_AVH) { - violation |= !subaru_avh_button; - violation |= msg->bus != (subaru_gen2 ? SUBARU_ALT_BUS : SUBARU_MAIN_BUS); - violation |= !GET_BIT(msg, 45U); - violation |= subaru_get_checksum(msg) != subaru_compute_checksum(msg); - } - if (violation){ tx = false; } @@ -363,12 +347,6 @@ static safety_config subaru_init(uint16_t param) { SUBARU_STOP_START_TX_MSGS(SUBARU_ALT_BUS) }; - static const CanMsg SUBARU_GEN2_LKAS_ANGLE_STOP_START_AVH_TX_MSGS[] = { - SUBARU_BASE_TX_MSGS(SUBARU_ALT_BUS, MSG_SUBARU_ES_LKAS_ANGLE) - SUBARU_COMMON_TX_MSGS(SUBARU_ALT_BUS) - SUBARU_STOP_START_AVH_TX_MSGS(SUBARU_ALT_BUS) - }; - static const CanMsg SUBARU_D_PLATFORM_ANGLE_MAIN_TX_MSGS[] = { SUBARU_D_PLATFORM_ANGLE_TX_MSGS(SUBARU_MAIN_BUS) SUBARU_COMMON_TX_MSGS(SUBARU_ALT_BUS) @@ -380,12 +358,6 @@ static safety_config subaru_init(uint16_t param) { SUBARU_STOP_START_TX_MSGS(SUBARU_ALT_BUS) }; - static const CanMsg SUBARU_D_PLATFORM_ANGLE_STOP_START_AVH_MAIN_TX_MSGS[] = { - SUBARU_D_PLATFORM_ANGLE_TX_MSGS(SUBARU_MAIN_BUS) - SUBARU_COMMON_TX_MSGS(SUBARU_ALT_BUS) - SUBARU_STOP_START_AVH_TX_MSGS(SUBARU_ALT_BUS) - }; - static const CanMsg SUBARU_D_PLATFORM_ANGLE_CAMERA_TX_MSGS[] = { SUBARU_D_PLATFORM_ANGLE_TX_MSGS(SUBARU_CAM_BUS) SUBARU_COMMON_TX_MSGS(SUBARU_ALT_BUS) @@ -433,9 +405,6 @@ static safety_config subaru_init(uint16_t param) { const uint16_t SUBARU_PARAM_STOP_START_BUTTON = 256; subaru_stop_start_button = GET_FLAG(param, SUBARU_PARAM_STOP_START_BUTTON); - const uint16_t SUBARU_PARAM_AVH_BUTTON = 512; - subaru_avh_button = GET_FLAG(param, SUBARU_PARAM_AVH_BUTTON); - #ifdef ALLOW_DEBUG const uint16_t SUBARU_PARAM_LONGITUDINAL = 2; subaru_longitudinal = GET_FLAG(param, SUBARU_PARAM_LONGITUDINAL); @@ -443,12 +412,10 @@ static safety_config subaru_init(uint16_t param) { safety_config ret; if (subaru_lkas_angle) { - ret = subaru_d_platform ? (subaru_stop_start_button ? (subaru_avh_button ? BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_STOP_START_AVH_MAIN_TX_MSGS) : \ - BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_STOP_START_MAIN_TX_MSGS)) : \ + ret = subaru_d_platform ? (subaru_stop_start_button ? BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_STOP_START_MAIN_TX_MSGS) : \ (subaru_d_platform_camera ? BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_CAMERA_TX_MSGS) : \ BUILD_SAFETY_CFG(subaru_d_platform_angle_rx_checks, SUBARU_D_PLATFORM_ANGLE_MAIN_TX_MSGS))) : \ - subaru_gen2 ? (subaru_stop_start_button ? (subaru_avh_button ? BUILD_SAFETY_CFG(subaru_gen2_lkas_angle_rx_checks, SUBARU_GEN2_LKAS_ANGLE_STOP_START_AVH_TX_MSGS) : \ - BUILD_SAFETY_CFG(subaru_gen2_lkas_angle_rx_checks, SUBARU_GEN2_LKAS_ANGLE_STOP_START_TX_MSGS)) : \ + subaru_gen2 ? (subaru_stop_start_button ? BUILD_SAFETY_CFG(subaru_gen2_lkas_angle_rx_checks, SUBARU_GEN2_LKAS_ANGLE_STOP_START_TX_MSGS) : \ BUILD_SAFETY_CFG(subaru_gen2_lkas_angle_rx_checks, SUBARU_GEN2_LKAS_ANGLE_TX_MSGS)) : \ BUILD_SAFETY_CFG(subaru_lkas_angle_rx_checks, SUBARU_LKAS_ANGLE_TX_MSGS); } else if (subaru_gen2) { diff --git a/opendbc_repo/opendbc/safety/modes/volvo.h b/opendbc_repo/opendbc/safety/modes/volvo.h index 24b9ac4de..e1a11676a 100644 --- a/opendbc_repo/opendbc/safety/modes/volvo.h +++ b/opendbc_repo/opendbc/safety/modes/volvo.h @@ -43,7 +43,6 @@ #define VOLVO_ANGLE_DEG_TO_CAN 17.869907f #define VOLVO_MAX_ANGLE_CAN 9650 #define VOLVO_RELAY_ANGLE_TOLERANCE 54 // approximately 3 degrees -#define VOLVO_DRIVER_OVERRIDE 5 // CAN bus definitions for Volvo @@ -83,8 +82,6 @@ static const AngleSteeringLimits VOLVO_ANGLE_STEERING_LIMITS = { }; static void volvo_rx_hook(const CANPacket_t *msg) { - // Monitor the vehicle state required for cruise, disengagement, and angle - // safety. All steering TX frames are separately constrained in volvo_tx_hook. // Main bus (bus 0) messages if (msg->bus == VOLVO_MAIN_BUS) { @@ -148,13 +145,11 @@ static void volvo_rx_hook(const CANPacket_t *msg) { // DRIVER_INPUT is the signal consumed by carstate.py for driver torque. // The PSCM frame's DRIVER_INPUT_DEVIATION is a different signal and must - // not be substituted here: doing so leaves the hardware disengage path blind. if (msg->addr == VOLVO_DRIVER_INPUT) { // STEERING_DRIVER_INPUT is a Motorola signal starting at bit 55. The // DBC also carries a +1 offset, so its raw byte is data[6]. const int driver_input = to_signed(msg->data[6], 8) + 1; update_sample(&torque_driver, driver_input); - steering_disengage = SAFETY_ABS(driver_input) > VOLVO_DRIVER_OVERRIDE; } } diff --git a/opendbc_repo/opendbc/safety/tests/test_subaru.py b/opendbc_repo/opendbc/safety/tests/test_subaru.py index ebd7b1bf7..826c1d3c2 100755 --- a/opendbc_repo/opendbc/safety/tests/test_subaru.py +++ b/opendbc_repo/opendbc/safety/tests/test_subaru.py @@ -37,7 +37,6 @@ class SubaruMsg(enum.IntEnum): ES_STATIC_1 = 0x22a ES_STATIC_2 = 0x325 Dashlights = 0x390 - AVH = 0x32b SUBARU_MAIN_BUS = 0 @@ -386,20 +385,6 @@ class TestSubaruGen2FixedAngleStopStartSafety(TestSubaruGen2FixedAngleSafety): self.assertFalse(self._tx(self._stop_start_msg(False))) -class TestSubaruGen2FixedAngleStopStartAvhSafety(TestSubaruGen2FixedAngleStopStartSafety): - FLAGS = TestSubaruGen2FixedAngleStopStartSafety.FLAGS | SubaruSafetyFlags.AVH_BUTTON - TX_MSGS = TestSubaruGen2FixedAngleStopStartSafety.TX_MSGS + [[SubaruMsg.AVH, SUBARU_ALT_BUS]] - - def _avh_msg(self, pressed): - return self.packer.make_can_msg_safety( - "AVH", SUBARU_ALT_BUS, {"COUNTER": 0, "AVH": pressed}, - ) - - def test_avh_tx_requires_pressed_bit(self): - self.assertTrue(self._tx(self._avh_msg(True))) - self.assertFalse(self._tx(self._avh_msg(False))) - - class TestSubaruDPlatformAngleSafety(TestSubaruStockLongitudinalSafetyBase, TestSubaruAngleSafetyBase): FLAGS = SubaruSafetyFlags.GEN2 | SubaruSafetyFlags.LKAS_ANGLE | SubaruSafetyFlags.D_PLATFORM ALT_MAIN_BUS = SUBARU_ALT_BUS diff --git a/opendbc_repo/opendbc/safety/tests/test_volvo.py b/opendbc_repo/opendbc/safety/tests/test_volvo.py index 810211752..43b9ae192 100644 --- a/opendbc_repo/opendbc/safety/tests/test_volvo.py +++ b/opendbc_repo/opendbc/safety/tests/test_volvo.py @@ -211,21 +211,17 @@ class TestVolvoSafetyBase(common.CarSafetyTest): self.assertTrue(self._tx(valid)) self.assertFalse(self._tx(invalid)) - def test_driver_override_disengages_controls(self): + def test_driver_input_is_a_normal_override(self): def driver_input_msg(value): return self.mid_packer.make_can_msg_safety( "DRIVER_INPUT", VOLVO_PARTY_BUS, {"STEERING_DRIVER_INPUT": value}) - for value in (2, 3, 5): + for value in (2, 3, 5, 6, 20, -20): self._rx(driver_input_msg(0)) self.safety.set_controls_allowed(True) self._rx(driver_input_msg(value)) - self.assertTrue(self.safety.get_controls_allowed(), f"unexpected disengage at {value=}") - - self._rx(driver_input_msg(0)) - self.safety.set_controls_allowed(True) - self._rx(driver_input_msg(6)) - self.assertFalse(self.safety.get_controls_allowed()) + self.assertTrue(self.safety.get_controls_allowed(), f"unexpected safety disengage at {value=}") + self.assertFalse(self.safety.get_steering_disengage_prev()) # ---- Volvo-specific consistency tests ---- diff --git a/scripts/model_lab_chestnut_batch.py b/scripts/model_lab_chestnut_batch.py new file mode 100644 index 000000000..38bdb9ddc --- /dev/null +++ b/scripts/model_lab_chestnut_batch.py @@ -0,0 +1,590 @@ +#!/usr/bin/env python3 +"""Build and publish Chestnut/AMD variants for every small manifest model. + +The queue is intentionally sequential. It keeps only one ONNX source and one +compiler output on the comma, copies each verified artifact back to the host, +uploads it, then publishes a freshly merged manifest. The state file makes an +interrupted run resumable without rebuilding completed models. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +import time +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPTS_DIR = REPO_ROOT / "scripts" +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from model_compiler import detect_component +from model_rebuild_pipeline import ensure_workspace, extract_model, find_model_paths, ensure_git_ref + + +DEFAULT_REMOTE = os.environ.get("STAR_PILOT_MODEL_REMOTE", "comma@192.168.3.110") +DEFAULT_BUCKET = os.environ.get("STARPILOT_HF_BUCKET", "StarPilot-Driving/StarPilot-Resources") +DEFAULT_ARTIFACT_DIR = Path.home() / "StarPilot-Model-Lab-Artifacts" / "v25" +DEFAULT_MANIFEST = DEFAULT_ARTIFACT_DIR / "model_names_v25.json" +DEFAULT_SOURCE_MAP = SCRIPTS_DIR / "model_source_map_v25.json" +DEFAULT_OPENPILOT = Path.home() / "openpilot" +REMOTE_ROOT = "/data/openpilot" +SSH_OPTIONS = ( + "-o", "ConnectTimeout=10", + "-o", "ConnectionAttempts=1", + "-o", "ServerAliveInterval=30", + "-o", "ServerAliveCountMax=600", +) +RSYNC_SSH = "ssh -o ConnectTimeout=10 -o ConnectionAttempts=1 -o ServerAliveInterval=30 -o ServerAliveCountMax=600" +SAFE_MODEL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +COMPONENT_FILENAMES = { + "driving_supercombo": "driving_supercombo.onnx", + "driving_vision": "driving_vision.onnx", + "driving_policy": "driving_policy.onnx", + "driving_on_policy": "driving_on_policy.onnx", + "driving_off_policy": "driving_off_policy.onnx", +} + + +def utc_now() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def load_json(path: Path): + return json.loads(path.read_text(encoding="utf-8")) + + +def write_json(path: Path, payload: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + temporary.replace(path) + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def run(command: list[str], *, capture: bool = False, check: bool = True, timeout: int | None = None, + stdout=None, stderr=None) -> subprocess.CompletedProcess: + return subprocess.run( + command, + text=capture, + capture_output=capture, + check=check, + timeout=timeout, + stdout=stdout, + stderr=stderr, + ) + + +def validate_model_id(model_id: str) -> str: + if not SAFE_MODEL_ID.fullmatch(model_id): + raise ValueError(f"Unsafe model ID: {model_id!r}") + return model_id + + +class Batch: + def __init__(self, args: argparse.Namespace): + self.args = args + self.hf = shutil.which("hf") + if not self.hf: + raise FileNotFoundError("Hugging Face CLI (hf) is not installed") + self.artifact_dir = args.artifact_dir.expanduser().resolve() + self.workspace = self.artifact_dir / "batch" + self.sources_workspace = self.workspace / "source-workspace" + self.sources_dir = self.sources_workspace / "onnx" + self.logs_dir = self.workspace / "logs" + self.results_dir = self.workspace / "results" + self.state_path = self.results_dir / "chestnut_batch_state.json" + self.manifest_path = args.manifest.expanduser().resolve() + self.source_map = load_json(args.source_map.expanduser().resolve()) + self.manifest = load_json(self.manifest_path) + self.models = self.manifest.get("models", self.manifest) + if not isinstance(self.models, list): + raise ValueError("Manifest must contain a models list") + self.models_by_id = {str(model.get("id") or ""): model for model in self.models} + ensure_workspace(self.sources_workspace) + self.logs_dir.mkdir(parents=True, exist_ok=True) + self.results_dir.mkdir(parents=True, exist_ok=True) + self.state = self._load_state() + self.inventory = self._load_inventory() + + @property + def bucket_root(self) -> str: + return f"hf://buckets/{self.args.bucket}" + + def _load_state(self) -> dict: + if self.state_path.is_file(): + state = load_json(self.state_path) + state.setdefault("models", {}) + state["resumed_at"] = utc_now() + return state + return { + "remote": self.args.remote, + "bucket": self.args.bucket, + "started_at": utc_now(), + "models": {}, + } + + def save_state(self) -> None: + self.state["updated_at"] = utc_now() + write_json(self.state_path, self.state) + + def _load_inventory(self) -> dict[str, list[dict]]: + result = run( + [self.hf, "buckets", "ls", "-R", f"{self.bucket_root}/onnx/", "--format", "json"], + capture=True, + ) + entries = json.loads(result.stdout) + inventory: dict[str, list[dict]] = {} + for entry in entries: + path = str(entry.get("path") or "") + parts = Path(path).parts + if len(parts) == 3 and parts[0] == "onnx" and path.endswith(".onnx"): + inventory.setdefault(parts[1], []).append(entry) + return inventory + + def selected_models(self) -> list[dict]: + requested = {validate_model_id(value) for value in self.args.ids.split(",") if value} if self.args.ids else set() + selected = [model for model in self.models if not bool(model.get("uses_external_gpu", False))] + if requested: + unknown = requested - self.models_by_id.keys() + if unknown: + raise ValueError(f"Unknown manifest model IDs: {', '.join(sorted(unknown))}") + selected = [model for model in selected if model["id"] in requested] + if self.args.limit: + selected = selected[:self.args.limit] + return selected + + def source_plan(self, model: dict) -> dict: + model_id = validate_model_id(model["id"]) + source = self.source_map.get(model_id) + if not isinstance(source, dict): + raise KeyError(f"No source mapping for small model {model_id}") + source_id = validate_model_id(str(source.get("source_id") or model_id)) + archived = self.inventory.get(source_id, []) + if archived: + components = [detect_component(Path(entry["path"])) for entry in archived] + if None in components or len(set(components)) != len(components): + raise ValueError(f"Ambiguous archived ONNX components for {model_id} ({source_id})") + self._validate_components(model_id, source["input_format"], set(components)) + signature_payload = { + "input_format": source["input_format"], + "version": str(model.get("version") or ""), + "files": sorted( + (detect_component(Path(entry["path"])), str(entry.get("xet_hash") or ""), int(entry.get("size") or 0)) + for entry in archived + ), + } + signature = hashlib.sha256(json.dumps(signature_payload, sort_keys=True).encode()).hexdigest() + return {"kind": "archive", "source_id": source_id, "files": archived, "signature": signature, **source} + + repo = self.args.openpilot.expanduser().resolve() + ensure_git_ref(repo, source["ref"]) + paths = find_model_paths(repo, source["ref"], source["input_format"], False) + components = {detect_component(Path(path)) for path in paths} + self._validate_components(model_id, source["input_format"], components) + signature = hashlib.sha256( + f"git:{source['ref']}:{source['input_format']}:{model.get('version', '')}".encode() + ).hexdigest() + return {"kind": "git", "source_id": source_id, "files": paths, "signature": signature, **source} + + @staticmethod + def _validate_components(model_id: str, input_format: str, components: set[str | None]) -> None: + if input_format == "supercombo" and components != {"driving_supercombo"}: + raise ValueError(f"{model_id} needs one supercombo source, found {sorted(str(c) for c in components)}") + if input_format == "split" and ( + "driving_vision" not in components or not {"driving_policy", "driving_on_policy"} & components + ): + raise ValueError(f"{model_id} has incomplete split sources: {sorted(str(c) for c in components)}") + + def audit(self) -> dict: + selected = self.selected_models() + report = {"total": len(selected), "archive": [], "git": [], "failures": {}} + for model in selected: + model_id = model["id"] + try: + plan = self.source_plan(model) + report[plan["kind"]].append(model_id) + except Exception as error: + report["failures"][model_id] = str(error) + report["ready"] = report["total"] - len(report["failures"]) + print(json.dumps(report, indent=2), flush=True) + return report + + def remote(self, command: str, *, capture: bool = False, check: bool = True, + timeout: int | None = None, stdout=None, stderr=None) -> subprocess.CompletedProcess: + return run( + ["ssh", *SSH_OPTIONS, self.args.remote, command], + capture=capture, + check=check, + timeout=timeout, + stdout=stdout, + stderr=stderr, + ) + + def hardware_preflight(self) -> None: + command = ( + f"set -eu; cd {shlex.quote(REMOTE_ROOT)}; " + "test \"$(cat /data/params/d/IsOffroad 2>/dev/null)\" = 1; " + "/usr/local/venv/bin/python3 -c " + + shlex.quote("from openpilot.system.hardware.chestnut.flash import link_up; raise SystemExit(0 if link_up() else 1)") + + "; test -x /data/openpilot/models" + ) + result = self.remote(command, capture=True, check=False, timeout=20) + if result.returncode: + raise RuntimeError("Comma must be reachable, offroad, and connected to an active Chestnut PCIe link") + + def active_remote_compiles(self) -> list[str]: + result = self.remote("pgrep -af '[c]ompile_modeld.py' || true", capture=True, check=False, timeout=20) + if result.returncode and not result.stdout: + raise RuntimeError(f"Could not inspect remote compiler: {result.stderr.strip()}") + return [line for line in result.stdout.splitlines() if line.strip()] + + def wait_for_remote_idle(self) -> None: + active = self.active_remote_compiles() + while active: + print(f"REMOTE_BUSY processes={len(active)}", flush=True) + time.sleep(30) + active = self.active_remote_compiles() + + def _source_dir(self, model_id: str) -> Path: + return self.sources_dir / validate_model_id(model_id) + + def prepare_source(self, model: dict, plan: dict) -> Path: + model_id = model["id"] + source_dir = self._source_dir(model_id) + if source_dir.is_dir(): + shutil.rmtree(source_dir) + source_dir.mkdir(parents=True) + if plan["kind"] == "archive": + for entry in plan["files"]: + component = detect_component(Path(entry["path"])) + if component is None: + raise ValueError(f"Unknown source component: {entry['path']}") + destination = source_dir / f"{model_id}_{COMPONENT_FILENAMES[component]}" + run([ + self.hf, "buckets", "cp", + f"{self.bucket_root}/{entry['path']}", str(destination), "--format", "quiet", + ]) + expected_size = int(entry.get("size") or 0) + if expected_size and destination.stat().st_size != expected_size: + raise ValueError(f"Downloaded source size mismatch for {destination.name}") + else: + extract_model(model_id, self.source_map[model_id], self.args.openpilot.expanduser().resolve(), self.sources_workspace) + for path in sorted(source_dir.glob("*.onnx")): + component = detect_component(path) + if component is None: + raise ValueError(f"Unknown extracted source component: {path.name}") + archive_name = f"{plan['source_id']}_{COMPONENT_FILENAMES[component]}" + destination = f"{self.bucket_root}/onnx/{plan['source_id']}/{archive_name}" + run([self.hf, "buckets", "cp", str(path), destination, "--format", "quiet"]) + print(f"SOURCE_ARCHIVED id={model_id} source_id={plan['source_id']}", flush=True) + return source_dir + + def remote_paths(self, model_id: str) -> tuple[str, str]: + validate_model_id(model_id) + return ( + f"{REMOTE_ROOT}/uncompiledmodels/{model_id}", + f"{REMOTE_ROOT}/compiledmodels/{model_id}_driving_tinygrad.pkl", + ) + + def cleanup_remote(self, model_id: str, *, source: bool = True, output: bool = True) -> None: + remote_source, remote_output = self.remote_paths(model_id) + targets = [] + if source: + targets.append(shlex.quote(remote_source)) + if output: + targets.append(shlex.quote(remote_output)) + if targets: + self.remote("rm -rf -- " + " ".join(targets), check=False, timeout=30) + + def stage_source(self, model_id: str, source_dir: Path) -> None: + remote_source, _ = self.remote_paths(model_id) + self.cleanup_remote(model_id) + self.remote(f"mkdir -p {shlex.quote(remote_source)} {shlex.quote(REMOTE_ROOT + '/compiledmodels')}") + run([ + "rsync", "-az", "-e", RSYNC_SSH, "--exclude=._*", + f"{source_dir}/", f"{self.args.remote}:{remote_source}/", + ]) + + def compile(self, model: dict, plan: dict) -> Path: + model_id = model["id"] + remote_source, remote_output = self.remote_paths(model_id) + command = " ".join([ + f"cd {shlex.quote(REMOTE_ROOT)} && ./models", + "--model", shlex.quote(model_id), + "--input-dir", shlex.quote(remote_source), + "--output-dir", shlex.quote(REMOTE_ROOT + "/compiledmodels"), + "--input-format", shlex.quote(plan["input_format"]), + "--version", shlex.quote(str(model.get("version") or "")), + "--gpu", "--no-split", + ]) + log_path = self.logs_dir / f"{model_id}.log" + print(f"COMPILE_START id={model_id} source={plan['kind']} version={model.get('version', '')}", flush=True) + started = time.monotonic() + with log_path.open("ab") as log: + log.write(f"\n=== START {utc_now()} ===\n".encode()) + result = self.remote(command, check=False, stdout=log, stderr=subprocess.STDOUT) + if result.returncode: + self.wait_for_remote_idle() + if not self.remote_file_exists(remote_output): + raise RuntimeError(f"Chestnut compilation failed; see {log_path}") + elapsed = time.monotonic() - started + print(f"COMPILE_DONE id={model_id} seconds={elapsed:.1f}", flush=True) + return self.pull_artifact(model_id) + + def remote_file_exists(self, path: str) -> bool: + result = self.remote(f"test -f {shlex.quote(path)}", check=False, timeout=20) + return result.returncode == 0 + + def pull_artifact(self, model_id: str) -> Path: + _, remote_output = self.remote_paths(model_id) + destination = self.artifact_dir / f"{model_id}_driving_chestnut_tinygrad.pkl" + incoming = destination.with_suffix(destination.suffix + ".incoming") + incoming.unlink(missing_ok=True) + run(["rsync", "-az", "-e", RSYNC_SSH, f"{self.args.remote}:{remote_output}", str(incoming)]) + if not incoming.is_file() or incoming.stat().st_size == 0: + raise FileNotFoundError(f"No compiler output for {model_id}") + incoming.replace(destination) + destination.chmod(0o644) + return destination + + def artifact_metadata(self, model_id: str, artifact: Path) -> dict: + expected_name = f"{model_id}_driving_chestnut_tinygrad.pkl" + if artifact.name != expected_name or not artifact.is_file(): + raise ValueError(f"Invalid local Chestnut artifact path for {model_id}: {artifact}") + return { + "artifact_format": "tinygrad_single_v1", + "artifact_filename": artifact.name, + "artifact_size": artifact.stat().st_size, + "artifact_sha256": sha256_file(artifact), + "artifact_chunk_count": 0, + "execution_device": "AMD", + } + + def upload_artifact(self, model_id: str, artifact: Path) -> None: + destination = f"{self.bucket_root}/models/v25/{model_id}/{artifact.name}" + run([self.hf, "buckets", "cp", str(artifact), destination, "--format", "quiet"]) + listing = run([self.hf, "buckets", "ls", "-R", destination, "--format", "json"], capture=True) + entries = json.loads(listing.stdout) + if len(entries) != 1 or int(entries[0].get("size") or 0) != artifact.stat().st_size: + raise RuntimeError(f"Uploaded artifact verification failed for {model_id}") + + def completed_metadata(self) -> dict[str, dict]: + completed = {} + for model_id, record in self.state.get("models", {}).items(): + if record.get("status") == "published" and isinstance(record.get("artifact"), dict): + completed[model_id] = record["artifact"] + return completed + + def publish_manifest(self) -> None: + incoming = self.workspace / "live_manifest.json" + run([ + self.hf, "buckets", "cp", + f"{self.bucket_root}/manifests/model_names_v25.json", str(incoming), "--format", "quiet", + ]) + payload = load_json(incoming) + models = payload.get("models", payload) + completed = self.completed_metadata() + for model in models: + if bool(model.get("uses_external_gpu", False)): + continue + model["model_size"] = "small" + model["model_lab_eligible"] = True + if model["id"] in completed: + artifacts = model.get("accelerator_artifacts") + if not isinstance(artifacts, dict): + artifacts = {} + artifacts["chestnut"] = completed[model["id"]] + model["accelerator_artifacts"] = artifacts + write_json(self.manifest_path, payload if isinstance(payload, dict) else {"models": models}) + run([ + self.hf, "buckets", "cp", str(self.manifest_path), + f"{self.bucket_root}/manifests/model_names_v25.json", "--format", "quiet", + ]) + print(f"MANIFEST_PUBLISHED completed={len(completed)}", flush=True) + + def valid_existing_artifact(self, model_id: str) -> tuple[Path, dict] | None: + path = self.artifact_dir / f"{model_id}_driving_chestnut_tinygrad.pkl" + if not path.is_file(): + return None + metadata = self.artifact_metadata(model_id, path) + manifest_artifact = ( + self.models_by_id[model_id].get("accelerator_artifacts", {}).get("chestnut", {}) + if isinstance(self.models_by_id[model_id].get("accelerator_artifacts"), dict) else {} + ) + if (manifest_artifact.get("artifact_size") == metadata["artifact_size"] + and manifest_artifact.get("artifact_sha256") == metadata["artifact_sha256"]): + return path, metadata + state_artifact = self.state.get("models", {}).get(model_id, {}).get("artifact", {}) + if (state_artifact.get("artifact_size") == metadata["artifact_size"] + and state_artifact.get("artifact_sha256") == metadata["artifact_sha256"]): + return path, metadata + return None + + @staticmethod + def source_record(plan: dict) -> dict: + return { + "kind": plan["kind"], + "source_id": plan["source_id"], + "ref": plan["ref"], + "signature": plan["signature"], + } + + def equivalent_artifact(self, model_id: str, plan: dict) -> tuple[str, Path] | None: + """Find a completed artifact built from byte-identical ONNXs and ABI.""" + for candidate_id, record in self.state.get("models", {}).items(): + if candidate_id == model_id or record.get("status") != "published": + continue + candidate_signature = record.get("source", {}).get("signature") + if not candidate_signature and candidate_id in self.models_by_id: + try: + candidate_signature = self.source_plan(self.models_by_id[candidate_id])["signature"] + except Exception: + continue + candidate_path = self.artifact_dir / f"{candidate_id}_driving_chestnut_tinygrad.pkl" + if candidate_signature == plan["signature"] and candidate_path.is_file(): + return candidate_id, candidate_path + return None + + def process_model(self, model: dict) -> None: + model_id = model["id"] + plan = self.source_plan(model) + existing = self.valid_existing_artifact(model_id) + if existing: + artifact, metadata = existing + if self.state.get("models", {}).get(model_id, {}).get("status") != "published": + self.upload_artifact(model_id, artifact) + self.state["models"][model_id] = { + "status": "published", + "source": self.source_record(plan), + "artifact": metadata, + "completed_at": utc_now(), + } + self.save_state() + print(f"SKIP_VERIFIED id={model_id} bytes={metadata['artifact_size']}", flush=True) + return + + equivalent = self.equivalent_artifact(model_id, plan) + if equivalent: + source_model_id, source_artifact = equivalent + artifact = self.artifact_dir / f"{model_id}_driving_chestnut_tinygrad.pkl" + shutil.copy2(source_artifact, artifact) + metadata = self.artifact_metadata(model_id, artifact) + self.upload_artifact(model_id, artifact) + self.state["models"][model_id] = { + "status": "published", + "source": self.source_record(plan), + "derived_from": source_model_id, + "artifact": metadata, + "completed_at": utc_now(), + } + self.save_state() + print(f"PUBLISHED_DEDUP id={model_id} identical_to={source_model_id} bytes={metadata['artifact_size']}", flush=True) + return + + self.hardware_preflight() + self.wait_for_remote_idle() + source_dir = self.prepare_source(model, plan) + try: + self.stage_source(model_id, source_dir) + artifact = self.compile(model, plan) + metadata = self.artifact_metadata(model_id, artifact) + self.upload_artifact(model_id, artifact) + self.state["models"][model_id] = { + "status": "published", + "source": self.source_record(plan), + "artifact": metadata, + "completed_at": utc_now(), + } + self.save_state() + print(f"PUBLISHED id={model_id} bytes={metadata['artifact_size']} sha256={metadata['artifact_sha256']}", flush=True) + finally: + self.cleanup_remote(model_id) + if source_dir.is_dir(): + shutil.rmtree(source_dir) + + def run_queue(self) -> int: + selected = self.selected_models() + audit = self.audit() + if audit["failures"]: + raise RuntimeError(f"Source audit failed for {len(audit['failures'])} small models") + if self.args.dry_run: + return 0 + + failures = 0 + for index, model in enumerate(selected, 1): + model_id = model["id"] + print(f"QUEUE index={index}/{len(selected)} id={model_id}", flush=True) + try: + self.process_model(model) + except Exception as error: + failures += 1 + self.state["models"][model_id] = { + "status": "failed", + "error": str(error), + "failed_at": utc_now(), + } + self.save_state() + print(f"FAILED id={model_id} error={error}", file=sys.stderr, flush=True) + if self.args.stop_on_failure: + break + continue + if not self.args.no_publish: + try: + self.publish_manifest() + except Exception as error: + failures += 1 + self.state["manifest_error"] = {"error": str(error), "at": utc_now(), "after_model": model_id} + self.save_state() + print(f"MANIFEST_FAILED after={model_id} error={error}", file=sys.stderr, flush=True) + if self.args.stop_on_failure: + break + self.state["finished_at"] = utc_now() + self.state["failures"] = failures + self.save_state() + return 1 if failures else 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("audit", "run"), nargs="?", default="run") + parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + parser.add_argument("--source-map", type=Path, default=DEFAULT_SOURCE_MAP) + parser.add_argument("--artifact-dir", type=Path, default=DEFAULT_ARTIFACT_DIR) + parser.add_argument("--openpilot", type=Path, default=DEFAULT_OPENPILOT) + parser.add_argument("--remote", default=DEFAULT_REMOTE) + parser.add_argument("--bucket", default=DEFAULT_BUCKET) + parser.add_argument("--ids", default="", help="Optional comma-separated manifest model IDs") + parser.add_argument("--limit", type=int, default=0) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--no-publish", action="store_true") + parser.add_argument("--stop-on-failure", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + batch = Batch(args) + if args.command == "audit": + return 1 if batch.audit()["failures"] else 0 + return batch.run_queue() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/model_source_map_v25.json b/scripts/model_source_map_v25.json index c9fb50389..d39669344 100644 --- a/scripts/model_source_map_v25.json +++ b/scripts/model_source_map_v25.json @@ -359,6 +359,11 @@ "input_format": "supercombo", "source_id": "deeprl3v2" }, + "deeprl3v2": { + "ref": "702fa71ad4dd8de08425eb11a1a42aaeb64892c9", + "input_format": "supercombo", + "source_id": "deeprl3v2" + }, "rh3": { "ref": "93f5aa469a72b7621aef7da7901c100e0113e4d9", "input_format": "supercombo", @@ -379,6 +384,26 @@ "input_format": "supercombo", "source_id": "rdf2" }, + "rdf33": { + "ref": "ea2151ba4b82854277f37f03b949f15fe2733dc8", + "input_format": "supercombo", + "source_id": "rdf3" + }, + "rdf43": { + "ref": "a5a6412d08474cffb49a69afb910756afdee123e", + "input_format": "supercombo", + "source_id": "rdf4" + }, + "rdf53": { + "ref": "7fb03ca474f03e95e59ec0c8a6c5fba831bd5fd1", + "input_format": "supercombo", + "source_id": "rdf5" + }, + "rdf63": { + "ref": "35703097905a122c9f3ddf0d12889b4873d7e2a2", + "input_format": "supercombo", + "source_id": "rdf6" + }, "tsf": { "ref": "4d911346cde4e0d2978a625f31679808284cc19d", "input_format": "supercombo", @@ -426,4 +451,3 @@ "source_id": "bmrlnapv6" } } - diff --git a/selfdrive/car/cruise.py b/selfdrive/car/cruise.py index b929d5e8b..0e8a06a31 100644 --- a/selfdrive/car/cruise.py +++ b/selfdrive/car/cruise.py @@ -71,6 +71,10 @@ class VCruiseHelper: long_interval = self._get_cruise_delta_interval(getattr(starpilot_toggles, "cruise_increase_long", None)) return short_interval, long_interval + def _uses_software_cruise(self) -> bool: + return bool(self.gm_cc_only or self.redneck_non_pcm or + not self.CP.pcmCruise or getattr(self.CP, "openpilotLongitudinalControl", False)) + @property def v_cruise_initialized(self): return self.v_cruise_kph != V_CRUISE_UNSET @@ -90,7 +94,7 @@ class VCruiseHelper: self.v_cruise_kph_last = self.v_cruise_kph if CS.cruiseState.available: - if self.gm_cc_only or self.redneck_non_pcm or not self.CP.pcmCruise: + if self._uses_software_cruise(): # if stock cruise is completely disabled, then we can use our own set speed logic self._update_v_cruise_non_pcm(CS, enabled, is_metric, speed_limit_changed, starpilot_toggles, starpilot_car_state, slc_target_with_offset) @@ -206,8 +210,7 @@ class VCruiseHelper: def initialize_v_cruise(self, CS, experimental_mode: bool, resume_prev_button: bool, starpilot_toggles: SimpleNamespace, desired_speed_limit: float = 0.0) -> None: - # initializing is handled by the PCM - if self.CP.pcmCruise and not (self.gm_cc_only or self.redneck_non_pcm): + if self.CP.pcmCruise and not self._uses_software_cruise(): return engage_floor_kph = max(V_CRUISE_MIN, 7.0 * CV.MPH_TO_KPH) diff --git a/selfdrive/car/tests/test_cruise_speed.py b/selfdrive/car/tests/test_cruise_speed.py index eec2b5b45..90944c2fb 100644 --- a/selfdrive/car/tests/test_cruise_speed.py +++ b/selfdrive/car/tests/test_cruise_speed.py @@ -482,6 +482,50 @@ class TestVCruiseHelper: assert self.v_cruise_helper.v_cruise_kph == pytest.approx(initial_v_cruise_kph + IMPERIAL_INCREMENT) + def test_openpilot_longitudinal_pcm_cruise_uses_custom_intervals(self): + CP = car.CarParams(pcmCruise=True, openpilotLongitudinalControl=True) + helper = VCruiseHelper(CP) + toggles = SimpleNamespace( + cruise_increase=5, + cruise_increase_long=1, + is_metric=True, + set_speed_limit=False, + ) + + helper.initialize_v_cruise(car.CarState(vEgo=40 * CV.KPH_TO_MS), False, False, toggles) + initial_v_cruise_kph = helper.v_cruise_kph + + pressed_cs = car.CarState(cruiseState={"available": True}) + pressed_cs.buttonEvents = [ButtonEvent(type=ButtonType.accelCruise, pressed=True)] + helper.update_v_cruise(pressed_cs, True, True, False, toggles) + + released_cs = car.CarState(cruiseState={"available": True}) + released_cs.buttonEvents = [ButtonEvent(type=ButtonType.accelCruise, pressed=False)] + helper.update_v_cruise(released_cs, True, True, False, toggles) + assert helper.v_cruise_kph == pytest.approx(initial_v_cruise_kph + 5) + + pressed_cs.buttonEvents = [ButtonEvent(type=ButtonType.accelCruise, pressed=True)] + helper.update_v_cruise(pressed_cs, True, True, False, toggles) + for _ in range(50): + helper.update_v_cruise(car.CarState(cruiseState={"available": True}), True, True, False, toggles) + assert helper.v_cruise_kph == pytest.approx(initial_v_cruise_kph + 6) + + def test_stock_pcm_cruise_still_uses_pcm_speed(self): + CP = car.CarParams(pcmCruise=True, openpilotLongitudinalControl=False) + helper = VCruiseHelper(CP) + toggles = SimpleNamespace(cruise_increase=5, cruise_increase_long=1) + pcm_speed_kph = 72.0 + cs = car.CarState( + cruiseState={ + "available": True, + "speed": pcm_speed_kph * CV.KPH_TO_MS, + "speedCluster": pcm_speed_kph * CV.KPH_TO_MS, + }, + ) + + helper.update_v_cruise(cs, True, True, False, toggles) + assert helper.v_cruise_kph == pytest.approx(pcm_speed_kph) + class TestVCruiseHelperRedneck: def setup_method(self): diff --git a/selfdrive/controls/lib/latcontrol_torque.py b/selfdrive/controls/lib/latcontrol_torque.py index 799d7fe6f..d9299a490 100644 --- a/selfdrive/controls/lib/latcontrol_torque.py +++ b/selfdrive/controls/lib/latcontrol_torque.py @@ -519,7 +519,9 @@ class LatControlTorque(LatControl): CS.vEgo, setpoint, prius_deadzone_max, ) elif genesis_g70_active: - vehicle_friction_jerk_deadzone = get_genesis_g70_friction_jerk_deadzone(CS.vEgo, setpoint) + vehicle_friction_jerk_deadzone = get_genesis_g70_friction_jerk_deadzone( + CS.vEgo, setpoint, desired_lateral_jerk, + ) elif self.is_genesis_gv70: vehicle_friction_jerk_deadzone = get_genesis_gv70_friction_jerk_deadzone(CS.vEgo, setpoint) elif kia_carnival_active: @@ -652,6 +654,9 @@ class LatControlTorque(LatControl): output_torque *= get_genesis_gv70_high_speed_error_scale( setpoint, measurement, desired_lateral_jerk, CS.vEgo, ) + output_torque *= get_genesis_gv70_reversal_output_scale( + setpoint, measurement, desired_lateral_jerk, CS.vEgo, + ) elif sonata_hybrid_active: output_torque *= sonata_hybrid_center_taper output_torque *= sonata_hybrid_center_output_taper diff --git a/selfdrive/controls/lib/latcontrol_vehicle_tunes.py b/selfdrive/controls/lib/latcontrol_vehicle_tunes.py index 05c1093b9..2beaef92c 100644 --- a/selfdrive/controls/lib/latcontrol_vehicle_tunes.py +++ b/selfdrive/controls/lib/latcontrol_vehicle_tunes.py @@ -243,6 +243,13 @@ GENESIS_GV70_HIGH_SPEED_ERROR_DAMPING_ERROR = 0.18 GENESIS_GV70_HIGH_SPEED_ERROR_DAMPING_ERROR_WIDTH = 0.15 GENESIS_GV70_HIGH_SPEED_ERROR_DAMPING_JERK = 0.15 GENESIS_GV70_HIGH_SPEED_ERROR_DAMPING_JERK_WIDTH = 0.10 +GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_MAX = 0.28 +GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_SPEED = 25.0 * CV.MPH_TO_MS +GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_SPEED_WIDTH = 5.0 * CV.MPH_TO_MS +GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_ERROR = 0.30 +GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_ERROR_WIDTH = 0.16 +GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_JERK = 0.20 +GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_JERK_WIDTH = 0.10 GENESIS_GV70_LOW_SPEED_CENTER_OVERSHOOT_MAX = 0.28 GENESIS_GV70_LOW_SPEED_CENTER_OVERSHOOT_SPEED = 18.0 * CV.MPH_TO_MS GENESIS_GV70_LOW_SPEED_CENTER_OVERSHOOT_SPEED_WIDTH = 3.5 * CV.MPH_TO_MS @@ -268,6 +275,15 @@ GENESIS_G70_FRICTION_JERK_DEADZONE_LAT = 0.30 GENESIS_G70_FRICTION_JERK_DEADZONE_LAT_WIDTH = 0.08 GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED = 12.0 GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED_WIDTH = 3.5 +GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_MAX = 0.16 +GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_SPEED = 35.0 * CV.MPH_TO_MS +GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_SPEED_WIDTH = 8.0 * CV.MPH_TO_MS +GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT = 0.35 +GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_WIDTH = 0.15 +GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF = 1.25 +GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF_WIDTH = 0.25 +GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_JERK = 0.20 +GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_JERK_WIDTH = 0.12 GENESIS_G70_CENTER_OUTPUT_TAPER_MAX = 0.22 GENESIS_G70_CENTER_OUTPUT_TAPER_LAT = 0.30 GENESIS_G70_CENTER_OUTPUT_TAPER_LAT_WIDTH = 0.10 @@ -655,8 +671,8 @@ PALISADE_FF_CUTOFF = 1.25 PALISADE_FF_CUTOFF_WIDTH = 0.36 PALISADE_TRANSITION_SPEED = 9.0 PALISADE_PHASE_SCALE = 0.11 -PALISADE_TURN_IN_BOOST_LEFT = 0.34 -PALISADE_TURN_IN_BOOST_RIGHT = 0.24 +PALISADE_TURN_IN_BOOST_LEFT = 0.44 +PALISADE_TURN_IN_BOOST_RIGHT = 0.34 PALISADE_UNWIND_TAPER_LEFT = 0.18 PALISADE_UNWIND_TAPER_RIGHT = 0.30 PALISADE_FRICTION_MULT = 1.02 @@ -675,11 +691,11 @@ PALISADE_CENTER_TAPER_LAT = 0.28 PALISADE_CENTER_TAPER_LAT_WIDTH = 0.055 PALISADE_CENTER_TAPER_SPEED = 12.0 PALISADE_CENTER_TAPER_SPEED_WIDTH = 2.5 -PALISADE_CENTER_OUTPUT_TAPER_MAX = 0.10 +PALISADE_CENTER_OUTPUT_TAPER_MAX = 0.12 PALISADE_CENTER_OUTPUT_TAPER_LAT = 0.28 PALISADE_CENTER_OUTPUT_TAPER_LAT_WIDTH = 0.055 -PALISADE_CENTER_OUTPUT_TAPER_SPEED = 18.0 -PALISADE_CENTER_OUTPUT_TAPER_SPEED_WIDTH = 2.5 +PALISADE_CENTER_OUTPUT_TAPER_SPEED = 15.0 +PALISADE_CENTER_OUTPUT_TAPER_SPEED_WIDTH = 3.0 GENESIS_G90_LATERAL_TESTING_GROUND_ID = testing_ground.id_4 GENESIS_G90_FF_GAIN_LEFT = 0.32 @@ -3131,6 +3147,24 @@ def get_genesis_gv70_high_speed_error_scale(setpoint: float, measured_lateral_ac return 1.0 - reduction +def get_genesis_gv70_reversal_output_scale(setpoint: float, measured_lateral_accel: float, + desired_lateral_jerk: float, v_ego: float) -> float: + commanded_unwind = setpoint * desired_lateral_jerk < 0.0 + measured_reversal = setpoint * measured_lateral_accel < 0.0 + if not commanded_unwind and not measured_reversal: + return 1.0 + + tracking_error = abs(measured_lateral_accel - setpoint) + speed_weight = _sigmoid((v_ego - GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_SPEED) / + GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_SPEED_WIDTH) + error_weight = _sigmoid((tracking_error - GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_ERROR) / + GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_ERROR_WIDTH) + jerk_weight = _sigmoid((abs(desired_lateral_jerk) - GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_JERK) / + GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_JERK_WIDTH) + reduction = (GENESIS_GV70_REVERSAL_OUTPUT_DAMPING_MAX * speed_weight * error_weight * jerk_weight) + return 1.0 - reduction + + def get_genesis_gv70_low_speed_center_overshoot_scale(setpoint: float, measured_lateral_accel: float, v_ego: float) -> float: if abs(setpoint) > 0.08 and setpoint * measured_lateral_accel < 0.0: @@ -3164,12 +3198,34 @@ def get_genesis_g70_friction_threshold(v_ego: float, desired_lateral_accel: floa return base_threshold * (1.0 + gain) -def get_genesis_g70_friction_jerk_deadzone(v_ego: float, desired_lateral_accel: float) -> float: +def get_genesis_g70_friction_jerk_deadzone(v_ego: float, desired_lateral_accel: float, + desired_lateral_jerk: float = 0.0) -> float: speed_weight = _sigmoid((v_ego - GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED) / GENESIS_G70_FRICTION_JERK_DEADZONE_SPEED_WIDTH) center_weight = _sigmoid((GENESIS_G70_FRICTION_JERK_DEADZONE_LAT - abs(desired_lateral_accel)) / GENESIS_G70_FRICTION_JERK_DEADZONE_LAT_WIDTH) - return GENESIS_G70_FRICTION_JERK_DEADZONE_MAX * speed_weight * center_weight + deadzone = GENESIS_G70_FRICTION_JERK_DEADZONE_MAX * speed_weight * center_weight + + if desired_lateral_accel * desired_lateral_jerk < 0.0: + curve_speed_weight = _sigmoid( + (max(v_ego, 0.0) - GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_SPEED) / + GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_SPEED_WIDTH + ) + curve_onset_weight = _sigmoid( + (abs(desired_lateral_accel) - GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT) / + GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_WIDTH + ) + curve_cutoff_weight = _sigmoid( + (GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF - abs(desired_lateral_accel)) / + GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_LAT_CUTOFF_WIDTH + ) + jerk_weight = _sigmoid( + (abs(desired_lateral_jerk) - GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_JERK) / + GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_JERK_WIDTH + ) + deadzone += (GENESIS_G70_CURVE_UNWIND_FRICTION_JERK_DEADZONE_MAX * curve_speed_weight * + curve_onset_weight * curve_cutoff_weight * jerk_weight) + return deadzone def get_genesis_g70_center_output_scale(desired_lateral_accel: float, v_ego: float) -> float: diff --git a/selfdrive/controls/tests/test_latcontrol.py b/selfdrive/controls/tests/test_latcontrol.py index f1e1f8bc8..a23b1e8ee 100644 --- a/selfdrive/controls/tests/test_latcontrol.py +++ b/selfdrive/controls/tests/test_latcontrol.py @@ -99,6 +99,7 @@ from openpilot.selfdrive.controls.lib.latcontrol_torque import ( get_genesis_gv70_friction_jerk_deadzone, get_genesis_gv70_friction_threshold, get_genesis_gv70_high_speed_error_scale, + get_genesis_gv70_reversal_output_scale, get_genesis_gv70_unwind_ff_scale, get_honda_accord_ff_scale, get_elantra_non_scc_ff_scale, @@ -956,6 +957,15 @@ class TestLatControl: assert get_genesis_gv70_high_speed_error_scale(-0.7, 0.58, -0.8, 20.0) > \ get_genesis_gv70_high_speed_error_scale(-0.7, 0.58, -0.8, 33.5) + def test_genesis_gv70_reversal_damping_is_medium_speed_and_phase_gated(self): + same_direction = get_genesis_gv70_reversal_output_scale(0.7, 0.9, 0.8, 16.0) + low_speed = get_genesis_gv70_reversal_output_scale(-0.7, 0.7, -0.8, 8.0) + route_speed = get_genesis_gv70_reversal_output_scale(-0.7, 0.7, -0.8, 15.0) + + assert same_direction == pytest.approx(1.0) + assert route_speed < 1.0 + assert route_speed < low_speed + def test_genesis_gv70_low_speed_center_overshoot_damping(self): center_overshoot = get_genesis_gv70_low_speed_center_overshoot_scale(0.02, 0.45, 22.0 * 0.44704) clean_center = get_genesis_gv70_low_speed_center_overshoot_scale(0.02, 0.02, 22.0 * 0.44704) @@ -992,6 +1002,10 @@ class TestLatControl: assert get_genesis_g70_angle_output_scale(55.0, 1.0) > get_genesis_g70_angle_output_scale(85.0, 1.0) assert get_genesis_g70_angle_output_scale(85.0, -1.0) == pytest.approx(1.0) assert get_genesis_g70_friction_jerk_deadzone(25.0, 0.0) > 0.25 + hwy_unwind_deadzone = get_genesis_g70_friction_jerk_deadzone(68.0 * 0.44704, 0.8, -0.6) + hwy_turn_in_deadzone = get_genesis_g70_friction_jerk_deadzone(68.0 * 0.44704, 0.8, 0.6) + assert hwy_unwind_deadzone > hwy_turn_in_deadzone + assert hwy_unwind_deadzone > 0.08 assert get_genesis_g70_unwind_ff_scale(-0.7, -0.95, 0.5, 25.0) < 0.90 assert get_genesis_g70_unwind_ff_scale(-0.7, -0.95, -0.5, 25.0) == 1.0 assert get_genesis_g70_unwind_ff_scale(-0.7, 0.2, 0.5, 25.0) == 1.0 diff --git a/selfdrive/modeld/modeld.py b/selfdrive/modeld/modeld.py index a832a25cd..bb57c2186 100755 --- a/selfdrive/modeld/modeld.py +++ b/selfdrive/modeld/modeld.py @@ -2,6 +2,7 @@ from collections.abc import Callable import ctypes from functools import cached_property +import json import os import struct from openpilot.system.hardware import HARDWARE, TICI @@ -49,7 +50,21 @@ from openpilot.selfdrive.modeld.compile_modeld import ( ) from openpilot.selfdrive.modeld.helpers import get_tg_input_devices, load_oob, tinygrad_dev_config, usbgpu_present from openpilot.selfdrive.modeld.usbgpu_link import wait_usbgpu_link -from openpilot.starpilot.assets.model_manager import ModelManager, model_uses_external_gpu +from openpilot.starpilot.assets.model_manager import ( + ModelManager, + load_model_artifact_metadata, + model_accelerator_artifact_available, + model_accelerator_artifact_installed, + model_accelerator_artifact_path, + model_uses_external_gpu, +) +from openpilot.starpilot.common.model_lab import ( + MODEL_LAB_RUNTIME_PARAM, + compose_model_outputs, + hybrid_action_values, + load_model_lab_config, + model_lab_manifest_eligible, +) from openpilot.starpilot.common.model_versions import is_tinygrad_model_version from openpilot.starpilot.common.starpilot_variables import get_starpilot_toggles, MODELS_PATH, params_memory @@ -478,28 +493,34 @@ class ModelState: return numpy_inputs, prev_desired_curv_key def __init__(self, cam_w: int, cam_h: int, external_gpu_active: bool = False, - model_id_override: str | None = None, write_model_version: bool = True): + model_id_override: str | None = None, write_model_version: bool = True, + model_version_override: str | None = None, model_path_override: Path | None = None, + force_external_gpu: bool = False): params = Params() selected_model = model_id_override or _resolve_mirrored_param(params, "Model", "DrivingModel") or BUILTIN_MODEL_KEY model_id = _canonical_model_id(selected_model) requires_external_gpu = model_uses_external_gpu(model_id) + if force_external_gpu and not external_gpu_active: + raise RuntimeError("an external GPU artifact cannot run without Chestnut") if requires_external_gpu and not external_gpu_active: cloudlog.error(f"Model {model_id} requires an external GPU; falling back to {BUILTIN_MODEL_KEY}") model_id = BUILTIN_MODEL_KEY - use_builtin = model_id == BUILTIN_MODEL_KEY + use_builtin = model_id == BUILTIN_MODEL_KEY and model_path_override is None loaded_builtin = use_builtin - if use_builtin: + if model_path_override is not None: + model_path = Path(model_path_override) + elif use_builtin: model_path = Path(__file__).parent / "models" / "driving_tinygrad.pkl" else: model_path = MODELS_PATH / f"{model_id}_driving_tinygrad.pkl" - if not file_chunked_exists(model_path) and not use_builtin: + if not file_chunked_exists(model_path) and not use_builtin and model_path_override is None: cloudlog.error(f"Missing model artifact {model_path}, downloading {model_id}...") try: ModelManager(params, params_memory).download_model(model_id) except Exception: cloudlog.exception(f"Failed to download model {model_id}") - if not file_chunked_exists(model_path) and not use_builtin: + if not file_chunked_exists(model_path) and not use_builtin and model_path_override is None: fallback_path = Path(__file__).parent / "models" / "driving_tinygrad.pkl" if file_chunked_exists(fallback_path): cloudlog.error(f"Falling back to builtin model artifact after {model_id} download failed") @@ -509,7 +530,8 @@ class ModelState: if not file_chunked_exists(model_path): raise FileNotFoundError(model_path) - self.uses_external_gpu = external_gpu_active and requires_external_gpu and not loaded_builtin + self.model_id = BUILTIN_MODEL_KEY if loaded_builtin else model_id + self.uses_external_gpu = external_gpu_active and (requires_external_gpu or force_external_gpu) and not loaded_builtin artifact = _normalize_model_artifact(_load_model_artifact(model_path)) self.model_type = artifact["model_type"] @@ -549,14 +571,15 @@ class ModelState: self.frame_buf_size = get_nv12_info(cam_w, cam_h)[3] self._blob_cache: dict[tuple[str, int], Tensor] = {} - model_version = _resolve_mirrored_param(params, "ModelVersion", "DrivingModelVersion") + model_version = str(model_version_override or "").strip() + if not model_version: + model_version = _resolve_mirrored_param(params, "ModelVersion", "DrivingModelVersion") if not model_version: model_version = str(artifact.get("behavior_version") or "") if not model_version: versions_path = MODELS_PATH / ".model_versions.json" if versions_path.is_file(): try: - import json model_version = str(json.loads(versions_path.read_text()).get(model_id) or "") except Exception: pass @@ -791,6 +814,147 @@ def _load_external_gpu_model(cam_w: int, cam_h: int, selected_model: str, _set_hcq_wait_timeout(BIG_MODEL_RUN_WAIT_TIMEOUT_MS) +def _model_versions() -> dict[str, str]: + versions_path = MODELS_PATH / ".model_versions.json" + try: + payload = json.loads(versions_path.read_text()) + return {str(key): str(value) for key, value in payload.items()} if isinstance(payload, dict) else {} + except (OSError, TypeError, ValueError): + return {} + + +def _load_model_lab_model(cam_w: int, cam_h: int, model_id: str, version: str) -> ModelState: + if not model_accelerator_artifact_available(model_id) or not model_accelerator_artifact_installed(model_id): + raise RuntimeError(f"Model Laboratory AMD artifact is unavailable for {model_id}") + candidate = ModelState( + cam_w, + cam_h, + True, + model_id_override=model_id, + write_model_version=False, + model_version_override=version, + model_path_override=model_accelerator_artifact_path(model_id), + force_external_gpu=True, + ) + if candidate.model_id != _canonical_model_id(model_id) or not candidate.uses_external_gpu: + raise RuntimeError(f"Model Laboratory failed to load {model_id} on AMD") + return candidate + + +def _isolate_next_model_artifact_load() -> int: + from tinygrad.uop.ops import Ops, UOpMetaClass + + buffer_keys = [key for key in UOpMetaClass.ucache if key[0] is Ops.BUFFER] + for key in buffer_keys: + UOpMetaClass.ucache.pop(key, None) + return len(buffer_keys) + + +def _load_model_lab_models(cam_w: int, cam_h: int, lateral_id: str, longitudinal_id: str, + version: str, CP=None, demo: bool = False) -> tuple[ModelState, ModelState] | None: + try: + if not demo: + wait_for_external_gpu_power_ready(CP) + _set_hcq_wait_timeout(BIG_MODEL_LOAD_WAIT_TIMEOUT_MS) + wait_usbgpu_link() + _isolate_next_model_artifact_load() + lateral = _load_model_lab_model(cam_w, cam_h, lateral_id, version) + lateral.warmup() + evicted = _isolate_next_model_artifact_load() + cloudlog.info(f"Model Laboratory isolated {evicted} realized buffer UOps before loading the second model") + longitudinal = _load_model_lab_model(cam_w, cam_h, longitudinal_id, version) + longitudinal.warmup() + return lateral, longitudinal + except Exception: + cloudlog.exception("Model Laboratory AMD model load or warmup failed") + return None + finally: + _close_tinygrad_disk_cache_connection() + _set_hcq_wait_timeout(BIG_MODEL_RUN_WAIT_TIMEOUT_MS) + + +def _model_outputs_finite(*outputs: dict[str, np.ndarray]) -> bool: + return all( + np.isfinite(value).all() + for output in outputs + for value in output.values() + if isinstance(value, np.ndarray) + ) + +def _model_lab_runtime_request(params: Params, chestnut_ready: bool) -> tuple[dict, str | None]: + config = load_model_lab_config(params) + if not config["enabled"]: + return config, None + if not chestnut_ready: + return config, "Chestnut is not connected and firmware-ready" + + lateral_id = _canonical_model_id(config["lateralModel"]) + longitudinal_id = _canonical_model_id(config["longitudinalModel"]) + config.update({"lateralModel": lateral_id, "longitudinalModel": longitudinal_id}) + if not lateral_id or not longitudinal_id: + return config, "both model roles must be selected" + if lateral_id == longitudinal_id: + return config, "the lateral and longitudinal models must be different" + + versions = _model_versions() + for role, model_id in (("lateral", lateral_id), ("longitudinal", longitudinal_id)): + metadata = load_model_artifact_metadata(model_id) + version = versions.get(model_id, "") + if not model_lab_manifest_eligible(metadata, version): + return config, f"{role} model {model_id} is not a compatible small model" + if not model_accelerator_artifact_available(model_id): + return config, f"{role} model {model_id} has no precompiled AMD artifact in the manifest" + if not model_accelerator_artifact_installed(model_id): + return config, f"{role} model {model_id} AMD artifact is not installed by Model Manager" + if versions[lateral_id] != versions[longitudinal_id]: + return config, "the two models must use the same behavior version" + return config, None + + +def _set_model_lab_runtime(params: Params, *, requested: bool, active: bool, + config: dict | None = None, error: str = "") -> None: + config = config or {} + params.put(MODEL_LAB_RUNTIME_PARAM, { + "requested": bool(requested), + "active": bool(active), + "lateralModel": str(config.get("lateralModel") or ""), + "longitudinalModel": str(config.get("longitudinalModel") or ""), + "schedule": "sequential_20hz" if requested else "", + "executionDevice": "AMD" if active else "", + "error": str(error or ""), + }) + + +def _runner_frame_args(model: ModelState, buf_main, buf_extra, + model_transform_main: np.ndarray, model_transform_extra: np.ndarray, + vec_desire: np.ndarray, traffic_convention: np.ndarray, + lat_action_t: float, long_action_t: float, + prev_action: log.ModelDataV2.Action, v_ego: float, + lateral_control_params: np.ndarray) -> tuple[dict, dict, dict[str, np.ndarray]]: + bufs = { + model.road_key: buf_main, + model.wide_key: buf_extra, + } + transforms = { + model.road_key: model_transform_main, + model.wide_key: model_transform_extra, + } + inputs: dict[str, np.ndarray] = { + model.desire_key: vec_desire, + "traffic_convention": traffic_convention, + } + if "action_t" in model.numpy_inputs or (model.off_policy_enabled and "action_t" in model.off_policy_numpy_inputs): + inputs["action_t"] = np.array([lat_action_t, long_action_t], dtype=np.float32) + if "prev_action" in model.numpy_inputs or (model.off_policy_enabled and "prev_action" in model.off_policy_numpy_inputs): + inputs["prev_action"] = np.array([ + prev_action.desiredCurvature * max(1.0, v_ego) ** 2, + prev_action.desiredAcceleration, + ], dtype=np.float32) + if "lateral_control_params" in model.numpy_inputs: + inputs["lateral_control_params"] = lateral_control_params + return bufs, transforms, inputs + + def main(demo=False): cloudlog.warning("modeld init") @@ -802,14 +966,24 @@ def main(demo=False): params = Params() selected_model = _canonical_model_id(_resolve_mirrored_param(params, "Model", "DrivingModel") or BUILTIN_MODEL_KEY) usbgpu_present_now = usbgpu_present() + model_lab_config, model_lab_error = _model_lab_runtime_request(params, usbgpu_present_now) + model_lab_requested = bool(model_lab_config["enabled"]) + model_lab_ready = model_lab_requested and model_lab_error is None external_model_selected = model_uses_external_gpu(selected_model) external_artifact = MODELS_PATH / f"{selected_model}_driving_tinygrad.pkl" external_artifact_ready = external_model_selected and file_chunked_exists(external_artifact) - external_gpu_requested = usbgpu_present_now and external_model_selected + external_gpu_requested = usbgpu_present_now and (external_model_selected or model_lab_ready) params.put_bool("UsbGpuPresent", usbgpu_present_now) - params.put_bool("UsbGpuCompiled", external_artifact_ready) + params.put_bool("UsbGpuCompiled", external_artifact_ready or model_lab_ready) params.put_bool("UsbGpuActive", False) params.put_bool("UsbGpuLoading", external_gpu_requested) + _set_model_lab_runtime( + params, + requested=model_lab_requested, + active=False, + config=model_lab_config, + error=model_lab_error or "", + ) # visionipc clients while True: @@ -839,8 +1013,47 @@ def main(demo=False): model = None small_model = None big_model = None + model_lab_longitudinal = None + model_lab_active = False + model_lab_timings: list[float] = [] CP = None - if external_gpu_requested: + if model_lab_ready: + if demo: + CP = get_demo_car_params() + else: + CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) + small_model = ModelState( + vipc_client_main.width, + vipc_client_main.height, + False, + model_id_override=BUILTIN_MODEL_KEY, + write_model_version=False, + ) + versions = _model_versions() + lateral_id = model_lab_config["lateralModel"] + longitudinal_id = model_lab_config["longitudinalModel"] + pair = _load_model_lab_models( + vipc_client_main.width, + vipc_client_main.height, + lateral_id, + longitudinal_id, + versions[lateral_id], + CP, + demo, + ) + if pair is not None: + model, model_lab_longitudinal = pair + model_lab_active = True + params.put("ModelVersion", model.policy_generation) + params.put("DrivingModelVersion", model.policy_generation) + else: + model_lab_error = "one or both precompiled AMD models failed to load; using the built-in model" + cloudlog.error(f"Model Laboratory unavailable: {model_lab_error}") + model = small_model + params.put("ModelVersion", model.policy_generation) + params.put("DrivingModelVersion", model.policy_generation) + + elif external_gpu_requested: if demo: CP = get_demo_car_params() else: @@ -868,10 +1081,17 @@ def main(demo=False): else: model = _load_model_state(vipc_client_main.width, vipc_client_main.height, selected_model, False, params) - external_gpu_active = model.uses_external_gpu - params.put_bool("UsbGpuCompiled", external_model_selected and file_chunked_exists(external_artifact)) + external_gpu_active = model_lab_active or model.uses_external_gpu + params.put_bool("UsbGpuCompiled", external_artifact_ready or model_lab_ready) params.put_bool("UsbGpuActive", external_gpu_active) params.put_bool("UsbGpuLoading", False) + _set_model_lab_runtime( + params, + requested=model_lab_requested, + active=model_lab_active, + config=model_lab_config, + error=model_lab_error or "", + ) cloudlog.warning(f"models loaded in {time.monotonic() - start_time:.1f}s, modeld starting") # messaging @@ -995,39 +1215,41 @@ def main(demo=False): frames_dropped = 0. run_count = run_count + 1 - frame_drop_ratio = frames_dropped / (1 + frames_dropped) - prepare_only = model.can_prepare_only and vipc_dropped_frames > 0 - if prepare_only: - cloudlog.error(f"skipping model eval. Dropped {vipc_dropped_frames} frames") + if model_lab_active and run_count % ModelConstants.MODEL_FREQ == 0 and not usbgpu_present(): + model_lab_active = False + model_lab_longitudinal = None + if small_model is None: + raise RuntimeError("Model Laboratory has no built-in fallback model") + model = small_model + external_gpu_active = False + model_lab_error = "Chestnut disconnected; using the built-in model" + params.put_bool("UsbGpuPresent", False) + params.put_bool("UsbGpuActive", False) + params.put("ModelVersion", model.policy_generation) + params.put("DrivingModelVersion", model.policy_generation) + _set_model_lab_runtime( + params, + requested=model_lab_requested, + active=False, + config=model_lab_config, + error=model_lab_error, + ) + if chestnut_state is not None: + chestnut_state.big = False + cloudlog.error(f"Model Laboratory stopped: {model_lab_error}") - bufs = { - model.road_key: buf_main, - model.wide_key: buf_extra, - } - transforms = { - model.road_key: model_transform_main, - model.wide_key: model_transform_extra, - } + frame_drop_ratio = frames_dropped / (1 + frames_dropped) + dropped_frame = vipc_dropped_frames > 0 + if dropped_frame and (model.can_prepare_only or (model_lab_longitudinal is not None and model_lab_longitudinal.can_prepare_only)): + cloudlog.error(f"skipping model eval. Dropped {vipc_dropped_frames} frames") frame_delay = DT_MDL # Average time elapsed since the current frame finished exposing. action_delay = DT_MDL / 2 # Target the midpoint between current output and the next model step. lat_action_t = lat_delay + frame_delay + action_delay long_action_t = long_delay + frame_delay + action_delay - inputs:dict[str, np.ndarray] = { - model.desire_key: vec_desire, - 'traffic_convention': traffic_convention, - } - if 'action_t' in model.numpy_inputs or (model.off_policy_enabled and 'action_t' in model.off_policy_numpy_inputs): - inputs['action_t'] = np.array([lat_action_t, long_action_t], dtype=np.float32) - if 'prev_action' in model.numpy_inputs or (model.off_policy_enabled and 'prev_action' in model.off_policy_numpy_inputs): - inputs['prev_action'] = np.array([ - prev_action.desiredCurvature * max(1.0, v_ego) ** 2, - prev_action.desiredAcceleration, - ], dtype=np.float32) - # Include optional inputs only if the loaded model expects them - if 'lateral_control_params' in model.numpy_inputs: - inputs['lateral_control_params'] = lateral_control_params + lateral_model_output = None + longitudinal_model_output = None mt1 = time.perf_counter() try: @@ -1035,20 +1257,78 @@ def main(demo=False): chestnut_state is not None and run_count % round(ModelConstants.MODEL_FREQ / SERVICE_LIST["chestnutState"].frequency) == 0 ) - model_output = model.run( - bufs, - transforms, - inputs, - prepare_only, - chestnut_state.send if send_chestnut else None, - ) + if model_lab_longitudinal is not None: + lateral_bufs, lateral_transforms, lateral_inputs = _runner_frame_args( + model, buf_main, buf_extra, model_transform_main, model_transform_extra, + vec_desire, traffic_convention, lat_action_t, long_action_t, + prev_action, v_ego, lateral_control_params, + ) + lateral_model_output = model.run( + lateral_bufs, + lateral_transforms, + lateral_inputs, + model.can_prepare_only and dropped_frame, + ) + longitudinal_bufs, longitudinal_transforms, longitudinal_inputs = _runner_frame_args( + model_lab_longitudinal, buf_main, buf_extra, model_transform_main, model_transform_extra, + vec_desire, traffic_convention, lat_action_t, long_action_t, + prev_action, v_ego, lateral_control_params, + ) + longitudinal_model_output = model_lab_longitudinal.run( + longitudinal_bufs, + longitudinal_transforms, + longitudinal_inputs, + model_lab_longitudinal.can_prepare_only and dropped_frame, + chestnut_state.send if send_chestnut else None, + ) + if ( + lateral_model_output is not None + and longitudinal_model_output is not None + and not _model_outputs_finite(lateral_model_output, longitudinal_model_output) + ): + raise RuntimeError("Model Laboratory produced non-finite output") + model_output = ( + compose_model_outputs(lateral_model_output, longitudinal_model_output, longitudinal_model_output) + if lateral_model_output is not None and longitudinal_model_output is not None + else None + ) + else: + bufs, transforms, inputs = _runner_frame_args( + model, buf_main, buf_extra, model_transform_main, model_transform_extra, + vec_desire, traffic_convention, lat_action_t, long_action_t, + prev_action, v_ego, lateral_control_params, + ) + model_output = model.run( + bufs, + transforms, + inputs, + model.can_prepare_only and dropped_frame, + chestnut_state.send if send_chestnut else None, + ) + lateral_model_output = model_output except Exception: - if not external_gpu_active or small_model is None: - raise - cloudlog.exception("external GPU model failed, falling back to builtin model") + if model_lab_active: + cloudlog.exception("Model Laboratory inference failed, falling back to the built-in model") + if small_model is None: + raise RuntimeError("Model Laboratory has no built-in fallback model") from None + model = small_model + model_lab_longitudinal = None + model_lab_active = False + model_lab_error = "Model Laboratory inference failed; using the built-in model" + _set_model_lab_runtime( + params, + requested=model_lab_requested, + active=False, + config=model_lab_config, + error=model_lab_error, + ) + else: + if not external_gpu_active or small_model is None: + raise + cloudlog.exception("external GPU model failed, falling back to builtin model") + model = small_model + big_model = None params.put_bool("UsbGpuActive", False) - model = small_model - big_model = None external_gpu_active = False params.put("ModelVersion", model.policy_generation) params.put("DrivingModelVersion", model.policy_generation) @@ -1060,6 +1340,16 @@ def main(demo=False): mt2 = time.perf_counter() model_execution_time = mt2 - mt1 + if model_lab_active and model_lab_longitudinal is not None: + model_lab_timings.append(model_execution_time * 1000) + if run_count % (ModelConstants.MODEL_FREQ * 10) == 0: + timing_summary = "/".join(( + f"p50:{np.percentile(model_lab_timings, 50):.1f}", + f"p95:{np.percentile(model_lab_timings, 95):.1f}", + f"max:{max(model_lab_timings):.1f}ms", + )) + cloudlog.warning(f"Model Laboratory timing (two AMD models at 20 Hz): {timing_summary}") + model_lab_timings = [] if model_output is not None and vipc_dropped_frames > 0: cloudlog.error(f"suppressing model output after dropping {vipc_dropped_frames} frames") @@ -1070,13 +1360,31 @@ def main(demo=False): drivingdata_send = messaging.new_message('drivingModelData') posenet_send = messaging.new_message('cameraOdometry') - action = get_action_from_model( - model_output, prev_action, - lat_action_t, - long_action_t, - v_ego, model.mlsim, model.is_v9, model.is_v14, model.is_v15, starpilot_toggles, - lat_smooth_seconds, long_smooth_seconds, is_v16=model.is_v16, - ) + if model_lab_active and longitudinal_model_output is not None: + lateral_action = get_action_from_model( + lateral_model_output, prev_action, + lat_action_t, + long_action_t, + v_ego, model.mlsim, model.is_v9, model.is_v14, model.is_v15, starpilot_toggles, + lat_smooth_seconds, long_smooth_seconds, is_v16=model.is_v16, + ) + longitudinal_action = get_action_from_model( + longitudinal_model_output, prev_action, + lat_action_t, + long_action_t, + v_ego, model_lab_longitudinal.mlsim, model_lab_longitudinal.is_v9, + model_lab_longitudinal.is_v14, model_lab_longitudinal.is_v15, starpilot_toggles, + lat_smooth_seconds, long_smooth_seconds, is_v16=model_lab_longitudinal.is_v16, + ) + action = log.ModelDataV2.Action(**hybrid_action_values(lateral_action, longitudinal_action)) + else: + action = get_action_from_model( + model_output, prev_action, + lat_action_t, + long_action_t, + v_ego, model.mlsim, model.is_v9, model.is_v14, model.is_v15, starpilot_toggles, + lat_smooth_seconds, long_smooth_seconds, is_v16=model.is_v16, + ) prev_action = action fill_model_msg(drivingdata_send, modelv2_send, model_output, action, publish_state, meta_main.frame_id, meta_extra.frame_id, frame_id, diff --git a/selfdrive/modeld/tests/test_model_laboratory.py b/selfdrive/modeld/tests/test_model_laboratory.py new file mode 100644 index 000000000..7c5274e59 --- /dev/null +++ b/selfdrive/modeld/tests/test_model_laboratory.py @@ -0,0 +1,209 @@ +import json +from types import SimpleNamespace + +import numpy as np +from tinygrad.uop.ops import Ops, UOpMetaClass + +from openpilot.selfdrive.modeld import modeld + + +class FakeParams: + def __init__(self, config): + self.config = config + self.values = {} + + def get(self, key): + if key == "ModelLabConfig": + return self.config + return None + + def put(self, key, value): + self.values[key] = value + + +def test_runtime_request_accepts_only_two_ready_small_same_version_models(tmp_path, monkeypatch): + config = {"enabled": True, "lateralModel": "lat", "longitudinalModel": "long"} + params = FakeParams(config) + (tmp_path / ".model_versions.json").write_text(json.dumps({"lat": "v15", "long": "v15"})) + (tmp_path / "lat_driving_tinygrad.pkl").write_bytes(b"lat") + (tmp_path / "long_driving_tinygrad.pkl").write_bytes(b"long") + monkeypatch.setattr(modeld, "MODELS_PATH", tmp_path) + monkeypatch.setattr( + modeld, + "load_model_artifact_metadata", + lambda model_id: {"model_size": "small", "model_lab_eligible": model_id in {"lat", "long"}}, + ) + monkeypatch.setattr(modeld, "model_accelerator_artifact_available", lambda model_id: model_id in {"lat", "long"}) + monkeypatch.setattr(modeld, "model_accelerator_artifact_installed", lambda model_id: model_id in {"lat", "long"}) + + normalized, error = modeld._model_lab_runtime_request(params, chestnut_ready=True) + + assert error is None + assert normalized == config + + +def test_runtime_request_revalidates_hardware_version_and_size(tmp_path, monkeypatch): + params = FakeParams({"enabled": True, "lateralModel": "lat", "longitudinalModel": "long"}) + (tmp_path / ".model_versions.json").write_text(json.dumps({"lat": "v15", "long": "v9"})) + for model_id in ("lat", "long"): + (tmp_path / f"{model_id}_driving_tinygrad.pkl").write_bytes(b"artifact") + monkeypatch.setattr(modeld, "MODELS_PATH", tmp_path) + monkeypatch.setattr(modeld, "load_model_artifact_metadata", lambda _model_id: {"model_size": "small"}) + monkeypatch.setattr(modeld, "model_accelerator_artifact_available", lambda _model_id: True) + monkeypatch.setattr(modeld, "model_accelerator_artifact_installed", lambda _model_id: True) + + assert "Chestnut" in modeld._model_lab_runtime_request(params, chestnut_ready=False)[1] + assert "same behavior version" in modeld._model_lab_runtime_request(params, chestnut_ready=True)[1] + + (tmp_path / ".model_versions.json").write_text(json.dumps({"lat": "v15", "long": "v15"})) + monkeypatch.setattr( + modeld, + "load_model_artifact_metadata", + lambda model_id: {"model_size": "chestnut" if model_id == "long" else "small"}, + ) + assert "compatible small model" in modeld._model_lab_runtime_request(params, chestnut_ready=True)[1] + + +def test_model_lab_loader_uses_installed_artifact_and_manifest_version(monkeypatch): + calls = [] + + def fake_model_state(cam_w, cam_h, external_gpu_active, **kwargs): + calls.append((cam_w, cam_h, external_gpu_active, kwargs)) + return SimpleNamespace(model_id="lat", uses_external_gpu=True) + + monkeypatch.setattr(modeld, "ModelState", fake_model_state) + monkeypatch.setattr(modeld, "model_accelerator_artifact_available", lambda _model_id: True) + monkeypatch.setattr(modeld, "model_accelerator_artifact_installed", lambda _model_id: True) + monkeypatch.setattr(modeld, "model_accelerator_artifact_path", lambda _model_id: modeld.Path("/models/lat-amd.pkl")) + loaded = modeld._load_model_lab_model(1928, 1208, "lat", "v11") + + assert loaded.model_id == "lat" + assert calls == [(1928, 1208, True, { + "model_id_override": "lat", + "write_model_version": False, + "model_version_override": "v11", + "model_path_override": modeld.Path("/models/lat-amd.pkl"), + "force_external_gpu": True, + })] + + +def test_model_lab_finite_output_guard_checks_both_models(): + assert modeld._model_outputs_finite({"plan": np.zeros(2)}, {"lead": np.ones(2)}) + assert not modeld._model_outputs_finite({"plan": np.array([np.nan])}, {"lead": np.ones(2)}) + + +def test_model_lab_isolates_only_realized_buffer_uops(monkeypatch): + buffer_key = (Ops.BUFFER, "serialized-model-buffer") + shape_key = (Ops.RESHAPE, "shared-input-shape") + buffer_value, shape_value = object(), object() + monkeypatch.setattr(UOpMetaClass, "ucache", {buffer_key: buffer_value, shape_key: shape_value}) + + assert modeld._isolate_next_model_artifact_load() == 1 + assert UOpMetaClass.ucache == {shape_key: shape_value} + + +def test_model_lab_loads_and_warms_both_amd_models_before_returning(monkeypatch): + calls = [] + + class FakeModel: + def __init__(self, model_id): + self.model_id = model_id + + def warmup(self): + calls.append(("warmup", self.model_id)) + + monkeypatch.setattr(modeld, "wait_for_external_gpu_power_ready", lambda CP: calls.append(("power", CP))) + monkeypatch.setattr(modeld, "wait_usbgpu_link", lambda: calls.append("link")) + monkeypatch.setattr(modeld, "_set_hcq_wait_timeout", lambda timeout: calls.append(("timeout", timeout))) + monkeypatch.setattr(modeld, "_close_tinygrad_disk_cache_connection", lambda: calls.append("close_cache")) + monkeypatch.setattr( + modeld, + "_isolate_next_model_artifact_load", + lambda: calls.append("isolate_buffers") or 7, + ) + monkeypatch.setattr( + modeld, + "_load_model_lab_model", + lambda _w, _h, model_id, version: calls.append(("load", model_id, version)) or FakeModel(model_id), + ) + + pair = modeld._load_model_lab_models(1928, 1208, "lat", "long", "v15", "car-params") + + assert [model.model_id for model in pair] == ["lat", "long"] + assert calls == [ + ("power", "car-params"), + ("timeout", modeld.BIG_MODEL_LOAD_WAIT_TIMEOUT_MS), + "link", + "isolate_buffers", + ("load", "lat", "v15"), + ("warmup", "lat"), + "isolate_buffers", + ("load", "long", "v15"), + ("warmup", "long"), + "close_cache", + ("timeout", modeld.BIG_MODEL_RUN_WAIT_TIMEOUT_MS), + ] + + +def test_each_runner_receives_its_own_input_names_and_shared_frame_data(): + model = SimpleNamespace( + road_key="road", + wide_key="wide", + desire_key="desire_pulse", + numpy_inputs={"action_t": object(), "prev_action": object(), "lateral_control_params": object()}, + off_policy_enabled=False, + off_policy_numpy_inputs={}, + ) + previous_action = SimpleNamespace(desiredCurvature=0.25, desiredAcceleration=-0.5) + road_buffer, wide_buffer = object(), object() + road_transform = np.eye(3, dtype=np.float32) + wide_transform = np.eye(3, dtype=np.float32) * 2 + desire = np.arange(8, dtype=np.float32) + traffic = np.array([1, 0], dtype=np.float32) + lateral_control = np.array([10.0, 0.2], dtype=np.float32) + + buffers, transforms, inputs = modeld._runner_frame_args( + model, + road_buffer, + wide_buffer, + road_transform, + wide_transform, + desire, + traffic, + 0.3, + 0.6, + previous_action, + 10.0, + lateral_control, + ) + + assert buffers == {"road": road_buffer, "wide": wide_buffer} + np.testing.assert_array_equal(transforms["road"], road_transform) + np.testing.assert_array_equal(transforms["wide"], wide_transform) + np.testing.assert_array_equal(inputs["desire_pulse"], desire) + np.testing.assert_allclose(inputs["action_t"], [0.3, 0.6]) + np.testing.assert_allclose(inputs["prev_action"], [25.0, -0.5]) + np.testing.assert_array_equal(inputs["lateral_control_params"], lateral_control) + + +def test_runtime_status_records_requested_pair_and_fallback_error(): + params = FakeParams({}) + config = {"lateralModel": "lat", "longitudinalModel": "long"} + + modeld._set_model_lab_runtime( + params, + requested=True, + active=False, + config=config, + error="synthetic fallback", + ) + + assert params.values["ModelLabRuntime"] == { + "requested": True, + "active": False, + "lateralModel": "lat", + "longitudinalModel": "long", + "schedule": "sequential_20hz", + "executionDevice": "", + "error": "synthetic fallback", + } diff --git a/selfdrive/selfdrived/tests/test_state_machine.py b/selfdrive/selfdrived/tests/test_state_machine.py index 62cc8d2bf..991af75dd 100644 --- a/selfdrive/selfdrived/tests/test_state_machine.py +++ b/selfdrive/selfdrived/tests/test_state_machine.py @@ -94,3 +94,13 @@ class TestStateMachine: self.update() assert self.state_machine.state == state self.events.clear() + + def test_lateral_override_returns_to_enabled_after_release(self): + self.state_machine.state = State.enabled + self.events.add(make_event([ET.OVERRIDE_LATERAL])) + self.update() + assert self.state_machine.state == State.overriding + + self.events.clear() + self.update() + assert self.state_machine.state == State.enabled diff --git a/selfdrive/ui/layouts/settings/starpilot/longitudinal.py b/selfdrive/ui/layouts/settings/starpilot/longitudinal.py index 46d7d69c8..b37124b5b 100644 --- a/selfdrive/ui/layouts/settings/starpilot/longitudinal.py +++ b/selfdrive/ui/layouts/settings/starpilot/longitudinal.py @@ -726,17 +726,19 @@ class StarPilotLongitudinalLayout(_SettingsPage): self._daily_rows = [ SettingRow("CustomCruise", "value", tr_noop("Cruise Interval"), subtitle="", - get_value=lambda: f"{max(1, self._params.get_int('CustomCruise'))}{self._speed_unit()}", + get_value=lambda: f"{max(1, self._params.get_float('CustomCruise')):g}{self._speed_unit()}", on_click=lambda: self._show_slider("CustomCruise", 1, 150 if self._is_metric() else 99, unit=self._speed_unit(), - current_value=max(1, self._params.get_int("CustomCruise"))), + value_type="float", + current_value=max(1, self._params.get_float("CustomCruise"))), visible=lambda: self._params.get_bool("QOLLongitudinal")), SettingRow("CustomCruiseLong", "value", tr_noop("Cruise Long"), subtitle="", - get_value=lambda: f"{max(1, self._params.get_int('CustomCruiseLong'))}{self._speed_unit()}", + get_value=lambda: f"{max(1, self._params.get_float('CustomCruiseLong')):g}{self._speed_unit()}", on_click=lambda: self._show_slider("CustomCruiseLong", 1, 150 if self._is_metric() else 99, unit=self._speed_unit(), - current_value=max(1, self._params.get_int("CustomCruiseLong"))), + value_type="float", + current_value=max(1, self._params.get_float("CustomCruiseLong"))), visible=lambda: self._params.get_bool("QOLLongitudinal")), SettingRow("ForceStops", "toggle", tr_noop("Force Stops"), subtitle="", diff --git a/selfdrive/ui/layouts/settings/starpilot/system_settings.py b/selfdrive/ui/layouts/settings/starpilot/system_settings.py index 0786351fe..05f3605f9 100644 --- a/selfdrive/ui/layouts/settings/starpilot/system_settings.py +++ b/selfdrive/ui/layouts/settings/starpilot/system_settings.py @@ -56,7 +56,9 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import ( TOGGLE_MIN_HEIGHT, TOGGLE_ROW_HEIGHT, ) +from openpilot.starpilot.common import param_profiles from openpilot.starpilot.common.connect_server import prepare_konik_server_switch +from openpilot.starpilot.common.starpilot_variables import EXCLUDED_KEYS as STARPILOT_EXCLUDED_KEYS, TOGGLE_BACKUPS, update_starpilot_toggles LEGACY_STARPILOT_PARAM_RENAMES = { "FrogPilotApiToken": "StarPilotApiToken", @@ -611,7 +613,7 @@ class AetherBackupsCareDialog(Widget): self._buttons = [ {"id": "system_backups", "text": tr("System Backups"), "danger": False}, - {"id": "toggle_snapshots", "text": tr("Toggle Snapshots"), "danger": False}, + {"id": "toggle_snapshots", "text": tr("Profiles & Snapshots"), "danger": False}, {"id": "report_issue", "text": tr("Report Issue"), "danger": False}, {"id": "flash_panda", "text": tr("Flash Panda"), "danger": False}, {"id": "clear_data", "text": tr("Clear Driving Data"), "danger": True}, @@ -901,27 +903,36 @@ class StarPilotSystemLayout(_SettingsPage): options = [tr("Create Backup"), tr("Restore Backup"), tr("Delete Backup")] title = tr("System Backups") else: - options = [tr("Save Toggle Snapshot"), tr("Restore Toggle Snapshot"), tr("Delete Toggle Snapshot")] - title = tr("Toggle Snapshots") + options = [ + tr("Profile Slot A"), + tr("Profile Slot B"), + tr("Save Named Snapshot"), + tr("Restore Named Snapshot"), + tr("Delete Named Snapshot"), + ] + title = tr("Settings Profiles & Snapshots") def on_select(res): if res != DialogResult.CONFIRM or not dialog.selection: return selection = dialog.selection - if selection == options[0]: - if backup_kind == "system": + if backup_kind == "system": + if selection == options[0]: self._on_create_backup() - else: - self._on_create_toggle_backup() - elif selection == options[1]: - if backup_kind == "system": + elif selection == options[1]: self._on_restore_backup() - else: - self._on_restore_toggle_backup() - elif selection == options[2]: - if backup_kind == "system": + elif selection == options[2]: self._on_delete_backup() - else: + else: + if selection == options[0]: + self._open_param_profile("a") + elif selection == options[1]: + self._open_param_profile("b") + elif selection == options[2]: + self._on_create_toggle_backup() + elif selection == options[3]: + self._on_restore_toggle_backup() + elif selection == options[4]: self._on_delete_toggle_backup() dialog = MultiOptionDialog(title, options, callback=on_select) @@ -1182,6 +1193,70 @@ class StarPilotSystemLayout(_SettingsPage): self._keyboard.set_callback(lambda result: on_name(result, self._keyboard.text)) gui_app.push_widget(self._keyboard) + def _open_param_profile(self, slot: str): + status = param_profiles.profile_status(slot, profile_root=TOGGLE_BACKUPS) + options = [tr("Save Current Settings")] + if status["saved"] and not status.get("invalid"): + options.append(tr("Load Saved Settings")) + + def _on_select(res): + if res != DialogResult.CONFIRM or not dialog.selection: + return + if dialog.selection == options[0]: + if ui_state.started: + gui_app.push_widget(alert_dialog(tr("Settings profiles can only be saved while parked."))) + return + if status["saved"]: + gui_app.push_widget(ConfirmDialog( + tr("Overwrite {} with your current settings?").format(status["label"]), + tr("Overwrite"), + callback=lambda confirm_res: self._save_param_profile(slot) if confirm_res == DialogResult.CONFIRM else None, + )) + else: + self._save_param_profile(slot) + elif len(options) > 1 and dialog.selection == options[1]: + if ui_state.started: + gui_app.push_widget(alert_dialog(tr("Settings profiles can only be loaded while parked."))) + return + gui_app.push_widget(ConfirmDialog( + tr("Load {} and overwrite your current settings?").format(status["label"]), + tr("Load"), + callback=lambda confirm_res: self._load_param_profile(slot) if confirm_res == DialogResult.CONFIRM else None, + )) + + dialog = MultiOptionDialog(status["label"], options, callback=_on_select) + gui_app.push_widget(dialog) + + def _save_param_profile(self, slot: str): + try: + status = param_profiles.save_profile( + self._params, + slot, + allowed_keys=param_profiles.eligible_profile_keys(self._params, excluded_keys=STARPILOT_EXCLUDED_KEYS), + profile_root=TOGGLE_BACKUPS, + ) + except param_profiles.ParamProfileError as error: + gui_app.push_widget(alert_dialog(str(error))) + return + gui_app.push_widget(alert_dialog(tr("Saved current settings to {}.").format(status["label"]))) + + def _load_param_profile(self, slot: str): + try: + result = param_profiles.load_profile( + self._params, + slot, + allowed_keys=param_profiles.eligible_profile_keys(self._params, excluded_keys=STARPILOT_EXCLUDED_KEYS), + profile_root=TOGGLE_BACKUPS, + legacy_renames=LEGACY_STARPILOT_PARAM_RENAMES, + ) + except param_profiles.ParamProfileError as error: + gui_app.push_widget(alert_dialog(str(error))) + return + update_starpilot_toggles() + gui_app.push_widget(alert_dialog( + tr("Loaded {} settings from {}.").format(result["restoredCount"], result["label"]) + )) + def _on_restore_toggle_backup(self): backups = self._get_backups("toggle_backups") if not backups: diff --git a/starpilot/assets/model_manager.py b/starpilot/assets/model_manager.py index 2a66b636c..c3fe49e77 100644 --- a/starpilot/assets/model_manager.py +++ b/starpilot/assets/model_manager.py @@ -22,6 +22,7 @@ from openpilot.starpilot.common.model_versions import ( driving_artifact_filename, is_supported_artifact_format, ) +from openpilot.starpilot.common.model_lab import load_model_lab_config from openpilot.starpilot.common.starpilot_utilities import delete_file from openpilot.starpilot.common.starpilot_variables import MODELS_PATH from openpilot.common.file_chunker import file_chunked_exists, get_existing_chunks, get_manifest_path @@ -52,8 +53,11 @@ CANCEL_DOWNLOAD_PARAM = "CancelModelDownload" DOWNLOAD_PROGRESS_PARAM = "ModelDownloadProgress" MODEL_DOWNLOAD_PARAM = "ModelToDownload" MODEL_DOWNLOAD_ALL_PARAM = "DownloadAllModels" +MODEL_LAB_DOWNLOAD_PARAM = "ModelLabModelToDownload" ALLOW_GPU_DOWNLOAD_WITHOUT_GPU_PARAM = "AllowGpuModelDownloadWithoutGpu" UPDATE_TINYGRAD_PARAM = "UpdateTinygrad" +MODEL_LAB_ACCELERATOR = "chestnut" +MODEL_LAB_EXECUTION_DEVICE = "AMD" def _clean_model_name(name: str) -> str: @@ -109,6 +113,38 @@ def model_uses_external_gpu(model_key: str) -> bool: return bool(load_model_artifact_metadata(model_key).get("uses_external_gpu", False)) +def model_accelerator_artifact_metadata(model_key: str, accelerator: str = MODEL_LAB_ACCELERATOR) -> dict: + metadata = load_model_artifact_metadata(model_key) + artifacts = metadata.get("accelerator_artifacts", {}) + if not isinstance(artifacts, dict): + return {} + artifact = artifacts.get(str(accelerator or "").strip().lower(), {}) + return artifact if isinstance(artifact, dict) else {} + + +def model_accelerator_artifact_filename(model_key: str, accelerator: str = MODEL_LAB_ACCELERATOR) -> str: + model_key = canonical_model_key(model_key) + accelerator = str(accelerator or "").strip().lower() + return f"{model_key}_driving_{accelerator}_tinygrad.pkl" + + +def model_accelerator_artifact_path(model_key: str, accelerator: str = MODEL_LAB_ACCELERATOR) -> Path: + return MODELS_PATH / model_accelerator_artifact_filename(model_key, accelerator) + + +def model_accelerator_artifact_available(model_key: str, accelerator: str = MODEL_LAB_ACCELERATOR) -> bool: + artifact = model_accelerator_artifact_metadata(model_key, accelerator) + execution_device = str(artifact.get("execution_device") or artifact.get("device") or "").strip().upper() + artifact_format = str(artifact.get("artifact_format") or UNIFIED_ARTIFACT_FORMAT).strip() + return bool(artifact) and execution_device == MODEL_LAB_EXECUTION_DEVICE and is_supported_artifact_format(artifact_format) + + +def model_accelerator_artifact_installed(model_key: str, accelerator: str = MODEL_LAB_ACCELERATOR) -> bool: + return model_accelerator_artifact_available(model_key, accelerator) and file_chunked_exists( + model_accelerator_artifact_path(model_key, accelerator) + ) + + def external_gpu_available() -> bool: """Return whether the supported external GPU link is ready for modeld.""" try: @@ -337,6 +373,31 @@ class ModelManager: return artifact_url_map + @staticmethod + def _normalize_accelerator_artifacts(model: dict) -> dict[str, dict]: + raw_artifacts = model.get("accelerator_artifacts") + if not isinstance(raw_artifacts, dict): + return {} + + artifacts: dict[str, dict] = {} + for accelerator, raw_artifact in raw_artifacts.items(): + accelerator = str(accelerator or "").strip().lower() + if not accelerator or not isinstance(raw_artifact, dict): + continue + artifact_format = str(raw_artifact.get("artifact_format") or UNIFIED_ARTIFACT_FORMAT).strip() + if not is_supported_artifact_format(artifact_format): + continue + artifacts[accelerator] = { + "artifact_format": artifact_format, + "artifact_filename": str(raw_artifact.get("artifact_filename") or "").strip(), + "artifact_size": int(raw_artifact.get("artifact_size") or 0), + "artifact_sha256": str(raw_artifact.get("artifact_sha256") or "").strip().lower(), + "artifact_chunk_count": int(raw_artifact.get("artifact_chunk_count") or 0), + "artifact_url": str(raw_artifact.get("artifact_url") or raw_artifact.get("download_url") or "").strip(), + "execution_device": str(raw_artifact.get("execution_device") or raw_artifact.get("device") or "").strip().upper(), + } + return artifacts + def _build_artifact_metadata_map(self, model_info: list[dict]) -> dict[str, dict]: metadata: dict[str, dict] = {} for model in model_info: @@ -344,6 +405,9 @@ class ModelManager: artifact_format = str(model.get("artifact_format") or UNIFIED_ARTIFACT_FORMAT).strip() if not model_key or not is_supported_artifact_format(artifact_format): continue + uses_external_gpu = bool(model.get("uses_external_gpu", False)) + model_size_declared = bool(model.get("model_size") or model.get("size_class")) + model_size = str(model.get("model_size") or model.get("size_class") or ("chestnut" if uses_external_gpu else "small")).strip() metadata[model_key] = { "artifact_format": artifact_format, "artifact_filename": str(model.get("artifact_filename") or "").strip(), @@ -351,7 +415,11 @@ class ModelManager: "artifact_sha256": str(model.get("artifact_sha256") or "").strip().lower(), "artifact_chunk_count": int(model.get("artifact_chunk_count") or 0), "artifact_url": str(model.get("artifact_url") or model.get("download_url") or "").strip(), - "uses_external_gpu": bool(model.get("uses_external_gpu", False)), + "uses_external_gpu": uses_external_gpu, + "model_size": model_size, + "model_size_declared": model_size_declared, + "model_lab_eligible": bool(model.get("model_lab_eligible", not uses_external_gpu)), + "accelerator_artifacts": self._normalize_accelerator_artifacts(model), } return metadata @@ -424,6 +492,9 @@ class ModelManager: def randomize_selected_model(self) -> str | None: if not self._param_bool("ModelRandomizer"): return None + if load_model_lab_config(self.params)["enabled"]: + print("Model Randomizer skipped while Model Laboratory is enabled.") + return None choices = self._installed_model_choices() if not choices: @@ -615,6 +686,9 @@ class ModelManager: "community_favorite": False, "artifact_format": UNIFIED_ARTIFACT_FORMAT, "uses_external_gpu": bool(info.get("uses_external_gpu", False)), + "model_size": str(info.get("model_size") or "small").strip(), + "model_lab_eligible": bool(info.get("model_lab_eligible", not info.get("uses_external_gpu", False))), + "accelerator_artifacts": info.get("accelerator_artifacts", {}), } return list(discovered.values()) @@ -740,6 +814,132 @@ class ModelManager: finally: self.params_memory.remove(ALLOW_GPU_DOWNLOAD_WITHOUT_GPU_PARAM) + def _download_artifact_to_path(self, model_key: str, file_path: Path, remote_filename: str, + artifact_metadata: dict, artifact_urls: dict[str, str], + resource_urls: list[str]) -> bool: + manifest_version = self._param_text("ModelManifestVersion") or MANIFEST_CANDIDATES[0] + candidate_urls: list[tuple[str, bool, bool]] = [] + custom_url = ( + artifact_urls.get(file_path.name) + or artifact_urls.get(remote_filename) + or artifact_metadata.get("artifact_url") + or "" + ).strip() + if custom_url: + candidate_urls.append((custom_url, True, False)) + + for resource_url in resource_urls: + for artifact_url in self._artifact_source_urls(resource_url, manifest_version, model_key, remote_filename): + if not any(existing[0] == artifact_url for existing in candidate_urls): + candidate_urls.append((artifact_url, False, True)) + + for candidate_url, allow_unknown_size, allow_multipart in candidate_urls: + chunk_count = int(artifact_metadata.get("artifact_chunk_count") or 0) + if chunk_count and download_chunked_file( + CANCEL_DOWNLOAD_PARAM, + file_path, + DOWNLOAD_PROGRESS_PARAM, + candidate_url, + self.params_memory, + expected_size=artifact_metadata.get("artifact_size"), + expected_sha256=artifact_metadata.get("artifact_sha256"), + expected_chunk_count=chunk_count, + ): + return True + + download_file( + CANCEL_DOWNLOAD_PARAM, + file_path, + DOWNLOAD_PROGRESS_PARAM, + candidate_url, + MODEL_DOWNLOAD_PARAM, + self.params_memory, + allow_unknown_size=allow_unknown_size, + suppress_errors=True, + ) + if self.params_memory.get_bool(CANCEL_DOWNLOAD_PARAM): + return False + + if verify_download( + file_path, + candidate_url, + allow_unknown_size=allow_unknown_size, + expected_size=artifact_metadata.get("artifact_size"), + expected_sha256=artifact_metadata.get("artifact_sha256"), + ): + return True + delete_file(file_path, print_error=False) + + if not chunk_count and download_chunked_file( + CANCEL_DOWNLOAD_PARAM, + file_path, + DOWNLOAD_PROGRESS_PARAM, + candidate_url, + self.params_memory, + ): + return True + + if allow_multipart and download_multipart_file( + CANCEL_DOWNLOAD_PARAM, + file_path, + DOWNLOAD_PROGRESS_PARAM, + candidate_url, + MODEL_DOWNLOAD_PARAM, + self.params_memory, + ): + return True + + delete_chunked_artifact(file_path) + return False + + def download_model_accelerator(self, model_key: str, accelerator: str = MODEL_LAB_ACCELERATOR) -> bool: + self.downloading_model = True + model_key = self._canonical_model_key(model_key) + accelerator = str(accelerator or "").strip().lower() + try: + if accelerator == MODEL_LAB_ACCELERATOR and not external_gpu_available(): + handle_error(None, "External GPU required...", "Chestnut is not connected and firmware-ready.", + MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory) + return False + + artifact_metadata = model_accelerator_artifact_metadata(model_key, accelerator) + if not model_accelerator_artifact_available(model_key, accelerator): + handle_error(None, "Accelerator artifact unavailable...", + f"The manifest has no precompiled {MODEL_LAB_EXECUTION_DEVICE} artifact for {model_key}.", + MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory) + return False + + resource_urls = get_resource_urls() + if not resource_urls: + handle_error(None, "Hugging Face and GitHub are offline...", "Repository unavailable", + MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory) + return False + + artifact_urls = self._load_artifact_url_map().get(model_key, {}) + local_path = model_accelerator_artifact_path(model_key, accelerator) + remote_filename = str(artifact_metadata.get("artifact_filename") or local_path.name).strip() + if Path(remote_filename).name != remote_filename: + handle_error(None, "Invalid accelerator artifact filename...", "Model download failed", + MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory) + return False + + if not self._download_artifact_to_path( + model_key, local_path, remote_filename, artifact_metadata, artifact_urls, resource_urls, + ): + if self.params_memory.get_bool(CANCEL_DOWNLOAD_PARAM): + handle_error(None, "Download cancelled...", "Download cancelled...", + MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory) + else: + handle_error(local_path, "Verification failed...", f"Verification failed for {remote_filename}", + MODEL_LAB_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory) + return False + + self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "Chestnut artifact downloaded!") + return True + finally: + self.params_memory.remove(MODEL_LAB_DOWNLOAD_PARAM) + self.downloading_model = False + def _download_model(self, model_to_download: str, allow_gpu_without_gpu: bool): self.downloading_model = True model_to_download = self._canonical_model_key(model_to_download) @@ -787,83 +987,15 @@ class ModelManager: for filename in required_files: file_path = MODELS_PATH / filename remote_filename = str(artifact_metadata.get("artifact_filename") or filename).strip() - manifest_version = self._param_text("ModelManifestVersion") or MANIFEST_CANDIDATES[0] - candidate_urls: list[tuple[str, bool, bool]] = [] + download_succeeded = self._download_artifact_to_path( + model_to_download, file_path, remote_filename, artifact_metadata, artifact_urls, resource_urls, + ) - custom_url = (artifact_urls.get(filename) or artifact_urls.get(remote_filename) or artifact_metadata.get("artifact_url") or "").strip() - if custom_url: - candidate_urls.append((custom_url, True, False)) - - for resource_url in resource_urls: - for artifact_url in self._artifact_source_urls(resource_url, manifest_version, model_to_download, remote_filename): - if not any(existing[0] == artifact_url for existing in candidate_urls): - candidate_urls.append((artifact_url, False, True)) - - download_succeeded = False - for candidate_url, allow_unknown_size, allow_multipart in candidate_urls: - chunk_count = int(artifact_metadata.get("artifact_chunk_count") or 0) - if chunk_count and download_chunked_file( - CANCEL_DOWNLOAD_PARAM, - file_path, - DOWNLOAD_PROGRESS_PARAM, - candidate_url, - self.params_memory, - expected_size=artifact_metadata.get("artifact_size"), - expected_sha256=artifact_metadata.get("artifact_sha256"), - expected_chunk_count=chunk_count, - ): - download_succeeded = True - break - - download_file( - CANCEL_DOWNLOAD_PARAM, - file_path, - DOWNLOAD_PROGRESS_PARAM, - candidate_url, - MODEL_DOWNLOAD_PARAM, - self.params_memory, - allow_unknown_size=allow_unknown_size, - suppress_errors=True, - ) + if not download_succeeded: if self.params_memory.get_bool(CANCEL_DOWNLOAD_PARAM): handle_error(None, "Download cancelled...", "Download cancelled...", MODEL_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory) self.downloading_model = False return - - if verify_download( - file_path, - candidate_url, - allow_unknown_size=allow_unknown_size, - expected_size=artifact_metadata.get("artifact_size"), - expected_sha256=artifact_metadata.get("artifact_sha256"), - ): - download_succeeded = True - break - delete_file(file_path, print_error=False) - - if not chunk_count and download_chunked_file( - CANCEL_DOWNLOAD_PARAM, - file_path, - DOWNLOAD_PROGRESS_PARAM, - candidate_url, - self.params_memory, - ): - download_succeeded = True - break - - if allow_multipart and download_multipart_file( - CANCEL_DOWNLOAD_PARAM, - file_path, - DOWNLOAD_PROGRESS_PARAM, - candidate_url, - MODEL_DOWNLOAD_PARAM, - self.params_memory, - ): - download_succeeded = True - break - - if not download_succeeded: - delete_chunked_artifact(file_path) handle_error(file_path, "Verification failed...", f"Verification failed for {filename}", MODEL_DOWNLOAD_PARAM, DOWNLOAD_PROGRESS_PARAM, self.params_memory) self.downloading_model = False return diff --git a/starpilot/assets/tests/test_model_pipeline.py b/starpilot/assets/tests/test_model_pipeline.py index 7776e0f17..bc57e798e 100644 --- a/starpilot/assets/tests/test_model_pipeline.py +++ b/starpilot/assets/tests/test_model_pipeline.py @@ -141,6 +141,110 @@ def test_external_gpu_requirement_is_cached_from_manifest(tmp_path, monkeypatch) assert not model_manager.model_uses_external_gpu("missing") +def test_manifest_metadata_classifies_model_lab_candidates_and_accelerator_artifacts(tmp_path, monkeypatch): + monkeypatch.setattr(model_manager, "MODELS_PATH", tmp_path) + manager = object.__new__(ModelManager) + metadata = manager._build_artifact_metadata_map([ + {"id": "legacy-small", "version": "v15"}, + { + "id": "declared-small", + "version": "v15", + "model_size": "small", + "model_lab_eligible": True, + "accelerator_artifacts": { + "chestnut": { + "artifact_filename": "declared-small-amd.pkl", + "artifact_size": 123, + "artifact_sha256": "a" * 64, + "artifact_chunk_count": 2, + "execution_device": "AMD", + }, + }, + }, + {"id": "chestnut", "version": "v16", "uses_external_gpu": True}, + ]) + (tmp_path / model_manager.ARTIFACT_METADATA_CACHE).write_text(json.dumps(metadata)) + + assert metadata["legacy-small"]["model_size"] == "small" + assert metadata["legacy-small"]["model_size_declared"] is False + assert metadata["legacy-small"]["model_lab_eligible"] is True + assert metadata["declared-small"]["model_size_declared"] is True + assert metadata["declared-small"]["accelerator_artifacts"]["chestnut"] == { + "artifact_format": UNIFIED_ARTIFACT_FORMAT, + "artifact_filename": "declared-small-amd.pkl", + "artifact_size": 123, + "artifact_sha256": "a" * 64, + "artifact_chunk_count": 2, + "artifact_url": "", + "execution_device": "AMD", + } + assert metadata["chestnut"]["model_size"] == "chestnut" + assert metadata["chestnut"]["model_lab_eligible"] is False + assert model_manager.model_accelerator_artifact_available("declared-small") + assert not model_manager.model_accelerator_artifact_available("legacy-small") + assert model_manager.model_accelerator_artifact_path("declared-small") == ( + tmp_path / "declared-small_driving_chestnut_tinygrad.pkl" + ) + + +def test_model_manager_downloads_precompiled_accelerator_variant_without_compiling(tmp_path, monkeypatch): + monkeypatch.setattr(model_manager, "MODELS_PATH", tmp_path) + manager = object.__new__(ModelManager) + + class FakeParams: + def __init__(self, values=None): + self.values = values or {} + + def get(self, key): + return self.values.get(key) + + def get_bool(self, key): + return bool(self.values.get(key, False)) + + def put(self, key, value): + self.values[key] = value + + def remove(self, key): + self.values.pop(key, None) + + manager.params = FakeParams({"ModelManifestVersion": "v25"}) + manager.params_memory = FakeParams({model_manager.MODEL_LAB_DOWNLOAD_PARAM: "lat"}) + manager.downloading_model = False + metadata = manager._build_artifact_metadata_map([{ + "id": "lat", + "accelerator_artifacts": { + "chestnut": { + "artifact_filename": "lat-amd.pkl", + "artifact_size": 456, + "execution_device": "AMD", + }, + }, + }]) + (tmp_path / model_manager.ARTIFACT_METADATA_CACHE).write_text(json.dumps(metadata)) + monkeypatch.setattr(model_manager, "external_gpu_available", lambda: True) + monkeypatch.setattr(model_manager, "get_resource_urls", lambda: ["https://models.example"]) + monkeypatch.setattr(manager, "_load_artifact_url_map", lambda: {}) + calls = [] + + def fake_download(model_key, path, remote_filename, artifact_metadata, artifact_urls, resource_urls): + calls.append((model_key, path, remote_filename, artifact_metadata, artifact_urls, resource_urls)) + path.write_bytes(b"precompiled-amd") + return True + + monkeypatch.setattr(manager, "_download_artifact_to_path", fake_download) + + assert manager.download_model_accelerator("lat") + assert calls[0][0:3] == ( + "lat", + tmp_path / "lat_driving_chestnut_tinygrad.pkl", + "lat-amd.pkl", + ) + assert calls[0][3]["execution_device"] == "AMD" + assert calls[0][5] == ["https://models.example"] + assert manager.params_memory.values[model_manager.DOWNLOAD_PROGRESS_PARAM] == "Chestnut artifact downloaded!" + assert model_manager.MODEL_LAB_DOWNLOAD_PARAM not in manager.params_memory.values + + def test_local_gpu_compile_persists_runtime_metadata(tmp_path, monkeypatch): models_path = tmp_path / "models" compiled_path = tmp_path / "compiled" / "local-large_driving_tinygrad.pkl" diff --git a/starpilot/car/ford/lateral.py b/starpilot/car/ford/lateral.py index 8a9049dcc..0436fb37e 100644 --- a/starpilot/car/ford/lateral.py +++ b/starpilot/car/ford/lateral.py @@ -32,7 +32,6 @@ CURVATURE_LOOKAHEAD_MAX = 0.40 FORD_CURVATURE_LOOKAHEAD = { CAR.FORD_EXPLORER_MK6: 0.20, } -ANGLE_HANDOFF_PRESS_SECONDS = 0.5 ANGLE_HANDOFF_RECOVERY_SECONDS = 0.75 HANDOFF_PAUSE_MIN_FRAMES = 3 HANDOFF_PAUSE_FRAMES = 6 @@ -129,12 +128,9 @@ class FordLateralController: self.curvature_samples = deque(maxlen=max(2, round(0.3 / STEER_DT))) self.path_angle_last = 0.0 self.curvature_last = 0.0 - self.handoff_press_timer = 0.0 - self.handoff_driver_override = False self.angle_pause_frames = 0 self.angle_pause_cooldown = 0.0 self.angle_handoff_recovery = 0.0 - self.angle_handoff_rebase = False self.angle_stall_timer = 0.0 self.angle_stall_recoveries = 0 self._frame = 0 @@ -225,36 +221,19 @@ class FordLateralController: self.human_turn_enabled, CS.out.steeringPressed, CS.out.steeringAngleDeg) def _reset_handoff(self): - self.handoff_press_timer = 0.0 - self.handoff_driver_override = False self.angle_pause_frames = 0 self.angle_pause_cooldown = 0.0 self.angle_handoff_recovery = 0.0 - self.angle_handoff_rebase = False self.angle_stall_timer = 0.0 self.angle_stall_recoveries = 0 - def _angle_handoff_pause_active(self, CS) -> bool: - if not self.human_turn_enabled: - self._reset_handoff() - return False + def _update_angle_driver_override(self, steering_pressed: bool) -> bool: + if steering_pressed: + self.angle_handoff_recovery = ANGLE_HANDOFF_RECOVERY_SECONDS + return steering_pressed + def _angle_stall_pause_active(self, CS) -> bool: self.angle_pause_cooldown = max(0.0, self.angle_pause_cooldown - STEER_DT) - if CS.out.steeringPressed: - self.angle_handoff_recovery = 0.0 - self.angle_handoff_rebase = False - self.handoff_press_timer += STEER_DT - self.handoff_driver_override |= self.handoff_press_timer + 1e-9 >= ANGLE_HANDOFF_PRESS_SECONDS - else: - if self.handoff_driver_override: - self.angle_handoff_recovery = ANGLE_HANDOFF_RECOVERY_SECONDS - self.angle_handoff_rebase = True - if (self.angle_pause_cooldown <= 0.0 and self.angle_pause_frames <= 0 - and abs(self.path_angle_last) < HANDOFF_MAX_PATH_ANGLE): - self.angle_pause_frames = HANDOFF_PAUSE_FRAMES - self.handoff_driver_override = False - self.handoff_press_timer = 0.0 - if self.angle_pause_frames > 0: pause_frames_sent = HANDOFF_PAUSE_FRAMES - self.angle_pause_frames pscm_available = getattr(CS, "lateral_control_status", None) == LAT_CTL_STATUS_AVAILABLE @@ -347,9 +326,8 @@ class FordLateralController: self._reset_handoff() return self._inactive_angle_result(current) - manual_turn = self._manual_turn(CC, CS) - handoff_pause = self._angle_handoff_pause_active(CS) - if manual_turn or handoff_pause: + driver_override = self._update_angle_driver_override(bool(CS.out.steeringPressed)) + if self._angle_stall_pause_active(CS): return self._inactive_angle_result(current) v_ego = float(CS.out.vEgoRaw) @@ -369,7 +347,7 @@ class FordLateralController: measured_curvature = float(getattr(CC, "currentCurvature", current)) if not np.isfinite(measured_curvature): measured_curvature = current - requested = self._recover_angle_handoff(requested, measured_curvature) + requested = measured_curvature if driver_override else self._recover_angle_handoff(requested, measured_curvature) low_gain_high_speed, high_gain_high_speed = self._platform_angle_gains() low_gain = float(np.interp(v_ego, [13.5, 26.82], @@ -381,10 +359,7 @@ class FordLateralController: path_angle = float(np.clip(requested * v_ego * gain, PATH_ANGLE_MIN, PATH_ANGLE_MAX)) max_delta = float(np.interp(v_ego, [9.0, 10.0, 15.0, 25.0], [0.055, 0.055, 0.0425, 0.009])) - if self.angle_handoff_rebase: - self.angle_handoff_rebase = False - else: - path_angle = float(np.clip(path_angle, self.path_angle_last - max_delta, self.path_angle_last + max_delta)) + path_angle = float(np.clip(path_angle, self.path_angle_last - max_delta, self.path_angle_last + max_delta)) self.path_angle_last = path_angle lane_change = self._lane_change()[0] diff --git a/starpilot/car/ford/tests/test_lateral.py b/starpilot/car/ford/tests/test_lateral.py index bfd7c3b29..23108fe7f 100644 --- a/starpilot/car/ford/tests/test_lateral.py +++ b/starpilot/car/ford/tests/test_lateral.py @@ -106,15 +106,16 @@ def test_angle_strategy_uses_path_angle_and_shadow(controller): assert result.shadow_curvature == pytest.approx(0.0005) -def test_manual_turn_releases_lateral(controller): +def test_manual_turn_keeps_angle_session_active(controller): controller.human_turn_enabled = True - CC = SimpleNamespace(latActive=True) - CS = car_state(steering_pressed=True, steering_angle=50.0) - actuators = SimpleNamespace(curvature=0.001) + measured_curvature = 0.004 + CC = SimpleNamespace(latActive=True, currentCurvature=measured_curvature) + CS = car_state(speed=8.0, curvature=measured_curvature, steering_pressed=True, steering_angle=50.0) + actuators = SimpleNamespace(curvature=-0.005) for _ in range(61): result = controller.update_angle(CC, CS, actuators) - assert not result.active - assert result.path_angle == 0.0 + assert result.active + assert result.path_angle == pytest.approx(measured_curvature * 8.0 * 1.3) def test_curvature_control_stays_active_during_driver_correction(controller): @@ -144,19 +145,33 @@ def test_curvature_manual_turn_keeps_session_active_with_neutral_command(control assert result.path_angle == 0.0 -def test_angle_control_pulses_inactive_after_sustained_driver_correction(controller): +def test_angle_control_stays_active_after_sustained_driver_correction(controller): controller.human_turn_enabled = True - CC = SimpleNamespace(latActive=True) - actuators = SimpleNamespace(curvature=0.001) + measured_curvature = 0.001 + CC = SimpleNamespace(latActive=True, currentCurvature=measured_curvature) + actuators = SimpleNamespace(curvature=-0.001) - for _ in range(10): - assert controller.update_angle( - CC, car_state(steering_pressed=True, steering_angle=10.0), actuators).active + for _ in range(20): + result = controller.update_angle( + CC, car_state(curvature=measured_curvature, steering_pressed=True, steering_angle=10.0), actuators) + assert result.active + assert result.path_angle > 0.0 for _ in range(HANDOFF_PAUSE_FRAMES): - assert not controller.update_angle(CC, car_state(), actuators).active + assert controller.update_angle(CC, car_state(curvature=measured_curvature), actuators).active - assert controller.update_angle(CC, car_state(), actuators).active + +def test_angle_driver_override_is_handoff_safe_with_human_turn_detection_disabled(controller): + controller.human_turn_enabled = False + measured_curvature = 0.002 + result = controller.update_angle( + SimpleNamespace(latActive=True, currentCurvature=measured_curvature), + car_state(curvature=measured_curvature, steering_pressed=True), + SimpleNamespace(curvature=-0.002), + ) + + assert result.active + assert result.path_angle > 0.0 def test_short_driver_correction_does_not_pause_angle_control(controller): @@ -171,36 +186,34 @@ def test_short_driver_correction_does_not_pause_angle_control(controller): assert controller.update_angle(CC, car_state(), actuators).active -def test_angle_control_resumes_after_pscm_acknowledges_pause(controller): +def test_angle_driver_handoff_does_not_depend_on_pscm_mode_reset(controller): controller.human_turn_enabled = True - CC = SimpleNamespace(latActive=True) - actuators = SimpleNamespace(curvature=0.001) + measured_curvature = 0.001 + CC = SimpleNamespace(latActive=True, currentCurvature=measured_curvature) + actuators = SimpleNamespace(curvature=-0.001) for _ in range(10): assert controller.update_angle( - CC, car_state(steering_pressed=True, steering_angle=10.0), actuators).active + CC, car_state(curvature=measured_curvature, steering_pressed=True, steering_angle=10.0), actuators).active for _ in range(HANDOFF_PAUSE_MIN_FRAMES): - assert not controller.update_angle( + assert controller.update_angle( CC, car_state(lateral_control_status=1), actuators).active - assert controller.update_angle( - CC, car_state(lateral_control_status=1), actuators).active - -def test_long_manual_turn_still_resets_angle_control_on_release(controller): +def test_long_manual_turn_hands_angle_control_back_without_disabling(controller): controller.human_turn_enabled = True - CC = SimpleNamespace(latActive=True, currentCurvature=0.0) - actuators = SimpleNamespace(curvature=0.001) + measured_curvature = 0.004 + CC = SimpleNamespace(latActive=True, currentCurvature=measured_curvature) + actuators = SimpleNamespace(curvature=-0.005) for _ in range(40): - controller.update_angle( - CC, car_state(steering_pressed=True, steering_angle=50.0), actuators) + assert controller.update_angle( + CC, car_state(speed=8.0, curvature=measured_curvature, steering_pressed=True, steering_angle=50.0), actuators).active for _ in range(HANDOFF_PAUSE_FRAMES): - assert not controller.update_angle(CC, car_state(), actuators).active - - assert controller.update_angle(CC, car_state(), actuators).active + assert controller.update_angle( + CC, car_state(speed=8.0, curvature=measured_curvature), actuators).active def test_angle_handoff_reenters_from_measured_curvature(controller): @@ -213,9 +226,6 @@ def test_angle_handoff_reenters_from_measured_curvature(controller): for _ in range(10): controller.update_angle( CC, car_state(speed=8.0, curvature=measured_curvature, steering_pressed=True, steering_angle=10.0), actuators) - for _ in range(HANDOFF_PAUSE_FRAMES): - assert not controller.update_angle( - CC, car_state(speed=8.0, curvature=measured_curvature), actuators).active resumed = controller.update_angle(CC, car_state(speed=8.0, curvature=measured_curvature), actuators) assert resumed.active diff --git a/starpilot/common/assets/device_settings_layout.json b/starpilot/common/assets/device_settings_layout.json index 8c69209b0..dde3e088d 100644 --- a/starpilot/common/assets/device_settings_layout.json +++ b/starpilot/common/assets/device_settings_layout.json @@ -3686,16 +3686,6 @@ "galaxy_only": true, "settings_tier": "simple" }, - { - "key": "SubaruAvhOnAtStartup", - "label": "AVH On at Startup", - "description": "For supported Subaru Legacy 2025 vehicles, send one momentary Auto Vehicle Hold request after ignition while stationary and in Park or Neutral.", - "picker_description": "Requests Auto Vehicle Hold ON once after ignition on the supported Legacy.", - "data_type": "bool", - "ui_type": "toggle", - "galaxy_only": true, - "settings_tier": "simple" - }, { "key": "ClusterOffset", "label": "Dashboard Speed Offset", diff --git a/starpilot/common/model_lab.py b/starpilot/common/model_lab.py new file mode 100644 index 000000000..0c8135fdf --- /dev/null +++ b/starpilot/common/model_lab.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import json +from typing import Any + +import numpy as np + +MODEL_LAB_CONFIG_PARAM = "ModelLabConfig" +MODEL_LAB_RUNTIME_PARAM = "ModelLabRuntime" +MODEL_LAB_MIN_MODEL_VERSION = 8 + +LATERAL_OUTPUT_KEYS = ( + "desired_curvature", + "desired_curvature_stds", + "lat_planner_solution", + "lat_planner_solution_stds", + "lane_lines", + "lane_lines_stds", + "lane_lines_prob", + "road_edges", + "road_edges_stds", + "desire_state", + "desire_pred", +) + +CURRENT_FRAME_OUTPUT_KEYS = ( + "pose", + "pose_stds", + "wide_from_device_euler", + "wide_from_device_euler_stds", + "road_transform", + "road_transform_stds", +) + +LATERAL_PLAN_COLUMNS = (1, 4, 7, 11, 14) + + +def parse_model_version(version: Any) -> int | None: + text = str(version or "").strip().lower() + if not text.startswith("v") or not text[1:].isdigit(): + return None + return int(text[1:]) + + +def model_lab_version_supported(version: Any) -> bool: + parsed = parse_model_version(version) + return parsed is not None and parsed >= MODEL_LAB_MIN_MODEL_VERSION + + +def is_small_model_metadata(metadata: dict[str, Any] | None) -> bool: + metadata = metadata if isinstance(metadata, dict) else {} + if bool(metadata.get("uses_external_gpu", False)): + return False + size_class = str(metadata.get("model_size") or metadata.get("size_class") or "").strip().lower() + if size_class: + return size_class in {"small", "standard", "on_device", "on-device"} + + return True + + +def model_lab_manifest_eligible(metadata: dict[str, Any] | None, version: Any) -> bool: + metadata = metadata if isinstance(metadata, dict) else {} + explicit = metadata.get("model_lab_eligible") + if explicit is not None and not bool(explicit): + return False + return is_small_model_metadata(metadata) and model_lab_version_supported(version) + + +def normalize_model_lab_config(value: Any) -> dict[str, Any]: + if isinstance(value, bytes): + value = value.decode("utf-8", errors="ignore") + if isinstance(value, str): + try: + value = json.loads(value) if value.strip() else {} + except (TypeError, ValueError): + value = {} + if not isinstance(value, dict): + value = {} + + return { + "enabled": bool(value.get("enabled", False)), + "lateralModel": str(value.get("lateralModel") or "").strip(), + "longitudinalModel": str(value.get("longitudinalModel") or "").strip(), + } + + +def load_model_lab_config(params) -> dict[str, Any]: + try: + return normalize_model_lab_config(params.get(MODEL_LAB_CONFIG_PARAM)) + except Exception: + return normalize_model_lab_config(None) + + +def validate_model_lab_selection( + config: Any, + catalog: dict[str, dict[str, Any]], + *, + chestnut_ready: bool, + require_installed: bool = True, +) -> str | None: + normalized = normalize_model_lab_config(config) + if not normalized["enabled"]: + return None + if not chestnut_ready: + return "Chestnut is not connected and firmware-ready." + + lateral_id = normalized["lateralModel"] + longitudinal_id = normalized["longitudinalModel"] + if not lateral_id or not longitudinal_id: + return "Choose both a lateral and a longitudinal model." + if lateral_id == longitudinal_id: + return "Choose two different small models." + + for role, model_id in (("Lateral", lateral_id), ("Longitudinal", longitudinal_id)): + model = catalog.get(model_id) + if model is None: + return f"{role} model '{model_id}' is not in the current manifest." + if not bool(model.get("small", False)): + return f"{role} model '{model_id}' is Chestnut-class, not a small model." + if not bool(model.get("modelLabEligible", False)): + return f"{role} model '{model_id}' is not compatible with Model Laboratory." + if not bool(model.get("modelLabArtifactAvailable", False)): + return f"{role} model '{model_id}' has no precompiled AMD artifact in the current manifest." + if require_installed and not bool(model.get("modelLabArtifactInstalled", False)): + return f"{role} model '{model_id}' has not downloaded its precompiled AMD artifact." + + lateral_version = str(catalog[lateral_id].get("version") or "").strip() + longitudinal_version = str(catalog[longitudinal_id].get("version") or "").strip() + if lateral_version != longitudinal_version: + return "Choose models from the same behavior version; the longitudinal planner currently has one shared version contract." + return None + + +def _merge_plan_tensor(lateral: np.ndarray, longitudinal: np.ndarray) -> np.ndarray: + if lateral.shape != longitudinal.shape or lateral.ndim < 2 or lateral.shape[-1] < 15: + raise ValueError( + f"Model Laboratory plan tensors are incompatible: lateral={lateral.shape}, longitudinal={longitudinal.shape}" + ) + merged = longitudinal.copy() + merged[..., LATERAL_PLAN_COLUMNS] = lateral[..., LATERAL_PLAN_COLUMNS] + return merged + + +def _merge_action_tensor(lateral: np.ndarray, longitudinal: np.ndarray) -> np.ndarray: + if lateral.shape != longitudinal.shape or lateral.ndim < 1 or lateral.shape[-1] < 2: + raise ValueError( + f"Model Laboratory action tensors are incompatible: lateral={lateral.shape}, longitudinal={longitudinal.shape}" + ) + merged = longitudinal.copy() + merged[..., 0] = lateral[..., 0] + return merged + + +def compose_model_outputs( + lateral_output: dict[str, np.ndarray], + longitudinal_output: dict[str, np.ndarray], + current_frame_output: dict[str, np.ndarray] | None = None, +) -> dict[str, np.ndarray]: + """Compose normalized model outputs without mutating either runner's state.""" + if "plan" not in lateral_output or "plan" not in longitudinal_output: + raise ValueError("Model Laboratory requires a plan output from both models.") + + composed = dict(longitudinal_output) + composed["plan"] = _merge_plan_tensor(lateral_output["plan"], longitudinal_output["plan"]) + + if ("plan_stds" in lateral_output) != ("plan_stds" in longitudinal_output): + raise ValueError("Model Laboratory requires matching plan uncertainty outputs.") + if "plan_stds" in lateral_output and "plan_stds" in longitudinal_output: + composed["plan_stds"] = _merge_plan_tensor(lateral_output["plan_stds"], longitudinal_output["plan_stds"]) + else: + composed.pop("plan_stds", None) + + if ("action" in lateral_output) != ("action" in longitudinal_output): + raise ValueError("Model Laboratory requires matching action outputs.") + if "action" in lateral_output and "action" in longitudinal_output: + composed["action"] = _merge_action_tensor(lateral_output["action"], longitudinal_output["action"]) + else: + composed.pop("action", None) + if ("action_stds" in lateral_output) != ("action_stds" in longitudinal_output): + raise ValueError("Model Laboratory requires matching action uncertainty outputs.") + if "action_stds" in lateral_output and "action_stds" in longitudinal_output: + composed["action_stds"] = _merge_action_tensor(lateral_output["action_stds"], longitudinal_output["action_stds"]) + else: + composed.pop("action_stds", None) + + for key in LATERAL_OUTPUT_KEYS: + if key in lateral_output: + composed[key] = lateral_output[key] + else: + composed.pop(key, None) + current_frame_output = lateral_output if current_frame_output is None else current_frame_output + for key in CURRENT_FRAME_OUTPUT_KEYS: + if key in current_frame_output: + composed[key] = current_frame_output[key] + else: + composed.pop(key, None) + return composed + + +def hybrid_action_values(lateral_action: Any, longitudinal_action: Any) -> dict[str, Any]: + return { + "desiredCurvature": float(lateral_action.desiredCurvature), + "desiredAcceleration": float(longitudinal_action.desiredAcceleration), + "shouldStop": bool(longitudinal_action.shouldStop), + } diff --git a/starpilot/common/param_profiles.py b/starpilot/common/param_profiles.py new file mode 100644 index 000000000..0a60da91e --- /dev/null +++ b/starpilot/common/param_profiles.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import base64 +import json +import math +import threading +from datetime import UTC, datetime +from pathlib import Path + +from openpilot.common.params import ParamKeyFlag, ParamKeyType + + +PROFILE_FORMAT = "starpilot-params-profile" +PROFILE_VERSION = 1 +PROFILE_MAX_BYTES = 2_000_000 +DEFAULT_PROFILE_ROOT = Path("/data/toggle_backups") +PROFILE_SLOTS = { + "a": "Profile Slot A", + "b": "Profile Slot B", +} +PROFILE_NO_DEFAULT_KEYS = { + "AdbEnabled", + "AlphaLongitudinalEnabled", + "AlwaysOnDM", + "ExperimentalMode", + "ExperimentalModeConfirmed", + "IsLdwEnabled", + "IsMetric", + "IsRHD", + "IsRHDOverride", + "RecordAudio", + "RecordFront", + "SshEnabled", +} + +_PROFILE_LOCK = threading.Lock() + + +class ParamProfileError(ValueError): + pass + + +def _normalize_slot(slot: str) -> str: + normalized = str(slot or "").strip().lower() + if normalized not in PROFILE_SLOTS: + raise ParamProfileError("Unknown settings profile slot.") + return normalized + + +def _profile_path(slot: str, profile_root: Path | None = None) -> Path: + normalized = _normalize_slot(slot) + root = Path(profile_root) if profile_root is not None else DEFAULT_PROFILE_ROOT + return root / f".params-profile-{normalized}.json" + + +def _key_text(raw_key) -> str: + return raw_key.decode("utf-8") if isinstance(raw_key, bytes) else str(raw_key) + + +def eligible_profile_keys(params, *, excluded_keys: set[str] | None = None) -> set[str]: + excluded = excluded_keys or set() + keys = set() + for raw_key in params.all_keys(): + key = _key_text(raw_key) + if key in excluded: + continue + + try: + flags = params.get_key_flag(raw_key) + default_value = params.get_default_value(raw_key) + except Exception: + continue + + if not flags & ParamKeyFlag.PERSISTENT or flags & ParamKeyFlag.DONT_LOG: + continue + if default_value is None and key not in PROFILE_NO_DEFAULT_KEYS: + continue + keys.add(key) + return keys + + +def _get_current_value(params, key: str): + try: + return params.get(key, return_default=True) + except TypeError: + return params.get(key) + + +def _serialize_value(value_type: ParamKeyType, value): + if value_type == ParamKeyType.BYTES: + raw_value = value if isinstance(value, bytes) else str(value).encode("utf-8") + return base64.b64encode(raw_value).decode("ascii") + if value_type == ParamKeyType.TIME: + return value.isoformat() if isinstance(value, datetime) else str(value) + if isinstance(value, tuple): + return list(value) + if isinstance(value, float) and not math.isfinite(value): + raise ValueError("non-finite numeric value") + return value + + +def _deserialize_value(value_type: ParamKeyType, value): + if value_type == ParamKeyType.BYTES: + if not isinstance(value, str): + raise ValueError("invalid bytes value") + return base64.b64decode(value.encode("ascii"), validate=True) + if value_type == ParamKeyType.TIME: + if not isinstance(value, str): + raise ValueError("invalid time value") + return datetime.fromisoformat(value) + return value + + +def _build_profile_payload(params, slot: str, allowed_keys: set[str] | None = None) -> dict: + normalized = _normalize_slot(slot) + keys = eligible_profile_keys(params) if allowed_keys is None else set(allowed_keys) + settings = {} + for key in sorted(keys): + try: + value = _get_current_value(params, key) + if value is None: + continue + value_type = ParamKeyType(params.get_type(key)) + serialized_value = _serialize_value(value_type, value) + json.dumps(serialized_value, allow_nan=False) + settings[key] = { + "type": int(value_type), + "value": serialized_value, + } + except (TypeError, ValueError, OverflowError): + continue + + if not settings: + raise ParamProfileError("No compatible settings were available to save.") + + return { + "format": PROFILE_FORMAT, + "version": PROFILE_VERSION, + "slot": normalized, + "createdAt": datetime.now(UTC).isoformat(), + "settingsCount": len(settings), + "settings": settings, + } + + +def save_profile(params, slot: str, *, allowed_keys: set[str] | None = None, profile_root: Path | None = None) -> dict: + normalized = _normalize_slot(slot) + payload = _build_profile_payload(params, normalized, allowed_keys) + encoded = json.dumps(payload, indent=2, allow_nan=False).encode("utf-8") + if len(encoded) > PROFILE_MAX_BYTES: + raise ParamProfileError("The settings profile is too large to save.") + + path = _profile_path(normalized, profile_root) + temp_path = path.with_suffix(".tmp") + with _PROFILE_LOCK: + path.parent.mkdir(parents=True, exist_ok=True) + temp_path.write_bytes(encoded) + temp_path.chmod(0o600) + temp_path.replace(path) + return profile_status(normalized, profile_root=profile_root) + + +def _read_profile(slot: str, profile_root: Path | None = None) -> dict: + normalized = _normalize_slot(slot) + path = _profile_path(normalized, profile_root) + if not path.is_file(): + raise ParamProfileError(f"{PROFILE_SLOTS[normalized]} has not been saved yet.") + if path.stat().st_size > PROFILE_MAX_BYTES: + raise ParamProfileError("The saved settings profile is too large.") + + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise ParamProfileError("The saved settings profile is damaged.") from error + + if not isinstance(payload, dict) or payload.get("format") != PROFILE_FORMAT: + raise ParamProfileError("The saved settings profile is invalid.") + version = payload.get("version") + if not isinstance(version, int) or version > PROFILE_VERSION: + raise ParamProfileError("The saved settings profile requires a newer StarPilot version.") + if payload.get("slot") != normalized or not isinstance(payload.get("settings"), dict): + raise ParamProfileError("The saved settings profile is invalid.") + return payload + + +def load_profile(params, slot: str, *, allowed_keys: set[str] | None = None, profile_root: Path | None = None, + legacy_renames: dict[str, str] | None = None) -> dict: + normalized = _normalize_slot(slot) + with _PROFILE_LOCK: + payload = _read_profile(normalized, profile_root) + keys = eligible_profile_keys(params) if allowed_keys is None else set(allowed_keys) + renames = legacy_renames or {} + restored_count = 0 + skipped_count = 0 + for saved_key, entry in payload["settings"].items(): + key = renames.get(saved_key, saved_key) + if not isinstance(key, str) or key not in keys or not isinstance(entry, dict): + skipped_count += 1 + continue + try: + current_type = ParamKeyType(params.get_type(key)) + saved_type = ParamKeyType(entry.get("type")) + if saved_type != current_type or "value" not in entry: + raise ValueError("setting type changed") + params.put(key, _deserialize_value(current_type, entry["value"])) + restored_count += 1 + except (KeyError, TypeError, ValueError, OverflowError): + skipped_count += 1 + + if restored_count == 0: + raise ParamProfileError("No compatible settings were found in this profile.") + return { + "slot": normalized, + "label": PROFILE_SLOTS[normalized], + "restoredCount": restored_count, + "skippedCount": skipped_count, + } + + +def profile_status(slot: str, *, profile_root: Path | None = None) -> dict: + normalized = _normalize_slot(slot) + status = { + "slot": normalized, + "label": PROFILE_SLOTS[normalized], + "saved": False, + "createdAt": None, + "settingsCount": 0, + } + path = _profile_path(normalized, profile_root) + if not path.is_file(): + return status + try: + payload = _read_profile(normalized, profile_root) + except ParamProfileError: + return {**status, "saved": True, "invalid": True} + return { + **status, + "saved": True, + "createdAt": payload.get("createdAt"), + "settingsCount": len(payload["settings"]), + } + + +def list_profiles(*, profile_root: Path | None = None) -> list[dict]: + return [profile_status(slot, profile_root=profile_root) for slot in PROFILE_SLOTS] diff --git a/starpilot/common/safe_mode.py b/starpilot/common/safe_mode.py index 062db2353..f96e72ceb 100644 --- a/starpilot/common/safe_mode.py +++ b/starpilot/common/safe_mode.py @@ -22,6 +22,7 @@ SAFE_MODE_MANAGED_KEYS = ( "DrivingModelName", "ModelVersion", "DrivingModelVersion", + "ModelLabConfig", "ModelRandomizer", "LatSmoothSeconds", "LongSmoothSeconds", @@ -198,7 +199,6 @@ SAFE_MODE_MANAGED_KEYS = ( "SubaruSNG", "SubaruSNGManualParkingBrake", "SubaruStopStartOff", - "SubaruAvhOnAtStartup", "VoltSNG", "JeepBrakeHold", "GMAutoHold", @@ -217,7 +217,6 @@ SAFE_MODE_FIXED_VALUES = { "LongitudinalPersonality": int(log.LongitudinalPersonality.relaxed), "UseAutoSteerDelay": True, "SubaruStopStartOff": False, - "SubaruAvhOnAtStartup": False, } SAFE_MODE_STOCK_PARAM_MAP = { diff --git a/starpilot/common/starpilot_variables.py b/starpilot/common/starpilot_variables.py index c98f0d896..b72dbd11e 100644 --- a/starpilot/common/starpilot_variables.py +++ b/starpilot/common/starpilot_variables.py @@ -20,7 +20,7 @@ from opendbc.car.gm.values import CAR as GM_CAR, EV_CAR as GM_EV_CAR, GMFlags from opendbc.car.hyundai.values import CAR as HYUNDAI_CAR, EV_CAR as HYUNDAI_EV_CAR, HyundaiFlags, HyundaiStarPilotSafetyFlags from opendbc.car.interfaces import TORQUE_SUBSTITUTE_PATH, CarInterfaceBase, GearShifter from opendbc.car.mock.values import CAR as MOCK -from opendbc.car.subaru.values import SUBARU_AVH_CARS, SUBARU_STOP_START_CARS, SubaruFlags +from opendbc.car.subaru.values import SUBARU_STOP_START_CARS, SubaruFlags from opendbc.car.tesla.values import CAR as TESLA_CAR from opendbc.car.toyota.values import CAR as TOYOTA_CAR, ToyotaStarPilotFlags from openpilot.common.basedir import BASEDIR @@ -1545,10 +1545,6 @@ class StarPilotVariables: toggle.subaru_stop_start_off = self.get_value( "SubaruStopStartOff", condition=toggle.car_model in SUBARU_STOP_START_CARS, ) - toggle.subaru_avh_on = self.get_value( - "SubaruAvhOnAtStartup", condition=toggle.car_model in SUBARU_AVH_CARS, - ) - toggle.jeep_brake_hold = self.get_value( "JeepBrakeHold", condition=toggle.car_make == "chrysler" and toggle.car_model in CHRYSLER_JEEPS, diff --git a/starpilot/common/tests/test_model_lab.py b/starpilot/common/tests/test_model_lab.py new file mode 100644 index 000000000..48ab94e49 --- /dev/null +++ b/starpilot/common/tests/test_model_lab.py @@ -0,0 +1,160 @@ +from types import SimpleNamespace + +import numpy as np +import pytest + +from openpilot.starpilot.common.model_lab import ( + LATERAL_PLAN_COLUMNS, + compose_model_outputs, + hybrid_action_values, + is_small_model_metadata, + model_lab_manifest_eligible, + normalize_model_lab_config, + validate_model_lab_selection, +) + + +def _catalog_model(version="v15", *, small=True, eligible=True, artifact_available=True, artifact_installed=True): + return { + "version": version, + "small": small, + "modelLabEligible": eligible, + "modelLabArtifactAvailable": artifact_available, + "modelLabArtifactInstalled": artifact_installed, + } + + +def test_manifest_eligibility_uses_explicit_size_and_legacy_gpu_inference(): + assert not is_small_model_metadata({"model_size": "small", "uses_external_gpu": True}) + assert not is_small_model_metadata({"model_size": "chestnut", "uses_external_gpu": False}) + assert is_small_model_metadata({"uses_external_gpu": False}) + assert not is_small_model_metadata({"uses_external_gpu": True}) + assert model_lab_manifest_eligible({"uses_external_gpu": False}, "v15") + assert not model_lab_manifest_eligible({"uses_external_gpu": True}, "v16") + assert not model_lab_manifest_eligible({"model_lab_eligible": False}, "v15") + assert not model_lab_manifest_eligible({"uses_external_gpu": False}, "v7") + + +def test_config_normalization_is_closed_by_default(): + assert normalize_model_lab_config("not-json") == { + "enabled": False, + "lateralModel": "", + "longitudinalModel": "", + } + assert normalize_model_lab_config('{"enabled": true, "lateralModel": " lat ", "longitudinalModel": "long"}') == { + "enabled": True, + "lateralModel": "lat", + "longitudinalModel": "long", + } + + +@pytest.mark.parametrize( + ("chestnut_ready", "catalog", "lateral", "longitudinal", "expected"), + [ + (False, {"lat": _catalog_model(), "long": _catalog_model()}, "lat", "long", "Chestnut"), + (True, {"lat": _catalog_model()}, "lat", "missing", "current manifest"), + (True, {"lat": _catalog_model(small=False), "long": _catalog_model()}, "lat", "long", "Chestnut-class"), + (True, {"lat": _catalog_model(artifact_available=False), "long": _catalog_model()}, "lat", "long", "no precompiled AMD"), + (True, {"lat": _catalog_model(artifact_installed=False), "long": _catalog_model()}, "lat", "long", "not downloaded"), + (True, {"lat": _catalog_model("v15"), "long": _catalog_model("v9")}, "lat", "long", "same behavior version"), + ], +) +def test_selection_validation_rejects_unsafe_pairs(chestnut_ready, catalog, lateral, longitudinal, expected): + error = validate_model_lab_selection( + {"enabled": True, "lateralModel": lateral, "longitudinalModel": longitudinal}, + catalog, + chestnut_ready=chestnut_ready, + ) + assert expected in error + + +def test_selection_validation_accepts_distinct_ready_small_same_version_models(): + catalog = {"lat": _catalog_model(), "long": _catalog_model()} + assert validate_model_lab_selection( + {"enabled": True, "lateralModel": "lat", "longitudinalModel": "long"}, + catalog, + chestnut_ready=True, + ) is None + assert validate_model_lab_selection({"enabled": False}, {}, chestnut_ready=False) is None + + +def test_composition_assigns_lateral_and_longitudinal_outputs_without_mutation(): + lateral_plan = np.full((1, 33, 15), 11.0, dtype=np.float32) + longitudinal_plan = np.full((1, 33, 15), 22.0, dtype=np.float32) + lateral_action = np.array([[1.5, 2.5]], dtype=np.float32) + longitudinal_action = np.array([[3.5, 4.5]], dtype=np.float32) + lateral = { + "plan": lateral_plan, + "plan_stds": lateral_plan + 1, + "action": lateral_action, + "action_stds": lateral_action + 1, + "lane_lines": np.array([111.0]), + "pose": np.array([113.0]), + "lead": np.array([112.0]), + } + longitudinal = { + "plan": longitudinal_plan, + "plan_stds": longitudinal_plan + 2, + "action": longitudinal_action, + "action_stds": longitudinal_action + 2, + "lane_lines": np.array([221.0]), + "pose": np.array([223.0]), + "lead": np.array([222.0]), + } + + composed = compose_model_outputs(lateral, longitudinal) + + lateral_columns = set(LATERAL_PLAN_COLUMNS) + for column in range(15): + expected = 11.0 if column in lateral_columns else 22.0 + np.testing.assert_array_equal(composed["plan"][..., column], expected) + assert composed["action"][0, 0] == lateral_action[0, 0] + assert composed["action"][0, 1] == longitudinal_action[0, 1] + assert composed["lane_lines"] is lateral["lane_lines"] + assert composed["pose"] is lateral["pose"] + assert composed["lead"] is longitudinal["lead"] + np.testing.assert_array_equal(lateral_plan, 11.0) + np.testing.assert_array_equal(longitudinal_plan, 22.0) + + composed_on_longitudinal_frame = compose_model_outputs(lateral, longitudinal, longitudinal) + assert composed_on_longitudinal_frame["pose"] is longitudinal["pose"] + + +def test_composition_fails_closed_for_incompatible_plan_contracts(): + with pytest.raises(ValueError, match="incompatible"): + compose_model_outputs( + {"plan": np.zeros((1, 33, 15))}, + {"plan": np.zeros((1, 32, 15))}, + ) + + +def test_composition_does_not_leak_longitudinal_values_into_lateral_only_fields(): + composed = compose_model_outputs( + {"plan": np.zeros((1, 33, 15))}, + { + "plan": np.zeros((1, 33, 15)), + "desired_curvature": np.ones((1, 1)), + "lane_lines": np.ones((1, 4, 33, 2)), + }, + ) + assert "action" not in composed + assert "desired_curvature" not in composed + assert "lane_lines" not in composed + + +def test_composition_rejects_asymmetric_action_contracts(): + with pytest.raises(ValueError, match="matching action outputs"): + compose_model_outputs( + {"plan": np.zeros((1, 33, 15))}, + {"plan": np.zeros((1, 33, 15)), "action": np.ones((1, 2))}, + ) + + +def test_hybrid_action_uses_only_the_assigned_responsibility(): + lateral = SimpleNamespace(desiredCurvature=0.125, desiredAcceleration=99, shouldStop=True) + longitudinal = SimpleNamespace(desiredCurvature=88, desiredAcceleration=-0.75, shouldStop=False) + assert hybrid_action_values(lateral, longitudinal) == { + "desiredCurvature": 0.125, + "desiredAcceleration": -0.75, + "shouldStop": False, + } diff --git a/starpilot/common/tests/test_param_profiles.py b/starpilot/common/tests/test_param_profiles.py new file mode 100644 index 000000000..e4b78aac7 --- /dev/null +++ b/starpilot/common/tests/test_param_profiles.py @@ -0,0 +1,86 @@ +import json + +import pytest + +from openpilot.common.params import ParamKeyFlag, ParamKeyType +from openpilot.starpilot.common import param_profiles + + +class FakeParams: + def __init__(self): + persistent = ParamKeyFlag.PERSISTENT + self.definitions = { + "BooleanSetting": (True, ParamKeyType.BOOL, persistent), + "NumericSetting": (1.5, ParamKeyType.FLOAT, persistent), + "JsonSetting": ({"mode": "default"}, ParamKeyType.JSON, persistent), + "SecretSetting": ("", ParamKeyType.STRING, persistent | ParamKeyFlag.DONT_LOG), + "TransientSetting": (False, ParamKeyType.BOOL, ParamKeyFlag.CLEAR_ON_MANAGER_START), + } + self.values = { + "BooleanSetting": False, + "NumericSetting": 2.75, + "JsonSetting": {"mode": "custom"}, + "SecretSetting": "secret", + "TransientSetting": True, + } + + def all_keys(self): + return list(self.definitions) + + def get(self, key, return_default=False): + default = self.definitions[key][0] if return_default else None + return self.values.get(key, default) + + def get_default_value(self, key): + return self.definitions[key][0] + + def get_key_flag(self, key): + return self.definitions[key][2] + + def get_type(self, key): + return self.definitions[key][1] + + def put(self, key, value): + self.values[key] = value + + +def test_profile_slots_round_trip_only_eligible_settings(tmp_path): + params = FakeParams() + + status = param_profiles.save_profile(params, "a", profile_root=tmp_path) + payload = json.loads((tmp_path / ".params-profile-a.json").read_text()) + + assert status["saved"] is True + assert status["settingsCount"] == 3 + assert set(payload["settings"]) == {"BooleanSetting", "NumericSetting", "JsonSetting"} + + params.values.update({ + "BooleanSetting": True, + "NumericSetting": 9.0, + "JsonSetting": {"mode": "changed"}, + "SecretSetting": "new-secret", + "TransientSetting": False, + }) + result = param_profiles.load_profile(params, "a", profile_root=tmp_path) + + assert result["restoredCount"] == 3 + assert result["skippedCount"] == 0 + assert params.values["BooleanSetting"] is False + assert params.values["NumericSetting"] == 2.75 + assert params.values["JsonSetting"] == {"mode": "custom"} + assert params.values["SecretSetting"] == "new-secret" + assert params.values["TransientSetting"] is False + + +def test_profile_slots_report_missing_and_damaged_profiles(tmp_path): + params = FakeParams() + + with pytest.raises(param_profiles.ParamProfileError, match="has not been saved"): + param_profiles.load_profile(params, "b", profile_root=tmp_path) + with pytest.raises(param_profiles.ParamProfileError, match="Unknown"): + param_profiles.save_profile(params, "c", profile_root=tmp_path) + + (tmp_path / ".params-profile-b.json").write_text("not json") + assert param_profiles.profile_status("b", profile_root=tmp_path)["invalid"] is True + with pytest.raises(param_profiles.ParamProfileError, match="damaged"): + param_profiles.load_profile(params, "b", profile_root=tmp_path) diff --git a/starpilot/starpilot_process.py b/starpilot/starpilot_process.py index c44d68950..0db249cac 100644 --- a/starpilot/starpilot_process.py +++ b/starpilot/starpilot_process.py @@ -17,7 +17,12 @@ from openpilot.system.sentry import capture_flm_tune_submission, capture_report from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID from openpilot.system.hardware.hw import Paths -from openpilot.starpilot.assets.model_manager import MODEL_DOWNLOAD_ALL_PARAM, MODEL_DOWNLOAD_PARAM, ModelManager +from openpilot.starpilot.assets.model_manager import ( + MODEL_DOWNLOAD_ALL_PARAM, + MODEL_DOWNLOAD_PARAM, + MODEL_LAB_DOWNLOAD_PARAM, + ModelManager, +) from openpilot.starpilot.assets.theme_manager import THEME_COMPONENT_PARAMS, ThemeManager from openpilot.starpilot.common.starpilot_functions import update_maps, update_openpilot from openpilot.starpilot.common.safe_mode import ( @@ -102,6 +107,12 @@ def check_assets(now, model_manager, theme_manager, thread_manager, params, para model_to_download = model_to_download.decode("utf-8", errors="replace") if model_to_download: thread_manager.run_with_lock(model_manager.download_model, (model_to_download,)) + else: + lab_model_to_download = params_memory.get(MODEL_LAB_DOWNLOAD_PARAM) + if isinstance(lab_model_to_download, bytes): + lab_model_to_download = lab_model_to_download.decode("utf-8", errors="replace") + if lab_model_to_download: + thread_manager.run_with_lock(model_manager.download_model_accelerator, (lab_model_to_download,)) for asset_type, asset_param in THEME_COMPONENT_PARAMS.items(): asset_to_download = params_memory.get(asset_param) diff --git a/starpilot/system/the_galaxy/assets/components/router.js b/starpilot/system/the_galaxy/assets/components/router.js index 8e92e4a64..e22adaf8d 100644 --- a/starpilot/system/the_galaxy/assets/components/router.js +++ b/starpilot/system/the_galaxy/assets/components/router.js @@ -21,6 +21,7 @@ import { Sidebar } from "/assets/components/sidebar.js?v=controllers-nav-1" import { SentryMode } from "/assets/components/tools/sentry.js" import { SpeedLimits } from "/assets/components/tools/speed_limits.js" import { ModelManager } from "/assets/components/tools/model_manager.js?v=20260825a" +import { ModelLaboratory } from "/assets/components/tools/model_laboratory.js?v=model-lab-1" import { LivePlots } from "/assets/components/tools/plots.js" import { ThemeMaker } from "/assets/components/tools/theme_maker.js" import { TestingGround } from "/assets/components/tools/testing_ground.js" @@ -87,6 +88,7 @@ function Root() { createRoute("settings", "/settings/:section/:subsection?", SettingsView), createRoute("speed_limits", "/download_speed_limits", SpeedLimits), createRoute("model_manager", "/manage_models", ModelManager), + createRoute("model_laboratory", "/model_laboratory", ModelLaboratory), createRoute("tuning", "/tuning", Tuning), createRoute("lateral_maneuvers", "/lateral_maneuvers", Tuning), createRoute("longitudinal_maneuvers", "/longitudinal_maneuvers", LongitudinalManeuvers), diff --git a/starpilot/system/the_galaxy/assets/components/sidebar.js b/starpilot/system/the_galaxy/assets/components/sidebar.js index f3e080769..53b53b105 100644 --- a/starpilot/system/the_galaxy/assets/components/sidebar.js +++ b/starpilot/system/the_galaxy/assets/components/sidebar.js @@ -23,6 +23,7 @@ const MENU_ITEMS = { { name: "Navigation", link: "/set_navigation_destination", icon: "bi-geo-alt-fill" }, { name: "App Keys", link: "/manage_navigation_keys", icon: "bi-key-fill" }, { name: "Model Manager", link: "/manage_models", icon: "bi-cpu" }, + { name: "Model Laboratory", link: "/model_laboratory", icon: "bi-bezier2" }, { name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" }, { name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2" }, { name: "Troubleshoot", link: "/troubleshoot", icon: "bi-tools" }, diff --git a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js index bd58be84e..08840a7d5 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js +++ b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js @@ -40,7 +40,6 @@ const VEHICLE_SETTING_MAKES = { SubaruSNG: ["Subaru"], SubaruSNGManualParkingBrake: ["Subaru"], SubaruStopStartOff: ["Subaru"], - SubaruAvhOnAtStartup: ["Subaru"], ClusterOffset: ["Lexus", "Toyota"], SNGHack: ["Lexus", "Toyota"], ToyotaAutoHold: ["Lexus", "Toyota"], @@ -523,6 +522,10 @@ function numericBounds(param) { return { min: 1, max: 101, step: 1 } } + if (param.key === "LaneCenterOffset") { + return { min: -0.3, max: 0.3, step: 0.01 } + } + // Personality jerk params are stored as percentage-style integers (25..200). // Layout metadata currently uses normalized 0.5..3.0 ranges, which breaks // the +/- stepper and clamps values like 50 down to 3. @@ -1053,6 +1056,18 @@ function stepNumericParam(param, direction) { updateNumericParam(param, next) } +function canStepNumericParam(param, direction) { + const bounds = numericBounds(param) + const min = Number(bounds.min) + const max = Number(bounds.max) + const current = resolveCurrentNumericValue(param, bounds) + const precision = stepPrecision(bounds.step, param.precision) + const epsilon = Math.pow(10, -(precision + 2)) + + if (!Number.isFinite(min) || !Number.isFinite(max) || !Number.isFinite(current)) return false + return direction < 0 ? current > min + epsilon : current < max - epsilon +} + function applyManualNumericParam(param) { if (isNumericUpdating(param.key)) return @@ -1613,8 +1628,6 @@ function renderSettingRow(p) { const precision = stepPrecision(bounds.step, p.precision) const epsilon = Math.pow(10, -(precision + 2)) const updating = isNumericUpdating(p.key) - const canDecrease = !updating && currentNumeric > (Number(bounds.min) + epsilon) - const canIncrease = !updating && currentNumeric < (Number(bounds.max) - epsilon) const defaultNumeric = resolveDefaultNumericValue(p, bounds) const defaultLabel = defaultNumeric !== null ? formatSliderValue(defaultNumeric, String(bounds.step), p.precision, p.key) @@ -1625,7 +1638,7 @@ function renderSettingRow(p) {
${formatSliderValue(bounds.min, String(bounds.step), p.precision, p.key)} to ${formatSliderValue(bounds.max, String(bounds.step), p.precision, p.key)} @@ -1658,7 +1671,7 @@ function renderSettingRow(p) {
` })()} diff --git a/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.css b/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.css new file mode 100644 index 000000000..030380245 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.css @@ -0,0 +1,268 @@ +.ml-wrapper { + color: var(--text-color); + max-width: 1120px; + padding: 0 var(--padding-base) var(--padding-base) 0; +} + +.ml-hero, +.ml-card-heading, +.ml-actions, +.ml-chips, +.ml-preview { + align-items: center; + display: flex; + flex-wrap: wrap; +} + +.ml-hero { + gap: var(--gap-lg); + justify-content: space-between; + margin-bottom: var(--margin-base); +} + +.ml-hero h2, +.ml-card h3 { + margin: 0; +} + +.ml-hero p, +.ml-card-heading p { + color: var(--text-muted); + margin: 0.3rem 0 0; +} + +.ml-kicker { + color: var(--accent-color); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.12em; + margin-bottom: 0.2rem; + text-transform: uppercase; +} + +.ml-card { + background: var(--card-bg); + border: 1px solid var(--sidebar-border-color); + border-radius: var(--border-radius-lg); + margin-bottom: var(--margin-base); + padding: var(--padding-base); +} + +.ml-card-heading { + gap: var(--gap-md); + justify-content: space-between; + margin-bottom: var(--margin-base); +} + +.ml-chip, +.ml-state { + background: var(--secondary-bg); + border: 1px solid var(--sidebar-border-color); + border-radius: 999px; + color: var(--text-muted); + display: inline-flex; + font-size: 0.78rem; + padding: 0.2rem 0.55rem; +} + +.ml-chips { + gap: var(--gap-xs); +} + +.ml-chip-good, +.ml-state.is-enabled { + background: rgba(72, 187, 120, 0.16); + border-color: rgba(72, 187, 120, 0.5); + color: var(--success-bg); +} + +.ml-chip-warning { + background: rgba(224, 85, 119, 0.12); + border-color: rgba(224, 85, 119, 0.45); + color: var(--danger-fg); +} + +.ml-pair { + align-items: end; + display: grid; + gap: var(--gap-md); + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); +} + +.ml-pair label { + display: grid; + gap: 0.35rem; +} + +.ml-pair label > span { + font-weight: 700; +} + +.ml-pair small, +.ml-muted { + color: var(--text-muted); +} + +.ml-select { + background: var(--input-bg); + border: 1px solid var(--sidebar-border-color); + border-radius: var(--border-radius-base); + color: var(--text-color); + font-size: var(--font-size-base); + min-height: 2.7rem; + padding: 0.45rem 0.6rem; + width: 100%; +} + +.ml-plus { + color: var(--accent-color); + font-size: 1.8rem; + line-height: 2.7rem; +} + +.ml-preview { + background: var(--secondary-bg); + border-radius: var(--border-radius-base); + gap: var(--gap-sm); + margin-top: var(--margin-base); + padding: 0.7rem 0.8rem; +} + +.ml-preview span { + color: var(--text-muted); +} + +.ml-actions { + gap: var(--gap-sm); + margin-top: var(--margin-base); +} + +.ml-button { + background: var(--sidebar-active-bg); + border: 0; + border-radius: var(--border-radius-base); + color: var(--text-color); + min-height: 2.4rem; + padding: 0.5rem 0.85rem; +} + +.ml-button-primary { + background: var(--success-bg); + color: var(--color-black); +} + +.ml-button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.ml-validation { + color: var(--danger-fg); + margin: 0.75rem 0 0; +} + +.ml-alert { + border: 1px solid; + border-radius: var(--border-radius-base); + margin-bottom: var(--margin-base); + padding: 0.65rem 0.8rem; +} + +.ml-alert-error { + background: rgba(224, 85, 119, 0.12); + border-color: rgba(224, 85, 119, 0.45); + color: var(--danger-fg); +} + +.ml-alert-good { + background: rgba(72, 187, 120, 0.12); + border-color: rgba(72, 187, 120, 0.45); +} + +.ml-runtime-grid { + display: grid; + gap: var(--gap-md); + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-bottom: var(--margin-base); +} + +.ml-runtime-grid > div { + background: var(--secondary-bg); + border-radius: var(--border-radius-base); + display: grid; + gap: 0.25rem; + padding: 0.7rem; +} + +.ml-runtime-grid span { + color: var(--text-muted); + font-size: 0.78rem; +} + +.ml-model-list { + border: 1px solid var(--sidebar-border-color); + border-radius: var(--border-radius-base); + max-height: 24rem; + overflow: auto; +} + +.ml-model { + align-items: center; + border-bottom: 1px solid var(--sidebar-border-color); + display: flex; + gap: var(--gap-md); + justify-content: space-between; + padding: 0.65rem 0.75rem; +} + +.ml-model:last-child { + border-bottom: 0; +} + +.ml-note { + color: var(--text-muted); + margin-top: var(--margin-base); +} + +.ml-note code { + color: var(--text-color); + overflow-wrap: anywhere; +} + +.ml-findings { + display: grid; + gap: var(--gap-md); + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.ml-findings ul { + color: var(--text-muted); + margin-bottom: 0; + padding-left: 1.2rem; +} + +.ml-findings li + li { + margin-top: 0.45rem; +} + +@media (max-width: 760px) { + .ml-wrapper { + padding-right: 0; + } + + .ml-pair, + .ml-runtime-grid, + .ml-findings { + grid-template-columns: 1fr; + } + + .ml-plus { + line-height: 1; + text-align: center; + } + + .ml-model { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.js b/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.js new file mode 100644 index 000000000..8efb23c11 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/components/tools/model_laboratory.js @@ -0,0 +1,384 @@ +import { html, reactive } from "/assets/vendor/arrow-core.js" + +const state = reactive({ + loading: true, + saving: false, + error: "", + message: "", + chestnutReady: false, + isOnroad: false, + configuration: { enabled: false, lateralModel: "", longitudinalModel: "" }, + runtime: {}, + download: {}, + models: [], + summary: {}, + manifest: { version: "unknown", shortcomings: [], opportunities: [] }, +}) + +let initialized = false +let pollHandle = null + +function modelById(modelId) { + return state.models.find(model => model.value === modelId) +} + +function modelLabel(modelId) { + return modelById(modelId)?.label || modelId || "not selected" +} + +function readyModels() { + return state.models.filter(model => model.modelLabArtifactAvailable) +} + +function candidateModels(role) { + const ready = readyModels() + if (role !== "longitudinal") return ready + const lateral = modelById(state.configuration.lateralModel) + if (!lateral) return ready + return ready.filter(model => model.value !== lateral.value && model.version === lateral.version) +} + +function selectionError() { + if (!state.chestnutReady) return "Connect a firmware-ready Chestnut first." + if (state.isOnroad) return "Park before changing the laboratory pair." + const lateral = modelById(state.configuration.lateralModel) + const longitudinal = modelById(state.configuration.longitudinalModel) + if (!lateral || !longitudinal) return "Choose two small models with published Chestnut artifacts." + if (lateral.value === longitudinal.value) return "Lateral and longitudinal models must be different." + if (!lateral.modelLabArtifactAvailable || !longitudinal.modelLabArtifactAvailable) { + return "Both models need a precompiled AMD artifact in the manifest." + } + if (!lateral.modelLabArtifactInstalled || !longitudinal.modelLabArtifactInstalled) { + return "Prepare both precompiled AMD artifacts first." + } + if (lateral.version !== longitudinal.version) return "Both models must use the same behavior version." + return "" +} + +function applyPayload(payload) { + state.chestnutReady = Boolean(payload?.chestnutReady) + state.isOnroad = Boolean(payload?.isOnroad) + state.configuration = { + enabled: Boolean(payload?.configuration?.enabled), + lateralModel: String(payload?.configuration?.lateralModel || ""), + longitudinalModel: String(payload?.configuration?.longitudinalModel || ""), + } + state.runtime = payload?.runtime && typeof payload.runtime === "object" ? payload.runtime : {} + state.download = payload?.download && typeof payload.download === "object" ? payload.download : {} + state.models = Array.isArray(payload?.models) ? payload.models : [] + state.summary = payload?.summary && typeof payload.summary === "object" ? payload.summary : {} + state.manifest = payload?.manifest && typeof payload.manifest === "object" + ? payload.manifest + : { version: "unknown", shortcomings: [], opportunities: [] } + state.error = String(payload?.configurationError || "") + + const ready = readyModels() + if (!modelById(state.configuration.lateralModel) && ready.length > 0) { + state.configuration.lateralModel = ready[0].value + } + if (!modelById(state.configuration.longitudinalModel) && ready.length > 1) { + state.configuration.longitudinalModel = ready.find(model => ( + model.value !== state.configuration.lateralModel && + model.version === modelById(state.configuration.lateralModel)?.version + ))?.value || "" + } +} + +async function requestJson(url, options = {}) { + const response = await fetch(url, { cache: "no-store", ...options }) + let payload = {} + try { + payload = await response.json() + } catch { + } + if (!response.ok) throw new Error(payload.error || `Request failed (${response.status})`) + return payload +} + +async function refresh() { + try { + applyPayload(await requestJson("/api/model-laboratory")) + } catch (error) { + state.error = error?.message || String(error) + } finally { + state.loading = false + setTimeout(bindControls, 0) + } +} + +async function save(enabled) { + if (state.saving) return + if (enabled) { + const error = selectionError() + if (error) { + state.error = error + return + } + } + + state.saving = true + state.error = "" + state.message = "" + try { + const payload = await requestJson("/api/model-laboratory", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + enabled, + lateralModel: state.configuration.lateralModel, + longitudinalModel: state.configuration.longitudinalModel, + }), + }) + applyPayload(payload) + state.message = String(payload.message || "Model Laboratory configuration saved.") + } catch (error) { + state.error = error?.message || String(error) + } finally { + state.saving = false + } +} + +async function prepareModel(modelId) { + if (state.saving || !modelId) return + state.saving = true + state.error = "" + state.message = "" + try { + const payload = await requestJson("/api/model-laboratory/download", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: modelId }), + }) + state.message = String(payload.message || "Chestnut artifact download queued.") + await refresh() + } catch (error) { + state.error = error?.message || String(error) + } finally { + state.saving = false + } +} + +function bindControls() { + const lateral = document.getElementById("ml-lateral-model") + const longitudinal = document.getElementById("ml-longitudinal-model") + const enable = document.getElementById("ml-enable") + const disable = document.getElementById("ml-disable") + const refreshButton = document.getElementById("ml-refresh") + document.querySelectorAll("[data-ml-download]").forEach(button => { + if (button.dataset.bound === "1") return + button.dataset.bound = "1" + button.addEventListener("click", () => prepareModel(button.dataset.mlDownload)) + }) + + if (lateral) { + lateral.value = state.configuration.lateralModel + if (lateral.dataset.bound !== "1") { + lateral.dataset.bound = "1" + lateral.addEventListener("change", event => { + state.configuration.lateralModel = event.target.value + const long = modelById(state.configuration.longitudinalModel) + const lat = modelById(event.target.value) + if (long && lat && (long.value === lat.value || long.version !== lat.version)) { + state.configuration.longitudinalModel = candidateModels("longitudinal")[0]?.value || "" + if (longitudinal) longitudinal.value = state.configuration.longitudinalModel + } + }) + } + } + if (longitudinal) { + longitudinal.value = state.configuration.longitudinalModel + if (longitudinal.dataset.bound !== "1") { + longitudinal.dataset.bound = "1" + longitudinal.addEventListener("change", event => { state.configuration.longitudinalModel = event.target.value }) + } + } + if (enable && enable.dataset.bound !== "1") { + enable.dataset.bound = "1" + enable.addEventListener("click", () => save(true)) + } + if (disable && disable.dataset.bound !== "1") { + disable.dataset.bound = "1" + disable.addEventListener("click", () => save(false)) + } + if (refreshButton && refreshButton.dataset.bound !== "1") { + refreshButton.dataset.bound = "1" + refreshButton.addEventListener("click", refresh) + } +} + +function ensurePolling() { + if (pollHandle) return + const poll = async () => { + if (window.location.pathname !== "/model_laboratory") { + pollHandle = null + return + } + await refresh() + pollHandle = setTimeout(poll, 5000) + } + pollHandle = setTimeout(poll, 5000) +} + +function renderModel(model) { + const artifactStatus = model.modelLabArtifactInstalled + ? "AMD ready" + : model.modelLabArtifactAvailable ? "AMD download needed" : "AMD not published" + return html` +
+
+ ${model.label} +
${model.value} · ${model.series || "Unknown series"}
+
+
+ ${model.version || "unknown version"} + ${model.modelSize || "small"} + + ${artifactStatus} + + ${model.modelLabArtifactAvailable && !model.modelLabArtifactInstalled ? html` + + ` : ""} +
+
+ ` +} + +export function ModelLaboratory() { + if (!initialized) { + initialized = true + refresh() + } + ensurePolling() + setTimeout(bindControls, 0) + + return html` +
+
+
+
Chestnut experiment
+

Model Laboratory

+

Use the lateral judgment of one small model and the longitudinal judgment of another.

+
+
+ + ${() => state.chestnutReady ? "Chestnut ready" : "Chestnut required"} + + + ${() => state.isOnroad ? "Onroad · locked" : "Parked · configurable"} + +
+
+ + ${() => state.error ? html`
${state.error}
` : ""} + ${() => state.message ? html`
${state.message}
` : ""} + ${() => state.loading ? html`
Loading laboratory status…
` : ""} + + ${() => !state.loading ? html` +
+
+
+

Compose a pair

+

Both precompiled small models stay resident and run every camera frame on Chestnut's AMD GPU.

+
+ + ${() => state.configuration.enabled ? "Enabled" : "Disabled"} + +
+ +
+ +
+
+ +
+ +
+ ${() => modelLabel(state.configuration.lateralModel)} + steers + + ${() => modelLabel(state.configuration.longitudinalModel)} + paces +
+ + ${() => selectionError() ? html`

${selectionError()}

` : ""} +
+ + + +
+
+ +
+
+
+

Runtime

+

The configuration activates when modeld starts for a drive.

+
+ + ${() => state.runtime?.active ? "Pair active" : state.runtime?.requested ? "Pair requested" : "Inactive"} + +
+
+
Lateral${() => modelLabel(state.runtime?.lateralModel)}
+
Longitudinal${() => modelLabel(state.runtime?.longitudinalModel)}
+
+ ${() => state.runtime?.error ? html`
${state.runtime.error}
` : ""} +

Both roles evaluate the same frame at 20 Hz. A runtime failure suppresses that frame and falls back to the built-in QCOM model.

+
+ +
+
+
+

Small-model readiness

+

${state.summary.ready || 0} AMD-ready · ${state.summary.published || 0} published · ${state.summary.eligible || 0} eligible small models.

+
+ Manifest ${state.manifest.version || "unknown"} +
+
${() => state.models.map(renderModel)}
+
+ Model Manager downloads the manifest's precompiled AMD variants. Nothing is compiled on the comma. + A normal installed model may still need its separate Chestnut artifact. +
+
+ +
+
+

Manifest shortcomings

+
    ${(state.manifest.shortcomings || []).map(item => html`
  • ${item}
  • `)}
+
+
+

Opportunities

+
    ${(state.manifest.opportunities || []).map(item => html`
  • ${item}
  • `)}
+
+
+ ` : ""} +
+ ` +} diff --git a/starpilot/system/the_galaxy/assets/components/tools/toggles.css b/starpilot/system/the_galaxy/assets/components/tools/toggles.css index dbe9c6335..6fe68610d 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/toggles.css +++ b/starpilot/system/the_galaxy/assets/components/tools/toggles.css @@ -75,6 +75,46 @@ width: 100%; } +.toggle-profile-section { + border-top: 1px solid var(--track-color); + margin-top: var(--padding-lg); + padding-top: var(--padding-lg); + width: 100%; +} + +.toggle-profile-row { + background: var(--input-bg); + border-radius: var(--border-radius-lg); + margin-top: var(--padding-sm); + padding: var(--padding-sm); +} + +.toggle-profile-heading, +.toggle-profile-actions { + align-items: center; + display: flex; + gap: var(--padding-sm); + justify-content: space-between; +} + +.toggle-profile-heading span { + color: var(--text-color); + font-size: var(--font-size-sm); +} + +.toggle-profile-actions { + margin-top: var(--padding-sm); +} + +.toggle-profile-actions .toggle-control-button + .toggle-control-button { + margin-top: 0; +} + +.toggle-profile-warning { + color: var(--danger-fg); + text-align: center; +} + .toggle-control-wrapper { display: flex; justify-content: center; diff --git a/starpilot/system/the_galaxy/assets/components/tools/toggles.js b/starpilot/system/the_galaxy/assets/components/tools/toggles.js index 9d7b40b98..c29544bb1 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/toggles.js +++ b/starpilot/system/the_galaxy/assets/components/tools/toggles.js @@ -11,6 +11,10 @@ const state = reactive({ factoryResetBusy: false, routeDeleteBusy: false, factoryResetStatus: null, + profiles: [], + profileBusy: "", + profileIsOnroad: false, + profileConfirm: null, }) let initialized = false @@ -98,6 +102,18 @@ async function fetchFactoryResetStatus() { } } +async function fetchToggleProfiles() { + try { + const response = await fetch("/api/toggles/profiles", { cache: "no-store" }) + const payload = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(payload.message || "Failed to load settings profiles.") + state.profiles = Array.isArray(payload.slots) ? payload.slots : [] + state.profileIsOnroad = !!payload.isOnroad + } catch (error) { + state.profiles = [] + } +} + async function restoreToggles(event) { const uploadedFile = event.target.files[0] if (!uploadedFile) return @@ -155,6 +171,7 @@ function initialize() { export function ToggleControl() { initialize() fetchFactoryResetStatus() + fetchToggleProfiles() async function backupToggles() { try { @@ -197,6 +214,33 @@ export function ToggleControl() { fileInput.click() } + function confirmProfileAction(profile, action) { + if (state.profileBusy || state.profileIsOnroad) return + state.profileConfirm = { profile, action } + } + + async function runProfileAction() { + const pending = state.profileConfirm + state.profileConfirm = null + if (!pending || state.profileBusy) return + + const { profile, action } = pending + state.profileBusy = `${action}-${profile.slot}` + try { + const response = await fetch(`/api/toggles/profiles/${encodeURIComponent(profile.slot)}/${action}`, { method: "POST" }) + const payload = await response.json().catch(() => ({})) + if (!response.ok || payload.success === false) { + throw new Error(payload.message || `Failed to ${action} settings profile.`) + } + showSnackbar(payload.message || `${profile.label} ${action === "save" ? "saved" : "loaded"}.`) + await fetchToggleProfiles() + } catch (error) { + showSnackbar(error?.message || `Failed to ${action} settings profile.`, "error") + } finally { + state.profileBusy = "" + } + } + function confirmSaveMe() { state.showSaveMeModal = true; } @@ -266,6 +310,35 @@ export function ToggleControl() {

+
+
Settings Profiles
+

+ Keep two local configurations for different vehicles, drivers, or troubleshooting. Pairing and sensitive device data are not included. +

+ ${() => state.profileIsOnroad ? html`

Park the vehicle to save or load a profile.

` : ""} + ${() => state.profiles.map(profile => html` +
+
+ ${profile.label} + ${profile.invalid ? "Damaged" : profile.saved ? `${profile.settingsCount} settings` : "Empty"} +
+
+ + +
+
+ `)} +
@@ -337,6 +410,15 @@ export function ToggleControl() { onConfirm: deleteAllRoutes, onCancel: () => { state.showDeleteRoutesModal = false; }, confirmText: "Delete Routes" + }) : ""} + ${() => state.profileConfirm ? Modal({ + title: `${state.profileConfirm.action === "save" ? (state.profileConfirm.profile.saved ? "Overwrite" : "Save") : "Load"} ${state.profileConfirm.profile.label}?`, + message: state.profileConfirm.action === "save" + ? "This stores the current persistent StarPilot settings in this local slot." + : "This applies every saved setting in the slot to the device.", + onConfirm: runProfileAction, + onCancel: () => { state.profileConfirm = null; }, + confirmText: state.profileConfirm.action === "save" ? "Save Settings" : "Load Settings" }) : ""} ` } diff --git a/starpilot/system/the_galaxy/assets/mobile/js/api.js b/starpilot/system/the_galaxy/assets/mobile/js/api.js index 5bcb69b15..db4bbf89f 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/api.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/api.js @@ -37,6 +37,10 @@ async function delOk(url) { return (await fetch(url, { method: "DELETE" })).ok } +function postOk(url, opts = {}) { + return fetch(url, initFor({ ...opts, method: "POST" })).then((res) => res.ok) +} + export const api = { postAction(endpoint) { return request(endpoint, { method: "POST" }) }, getOptions(endpoint) { return request(endpoint) }, @@ -209,6 +213,10 @@ export const api = { return data }, + getToggleProfiles() { return request("/api/toggles/profiles", { cache: "no-store" }) }, + saveToggleProfile(slot) { return request(`/api/toggles/profiles/${encodeURIComponent(slot)}/save`, { method: "POST" }) }, + loadToggleProfile(slot) { return request(`/api/toggles/profiles/${encodeURIComponent(slot)}/load`, { method: "POST" }) }, + selectTestingGround(body) { return request("/api/testing_grounds/select", { method: "POST", data: body }) }, getSentryStatus() { return requestOk("/api/sentry/status", { cache: "no-store" }) }, diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js b/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js index 48d7fed9e..d7297c785 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/AppShell.js @@ -14,6 +14,9 @@ const NAV = { { name: "Navigation & Maps", link: "/navigation", icon: "bi-map" }, { name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation" }, { name: "System Tools", link: "/system", icon: "bi-arrow-repeat" }, + { name: "Model Laboratory", link: "/model_laboratory", icon: "bi-bezier2" }, + { name: "Plots", link: "/plots", icon: "bi-graph-up-arrow" }, + { name: "Testing Ground", link: "/testing_ground", icon: "bi-bezier2" }, { name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" }, { name: "Tuning, Plots & Testing", link: "/tuning", icon: "bi-sign-turn-right" }, { name: "Vehicle Controls", link: "/vehicle", icon: "bi-car-front" }, diff --git a/starpilot/system/the_galaxy/assets/mobile/js/params.js b/starpilot/system/the_galaxy/assets/mobile/js/params.js index f0a0dcce6..ab0140d11 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/params.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/params.js @@ -28,7 +28,6 @@ const VEHICLE_SETTING_MAKES = { SubaruSNG: ["Subaru"], SubaruSNGManualParkingBrake: ["Subaru"], SubaruStopStartOff: ["Subaru"], - SubaruAvhOnAtStartup: ["Subaru"], ClusterOffset: ["Lexus", "Toyota"], SNGHack: ["Lexus", "Toyota"], ToyotaAutoHold: ["Lexus", "Toyota"], @@ -101,6 +100,9 @@ export function numericBounds(param, values) { if (param.key === "ScreenBrightness" || param.key === "ScreenBrightnessOnroad") { return { min: 1, max: 101, step: 1 } } + if (param.key === "LaneCenterOffset") { + return { min: -0.3, max: 0.3, step: 0.01 } + } if (/^(Traffic|Aggressive|Standard|Relaxed)Jerk(Acceleration|Deceleration|Danger|SpeedDecrease|Speed)$/.test(String(param.key || ""))) { return { min: 25, max: 200, step: 1 } } diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js b/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js index 4a335b678..b0595a02e 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js @@ -22,13 +22,16 @@ export const SystemTools = { branches: [], currentBranch: "", branchLoading: true, + isOnroad: false, fastStatus: null, checkedForUpdates: false, busy: "", + profiles: [], + profileBusy: "", } }, created() { this.poll = usePolling(() => this.loadFastStatus(), { interval: 3000 }); this.poll.start() }, - mounted() { this.loadBranches() }, + mounted() { this.loadBranches(); this.loadProfiles() }, beforeUnmount() { this.poll?.destroy() }, computed: { updateAvailable() { return !!this.fastStatus?.updateAvailable && !this.fastStatus?.running }, @@ -79,6 +82,50 @@ export const SystemTools = { showSnackbar(e?.message || "Backup failed.", "error") } }, + async loadProfiles() { + try { + const data = await api.getToggleProfiles() + this.profiles = Array.isArray(data?.slots) ? data.slots : [] + this.isOnroad = !!data?.isOnroad + } catch (e) { + this.profiles = [] + } + }, + async saveProfile(profile) { + if (this.profileBusy || this.isOnroad) return + if (profile.saved && !(await GalaxyConfirm({ + title: `Overwrite ${profile.label}?`, + message: "This replaces the settings currently stored in this slot.", + confirmLabel: "Overwrite", + }))) return + this.profileBusy = `save-${profile.slot}` + try { + const result = await api.saveToggleProfile(profile.slot) + showSnackbar(result?.message || `Saved ${profile.label}.`) + await this.loadProfiles() + } catch (e) { + showSnackbar(e?.message || "Failed to save settings profile.", "error") + } finally { + this.profileBusy = "" + } + }, + async loadProfile(profile) { + if (this.profileBusy || this.isOnroad || !profile.saved || profile.invalid) return + if (!(await GalaxyConfirm({ + title: `Load ${profile.label}?`, + message: "This applies every saved setting in the slot to the device.", + confirmLabel: "Load Settings", + }))) return + this.profileBusy = `load-${profile.slot}` + try { + const result = await api.loadToggleProfile(profile.slot) + showSnackbar(result?.message || `Loaded ${profile.label}.`) + } catch (e) { + showSnackbar(e?.message || "Failed to load settings profile.", "error") + } finally { + this.profileBusy = "" + } + }, onRestoreFile(e) { const file = e.target.files[0] e.target.value = "" @@ -256,6 +303,27 @@ export const SystemTools = { +
+

Settings Profiles

+

Keep two local configurations for different vehicles, drivers, or troubleshooting. Profiles never include pairing or sensitive device data.

+ +
+
+
+ {{ profile.label }} + {{ profile.invalid ? 'Damaged' : profile.saved ? profile.settingsCount + ' settings' : 'Empty' }} +
+
+ + +
+
+
+
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/ToolEmbed.js b/starpilot/system/the_galaxy/assets/mobile/js/views/ToolEmbed.js index 3e9095b34..7ca802d94 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/views/ToolEmbed.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/ToolEmbed.js @@ -9,6 +9,7 @@ export const ToolEmbed = { title() { const map = { "/manage_models": "Model Manager", + "/model_laboratory": "Model Laboratory", "/galaxy": "Galaxy", "/sentry": "Sentry Mode", "/plots": "Live Plots", diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Tools.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Tools.js index 1b181ef82..af7be1939 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/views/Tools.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Tools.js @@ -5,6 +5,7 @@ const TOOLS = [ { name: "Galaxy & App Install", link: "/galaxy", icon: "bi-globe2", desc: "Remote access, pairing, & app install" }, { name: "Logs & Diagnostics", link: "/logs", icon: "bi-exclamation-triangle", desc: "Error logs, tmux, troubleshoot" }, { name: "Model Manager", link: "/manage_models", icon: "bi-cpu", desc: "Install/swap models" }, + { name: "Model Laboratory", link: "/model_laboratory", icon: "bi-bezier2", desc: "Pair lateral and longitudinal models" }, { name: "Navigation & Maps", link: "/navigation", icon: "bi-map", desc: "Offline maps & destinations" }, { name: "Sentry Mode", link: "/sentry", icon: "bi-shield-exclamation", desc: "Sentry alerts & security" }, { name: "System Tools", link: "/system", icon: "bi-arrow-repeat", desc: "Backup, restore, updates" }, diff --git a/starpilot/system/the_galaxy/templates/index.html b/starpilot/system/the_galaxy/templates/index.html index c47ed408c..cca2f9932 100644 --- a/starpilot/system/the_galaxy/templates/index.html +++ b/starpilot/system/the_galaxy/templates/index.html @@ -30,6 +30,7 @@ + diff --git a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py index 277b0c976..b1c75e8aa 100644 --- a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py +++ b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py @@ -41,9 +41,11 @@ loggerd_uploader.listdir_by_creation = lambda path: [ sys.modules.setdefault("openpilot.system.loggerd.uploader", loggerd_uploader) model_manager = ModuleType("openpilot.starpilot.assets.model_manager") +model_manager.MODEL_LAB_DOWNLOAD_PARAM = "ModelLabModelToDownload" model_manager.canonical_model_key = lambda value: str(value or "").strip().lower().replace(" ", "-") model_manager.external_gpu_available = lambda: False model_manager.is_builtin_model_key = lambda key: False +model_manager.model_accelerator_artifact_filename = lambda key: f"{key}_driving_chestnut_tinygrad.pkl" model_manager.model_key_aliases = lambda key: () model_manager.model_uses_external_gpu = lambda key: False sys.modules.setdefault("openpilot.starpilot.assets.model_manager", model_manager) @@ -310,6 +312,7 @@ def _install_server_import_stubs(): "SCREEN_RECORDINGS_PATH": Path("/tmp/dashboard-test-recordings"), "STOCK_THEME_PATH": Path("/tmp/dashboard-test-stock-theme"), "THEME_SAVE_PATH": Path("/tmp/dashboard-test-themes"), + "TOGGLE_BACKUPS": Path("/tmp/dashboard-test-toggle-backups"), }.items(): setattr(starpilot_variables, name, value) starpilot_variables.default_ev_tuning_enabled = lambda *args, **kwargs: False @@ -387,6 +390,9 @@ class FakeParams: def put(self, key, value): self.values[key] = value + def remove(self, key): + self.values.pop(key, None) + class FailingPutParams(FakeParams): def put(self, key, value): @@ -1772,6 +1778,119 @@ def _load_server_module(): return module +def test_model_laboratory_api_uses_installed_models_and_enforces_hardware_size_version_guards(monkeypatch, tmp_path): + server = _load_server_module() + assert server._import_galaxy_web_symbols() + + class ModelLabParams(FakeParams): + defaults = { + "Model": "rdf43", + "DrivingModel": "rdf43", + "DrivingModelName": "Regret Driven Framework V4", + "ModelVersion": "v15", + "DrivingModelVersion": "v15", + } + + def get_default_value(self, key): + return self.defaults.get(key) + + params = ModelLabParams({ + "AvailableModels": "lat,long,old,big", + "AvailableModelNames": "Lateral Ace,Longitudinal Ace,Old Generation,Chestnut One Billion", + "AvailableModelSeries": "Lab,Lab,Legacy,Large", + "AvailableModelArtifactFormats": "tinygrad_single_v1,tinygrad_single_v1,tinygrad_single_v1,tinygrad_single_v1", + "ModelVersions": "v15,v15,v9,v16", + "ModelReleasedDates": "2026-01-01,2026-01-02,2025-01-01,2026-08-01", + "ModelManifestVersion": "v25", + "Model": "rdf43", + "DrivingModel": "rdf43", + }) + metadata = { + "lat": {"model_size": "small", "model_size_declared": True, "model_lab_eligible": True, + "accelerator_artifacts": {"chestnut": {"execution_device": "AMD"}}}, + "long": {"model_size": "small", "model_size_declared": True, "model_lab_eligible": True, + "accelerator_artifacts": {"chestnut": {"execution_device": "AMD"}}}, + "old": {"model_size": "small", "model_size_declared": True, "model_lab_eligible": True, + "accelerator_artifacts": {"chestnut": {"execution_device": "AMD"}}}, + "big": {"model_size": "chestnut", "model_size_declared": True, "uses_external_gpu": True}, + } + (tmp_path / ".model_artifacts.json").write_text(json.dumps(metadata)) + (tmp_path / "lat_driving_tinygrad.pkl").write_bytes(b"lat") + (tmp_path / "long_driving_tinygrad.pkl").write_bytes(b"long") + (tmp_path / "old_driving_tinygrad.pkl").write_bytes(b"old") + (tmp_path / "lat_driving_chestnut_tinygrad.pkl").write_bytes(b"lat-amd") + (tmp_path / "long_driving_chestnut_tinygrad.pkl").write_bytes(b"long-amd") + (tmp_path / "old_driving_chestnut_tinygrad.pkl").write_bytes(b"old-amd") + + app = server.Flask( + "model_lab_test", + template_folder=str(MODULE_DIR / "templates"), + static_folder=str(MODULE_DIR / "assets"), + ) + server.setup(app) + monkeypatch.setattr(server, "params", params) + params_memory = FakeParams() + monkeypatch.setattr(server, "params_memory", params_memory) + monkeypatch.setattr(server, "MODELS_PATH", tmp_path) + monkeypatch.setattr(server, "external_gpu_available", lambda: True) + monkeypatch.setattr(server, "model_uses_external_gpu", lambda key: key == "big") + client = app.test_client() + + status = client.get("/api/model-laboratory") + status_payload = status.get_json() + assert status.status_code == 200 + assert status_payload["chestnutReady"] is True + assert {model["value"] for model in status_payload["models"]} == {"rdf43", "lat", "long", "old"} + assert status_payload["summary"]["ready"] == 3 + assert status_payload["summary"]["published"] == 3 + + enabled = client.put("/api/model-laboratory", json={ + "enabled": True, + "lateralModel": "lat", + "longitudinalModel": "long", + }) + assert enabled.status_code == 200 + assert params.values["ModelLabConfig"]["enabled"] is True + assert params.values["Model"] == params.values["DrivingModel"] == "lat" + assert params.values["ModelVersion"] == params.values["DrivingModelVersion"] == "v15" + + mismatched = client.put("/api/model-laboratory", json={ + "enabled": True, + "lateralModel": "lat", + "longitudinalModel": "old", + }) + assert mismatched.status_code == 409 + assert "same behavior version" in mismatched.get_json()["error"] + + oversized = client.put("/api/model-laboratory", json={ + "enabled": True, + "lateralModel": "lat", + "longitudinalModel": "big", + }) + assert oversized.status_code == 409 + assert "Chestnut-class" in oversized.get_json()["error"] + + (tmp_path / "old_driving_chestnut_tinygrad.pkl").unlink() + queued = client.post("/api/model-laboratory/download", json={"model": "old"}) + assert queued.status_code == 200 + assert params_memory.values["ModelLabModelToDownload"] == "old" + assert "precompiled AMD" in params_memory.values["ModelDownloadProgress"] + params_memory.remove("ModelLabModelToDownload") + + monkeypatch.setattr(server, "external_gpu_available", lambda: False) + no_chestnut = client.put("/api/model-laboratory", json={ + "enabled": True, + "lateralModel": "lat", + "longitudinalModel": "long", + }) + assert no_chestnut.status_code == 409 + assert "Chestnut" in no_chestnut.get_json()["error"] + + params.values["IsOnroad"] = True + onroad = client.put("/api/model-laboratory", json={"enabled": False}) + assert onroad.status_code == 403 + + def test_clear_generated_build_state_preserves_prebuilts_and_user_data(tmp_path): server = _load_server_module() sconsign = tmp_path / ".sconsign.dblite" @@ -2098,3 +2217,85 @@ def test_toggle_restore_reports_invalid_and_unavailable_settings(monkeypatch): assert damaged_response.get_json()["success"] is False assert wrong_format_response.status_code == 400 assert wrong_format_response.get_json()["success"] is False + + +def test_toggle_profile_slots_save_and_load_the_same_filtered_settings(monkeypatch, tmp_path): + server = _load_server_module() + assert server._import_galaxy_web_symbols() + + definitions = { + "EnabledSetting": (True, server.ParamKeyType.BOOL, server.ParamKeyFlag.PERSISTENT), + "NumericSetting": (1.5, server.ParamKeyType.FLOAT, server.ParamKeyFlag.PERSISTENT), + "SensitiveSetting": ("", server.ParamKeyType.STRING, server.ParamKeyFlag.PERSISTENT | server.ParamKeyFlag.DONT_LOG), + } + + class ToggleParams: + def __init__(self): + self.values = { + "EnabledSetting": False, + "NumericSetting": 2.75, + "SensitiveSetting": "secret", + } + + def get(self, key, block=False): + del block + return self.values.get(key, definitions[key][0]) + + def get_default_value(self, key): + return definitions[key][0] + + def get_key_flag(self, key): + return definitions[key][2] + + def get_type(self, key): + return definitions[key][1] + + def put(self, key, value): + self.values[key] = value + + raw_params = ToggleParams() + server.starpilot_default_params = [ + (key, default, value_type, 0) + for key, (default, value_type, _) in definitions.items() + ] + monkeypatch.setattr(server, "_params_raw", raw_params) + monkeypatch.setattr(server, "params", FakeParams({"IsOnroad": False})) + monkeypatch.setattr(server, "EXCLUDED_KEYS", set()) + monkeypatch.setattr(server, "TOGGLE_BACKUPS", tmp_path) + update_calls = [] + monkeypatch.setattr(server, "update_starpilot_toggles", lambda: update_calls.append(True)) + + app = server.Flask( + "toggle_profile_test", + template_folder=str(MODULE_DIR / "templates"), + static_folder=str(MODULE_DIR / "assets"), + ) + server.setup(app) + client = app.test_client() + + initial = client.get("/api/toggles/profiles").get_json() + saved = client.post("/api/toggles/profiles/a/save") + assert initial["slots"][0]["saved"] is False + assert saved.status_code == 200 + assert saved.get_json()["profile"]["settingsCount"] == 2 + + raw_params.values.update({ + "EnabledSetting": True, + "NumericSetting": 9.0, + "SensitiveSetting": "new-secret", + }) + loaded = client.post("/api/toggles/profiles/a/load") + assert loaded.status_code == 200 + assert loaded.get_json()["restoredCount"] == 2 + assert raw_params.values["EnabledSetting"] is False + assert raw_params.values["NumericSetting"] == 2.75 + assert raw_params.values["SensitiveSetting"] == "new-secret" + assert update_calls == [True] + + missing = client.post("/api/toggles/profiles/b/load") + assert missing.status_code == 400 + assert "has not been saved" in missing.get_json()["message"] + + server.params.values["IsOnroad"] = True + onroad = client.post("/api/toggles/profiles/a/load") + assert onroad.status_code == 403 diff --git a/starpilot/system/the_galaxy/tests/test_device_settings_frontend.py b/starpilot/system/the_galaxy/tests/test_device_settings_frontend.py index 199917d90..bf6e0455a 100644 --- a/starpilot/system/the_galaxy/tests/test_device_settings_frontend.py +++ b/starpilot/system/the_galaxy/tests/test_device_settings_frontend.py @@ -46,6 +46,14 @@ def test_device_settings_uses_the_params_api_and_layout_json(): assert 'fetch("/assets/components/tools/device_settings_layout.json?v=settings-tier-1"' in source +def test_lane_center_offset_can_step_below_zero(): + source = _device_settings() + + assert 'if (param.key === "LaneCenterOffset")' in source + assert "return { min: -0.3, max: 0.3, step: 0.01 }" in source + assert "canStepNumericParam(p, -1)" in source + + def test_developer_mode_notice_has_styles(): css = DEVICE_SETTINGS_CSS_PATH.read_text(encoding="utf-8") diff --git a/starpilot/system/the_galaxy/tests/test_frontend_module_graph.py b/starpilot/system/the_galaxy/tests/test_frontend_module_graph.py index aac3b6a2e..7b9c2866e 100644 --- a/starpilot/system/the_galaxy/tests/test_frontend_module_graph.py +++ b/starpilot/system/the_galaxy/tests/test_frontend_module_graph.py @@ -8,6 +8,7 @@ INDEX_PATH = REPO_ROOT / "starpilot/system/the_galaxy/templates/index.html" BLUETOOTH_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/bluetooth.js" CONTROLLERS_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/wheel_controls.js" SIDEBAR_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/sidebar.js" +MODEL_LAB_PATH = REPO_ROOT / "starpilot/system/the_galaxy/assets/components/tools/model_laboratory.js" def test_settings_does_not_create_a_second_router_module(): @@ -95,3 +96,29 @@ def test_bluetooth_and_controllers_sidebar_order(): sentry = source.index('{ name: "Sentry Mode"') controllers = source.index('{ name: "Controllers"') assert toggles < bluetooth < sentry < controllers + +def test_model_laboratory_is_wired_into_classic_and_mobile_navigation(): + router = ROUTER_PATH.read_text(encoding="utf-8") + sidebar = SIDEBAR_PATH.read_text(encoding="utf-8") + template = INDEX_PATH.read_text(encoding="utf-8") + mobile_tools = (REPO_ROOT / "starpilot/system/the_galaxy/assets/mobile/js/views/Tools.js").read_text(encoding="utf-8") + mobile_embed = (REPO_ROOT / "starpilot/system/the_galaxy/assets/mobile/js/views/ToolEmbed.js").read_text(encoding="utf-8") + + assert MODEL_LAB_PATH.is_file() + assert 'createRoute("model_laboratory", "/model_laboratory", ModelLaboratory)' in router + assert '{ name: "Model Laboratory", link: "/model_laboratory"' in sidebar + assert "/assets/components/tools/model_laboratory.css" in template + assert '{ name: "Model Laboratory", link: "/model_laboratory"' in mobile_tools + assert '"/model_laboratory": "Model Laboratory"' in mobile_embed + + +def test_model_laboratory_frontend_exposes_guards_and_role_copy(): + source = MODEL_LAB_PATH.read_text(encoding="utf-8") + assert 'if (!state.chestnutReady)' in source + assert 'if (state.isOnroad)' in source + assert "model.modelLabArtifactInstalled" in source + assert "Nothing is compiled on the comma" in source + assert "run every camera frame on Chestnut's AMD GPU" in source + assert 'lateral.version !== longitudinal.version' in source + assert "Path shape, curvature, lane geometry" in source + assert "Speed, acceleration, stopping, leads" in source diff --git a/starpilot/system/the_galaxy/tests/test_ui_vue_frontend.py b/starpilot/system/the_galaxy/tests/test_ui_vue_frontend.py index bafae41e0..ced2e64b2 100644 --- a/starpilot/system/the_galaxy/tests/test_ui_vue_frontend.py +++ b/starpilot/system/the_galaxy/tests/test_ui_vue_frontend.py @@ -110,7 +110,9 @@ def test_ui_ports_all_tool_views(): "js/views/Tuning.js": ["LateralTuningPanel", "LongitudinalManeuvers"], "js/views/Navigation.js": ["getNavigation", "setNavigation", "MapsPanel", "NavigationKeysPanel"], "js/views/ToolEmbed.js": ["/manage_maps", "/manage_navigation_keys"], - "js/views/SystemTools.js": ["backupToggles", "restoreToggles", "getUpdateBranches", "factoryReset"], + "js/views/SystemTools.js": [ + "backupToggles", "restoreToggles", "getToggleProfiles", "saveToggleProfile", "loadToggleProfile", "getUpdateBranches", "factoryReset", + ], "js/components/WheelControls.js": ["getWheelControlsStatus"], "js/components/BluetoothPanel.js": ["getBluetoothStatus"], } @@ -501,6 +503,10 @@ assert(P.countAdvancedHiddenByDeveloperMode([sec], { GalaxyDeveloperMode: true } const slider = { key: "DeviceShutdown", data_type: "int", min: 1, max: 30, step: 1 } assert(P.snapNumericToBoundsAndStep(17.9, P.numericBounds(slider, {}), 0) === 18, "snap") assert(P.formatSliderValue(6, "1", 0, "DeviceShutdown") === "6 hours", "format") +const laneOffset = { key: "LaneCenterOffset", data_type: "float", min: 0, max: 0.3, step: 0.01 } +const laneBounds = P.numericBounds(laneOffset, {}) +assert(laneBounds.min === -0.3, "lane offset keeps signed lower bound") +assert(P.snapNumericToBoundsAndStep(-0.01, laneBounds, 2) === -0.01, "lane offset snaps below zero") console.log("params.js logic OK") """, encoding="utf-8", diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 984fa69b2..74456a7dc 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -39,7 +39,7 @@ from opendbc.car.gm.values import GMFlags from opendbc.car.toyota.carcontroller import LOCK_CMD, UNLOCK_CMD from opendbc.car.toyota.values import ToyotaStarPilotFlags from openpilot.common.constants import CV -from openpilot.common.file_chunker import get_chunk_name, get_manifest_path +from openpilot.common.file_chunker import file_chunked_exists, get_chunk_name, get_manifest_path from openpilot.common.params import ParamKeyFlag, ParamKeyType, Params from openpilot.common.realtime import DT_HW from openpilot.common.swaglog import cloudlog @@ -52,13 +52,24 @@ from openpilot.tools.longitudinal_maneuvers.capabilities import get_longitudinal from panda import Panda from openpilot.starpilot.assets.model_manager import ( + MODEL_LAB_DOWNLOAD_PARAM, canonical_model_key, external_gpu_available, is_builtin_model_key, + model_accelerator_artifact_filename, model_key_aliases, model_uses_external_gpu, ) +from openpilot.starpilot.common.model_lab import ( + MODEL_LAB_CONFIG_PARAM, + MODEL_LAB_RUNTIME_PARAM, + is_small_model_metadata, + model_lab_manifest_eligible, + normalize_model_lab_config, + validate_model_lab_selection, +) from openpilot.starpilot.assets.theme_manager import HOLIDAY_THEME_PATH, THEME_COMPONENT_PARAMS +from openpilot.starpilot.common import param_profiles from openpilot.starpilot.common.accel_profile import ( CUSTOM_ACCEL_PROFILE_BREAKPOINT_PARAM_KEYS, CUSTOM_ACCEL_PROFILE_BREAKPOINTS_INITIALIZED_KEY, @@ -103,7 +114,7 @@ from openpilot.starpilot.common.favorite_slots import ( ) from openpilot.starpilot.common.lateral_delay import full_lateral_delay from openpilot.starpilot.common.starpilot_utilities import delete_file, get_lock_status, run_cmd -from openpilot.starpilot.common.starpilot_variables import ACTIVE_THEME_PATH, BUTTON_FUNCTIONS, ERROR_LOGS_PATH, EXCLUDED_KEYS, LEGACY_STARPILOT_PARAM_RENAMES, MAPS_PATH, MODELS_PATH, RESOURCES_REPO, SCREEN_RECORDINGS_PATH, STOCK_THEME_PATH, THEME_SAVE_PATH,\ +from openpilot.starpilot.common.starpilot_variables import ACTIVE_THEME_PATH, BUTTON_FUNCTIONS, ERROR_LOGS_PATH, EXCLUDED_KEYS, LEGACY_STARPILOT_PARAM_RENAMES, MAPS_PATH, MODELS_PATH, RESOURCES_REPO, SCREEN_RECORDINGS_PATH, STOCK_THEME_PATH, THEME_SAVE_PATH, TOGGLE_BACKUPS,\ default_ev_tuning_enabled, migrate_cancel_button_controls, update_starpilot_toggles from openpilot.starpilot.common.testing_grounds import ( DEFAULT_TESTING_GROUND_VARIANT as SHARED_DEFAULT_TESTING_GROUND_VARIANT, @@ -1164,20 +1175,7 @@ def _dispatch_sentry_event(event: dict, *, bypass_rate_limit: bool = False) -> N TOGGLE_BACKUP_FORMAT = "starpilot-toggle-backup" TOGGLE_BACKUP_VERSION = 1 TOGGLE_BACKUP_MAX_ENCODED_BYTES = 2_000_000 -TOGGLE_BACKUP_NO_DEFAULT_KEYS = { - "AdbEnabled", - "AlphaLongitudinalEnabled", - "AlwaysOnDM", - "ExperimentalMode", - "ExperimentalModeConfirmed", - "IsLdwEnabled", - "IsMetric", - "IsRHD", - "IsRHDOverride", - "RecordAudio", - "RecordFront", - "SshEnabled", -} +TOGGLE_BACKUP_NO_DEFAULT_KEYS = param_profiles.PROFILE_NO_DEFAULT_KEYS def _get_toggle_backup_keys(): @@ -4988,6 +4986,8 @@ def setup(app): "/assets/components/tools/pip_sidecam.js", "/assets/components/tools/pip_sidecam.css", "/assets/components/tools/toggles.js", + "/assets/components/tools/model_laboratory.js", + "/assets/components/tools/model_laboratory.css", "/assets/components/tools/bluetooth.js", "/assets/components/tools/bluetooth.css", "/assets/components/tools/wheel_controls.js", @@ -5966,6 +5966,12 @@ def setup(app): if model_uses_external_gpu(selected_model) and not external_gpu_available(): return jsonify({"error": "This model requires a detected external GPU."}), 409 + lab_config = normalize_model_lab_config(params.get(MODEL_LAB_CONFIG_PARAM, encoding="utf-8") or "") + if lab_config["enabled"]: + lab_config["enabled"] = False + params.put(MODEL_LAB_CONFIG_PARAM, lab_config) + params.remove(MODEL_LAB_RUNTIME_PARAM) + params.put("Model", selected_model) params.put("DrivingModel", selected_model) @@ -6213,6 +6219,136 @@ def setup(app): }, }), 200 + def _model_lab_status_payload(): + models = get_model_catalog() + model_by_key = {model["value"]: model for model in models} + config = normalize_model_lab_config(params.get(MODEL_LAB_CONFIG_PARAM, encoding="utf-8") or "") + config["lateralModel"] = canonical_model_key(config["lateralModel"]) + config["longitudinalModel"] = canonical_model_key(config["longitudinalModel"]) + chestnut_ready = external_gpu_available() + runtime = {} + try: + runtime_value = params.get(MODEL_LAB_RUNTIME_PARAM, encoding="utf-8") or "" + runtime = json.loads(runtime_value) if isinstance(runtime_value, str) and runtime_value else runtime_value + if not isinstance(runtime, dict): + runtime = {} + except (TypeError, ValueError): + runtime = {} + + eligible_models = [model for model in models if model.get("modelLabEligible")] + ready_models = [model for model in eligible_models if model.get("modelLabArtifactInstalled")] + lab_model_to_download = params_memory.get(MODEL_LAB_DOWNLOAD_PARAM, encoding="utf-8") or "" + configuration_error = validate_model_lab_selection( + config, + model_by_key, + chestnut_ready=chestnut_ready, + require_installed=True, + ) + return { + "chestnutReady": chestnut_ready, + "isOnroad": params.get_bool("IsOnroad"), + "configuration": config, + "configurationError": configuration_error or "", + "runtime": runtime, + "download": { + "model": lab_model_to_download, + "progress": params_memory.get(MODEL_DOWNLOAD_PROGRESS_PARAM, encoding="utf-8") or "", + }, + "models": eligible_models, + "summary": { + "eligible": len(eligible_models), + "ready": len(ready_models), + "published": sum(1 for model in eligible_models if model.get("modelLabArtifactAvailable")), + "declaredSize": sum(1 for model in eligible_models if model.get("manifestDeclaredSize")), + }, + "manifest": { + "version": params.get("ModelManifestVersion", encoding="utf-8") or "unknown", + "shortcomings": [ + "The current manifest does not consistently declare model size; legacy non-Chestnut entries are treated as small.", + "The current manifest does not publish AMD-compiled variants for its ordinary small-model downloads.", + "The current manifest does not declare lateral or longitudinal quality/capability tags.", + "The current manifest does not declare output-contract compatibility, memory, or frame-time measurements.", + ], + "opportunities": [ + "Publish model_size and model_lab_eligible for every model.", + "Publish an accelerator_artifacts.chestnut entry pointing to a precompiled AMD pickle for each supported small model.", + "Publish role scores and pairing notes from replay evaluations.", + "Publish architecture, output-contract, peak-memory, and p50/p95 execution metadata.", + ], + }, + } + + @app.route("/api/model-laboratory", methods=["GET", "PUT"]) + def model_laboratory(): + if request.method == "GET": + return jsonify(_model_lab_status_payload()), 200 + + if params.get_bool("IsOnroad"): + return jsonify({"error": "Model Laboratory can only be configured while parked."}), 403 + + data = request.get_json(silent=True) or {} + config = normalize_model_lab_config({ + "enabled": data.get("enabled", False), + "lateralModel": canonical_model_key(str(data.get("lateralModel") or "")), + "longitudinalModel": canonical_model_key(str(data.get("longitudinalModel") or "")), + }) + models = get_model_catalog() + model_by_key = {model["value"]: model for model in models} + error = validate_model_lab_selection( + config, + model_by_key, + chestnut_ready=external_gpu_available(), + require_installed=True, + ) + if error: + return jsonify({"error": error}), 409 + + params.put(MODEL_LAB_CONFIG_PARAM, config) + params.remove(MODEL_LAB_RUNTIME_PARAM) + if config["enabled"]: + lateral = model_by_key[config["lateralModel"]] + params.put("Model", lateral["value"]) + params.put("DrivingModel", lateral["value"]) + params.put("DrivingModelName", lateral["label"]) + if lateral.get("version"): + params.put("ModelVersion", lateral["version"]) + params.put("DrivingModelVersion", lateral["version"]) + message = "Model Laboratory enabled. The pair will load on the next drive." + else: + message = "Model Laboratory disabled." + + return jsonify({"message": message, **_model_lab_status_payload()}), 200 + + @app.route("/api/model-laboratory/download", methods=["POST"]) + def download_model_laboratory_artifact(): + if params.get_bool("IsOnroad"): + return jsonify({"error": "Model Laboratory artifacts can only be downloaded while parked."}), 403 + if not external_gpu_available(): + return jsonify({"error": "Chestnut is not connected and firmware-ready."}), 409 + if ( + params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) + or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "") + or (params_memory.get(MODEL_LAB_DOWNLOAD_PARAM, encoding="utf-8") or "") + ): + return jsonify({"error": "A model download is already in progress."}), 409 + + data = request.get_json(silent=True) or {} + model_key = canonical_model_key(str(data.get("model") or "").strip()) + model = next((entry for entry in get_model_catalog() if entry["value"] == model_key), None) + if model is None: + return jsonify({"error": f"Unknown model '{model_key}'."}), 404 + if not model.get("modelLabEligible"): + return jsonify({"error": "Only compatible small models can be prepared for Model Laboratory."}), 409 + if not model.get("modelLabArtifactAvailable"): + return jsonify({"error": "The manifest does not publish a precompiled AMD artifact for this model."}), 409 + if model.get("modelLabArtifactInstalled"): + return jsonify({"message": f"\"{model['label']}\" is already prepared for Chestnut."}), 200 + + params_memory.remove(MODEL_CANCEL_DOWNLOAD_PARAM) + params_memory.put(MODEL_LAB_DOWNLOAD_PARAM, model_key) + params_memory.put(MODEL_DOWNLOAD_PROGRESS_PARAM, "Downloading precompiled AMD artifact...") + return jsonify({"message": f"Started preparing \"{model['label']}\" for Chestnut."}), 200 + @app.route("/api/models/preferences", methods=["GET", "PUT"]) def get_or_set_models_preferences(): if request.method == "GET": @@ -6247,11 +6383,12 @@ def setup(app): def get_models_status(): models = get_model_catalog() model_to_download = canonical_model_key(params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "") + lab_model_to_download = canonical_model_key(params_memory.get(MODEL_LAB_DOWNLOAD_PARAM, encoding="utf-8") or "") download_all = params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) progress = params_memory.get(MODEL_DOWNLOAD_PROGRESS_PARAM, encoding="utf-8") or "" cancelling = params_memory.get_bool(MODEL_CANCEL_DOWNLOAD_PARAM) - downloading = bool(model_to_download) or download_all + downloading = bool(model_to_download or lab_model_to_download) or download_all current_model = _current_model_key() sort_mode = read_legacy_param_file(MODEL_SORT_MODE_PARAM, DEFAULT_MODEL_SORT_MODE) terminal = progress in ("Downloaded!", "All models downloaded!") or bool(re.search(r"cancelled|exists|failed|offline|invalid|error", progress, re.IGNORECASE)) @@ -6267,6 +6404,7 @@ def setup(app): summary["installed"], summary["missing"], model_to_download, + lab_model_to_download, download_all, downloading, cancelling, @@ -6299,6 +6437,7 @@ def setup(app): return jsonify({ "modelToDownload": model_to_download, + "modelLabModelToDownload": lab_model_to_download, "downloadAll": download_all, "downloading": downloading, "cancelling": cancelling, @@ -6316,7 +6455,11 @@ def setup(app): if params.get_bool("IsOnroad"): return jsonify({"error": "Cannot refresh model manifest while driving."}), 403 - if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""): + if ( + params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) + or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "") + or (params_memory.get(MODEL_LAB_DOWNLOAD_PARAM, encoding="utf-8") or "") + ): return jsonify({"error": "Cannot refresh model manifest while a download is in progress."}), 409 try: @@ -6335,7 +6478,11 @@ def setup(app): if params.get_bool("IsOnroad"): return jsonify({"error": "Cannot download models while driving."}), 403 - if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""): + if ( + params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) + or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "") + or (params_memory.get(MODEL_LAB_DOWNLOAD_PARAM, encoding="utf-8") or "") + ): return jsonify({"error": "A model download is already in progress."}), 409 data = request.get_json() or {} @@ -6367,7 +6514,11 @@ def setup(app): if params.get_bool("IsOnroad"): return jsonify({"error": "Cannot download models while driving."}), 403 - if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""): + if ( + params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) + or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "") + or (params_memory.get(MODEL_LAB_DOWNLOAD_PARAM, encoding="utf-8") or "") + ): return jsonify({"error": "A model download is already in progress."}), 409 data = request.get_json(silent=True) or {} @@ -6390,8 +6541,9 @@ def setup(app): @app.route("/api/models/cancel", methods=["POST"]) def cancel_model_download(): model_to_download = params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "" + lab_model_to_download = params_memory.get(MODEL_LAB_DOWNLOAD_PARAM, encoding="utf-8") or "" download_all = params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) - if not model_to_download and not download_all: + if not model_to_download and not lab_model_to_download and not download_all: return jsonify({"message": "No active model download to cancel."}), 200 params_memory.put_bool(MODEL_CANCEL_DOWNLOAD_PARAM, True) @@ -6402,7 +6554,11 @@ def setup(app): if params.get_bool("IsOnroad"): return jsonify({"error": "Cannot delete model files while driving."}), 403 - if params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or ""): + if ( + params_memory.get_bool(MODEL_DOWNLOAD_ALL_PARAM) + or (params_memory.get(MODEL_DOWNLOAD_PARAM, encoding="utf-8") or "") + or (params_memory.get(MODEL_LAB_DOWNLOAD_PARAM, encoding="utf-8") or "") + ): return jsonify({"error": "Cannot delete model files while a download is in progress."}), 409 data = request.get_json() or {} @@ -6692,6 +6848,12 @@ def setup(app): except Exception: on_disk_files = set() + try: + metadata_payload = json.loads((MODELS_PATH / ".model_artifacts.json").read_text()) + artifact_metadata = metadata_payload if isinstance(metadata_payload, dict) else {} + except (OSError, TypeError, ValueError): + artifact_metadata = {} + external_gpu_present = external_gpu_available() models_by_key = {} for i, key in enumerate(available): @@ -6706,7 +6868,20 @@ def setup(app): released = released_dates[i] if i < len(released_dates) else "" requires_external_gpu = model_uses_external_gpu(canonical_key) gpu_available = not requires_external_gpu or external_gpu_present - + metadata = artifact_metadata.get(canonical_key, {}) + metadata = metadata if isinstance(metadata, dict) else {} + small_model = is_small_model_metadata({**metadata, "uses_external_gpu": requires_external_gpu}) + lab_eligible = model_lab_manifest_eligible({**metadata, "uses_external_gpu": requires_external_gpu}, model_version) + accelerator_artifacts = metadata.get("accelerator_artifacts", {}) + accelerator_artifacts = accelerator_artifacts if isinstance(accelerator_artifacts, dict) else {} + chestnut_artifact = accelerator_artifacts.get("chestnut", {}) + chestnut_artifact = chestnut_artifact if isinstance(chestnut_artifact, dict) else {} + lab_artifact_available = ( + bool(chestnut_artifact) + and str(chestnut_artifact.get("execution_device") or chestnut_artifact.get("device") or "").strip().upper() == "AMD" + ) + lab_artifact_path = MODELS_PATH / model_accelerator_artifact_filename(canonical_key) + lab_artifact_installed = lab_artifact_available and file_chunked_exists(lab_artifact_path) existing = models_by_key.get(canonical_key) if existing is None: models_by_key[canonical_key] = { @@ -6717,6 +6892,12 @@ def setup(app): "artifactFormat": artifact_format, "requiresGpu": requires_external_gpu, "gpuAvailable": gpu_available, + "small": small_model, + "modelSize": str(metadata.get("model_size") or ("small (inferred)" if small_model else "chestnut (inferred)")), + "manifestDeclaredSize": bool(metadata.get("model_size_declared", metadata.get("size_class"))), + "modelLabEligible": lab_eligible, + "modelLabArtifactAvailable": lab_artifact_available, + "modelLabArtifactInstalled": lab_artifact_installed, "released": released, "builtin": is_builtin_model_key(canonical_key), "communityFavorite": canonical_key in community_favorites, @@ -6739,6 +6920,10 @@ def setup(app): existing["userFavorite"] = existing["userFavorite"] or canonical_key in user_favorites existing["requiresGpu"] = existing["requiresGpu"] or requires_external_gpu existing["gpuAvailable"] = not existing["requiresGpu"] or external_gpu_present + existing["small"] = existing["small"] and small_model + existing["modelLabEligible"] = existing["modelLabEligible"] and lab_eligible + existing["modelLabArtifactAvailable"] = existing["modelLabArtifactAvailable"] and lab_artifact_available + existing["modelLabArtifactInstalled"] = existing["modelLabArtifactInstalled"] and lab_artifact_installed default_key = _default_model_key() default_entry = models_by_key.setdefault(default_key, { @@ -6749,6 +6934,12 @@ def setup(app): "artifactFormat": "tinygrad_single_v1", "requiresGpu": False, "gpuAvailable": True, + "small": True, + "modelSize": "small (inferred)", + "manifestDeclaredSize": False, + "modelLabEligible": model_lab_manifest_eligible(artifact_metadata.get(default_key, {}), _default_model_version()), + "modelLabArtifactAvailable": False, + "modelLabArtifactInstalled": False, "released": "", "builtin": True, "communityFavorite": default_key in community_favorites, @@ -9322,6 +9513,57 @@ def setup(app): "skippedCount": skipped_count, }) + @app.route("/api/toggles/profiles", methods=["GET"]) + def get_toggle_profiles(): + return jsonify({ + "slots": param_profiles.list_profiles(profile_root=TOGGLE_BACKUPS), + "isOnroad": _safe_params_get_bool("IsOnroad"), + }) + + @app.route("/api/toggles/profiles//save", methods=["POST"]) + def save_toggle_profile(slot): + if _safe_params_get_bool("IsOnroad"): + return jsonify({"success": False, "message": "Settings profiles can only be saved while parked."}), 403 + try: + status = param_profiles.save_profile( + _params_raw, + slot, + allowed_keys=_get_toggle_backup_keys(), + profile_root=TOGGLE_BACKUPS, + ) + except param_profiles.ParamProfileError as error: + return jsonify({"success": False, "message": str(error)}), 400 + return jsonify({ + "success": True, + "message": f"Saved current settings to {status['label']}.", + "profile": status, + }) + + @app.route("/api/toggles/profiles//load", methods=["POST"]) + def load_toggle_profile(slot): + if _safe_params_get_bool("IsOnroad"): + return jsonify({"success": False, "message": "Settings profiles can only be loaded while parked."}), 403 + try: + result = param_profiles.load_profile( + _params_raw, + slot, + allowed_keys=_get_toggle_backup_keys(), + profile_root=TOGGLE_BACKUPS, + legacy_renames=LEGACY_STARPILOT_PARAM_RENAMES, + ) + except param_profiles.ParamProfileError as error: + return jsonify({"success": False, "message": str(error)}), 400 + + update_starpilot_toggles() + message = f"Loaded {result['label']} ({result['restoredCount']} settings)." + if result["skippedCount"]: + message += f" Skipped {result['skippedCount']} incompatible settings." + return jsonify({ + "success": True, + "message": message, + **result, + }) + @app.route("/api/toggles/reset_default", methods=["POST"]) def reset_toggle_values(): for raw_key in _params_raw.all_keys(): diff --git a/system/webrtc/tests/test_webrtcd.py b/system/webrtc/tests/test_webrtcd.py index 9fb6a42e5..92af60ff4 100644 --- a/system/webrtc/tests/test_webrtcd.py +++ b/system/webrtc/tests/test_webrtcd.py @@ -4,7 +4,7 @@ import pytest pytest.importorskip("libdatachannel", reason="the upstream WebRTC backend requires Python 3.12") -from openpilot.system.webrtc.webrtcd import ServerState, handle_get_schema, handle_post_notify, on_shutdown +from openpilot.system.webrtc.webrtcd import ServerState, handle_get_schema, handle_get_stream, handle_post_notify, on_shutdown @pytest.mark.asyncio @@ -22,6 +22,13 @@ async def test_get_schema_rejects_unknown_service(): await handle_get_schema(ServerState(), "notARealService") +@pytest.mark.asyncio +async def test_stream_rejects_non_json_content_type(): + response = await handle_get_stream(ServerState(), b"{}", "text/plain") + + assert response == (415, b'{"error": "unsupported media type"}', "application/json; charset=utf-8") + + @pytest.mark.asyncio async def test_notify_and_shutdown_active_stream(mocker): state = ServerState() diff --git a/system/webrtc/webrtcd.py b/system/webrtc/webrtcd.py index cdc4be387..d291c4b4e 100644 --- a/system/webrtc/webrtcd.py +++ b/system/webrtc/webrtcd.py @@ -329,9 +329,11 @@ class StreamSession: async def run(self): try: self.params.put("LivestreamRequestKeyframe", True) + + self.stream.set_message_handler(self.message_handler) + await asyncio.wait_for(self.stream.wait_for_connection(), timeout=15) if self.stream.has_messaging_channel(): - self.stream.set_message_handler(self.message_handler) if self.incoming_bridge is not None: await self.shared_pub_master.add_services_if_needed(self.incoming_bridge_services) if self.outgoing_bridge is not None: @@ -395,7 +397,10 @@ def _text_response(text: str, status: int = 200) -> tuple[int, bytes, str]: return (status, text.encode(), "text/plain; charset=utf-8") -async def handle_get_stream(state: ServerState, raw_body: bytes) -> tuple[int, bytes, str]: +async def handle_get_stream(state: ServerState, raw_body: bytes, content_type: str) -> tuple[int, bytes, str]: + if content_type != "application/json": + return _json_response({"error": "unsupported media type"}, status=415) + stream_dict = state.streams parsed_dict = json.loads(raw_body) valid_fields = {f.name for f in StreamRequestBody.__dataclass_fields__.values()} @@ -511,7 +516,7 @@ class WebrtcdHandler(BaseHTTPRequestHandler): services = parse_qs(parsed.query).get("services", [""])[0] result = self._run(handle_get_schema(self.server.state, services)) elif parsed.path == "/stream": - result = self._run(handle_get_stream(self.server.state, self._read_body())) + result = self._run(handle_get_stream(self.server.state, self._read_body(), self.headers.get_content_type())) else: # /notify try: payload = json.loads(self._read_body()) @@ -614,7 +619,7 @@ def webrtcd_thread(host: str, port: int): def main(): parser = argparse.ArgumentParser(description="WebRTC daemon") - parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to listen on") + parser.add_argument("--host", type=str, default="127.0.0.1", help="Host to listen on") parser.add_argument("--port", type=int, default=5001, help="Port to listen on") args = parser.parse_args()