diff --git a/common/libcommon.a b/common/libcommon.a index b5ca68a3cd..4f3cdf222d 100644 Binary files a/common/libcommon.a and b/common/libcommon.a differ diff --git a/common/params_keys.h b/common/params_keys.h index 900d87d3d7..d13b0e87b0 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -466,6 +466,7 @@ inline static std::unordered_map keys = { {"LeadDetectionThreshold", {PERSISTENT, INT, "35", "50", 3}}, {"LeadIndicator", {PERSISTENT, BOOL, "0", "0", 2, SETTINGS_SIMPLE}}, {"LeadInfo", {PERSISTENT, BOOL, "0", "0", 3}}, + {"LeadInfoMode", {PERSISTENT, INT, "2", "2", 3}}, {"LKASButtonControl", {PERSISTENT, INT, "5", "0", 2, SETTINGS_SIMPLE}}, {"LockDoors", {PERSISTENT, BOOL, "1", "0", 0}}, {"LockDoorsTimer", {PERSISTENT, INT, "0", "0", 0}}, diff --git a/common/params_pyx.so b/common/params_pyx.so index c5024761f2..34893246f4 100755 Binary files a/common/params_pyx.so and b/common/params_pyx.so differ diff --git a/opendbc_repo/opendbc/dbc/hyundai_can_refresh_generated.dbc b/opendbc_repo/opendbc/dbc/hyundai_can_refresh_generated.dbc index 8e73e28018..bb6acbd6d7 100644 --- a/opendbc_repo/opendbc/dbc/hyundai_can_refresh_generated.dbc +++ b/opendbc_repo/opendbc/dbc/hyundai_can_refresh_generated.dbc @@ -1497,7 +1497,7 @@ BO_ 913 BCM_PO_11: 8 Vector__XXX SG_ BCM_Door_Dri_Status : 5|1@0+ (1,0) [0|1] "" PT_ESC_ABS SG_ BCM_Shift_R_MT_SW_Status : 39|2@0+ (1,0) [0|3] "" PT_ESC_ABS SG_ LDA_BTN : 4|1@0+ (1,0) [0|1] "" XXX - SG_ RAY_LKAS_BTN : 0|2@1+ (1,0) [0|3] "" XXX + SG_ RAY_LKAS_BTN : 4|1@0+ (1,0) [0|1] "" XXX BO_ 1426 LABEL11: 8 XXX SG_ CC_React : 34|1@1+ (1,0) [0|1] "" XXX diff --git a/scripts/model_release.py b/scripts/model_release.py index 1a749fc8bc..9b45d82a64 100755 --- a/scripts/model_release.py +++ b/scripts/model_release.py @@ -19,6 +19,7 @@ import shlex import shutil import subprocess import sys +import tempfile import urllib.error import urllib.parse import urllib.request @@ -619,6 +620,70 @@ def update_manifest(repo: Path, info: ReleaseInfo, result: dict, manifest_versio return path +def manifest_models(payload: object) -> list[dict]: + models = payload.get("models") if isinstance(payload, dict) else payload + if not isinstance(models, list) or not models: + raise ReleaseError("Unsupported or empty Hugging Face manifest") + if any(not isinstance(model, dict) or not str(model.get("id") or "").strip() for model in models): + raise ReleaseError("Hugging Face manifest contains an invalid model entry") + model_ids = [str(model["id"]).strip() for model in models] + if len(model_ids) != len(set(model_ids)): + raise ReleaseError("Hugging Face manifest contains duplicate model IDs") + return models + + +def accelerator_artifact_map(payload: object) -> dict[tuple[str, str], dict]: + artifacts: dict[tuple[str, str], dict] = {} + for model in manifest_models(payload): + model_id = str(model.get("id") or "").strip() + model_artifacts = model.get("accelerator_artifacts") + if not model_id or not isinstance(model_artifacts, dict): + continue + for accelerator, metadata in model_artifacts.items(): + if isinstance(metadata, dict): + artifacts[(model_id, str(accelerator))] = metadata + return artifacts + + +def validate_manifest_update(before: object, after: object, replacing_model_id: str) -> None: + before_by_id = {str(model["id"]).strip(): model for model in manifest_models(before)} + after_by_id = {str(model["id"]).strip(): model for model in manifest_models(after)} + before_ids = set(before_by_id) + after_ids = set(after_by_id) + expected_ids = before_ids | {replacing_model_id} + if after_ids != expected_ids: + missing = sorted(expected_ids - after_ids) + unexpected = sorted(after_ids - expected_ids) + raise ReleaseError( + "Refusing to publish a manifest with an unexpected model set" + + (f"; missing: {', '.join(missing)}" if missing else "") + + (f"; unexpected: {', '.join(unexpected)}" if unexpected else "") + ) + + before_artifacts = accelerator_artifact_map(before) + after_artifacts = accelerator_artifact_map(after) + regressions = [ + f"{model_id}:{accelerator}" + for (model_id, accelerator), metadata in before_artifacts.items() + if model_id != replacing_model_id and after_artifacts.get((model_id, accelerator)) != metadata + ] + if regressions: + raise ReleaseError( + "Refusing to publish a manifest that removes or changes existing accelerator metadata for: " + + ", ".join(sorted(regressions)) + ) + + unrelated_changes = sorted( + model_id for model_id, model in before_by_id.items() + if model_id != replacing_model_id and after_by_id[model_id] != model + ) + if unrelated_changes: + raise ReleaseError( + "Refusing to publish a manifest that changes unrelated model entries: " + + ", ".join(unrelated_changes) + ) + + def find_hf() -> str: candidates = [shutil.which("hf"), str(Path.home() / ".local/bin/hf")] for candidate in candidates: @@ -633,15 +698,42 @@ def hf_copy(source: Path, bucket: str, remote_path: str) -> None: run([hf, "buckets", "cp", str(source), destination, "--format", "quiet"]) +def refresh_huggingface_manifest(manifest: Path, bucket: str) -> dict: + remote_path = f"manifests/{manifest.name}" + source = f"hf://buckets/{bucket}/{remote_path}" + manifest.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".model-release-", dir=manifest.parent) as temporary_dir: + candidate = Path(temporary_dir) / manifest.name + run([find_hf(), "buckets", "cp", source, str(candidate), "--format", "quiet"]) + try: + payload = json.loads(candidate.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ReleaseError(f"Invalid live Hugging Face manifest: {error}") from error + accelerator_artifact_map(payload) + candidate.replace(manifest) + return payload + + +def prepare_huggingface_manifest(manifest: Path, info: ReleaseInfo, result: dict, + bucket: str, manifest_version: str) -> Path: + live_payload = refresh_huggingface_manifest(manifest, bucket) + updated_manifest = update_manifest(manifest.parent, info, result, manifest_version) + updated_payload = json.loads(updated_manifest.read_text(encoding="utf-8")) + validate_manifest_update(live_payload, updated_payload, info.model_id) + return updated_manifest + + def upload_huggingface(info: ReleaseInfo, result: dict, workspace: Path, bucket: str, manifest_version: str, - manifest: Path, upload_onnx: bool, source: Path) -> None: + manifest: Path, upload_onnx: bool, source: Path) -> Path: artifact_dir = Path(result["path"]) for filename in result["files"]: hf_copy(artifact_dir / filename, bucket, f"models/{manifest_version}/{info.model_id}/{filename}") if upload_onnx: hf_copy(source, bucket, f"onnx/{info.model_id}/{source.name}") + manifest = prepare_huggingface_manifest(manifest, info, result, bucket, manifest_version) hf_copy(manifest, bucket, f"manifests/{manifest.name}") print(f"Hugging Face upload complete: {bucket}/models/{manifest_version}/{info.model_id}/") + return manifest def git_output(repo: Path, args: list[str]) -> str: @@ -777,9 +869,9 @@ def main() -> int: result = remote_compile(info, source, ip, workspace, args.keep_device_files) resources_repo = args.resources_repo.expanduser().resolve() check_resources_repo(resources_repo, args.resources_branch) - manifest = update_manifest(resources_repo, info, result, args.manifest_version) - upload_huggingface(info, result, workspace, args.hf_bucket, args.manifest_version, - manifest, not args.no_onnx_upload, source) + manifest = resources_repo / f"model_names_{args.manifest_version}.json" + manifest = upload_huggingface(info, result, workspace, args.hf_bucket, args.manifest_version, + manifest, not args.no_onnx_upload, source) push_github(info, result, resources_repo, args.manifest_version, manifest, args.resources_branch, args.force) print("\nRelease complete.") print(f" local artifact: {result['path']}") diff --git a/scripts/tests/test_model_release.py b/scripts/tests/test_model_release.py index 946b25677d..fbd12eacca 100644 --- a/scripts/tests/test_model_release.py +++ b/scripts/tests/test_model_release.py @@ -3,8 +3,20 @@ from __future__ import annotations import json from pathlib import Path +import pytest + from scripts import model_release -from scripts.model_release import parse_lfs_pointer, parse_pasted_release, runtime_file, update_manifest +from scripts.model_release import ( + ReleaseError, + parse_lfs_pointer, + parse_pasted_release, + prepare_huggingface_manifest, + refresh_huggingface_manifest, + runtime_file, + update_manifest, + upload_huggingface, + validate_manifest_update, +) RELEASE_TEXT = """ @@ -95,3 +107,138 @@ def test_update_manifest_replaces_one_entry(tmp_path: Path): assert entry["artifact_size"] == 123 assert entry["artifact_chunk_count"] == 2 assert entry["uses_external_gpu"] + + +def test_prepare_manifest_refreshes_live_copy_before_adding_model(tmp_path: Path, monkeypatch): + manifest = tmp_path / "model_names_v25.json" + manifest.write_text(json.dumps({"models": [{"id": "stale"}]}) + "\n") + live_payload = { + "models": [{ + "id": "small-model", + "model_lab_eligible": True, + "accelerator_artifacts": { + "chestnut": { + "artifact_filename": "small-model_driving_chestnut_tinygrad.pkl", + "artifact_sha256": "b" * 64, + "execution_device": "AMD", + }, + }, + }], + } + + def fake_refresh(path, bucket): + assert bucket == "StarPilot-Driving/StarPilot-Resources" + path.write_text(json.dumps(live_payload) + "\n") + return live_payload + + monkeypatch.setattr(model_release, "refresh_huggingface_manifest", fake_refresh) + info = parse_pasted_release(RELEASE_TEXT, "bmrlnapv4", "v16") + prepared = prepare_huggingface_manifest( + manifest, + info, + {"size": 123, "sha256": "a" * 64, "chunk_count": 2}, + "StarPilot-Driving/StarPilot-Resources", + "v25", + ) + + models = {entry["id"]: entry for entry in json.loads(prepared.read_text())["models"]} + assert set(models) == {"small-model", "bmrlnapv4"} + assert models["small-model"] == live_payload["models"][0] + assert models["bmrlnapv4"]["artifact_sha256"] == "a" * 64 + + +def test_manifest_guard_rejects_unrelated_accelerator_metadata_regression(): + chestnut = {"execution_device": "AMD", "artifact_sha256": "a" * 64} + before = {"models": [ + {"id": "keep", "accelerator_artifacts": {"chestnut": chestnut}}, + {"id": "replace", "accelerator_artifacts": {"chestnut": chestnut}}, + ]} + after = {"models": [{"id": "keep"}, {"id": "replace"}]} + + with pytest.raises(ReleaseError, match="keep:chestnut"): + validate_manifest_update(before, after, "replace") + + validate_manifest_update( + {"models": [{"id": "replace", "accelerator_artifacts": {"chestnut": chestnut}}]}, + {"models": [{"id": "replace"}]}, + "replace", + ) + + with pytest.raises(ReleaseError, match="unrelated model entries: keep"): + validate_manifest_update( + {"models": [{"id": "keep", "model_lab_eligible": True}]}, + {"models": [{"id": "keep", "model_lab_eligible": False}, {"id": "replace"}]}, + "replace", + ) + + +def test_refresh_manifest_is_atomic_and_uses_hugging_face_bucket(tmp_path: Path, monkeypatch): + manifest = tmp_path / "model_names_v25.json" + manifest.write_text(json.dumps({"models": [{"id": "stale"}]}) + "\n") + live_payload = {"models": [{"id": "live"}]} + commands = [] + + monkeypatch.setattr(model_release, "find_hf", lambda: "/usr/bin/hf") + + def fake_run(command, **kwargs): + commands.append(command) + Path(command[4]).write_text(json.dumps(live_payload) + "\n") + + monkeypatch.setattr(model_release, "run", fake_run) + assert refresh_huggingface_manifest(manifest, "owner/resources") == live_payload + assert json.loads(manifest.read_text()) == live_payload + assert commands[0][0:4] == [ + "/usr/bin/hf", + "buckets", + "cp", + "hf://buckets/owner/resources/manifests/model_names_v25.json", + ] + + +def test_refresh_manifest_keeps_local_copy_when_live_json_is_invalid(tmp_path: Path, monkeypatch): + manifest = tmp_path / "model_names_v25.json" + original = {"models": [{"id": "safe"}]} + manifest.write_text(json.dumps(original) + "\n") + monkeypatch.setattr(model_release, "find_hf", lambda: "/usr/bin/hf") + monkeypatch.setattr(model_release, "run", lambda command, **kwargs: Path(command[4]).write_text("{")) + + with pytest.raises(ReleaseError, match="Invalid live Hugging Face manifest"): + refresh_huggingface_manifest(manifest, "owner/resources") + assert json.loads(manifest.read_text()) == original + + +def test_upload_refreshes_manifest_after_artifacts(tmp_path: Path, monkeypatch): + artifact_dir = tmp_path / "artifacts" + artifact_dir.mkdir() + manifest = tmp_path / "resources" / "model_names_v25.json" + manifest.parent.mkdir() + manifest.write_text(json.dumps({"models": [{"id": "old"}]}) + "\n") + source = tmp_path / "model.onnx" + calls = [] + info = parse_pasted_release(RELEASE_TEXT, "bmrlnapv4", "v16") + result = { + "path": str(artifact_dir), + "files": ["bmrlnapv4_driving_tinygrad.pkl"], + "size": 123, + "sha256": "a" * 64, + "chunk_count": 0, + } + + monkeypatch.setattr(model_release, "hf_copy", lambda source, bucket, remote: calls.append(("copy", remote))) + + def fake_prepare(path, release_info, release_result, bucket, version): + calls.append(("prepare", path.name)) + return path + + monkeypatch.setattr(model_release, "prepare_huggingface_manifest", fake_prepare) + returned = upload_huggingface( + info, result, tmp_path, "owner/resources", "v25", manifest, True, source, + ) + + assert returned == manifest + assert calls == [ + ("copy", "models/v25/bmrlnapv4/bmrlnapv4_driving_tinygrad.pkl"), + ("copy", "onnx/bmrlnapv4/model.onnx"), + ("prepare", "model_names_v25.json"), + ("copy", "manifests/model_names_v25.json"), + ] diff --git a/selfdrive/controls/lib/longcontrol.py b/selfdrive/controls/lib/longcontrol.py index dbea793485..985e49b9ec 100644 --- a/selfdrive/controls/lib/longcontrol.py +++ b/selfdrive/controls/lib/longcontrol.py @@ -262,7 +262,7 @@ class LongControl: output_accel = min(output_accel, 0.0) output_accel -= starpilot_toggles.stoppingDecelRate * DT_CTRL output_accel = self.vehicle_tuning.shape_stopping_accel( - output_accel, a_target, should_stop, CS.vEgo, has_lead, starpilot_toggles.stopAccel, + output_accel, a_target, should_stop, CS.vEgo, has_lead, starpilot_toggles.stopAccel, leads=leads, ) output_accel = self._apply_moving_stop_target_follow(output_accel, a_target, should_stop, CS, starpilot_toggles) self.reset(preserve_stop_release=True) diff --git a/selfdrive/controls/lib/longcontrol_vehicle_tunes.py b/selfdrive/controls/lib/longcontrol_vehicle_tunes.py index a76292d138..2abb1ed91b 100644 --- a/selfdrive/controls/lib/longcontrol_vehicle_tunes.py +++ b/selfdrive/controls/lib/longcontrol_vehicle_tunes.py @@ -3,6 +3,7 @@ import numpy as np from opendbc.car.gm.values import CAR, GMFlags from opendbc.car.subaru.values import CAR as SUBARU_CAR from opendbc.car.toyota.values import CAR as TOYOTA_CAR +from opendbc.car.volkswagen.values import CAR as VOLKSWAGEN_CAR from openpilot.common.realtime import DT_CTRL from openpilot.starpilot.common.testing_grounds import testing_ground @@ -63,6 +64,12 @@ HYUNDAI_ELANTRA_FINAL_STOP_URGENCY_MARGIN = 0.45 HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED = 1.0 HYUNDAI_SANTA_FE_FINAL_STOP_CAP_BP = [0.0, 0.2, 0.5, HYUNDAI_SANTA_FE_FINAL_STOP_MAX_SPEED] HYUNDAI_SANTA_FE_FINAL_STOP_CAP_V = [-0.25, -0.30, -0.50, -0.90] +VOLKSWAGEN_TAOS_COMFORT_STOP_MAX_SPEED = 4.5 +VOLKSWAGEN_TAOS_COMFORT_STOP_MIN_DISTANCE = 5.0 +VOLKSWAGEN_TAOS_COMFORT_STOP_MIN_TTC = 4.0 +VOLKSWAGEN_TAOS_COMFORT_STOP_MAX_CLOSING_SPEED = 1.5 +VOLKSWAGEN_TAOS_COMFORT_STOP_CAP_BP = [0.0, 0.5, 1.0, 2.0, 3.5, VOLKSWAGEN_TAOS_COMFORT_STOP_MAX_SPEED] +VOLKSWAGEN_TAOS_COMFORT_STOP_CAP_V = [-0.45, -0.55, -0.65, -0.80, -0.95, -1.10] def get_bolt_acc_pedal_friction_bias(output_accel, a_target, v_ego): @@ -152,6 +159,10 @@ class LongControlVehicleTuning: self.is_hyundai_santa_fe_2022 = bool( CP.brand == "hyundai" and str(getattr(CP, "carFingerprint", "")) == "HYUNDAI_SANTA_FE_2022" ) + self.is_volkswagen_taos = bool( + CP.brand == "volkswagen" and + str(getattr(CP, "carFingerprint", "")) == str(VOLKSWAGEN_CAR.VOLKSWAGEN_TAOS_MK1) + ) self.is_bolt_acc_pedal_friction_car = bool( CP.brand == "gm" and CP.enableGasInterceptorDEPRECATED and @@ -172,8 +183,32 @@ class LongControlVehicleTuning: self.bolt_start_handoff_frames = 0 self.subaru_stop_release_frames = 0 - def shape_stopping_accel(self, output_accel, a_target, should_stop, v_ego, has_lead, stop_accel): + def shape_stopping_accel(self, output_accel, a_target, should_stop, v_ego, has_lead, stop_accel, leads=None): """Shape low-speed stop braking without overriding urgent targets.""" + if self.is_volkswagen_taos and should_stop and has_lead and v_ego < VOLKSWAGEN_TAOS_COMFORT_STOP_MAX_SPEED: + comfort_lead = next(( + lead for lead in (leads or ()) + if bool(getattr(lead, "status", False)) and + abs(float(getattr(lead, "yRel", 0.0))) <= 1.75 and + float(getattr(lead, "dRel", 0.0)) > 0.0 + ), None) + if comfort_lead is not None: + lead_distance = float(getattr(comfort_lead, "dRel", 0.0)) + lead_speed = max(0.0, float(getattr(comfort_lead, "vLead", 0.0))) + closing_speed = max(0.0, float(v_ego) - lead_speed) + ttc = lead_distance / max(closing_speed, 0.1) if closing_speed > 0.1 else float("inf") + if ( + lead_distance >= VOLKSWAGEN_TAOS_COMFORT_STOP_MIN_DISTANCE and + ttc >= VOLKSWAGEN_TAOS_COMFORT_STOP_MIN_TTC and + closing_speed <= VOLKSWAGEN_TAOS_COMFORT_STOP_MAX_CLOSING_SPEED + ): + comfort_cap = float(interp( + v_ego, + VOLKSWAGEN_TAOS_COMFORT_STOP_CAP_BP, + VOLKSWAGEN_TAOS_COMFORT_STOP_CAP_V, + )) + return max(float(output_accel), comfort_cap) + if ( self.is_hyundai_elantra_2021 and should_stop and diff --git a/selfdrive/controls/tests/test_longcontrol.py b/selfdrive/controls/tests/test_longcontrol.py index 7cf909731f..6249de8096 100644 --- a/selfdrive/controls/tests/test_longcontrol.py +++ b/selfdrive/controls/tests/test_longcontrol.py @@ -8,6 +8,7 @@ import openpilot.selfdrive.controls.lib.longcontrol_vehicle_tunes as vehicle_tun from opendbc.car.gm.values import CAR, GMFlags from opendbc.car.subaru.values import CAR as SUBARU_CAR from opendbc.car.toyota.values import CAR as TOYOTA_CAR +from opendbc.car.volkswagen.values import CAR as VOLKSWAGEN_CAR from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.controls.lib.longcontrol import ( LongControl, @@ -1236,6 +1237,34 @@ def test_santa_fe_final_stop_cap_softens_only_last_kmh(): assert tuning.shape_stopping_accel(-2.0, 0.3, False, 0.2, False, -2.0) == pytest.approx(-2.0) +def test_taos_comfort_stop_cap_softens_non_urgent_moving_lead(): + CP = make_longcontrol_cp( + brand="volkswagen", + carFingerprint=VOLKSWAGEN_CAR.VOLKSWAGEN_TAOS_MK1, + ) + tuning = vehicle_tunes.LongControlVehicleTuning(CP) + moving_lead = SimpleNamespace(status=True, dRel=7.0, vLead=2.6, yRel=0.0) + + output = tuning.shape_stopping_accel( + -1.88, -2.12, True, 3.4, True, -0.55, leads=(moving_lead,) + ) + + assert output == pytest.approx(-0.94) + + +def test_taos_comfort_stop_cap_preserves_urgent_lead_braking(): + CP = make_longcontrol_cp( + brand="volkswagen", + carFingerprint=VOLKSWAGEN_CAR.VOLKSWAGEN_TAOS_MK1, + ) + tuning = vehicle_tunes.LongControlVehicleTuning(CP) + stopped_lead = SimpleNamespace(status=True, dRel=6.0, vLead=0.2, yRel=0.0) + + assert tuning.shape_stopping_accel( + -1.88, -2.12, True, 3.4, True, -0.55, leads=(stopped_lead,) + ) == pytest.approx(-1.88) + + def test_toyota_sienna_target_filter_smooths_mild_high_speed_handoffs(): CP = make_longcontrol_cp(brand="toyota", carFingerprint=TOYOTA_CAR.TOYOTA_SIENNA_4TH_GEN) tuning = vehicle_tunes.LongControlVehicleTuning(CP) diff --git a/selfdrive/ui/lib/starpilot_visuals.py b/selfdrive/ui/lib/starpilot_visuals.py index 1669eeb841..84a013d5c7 100644 --- a/selfdrive/ui/lib/starpilot_visuals.py +++ b/selfdrive/ui/lib/starpilot_visuals.py @@ -1,6 +1,7 @@ from __future__ import annotations import math +from enum import IntEnum import pyray as rl @@ -11,6 +12,12 @@ _BORDER_ROUNDNESS = 0.12 _BORDER_RADIUS_MULTIPLE = 3.0 +class LeadInfoMode(IntEnum): + OFF = 0 + DISTANCE = 1 + SPEED = 2 + + def get_border_roundness(rect: rl.Rectangle, border_width: float) -> float: """Keep a rectangular camera inset inside the rounded frame at thin widths.""" min_dimension = max(1.0, min(rect.width, rect.height)) @@ -43,3 +50,16 @@ def lead_indicator_enabled(params: Params | None = None, *, hide_by_default: boo if active_params.get("HideLeadMarker") is None: return not hide_by_default return not active_params.get_bool("HideLeadMarker") + + +def lead_info_mode(params: Params | None = None) -> LeadInfoMode: + active_params = params if params is not None else Params() + if not active_params.get_bool("LeadInfo"): + return LeadInfoMode.OFF + + try: + mode = LeadInfoMode(active_params.get_int("LeadInfoMode", return_default=True, default=LeadInfoMode.SPEED)) + except ValueError: + mode = LeadInfoMode.SPEED + + return LeadInfoMode.SPEED if mode == LeadInfoMode.OFF else mode diff --git a/selfdrive/ui/mici/layouts/settings/visuals.py b/selfdrive/ui/mici/layouts/settings/visuals.py index 61fdabddd0..723ac844f6 100644 --- a/selfdrive/ui/mici/layouts/settings/visuals.py +++ b/selfdrive/ui/mici/layouts/settings/visuals.py @@ -1,11 +1,16 @@ from openpilot.common.params import Params -from openpilot.selfdrive.ui.lib.starpilot_visuals import lead_indicator_enabled +from openpilot.selfdrive.ui.lib.starpilot_visuals import LeadInfoMode, lead_indicator_enabled, lead_info_mode from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigParamControl, BigToggle from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigMultiOptionDialog from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.widgets.scroller import NavScroller CAMERA_VIEW_LABELS = ["Auto", "Driver", "Standard", "Wide", "None"] +LEAD_INFO_LABELS = { + LeadInfoMode.OFF: "Off", + LeadInfoMode.DISTANCE: "Distance", + LeadInfoMode.SPEED: "Speed", +} class CameraViewBigButton(BigButton): @@ -53,6 +58,37 @@ class LeadIndicatorBigButton(BigToggle): self.set_checked(lead_indicator_enabled(self.params, hide_by_default=True)) +class LeadInfoBigButton(BigButton): + def __init__(self): + super().__init__("lead info", "", gui_app.texture("icons_mici/onroad/eye_fill.png", 64, 64)) + self.params = Params() + self.set_click_callback(self._show_selector) + self.refresh() + + def refresh(self): + self.set_value(LEAD_INFO_LABELS[lead_info_mode(self.params)].lower()) + + def _show_selector(self): + current_mode = lead_info_mode(self.params) + options = list(LEAD_INFO_LABELS.values()) + dialog_holder: dict[str, BigMultiOptionDialog] = {} + + def on_confirm(): + selected = dialog_holder["dialog"].get_selected_option() + try: + selected_mode = next(mode for mode, label in LEAD_INFO_LABELS.items() if label == selected) + except StopIteration: + gui_app.push_widget(BigDialog("", "Invalid lead info mode")) + return + self.params.put_int("LeadInfoMode", int(selected_mode)) + self.params.put_bool("LeadInfo", selected_mode != LeadInfoMode.OFF) + self.refresh() + + dialog = BigMultiOptionDialog(options=options, default=LEAD_INFO_LABELS[current_mode], right_btn_callback=on_confirm) + dialog_holder["dialog"] = dialog + gui_app.push_widget(dialog) + + class VisualsLayoutMici(NavScroller): def __init__(self): super().__init__() @@ -63,7 +99,7 @@ class VisualsLayoutMici(NavScroller): self._torque_bar_btn = BigParamControl("torque bar", "EnableTorqueBarWidget") self._rainbow_path_btn = BigParamControl("rainbow road", "RainbowPath") self._lead_indicator_btn = LeadIndicatorBigButton() - self._lead_info_btn = BigParamControl("show lead speed", "LeadInfo") + self._lead_info_btn = LeadInfoBigButton() self._speed_limit_signs_btn = BigParamControl("show speed limits", "ShowSpeedLimits") self._slc_confirmation_btn = BigParamControl("confirm new speed limits", "SLCConfirmation") self._slc_confirmation_lower_btn = BigParamControl("confirm lower limits", "SLCConfirmationLower") @@ -95,6 +131,7 @@ class VisualsLayoutMici(NavScroller): def _refresh(self): self._camera_view_btn.refresh() self._lead_indicator_btn.refresh() + self._lead_info_btn.refresh() self._lead_info_btn.set_enabled(lead_indicator_enabled(self._lead_info_btn.params, hide_by_default=True)) confirmation_enabled = self._slc_confirmation_btn.params.get_bool("SLCConfirmation") self._slc_confirmation_lower_btn.set_visible(confirmation_enabled) diff --git a/selfdrive/ui/mici/onroad/model_renderer.py b/selfdrive/ui/mici/onroad/model_renderer.py index 66ec790aac..adf8b866d7 100644 --- a/selfdrive/ui/mici/onroad/model_renderer.py +++ b/selfdrive/ui/mici/onroad/model_renderer.py @@ -10,7 +10,7 @@ from openpilot.selfdrive.controls.lib.lane_centering import get_lane_centering_v from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT from openpilot.selfdrive.ui.lib.starpilot_theme import get_param_color, get_theme_color, get_visual_color, is_stock_color_scheme, with_alpha from openpilot.selfdrive.ui.onroad.starpilot.rainbow_path import RainbowPath -from openpilot.selfdrive.ui.lib.starpilot_visuals import blend_colors, lead_indicator_enabled +from openpilot.selfdrive.ui.lib.starpilot_visuals import LeadInfoMode, blend_colors, lead_indicator_enabled, lead_info_mode from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.selfdrive.ui.mici.onroad.starpilot_status import get_border_color from openpilot.system.ui.lib.application import gui_app, FontWeight @@ -67,7 +67,7 @@ class ModelRenderer(Widget): self._lane_line_probs = np.zeros(4, dtype=np.float32) self._road_edge_stds = np.zeros(2, dtype=np.float32) self._lead_vehicles = [LeadVehicle(), LeadVehicle()] - self._lead_info_enabled = False + self._lead_info_mode = LeadInfoMode.OFF self._path_offset_z = HEIGHT_INIT[0] # Initialize ModelPoints objects @@ -138,7 +138,7 @@ class ModelRenderer(Widget): model = sm['modelV2'] radar_state = sm['radarState'] if sm.valid['radarState'] else None lead_one = radar_state.leadOne if radar_state else None - self._lead_info_enabled = self._params.get_bool("LeadInfo") + self._lead_info_mode = lead_info_mode(self._params) render_lead_indicator = self._should_render_lead_indicator(radar_state) # Update model data when needed @@ -512,8 +512,15 @@ class ModelRenderer(Widget): rl.draw_triangle_fan(lead.chevron, len(lead.chevron), with_alpha(lead_color, lead.fill_alpha)) lead_one = radar_state.leadOne - if self._lead_info_enabled and lead_one and lead_one.status: - self._draw_lead_speed(lead_one) + if self._lead_info_mode != LeadInfoMode.OFF and lead_one and lead_one.status: + self._draw_lead_info(lead_one) + + @staticmethod + def _format_lead_distance(lead_distance: float, is_metric: bool, use_si_metrics: bool) -> str: + lead_distance = max(float(lead_distance), 0.0) + if is_metric or use_si_metrics: + return f"{round(lead_distance)} m" + return f"{round(lead_distance * CV.METER_TO_FOOT)} ft" @staticmethod def _format_lead_speed(lead_speed: float, is_metric: bool, use_si_metrics: bool) -> str: @@ -524,14 +531,16 @@ class ModelRenderer(Widget): return f"{round(lead_speed * CV.MS_TO_KPH)} km/h" return f"{round(lead_speed * CV.MS_TO_MPH)} mph" - def _draw_lead_speed(self, lead_data) -> None: + def _format_lead_info(self, lead_data, is_metric: bool, use_si_metrics: bool) -> str: + if self._lead_info_mode == LeadInfoMode.DISTANCE: + return self._format_lead_distance(getattr(lead_data, "dRel", 0.0), is_metric, use_si_metrics) + return self._format_lead_speed(getattr(lead_data, "vLead", 0.0), is_metric, use_si_metrics) + + def _draw_lead_info(self, lead_data) -> None: from openpilot.selfdrive.ui.onroad.starpilot.path import _draw_text_with_outline - text = self._format_lead_speed( - getattr(lead_data, "vLead", 0.0), - ui_state.is_metric, - ui_state.starpilot_toggles.get("UseSiMetrics", False), - ) + use_si_metrics = ui_state.starpilot_toggles.get("UseSiMetrics", False) + text = self._format_lead_info(lead_data, ui_state.is_metric, use_si_metrics) font = gui_app.font(FontWeight.SEMI_BOLD) font_size = 40 text_size = measure_text_cached(font, text, font_size) diff --git a/selfdrive/ui/mici/tests/test_lead_indicator.py b/selfdrive/ui/mici/tests/test_lead_indicator.py index ac5aef7944..a6131d3688 100644 --- a/selfdrive/ui/mici/tests/test_lead_indicator.py +++ b/selfdrive/ui/mici/tests/test_lead_indicator.py @@ -6,9 +6,10 @@ import openpilot.selfdrive.ui.mici.onroad.model_renderer as model_renderer class _FakeParams: - def __init__(self, enabled: bool, lead_info: bool = False): + def __init__(self, enabled: bool, lead_info: bool = False, lead_info_mode: int = model_renderer.LeadInfoMode.SPEED): self.enabled = enabled self.lead_info = lead_info + self.lead_info_mode = lead_info_mode def get(self, key): assert key == "HideLeadMarker" @@ -21,6 +22,10 @@ class _FakeParams: return self.lead_info raise AssertionError(key) + def get_int(self, key, **_kwargs): + assert key == "LeadInfoMode" + return self.lead_info_mode + def test_lead_indicator_renders_in_aol_without_longitudinal_control(monkeypatch): monkeypatch.setattr(model_renderer, "ui_state", SimpleNamespace(always_on_lateral_active=True)) @@ -47,17 +52,59 @@ def test_lead_indicator_still_honors_disabled_setting(): (False, True, "10 m/s"), ], ) -def test_lead_speed_uses_c3_units(is_metric, use_si_metrics, expected): +def test_lead_speed_uses_selected_units(is_metric, use_si_metrics, expected): assert model_renderer.ModelRenderer._format_lead_speed(10.0, is_metric, use_si_metrics) == expected -def test_lead_metrics_draw_only_speed_when_enabled(monkeypatch): +@pytest.mark.parametrize( + ("is_metric", "use_si_metrics", "expected"), + [ + (False, False, "33 ft"), + (True, False, "10 m"), + (False, True, "10 m"), + ], +) +def test_lead_distance_uses_selected_units(is_metric, use_si_metrics, expected): + assert model_renderer.ModelRenderer._format_lead_distance(10.0, is_metric, use_si_metrics) == expected + + +@pytest.mark.parametrize( + ("lead_info", "configured_mode", "expected"), + [ + (False, model_renderer.LeadInfoMode.SPEED, model_renderer.LeadInfoMode.OFF), + (True, model_renderer.LeadInfoMode.DISTANCE, model_renderer.LeadInfoMode.DISTANCE), + (True, model_renderer.LeadInfoMode.SPEED, model_renderer.LeadInfoMode.SPEED), + (True, model_renderer.LeadInfoMode.OFF, model_renderer.LeadInfoMode.SPEED), + (True, 99, model_renderer.LeadInfoMode.SPEED), + ], +) +def test_lead_info_mode_preserves_legacy_speed_behavior(lead_info, configured_mode, expected): + params = _FakeParams(enabled=True, lead_info=lead_info, lead_info_mode=configured_mode) + assert model_renderer.lead_info_mode(params) == expected + + +@pytest.mark.parametrize( + ("mode", "expected"), + [ + (model_renderer.LeadInfoMode.DISTANCE, "82 ft"), + (model_renderer.LeadInfoMode.SPEED, "27 mph"), + ], +) +def test_lead_info_formats_selected_metric(mode, expected): + renderer = object.__new__(model_renderer.ModelRenderer) + renderer._lead_info_mode = mode + lead = SimpleNamespace(dRel=25.0, vLead=12.0) + + assert renderer._format_lead_info(lead, is_metric=False, use_si_metrics=False) == expected + + +def test_lead_metrics_draw_selected_value_when_enabled(monkeypatch): drawn_metrics = [] monkeypatch.setattr(model_renderer, "get_theme_color", lambda *_args: model_renderer.rl.RED) monkeypatch.setattr(model_renderer.rl, "draw_triangle_fan", lambda *_args: None) renderer = object.__new__(model_renderer.ModelRenderer) - renderer._lead_info_enabled = True + renderer._lead_info_mode = model_renderer.LeadInfoMode.DISTANCE renderer._lead_vehicles = [ model_renderer.LeadVehicle( glow=[(1.0, 2.0)] * 3, @@ -66,8 +113,8 @@ def test_lead_metrics_draw_only_speed_when_enabled(monkeypatch): ), model_renderer.LeadVehicle(), ] - renderer._draw_lead_speed = drawn_metrics.append - lead_one = SimpleNamespace(status=True, vLead=10.0) + renderer._draw_lead_info = drawn_metrics.append + lead_one = SimpleNamespace(status=True, dRel=25.0, vLead=10.0) renderer._draw_lead_indicator(SimpleNamespace(leadOne=lead_one, leadTwo=SimpleNamespace(status=False))) diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 25d698f275..9542a2a481 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -60,7 +60,6 @@ from openpilot.starpilot.assets.model_manager import ( external_gpu_available, get_model_profile, is_builtin_model_key, - model_accelerator_catalog_artifact_metadata, model_accelerator_artifact_filename, model_key_aliases, model_uses_external_gpu, @@ -7424,18 +7423,15 @@ def setup(app): 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_compatible = model_lab_manifest_eligible({**metadata, "uses_external_gpu": requires_external_gpu}, model_version) + 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 {} - if not chestnut_artifact: - chestnut_artifact = model_accelerator_catalog_artifact_metadata(canonical_key) lab_artifact_available = ( bool(chestnut_artifact) and str(chestnut_artifact.get("execution_device") or chestnut_artifact.get("device") or "").strip().upper() == "AMD" ) - lab_eligible = lab_compatible and lab_artifact_available 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) @@ -7482,11 +7478,6 @@ def setup(app): existing["modelLabArtifactInstalled"] = existing["modelLabArtifactInstalled"] and lab_artifact_installed default_key = _default_model_key() - default_chestnut_artifact = model_accelerator_catalog_artifact_metadata(default_key) - default_lab_artifact_available = ( - bool(default_chestnut_artifact) - and str(default_chestnut_artifact.get("execution_device") or default_chestnut_artifact.get("device") or "").strip().upper() == "AMD" - ) default_entry = models_by_key.setdefault(default_key, { "value": default_key, "label": _default_model_name(), @@ -7498,13 +7489,9 @@ def setup(app): "small": True, "modelSize": "small (inferred)", "manifestDeclaredSize": False, - "modelLabEligible": default_lab_artifact_available and model_lab_manifest_eligible( - artifact_metadata.get(default_key, {}), _default_model_version() - ), - "modelLabArtifactAvailable": default_lab_artifact_available, - "modelLabArtifactInstalled": default_lab_artifact_available and file_chunked_exists( - MODELS_PATH / model_accelerator_artifact_filename(default_key) - ), + "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, diff --git a/tools/StarPilot/feasibleparams.txt b/tools/StarPilot/feasibleparams.txt index 9ed6d64b10..384349a71b 100644 --- a/tools/StarPilot/feasibleparams.txt +++ b/tools/StarPilot/feasibleparams.txt @@ -166,6 +166,7 @@ LateralTune LeadDepartingAlert LeadDetectionThreshold LeadInfo +LeadInfoMode LiveDelay LiveParameters LiveParametersV2