From 6f3d863ecd01e0a6c4977d4da7d4acff472c6388 Mon Sep 17 00:00:00 2001 From: AngusBell97 <124716116+AngusBell97@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:12:30 +0100 Subject: [PATCH] Align following presets with Dom defaults --- docs/personality_custom_graphs.md | 40 +++++++++++- .../longitudinal_personality_profiles.py | 44 ++++++++++--- .../tests/test_following_preset_defaults.py | 65 +++++++++++++++++++ .../test_longitudinal_personality_profiles.py | 21 ++++-- .../test_personality_following_profiles.py | 28 +++++++- .../components/tools/device_settings.js | 6 +- .../js/components/PersonalityProfiles.js | 12 +++- .../fixtures/personality_profiles.json | 2 +- .../browser/personality_following_presets.cjs | 43 ++++++++++++ .../tests/browser/personality_profiles.cjs | 4 ++ .../test_following_preset_defaults_api.py | 48 ++++++++++++++ .../tests/test_personality_profiles_js.py | 32 +++++++++ 12 files changed, 321 insertions(+), 24 deletions(-) create mode 100644 starpilot/common/tests/test_following_preset_defaults.py create mode 100644 starpilot/system/the_galaxy/tests/browser/personality_following_presets.cjs create mode 100644 starpilot/system/the_galaxy/tests/test_following_preset_defaults_api.py diff --git a/docs/personality_custom_graphs.md b/docs/personality_custom_graphs.md index b801a6b58d..fc9cf116d9 100644 --- a/docs/personality_custom_graphs.md +++ b/docs/personality_custom_graphs.md @@ -24,7 +24,42 @@ Dom-default points outside the ordinary editor range (such as Traffic braking at 0.35 m/s², configured Traffic following at 0.5 seconds or truck acceleration at 6 m/s²) remain visible and are preserved when another point is edited. New point edits still use the existing authoring bounds. This does not expand -braking authority or change named-preset controller behaviour. +braking authority or change acceleration/braking preset definitions. + +## Following presets + +Named following presets now match Dom's factory following settings with custom +personalities enabled. Close follows Aggressive, Medium follows Standard and +Far follows Relaxed. The presets are available in every personality. + +| Preset | Previous curve | Revised curve | +| --- | --- | --- | +| Close | 1.25 s at every speed | 1.25 s through 45 mph, falling to 1.0 s at 70 mph | +| Medium | 1.45 s at every speed | 1.45 s through 45 mph, falling to 1.2 s at 70 mph | +| Far | 1.75 s at every speed | 1.6 s through 45 mph, falling to 1.4 s at 70 mph | +| Traffic | No named preset | 0.75 s at rest, rising to 1.6 s at 25 m/s (55.92 mph) | + +Interpolation is linear between the stated breakpoints and constant outside +them. Named presets use the exact native speed axes at runtime. First-use +Custom conversion samples them onto the existing 10 mph editor grid. + +Existing v1/v2 Close, Medium and Far selections keep their old fixed headways +as `legacy_close`, `legacy_medium` and `legacy_far`. Both Galaxy pickers show +the selected compatibility entry as **Previous Close**, **Previous Medium** +or **Previous Far**. Explicitly selecting a current preset adopts its new curve. +The previous entry disappears when it is no longer selected. + +Existing `dom_default` selections continue to inherit configured settings; +they are not silently converted to fixed named presets. Fresh profiles also +retain this inheritance. The named curves match untouched factory settings; +users' changed global following values can still differ from them. + +Acceleration and braking presets are unchanged. Standard acceleration and Eco +braking match the normal factory defaults for Aggressive, Standard and Relaxed +when named-preset and global powertrain tuning agree. Named presets use detected +EV/truck tuning; the Dom-default resolver respects the global tuning switches, +so these can differ. Traffic retains its dedicated acceleration/braking defaults; +this change adds only its named following preset. ## Storage compatibility @@ -34,7 +69,8 @@ only Custom uses them. An actual graph edit or reset retires preserved v1 interpolation for that category; a preset switch or unchanged submission does not. -Valid v2 documents are read losslessly and upgraded on the next normal write. +Valid v2 documents retain their runtime meaning and are upgraded on the next +normal write, including the fixed following compatibility names above. Version 1 keeps its existing explicit, verified migration flow. Reads never rewrite Params. Category conflict detection, off-road checks and atomic profile document writes still apply to edits and resets. diff --git a/starpilot/common/longitudinal_personality_profiles.py b/starpilot/common/longitudinal_personality_profiles.py index 9ce0178774..3ddca1f7e3 100644 --- a/starpilot/common/longitudinal_personality_profiles.py +++ b/starpilot/common/longitudinal_personality_profiles.py @@ -36,7 +36,8 @@ def is_truck_fingerprint(fingerprint: object) -> bool: ACCELERATION_PRESETS = ("dom_default", "standard", "eco", "sport", "sport_plus", "custom") BRAKING_PRESETS = ("dom_default", "standard", "eco", "sport", "custom") -FOLLOWING_PRESETS = ("dom_default", "close", "medium", "far", "custom") +_LEGACY_FOLLOWING_PRESETS = ("close", "medium", "far") +FOLLOWING_PRESETS = ("dom_default", "close", "medium", "far", "traffic", "custom", "legacy_close", "legacy_medium", "legacy_far") CURVE_BOUNDS = { "acceleration": (0.0, 3.5), "braking": (0.5, 2.0), @@ -115,10 +116,21 @@ _BRAKING_PRESET_CURVES = { "standard": (1.0,) * len(BRAKING_SPEEDS_MPH), "sport": (2.0,) * len(BRAKING_SPEEDS_MPH), } +# Named following presets use Dom's native speed breakpoints. Custom retains +# its editable 10 mph grid; sampling named presets onto that grid is only for +# first-use Custom conversion and graph previews, not runtime interpolation. FOLLOWING_PRESET_CURVES = { - "close": (1.25,) * len(FOLLOWING_SPEEDS_MPH), - "medium": (1.45,) * len(FOLLOWING_SPEEDS_MPH), - "far": (1.75,) * len(FOLLOWING_SPEEDS_MPH), + "close": (1.25, 1.0), + "medium": (1.45, 1.2), + "far": (1.6, 1.4), + "traffic": (0.75, 1.6), + "legacy_close": (1.25, 1.25), + "legacy_medium": (1.45, 1.45), + "legacy_far": (1.75, 1.75), +} +_FOLLOWING_PRESET_SPEEDS_MPH = { + preset: (0.0, 25.0 / 0.44704) if preset == "traffic" else (45.0, 70.0) + for preset in FOLLOWING_PRESET_CURVES } PROFILE_AXES = { "acceleration": { @@ -148,7 +160,7 @@ _ACCELERATION_PROFILE_IDS = { } _PERSONALITY_REFERENCE_PRESETS = { - "traffic": {"acceleration": "eco", "braking": "standard", "following": "close"}, + "traffic": {"acceleration": "eco", "braking": "standard", "following": "traffic"}, "aggressive": {"acceleration": "sport_plus", "braking": "sport", "following": "close"}, "standard": {"acceleration": "standard", "braking": "standard", "following": "medium"}, "relaxed": {"acceleration": "eco", "braking": "eco", "following": "far"}, @@ -309,6 +321,10 @@ def _strict_document( return None profile = {} for category in _CATEGORY_SPECS: + raw_category = raw_profile.get(category) + if (schema_version < 3 and category == "following" and isinstance(raw_category, dict) + and raw_category.get("preset") not in ("dom_default", "custom", *_LEGACY_FOLLOWING_PRESETS)): + return None validated = _validated_category_with_length( category, raw_profile.get(category), category_lengths[category], curve_bounds, legacy_curve_bounds, retain_custom=schema_version >= 3, @@ -325,9 +341,16 @@ def _strict_document( } +def _preserve_legacy_following_presets(profiles: dict[str, dict]) -> None: + for profile in profiles.values(): + config = profile["following"] + if config["preset"] in _LEGACY_FOLLOWING_PRESETS: + config["preset"] = "legacy_" + config["preset"] + + def strict_profile_document(raw_document) -> dict | None: - # Version 2 has the same axes and active curves. Read it losslessly; the next - # normal save upgrades the document without requiring a destructive reset. + # V2 uses fixed-distance named following presets. Preserve their original + # meaning; adopting a speed-dependent preset requires an explicit selection. decoded = _decode_json(raw_document) version = decoded.get("schemaVersion") if isinstance(decoded, dict) else None if type(version) is not int or version not in (2, PROFILE_SCHEMA_VERSION): @@ -341,6 +364,8 @@ def strict_profile_document(raw_document) -> dict | None: _V1_CURVE_BOUNDS, ) if document is not None: + if version == 2: + _preserve_legacy_following_presets(document["profiles"]) document["schemaVersion"] = PROFILE_SCHEMA_VERSION return document @@ -361,6 +386,7 @@ def migrate_profile_document(raw_document) -> dict | None: return None migrated_profiles = deepcopy(legacy["profiles"]) + _preserve_legacy_following_presets(migrated_profiles) for profile in migrated_profiles.values(): for category in ("acceleration", "braking"): config = profile[category] @@ -592,4 +618,6 @@ def interpolate_category_curve( speed * 0.44704 for speed in ACCELERATION_SPEEDS_MPH ) return interpolate_accel_profile(float(v_ego), values, breakpoints) - return _linear_interp(float(v_ego) / 0.44704, _CATEGORY_SPEEDS_MPH[category], values) + breakpoints = (_FOLLOWING_PRESET_SPEEDS_MPH[validated["preset"]] + if category == "following" and validated["preset"] != "custom" else _CATEGORY_SPEEDS_MPH[category]) + return _linear_interp(float(v_ego) / 0.44704, breakpoints, values) diff --git a/starpilot/common/tests/test_following_preset_defaults.py b/starpilot/common/tests/test_following_preset_defaults.py new file mode 100644 index 0000000000..4d6f73bfb8 --- /dev/null +++ b/starpilot/common/tests/test_following_preset_defaults.py @@ -0,0 +1,65 @@ +from copy import deepcopy + +import numpy as np +import pytest + +from openpilot.starpilot.common import longitudinal_personality_profiles as lpp + + +@pytest.mark.parametrize("preset,breakpoints,values", [ + ("close", [45.0, 70.0], [1.25, 1.0]), + ("medium", [45.0, 70.0], [1.45, 1.2]), + ("far", [45.0, 70.0], [1.6, 1.4]), + ("traffic", [0.0, 25.0 / 0.44704], [0.75, 1.6]), +]) +def test_named_following_presets_match_dom_at_and_between_native_breakpoints(preset, breakpoints, values): + config = {"preset": preset, "curve": []} + for mph in sorted(set([0, 10, 44.9, 45, 45.1, 50, 55, 55.9234073, 60, 65, 69.9, 70, 70.1, 90, 120, *breakpoints])): + expected = float(np.interp(mph, breakpoints, values)) + assert lpp.interpolate_category_curve("following", mph * 0.44704, config, False) == pytest.approx(expected, abs=1e-12) + sampled = lpp.initial_custom_curve("following", config, False, False) + assert sampled == [round(float(np.interp(mph, breakpoints, values)), 4) for mph in lpp.FOLLOWING_SPEEDS_MPH] + + +def test_close_medium_far_keep_their_order_at_every_speed(): + for mph in np.linspace(0, 120, 481): + values = [lpp.interpolate_category_curve("following", mph * 0.44704, {"preset": p, "curve": []}, False) for p in ("close", "medium", "far")] + assert values[0] < values[1] < values[2] + + +@pytest.mark.parametrize("preset,value", [("close", 1.25), ("medium", 1.45), ("far", 1.75)]) +def test_old_named_selection_keeps_fixed_distance_after_load_save_and_reload(preset, value): + original = lpp.profile_document(lpp.default_personality_profiles(False), enabled=True) + original["schemaVersion"] = 2 + original["profiles"]["standard"]["following"] = {"preset": preset, "curve": []} + saved = deepcopy(original) + loaded = lpp.strict_profile_document(original) + assert original == saved + config = loaded["profiles"]["standard"]["following"] + assert config == {"preset": "legacy_" + preset, "curve": []} + for mph in (0, 45, 60, 70, 100): + assert lpp.interpolate_category_curve("following", mph * 0.44704, config, False) == value + reloaded = lpp.strict_profile_document(lpp.serialize_personality_profiles(loaded["profiles"], False, enabled=True)) + assert reloaded == loaded + updated = lpp.update_personality_profile(loaded["profiles"], "standard", "following", preset, [], False) + assert updated["standard"]["following"] == {"preset": preset, "curve": []} + assert lpp.interpolate_category_curve("following", 70 * 0.44704, updated["standard"]["following"], False) != value + + +@pytest.mark.parametrize("preset", ["traffic", "legacy_close", "legacy_medium", "legacy_far"]) +def test_old_schema_cannot_smuggle_new_preset_names(preset): + document = lpp.profile_document(lpp.default_personality_profiles(False), enabled=True) + document["schemaVersion"] = 2 + document["profiles"]["standard"]["following"]["preset"] = preset + assert lpp.strict_profile_document(document) is None + + +def test_named_following_switches_keep_remembered_custom_points(): + profiles = lpp.default_personality_profiles(False) + custom = [1.55] * 10 + profiles["traffic"]["following"] = {"preset": "custom", "curve": custom} + for preset in ("close", "medium", "far", "traffic", "dom_default"): + profiles = lpp.update_personality_profile(profiles, "traffic", "following", preset, [], False) + assert profiles["traffic"]["following"]["curve"] == custom + restored = lpp.initial_custom_curve("following", profiles["traffic"]["following"], False, False) + assert restored == custom diff --git a/starpilot/common/tests/test_longitudinal_personality_profiles.py b/starpilot/common/tests/test_longitudinal_personality_profiles.py index 0ba5dcf563..e92b2aa83c 100644 --- a/starpilot/common/tests/test_longitudinal_personality_profiles.py +++ b/starpilot/common/tests/test_longitudinal_personality_profiles.py @@ -474,9 +474,13 @@ def test_fresh_profiles_start_with_dom_defaults(): def test_following_presets_match_stock_dom_personalities_exactly(): assert FOLLOWING_PRESET_CURVES == { - "close": (1.25,) * 10, - "medium": (1.45,) * 10, - "far": (1.75,) * 10, + "close": (1.25, 1.0), + "medium": (1.45, 1.2), + "far": (1.6, 1.4), + "traffic": (0.75, 1.6), + "legacy_close": (1.25, 1.25), + "legacy_medium": (1.45, 1.45), + "legacy_far": (1.75, 1.75), } @@ -500,9 +504,9 @@ def test_named_acceleration_presets_keep_native_dom_interpolation(): def test_reference_curves_are_profile_specific_and_use_the_custom_axis(): references = lpp.personality_reference_curves(False, False) assert references["traffic"]["acceleration"] != references["aggressive"]["acceleration"] - assert references["aggressive"]["following"] == [1.25] * 10 - assert references["standard"]["following"] == [1.45] * 10 - assert references["relaxed"]["following"] == [1.75] * 10 + assert references["aggressive"]["following"] == [1.25] * 5 + [1.2, 1.1, 1.0, 1.0, 1.0] + assert references["standard"]["following"] == [1.45] * 5 + [1.4, 1.3, 1.2, 1.2, 1.2] + assert references["relaxed"]["following"] == [1.6] * 5 + [1.56, 1.48, 1.4, 1.4, 1.4] for profile in references.values(): for curve in profile.values(): assert len(curve) == 10 @@ -545,7 +549,10 @@ def test_exact_v1_document_migrates_whole_or_not_at_all(): assert max(migrated_acceleration["curve"]) == CURVE_BOUNDS["acceleration"][1] assert migrated_acceleration["legacyCurve"] == legacy_profiles["aggressive"]["acceleration"]["curve"] assert interpolate_category_curve("acceleration", 40.0, migrated_acceleration, False, False) == 4.0 - assert migrated["profiles"]["standard"] == legacy_profiles["standard"] + assert migrated["profiles"]["standard"] == { + **legacy_profiles["standard"], + "following": {"preset": "legacy_medium", "curve": []}, + } malformed = json.loads(json.dumps(legacy)) malformed["profiles"]["aggressive"]["acceleration"]["curve"][0] = True diff --git a/starpilot/controls/tests/test_personality_following_profiles.py b/starpilot/controls/tests/test_personality_following_profiles.py index d402b16d8f..43a6ddd18a 100644 --- a/starpilot/controls/tests/test_personality_following_profiles.py +++ b/starpilot/controls/tests/test_personality_following_profiles.py @@ -213,7 +213,7 @@ def test_traffic_profile_wins_over_cereal_personality_without_changing_jerk(): controller.update(True, 0.0, _sm(traffic=True, personality=Personality.aggressive), _toggles(document)) - assert controller.t_follow == pytest.approx(1.75) + assert controller.t_follow == pytest.approx(1.6) assert controller.base_acceleration_jerk == 1.0 @@ -347,3 +347,29 @@ def test_every_advanced_param_maps_to_runtime_attribute_with_hundredth_conversio assert keywords["conversion"] == 0.01 assert keywords["min"] == 0.25 assert keywords["max"] == 2.0 + + +@pytest.mark.parametrize("profile,personality,preset", [ + ("aggressive", Personality.aggressive, "close"), + ("standard", Personality.standard, "medium"), + ("relaxed", Personality.relaxed, "far"), + ("traffic", Personality.standard, "traffic"), +]) +def test_named_presets_match_dom_default_runtime_with_factory_following_settings(monkeypatch, profile, personality, preset): + # Dom CITY_SPEED_LIMIT is 25 m/s, not 25 mph as the older fixture assumes. + monkeypatch.setattr(following_module, "TRAFFIC_MODE_BP", [0.0, 25.0]) + default_document = _document() + named_document = _document() + named_document["profiles"][profile]["following"] = {"preset": preset, "curve": []} + for speed in (0, 5, 10, 20, 45 * 0.44704, 50 * 0.44704, 25, 60 * 0.44704, 70 * 0.44704, 40): + results = [] + for document in (default_document, named_document): + toggles = _toggles(document) + toggles.aggressive_follow = [1.25, 1.0] + toggles.standard_follow = [1.45, 1.2] + toggles.relaxed_follow = [1.6, 1.4] + toggles.traffic_mode_follow = [0.75, 1.6] + controller = StarPilotFollowing(_planner()) + controller.update(True, speed, _sm(personality=personality, traffic=profile == "traffic"), toggles) + results.append((controller.t_follow, controller.base_acceleration_jerk, controller.base_danger_jerk, controller.base_speed_jerk)) + assert results[0] == pytest.approx(results[1], abs=1e-12) diff --git a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js index 862b1a7319..45e1611481 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js +++ b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js @@ -116,7 +116,7 @@ const PERSONALITY_CATEGORY_DEFINITIONS = { const PERSONALITY_OPTION_ORDER = { acceleration: ["eco", "standard", "sport", "sport_plus", "custom"], braking: ["eco", "standard", "sport", "custom"], - following: ["close", "medium", "far", "custom"], + following: ["close", "medium", "far", "traffic", "custom"], } const PERSONALITY_ADVANCED_KEYS = { traffic: ["TrafficJerkAcceleration", "TrafficJerkDeceleration", "TrafficJerkDanger", "TrafficJerkSpeedDecrease", "TrafficJerkSpeed"], @@ -1667,7 +1667,7 @@ function handleSectionTabClick(sectionSlug, event) { } function personalityPresetLabel(preset) { - return String(preset || "").split("_").map(part => part === "plus" ? "+" : `${part.charAt(0).toUpperCase()}${part.slice(1)}`).join(" ").replace(" +", "+") + return String(preset || "").split("_").map(part => part === "plus" ? "+" : part === "legacy" ? "Previous" : `${part.charAt(0).toUpperCase()}${part.slice(1)}`).join(" ").replace(" +", "+") } function personalityUpdateKey(profileId, category) { @@ -2122,6 +2122,7 @@ function renderPersonalityCategoryField(profile, category, config) { const definition = PERSONALITY_CATEGORY_DEFINITIONS[category] const availableOptions = new Set((state.personalityMeta?.options?.[category] || []).filter(option => option !== "dom_default")) const options = (PERSONALITY_OPTION_ORDER[category] || []).filter(option => availableOptions.has(option)) + if (category === "following" && config.preset.startsWith("legacy_") && availableOptions.has(config.preset)) options.unshift(config.preset) const updateKey = personalityUpdateKey(profile.id, category) return html` @@ -2139,6 +2140,7 @@ function renderPersonalityCategoryField(profile, category, config) { `)} ${() => config.preset === "dom_default" ? html`Using existing StarPilot defaults.` : ""} + ${() => category === "following" && config.preset.startsWith("legacy_") ? html`Previous fixed following distance retained. Select a preset to use its current curve.` : ""} ` } diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/PersonalityProfiles.js b/starpilot/system/the_galaxy/assets/mobile/js/components/PersonalityProfiles.js index 861cf8581f..e402a7e8c3 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/components/PersonalityProfiles.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/PersonalityProfiles.js @@ -29,12 +29,17 @@ export const PersonalityProfiles = { }, methods: { enabled(value) { return [true, 1, "1", "True", "true"].includes(value) }, - label(value) { return value.split("_").map(s => s === "plus" ? "+" : s[0].toUpperCase() + s.slice(1)).join(" ").replace(" +", "+") }, + label(value) { return value.split("_").map(s => s === "plus" ? "+" : s === "legacy" ? "Previous" : s[0].toUpperCase() + s.slice(1)).join(" ").replace(" +", "+") }, key: personalityProfileParamKey, speed(value) { return formatProfileSpeed(value, this.enabled(this.values.IsMetric)) }, speedUnit() { return profileSpeedUnit(this.enabled(this.values.IsMetric)) }, bounds(param) { return numericBounds(param, this.values) }, - options(category) { return (category === "following" ? ["close", "medium", "far", "custom"] : ["eco", "standard", "sport", "sport_plus", "custom"]).filter(x => this.data.options[category].includes(x)) }, + options(category, profile) { + const choices = (category === "following" ? ["close", "medium", "far", "traffic", "custom"] : ["eco", "standard", "sport", "sport_plus", "custom"]).filter(x => this.data.options[category].includes(x)) + const selected = this.data.profiles[profile]?.[category]?.preset + if (category === "following" && selected?.startsWith("legacy_") && this.data.options[category].includes(selected)) choices.unshift(selected) + return choices + }, advancedParams(profile) { return Object.values(this.meta).filter(p => p.parent_key === this.key(profile) && p.key.includes("Jerk")) }, @@ -288,11 +293,12 @@ export const PersonalityProfiles = { {{ title }} - {{ label(option) }} Using existing Dom default. + Previous fixed following distance retained. Select a preset to use its current curve. Advanced diff --git a/starpilot/system/the_galaxy/tests/browser/fixtures/personality_profiles.json b/starpilot/system/the_galaxy/tests/browser/fixtures/personality_profiles.json index 2398ef53a9..5aca147eea 100644 --- a/starpilot/system/the_galaxy/tests/browser/fixtures/personality_profiles.json +++ b/starpilot/system/the_galaxy/tests/browser/fixtures/personality_profiles.json @@ -1 +1 @@ -{"profiles": {"traffic": {"acceleration": {"preset": "standard", "curve": []}, "braking": {"preset": "standard", "curve": []}, "following": {"preset": "medium", "curve": []}}, "aggressive": {"acceleration": {"preset": "standard", "curve": []}, "braking": {"preset": "standard", "curve": []}, "following": {"preset": "medium", "curve": []}}, "standard": {"acceleration": {"preset": "standard", "curve": []}, "braking": {"preset": "standard", "curve": []}, "following": {"preset": "medium", "curve": []}}, "relaxed": {"acceleration": {"preset": "standard", "curve": []}, "braking": {"preset": "standard", "curve": []}, "following": {"preset": "medium", "curve": []}}}, "reference_curves": {"traffic": {"acceleration": [1.5, 1.302, 1.1135, 0.9375, 0.8039, 0.6611, 0.547, 0.4797, 0.3781, 0.35], "braking": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], "following": [1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25]}, "aggressive": {"acceleration": [3.5, 3.203, 2.827, 2.4343, 2.0616, 1.7444, 1.5441, 1.4093, 1.2063, 1.15], "braking": [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0], "following": [1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25]}, "standard": {"acceleration": [2.0, 1.802, 1.5669, 1.3468, 1.1398, 0.9611, 0.8456, 0.7445, 0.5922, 0.55], "braking": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], "following": [1.45, 1.45, 1.45, 1.45, 1.45, 1.45, 1.45, 1.45, 1.45, 1.45]}, "relaxed": {"acceleration": [1.5, 1.302, 1.1135, 0.9375, 0.8039, 0.6611, 0.547, 0.4797, 0.3781, 0.35], "braking": [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5], "following": [1.75, 1.75, 1.75, 1.75, 1.75, 1.75, 1.75, 1.75, 1.75, 1.75]}}, "bounds": {"acceleration": [0.0, 3.5], "braking": [0.5, 2.0], "following": [0.75, 3.0]}, "options": {"acceleration": ["dom_default", "standard", "eco", "sport", "sport_plus", "custom"], "braking": ["dom_default", "standard", "eco", "sport", "custom"], "following": ["dom_default", "close", "medium", "far", "custom"]}, "speed_breakpoints_mph": {"acceleration": [0, 10, 20, 30, 40, 50, 60, 70, 80, 90], "braking": [0, 10, 20, 30, 40, 50, 60, 70, 80, 90], "following": [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]}, "migration_required": false} \ No newline at end of file +{"profiles": {"traffic": {"acceleration": {"preset": "standard", "curve": []}, "braking": {"preset": "standard", "curve": []}, "following": {"preset": "medium", "curve": []}}, "aggressive": {"acceleration": {"preset": "standard", "curve": []}, "braking": {"preset": "standard", "curve": []}, "following": {"preset": "medium", "curve": []}}, "standard": {"acceleration": {"preset": "standard", "curve": []}, "braking": {"preset": "standard", "curve": []}, "following": {"preset": "medium", "curve": []}}, "relaxed": {"acceleration": {"preset": "standard", "curve": []}, "braking": {"preset": "standard", "curve": []}, "following": {"preset": "medium", "curve": []}}}, "reference_curves": {"traffic": {"acceleration": [1.5, 1.302, 1.1135, 0.9375, 0.8039, 0.6611, 0.547, 0.4797, 0.3781, 0.35], "braking": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], "following": [1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25]}, "aggressive": {"acceleration": [3.5, 3.203, 2.827, 2.4343, 2.0616, 1.7444, 1.5441, 1.4093, 1.2063, 1.15], "braking": [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0], "following": [1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25, 1.25]}, "standard": {"acceleration": [2.0, 1.802, 1.5669, 1.3468, 1.1398, 0.9611, 0.8456, 0.7445, 0.5922, 0.55], "braking": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], "following": [1.45, 1.45, 1.45, 1.45, 1.45, 1.45, 1.45, 1.45, 1.45, 1.45]}, "relaxed": {"acceleration": [1.5, 1.302, 1.1135, 0.9375, 0.8039, 0.6611, 0.547, 0.4797, 0.3781, 0.35], "braking": [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5], "following": [1.75, 1.75, 1.75, 1.75, 1.75, 1.75, 1.75, 1.75, 1.75, 1.75]}}, "bounds": {"acceleration": [0.0, 3.5], "braking": [0.5, 2.0], "following": [0.75, 3.0]}, "options": {"acceleration": ["dom_default", "standard", "eco", "sport", "sport_plus", "custom"], "braking": ["dom_default", "standard", "eco", "sport", "custom"], "following": ["dom_default", "close", "medium", "far", "traffic", "custom", "legacy_close", "legacy_medium", "legacy_far"]}, "speed_breakpoints_mph": {"acceleration": [0, 10, 20, 30, 40, 50, 60, 70, 80, 90], "braking": [0, 10, 20, 30, 40, 50, 60, 70, 80, 90], "following": [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]}, "migration_required": false} \ No newline at end of file diff --git a/starpilot/system/the_galaxy/tests/browser/personality_following_presets.cjs b/starpilot/system/the_galaxy/tests/browser/personality_following_presets.cjs new file mode 100644 index 0000000000..e8581a2cfb --- /dev/null +++ b/starpilot/system/the_galaxy/tests/browser/personality_following_presets.cjs @@ -0,0 +1,43 @@ +const assert = require('assert'); +const path = require('path'); + +module.exports = async ({page, data, writes, errors, output}) => { + const profiles = ['traffic', 'aggressive', 'standard', 'relaxed']; + const legacy = ['legacy_close', 'legacy_medium', 'legacy_far']; + const custom = Array(10).fill(1.55); + const settled = () => page.waitForFunction(() => { + const vm = document.querySelector('#app').__vue_app__._instance.proxy; + return !vm.busy && !vm.curvePending && vm.ready; + }); + for (let i = 0; i < profiles.length; i++) { + data.profiles[profiles[i]].following = {preset: legacy[i % 3], curve: [...custom]}; + } + await page.reload(); + await page.getByRole('button', {name: 'Manage', exact: true}).click(); + for (let i = 0; i < profiles.length; i++) { + const profile = page.locator('.gx-personalities__profile').nth(i); + const section = profile.locator('.gx-personalities__category').nth(2); + const previousName = 'Previous ' + ['Close', 'Medium', 'Far'][i % 3]; + const previous = section.getByRole('button', {name: previousName, exact: true}); + assert.equal(await previous.getAttribute('aria-pressed'), 'true'); + assert(await section.getByText('Previous fixed following distance retained.', {exact: false}).isVisible()); + for (const preset of ['Traffic', 'Close', 'Medium', 'Far', 'Custom']) { + await section.getByRole('button', {name: preset, exact: true}).click(); + await settled(); + assert.equal(writes.at(-1).preset, preset.toLowerCase()); + assert.deepEqual(data.profiles[profiles[i]].following.curve, custom); + assert.equal(await previous.count(), 0); + assert.equal(await section.getByRole('button', {name: preset, exact: true}).getAttribute('aria-pressed'), 'true'); + } + } + await page.reload(); + await page.getByRole('button', {name: 'Manage', exact: true}).click(); + for (const profile of profiles) assert.deepEqual(data.profiles[profile].following, {preset: 'custom', curve: custom}); + for (const width of [320, 1280]) { + await page.setViewportSize({width, height: 1000}); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + await page.screenshot({path: path.join(output, `following-presets-${width}.png`), fullPage: true}); + } + assert.deepEqual(errors, []); + console.log('PASS: Previous Close/Medium/Far retained and selected; explicit new Traffic/Close/Medium/Far selection; dormant Custom restoration in all four profiles; reload and responsive layout'); +}; diff --git a/starpilot/system/the_galaxy/tests/browser/personality_profiles.cjs b/starpilot/system/the_galaxy/tests/browser/personality_profiles.cjs index fff7057e85..3296a7386c 100644 --- a/starpilot/system/the_galaxy/tests/browser/personality_profiles.cjs +++ b/starpilot/system/the_galaxy/tests/browser/personality_profiles.cjs @@ -40,6 +40,10 @@ const output=process.env.PERSONALITY_BROWSER_OUTPUT || path.join(require('os').t assert(!(await page.locator('#gx-personality-settings').isVisible())); await page.getByRole('button',{name:'Manage',exact:true}).click(); assert.equal(await page.locator('.gx-personalities__profile').count(),4); + if(process.env.PERSONALITY_FOLLOWING_ONLY){ + await require('./personality_following_presets.cjs')({page,data,writes,errors,output}); + return; + } if(process.env.PERSONALITY_CUSTOM_ONLY){ await require('./personality_custom_graphs.cjs')({page,data,values,writes,errors,output}); return; diff --git a/starpilot/system/the_galaxy/tests/test_following_preset_defaults_api.py b/starpilot/system/the_galaxy/tests/test_following_preset_defaults_api.py new file mode 100644 index 0000000000..d21556e338 --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_following_preset_defaults_api.py @@ -0,0 +1,48 @@ +from copy import deepcopy + +import pytest + +from openpilot.starpilot.common import longitudinal_personality_profiles as lpp +from test_personality_profiles_api import _client + + +@pytest.mark.parametrize("personality,preset", [("aggressive", "close"), ("standard", "medium"), ("relaxed", "far"), ("traffic", "traffic")]) +def test_named_following_matches_default_reference_and_custom_conversion(monkeypatch, personality, preset): + client, _ = _client(monkeypatch, {"CustomPersonalities": True}) + endpoint = "/api/personality_profiles" + response = client.put(endpoint, json={"profile": personality, "category": "following", "preset": preset, "curve": []}) + assert response.status_code == 200 + body = response.get_json() + assert preset in body["options"]["following"] + reference = body["reference_curves"][personality]["following"] + assert [round(lpp.interpolate_category_curve("following", speed * 0.44704, body["profiles"][personality]["following"], False), 4) for speed in lpp.FOLLOWING_SPEEDS_MPH] == reference + response = client.put(endpoint, json={"profile": personality, "category": "following", "preset": "custom", "curve": []}) + assert response.status_code == 200 + assert response.get_json()["profiles"][personality]["following"]["curve"] == reference + + +def test_existing_fixed_selection_stays_visible_and_can_explicitly_change(monkeypatch): + profiles = lpp.default_personality_profiles(False) + profiles["standard"]["following"] = {"preset": "medium", "curve": []} + document = lpp.profile_document(profiles, enabled=True) + document["schemaVersion"] = 2 + original = deepcopy(document) + client, params = _client(monkeypatch, {"CustomPersonalities": True, lpp.PERSONALITY_PROFILES_PARAM: document}) + body = client.get("/api/personality_profiles").get_json() + assert body["migration_required"] is False + assert body["profiles"]["standard"]["following"]["preset"] == "legacy_medium" + assert "legacy_medium" in body["options"]["following"] + assert params.values[lpp.PERSONALITY_PROFILES_PARAM] == original + assert params.writes == [] + # The comparison includes the upgraded legacy identity, protecting old data. + response = client.put("/api/personality_profiles", json={"profile": "standard", "category": "following", "preset": "medium", "curve": [], "expected": body["profiles"]["standard"]["following"]}) + assert response.status_code == 200 + assert response.get_json()["profiles"]["standard"]["following"]["preset"] == "medium" + + +def test_existing_dom_default_keeps_inheriting_user_following_settings(monkeypatch): + client, _ = _client(monkeypatch, {"CustomPersonalities": True, "StandardFollow": 1.8, "StandardFollowHigh": 1.6}) + body = client.get("/api/personality_profiles").get_json() + assert body["profiles"]["standard"]["following"]["preset"] == "dom_default" + assert body["reference_curves"]["standard"]["following"][0] == 1.8 + assert body["reference_curves"]["standard"]["following"][-1] == 1.6 diff --git a/starpilot/system/the_galaxy/tests/test_personality_profiles_js.py b/starpilot/system/the_galaxy/tests/test_personality_profiles_js.py index bbccfe073c..46fc1eeda7 100644 --- a/starpilot/system/the_galaxy/tests/test_personality_profiles_js.py +++ b/starpilot/system/the_galaxy/tests/test_personality_profiles_js.py @@ -658,3 +658,35 @@ def test_responsive_canvas_keeps_metric_endpoint_labels_separate_and_scales_bitm assert [labels[0]["text"], labels[-1]["text"]] == ["0", "144.8"] for left, right in zip(labels, labels[1:]): assert left["x"] + left["width"] / 2 + 6 <= right["x"] - right["width"] / 2 + + +def test_classic_following_picker_displays_migrated_selection_and_current_traffic_option(): + source = DEVICE_SETTINGS_PATH.read_text(encoding="utf-8") + functions = "\n".join( + "function " + name + source.split("function " + name, 1)[1].split("\n}\n", 1)[0] + "\n}" + for name in ("personalityPresetLabel", "personalityUpdateKey", "renderPersonalityCategoryField") + ) + order = "const PERSONALITY_OPTION_ORDER = " + source.split("const PERSONALITY_OPTION_ORDER = ", 1)[1].split("\n}", 1)[0] + "\n};" + result = _run_node(order + functions + """ + const state = {values: {}, personalityUpdating: {}, personalityMeta: {options: {following: + ['dom_default', 'close', 'medium', 'far', 'traffic', 'custom', 'legacy_close', 'legacy_medium', 'legacy_far']}}}; + const PERSONALITY_CATEGORY_DEFINITIONS = {following: {label: 'Following'}}; + const html = (parts, ...values) => parts.reduce((text, part, i) => { + let value = values[i]; + if (typeof value === 'function') value = part.endsWith('@click="') ? '' : value(); + return text + part + (Array.isArray(value) ? value.join('') : value ?? ''); + }, ''); + console.log(JSON.stringify(['legacy_close', 'legacy_medium', 'legacy_far', 'traffic', 'medium'].map(preset => + renderPersonalityCategoryField({id: 'standard', label: 'Standard'}, 'following', {preset, curve: []})))); + """) + import re + for index, rendered in enumerate(result): + buttons = re.findall(r'', rendered, flags=re.S) + assert sum('aria-pressed="true"' in button for button in buttons) == 1 + assert any(re.search(r'>\s*Traffic\s*', button) for button in buttons) + if index < 3: + expected = ["Previous Close", "Previous Medium", "Previous Far"][index] + assert any(expected in button and 'aria-pressed="true"' in button for button in buttons) + assert 'Previous fixed following distance retained.' in rendered + else: + assert 'Previous ' not in rendered
Using existing Dom default.
Previous fixed following distance retained. Select a preset to use its current curve.