Align following presets with Dom defaults

This commit is contained in:
AngusBell97
2026-09-12 16:12:30 +01:00
committed by firestar5683
parent ab6c97fef8
commit 6f3d863ecd
12 changed files with 321 additions and 24 deletions
+38 -2
View File
@@ -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.
@@ -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)
@@ -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
@@ -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
@@ -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)
@@ -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`
<section class="ds-personality-field" aria-labelledby="personality-field-${profile.id}-${category}">
@@ -2139,6 +2140,7 @@ function renderPersonalityCategoryField(profile, category, config) {
`)}
</div>
${() => config.preset === "dom_default" ? html`<small class="ds-personality-default-note">Using existing StarPilot defaults.</small>` : ""}
${() => category === "following" && config.preset.startsWith("legacy_") ? html`<small class="ds-personality-default-note">Previous fixed following distance retained. Select a preset to use its current curve.</small>` : ""}
</section>
`
}
@@ -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 = {
<section v-for="(title, category) in CATEGORIES" :key="category" class="gx-personalities__category">
<h4>{{ title }}</h4>
<div class="gx-personalities__options" role="group" :aria-label="label(profile) + ' ' + title">
<button v-for="option in options(category)" :key="option" type="button" class="gx-btn gx-btn--tonal"
<button v-for="option in options(category, profile)" :key="option" type="button" class="gx-btn gx-btn--tonal"
:aria-pressed="data.profiles[profile][category].preset === option" :disabled="editingLocked"
@click="preset(profile, category, option)">{{ label(option) }}</button>
</div>
<p v-if="data.profiles[profile][category].preset === 'dom_default'">Using existing Dom default.</p>
<p v-if="category === 'following' && data.profiles[profile][category].preset.startsWith('legacy_')">Previous fixed following distance retained. Select a preset to use its current curve.</p>
</section>
<details class="gx-personalities__advanced" :open="advancedOpen[profile]" @toggle="advancedOpen[profile] = $event.target.open">
<summary>Advanced</summary>
@@ -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}
{"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}
@@ -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');
};
@@ -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;
@@ -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
@@ -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'<button.*?</button>', 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>', 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