Remember custom personality graphs and correct default resets

This commit is contained in:
AngusBell97
2026-09-12 15:25:07 +01:00
committed by firestar5683
parent a51205e302
commit ab6c97fef8
11 changed files with 413 additions and 55 deletions
+44
View File
@@ -0,0 +1,44 @@
# Custom personality graphs
Each personality keeps its own Custom acceleration, braking and following curve.
Selecting a named preset changes the active selection without deleting Custom
points. Selecting Custom again restores those points, including after a reload
or restart. If a category has never had Custom points, it is initialized from
the current selection, as before.
The existing **Reset to default** button, below each Custom graph's numeric
points in New Galaxy's Advanced section, replaces only that category's Custom
curve. It leaves the category set to Custom. The server resolves the reset
values; the dashed **Dom default** line uses the same resolver.
Defaults are Dom's configured base curves sampled at the editor's 10 mph
points. They include Traffic's dedicated acceleration and braking, following
settings, global tuning switches and powertrain overrides. Where gear mapping
is enabled, the reference uses normal gear. Live Eco/Sport gear, weather,
lead/stop and overspeed adjustments remain on the existing controller paths.
Sampling cannot reproduce every native breakpoint or between-point value;
resetting a Custom graph is not the same as delegating to the Dom-default
runtime path.
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.
## Storage compatibility
Profile document version 3 retains `curve` and optional `legacyCurve` while
`preset` is a named preset or `dom_default`. These retained values are dormant;
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.
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.
Older builds do not understand v3 documents. Retain a compatible settings
backup before rolling back to one of those builds. Curves discarded before
this change cannot be recovered automatically.
@@ -8,7 +8,7 @@ import math
import numbers
PERSONALITY_PROFILES_PARAM = "LongitudinalPersonalityProfiles"
PROFILE_SCHEMA_VERSION = 2
PROFILE_SCHEMA_VERSION = 3
PERSONALITY_IDS = ("traffic", "aggressive", "standard", "relaxed")
TRUCK_FINGERPRINT_TOKENS = (
" RAM 1500 ",
@@ -48,6 +48,9 @@ _V2_CURVE_BOUNDS = {
"following": (0.75, 3.0),
}
_V1_CURVE_BOUNDS = dict(_V2_CURVE_BOUNDS)
# Dom's Traffic default is below the point editor's minimum. Keep it losslessly
# after initialization/reset, while applying CURVE_BOUNDS to newly edited points.
_V3_CURVE_BOUNDS = {**_V2_CURVE_BOUNDS, "braking": (0.35, 2.0), "following": (0.5, 3.0)}
PERSONALITY_ADVANCED_PARAM_KEYS = frozenset(
f"{profile}{suffix}"
for profile in ("Traffic", "Aggressive", "Standard", "Relaxed")
@@ -218,6 +221,7 @@ def _validated_category_with_length(
expected_length: int,
curve_bounds: dict[str, tuple[float, float]],
legacy_curve_bounds: dict[str, tuple[float, float]] | None = None,
*, retain_custom: bool = False,
) -> dict | None:
if category not in _CATEGORY_SPECS or not isinstance(raw_category, dict):
return None
@@ -230,7 +234,7 @@ def _validated_category_with_length(
curve = raw_category.get("curve")
if not isinstance(preset, str) or preset not in presets or not isinstance(curve, list):
return None
if preset != "custom":
if preset != "custom" and (not retain_custom or not curve):
return {"preset": preset, "curve": []} if not curve and not has_legacy_curve else None
if len(curve) != expected_length:
return None
@@ -266,7 +270,7 @@ def _validated_category_with_length(
def _validated_category(category: str, raw_category) -> dict | None:
expected_length = _CATEGORY_SPECS.get(category, ((), 0))[1]
return _validated_category_with_length(category, raw_category, expected_length, _V2_CURVE_BOUNDS, _V1_CURVE_BOUNDS)
return _validated_category_with_length(category, raw_category, expected_length, _V3_CURVE_BOUNDS, _V1_CURVE_BOUNDS, retain_custom=True)
def _schema_values_equal(actual, expected) -> bool:
@@ -307,6 +311,7 @@ def _strict_document(
for category in _CATEGORY_SPECS:
validated = _validated_category_with_length(
category, raw_profile.get(category), category_lengths[category], curve_bounds, legacy_curve_bounds,
retain_custom=schema_version >= 3,
)
if validated is None:
return None
@@ -321,14 +326,23 @@ def _strict_document(
def strict_profile_document(raw_document) -> dict | None:
return _strict_document(
raw_document,
PROFILE_SCHEMA_VERSION,
# Version 2 has the same axes and active curves. Read it losslessly; the next
# normal save upgrades the document without requiring a destructive reset.
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):
return None
document = _strict_document(
decoded,
version,
PROFILE_AXES,
{category: expected_length for category, (_, expected_length) in _CATEGORY_SPECS.items()},
_V2_CURVE_BOUNDS,
_V2_CURVE_BOUNDS if version == 2 else _V3_CURVE_BOUNDS,
_V1_CURVE_BOUNDS,
)
if document is not None:
document["schemaVersion"] = PROFILE_SCHEMA_VERSION
return document
def migrate_profile_document(raw_document) -> dict | None:
@@ -406,6 +420,7 @@ def serialize_personality_profiles(profiles, ev_tuning: bool, truck_tuning: bool
def update_personality_profile(
profiles, personality: str, category: str, preset: str, curve, ev_tuning: bool, truck_tuning: bool = False,
*, reset: bool = False,
) -> dict[str, dict]:
if personality not in PERSONALITY_IDS:
raise ValueError(f"Unknown personality: {personality}")
@@ -414,12 +429,14 @@ def update_personality_profile(
base_document = profile_document(profiles, enabled=True)
canonical = strict_profile_document(base_document)
validated = _validated_category(category, {"preset": preset, "curve": curve})
if validated is not None and preset == "custom":
if preset != "custom" and (curve != [] or reset):
validated = None
if validated is not None and preset == "custom" and not reset:
minimum, maximum = CURVE_BOUNDS[category]
previous = canonical["profiles"][personality][category] if canonical is not None else None
for index, value in enumerate(curve):
if not minimum <= value <= maximum and (
previous is None or previous["preset"] != "custom" or value != previous["curve"][index]
previous is None or not previous["curve"] or value != previous["curve"][index]
):
validated = None
break
@@ -436,7 +453,10 @@ def update_personality_profile(
base = canonical["profiles"]
updated = deepcopy(base)
previous = updated[personality][category]
if preset == "custom" and previous["preset"] == "custom" and validated["curve"] == previous["curve"]:
# A preset only changes which curve is active. Dormant Custom data, including
# preserved v1 runtime interpolation, survives switching and serialization.
if preset != "custom" or (not reset and validated["curve"] == previous["curve"]):
previous["preset"] = preset
return updated
updated[personality][category] = validated
return updated
@@ -522,7 +542,9 @@ def initial_custom_curve(
if category not in _CATEGORY_SPECS or not isinstance(current_config, dict):
raise ValueError("Unknown or malformed profile category")
preset = current_config.get("preset")
if preset == "dom_default":
if current_config.get("curve"):
candidate = current_config["curve"]
elif preset == "dom_default":
candidate = legacy_curve
if category in ("acceleration", "braking") and isinstance(candidate, list) and len(candidate) == len(_V1_ACCELERATION_SPEEDS_MPH):
candidate = [
@@ -562,7 +584,7 @@ def interpolate_category_curve(
if validated is None:
raise ValueError(f"Invalid {category} profile configuration.")
values = category_curve(category, validated, ev_tuning, truck_tuning)
if "legacyCurve" in validated:
if validated["preset"] == "custom" and "legacyCurve" in validated:
return _linear_interp(float(v_ego), _NATIVE_ACCELERATION_SPEEDS_MS, validated["legacyCurve"])
if category == "acceleration":
from openpilot.starpilot.common.accel_profile import interpolate_accel_profile
@@ -38,7 +38,7 @@ from openpilot.starpilot.common.longitudinal_personality_profiles import (
def test_document_is_versioned_disabled_and_declares_exact_axes_and_units():
document = profile_document(default_personality_profiles(False), enabled=False)
assert document["schemaVersion"] == PROFILE_SCHEMA_VERSION == 2
assert document["schemaVersion"] == PROFILE_SCHEMA_VERSION == 3
assert document["enabled"] is False
assert document["axes"] == {
"acceleration": {
@@ -455,7 +455,7 @@ def test_following_custom_initialisation_uses_effective_legacy_curve():
def test_v2_uses_one_shared_ten_mph_custom_axis():
assert PROFILE_SCHEMA_VERSION == 2
assert PROFILE_SCHEMA_VERSION == 3
expected = tuple(range(0, 91, 10))
assert ACCELERATION_SPEEDS_MPH == expected
assert BRAKING_SPEEDS_MPH == expected
@@ -537,7 +537,7 @@ def test_exact_v1_document_migrates_whole_or_not_at_all():
migrated = lpp.migrate_profile_document(legacy)
assert migrated is not None
assert migrated["schemaVersion"] == 2
assert migrated["schemaVersion"] == PROFILE_SCHEMA_VERSION
assert migrated["enabled"] is True
migrated_acceleration = migrated["profiles"]["aggressive"]["acceleration"]
assert migrated_acceleration["preset"] == "custom"
@@ -0,0 +1,54 @@
import json
from copy import deepcopy
import pytest
from openpilot.starpilot.common import longitudinal_personality_profiles as lpp
@pytest.mark.parametrize("personality", lpp.PERSONALITY_IDS)
@pytest.mark.parametrize("category,preset", [("acceleration", "eco"), ("braking", "sport"), ("following", "far")])
def test_custom_survives_preset_changes_serialization_and_reload(personality, category, preset):
profiles = lpp.default_personality_profiles(False)
curve = [round(1.0 + i * 0.05, 4) for i in range(10)]
profiles = lpp.update_personality_profile(profiles, personality, category, "custom", curve, False)
other_profiles = deepcopy(profiles)
for selected in (preset, "dom_default", preset):
profiles = lpp.update_personality_profile(profiles, personality, category, selected, [], False)
profiles = lpp.load_personality_profiles(lpp.serialize_personality_profiles(profiles, False, enabled=True), False)
assert profiles[personality][category] == {"preset": selected, "curve": curve}
if selected != "dom_default":
assert lpp.category_curve(category, profiles[personality][category], False) == lpp.category_curve(category, {"preset": selected, "curve": []}, False)
seed = lpp.initial_custom_curve(category, profiles[personality][category], False, False, legacy_curve=[2.0] * 10)
profiles = lpp.update_personality_profile(profiles, personality, category, "custom", seed, False)
assert profiles == other_profiles
def test_switching_preserves_high_points_and_legacy_runtime_only_for_custom():
profiles = lpp.default_personality_profiles(False)
saved = {"preset": "custom", "curve": [4.0] * 10, "legacyCurve": [5.0] * 7}
profiles["aggressive"]["acceleration"] = deepcopy(saved)
profiles = lpp.update_personality_profile(profiles, "aggressive", "acceleration", "eco", [], False)
assert profiles["aggressive"]["acceleration"]["legacyCurve"] == saved["legacyCurve"]
assert lpp.interpolate_category_curve("acceleration", 12, profiles["aggressive"]["acceleration"], False) == lpp.interpolate_category_curve("acceleration", 12, {"preset": "eco", "curve": []}, False)
profiles = lpp.update_personality_profile(profiles, "aggressive", "acceleration", "custom", [4.0] * 10, False)
assert profiles["aggressive"]["acceleration"] == saved
def test_v2_load_is_lossless_and_next_write_uses_new_version():
document = lpp.profile_document(lpp.default_personality_profiles(False), enabled=True)
document["schemaVersion"] = 2
document["profiles"]["standard"]["acceleration"] = {"preset": "custom", "curve": [4.0] * 10}
loaded = lpp.strict_profile_document(document)
assert loaded is not None
assert loaded["profiles"] == document["profiles"]
assert json.loads(lpp.serialize_personality_profiles(loaded["profiles"], False, enabled=True))["schemaVersion"] > 2
document["profiles"]["standard"]["acceleration"]["preset"] = "eco"
assert lpp.strict_profile_document(document) is None # v2 never allowed dormant curves
@pytest.mark.parametrize("curve", [[True] * 10, [float("nan")] * 10, [1.0] * 9, [7.0] * 10])
def test_dormant_curves_are_validated(curve):
profiles = lpp.default_personality_profiles(False)
profiles["standard"]["acceleration"] = {"preset": "eco", "curve": curve}
assert lpp.strict_profile_document(lpp.profile_document(profiles, enabled=True)) is None
@@ -174,14 +174,14 @@ export const PersonalityProfiles = {
discard(profile, category) { delete this.drafts[profile + category]; delete this.curveErrors[profile + category] },
async saveCurve(profile, category, reset = false) {
if (this.editingLocked || this.disposed) return
const curve = reset ? this.data.reference_curves?.[profile]?.[category] : this.draft(profile, category)
const curve = reset ? [] : this.draft(profile, category)
if (!Array.isArray(curve)) return
const snapshot = [...curve]
this.curvePending = true
try {
if (this.contextPending) { try { await this.contextRequest } catch { return } }
if (this.disposed) return
if (await this.write(() => api.savePersonalityProfile({ profile, category, preset: "custom", curve: snapshot, expected: this.data.profiles[profile][category] }), () => !this.locked && !this.data?.migration_required)) this.notice = ""
if (await this.write(() => api.savePersonalityProfile({ profile, category, preset: "custom", curve: snapshot, ...(reset ? { reset: true } : {}), expected: this.data.profiles[profile][category] }), () => !this.locked && !this.data?.migration_required)) this.notice = ""
} finally {
this.discard(profile, category)
this.curvePending = false
@@ -202,7 +202,7 @@ export const PersonalityProfiles = {
if (this.drag?.profile === profile && this.drag.category === category) return this.drag.max
return Math.max(this.data.bounds[category][1], ...this.draft(profile, category), ...(this.data.reference_curves?.[profile]?.[category] || []))
},
graphMin(profile, category) { return this.drag?.profile === profile && this.drag.category === category ? this.drag.min : this.data.bounds[category][0] },
graphMin(profile, category) { return this.drag?.profile === profile && this.drag.category === category ? this.drag.min : Math.min(this.data.bounds[category][0], ...this.draft(profile, category), ...(this.data.reference_curves?.[profile]?.[category] || [])) },
graphPoints(profile, category, reference = false) {
const curve = reference ? this.data.reference_curves?.[profile]?.[category] : this.draft(profile, category)
const max = this.graphMax(profile, category)
@@ -299,7 +299,7 @@ export const PersonalityProfiles = {
<template v-for="(title, category) in CATEGORIES" :key="category">
<details v-if="data.profiles[profile][category].preset === 'custom'" open class="gx-personalities__curve">
<summary>Custom {{ title.toLowerCase() }} graph</summary>
<p>{{ category === 'following' ? 'Seconds' : 'm/s²' }} · {{ speedUnit() }}. Dashed: default.</p>
<p>{{ category === 'following' ? 'Seconds' : 'm/s²' }} · {{ speedUnit() }}. Dashed: Dom default.</p>
<div class="gx-personalities__plot" tabindex="0" :aria-label="label(profile) + ' ' + title + ' graph; scroll horizontally on narrow screens'">
<svg viewBox="-30 -12 340 140" role="group" :aria-disabled="editingLocked" :aria-label="label(profile) + ' ' + title + ' editable curve; exact values below'"
style="touch-action: pan-x" @pointerdown="pickPoint(profile, category, $event)" @pointermove="moveDrag" @pointerup="endDrag" @pointercancel="endDrag" @lostpointercapture="endDrag">
@@ -336,7 +336,8 @@ export const PersonalityProfiles = {
</label>
</div>
<div class="gx-personalities__options">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="editingLocked" @click="saveCurve(profile, category, true)">Reset to default</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="editingLocked"
:aria-label="label(profile) + ' ' + title + ' reset to default'" @click="saveCurve(profile, category, true)">Reset to default</button>
</div>
</details>
</template>
@@ -0,0 +1,56 @@
const assert = require('assert');
const path = require('path');
module.exports = async ({page, data, values, writes, errors, output}) => {
const categories = ['acceleration', 'braking', 'following'];
for (const category of categories) data.profiles.traffic[category] = {preset:'custom', curve:Array(10).fill(1.15)};
data.reference_curves.traffic.braking = Array(10).fill(0.35);
const reload = async () => {
await page.reload();
await page.getByRole('button', {name:'Manage', exact:true}).click();
await page.locator('.gx-personalities__profile').first().locator('.gx-personalities__advanced > summary').click();
};
const settled = () => page.waitForFunction(() => {
const vm = document.querySelector('#app').__vue_app__._instance.proxy;
return !vm.busy && !vm.curvePending && vm.ready;
});
await reload();
const profile = page.locator('.gx-personalities__profile').first();
for (let i=0;i<categories.length;i++) {
const category = categories[i];
const section = profile.locator('.gx-personalities__category').nth(i);
await section.getByRole('button',{name:i===2?'Far':'Eco',exact:true}).click();
await settled();
await section.getByRole('button',{name:'Custom',exact:true}).click();
await settled();
assert.deepEqual(data.profiles.traffic[category].curve,Array(10).fill(1.15));
const graph = profile.locator('.gx-personalities__curve').nth(i);
const reset = graph.getByRole('button',{name:/reset to default/i});
const other = JSON.stringify(data.profiles.traffic[categories[(i+1)%3]]);
await reset.click();
await settled();
assert.equal(writes.at(-1).reset,true);
assert.deepEqual(writes.at(-1).curve,[]);
assert(writes.at(-1).expected);
assert.deepEqual(data.profiles.traffic[category].curve,data.reference_curves.traffic[category]);
assert.equal(JSON.stringify(data.profiles.traffic[categories[(i+1)%3]]),other);
assert.deepEqual(await graph.locator('input').evaluateAll(inputs=>inputs.map(input=>Number(input.value))),data.reference_curves.traffic[category]);
assert.equal(await graph.locator('polyline').nth(0).getAttribute('points'),await graph.locator('polyline').nth(1).getAttribute('points'));
}
await reload();
assert.equal(Number(await profile.locator('.gx-personalities__curve').nth(1).locator('input').first().inputValue()),0.35);
const points = await profile.locator('.gx-personalities__curve').nth(1).locator('polyline').first().getAttribute('points');
assert(points.split(' ').every(pair=>Number(pair.split(',')[1])<=90),'low default stays inside graph axes');
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,`custom-reset-${width}.png`),fullPage:true});
}
values.IsOnroad=true; values.IsOffroad=false;
await page.reload();
await page.getByRole('button',{name:'Manage',exact:true}).click();
await profile.locator('.gx-personalities__advanced > summary').click();
for (const button of await profile.getByRole('button',{name:/reset to default/i}).all()) assert(await button.isDisabled());
assert.deepEqual(errors,[]);
console.log('PASS: rendered reset controls, retained presets, reload persistence, per-category defaults/reference parity, narrow/wide layout and onroad lock');
};
@@ -17,10 +17,13 @@ const output=process.env.PERSONALITY_BROWSER_OUTPUT || path.join(require('os').t
const u=new URL(route.request().url()); let file;
if(u.pathname==='/')return route.fulfill({contentType:'text/html',body:`<script type="importmap">{"imports":{"vue":"/assets/vendor/vue/vue.esm-browser.js"}}</script><link rel="stylesheet" href="/assets/vendor/bootstrap-icons/bootstrap-icons.min.css"><link rel="stylesheet" href="/assets/mobile/css/material.css"><div id="app"></div><div id="snackbar_wrapper"></div><script type="module">import {createApp} from '/assets/vendor/vue/vue.esm-browser.js';import {PersonalityProfiles} from '/assets/mobile/js/components/PersonalityProfiles.js';createApp(PersonalityProfiles).mount('#app');</script>`});
if(u.pathname==='/api/params/all'){await waitGate('params');return route.fulfill({json:values});}
if(u.pathname==='/api/longitudinal_mode' && route.request().method()==='GET')return route.fulfill({json:{mode:'chill',locked:false,reason:'',experimental_confirmed:false,values:{ExperimentalMode:false,ConditionalExperimental:false,ConditionalChill:false}}});
if(u.pathname==='/api/params/defaults')return route.fulfill({json:{}});
if(u.pathname==='/api/params'){const d=route.request().postDataJSON();values[d.key]=d.value;return route.fulfill({json:{success:true}});}
if(u.pathname==='/api/personality_profiles'){
if(route.request().method()==='PUT'){attempts++;await waitGate('put');if(failWrite||faults.failPut){failWrite=false;faults.failPut=false;return route.fulfill({status:503,json:{error:'Synthetic save failure'}});}const d=route.request().postDataJSON();writes.push(d);data.profiles[d.profile][d.category]={preset:d.preset,curve:d.curve.length?d.curve:[...data.reference_curves[d.profile][d.category]]};}
if(route.request().method()==='PUT'){attempts++;await waitGate('put');if(failWrite||faults.failPut){failWrite=false;faults.failPut=false;return route.fulfill({status:503,json:{error:'Synthetic save failure'}});}const d=route.request().postDataJSON();writes.push(d);const previous=data.profiles[d.profile][d.category];
if(d.expected && JSON.stringify(d.expected)!==JSON.stringify(previous))return route.fulfill({status:409,json:{error:'Saved profile changed'}});
data.profiles[d.profile][d.category]={preset:d.preset,curve:d.reset?[...data.reference_curves[d.profile][d.category]]:d.preset==='custom'?(d.curve.length?d.curve:previous.curve.length?[...previous.curve]:[...data.reference_curves[d.profile][d.category]]):[...previous.curve]};}
else {profileReads++;if(faults.readFailures){faults.readFailures--;return route.fulfill({status:503,json:{error:'Synthetic readback failure'}});}}
return route.fulfill({json:data});}
if(u.pathname.endsWith('device_settings_layout.json'))file=root+'/starpilot/common/assets/device_settings_layout.json';
@@ -37,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_CUSTOM_ONLY){
await require('./personality_custom_graphs.cjs')({page,data,values,writes,errors,output});
return;
}
if(process.env.PERSONALITY_POLL_ONLY){
await require('./personality_poll.cjs')({page,data,values,faults,counts:()=>({attempts}),errors});
return;
@@ -130,7 +137,7 @@ const output=process.env.PERSONALITY_BROWSER_OUTPUT || path.join(require('os').t
await n.press('Tab');
await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.curvePending);
assert.equal(Number(await n.inputValue()),1.5);
await graph.getByRole('button',{name:'Reset to default',exact:true}).click();
await graph.getByRole('button',{name:/reset to default/i}).click();
await page.waitForFunction(()=>!document.querySelector('#app').__vue_app__._instance.proxy.busy);
const p=['traffic','aggressive','standard','relaxed'][pi],c=['acceleration','braking','following'][ci];
assert.deepEqual(await graph.locator('input').evaluateAll(ns=>ns.map(n=>Number(n.value))),data.reference_curves[p][c]);
@@ -204,7 +211,7 @@ const output=process.env.PERSONALITY_BROWSER_OUTPUT || path.join(require('os').t
store.route='/settings/longitudinal-speed-following';store.params={open:'CustomPersonalities'};store.search='';createApp(Settings).mount('#app');
});
await page.locator('.gx-personalities__grid').waitFor();assert(await page.locator('#gx-personality-settings').isVisible());
assert.equal(await page.locator('.gx-longitudinal-mode').count(),0,'personality-only settings do not introduce unified mode');
assert.equal(await page.locator('.gx-longitudinal-mode').count(),1,'Dom Settings retains the existing unified mode selector');
for(const theme of ['dark','light']) {
await page.evaluate(t=>document.documentElement.dataset.theme=t,theme);
await page.locator('.gx-personalities__heading').scrollIntoViewIfNeeded();
@@ -0,0 +1,131 @@
from copy import deepcopy
import pytest
from openpilot.starpilot.common import longitudinal_personality_profiles as lpp
from openpilot.starpilot.common.accel_profile import A_CRUISE_MAX_VALS_TRAFFIC_ALL, interpolate_accel_profile, get_accel_profile_curve_values
from test_personality_profiles_api import _client, the_galaxy
@pytest.mark.parametrize("personality", lpp.PERSONALITY_IDS)
@pytest.mark.parametrize("category,preset", [("acceleration", "eco"), ("braking", "sport"), ("following", "far")])
def test_retention_and_reset_are_atomic_per_category(monkeypatch, personality, category, preset):
profiles = lpp.default_personality_profiles(False)
saved = {"preset": "custom", "curve": [1.15] * 10}
profiles[personality][category] = deepcopy(saved)
client, params = _client(monkeypatch, {"CustomPersonalities": True, lpp.PERSONALITY_PROFILES_PARAM: lpp.profile_document(profiles, enabled=True)})
endpoint = "/api/personality_profiles"
for selected in (preset, "dom_default", "custom"):
expected = client.get(endpoint).get_json()["profiles"][personality][category]
response = client.put(endpoint, json={"profile": personality, "category": category, "preset": selected, "curve": [], "expected": expected})
assert response.status_code == 200
assert response.get_json()["profiles"][personality][category]["curve"] == saved["curve"]
before = client.get(endpoint).get_json()
response = client.put(endpoint, json={"profile": personality, "category": category, "preset": "custom", "curve": [], "reset": True, "expected": saved})
assert response.status_code == 200
result = response.get_json()
expected_profiles = deepcopy(profiles)
expected_profiles[personality][category] = {"preset": "custom", "curve": before["reference_curves"][personality][category]}
assert result["profiles"] == expected_profiles
assert lpp.strict_profile_document(params.values[lpp.PERSONALITY_PROFILES_PARAM])["profiles"] == expected_profiles
for selected in (preset, "custom"):
response = client.put(endpoint, json={"profile": personality, "category": category, "preset": selected, "curve": []})
assert response.status_code == 200
assert response.get_json()["profiles"] == expected_profiles
def test_reference_lines_use_dom_traffic_and_enabled_following_defaults(monkeypatch):
client, _ = _client(monkeypatch, {"CustomPersonalities": True})
curves = client.get("/api/personality_profiles").get_json()["reference_curves"]
assert curves["traffic"]["acceleration"] == [round(interpolate_accel_profile(speed * 0.44704, A_CRUISE_MAX_VALS_TRAFFIC_ALL), 4) for speed in lpp.ACCELERATION_SPEEDS_MPH]
assert curves["traffic"]["braking"] == [0.35] * 10
assert curves["standard"]["braking"] == [0.5] * 10
assert curves["standard"]["following"] == [1.45] * 5 + [1.4, 1.3, 1.2, 1.2, 1.2]
assert curves["traffic"]["following"][0] == 0.75
assert curves["traffic"]["following"][-1] == 1.6
@pytest.mark.parametrize("tuning", [False, True])
def test_reference_respects_global_tuning_switches_and_powertrain_overrides(monkeypatch, tuning):
client, _ = _client(monkeypatch, {"CustomPersonalities": True, "LongitudinalTune": tuning, "AccelerationProfile": 2, "DecelerationProfile": 2, "EVTuning": False, "TruckTuning": True}, ev_tuning=True)
curves = client.get("/api/personality_profiles").get_json()["reference_curves"]
expected = get_accel_profile_curve_values(2 if tuning else 0, False, True)
assert curves["standard"]["acceleration"] == [round(interpolate_accel_profile(speed * 0.44704, expected), 4) for speed in lpp.ACCELERATION_SPEEDS_MPH]
assert curves["standard"]["braking"] == [2.0 if tuning else 0.5] * 10
@pytest.mark.parametrize("extra", [{"reset": "true"}, {"reset": 1}, {"reset": True, "preset": "eco"}, {"reset": True, "curve": [1.0] * 10}])
def test_malformed_reset_never_writes(monkeypatch, extra):
client, params = _client(monkeypatch)
response = client.put("/api/personality_profiles", json={"profile": "standard", "category": "acceleration", "preset": "custom", "curve": [], **extra})
assert response.status_code == 400
assert params.writes == []
def test_reset_obeys_offroad_and_stale_editor_guards(monkeypatch):
client, params = _client(monkeypatch, {"IsOnroad": True})
payload = {"profile": "standard", "category": "following", "preset": "custom", "curve": [], "reset": True}
assert client.put("/api/personality_profiles", json=payload).status_code == 403
assert params.writes == []
params.values.update(IsOnroad=False, IsOffroad=True)
payload["expected"] = {"preset": "custom", "curve": [1.0] * 10}
assert client.put("/api/personality_profiles", json=payload).status_code == 409
assert params.writes == []
@pytest.mark.parametrize("personality,category,values,expected", [
("traffic", "braking", {}, 0.35),
("standard", "acceleration", {"TruckTuning": True}, 6.0),
])
def test_first_custom_default_and_reset_keep_dom_points_outside_authoring_bounds(monkeypatch, personality, category, values, expected):
client, params = _client(monkeypatch, {"CustomPersonalities": True, **values})
endpoint = "/api/personality_profiles"
payload = {"profile": personality, "category": category, "preset": "custom", "curve": []}
response = client.put(endpoint, json=payload)
assert response.status_code == 200
config = response.get_json()["profiles"][personality][category]
assert config["curve"][0] == expected
original = deepcopy(config["curve"])
# Editing one point retains the default-only values at the other points.
edited = list(original)
edited[-1] = 1.0
response = client.put(endpoint, json={**payload, "curve": edited})
assert response.status_code == 200
assert response.get_json()["profiles"][personality][category]["curve"] == edited
response = client.put(endpoint, json={**payload, "reset": True})
assert response.status_code == 200
assert response.get_json()["profiles"][personality][category]["curve"] == original
invalid = list(original)
invalid[0] = 0.4 if category == "braking" else 5.5
before = deepcopy(params.values)
assert client.put(endpoint, json={**payload, "curve": invalid}).status_code == 400
assert params.values == before
def test_reset_retires_legacy_interpolation_even_if_display_points_match(monkeypatch):
profiles = lpp.default_personality_profiles(False)
profiles["standard"]["braking"] = {"preset": "custom", "curve": [0.5] * 10, "legacyCurve": [1.0] * 7}
client, _ = _client(monkeypatch, {lpp.PERSONALITY_PROFILES_PARAM: lpp.profile_document(profiles, enabled=True)})
result = client.put("/api/personality_profiles", json={"profile": "standard", "category": "braking", "preset": "custom", "curve": [], "reset": True})
assert result.status_code == 200
assert result.get_json()["profiles"]["standard"]["braking"] == {"preset": "custom", "curve": [0.5] * 10}
@pytest.mark.parametrize("enabled,low,high", [(True, 0.5, 1.1), (False, 0.75, 1.6)])
def test_traffic_following_defaults_honor_master_and_legacy_minimum(monkeypatch, enabled, low, high):
client, _ = _client(monkeypatch, {"CustomPersonalities": enabled, "TrafficFollow": 0.5, "RelaxedFollow": 1.1})
payload = {"profile": "traffic", "category": "following", "preset": "custom", "curve": [], "reset": True}
result = client.put("/api/personality_profiles", json=payload)
assert result.status_code == 200
body = result.get_json()
curve = body["profiles"]["traffic"]["following"]["curve"]
assert curve == body["reference_curves"]["traffic"]["following"]
assert (curve[0], curve[-1]) == (low, high)
def test_default_reference_uses_normal_gear_when_mapping_is_enabled(monkeypatch):
client, _ = _client(monkeypatch, {"QOLLongitudinal": True, "MapGears": True, "MapAcceleration": True, "MapDeceleration": True, "LongitudinalTune": True, "AccelerationProfile": 2, "DecelerationProfile": 2})
curves = client.get("/api/personality_profiles").get_json()["reference_curves"]
expected = get_accel_profile_curve_values(0, False, False)
assert curves["standard"]["acceleration"] == [round(interpolate_accel_profile(speed * 0.44704, expected), 4) for speed in lpp.ACCELERATION_SPEEDS_MPH]
assert curves["standard"]["braking"] == [1.0] * 10
@@ -1,8 +1,10 @@
import json
import sys
import numpy as np
import pytest
from openpilot.starpilot.common import accel_profile
from openpilot.starpilot.common.accel_profile import A_CRUISE_MAX_BP_CUSTOM, ACCELERATION_PROFILES, interpolate_accel_profile
from openpilot.starpilot.common.longitudinal_personality_profiles import (
FOLLOWING_SPEEDS_MPH,
@@ -22,6 +24,11 @@ from test_navigation_params import _params_client, the_galaxy
def _client(monkeypatch, values=None, *, ev_tuning=False, truck_tuning=False):
# Use the real pure curve implementation; the general Galaxy import fixture
# stubs it with incomplete vehicle tables for unrelated dashboard tests.
monkeypatch.setitem(sys.modules, "openpilot.starpilot.common.accel_profile", accel_profile)
for name in ("get_accel_profile_curve_values", "interpolate_accel_profile", "normalize_acceleration_profile", "normalize_deceleration_profile"):
monkeypatch.setattr(the_galaxy, name, getattr(accel_profile, name))
device_values = dict(values or {})
device_values.setdefault("IsOnroad", False)
device_values.setdefault("IsOffroad", not device_values["IsOnroad"])
@@ -248,7 +255,7 @@ def test_legacy_master_without_document_remains_enabled_when_first_profile_is_sa
assert document is not None and document["enabled"] is True
def test_first_save_persists_one_atomic_versioned_document_with_other_categories_standard(monkeypatch):
def test_first_save_persists_one_atomic_versioned_document_with_other_categories_dom_default(monkeypatch):
client, params = _client(monkeypatch)
response = client.put("/api/personality_profiles", json={
"profile": "standard", "category": "braking", "preset": "sport", "curve": [2.0] * 10,
@@ -262,7 +269,7 @@ def test_first_save_persists_one_atomic_versioned_document_with_other_categories
for category, config in profile.items():
if (profile_id, category) != ("standard", "braking"):
assert config == {
"preset": "medium" if category == "following" else "standard", "curve": [],
"preset": "dom_default", "curve": [],
}
assert len([write for write in params.writes if write[0] == PERSONALITY_PROFILES_PARAM]) == 1
@@ -345,7 +352,7 @@ def test_dom_default_custom_acceleration_seeds_from_effective_legacy_custom_curv
profiles["traffic"]["acceleration"] = {"preset": "dom_default", "curve": []}
values = {
"IsOnroad": False,
"CustomAccelProfile": True,
"CustomAccelProfile": True, "AdvancedLongitudinalTune": True,
"CustomAccelProfileInitialized": True,
PERSONALITY_PROFILES_PARAM: profile_document(profiles, enabled=False),
**{
@@ -360,7 +367,7 @@ def test_dom_default_custom_acceleration_seeds_from_effective_legacy_custom_curv
assert response.status_code == 200
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
expected = [
round(interpolate_accel_profile(speed * 0.44704, [1.1, 1.0, 0.9, 0.8, 0.7, 0.6, 0.5], A_CRUISE_MAX_BP_CUSTOM), 4)
round(interpolate_accel_profile(speed * 0.44704, accel_profile.A_CRUISE_MAX_VALS_TRAFFIC_ALL, A_CRUISE_MAX_BP_CUSTOM), 4)
for speed in FOLLOWING_SPEEDS_MPH
]
assert document["profiles"]["traffic"]["acceleration"]["curve"] == expected
@@ -386,7 +393,7 @@ def test_dom_default_custom_seed_resamples_valid_dynamic_curve_and_malformed_dyn
profiles["standard"]["acceleration"] = {"preset": "dom_default", "curve": []}
dynamic = {
"IsOnroad": False,
"CustomAccelProfile": True,
"CustomAccelProfile": True, "AdvancedLongitudinalTune": True,
PERSONALITY_PROFILES_PARAM: profile_document(profiles, enabled=False),
"CustomAccelProfileBreakpointsInitialized": True,
"CustomAccelProfilePointCount": 3,
@@ -450,7 +457,7 @@ def test_following_custom_seeds_from_legacy_profile_and_then_persists_edits(monk
assert document["profiles"]["standard"]["following"]["curve"] == [round(value, 4) for value in edited]
def test_fresh_following_custom_seeds_from_selected_medium_even_when_legacy_custom_is_off(monkeypatch):
def test_fresh_following_custom_seeds_from_dom_default_when_legacy_custom_is_off(monkeypatch):
client, params = _client(monkeypatch, {
"IsOnroad": False,
"CustomPersonalities": False,
@@ -462,15 +469,15 @@ def test_fresh_following_custom_seeds_from_selected_medium_even_when_legacy_cust
})
assert response.status_code == 200
document = strict_profile_document(params.values[PERSONALITY_PROFILES_PARAM])
assert document["profiles"]["relaxed"]["following"]["curve"] == [1.45] * len(FOLLOWING_SPEEDS_MPH)
assert document["profiles"]["relaxed"]["following"]["curve"] == [1.75] * len(FOLLOWING_SPEEDS_MPH)
def test_traffic_following_seed_matches_legacy_runtime_speed_units(monkeypatch):
profiles = default_personality_profiles(False)
profiles["traffic"]["following"] = {"preset": "dom_default", "curve": []}
client, params = _client(monkeypatch, {
"IsOnroad": False,
PERSONALITY_PROFILES_PARAM: profile_document(profiles, enabled=False),
"IsOnroad": False, "CustomPersonalities": True,
PERSONALITY_PROFILES_PARAM: profile_document(profiles, enabled=True),
"TrafficFollow": 0.8,
"RelaxedFollow": 1.6,
})
@@ -828,7 +835,7 @@ def test_known_v1_document_is_migrated_for_readback(monkeypatch):
assert body["configured"] is True
assert body["migration_required"] is True
assert body["schema_version"] == 2
assert body["schema_version"] == PROFILE_SCHEMA_VERSION
assert len(body["profiles"]["standard"]["acceleration"]["curve"]) == 10
assert body["profiles"]["standard"]["acceleration"]["legacyCurve"] == [1.0] * 7
@@ -519,7 +519,7 @@ def test_personality_cards_and_advanced_disclosures_have_unique_accessible_relat
assert 'aria-controls="personality-advanced-${profile.id}"' in advanced
assert 'id="personality-advanced-${profile.id}"' in source
assert 'aria-hidden="true"' in advanced
manage = source.split('${() => p.is_parent_toggle', 1)[1].split("` : \"\"}", 1)[0]
manage = source.split('p.is_parent_toggle && (p.key === "CustomPersonalities"', 1)[1].split("` : \"\"}", 1)[0]
assert 'aria-controls="${p.key === "CustomPersonalities" ? "personality-profiles-panel"' in manage
assert 'aria-expanded="${() => state.expanded[p.key] ? "true" : "false"}"' in manage
+57 -21
View File
@@ -150,7 +150,6 @@ from openpilot.starpilot.common.longitudinal_personality_profiles import (
initial_custom_curve,
is_truck_fingerprint,
migrate_profile_document,
personality_reference_curves,
profile_document,
strict_profile_document,
synchronise_profile_document_enabled,
@@ -3722,18 +3721,20 @@ def _get_detected_truck_tuning():
return False
def _get_effective_legacy_custom_accel_curve(ev_tuning: bool, truck_tuning: bool) -> list[float]:
def _get_effective_legacy_custom_accel_curve(
ev_tuning: bool, truck_tuning: bool, *, acceleration_profile=None, custom_enabled: bool | None = None,
) -> list[float]:
target_axis = np.array(ACCELERATION_SPEEDS_MPH, dtype=float) * 0.44704
def sample(values, breakpoints):
return [round(interpolate_accel_profile(float(speed), values, breakpoints), 4) for speed in target_axis]
preset_curve = get_accel_profile_curve_values(
normalize_acceleration_profile(_safe_params_get_live_raw("AccelerationProfile")),
normalize_acceleration_profile(_safe_params_get_live_raw("AccelerationProfile") if acceleration_profile is None else acceleration_profile),
ev_tuning,
truck_tuning,
)
if not _safe_params_get_bool("CustomAccelProfile"):
if not (_safe_params_get_bool("CustomAccelProfile") if custom_enabled is None else custom_enabled):
return sample(preset_curve, A_CRUISE_MAX_BP_CUSTOM)
raw_legacy = {key: _safe_params_get_live_raw(key) for key in CUSTOM_ACCEL_PROFILE_PARAM_KEYS}
@@ -3779,12 +3780,12 @@ def _get_effective_legacy_following_curve(profile_id: str) -> list[float]:
def follow_value(key: str) -> float:
try:
parsed = float(_safe_params_get_live_raw(key, defaults[key]))
parsed = float(_safe_params_get_live_raw(key, defaults[key]) if _safe_params_get_bool("CustomPersonalities") else defaults[key])
except (TypeError, ValueError):
parsed = defaults[key]
if not math.isfinite(parsed):
parsed = defaults[key]
return float(np.clip(parsed, *CURVE_BOUNDS["following"]))
return float(np.clip(parsed, 0.5 if key == "TrafficFollow" else CURVE_BOUNDS["following"][0], CURVE_BOUNDS["following"][1]))
if profile_id == "traffic":
breakpoints = (0.0, 25.0 / CV.MPH_TO_MS)
@@ -3798,6 +3799,43 @@ def _get_effective_legacy_following_curve(profile_id: str) -> list[float]:
return [round(float(point), 4) for point in np.interp(FOLLOWING_SPEEDS_MPH, breakpoints, values)]
def _get_dom_personality_reference_curves(ev_tuning: bool) -> dict[str, dict[str, list[float]]]:
"""Sample Dom's configured base curves, without live driving modifiers.
Use global powertrain/tuning switches for Dom default, just as the runtime
does. Named personality presets have a separate detected-powertrain policy.
"""
from openpilot.starpilot.common.accel_profile import A_CRUISE_MAX_VALS_TRAFFIC_ALL
truck_tuning = _safe_params_get_bool("TruckTuning")
raw_ev = _safe_params_get_live_raw("EVTuning")
ev_tuning = (ev_tuning if raw_ev in (None, b"", "") else _safe_params_get_bool("EVTuning")) and not truck_tuning
tuning = _safe_params_get_bool("LongitudinalTune")
custom_accel = _safe_params_get_bool("AdvancedLongitudinalTune") and _safe_params_get_bool("CustomAccelProfile")
acceleration_profile = normalize_acceleration_profile(_safe_params_get_live_raw("AccelerationProfile")) if tuning or custom_accel else 0
deceleration_profile = normalize_deceleration_profile(_safe_params_get_live_raw("DecelerationProfile", 1)) if tuning else 1
map_gears = _safe_params_get_bool("QOLLongitudinal") and _safe_params_get_bool("MapGears")
# A static speed graph uses the normal-gear base; live Eco/Sport, weather and
# overspeed/lead modifiers remain on Dom's existing controller paths.
if map_gears and _safe_params_get_bool("MapAcceleration") and not custom_accel:
acceleration_profile = 0
if map_gears and _safe_params_get_bool("MapDeceleration"):
deceleration_profile = 0
acceleration = _get_effective_legacy_custom_accel_curve(
ev_tuning, truck_tuning, acceleration_profile=acceleration_profile, custom_enabled=custom_accel,
)
traffic_acceleration = [round(interpolate_accel_profile(speed * CV.MPH_TO_MS, A_CRUISE_MAX_VALS_TRAFFIC_ALL), 4)
for speed in ACCELERATION_SPEEDS_MPH]
return {
profile: {
"acceleration": list(traffic_acceleration if profile == "traffic" else acceleration),
"braking": [0.35 if profile == "traffic" else {0: 1.0, 1: 0.5, 2: 2.0}[deceleration_profile]] * len(BRAKING_SPEEDS_MPH),
"following": _get_effective_legacy_following_curve(profile),
}
for profile in ("traffic", "aggressive", "standard", "relaxed")
}
def _get_runtime_default_param_overrides():
overrides = {}
static_defaults = _get_static_default_param_values()
@@ -6003,8 +6041,11 @@ def setup(app):
return jsonify({"error": "Stored longitudinal personality profiles require a verified migration before editing."}), 409
data = request.get_json(silent=True)
required_fields = {"profile", "category", "preset", "curve"}
if not isinstance(data, dict) or set(data) not in (required_fields, required_fields | {"expected"}):
return jsonify({"error": "Expected profile, category, preset, curve, and optional expected category."}), 400
if not isinstance(data, dict) or not required_fields <= set(data) or set(data) - required_fields - {"expected", "reset"}:
return jsonify({"error": "Expected profile, category, preset, curve, and optional expected category or reset."}), 400
reset = data.get("reset", False)
if type(reset) is not bool or (reset and (data["preset"] != "custom" or data["curve"] != [])):
return jsonify({"error": "Reset requires Custom and an empty curve; defaults are resolved by the server."}), 400
try:
current_config = profiles[data["profile"]][data["category"]]
@@ -6013,23 +6054,17 @@ def setup(app):
)):
return jsonify({"error": "Saved profile changed. Reload and review it before editing again."}), 409
curve = data["curve"]
if data["preset"] == "custom" and current_config.get("preset") != "custom":
initialize_default = data["preset"] == "custom" and current_config["preset"] == "dom_default" and not current_config["curve"]
if reset:
curve = _get_dom_personality_reference_curves(ev_tuning)[data["profile"]][data["category"]]
elif data["preset"] == "custom" and current_config.get("preset") != "custom":
if curve != []:
update_personality_profile(
profiles, data["profile"], data["category"], "custom", curve, ev_tuning, truck_tuning
)
legacy_curve = None
if current_config.get("preset") == "dom_default":
if data["category"] == "acceleration":
legacy_curve = _get_effective_legacy_custom_accel_curve(ev_tuning, truck_tuning)
elif data["category"] == "braking":
legacy_curve = {
0: [1.0] * len(BRAKING_SPEEDS_MPH),
1: [0.5] * len(BRAKING_SPEEDS_MPH),
2: [2.0] * len(BRAKING_SPEEDS_MPH),
}[normalize_deceleration_profile(_safe_params_get_live_raw("DecelerationProfile"))]
else:
legacy_curve = _get_effective_legacy_following_curve(data["profile"])
if current_config.get("preset") == "dom_default" and not current_config.get("curve"):
legacy_curve = _get_dom_personality_reference_curves(ev_tuning)[data["profile"]][data["category"]]
curve = initial_custom_curve(
data["category"], current_config, ev_tuning, truck_tuning, legacy_curve=legacy_curve
)
@@ -6043,6 +6078,7 @@ def setup(app):
curve,
ev_tuning,
truck_tuning,
reset=reset or initialize_default,
)
except (KeyError, TypeError, ValueError) as error:
return jsonify({"error": str(error)}), 400
@@ -6064,7 +6100,7 @@ def setup(app):
"following": list(FOLLOWING_PRESETS),
},
"profiles": profiles,
"reference_curves": personality_reference_curves(ev_tuning, truck_tuning),
"reference_curves": _get_dom_personality_reference_curves(ev_tuning),
"schema_version": PROFILE_SCHEMA_VERSION,
"speed_breakpoints_mph": {
"acceleration": list(ACCELERATION_SPEEDS_MPH),