diff --git a/docs/personality_custom_graphs.md b/docs/personality_custom_graphs.md new file mode 100644 index 0000000000..b801a6b58d --- /dev/null +++ b/docs/personality_custom_graphs.md @@ -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. diff --git a/starpilot/common/longitudinal_personality_profiles.py b/starpilot/common/longitudinal_personality_profiles.py index 7a6000ccfd..9ce0178774 100644 --- a/starpilot/common/longitudinal_personality_profiles.py +++ b/starpilot/common/longitudinal_personality_profiles.py @@ -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 diff --git a/starpilot/common/tests/test_longitudinal_personality_profiles.py b/starpilot/common/tests/test_longitudinal_personality_profiles.py index 04e95eb533..0ba5dcf563 100644 --- a/starpilot/common/tests/test_longitudinal_personality_profiles.py +++ b/starpilot/common/tests/test_longitudinal_personality_profiles.py @@ -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" diff --git a/starpilot/common/tests/test_personality_custom_retention.py b/starpilot/common/tests/test_personality_custom_retention.py new file mode 100644 index 0000000000..dd15e98829 --- /dev/null +++ b/starpilot/common/tests/test_personality_custom_retention.py @@ -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 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 e61e0b2c89..861cf8581f 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/components/PersonalityProfiles.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/PersonalityProfiles.js @@ -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 = { Custom {{ title.toLowerCase() }} graph - {{ category === 'following' ? 'Seconds' : 'm/s²' }} · {{ speedUnit() }}. Dashed: default. + {{ category === 'following' ? 'Seconds' : 'm/s²' }} · {{ speedUnit() }}. Dashed: Dom default. @@ -336,7 +336,8 @@ export const PersonalityProfiles = { - Reset to default + Reset to default diff --git a/starpilot/system/the_galaxy/tests/browser/personality_custom_graphs.cjs b/starpilot/system/the_galaxy/tests/browser/personality_custom_graphs.cjs new file mode 100644 index 0000000000..93d85da717 --- /dev/null +++ b/starpilot/system/the_galaxy/tests/browser/personality_custom_graphs.cjs @@ -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;iinputs.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'); +}; diff --git a/starpilot/system/the_galaxy/tests/browser/personality_profiles.cjs b/starpilot/system/the_galaxy/tests/browser/personality_profiles.cjs index 2b7198cdef..fff7057e85 100644 --- a/starpilot/system/the_galaxy/tests/browser/personality_profiles.cjs +++ b/starpilot/system/the_galaxy/tests/browser/personality_profiles.cjs @@ -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:``}); 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(); diff --git a/starpilot/system/the_galaxy/tests/test_personality_custom_graphs_api.py b/starpilot/system/the_galaxy/tests/test_personality_custom_graphs_api.py new file mode 100644 index 0000000000..1dd74b4f27 --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_personality_custom_graphs_api.py @@ -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 diff --git a/starpilot/system/the_galaxy/tests/test_personality_profiles_api.py b/starpilot/system/the_galaxy/tests/test_personality_profiles_api.py index d7cc4d7a29..8d32297ce7 100644 --- a/starpilot/system/the_galaxy/tests/test_personality_profiles_api.py +++ b/starpilot/system/the_galaxy/tests/test_personality_profiles_api.py @@ -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 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 99de475057..bbccfe073c 100644 --- a/starpilot/system/the_galaxy/tests/test_personality_profiles_js.py +++ b/starpilot/system/the_galaxy/tests/test_personality_profiles_js.py @@ -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 diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 270d19496b..cef799387e 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -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),
{{ category === 'following' ? 'Seconds' : 'm/s²' }} · {{ speedUnit() }}. Dashed: default.
{{ category === 'following' ? 'Seconds' : 'm/s²' }} · {{ speedUnit() }}. Dashed: Dom default.