mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-06 08:03:44 +08:00
this is my laboratory
This commit is contained in:
@@ -510,6 +510,9 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> 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<std::string, ParamKeyAttributes> 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, "", ""}},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]) + \
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+367
-59
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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="",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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) {
|
||||
<div class="ds-stepper">
|
||||
<button
|
||||
class="ds-stepper-btn"
|
||||
disabled="${() => isLocked() || !canDecrease || false}"
|
||||
disabled="${() => isLocked() || isNumericUpdating(p.key) || !canStepNumericParam(p, -1)}"
|
||||
@click="${() => stepNumericParam(p, -1)}">-</button>
|
||||
<div class="ds-stepper-meta">
|
||||
<span>${formatSliderValue(bounds.min, String(bounds.step), p.precision, p.key)} to ${formatSliderValue(bounds.max, String(bounds.step), p.precision, p.key)}</span>
|
||||
@@ -1658,7 +1671,7 @@ function renderSettingRow(p) {
|
||||
</div>
|
||||
<button
|
||||
class="ds-stepper-btn"
|
||||
disabled="${() => isLocked() || !canIncrease || false}"
|
||||
disabled="${() => isLocked() || isNumericUpdating(p.key) || !canStepNumericParam(p, 1)}"
|
||||
@click="${() => stepNumericParam(p, 1)}">+</button> </div>
|
||||
`
|
||||
})()}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
<div class="ml-model">
|
||||
<div>
|
||||
<strong>${model.label}</strong>
|
||||
<div class="ml-muted">${model.value} · ${model.series || "Unknown series"}</div>
|
||||
</div>
|
||||
<div class="ml-chips">
|
||||
<span class="ml-chip">${model.version || "unknown version"}</span>
|
||||
<span class="ml-chip">${model.modelSize || "small"}</span>
|
||||
<span class="ml-chip ${model.modelLabArtifactInstalled ? "ml-chip-good" : "ml-chip-warning"}">
|
||||
${artifactStatus}
|
||||
</span>
|
||||
${model.modelLabArtifactAvailable && !model.modelLabArtifactInstalled ? html`
|
||||
<button class="ml-button" data-ml-download="${model.value}" disabled="${() => state.saving || state.isOnroad}">
|
||||
Prepare for Chestnut
|
||||
</button>
|
||||
` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
export function ModelLaboratory() {
|
||||
if (!initialized) {
|
||||
initialized = true
|
||||
refresh()
|
||||
}
|
||||
ensurePolling()
|
||||
setTimeout(bindControls, 0)
|
||||
|
||||
return html`
|
||||
<div class="ml-wrapper">
|
||||
<header class="ml-hero">
|
||||
<div>
|
||||
<div class="ml-kicker">Chestnut experiment</div>
|
||||
<h2>Model Laboratory</h2>
|
||||
<p>Use the lateral judgment of one small model and the longitudinal judgment of another.</p>
|
||||
</div>
|
||||
<div class="ml-chips">
|
||||
<span class="ml-chip ${() => state.chestnutReady ? "ml-chip-good" : "ml-chip-warning"}">
|
||||
${() => state.chestnutReady ? "Chestnut ready" : "Chestnut required"}
|
||||
</span>
|
||||
<span class="ml-chip ${() => state.isOnroad ? "ml-chip-warning" : "ml-chip-good"}">
|
||||
${() => state.isOnroad ? "Onroad · locked" : "Parked · configurable"}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
${() => state.error ? html`<div class="ml-alert ml-alert-error">${state.error}</div>` : ""}
|
||||
${() => state.message ? html`<div class="ml-alert ml-alert-good">${state.message}</div>` : ""}
|
||||
${() => state.loading ? html`<div class="ml-card">Loading laboratory status…</div>` : ""}
|
||||
|
||||
${() => !state.loading ? html`
|
||||
<section class="ml-card">
|
||||
<div class="ml-card-heading">
|
||||
<div>
|
||||
<h3>Compose a pair</h3>
|
||||
<p>Both precompiled small models stay resident and run every camera frame on Chestnut's AMD GPU.</p>
|
||||
</div>
|
||||
<span class="ml-state ${() => state.configuration.enabled ? "is-enabled" : ""}">
|
||||
${() => state.configuration.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="ml-pair">
|
||||
<label>
|
||||
<span>Lateral model</span>
|
||||
<small>Path shape, curvature, lane geometry, and driving desire</small>
|
||||
<select id="ml-lateral-model" class="ml-select">
|
||||
<option value="">Choose a model</option>
|
||||
${() => candidateModels("lateral").map(model => html`
|
||||
<option value="${model.value}">
|
||||
${model.label} · ${model.version}
|
||||
</option>
|
||||
`)}
|
||||
</select>
|
||||
</label>
|
||||
<div class="ml-plus">+</div>
|
||||
<label>
|
||||
<span>Longitudinal model</span>
|
||||
<small>Speed, acceleration, stopping, leads, and scene confidence</small>
|
||||
<select id="ml-longitudinal-model" class="ml-select">
|
||||
<option value="">Choose a model</option>
|
||||
${() => candidateModels("longitudinal").map(model => html`
|
||||
<option value="${model.value}">
|
||||
${model.label} · ${model.version}
|
||||
</option>
|
||||
`)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="ml-preview">
|
||||
<b>${() => modelLabel(state.configuration.lateralModel)}</b>
|
||||
<span>steers</span>
|
||||
<i class="bi bi-arrow-left-right"></i>
|
||||
<b>${() => modelLabel(state.configuration.longitudinalModel)}</b>
|
||||
<span>paces</span>
|
||||
</div>
|
||||
|
||||
${() => selectionError() ? html`<p class="ml-validation">${selectionError()}</p>` : ""}
|
||||
<div class="ml-actions">
|
||||
<button id="ml-enable" class="ml-button ml-button-primary" disabled="${() => state.saving || Boolean(selectionError())}">
|
||||
Enable for next drive
|
||||
</button>
|
||||
<button id="ml-disable" class="ml-button" disabled="${() => state.saving || state.isOnroad || !state.configuration.enabled}">
|
||||
Disable
|
||||
</button>
|
||||
<button id="ml-refresh" class="ml-button" disabled="${() => state.saving}">Refresh</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ml-card">
|
||||
<div class="ml-card-heading">
|
||||
<div>
|
||||
<h3>Runtime</h3>
|
||||
<p>The configuration activates when modeld starts for a drive.</p>
|
||||
</div>
|
||||
<span class="ml-state ${() => state.runtime?.active ? "is-enabled" : ""}">
|
||||
${() => state.runtime?.active ? "Pair active" : state.runtime?.requested ? "Pair requested" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-runtime-grid">
|
||||
<div><span>Lateral</span><b>${() => modelLabel(state.runtime?.lateralModel)}</b></div>
|
||||
<div><span>Longitudinal</span><b>${() => modelLabel(state.runtime?.longitudinalModel)}</b></div>
|
||||
</div>
|
||||
${() => state.runtime?.error ? html`<div class="ml-alert ml-alert-error">${state.runtime.error}</div>` : ""}
|
||||
<p class="ml-muted">Both roles evaluate the same frame at 20 Hz. A runtime failure suppresses that frame and falls back to the built-in QCOM model.</p>
|
||||
</section>
|
||||
|
||||
<section class="ml-card">
|
||||
<div class="ml-card-heading">
|
||||
<div>
|
||||
<h3>Small-model readiness</h3>
|
||||
<p>${state.summary.ready || 0} AMD-ready · ${state.summary.published || 0} published · ${state.summary.eligible || 0} eligible small models.</p>
|
||||
</div>
|
||||
<span class="ml-chip">Manifest ${state.manifest.version || "unknown"}</span>
|
||||
</div>
|
||||
<div class="ml-model-list">${() => state.models.map(renderModel)}</div>
|
||||
<div class="ml-note">
|
||||
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.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ml-findings">
|
||||
<article class="ml-card">
|
||||
<h3>Manifest shortcomings</h3>
|
||||
<ul>${(state.manifest.shortcomings || []).map(item => html`<li>${item}</li>`)}</ul>
|
||||
</article>
|
||||
<article class="ml-card">
|
||||
<h3>Opportunities</h3>
|
||||
<ul>${(state.manifest.opportunities || []).map(item => html`<li>${item}</li>`)}</ul>
|
||||
</article>
|
||||
</section>
|
||||
` : ""}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
</p>
|
||||
<button class="toggle-control-button" @click="${backupToggles}">Backup Toggles</button>
|
||||
<button class="toggle-control-button" @click="${triggerRestorePrompt}">Restore Toggles</button>
|
||||
<div class="toggle-profile-section">
|
||||
<div class="toggle-control-title">Settings Profiles</div>
|
||||
<p class="toggle-control-text">
|
||||
Keep two local configurations for different vehicles, drivers, or troubleshooting. Pairing and sensitive device data are not included.
|
||||
</p>
|
||||
${() => state.profileIsOnroad ? html`<p class="toggle-profile-warning">Park the vehicle to save or load a profile.</p>` : ""}
|
||||
${() => state.profiles.map(profile => html`
|
||||
<div class="toggle-profile-row">
|
||||
<div class="toggle-profile-heading">
|
||||
<strong>${profile.label}</strong>
|
||||
<span>${profile.invalid ? "Damaged" : profile.saved ? `${profile.settingsCount} settings` : "Empty"}</span>
|
||||
</div>
|
||||
<div class="toggle-profile-actions">
|
||||
<button
|
||||
class="toggle-control-button"
|
||||
@click="${() => confirmProfileAction(profile, "save")}"
|
||||
disabled="${() => !!state.profileBusy || state.profileIsOnroad}">
|
||||
${() => state.profileBusy === `save-${profile.slot}` ? "Saving..." : profile.saved ? "Overwrite" : "Save Current"}
|
||||
</button>
|
||||
<button
|
||||
class="toggle-control-button"
|
||||
@click="${() => confirmProfileAction(profile, "load")}"
|
||||
disabled="${() => !!state.profileBusy || state.profileIsOnroad || !profile.saved || profile.invalid}">
|
||||
${() => state.profileBusy === `load-${profile.slot}` ? "Loading..." : "Load"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="toggle-control-widget" style="margin-left: 1.5rem">
|
||||
@@ -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"
|
||||
}) : ""}
|
||||
`
|
||||
}
|
||||
|
||||
@@ -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" }) },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
</GalaxySection>
|
||||
|
||||
<GalaxySection title="Backup & Restore" icon="bi-arrow-repeat" :collapsible="false">
|
||||
<div style="padding: var(--sp-3);">
|
||||
<h4 style="margin:0 0 4px;">Settings Profiles</h4>
|
||||
<p class="gx-note" style="margin:0 0 10px;">Keep two local configurations for different vehicles, drivers, or troubleshooting. Profiles never include pairing or sensitive device data.</p>
|
||||
<GxNotice v-if="isOnroad" text="Park the vehicle to save or load a profile." style="margin-bottom:12px;" />
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:10px; margin-bottom:16px;">
|
||||
<div v-for="profile in profiles" :key="profile.slot" class="gx-card" style="padding:12px;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:10px;">
|
||||
<strong>{{ profile.label }}</strong>
|
||||
<span class="gx-chip">{{ profile.invalid ? 'Damaged' : profile.saved ? profile.settingsCount + ' settings' : 'Empty' }}</span>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!profileBusy || isOnroad" @click="saveProfile(profile)">
|
||||
<i class="bi bi-save"></i> {{ profileBusy === 'save-' + profile.slot ? 'Saving...' : profile.saved ? 'Overwrite' : 'Save Current' }}
|
||||
</button>
|
||||
<button type="button" class="gx-btn" :disabled="!!profileBusy || isOnroad || !profile.saved || profile.invalid" @click="loadProfile(profile)">
|
||||
<i class="bi bi-arrow-down-circle"></i> {{ profileBusy === 'load-' + profile.slot ? 'Loading...' : 'Load' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: var(--sp-3); display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<button type="button" class="gx-btn" @click="backupToggles"><i class="bi bi-download"></i> Backup Toggles</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="$refs.restoreInput.click()"><i class="bi bi-upload"></i> Restore Toggles</button>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<link rel="stylesheet" href="/assets/components/tools/error_logs.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/maps.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/model_manager.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/model_laboratory.css?v=model-lab-1">
|
||||
<link rel="stylesheet" href="/assets/components/tools/plots.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/<slot>/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/<slot>/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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user