mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-04 19:43:49 +08:00
Add parked manual editing for thirty steering coordinates
This commit is contained in:
@@ -73,7 +73,7 @@ class Controls:
|
||||
self.lateral_controller_selection = read_selection(self.params, self.CP)
|
||||
self.vehicle_startup_preferences = VehicleStartupPreferences.read(
|
||||
self.params, enabled=self.params.get_bool("OpenpilotEnabledToggle"))
|
||||
self.torque_learning_allowed = learning_allowed(self.params, self.CP, selection=self.lateral_controller_selection)
|
||||
manual_effects, manual_status = self._configure_manual_tune()
|
||||
self.aol_replay = feature_enabled(self.params, self.CP, 'aol', os.environ)
|
||||
self.aol_transition_diagnostic = TransitionDiagnostic() if self.aol_replay and self.CP.brand == 'gm' else None
|
||||
self.ordinary_axis_ack_required = axis_policy_for(self.CP).ordinary_axis_ack_required
|
||||
@@ -123,11 +123,16 @@ class Controls:
|
||||
self.LaC = create_lateral_controller(self.CP, self.CI, DT_CTRL,
|
||||
mode=self.lateral_controller_selection.mode, turn_assist=self.vehicle_startup_preferences.turn_assist)
|
||||
if isinstance(self.LaC, LatControlTorque):
|
||||
# Only the actual startup owner reads configuration; replay clones stay pure.
|
||||
if manual_effects is not None and manual_effects.profile.context[1] != str(self.LaC.controller_mode):
|
||||
manual_effects, manual_status = None, 'context_changed'
|
||||
self.LaC.manual_flm_effects, self.LaC.manual_flm_status = manual_effects, manual_status
|
||||
self.lateral_gain_owner = create_gain_owner(self.params, self.CP, self.LaC)
|
||||
cloudlog.info({"event": "torque controller selected", "controller": self.LaC.controller_mode.value,
|
||||
"source": self.lateral_controller_selection.source,
|
||||
"vehicle": self.CP.carFingerprint, "policy": self.LaC.controller_policy,
|
||||
"automaticLearning": self.torque_learning_allowed, "startupParameterSource": "vehicle",
|
||||
"manualCoordinateStatus": self.LaC.manual_flm_status,
|
||||
"startupTorque": {"factor": self.LaC.torque_params.latAccelFactor,
|
||||
"offset": self.LaC.torque_params.latAccelOffset,
|
||||
"friction": self.LaC.torque_params.friction},
|
||||
@@ -152,6 +157,15 @@ class Controls:
|
||||
assist_enabled = self.vehicle_startup_preferences.turn_assist and model_turn_assist_supported(self.CP)
|
||||
self.turn_assist_enabled = lambda: assist_enabled
|
||||
|
||||
def _configure_manual_tune(self):
|
||||
manual_effects, manual_status = None, 'not_configured'
|
||||
if self.CP.lateralTuning.which() == 'torque' and str(self.CP.steerControlType) == 'torque':
|
||||
from openpilot.starpilot.flm.manual_coordinates import load as load_manual_coordinates
|
||||
manual_effects, manual_status = load_manual_coordinates(self.CP, self.lateral_controller_selection.mode, self.params)
|
||||
self.torque_learning_allowed = bool(manual_status == 'not_configured' and
|
||||
learning_allowed(self.params, self.CP, selection=self.lateral_controller_selection))
|
||||
return manual_effects, manual_status
|
||||
|
||||
def update(self, timeout: int = 15):
|
||||
self.sm.update(timeout)
|
||||
if self.sm.updated["extrinsicsCalibration"]:
|
||||
|
||||
@@ -67,6 +67,8 @@ class LatControlTorque(LatControl):
|
||||
self.pid._k_p = [[0.0], [STARPILOT_KP]]
|
||||
self.flm_source = flm.GmLiveSource(CP, self.controller_mode)
|
||||
self.flm_surface = None
|
||||
self.manual_flm_effects = None
|
||||
self.manual_flm_status = 'not_configured'
|
||||
from openpilot.starpilot.flm.runtime_adapter_registry import baseline_values
|
||||
self.native_learning_baseline = baseline_values(self)
|
||||
self.native_learning_refinement = None
|
||||
@@ -96,6 +98,8 @@ class LatControlTorque(LatControl):
|
||||
|
||||
def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, curvature_limited, lat_delay):
|
||||
self.flm_surface = self.flm_source.sample(active=active, speed=CS.vEgo)
|
||||
if self.manual_flm_effects is not None:
|
||||
self.manual_flm_effects.begin_frame()
|
||||
if self.starpilot_extension is not None:
|
||||
result = self.starpilot_extension.update(active, CS, VM, params, steer_limited_by_safety,
|
||||
desired_curvature, curvature_limited, lat_delay)
|
||||
|
||||
@@ -206,24 +206,24 @@ class ClockEditor(Widget):
|
||||
self._dragged = False
|
||||
self._keyboard_focus = False
|
||||
|
||||
def _handle_mouse_event(self, event):
|
||||
def _handle_mouse_event(self, mouse_event):
|
||||
if not isinstance(self._pressed, int):
|
||||
return
|
||||
if event.left_pressed:
|
||||
self._last_t = self._last_motion = event.t
|
||||
if mouse_event.left_pressed:
|
||||
self._last_t = self._last_motion = mouse_event.t
|
||||
return
|
||||
delta = event.pos.y - self._last_y
|
||||
self._dragged |= abs(event.pos.y - self._start_y) > 12 * self._scale
|
||||
delta = mouse_event.pos.y - self._last_y
|
||||
self._dragged |= abs(mouse_event.pos.y - self._start_y) > 12 * self._scale
|
||||
if delta:
|
||||
self._scroll(self._pressed, delta)
|
||||
elapsed = event.t - self._last_t
|
||||
elapsed = mouse_event.t - self._last_t
|
||||
if 0 < elapsed < 0.15:
|
||||
self._velocity = max(-1600 * self._scale, min(1600 * self._scale, delta / elapsed))
|
||||
self._last_motion = event.t
|
||||
self._last_y, self._last_t = event.pos.y, event.t
|
||||
if event.left_released and event.t - self._last_motion > 0.12:
|
||||
self._last_motion = mouse_event.t
|
||||
self._last_y, self._last_t = mouse_event.pos.y, mouse_event.t
|
||||
if mouse_event.left_released and mouse_event.t - self._last_motion > 0.12:
|
||||
self._velocity = 0.0
|
||||
if event.left_released and not rl.check_collision_point_rec(event.pos, self._rect):
|
||||
if mouse_event.left_released and not rl.check_collision_point_rec(mouse_event.pos, self._rect):
|
||||
self._pressed = None
|
||||
self._offset = self._velocity = 0.0
|
||||
|
||||
|
||||
@@ -220,7 +220,9 @@ class GlobalRuntime:
|
||||
self.stage_effect = False
|
||||
self.centre_ack = None
|
||||
policy = getattr(getattr(self.controller, 'starpilot_extension', None), 'policy', None)
|
||||
self.live_manual = bool(manual_current or getattr(self.controller, 'flm_surface', None) is not None or
|
||||
self.live_manual = bool(manual_current or getattr(self.controller, 'manual_flm_effects', None) is not None or
|
||||
getattr(self.controller, 'manual_flm_status', 'not_configured') not in ('configured', 'not_configured') or
|
||||
getattr(self.controller, 'flm_surface', None) is not None or
|
||||
getattr(policy, 'surface', None) is not None)
|
||||
self.native_required = bool(native_required)
|
||||
self.native_witness = native_witness
|
||||
|
||||
@@ -105,6 +105,7 @@ def identity_for(CP, controller, params, conversion_version):
|
||||
return None, False, False
|
||||
manual = profile is not None and (profile.factor.mode == 'custom' or profile.friction.mode == 'custom' or
|
||||
profile.proportional_gain.mode == 'custom' or profile.flm is not None or
|
||||
profile.manual_coordinates is not None and any(v for _, v in profile.manual_coordinates.values) or
|
||||
profile.geometry is not None and (profile.geometry.ratio.mode == 'custom' or
|
||||
profile.geometry.full_delay.mode == 'custom'))
|
||||
baseline = serialize_document({str(CP.carFingerprint): profile}).hex() if profile is not None else None
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Explicit parked manual coordinates, separate from learned history and authority."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
|
||||
from openpilot.starpilot.flm.canonical_surface_adapter import NAMES, MANIFEST_SHA256, coordinate_bounds
|
||||
|
||||
|
||||
def context(cp, controller):
|
||||
from openpilot.starpilot.flm.runtime_adapter_registry import car_params_digest
|
||||
from openpilot.starpilot.lateral.controller_selection import ControllerMode
|
||||
if (str(cp.steerControlType) != 'torque' or cp.lateralTuning.which() != 'torque'
|
||||
or cp.passive or cp.notCar or cp.dashcamOnly):
|
||||
raise ValueError('Manual coordinates require an active torque controller')
|
||||
return car_params_digest(cp), str(ControllerMode(controller)), MANIFEST_SHA256
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManualCoordinates:
|
||||
context: tuple[str, str, str]
|
||||
values: tuple[tuple[str, float], ...]
|
||||
|
||||
def validate(self):
|
||||
if (type(self.context) is not tuple or len(self.context) != 3
|
||||
or any(type(v) is not str for v in self.context)
|
||||
or any(len(self.context[i]) != 64 or self.context[i].strip('0123456789abcdef') for i in (0, 2))
|
||||
or not self.context[1] or len(self.context[1]) > 32 or not self.context[1].isascii()
|
||||
or type(self.values) is not tuple or len(self.values) != len(NAMES)):
|
||||
raise ValueError('Invalid manual coordinate context')
|
||||
from openpilot.starpilot.lateral.controller_selection import ControllerMode
|
||||
ControllerMode(self.context[1])
|
||||
for name, row in zip(NAMES, self.values, strict=True):
|
||||
if type(row) is not tuple or len(row) != 2 or row[0] != name:
|
||||
raise ValueError('Exactly thirty ordered manual coordinates required')
|
||||
low, high = coordinate_bounds(name)
|
||||
if type(row[1]) not in (int, float) or not math.isfinite(row[1]) or not low <= row[1] <= high:
|
||||
raise ValueError('Manual coordinate outside the selected envelope')
|
||||
# Validate the complete composed surface as well as each individual value.
|
||||
from openpilot.starpilot.flm.canonical_surface_adapter import CanonicalSurfaceEffects
|
||||
CanonicalSurfaceEffects(dict(self.values[:1]), self.digest(), accumulated=dict(self.values))
|
||||
return self
|
||||
|
||||
def digest(self):
|
||||
return hashlib.sha256(json.dumps((self.context, self.values), separators=(',', ':'), allow_nan=False).encode()).hexdigest()
|
||||
|
||||
def document(self):
|
||||
self.validate()
|
||||
return {'context': list(self.context), 'values': dict(self.values)}
|
||||
|
||||
@classmethod
|
||||
def from_document(cls, value):
|
||||
if (type(value) is not dict or set(value) != {'context', 'values'} or type(value['context']) is not list
|
||||
or type(value['values']) is not dict or set(value['values']) != set(NAMES)):
|
||||
raise ValueError('Invalid manual coordinate document')
|
||||
return cls(tuple(value['context']), tuple((n, value['values'][n]) for n in NAMES)).validate()
|
||||
|
||||
|
||||
class ManualEffects:
|
||||
"""Human-selected bounded math; never a live trial or a native permission."""
|
||||
|
||||
def __init__(self, profile):
|
||||
from openpilot.starpilot.flm.canonical_surface_adapter import CanonicalSurfaceEffects
|
||||
if type(profile) is not ManualCoordinates:
|
||||
raise ValueError('Typed manual coordinates required')
|
||||
profile.validate()
|
||||
self.profile = profile
|
||||
self.source = CanonicalSurfaceEffects(dict(profile.values[:1]), profile.digest(), accumulated=dict(profile.values))
|
||||
self.original_effect_anchor = self.source.original_effect_anchor
|
||||
|
||||
def begin_frame(self):
|
||||
self.source.begin_frame(time.monotonic_ns())
|
||||
|
||||
def selected_stage(self, *args, **kwargs):
|
||||
return self.source.selected_stage(*args, **kwargs)
|
||||
|
||||
def selected_friction(self, *args, **kwargs):
|
||||
return self.source.selected_friction(*args, **kwargs)
|
||||
|
||||
def selected_deadzone(self, *args, **kwargs):
|
||||
return self.source.selected_deadzone(*args, **kwargs)
|
||||
|
||||
def selected_output(self, *args, **kwargs):
|
||||
return self.source.selected_output(*args, **kwargs)
|
||||
|
||||
|
||||
def load(cp, controller, params=None):
|
||||
"""Load once at controller startup. Unreadable is distinct from absent."""
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.starpilot.saved_source import read_saved
|
||||
from openpilot.starpilot.lateral.torque_settings import DOCUMENT_KEY, MAX_DOCUMENT_BYTES, parse_document
|
||||
params = Params() if params is None else params
|
||||
raw, readable = read_saved(params, DOCUMENT_KEY, MAX_DOCUMENT_BYTES)
|
||||
if not readable:
|
||||
return None, 'unreadable'
|
||||
if raw is None:
|
||||
return None, 'not_configured'
|
||||
try:
|
||||
profile = parse_document(raw).get(str(cp.carFingerprint))
|
||||
manual = None if profile is None else profile.manual_coordinates
|
||||
if manual is None or not any(v for _, v in manual.values):
|
||||
return None, 'not_configured'
|
||||
tune = cp.lateralTuning.torque
|
||||
if (profile.basis != (float(tune.latAccelFactor), float(tune.latAccelOffset), float(tune.friction))
|
||||
or profile.flm is not None or manual.context != context(cp, controller)):
|
||||
return None, 'context_changed'
|
||||
return ManualEffects(manual), 'configured'
|
||||
except (AttributeError, KeyError, TypeError, ValueError, OverflowError, UnicodeError):
|
||||
return None, 'invalid'
|
||||
@@ -337,12 +337,26 @@ class EffectReplaySource:
|
||||
return self.source.selected_output(*args, **kwargs)
|
||||
|
||||
|
||||
def _selected_source(parent):
|
||||
from openpilot.starpilot.flm.manual_coordinates import ManualEffects
|
||||
source = getattr(parent, 'manual_flm_effects', None)
|
||||
if source is not None:
|
||||
policy = getattr(getattr(parent, 'starpilot_extension', None), 'policy', None)
|
||||
if getattr(parent, 'flm_surface', None) is not None or getattr(policy, 'surface', None) is not None:
|
||||
return None
|
||||
return source if type(source) is ManualEffects else None
|
||||
return getattr(parent, 'automatic_flm_effects', None)
|
||||
|
||||
|
||||
def manual(parent):
|
||||
policy = getattr(getattr(parent, 'starpilot_extension', None), 'policy', None)
|
||||
return getattr(parent, 'flm_surface', None) is not None or getattr(policy, 'surface', None) is not None
|
||||
return (getattr(parent, 'manual_flm_effects', None) is not None
|
||||
or getattr(parent, 'flm_surface', None) is not None or getattr(policy, 'surface', None) is not None)
|
||||
|
||||
|
||||
def bounded_profile_on_limited_frame(parent):
|
||||
if getattr(parent, 'manual_flm_effects', None) is not None:
|
||||
return False
|
||||
# This is parameter shaping, not a native grant. The live owner must already
|
||||
# hold current source/native permission; replay uses the same bounded math.
|
||||
# Legacy residual owners and arbitrary callback objects remain inhibited.
|
||||
@@ -357,14 +371,14 @@ def bounded_profile_on_limited_frame(parent):
|
||||
|
||||
|
||||
def apply_selected_stage(parent, frame):
|
||||
source = getattr(parent, 'automatic_flm_effects', None)
|
||||
source = _selected_source(parent)
|
||||
if (
|
||||
source is None
|
||||
or not frame.valid()
|
||||
or not frame.active
|
||||
or frame.driver_override
|
||||
or (frame.limited and (frame.curvature_limited or not bounded_profile_on_limited_frame(parent)))
|
||||
or manual(parent)
|
||||
or (manual(parent) and source is not getattr(parent, 'manual_flm_effects', None))
|
||||
):
|
||||
return frame.baseline_ff, frame.baseline_threshold
|
||||
try:
|
||||
@@ -379,13 +393,13 @@ def apply_selected_stage(parent, frame):
|
||||
|
||||
|
||||
def apply_selected_friction(parent, cs, setpoint, baseline, *, active, limited, curvature_limited=False):
|
||||
source = getattr(parent, 'automatic_flm_effects', None)
|
||||
source = _selected_source(parent)
|
||||
if (
|
||||
source is None
|
||||
or not active
|
||||
or (limited and (curvature_limited or not bounded_profile_on_limited_frame(parent)))
|
||||
or cs.steeringPressed
|
||||
or manual(parent)
|
||||
or (manual(parent) and source is not getattr(parent, 'manual_flm_effects', None))
|
||||
or not finite(baseline)
|
||||
):
|
||||
return baseline
|
||||
@@ -399,8 +413,9 @@ def apply_selected_friction(parent, cs, setpoint, baseline, *, active, limited,
|
||||
|
||||
|
||||
def apply_selected_deadzone(parent, cs, vm, baseline_accel, requested_curvature, *, active, limited, curvature_limited=False):
|
||||
source = getattr(parent, 'automatic_flm_effects', None)
|
||||
if source is None or not active or (limited and (curvature_limited or not bounded_profile_on_limited_frame(parent))) or cs.steeringPressed or manual(parent):
|
||||
source = _selected_source(parent)
|
||||
if (source is None or not active or (limited and (curvature_limited or not bounded_profile_on_limited_frame(parent)))
|
||||
or cs.steeringPressed or (manual(parent) and source is not getattr(parent, 'manual_flm_effects', None))):
|
||||
return baseline_accel
|
||||
try:
|
||||
speed = float(cs.vEgo)
|
||||
@@ -415,13 +430,13 @@ def apply_selected_deadzone(parent, cs, vm, baseline_accel, requested_curvature,
|
||||
|
||||
|
||||
def apply_selected_output(parent, cs, vm, params, requested_curvature, result, *, active, limited, curvature_limited=False):
|
||||
source = getattr(parent, 'automatic_flm_effects', None)
|
||||
source = _selected_source(parent)
|
||||
if (
|
||||
source is None
|
||||
or not active
|
||||
or (limited and (curvature_limited or not bounded_profile_on_limited_frame(parent)))
|
||||
or cs.steeringPressed
|
||||
or manual(parent)
|
||||
or (manual(parent) and source is not getattr(parent, 'manual_flm_effects', None))
|
||||
or cs.vEgo < 0.1 * 0.44704
|
||||
):
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Manual edits are explicit bounded configuration, never accepted learning."""
|
||||
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from opendbc.car import structs
|
||||
from opendbc.car.hyundai.values import CAR
|
||||
from openpilot.starpilot.flm.canonical_surface_adapter import NAMES
|
||||
from openpilot.starpilot.flm.manual_coordinates import ManualCoordinates, ManualEffects, context, load
|
||||
from openpilot.starpilot.flm.selected_effects import apply_selected_stage
|
||||
from openpilot.starpilot.flm.torque_stages import StageFrame
|
||||
from openpilot.starpilot.lateral.controller_selection import default_selection
|
||||
from openpilot.starpilot.lateral.torque_settings import DOCUMENT_KEY, FieldChoice, PlatformProfile, parse_document, serialize_document
|
||||
from openpilot.starpilot.saved_source import read_saved
|
||||
from openpilot.starpilot.ui.feature_settings_state import FeatureSettingsRequest
|
||||
from openpilot.starpilot.ui.torque_feature import TorqueFeature
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cp():
|
||||
value = structs.CarParams.new_message()
|
||||
value.carFingerprint = str(CAR.HYUNDAI_IONIQ_6)
|
||||
value.brand = 'hyundai'
|
||||
value.steerControlType = 'torque'
|
||||
tune = value.lateralTuning.init('torque')
|
||||
tune.latAccelFactor, tune.latAccelOffset, tune.friction = 2.5, 0., .1
|
||||
return value
|
||||
|
||||
|
||||
class PrivateParams:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
(root / 'd').mkdir()
|
||||
def get_param_path(self, key):
|
||||
return str(self.root / 'd' / key)
|
||||
def put(self, *args, **kwargs):
|
||||
raise AssertionError('Manual editing must use the exact-source document writer')
|
||||
|
||||
|
||||
def basis(cp):
|
||||
t = cp.lateralTuning.torque
|
||||
return float(t.latAccelFactor), float(t.latAccelOffset), float(t.friction)
|
||||
|
||||
|
||||
def manual(cp, **changes):
|
||||
values = dict.fromkeys(NAMES, 0.)
|
||||
values.update(changes)
|
||||
return ManualCoordinates(context(cp, default_selection(cp).mode), tuple(values.items())).validate()
|
||||
|
||||
|
||||
def save(params, cp, value):
|
||||
profile = PlatformProfile(basis(cp), FieldChoice(), FieldChoice(), manual_coordinates=value)
|
||||
raw = serialize_document({str(cp.carFingerprint): profile})
|
||||
from pathlib import Path
|
||||
Path(params.get_param_path(DOCUMENT_KEY)).write_bytes(raw)
|
||||
return raw
|
||||
|
||||
|
||||
def saved_profile(params, cp):
|
||||
raw, readable = read_saved(params, DOCUMENT_KEY, 16384)
|
||||
assert readable and raw is not None
|
||||
return parse_document(raw)[str(cp.carFingerprint)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', NAMES)
|
||||
def test_each_of_thirty_values_roundtrips_without_learning_history(cp, name):
|
||||
value = manual(cp, **{name: .01})
|
||||
restored = ManualCoordinates.from_document(value.document())
|
||||
assert restored == value
|
||||
assert dict(restored.values)[name] == .01
|
||||
assert sum(v != 0 for _, v in restored.values) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bad', [True, float('nan'), float('inf'), -.001, .251])
|
||||
def test_deadband_refuses_wrong_types_nonfinite_and_bounds(cp, bad):
|
||||
with pytest.raises((ValueError, OverflowError)):
|
||||
manual(cp, center_deadband_low_deg=bad)
|
||||
|
||||
|
||||
def test_full_profile_is_required_and_ordered(cp):
|
||||
value = manual(cp)
|
||||
with pytest.raises(ValueError):
|
||||
replace(value, values=value.values[:-1]).validate()
|
||||
with pytest.raises(ValueError):
|
||||
replace(value, values=tuple(reversed(value.values))).validate()
|
||||
|
||||
|
||||
def test_opposite_speed_window_edges_stay_ordered_inside_original_limits(cp):
|
||||
value = manual(cp, curvy_turn_in_trim_speed_min=.25, curvy_turn_in_trim_speed_max=-.25)
|
||||
assert dict(value.values)['curvy_turn_in_trim_speed_min'] == .25
|
||||
assert dict(value.values)['curvy_turn_in_trim_speed_max'] == -.25
|
||||
|
||||
|
||||
def test_same_actual_context_loads_once_and_does_not_write_history(tmp_path, cp):
|
||||
params = PrivateParams(tmp_path)
|
||||
raw = save(params, cp, manual(cp, center_taper_max=.01))
|
||||
owner, status = load(cp, default_selection(cp).mode, params)
|
||||
assert type(owner) is ManualEffects and status == 'configured'
|
||||
assert dict(owner.profile.values)['center_taper_max'] == .01
|
||||
assert read_saved(params, DOCUMENT_KEY, 16384) == (raw, True)
|
||||
assert read_saved(params, 'AutomaticSteeringLiveTrials', 65536) == (None, True)
|
||||
|
||||
|
||||
def test_changed_vehicle_context_refuses_instead_of_retagging(tmp_path, cp):
|
||||
params = PrivateParams(tmp_path)
|
||||
raw = save(params, cp, manual(cp, center_taper_max=.01))
|
||||
cp.mass = 1800.
|
||||
assert load(cp, default_selection(cp).mode, params) == (None, 'context_changed')
|
||||
assert read_saved(params, DOCUMENT_KEY, 16384) == (raw, True)
|
||||
|
||||
|
||||
def test_unreadable_is_not_absence(tmp_path, cp):
|
||||
params = PrivateParams(tmp_path)
|
||||
(tmp_path / 'd' / DOCUMENT_KEY).mkdir()
|
||||
assert load(cp, default_selection(cp).mode, params) == (None, 'unreadable')
|
||||
|
||||
|
||||
def test_neutral_profile_does_not_activate_manual_override(tmp_path, cp):
|
||||
params = PrivateParams(tmp_path)
|
||||
save(params, cp, manual(cp))
|
||||
assert load(cp, default_selection(cp).mode, params) == (None, 'not_configured')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('active,override,limited,curvature_limited', [
|
||||
(False, False, False, False), (True, True, False, False),
|
||||
(True, False, True, False), (True, False, True, True),
|
||||
])
|
||||
def test_manual_does_not_bypass_inactive_override_or_limiter(cp, active, override, limited, curvature_limited):
|
||||
source = ManualEffects(manual(cp, center_taper_max=.01))
|
||||
source.begin_frame()
|
||||
parent = SimpleNamespace(manual_flm_effects=source, flm_surface=None, starpilot_extension=None, automatic_flm_effects=None)
|
||||
frame = StageFrame(15., 0., 0., .5, .3, active, override, limited, curvature_limited)
|
||||
assert apply_selected_stage(parent, frame) == (.5, .3)
|
||||
|
||||
|
||||
def test_manual_uses_existing_selected_stage_with_no_auto_owner(cp):
|
||||
source = ManualEffects(manual(cp, center_taper_max=.01))
|
||||
source.begin_frame()
|
||||
parent = SimpleNamespace(manual_flm_effects=source, flm_surface=None, starpilot_extension=None, automatic_flm_effects=None)
|
||||
frame = StageFrame(15., 0., 0., .5, .3, True, False, False)
|
||||
ff, threshold = apply_selected_stage(parent, frame)
|
||||
assert 0 < ff < .5
|
||||
assert .3 <= threshold <= .375
|
||||
assert parent.automatic_flm_effects is None
|
||||
|
||||
|
||||
def feature(cp, params, state):
|
||||
capability = (str(cp.carFingerprint), 'hyundai', 'torque', 'torque', False, *basis(cp), None)
|
||||
def raw(key):
|
||||
return read_saved(params, key, 16384)[0]
|
||||
owner = SimpleNamespace(params=params, authority=lambda group: state['parked'] if group == 'parked_preferences' else True,
|
||||
vehicle_fingerprint=lambda: str(cp.carFingerprint), vehicle_params=lambda: cp,
|
||||
_capability=lambda group: capability, _raw=raw, _readable=lambda key: read_saved(params, key, 16384)[1],
|
||||
_dependents=lambda *keys: tuple((key, raw(key)) for key in keys))
|
||||
return TorqueFeature(owner), capability
|
||||
|
||||
|
||||
def request(row, value):
|
||||
return FeatureSettingsRequest(row.key, row.source, str(value), capability=row.capability,
|
||||
related_source=row.related_source, dependencies=row.dependencies, vehicle_fingerprint=row.vehicle_fingerprint)
|
||||
|
||||
|
||||
def test_parked_edit_exact_readback_and_single_value_reset(tmp_path, cp):
|
||||
params = PrivateParams(tmp_path)
|
||||
state = {'parked': True}
|
||||
editor, capability = feature(cp, params, state)
|
||||
rows = editor.manual_rows(capability, True)
|
||||
assert len(rows) == 30 and all(row.available for row in rows)
|
||||
row = next(r for r in rows if r.key.endswith(':center_taper_max'))
|
||||
row = replace(row, vehicle_fingerprint=str(cp.carFingerprint))
|
||||
assert editor.apply(request(row, 1.))
|
||||
profile = saved_profile(params, cp)
|
||||
assert profile.manual_coordinates is not None
|
||||
assert dict(profile.manual_coordinates.values)['center_taper_max'] == .01
|
||||
row = next(r for r in editor.manual_rows(capability, True) if r.key.endswith(':center_taper_max'))
|
||||
assert editor.apply(request(replace(row, vehicle_fingerprint=str(cp.carFingerprint)), 0.))
|
||||
assert saved_profile(params, cp).manual_coordinates is None
|
||||
|
||||
|
||||
def test_stale_source_and_onroad_writes_are_refused(tmp_path, cp):
|
||||
params = PrivateParams(tmp_path)
|
||||
state = {'parked': True}
|
||||
editor, capability = feature(cp, params, state)
|
||||
row = replace(editor.manual_rows(capability, True)[0], vehicle_fingerprint=str(cp.carFingerprint))
|
||||
assert editor.apply(request(row, 1.))
|
||||
assert not editor.apply(request(row, 2.))
|
||||
current = replace(editor.manual_rows(capability, True)[0], vehicle_fingerprint=str(cp.carFingerprint))
|
||||
state['parked'] = False
|
||||
assert not editor.apply(request(current, 2.))
|
||||
profile = saved_profile(params, cp)
|
||||
assert profile.manual_coordinates is not None
|
||||
assert dict(profile.manual_coordinates.values)[NAMES[0]] == .01
|
||||
|
||||
|
||||
def test_invalid_saved_document_is_not_replaced_by_a_new_manual_tune(tmp_path, cp):
|
||||
params = PrivateParams(tmp_path)
|
||||
(tmp_path / 'd' / DOCUMENT_KEY).write_bytes(b'{broken')
|
||||
editor, capability = feature(cp, params, {'parked': True})
|
||||
assert not any(row.available for row in editor.manual_rows(capability, True))
|
||||
assert (tmp_path / 'd' / DOCUMENT_KEY).read_bytes() == b'{broken'
|
||||
|
||||
|
||||
def test_authority_loss_under_native_lock_refuses_and_removes_stage(tmp_path, cp, monkeypatch):
|
||||
from openpilot.starpilot import saved_document
|
||||
params = PrivateParams(tmp_path)
|
||||
state = {'parked': True}
|
||||
editor, capability = feature(cp, params, state)
|
||||
row = replace(editor.manual_rows(capability, True)[0], vehicle_fingerprint=str(cp.carFingerprint))
|
||||
original = saved_document.acquire_native_lock
|
||||
def acquire(fd):
|
||||
original(fd)
|
||||
state['parked'] = False
|
||||
monkeypatch.setattr(saved_document, 'acquire_native_lock', acquire)
|
||||
assert not editor.apply(request(row, 1.))
|
||||
assert read_saved(params, DOCUMENT_KEY, 16384) == (None, True)
|
||||
assert not list(tmp_path.glob('.manual-steering-*'))
|
||||
|
||||
|
||||
def test_source_change_under_native_lock_is_not_overwritten(tmp_path, cp, monkeypatch):
|
||||
from openpilot.starpilot import saved_document
|
||||
from pathlib import Path
|
||||
params = PrivateParams(tmp_path)
|
||||
editor, capability = feature(cp, params, {'parked': True})
|
||||
row = replace(editor.manual_rows(capability, True)[0], vehicle_fingerprint=str(cp.carFingerprint))
|
||||
other = serialize_document({str(cp.carFingerprint): PlatformProfile(basis(cp), FieldChoice(), FieldChoice('custom', .12))})
|
||||
original = saved_document.acquire_native_lock
|
||||
def acquire(fd):
|
||||
original(fd)
|
||||
Path(params.get_param_path(DOCUMENT_KEY)).write_bytes(other)
|
||||
monkeypatch.setattr(saved_document, 'acquire_native_lock', acquire)
|
||||
assert not editor.apply(request(row, 1.))
|
||||
assert read_saved(params, DOCUMENT_KEY, 16384) == (other, True)
|
||||
assert not list(tmp_path.glob('.manual-steering-*'))
|
||||
|
||||
|
||||
def test_reset_all_uses_original_row_request_and_requires_confirmation(tmp_path, cp):
|
||||
from openpilot.starpilot.ui.feature_settings_state import row_change
|
||||
params = PrivateParams(tmp_path)
|
||||
save(params, cp, manual(cp, ff_gain_left=.01, center_taper_max=.01))
|
||||
editor, capability = feature(cp, params, {'parked': True})
|
||||
reset = replace(editor.manual_rows(capability, True)[-1], vehicle_fingerprint=str(cp.carFingerprint))
|
||||
unconfirmed = row_change(reset)
|
||||
assert unconfirmed is not None and unconfirmed.value == 'Reset'
|
||||
assert not editor.apply(unconfirmed)
|
||||
assert editor.apply(replace(unconfirmed, confirmation=True))
|
||||
assert saved_profile(params, cp).manual_coordinates is None
|
||||
|
||||
|
||||
def test_replay_controller_construction_does_not_read_manual_preferences(cp, monkeypatch):
|
||||
from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque
|
||||
from openpilot.starpilot.flm import manual_coordinates
|
||||
def forbidden(*args, **kwargs):
|
||||
raise AssertionError('Only the actual Controls owner may load manual configuration')
|
||||
monkeypatch.setattr(manual_coordinates, 'load', forbidden)
|
||||
ci = SimpleNamespace(
|
||||
torque_from_lateral_accel=lambda: lambda value, tune: value / tune.latAccelFactor,
|
||||
lateral_accel_from_torque=lambda: lambda value, tune: value * tune.latAccelFactor)
|
||||
controller = LatControlTorque(cp.as_reader(), ci, .01, controller_mode=default_selection(cp).mode)
|
||||
assert controller.manual_flm_effects is None
|
||||
assert controller.manual_flm_status == 'not_configured'
|
||||
|
||||
|
||||
def test_next_startup_loads_new_saved_values_without_mutating_running_profile(tmp_path, cp):
|
||||
params = PrivateParams(tmp_path)
|
||||
save(params, cp, manual(cp, center_taper_max=.01))
|
||||
original, status = load(cp, default_selection(cp).mode, params)
|
||||
assert status == 'configured'
|
||||
save(params, cp, manual(cp, center_taper_max=.02))
|
||||
following, status = load(cp, default_selection(cp).mode, params)
|
||||
assert status == 'configured'
|
||||
assert dict(original.profile.values)['center_taper_max'] == .01
|
||||
assert dict(following.profile.values)['center_taper_max'] == .02
|
||||
|
||||
|
||||
@pytest.mark.parametrize('saved,expected', [(False, True), (True, False)])
|
||||
def test_actual_controls_startup_holds_native_learning_only_for_manual_tune(tmp_path, cp, monkeypatch, saved, expected):
|
||||
from openpilot.selfdrive.controls import controlsd
|
||||
params = PrivateParams(tmp_path)
|
||||
if saved:
|
||||
save(params, cp, manual(cp, center_taper_max=.01))
|
||||
(tmp_path / 'd' / 'ForceAutoTuneOff').write_bytes(b'0')
|
||||
monkeypatch.setattr(controlsd, 'learning_allowed', lambda *args, **kwargs: True)
|
||||
owner = object.__new__(controlsd.Controls)
|
||||
owner.CP = cp
|
||||
monkeypatch.setattr(owner, 'params', params, raising=False)
|
||||
owner.lateral_controller_selection = default_selection(cp)
|
||||
effects, status = owner._configure_manual_tune()
|
||||
assert owner.torque_learning_allowed is expected
|
||||
assert (effects is not None) is saved
|
||||
assert status == ('configured' if saved else 'not_configured')
|
||||
assert (tmp_path / 'd' / 'ForceAutoTuneOff').read_bytes() == b'0'
|
||||
|
||||
|
||||
def test_actual_controls_startup_does_not_treat_unreadable_as_automatic_permission(tmp_path, cp, monkeypatch):
|
||||
from openpilot.selfdrive.controls import controlsd
|
||||
params = PrivateParams(tmp_path)
|
||||
(tmp_path / 'd' / DOCUMENT_KEY).mkdir()
|
||||
monkeypatch.setattr(controlsd, 'learning_allowed', lambda *args, **kwargs: True)
|
||||
owner = object.__new__(controlsd.Controls)
|
||||
owner.CP = cp
|
||||
monkeypatch.setattr(owner, 'params', params, raising=False)
|
||||
owner.lateral_controller_selection = default_selection(cp)
|
||||
assert owner._configure_manual_tune() == (None, 'unreadable')
|
||||
assert owner.torque_learning_allowed is False
|
||||
@@ -10,6 +10,18 @@ from openpilot.starpilot.flm.tests.test_live_tracking import episode
|
||||
from openpilot.starpilot.flm.tests.test_tracking_source_context import context, refresh
|
||||
|
||||
|
||||
def offer_state(owner):
|
||||
state = owner.offer_state
|
||||
assert state is not None
|
||||
return state
|
||||
|
||||
|
||||
def selector_hint(owner):
|
||||
hint = owner.selector_hint
|
||||
assert hint is not None
|
||||
return hint
|
||||
|
||||
|
||||
class Drive:
|
||||
"""Declared whole episodes and typed ACK operands, never vehicle evidence."""
|
||||
def __init__(self, initial=.0):
|
||||
@@ -41,7 +53,7 @@ class Drive:
|
||||
profile_sha256=profile or o._hash(o.accepted), goal_rms=goal, normalization_accel=max(.1, goal),
|
||||
goal_shape=shape or (goal / max(.1, goal),) * 8)
|
||||
refresh(o, self.now)
|
||||
if renew and o.offer_state is not None and o.offer_state['phase'] == 'rollback':
|
||||
if renew and o.offer_state is not None and offer_state(o)['phase'] == 'rollback':
|
||||
assert self.ack()
|
||||
return row, o.observe_episode(row, now_ns=self.now)
|
||||
|
||||
@@ -49,11 +61,11 @@ class Drive:
|
||||
o = self.owner
|
||||
for _ in range(20):
|
||||
self.row(name, **row_values)
|
||||
if o.offer_state is not None and o.offer_state['phase'] == 'trial':
|
||||
if o.offer_state is not None and offer_state(o)['phase'] == 'trial':
|
||||
offer = o.offer(self.now)
|
||||
assert offer is not None
|
||||
return offer
|
||||
pytest.fail('No bounded source trial selected')
|
||||
raise AssertionError('No bounded source trial selected')
|
||||
|
||||
def batch(self, offer, name='ff_gain_left', **row_values):
|
||||
rows = []
|
||||
@@ -68,9 +80,9 @@ def return_phase(d, name='ff_gain_left', **values):
|
||||
assert d.ack(trial)
|
||||
d.batch(trial, name, error=.03, goal=values.get('goal', .09), shape=values.get('shape'))
|
||||
o = d.owner
|
||||
assert o.offer_state['phase'] == 'rollback', 'A first positive batch must return to A, not confirm B'
|
||||
assert offer_state(o)['phase'] == 'rollback', 'A first positive batch must return to A, not confirm B'
|
||||
assert o.source_recheck is not None
|
||||
assert not o.offer_state['accepted'] and o.source_recheck.first_ack_ns == 0
|
||||
assert not offer_state(o)['accepted'] and o.source_recheck.first_ack_ns == 0
|
||||
return trial
|
||||
|
||||
|
||||
@@ -95,14 +107,14 @@ def test_each_coordinate_requires_return_baseline_and_fresh_reapplied_performanc
|
||||
initial = dict(o.accepted)
|
||||
first, second = reapply_phase(d, name)
|
||||
assert o.accepted == initial and o.settled_ns is None and not o.future
|
||||
assert len(o.before) == 3 and len(o.offer_state['source_rechecked_before']) == 3
|
||||
assert len(o.before) == 3 and len(offer_state(o)['source_rechecked_before']) == 3
|
||||
assert not d.ack(first) # Old B1 trial/sequence cannot acknowledge B2.
|
||||
early, admitted = d.row(name, profile=second.target_sha256, error=.03)
|
||||
assert not admitted and not o.future
|
||||
assert d.ack(second)
|
||||
for index in range(3):
|
||||
d.row(name, profile=second.target_sha256, error=.03)
|
||||
assert o.offer_state['phase'] == ('confirmed' if index == 2 else 'trial')
|
||||
assert offer_state(o)['phase'] == ('confirmed' if index == 2 else 'trial')
|
||||
assert o.accepted == initial
|
||||
confirmed = o.offer(d.now)
|
||||
assert confirmed is not None and confirmed.phase == 'confirmed'
|
||||
@@ -149,12 +161,12 @@ def test_null_response_cannot_confirm_when_fresh_baseline_has_same_apparent_bene
|
||||
assert d.ack(trial)
|
||||
d.batch(trial, **new)
|
||||
o = d.owner
|
||||
assert o.offer_state['phase'] == 'rollback', 'An easier input cannot immediately confirm an unchanged-response B'
|
||||
assert offer_state(o)['phase'] == 'rollback', 'An easier input cannot immediately confirm an unchanged-response B'
|
||||
assert o.source_recheck is not None
|
||||
assert not any(o.accepted.values()) and d.ack()
|
||||
for _ in range(3):
|
||||
d.row(**new)
|
||||
assert o.source_recheck is None and o.offer_state['phase'] == 'rollback' and o.offer_state['accepted']
|
||||
assert o.source_recheck is None and offer_state(o)['phase'] == 'rollback' and offer_state(o)['accepted']
|
||||
assert o.reason == 'return_baseline_did_not_confirm_improvement'
|
||||
assert not any(o.accepted.values())
|
||||
|
||||
@@ -190,7 +202,7 @@ def test_original_deadline_ends_every_recheck_transition(phase):
|
||||
if phase == 'confirmed_ack_pending':
|
||||
d.row(profile=target.target_sha256, error=.03)
|
||||
d.row(profile=target.target_sha256, error=.03)
|
||||
assert o.offer_state['phase'] == 'confirmed'
|
||||
assert offer_state(o)['phase'] == 'confirmed'
|
||||
d.now = first.deadline_ns + 1
|
||||
refresh(o,d.now)
|
||||
offer = o.offer(d.now)
|
||||
@@ -206,7 +218,7 @@ def test_actual_gross_oscillation_rejection_remains_terminal(stage):
|
||||
offer = d.trial() if stage == 'first_trial' else reapply_phase(d)[1]
|
||||
assert d.ack(offer)
|
||||
d.row(profile=offer.target_sha256, error=.03, reversal=10.)
|
||||
assert o.reason == 'trial_tracking_or_oscillation_worse' and o.offer_state['phase'] == 'rollback'
|
||||
assert o.reason == 'trial_tracking_or_oscillation_worse' and offer_state(o)['phase'] == 'rollback'
|
||||
assert getattr(o,'source_recheck',None) is None and o.paused_intent is None and not any(o.accepted.values())
|
||||
|
||||
|
||||
@@ -217,7 +229,7 @@ def test_interrupted_return_baseline_never_retains_provisional_candidate(reason,
|
||||
assert d.ack()
|
||||
d.row(error=.045)
|
||||
d.owner.interrupt(reason,context_changed=changed)
|
||||
assert d.owner.source_recheck is None and d.owner.offer_state['phase'] == 'rollback'
|
||||
assert d.owner.source_recheck is None and offer_state(d.owner)['phase'] == 'rollback'
|
||||
assert all(value == .002 for value in d.owner.accepted.values())
|
||||
|
||||
|
||||
@@ -230,17 +242,17 @@ def test_pretrial_numbers_wait_for_original_demand_without_an_offer_or_expiry_re
|
||||
break
|
||||
assert o.discovery is not None
|
||||
discovery = o.discovery
|
||||
selected,expires = o.selector_hint['selected_ns'],o.selector_hint['expires_ns']
|
||||
selected,expires = selector_hint(o)['selected_ns'],selector_hint(o)['expires_ns']
|
||||
d.row() # First whole independent baseline.
|
||||
ids = tuple(v.episode_id for v in o.comparison)
|
||||
assert len(ids) == 1
|
||||
d.row(goal=.4,kind='large')
|
||||
assert o.discovery is discovery and tuple(v.episode_id for v in o.comparison) == ids
|
||||
assert o.selector_hint['selected_ns'] == selected and o.selector_hint['expires_ns'] == expires
|
||||
assert selector_hint(o)['selected_ns'] == selected and selector_hint(o)['expires_ns'] == expires
|
||||
assert o.offer(d.now) is None
|
||||
for _ in range(2):
|
||||
d.row()
|
||||
assert o.offer_state is not None and o.offer_state['phase'] == 'trial'
|
||||
assert o.offer_state is not None and offer_state(o)['phase'] == 'trial'
|
||||
assert {v.episode_id for v in o.before}.issuperset(ids)
|
||||
|
||||
|
||||
@@ -254,7 +266,7 @@ def test_return_baseline_requires_its_own_current_exact_ack(bad):
|
||||
'wrong_hash': {'target_sha256': first.target_sha256},
|
||||
'stale': {'control_ns': d.now-100_000_001}}[bad]
|
||||
assert not d.ack(offer, **changes)
|
||||
assert o.source_recheck.first_ack_ns == 0 and not o.offer_state['accepted']
|
||||
assert o.source_recheck.first_ack_ns == 0 and not offer_state(o)['accepted']
|
||||
row, admitted = d.row(error=.045, renew=False)
|
||||
assert not admitted and not o.source_recheck.baseline
|
||||
assert row.episode_id not in {v.episode_id for v in o.source_recheck.before}
|
||||
@@ -297,7 +309,7 @@ def test_recheck_numeric_state_does_not_survive_identity_model_or_baseline_chang
|
||||
o.accepted['ff_gain_left'] = .002
|
||||
o.offer(d.now)
|
||||
assert o.source_recheck is None
|
||||
assert o.offer_state['phase'] == 'rollback'
|
||||
assert offer_state(o)['phase'] == 'rollback'
|
||||
|
||||
|
||||
def test_zero_error_return_baseline_is_not_censored_to_keep_candidate():
|
||||
@@ -308,7 +320,7 @@ def test_zero_error_return_baseline_is_not_censored_to_keep_candidate():
|
||||
d.row(error=0.)
|
||||
o = d.owner
|
||||
assert o.source_recheck is None and not any(o.accepted.values())
|
||||
assert o.offer_state['phase'] == 'rollback' and o.offer_state['accepted']
|
||||
assert offer_state(o)['phase'] == 'rollback' and offer_state(o)['accepted']
|
||||
assert o.reason == 'return_baseline_did_not_confirm_improvement'
|
||||
|
||||
|
||||
@@ -332,7 +344,7 @@ def test_missing_current_response_witness_cannot_advance_any_recheck_transition(
|
||||
d.now += 100_000_001
|
||||
assert o.offer(d.now) is None
|
||||
assert o.source_recheck is None and not any(o.accepted.values())
|
||||
assert o.offer_state is None or o.offer_state['phase'] == 'rollback'
|
||||
assert o.offer_state is None or offer_state(o)['phase'] == 'rollback'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('cause',['expiry','context'])
|
||||
@@ -349,7 +361,7 @@ def test_waiting_pretrial_comparison_cannot_renew_or_survive_changed_context(cau
|
||||
old_ids = {v.episode_id for v in o.comparison}
|
||||
assert old_ids
|
||||
if cause == 'expiry':
|
||||
d.now = o.selector_hint['expires_ns']+1
|
||||
d.now = selector_hint(o)['expires_ns']+1
|
||||
d.row()
|
||||
else:
|
||||
o.interrupt('changed_context',context_changed=True)
|
||||
|
||||
@@ -52,6 +52,8 @@ def apply_stages(parent, cs, setpoint, jerk, ff, threshold, *, active, limited,
|
||||
observer = getattr(parent, 'automatic_flm_observation', None)
|
||||
if observer is not None and frame.valid():
|
||||
observer.observe_stage(frame, ff, (0.0, 0.0))
|
||||
if getattr(parent, 'manual_flm_effects', None) is not None:
|
||||
return apply_selected_stage(parent, frame)
|
||||
if source is None:
|
||||
return ff, threshold
|
||||
if not frame.valid() or not frame.active or frame.driver_override:
|
||||
|
||||
@@ -252,6 +252,10 @@ def purpose(row):
|
||||
return 'Selects camera crop dimensions matching the source camera image.'
|
||||
if key.startswith('pip:mask:'):
|
||||
return 'Moves the camera-image crop center along the indicated axis.'
|
||||
if key.startswith('torque:surface:'):
|
||||
return ('Removes the thirty manual steering overrides while keeping saved Auto Learn history.'
|
||||
if key == 'torque:surface:reset' else
|
||||
'Adjusts this manual steering correction.')
|
||||
if key.startswith('torque:'):
|
||||
field = key.split(':')[1]
|
||||
return {'factor': 'Sets the lateral-acceleration-to-torque conversion for compatible steering controllers.',
|
||||
@@ -279,6 +283,8 @@ def description(row):
|
||||
suffix = INITIALIZATION.get(row.key, '')
|
||||
if row.key.startswith('lane_change:') and row.key not in ('lane_change:close_gap', 'lane_change:close_gap_seconds', 'lane_change:reset'):
|
||||
suffix = 'Changes apply when the current signal and maneuver end; turning assistance off cancels it after the preference refresh.'
|
||||
if row.key.startswith('torque:surface:'):
|
||||
suffix = row.reason.strip()
|
||||
if row.key.startswith('torque:') and row.key.split(':')[1] in ('factor', 'friction', 'gain'):
|
||||
suffix = ('First activation of a manual tuning owner requires a driving-process restart. An ' +
|
||||
'already active owner refreshes saved adjustments during the drive.')
|
||||
|
||||
@@ -41,7 +41,7 @@ from openpilot.starpilot.ui.vasm_owner import ANNOTATION as VASM_ANNOTATION, RES
|
||||
|
||||
PAGES = frozenset(("hub", "localization", "developer", "vehicle", "slc", "lane", "lane_change", "profiles", "personalities",
|
||||
"traffic", "aggressive", "standard", "relaxed",
|
||||
"curve", "torque", "torque/bluepilot", "aol", "wheel", "appearance", "display", "data", "power", "sounds", "pip", "sentry", "vasm",
|
||||
"curve", "torque", "tuning", "torque/bluepilot", "aol", "wheel", "appearance", "display", "data", "power", "sounds", "pip", "sentry", "vasm",
|
||||
"conditional", "conditional/cem", "conditional/ccm",
|
||||
*(f"{name}/{category}" for name in ("traffic", "aggressive", "standard", "relaxed")
|
||||
for category in ("acceleration", "braking", "following"))))
|
||||
@@ -393,6 +393,7 @@ def _projection(row: FeatureRow) -> dict:
|
||||
row.key in (PIP_RESET, PIP_EDITOR, VASM_RESET, VASM_ANNOTATION) or row.key.startswith(PIP_FORMAT_PREFIX)),
|
||||
"repairValue": row.repair_value, "defaultValue": row.default_value, "resetAvailable": row_default(row) is not None,
|
||||
"displayScale": "lane-strength" if row.key == "LaneCenteringStrength" else None,
|
||||
**({"coordinate": row.key.removeprefix("torque:surface:")} if row.key.startswith("torque:surface:") and row.key != "torque:surface:reset" else {}),
|
||||
"heading": is_heading(row.key)}
|
||||
|
||||
|
||||
@@ -457,6 +458,8 @@ def _question(row: FeatureRow, request: FeatureSettingsRequest) -> str:
|
||||
if key in ("profile:global_acceleration", "profile:global_braking"):
|
||||
return (f"Save {row.label.lower()} as {request.value}? Personalities set to Selected Profile follow this choice; " +
|
||||
"explicit personality overrides stay unchanged.")
|
||||
if key == "torque:surface:reset":
|
||||
return "Remove the thirty manual overrides for this vehicle? Saved Auto Learn history is kept."
|
||||
if key == "torque_gain_rebase":
|
||||
return "Apply the saved steering response to the selected controller and current vehicle tune? Friction and other models stay unchanged."
|
||||
if key == "torque_rebase":
|
||||
@@ -788,6 +791,8 @@ class SettingsGateway:
|
||||
if direction != 0:
|
||||
raise SettingsChanged("Invalid action")
|
||||
value = ("Off" if row.value == "On" else "On") if row.key == SETUP_ACTION else "confirm"
|
||||
if row.key == "torque:surface:reset":
|
||||
value = "Reset"
|
||||
if row.key == VASM_ANNOTATION:
|
||||
if draft is None:
|
||||
raise SettingsChanged("Camera regions are required")
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Shared declared-context HTTP fixture; no physical vehicle evidence."""
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import http.client
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from opendbc.car.structs import car
|
||||
from opendbc.car.toyota.interface import CarInterface as ToyotaInterface
|
||||
from opendbc.car.toyota.values import CAR as ToyotaCar
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.starpilot.galaxy.settings import AuthorityContext, SettingsGateway
|
||||
from openpilot.starpilot.galaxy.access import GalaxyAccessOwner
|
||||
from openpilot.starpilot.galaxy.server import make_server
|
||||
|
||||
|
||||
class MutableContext:
|
||||
def __init__(self, cp):
|
||||
self.value = AuthorityContext(True, cp, b"verified-cp")
|
||||
|
||||
def sample(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class SettingsHttpFixture(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.params = Params(temporary.name)
|
||||
native = ToyotaInterface.get_non_essential_params(ToyotaCar.TOYOTA_COROLLA_TSS2)
|
||||
cp = SimpleNamespace(carFingerprint="TOYOTA_COROLLA_TSS2", openpilotLongitudinalControl=True,
|
||||
pcmCruise=False, notCar=False, dashcamOnly=False, passive=False,
|
||||
brand="toyota", steerControlType=native.steerControlType, carVin="",
|
||||
lateralTuning=native.lateralTuning, minSteerSpeed=native.minSteerSpeed, safetyConfigs=native.safetyConfigs,
|
||||
transmissionType=car.CarParams.TransmissionType.automatic)
|
||||
self.context = MutableContext(cp)
|
||||
self.gateway = SettingsGateway(self.params, self.context)
|
||||
self.access = GalaxyAccessOwner(Path(temporary.name) / "access")
|
||||
self.assertTrue(self.access.configure("password123", lambda: True))
|
||||
self.server = make_server(port=0, owner=self.access, settings=self.gateway,
|
||||
parked=lambda: self.context.sample().parked)
|
||||
self.worker = threading.Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True)
|
||||
self.worker.start()
|
||||
self.addCleanup(self.stop)
|
||||
|
||||
def stop(self):
|
||||
self.server.shutdown()
|
||||
self.worker.join(timeout=2)
|
||||
self.server.server_close()
|
||||
|
||||
def request(self, path, *, payload=None, cookie="", origin=None):
|
||||
connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=3)
|
||||
headers = {"Cookie": cookie}
|
||||
if payload is not None:
|
||||
headers.update({"Content-Type": "application/json",
|
||||
"Origin": origin if origin is not None else f"http://127.0.0.1:{self.server.server_port}"})
|
||||
try:
|
||||
connection.request("POST" if payload is not None else "GET", path,
|
||||
body=json.dumps(payload) if payload is not None else None, headers=headers)
|
||||
result = connection.getresponse()
|
||||
return result.status, json.loads(result.read()), dict(result.getheaders())
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def login(self):
|
||||
status, _, headers = self.request("/api/auth/login", payload={"password": "password123"})
|
||||
self.assertEqual(status, 200)
|
||||
return headers["Set-Cookie"].split(";", 1)[0]
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Manual reset uses the real HTTP preview/confirm path in private settings."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.starpilot.galaxy.settings import AuthorityContext
|
||||
from openpilot.starpilot.galaxy.tests.settings_http_fixture import SettingsHttpFixture
|
||||
from openpilot.starpilot.saved_source import read_saved
|
||||
|
||||
|
||||
class ManualResetHttpTest(SettingsHttpFixture):
|
||||
def test_manual_reset_http_uses_row_choice_and_preserves_history(self):
|
||||
from opendbc.car.hyundai.interface import CarInterface
|
||||
from opendbc.car.hyundai.values import CAR
|
||||
from openpilot.starpilot.lateral.torque_settings import DOCUMENT_KEY, parse_document
|
||||
|
||||
cp = CarInterface.get_non_essential_params(CAR.HYUNDAI_IONIQ_6).as_reader()
|
||||
self.context.value = AuthorityContext(True, cp, cp.as_builder().to_bytes())
|
||||
history = Path(self.params.get_param_path("AutomaticSteeringLiveTrials"))
|
||||
history.write_bytes(b"unit-only inert history preservation sentinel")
|
||||
cookie = self.login()
|
||||
status, page, _ = self.request("/api/settings/pages/tuning", cookie=cookie)
|
||||
self.assertEqual(status, 200)
|
||||
index = next(i for i, row in enumerate(page["rows"]) if row.get("coordinate") == "center_taper_max")
|
||||
status, intent, _ = self.request("/api/settings/preview", cookie=cookie,
|
||||
payload={"view": page["view"], "row": index, "value": 1})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(self.request("/api/settings/confirm", cookie=cookie,
|
||||
payload={"intent": intent["intent"], "confirmed": True})[:2], (200, {"saved": True}))
|
||||
raw, readable = read_saved(self.params, DOCUMENT_KEY, 16384)
|
||||
self.assertTrue(readable)
|
||||
assert raw is not None
|
||||
manual = parse_document(raw)[str(cp.carFingerprint)].manual_coordinates
|
||||
assert manual is not None
|
||||
self.assertEqual(dict(manual.values)["center_taper_max"], .01)
|
||||
|
||||
status, page, _ = self.request("/api/settings/pages/tuning", cookie=cookie)
|
||||
self.assertEqual(status, 200)
|
||||
index = next(i for i, row in enumerate(page["rows"]) if row["label"] == "Reset manual tune")
|
||||
status, intent, _ = self.request("/api/settings/preview", cookie=cookie,
|
||||
payload={"view": page["view"], "row": index, "direction": 0})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(intent["proposed"], "Reset")
|
||||
self.assertEqual(self.request("/api/settings/confirm", cookie=cookie,
|
||||
payload={"intent": intent["intent"], "confirmed": True})[:2], (200, {"saved": True}))
|
||||
raw, readable = read_saved(self.params, DOCUMENT_KEY, 16384)
|
||||
self.assertTrue(readable)
|
||||
assert raw is not None
|
||||
self.assertIsNone(parse_document(raw)[str(cp.carFingerprint)].manual_coordinates)
|
||||
self.assertEqual(history.read_bytes(), b"unit-only inert history preservation sentinel")
|
||||
status, page, _ = self.request("/api/settings/pages/tuning", cookie=cookie)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertFalse(any(row["label"] == "Reset manual tune" for row in page["rows"]))
|
||||
coordinates = [row for row in page["rows"] if row.get("coordinate")]
|
||||
self.assertEqual(len(coordinates), 30)
|
||||
self.assertTrue(all(float(row["value"]) == 0 for row in coordinates))
|
||||
@@ -43,6 +43,19 @@ let history = []
|
||||
for (let i = 1; i <= 260; i++) history = appendPlotSample(history, payload(i), i * 750)
|
||||
assert.equal(history.length, MAX_POINTS)
|
||||
assert.equal(history[0].index, 21)
|
||||
|
||||
// Optional source ages must not move the shared observation clock backwards.
|
||||
let variedAge = []
|
||||
for (const [i, age] of [.1, 1.2, .1, .1, .1].entries()) {
|
||||
variedAge = appendPlotSample(variedAge, validatePlotPayload(payload(i + 1, { sampleAgeSeconds: age })), 1000 + i * 750)
|
||||
}
|
||||
assert.deepEqual(variedAge.map(sample => sample.at), [1000, 1750, 2500, 3250, 4000])
|
||||
assert.deepEqual(chartGeometry(variedAge, ['desiredLateralAccel', 'actualLateralAccel']).paths.map(paths => paths.length), [1, 1])
|
||||
const missingObservedInterval = variedAge.map((sample, index) => ({ ...sample, at: sample.at + (index >= 2 ? 2000 : 0) }))
|
||||
assert.deepEqual(chartGeometry(missingObservedInterval, ['desiredLateralAccel', 'actualLateralAccel']).paths.map(paths => paths.length), [2, 2])
|
||||
const unavailableSignal = variedAge.map((sample, index) => index === 2 ? { ...sample, actualLateralAccel: null } : sample)
|
||||
assert.deepEqual(chartGeometry(unavailableSignal, ['desiredLateralAccel', 'actualLateralAccel']).paths.map(paths => paths.length), [1, 2])
|
||||
|
||||
assert.equal(appendPlotSample(history, payload(260), 10000).length, MAX_POINTS)
|
||||
const restarted = appendPlotSample(history, payload(260, { sessionId: 'cd'.repeat(8) }), 10000)
|
||||
assert.equal(restarted.length, 1)
|
||||
@@ -141,7 +154,7 @@ aged.feed.start()
|
||||
aged.advance(90)
|
||||
await aged.reply(0, payload(1, { sampleAgeSeconds: 1.4 }))
|
||||
assert.equal(aged.states.at(-1).status, 'current')
|
||||
assert.equal(aged.states.at(-1).history[0].at, -400)
|
||||
assert.equal(aged.states.at(-1).history[0].at, 1090) // Receipt time, independent of source age.
|
||||
aged.advance(10)
|
||||
assert.equal(aged.states.at(-1).status, 'stale')
|
||||
aged.feed.stop()
|
||||
|
||||
@@ -19,7 +19,7 @@ class PreviewContractTest(unittest.TestCase):
|
||||
self.assertEqual([tool["path"] for tool in catalog["tools"] if tool["availability"] == "partial-preview"], ["/logs"])
|
||||
self.assertEqual([tool["path"] for tool in catalog["tools"] if tool["availability"] == "local-only"],
|
||||
["/bluetooth", "/cameras", "/galaxy", "/manage_models", "/model_laboratory", "/navigation", "/system",
|
||||
"/theme_maker", "/tuning/plots", "/vehicle", "/starport"])
|
||||
"/theme_maker", "/tuning", "/vehicle", "/starport"])
|
||||
self.assertEqual([tool["path"] for tool in catalog["tools"] if tool["availability"] == "always"], [])
|
||||
self.assertTrue(all(tool["availability"] in {"always", "unavailable", "partial-preview", "local-only"} for tool in catalog["tools"]))
|
||||
self.assertTrue(all(tool["name"] and tool["description"] and tool["icon"] for tool in catalog["tools"]))
|
||||
|
||||
@@ -114,3 +114,16 @@ def test_actual_owner_projections_have_no_uncovered_setting(tmp_path):
|
||||
for page, owner in (('sounds', SoundsOwner(params, lambda: True)), ('pip', PiPOwner(params, lambda: True, lambda: False)),
|
||||
('vasm', VASMOwner(params, lambda: True)), ('sentry', SentryOwner(params, lambda: True))):
|
||||
check(owner.snapshot().rows, page)
|
||||
|
||||
|
||||
def test_manual_coordinate_purposes_preserve_startup_and_context_warnings():
|
||||
from openpilot.starpilot.flm.canonical_surface_adapter import NAMES
|
||||
startup = "Parked only. Saved for the next drive; zero restores this stage's original baseline. Manual overrides pause Auto Learn."
|
||||
changed = "Vehicle tune, controller or effect definition changed. Reset this manual tune before editing."
|
||||
for name in NAMES:
|
||||
row = FeatureRow('torque:surface:' + name, 'Manual correction', '0', reason=startup)
|
||||
assert purpose(row) == 'Adjusts this manual steering correction.'
|
||||
assert description(row).endswith(startup)
|
||||
assert description(replace(row, available=False, reason=changed)).endswith(changed)
|
||||
reset = FeatureRow('torque:surface:reset', 'Reset manual tune', 'Reset')
|
||||
assert purpose(reset) == 'Removes the thirty manual steering overrides while keeping saved Auto Learn history.'
|
||||
|
||||
@@ -4,7 +4,6 @@ from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
@@ -16,25 +15,16 @@ from opendbc.car.structs import car
|
||||
from opendbc.car.toyota.interface import CarInterface as ToyotaInterface
|
||||
from opendbc.car.toyota.values import CAR as ToyotaCar
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.starpilot.galaxy.tests.settings_http_fixture import MutableContext, SettingsHttpFixture
|
||||
from openpilot.starpilot.galaxy.settings import (
|
||||
AuthorityContext, LiveContextSource, PAGES, SettingsChanged, SettingsGateway,
|
||||
)
|
||||
from openpilot.starpilot.galaxy.access import GalaxyAccessOwner
|
||||
from openpilot.starpilot.galaxy.server import make_server
|
||||
from openpilot.starpilot.saved_source import read_saved
|
||||
from openpilot.starpilot.lateral.controller_selection import learning_allowed
|
||||
from openpilot.starpilot.lateral.learning_preferences import KEY as LEARNING_KEY, read_preference, replace_preference
|
||||
from openpilot.starpilot import saved_document
|
||||
|
||||
|
||||
class MutableContext:
|
||||
def __init__(self, cp):
|
||||
self.value = AuthorityContext(True, cp, b"verified-cp")
|
||||
|
||||
def sample(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class SettingsGatewayTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
@@ -1512,50 +1502,7 @@ class LiveContextTest(unittest.TestCase):
|
||||
self.assertEqual(path.read_bytes(), b"not a versioned cache")
|
||||
|
||||
|
||||
class SettingsHttpTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.params = Params(temporary.name)
|
||||
native = ToyotaInterface.get_non_essential_params(ToyotaCar.TOYOTA_COROLLA_TSS2)
|
||||
cp = SimpleNamespace(carFingerprint="TOYOTA_COROLLA_TSS2", openpilotLongitudinalControl=True,
|
||||
pcmCruise=False, notCar=False, dashcamOnly=False, passive=False,
|
||||
brand="toyota", steerControlType=native.steerControlType, carVin="",
|
||||
lateralTuning=native.lateralTuning, minSteerSpeed=native.minSteerSpeed, safetyConfigs=native.safetyConfigs,
|
||||
transmissionType=car.CarParams.TransmissionType.automatic)
|
||||
self.context = MutableContext(cp)
|
||||
self.gateway = SettingsGateway(self.params, self.context)
|
||||
self.access = GalaxyAccessOwner(Path(temporary.name) / "access")
|
||||
self.assertTrue(self.access.configure("password123", lambda: True))
|
||||
self.server = make_server(port=0, owner=self.access, settings=self.gateway)
|
||||
self.worker = threading.Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True)
|
||||
self.worker.start()
|
||||
self.addCleanup(self.stop)
|
||||
|
||||
def stop(self):
|
||||
self.server.shutdown()
|
||||
self.worker.join(timeout=2)
|
||||
self.server.server_close()
|
||||
|
||||
def request(self, path, *, payload=None, cookie="", origin=None):
|
||||
connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=3)
|
||||
headers = {"Cookie": cookie}
|
||||
if payload is not None:
|
||||
headers.update({"Content-Type": "application/json",
|
||||
"Origin": origin if origin is not None else f"http://127.0.0.1:{self.server.server_port}"})
|
||||
try:
|
||||
connection.request("POST" if payload is not None else "GET", path,
|
||||
body=json.dumps(payload) if payload is not None else None, headers=headers)
|
||||
result = connection.getresponse()
|
||||
return result.status, json.loads(result.read()), dict(result.getheaders())
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def login(self):
|
||||
status, _, headers = self.request("/api/auth/login", payload={"password": "password123"})
|
||||
self.assertEqual(status, 200)
|
||||
return headers["Set-Cookie"].split(";", 1)[0]
|
||||
|
||||
class SettingsHttpTest(SettingsHttpFixture):
|
||||
def test_force_stop_disable_http_while_onroad(self):
|
||||
self.context.value = AuthorityContext(False, self.context.value.cp, b"verified-cp")
|
||||
self.params.put_bool("ForceStops", True, block=True)
|
||||
|
||||
@@ -2,47 +2,61 @@ import assert from 'node:assert/strict'
|
||||
import { FlmPage } from '../web/js/flm.js'
|
||||
import { PlotsPage } from '../web/js/plots.js'
|
||||
import { SettingsFeed } from '../web/js/settings.js'
|
||||
import { SURFACE_GROUPS } from '../web/js/flm-training-results.js'
|
||||
|
||||
const vm = { ...FlmPage.data(), activePage: true, mode: 'local', unauthorized() {} }
|
||||
for (const [name, method] of Object.entries(FlmPage.methods)) vm[name] = method.bind(vm)
|
||||
for (const [name, method] of Object.entries(FlmPage.computed)) Object.defineProperty(vm, name, { get: () => method.call(vm) })
|
||||
const calls = []
|
||||
const snapshot = { page: 'torque', view: 'original-view', subtitle: 'Saved steering tune', rows: [
|
||||
{ label: 'Use StarPilot Auto Learn', value: 'On', choices: ['Off', 'On'], available: true, action: true },
|
||||
{ label: 'Lat Accel', value: '2', minimum: 1, maximum: 3, step: .05, available: true, action: true },
|
||||
{ label: 'Friction', value: '.1', minimum: 0, maximum: .2, step: .01, available: false, action: true },
|
||||
] }
|
||||
const timers = new Map()
|
||||
const names = [...new Set(SURFACE_GROUPS.flatMap(group => group.names))]
|
||||
assert.equal(names.length, 30)
|
||||
const selected = names.indexOf(vm.selectedGroup.names[0])
|
||||
const locked = names.indexOf(vm.selectedGroup.names[1])
|
||||
const snapshot = { page: 'tuning', view: 'original-view', subtitle: 'Parked edits apply next drive', parked: true,
|
||||
rows: names.map((coordinate, index) => ({ label: coordinate, coordinate, value: '0',
|
||||
minimum: 0, maximum: 25, step: .25, available: index !== locked, action: true })) }
|
||||
const calls = [], timers = new Map()
|
||||
let edited = null
|
||||
vm.manualFeed = new SettingsFeed({ publish: update => Object.assign(vm.manualState, update),
|
||||
later: fn => { const key = timers.size + 1; timers.set(key, fn); return key }, cancelTimer: id => timers.delete(id),
|
||||
fetcher: async (path, options) => {
|
||||
calls.push({ path, body: options.body ? JSON.parse(options.body) : null })
|
||||
if (path.endsWith('/torque')) return { ok: true, json: async () => structuredClone(snapshot) }
|
||||
if (path.endsWith('/preview')) return { ok: true, json: async () => ({ intent: 'actual-settings-intent' }) }
|
||||
const body = options.body ? JSON.parse(options.body) : null
|
||||
calls.push({ path, body })
|
||||
if (path.endsWith('/tuning')) return { ok: true, json: async () => structuredClone(snapshot) }
|
||||
assert(!path.endsWith('/torque'), 'Tuning does not fetch the duplicate scalar controls')
|
||||
if (path.endsWith('/preview')) { edited = body; return { ok: true, json: async () => ({ intent: 'manual-coordinate-intent' }) } }
|
||||
assert(path.endsWith('/confirm'))
|
||||
return { ok: true, json: async () => ({ saved: true, page: { ...structuredClone(snapshot), view: 'saved-view',
|
||||
rows: snapshot.rows.map((row, index) => index === 1 ? { ...row, value: '2.1' } : row) } }) }
|
||||
rows: snapshot.rows.map((row, index) => index === edited.row ? { ...row, value: String(edited.value) } : row) } }) }
|
||||
} })
|
||||
|
||||
await vm.selectView('overview')
|
||||
assert.equal(calls.length, 0, 'Numeric settings do not poll outside Your tune')
|
||||
assert.equal(calls.length, 0)
|
||||
await vm.selectView('tune')
|
||||
assert.deepEqual(vm.manualRows.map(item => [item.row.label, item.index]), [['Lat Accel', 1], ['Friction', 2]])
|
||||
assert.equal(vm.manualRows[1].row.available, false, 'Backend unavailable control stays unavailable')
|
||||
assert.equal(await vm.saveManual(2, .12), false, 'Unavailable controls cannot dispatch edits')
|
||||
assert.equal(await vm.saveManual(0, 'Off'), false, 'Only numeric rows are editable here')
|
||||
assert(await vm.saveManual(1, 2.1))
|
||||
assert.deepEqual(calls[1].body, { view: 'original-view', row: 1, value: 2.1 })
|
||||
assert.deepEqual(calls[2].body, { intent: 'actual-settings-intent', confirmed: true, page: 'torque' })
|
||||
assert.equal(vm.manualRows[0].row.value, '2.1', 'Display changes only after saved readback')
|
||||
assert.equal(calls.length, 3, 'One edit uses existing preview then confirm')
|
||||
assert.equal(calls[0].path, './api/settings/pages/tuning')
|
||||
assert.equal(vm.manualRows.length, vm.selectedGroup.names.length, 'Only the selected group is shown')
|
||||
assert.equal(await vm.saveManual(locked, 1), false, 'Unavailable controls cannot dispatch edits')
|
||||
assert.equal(await vm.saveManual(selected, 1), true)
|
||||
assert.deepEqual(calls[1].body, { view: 'original-view', row: selected, value: 1 })
|
||||
assert.deepEqual(calls[2].body, { intent: 'manual-coordinate-intent', confirmed: true, page: 'tuning' })
|
||||
assert.equal(vm.manualState.data.rows[selected].value, '1', 'Only confirmed saved readback changes the displayed setting')
|
||||
assert.equal(vm.manualResetRequested, false, 'Numeric save does not open the reset dialog')
|
||||
vm.manualState.error = 'Saved settings changed'
|
||||
assert.equal(await vm.saveManual(1, 2.2), false)
|
||||
assert.equal(calls.length, 3, 'No writes with stale or failed settings')
|
||||
assert.equal(await vm.saveManual(selected, 2), false)
|
||||
assert.equal(calls.length, 3)
|
||||
vm.manualState.error = ''
|
||||
const resetIndex = vm.manualState.data.rows.length
|
||||
vm.manualState.data.rows.push({ label: 'Reset manual tune', value: 'Reset', choices: ['Reset'], confirm: true, available: true, action: true })
|
||||
await vm.reviewManualReset(resetIndex, 1)
|
||||
assert.equal(vm.manualResetRequested, true)
|
||||
assert(vm.manualState.pending, 'Reset is held for explicit confirmation')
|
||||
assert.deepEqual(calls.at(-1).body, { view: 'saved-view', row: resetIndex, direction: 1 })
|
||||
vm.cancelManualReset()
|
||||
assert.equal(vm.manualState.pending, null)
|
||||
assert.equal(vm.manualResetRequested, false)
|
||||
assert.equal(vm.manualState.data.rows[selected].value, '1', 'Cancel preserves the saved manual value')
|
||||
await vm.selectView('plots')
|
||||
assert.equal(vm.manualFeed.active, false)
|
||||
assert.equal(timers.size, 0, 'Leaving Your tune stops settings timers')
|
||||
assert.equal(await vm.saveManual(1, 2.3), false)
|
||||
assert.equal(timers.size, 0)
|
||||
assert.equal(await vm.saveManual(selected, 2), false)
|
||||
|
||||
const oldDocument = globalThis.document
|
||||
let started = 0, stopped = 0
|
||||
@@ -66,4 +80,4 @@ try {
|
||||
PlotsPage.beforeUnmount.call(page)
|
||||
assert.equal(stopped, 1)
|
||||
} finally { globalThis.document = oldDocument }
|
||||
console.log('Tuning numeric save/readback, stale write refusal, tab polling and retained plot lifecycle passed')
|
||||
console.log('Thirty-coordinate editor, saved readback, stale-write refusal and retained plot lifecycle passed')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { GxState } from "./state.js"
|
||||
import { GxNotice } from "./notice.js"
|
||||
import { GxDialog } from "./dialog.js"
|
||||
import { SettingsFeed, settingsBusy } from "./settings.js"
|
||||
import { GalaxySettingRow, settingControl } from "./galaxy-setting-row.js"
|
||||
import { PollTimer, connectionError } from "./polling.js"
|
||||
@@ -29,18 +30,17 @@ const PARAMETER_LABELS = {
|
||||
|
||||
export const FlmPage = {
|
||||
name: "FlmPage",
|
||||
components: { GxState, GxNotice, PlotGraph, PlotsPage, GalaxySettingRow },
|
||||
components: { GxState, GxNotice, GxDialog, PlotGraph, PlotsPage, GalaxySettingRow },
|
||||
props: { initialView: { type: String, default: "overview" }, mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
|
||||
go: { type: Function, default: path => { window.location.hash = path } } },
|
||||
data: () => ({ report: null, error: "", loading: false, request: null, receivedAt: 0, now: 0,
|
||||
manualState: { status: "idle", data: null, pending: null, error: "" },
|
||||
manualState: { status: "idle", data: null, pending: null, error: "" }, manualResetRequested: false,
|
||||
view: "overview", group: "balance", showAllHistory: false, groups: SURFACE_GROUPS }),
|
||||
watch: { initialView(view) { this.selectView(view) }, mode() { this.syncManualControls() } },
|
||||
mounted() {
|
||||
this.activePage = true
|
||||
this.manualFeed = new SettingsFeed({ publish: update => Object.assign(this.manualState, update), unauthorized: this.unauthorized })
|
||||
this.selectView(this.initialView)
|
||||
this.syncManualControls()
|
||||
this.poller = new PollTimer({ read: () => this.load(), interval: 1000 })
|
||||
this.visibility = () => {
|
||||
this.now = Date.now()
|
||||
@@ -55,7 +55,8 @@ export const FlmPage = {
|
||||
document.removeEventListener("visibilitychange", this.visibility)
|
||||
},
|
||||
computed: {
|
||||
manualRows() { return (this.manualState.data?.rows || []).map((row, index) => ({ row, index })).filter(({ row }) => settingControl(row) === "slider") },
|
||||
manualRows() { return (this.manualState.data?.rows || []).map((row, index) => ({ row, index })).filter(({ row }) => this.selectedGroup.names.includes(row.coordinate)) },
|
||||
manualActions() { return (this.manualState.data?.rows || []).map((row, index) => ({ row, index })).filter(({ row }) => row.confirm) },
|
||||
manualBusy() { return settingsBusy(this.manualState) },
|
||||
current() { return this.report?.current },
|
||||
preference() { return validLearningPreference(this.report?.preference) ? this.report.preference : null },
|
||||
@@ -108,13 +109,22 @@ export const FlmPage = {
|
||||
syncManualControls() {
|
||||
if (!this.manualFeed) return
|
||||
if (this.activePage && this.mode === "local" && this.view === "tune") {
|
||||
if (!this.manualFeed.active) return this.manualFeed.start("torque")
|
||||
} else this.manualFeed.stop()
|
||||
if (!this.manualFeed.active) return this.manualFeed.start("tuning")
|
||||
} else { this.manualResetRequested = false; this.manualFeed.stop() }
|
||||
},
|
||||
async reviewManualReset(index, direction) {
|
||||
const row = this.manualState.data?.rows?.[index]
|
||||
if (this.view !== "tune" || this.manualBusy || this.manualState.error || !row?.confirm || !row.available) return
|
||||
this.manualResetRequested = true
|
||||
await this.manualFeed.preview(index, direction)
|
||||
if (!this.manualState.pending) this.manualResetRequested = false
|
||||
},
|
||||
cancelManualReset() { this.manualResetRequested = false; this.manualFeed.cancel() },
|
||||
async confirmManualReset() { await this.manualFeed.confirm(); this.manualResetRequested = false },
|
||||
async saveManual(index, value) {
|
||||
const row = this.manualState.data?.rows?.[index]
|
||||
if (this.view !== "tune" || !this.manualFeed?.active || this.manualBusy || this.manualState.error ||
|
||||
!row?.available || !row.action || settingControl(row) !== "slider") return false
|
||||
!this.selectedGroup.names.includes(row?.coordinate) || !row.available || !row.action || settingControl(row) !== "slider") return false
|
||||
await this.manualFeed.previewValue(index, value)
|
||||
return !this.manualState.error && this.manualState.status === "ready"
|
||||
},
|
||||
@@ -124,7 +134,7 @@ export const FlmPage = {
|
||||
event.preventDefault()
|
||||
const index = VIEWS.indexOf(this.view)
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? VIEWS.length - 1 : (index + delta + VIEWS.length) % VIEWS.length
|
||||
this.view = VIEWS[next]
|
||||
this.selectView(VIEWS[next])
|
||||
event.currentTarget.parentElement.querySelectorAll('[role="tab"]')[next]?.focus()
|
||||
},
|
||||
async load() {
|
||||
@@ -153,8 +163,8 @@ export const FlmPage = {
|
||||
<header class="gx-page-header gx-settings__header gx-tuner__heading"><div><p class="gx-tuner__eyebrow">STEERING · AUTO LEARN</p><h2>Tuning and Analysis</h2><p>{{ vehicle }}</p></div></header>
|
||||
<GxState v-if="mode !== 'local'">Connect to your device to see its steering tune.</GxState>
|
||||
<template v-else>
|
||||
<GxNotice v-if="error" tone="danger">{{ error }}</GxNotice>
|
||||
<section class="gx-card gx-tuner__hero" :data-tone="state.tone" aria-label="Auto Learn status">
|
||||
<GxNotice v-if="error && view !== 'plots'" tone="danger">{{ error }}</GxNotice>
|
||||
<section v-if="view !== 'plots'" class="gx-card gx-tuner__hero" :data-tone="state.tone" aria-label="Auto Learn status">
|
||||
<div class="gx-tuner__hero-icon" aria-hidden="true"><i class="bi bi-sliders2"></i></div>
|
||||
<div class="gx-tuner__hero-copy"><div class="gx-tuner__eyebrow"><span class="gx-tuner__status-dot"></span>{{ loading && !report ? 'CONNECTING' : state.key === 'off' ? 'DISABLED' : choiceLive || live ? 'DEVICE CONNECTED' : 'WAITING FOR DATA' }}</div>
|
||||
<h3 role="status">{{ loading && !report ? 'Reading your tuner…' : state.title }}</h3><p>{{ state.description }}</p>
|
||||
@@ -162,6 +172,7 @@ export const FlmPage = {
|
||||
</div>
|
||||
<div class="gx-tuner__hero-meta"><span>{{ currentApplied ? 'Controller acknowledged' : live ? 'No correction acknowledged' : 'Current application unknown' }}</span><small>{{ reportAge === null ? 'No learner report yet' : 'Learner report ' + reportAge.toFixed(1) + 's old' }}</small></div>
|
||||
</section>
|
||||
<GxDialog v-if="manualResetRequested && manualState.pending" labelledby="manual-tune-confirm-title" @close="cancelManualReset"><h3 id="manual-tune-confirm-title">Reset manual tune?</h3><p>{{ manualState.pending.question }}</p><div class="gx-dialog__actions"><button type="button" class="gx-btn gx-btn--tonal" @click="cancelManualReset">Cancel</button><button type="button" class="gx-btn" @click="confirmManualReset">Reset</button></div></GxDialog>
|
||||
<nav class="gx-tuner__tabs" role="tablist" aria-label="Tuner views">
|
||||
<button v-for="tab in ['overview', 'tune', 'activity', 'plots']" :key="tab" type="button" role="tab" :id="'tuner-tab-' + tab" :aria-controls="'tuner-panel-' + tab" :aria-selected="view === tab" :tabindex="view === tab ? 0 : -1" @click="selectView(tab)" @keydown="tabKey">{{ tab === 'overview' ? 'Overview' : tab === 'tune' ? 'Your tune' : tab === 'activity' ? 'Activity' : 'Live plots' }}<span v-if="tab === 'tune'" class="gx-tuner__count">30</span></button>
|
||||
</nav>
|
||||
@@ -182,17 +193,17 @@ export const FlmPage = {
|
||||
</section></div>
|
||||
</section>
|
||||
<section v-if="view === 'tune'" id="tuner-panel-tune" role="tabpanel" aria-labelledby="tuner-tab-tune" class="gx-tuner__panel">
|
||||
<section class="gx-card gx-tuner__section gx-settings__section gx-tuner__manual"><div class="gx-tuner__section-heading"><div><h3>Steering controls</h3><p>Adjust your saved vehicle tune.</p></div></div>
|
||||
<GxNotice v-if="manualState.error" tone="danger">{{ manualState.error }}</GxNotice>
|
||||
<p v-if="!manualState.data" class="gx-tuner__caption">{{ manualState.status === 'loading' ? 'Loading steering controls…' : 'Steering controls unavailable. Reconnecting…' }}</p>
|
||||
<template v-else><p class="gx-tuner__caption">{{ manualState.data.subtitle }}</p>
|
||||
<GalaxySettingRow v-for="{ row, index } in manualRows" :key="index" :row="row" :index="index" :busy="manualBusy" :disabled="!!manualState.error" :save-value="saveManual" @reset-default="index => manualFeed.resetDefault(index)" />
|
||||
<p v-if="!manualRows.length" class="gx-tuner__caption">No manual numeric controls are available for this vehicle.</p>
|
||||
</template>
|
||||
</section>
|
||||
<div class="gx-tuner__section-heading"><div><h3>Auto Learn corrections</h3><p>Thirty automatic correction values, grouped by steering behavior.</p></div><span class="gx-tuner__pill">Reported corrections</span></div>
|
||||
<div class="gx-tuner__section-heading"><div><h3>Your tune</h3><p>Thirty steering parameters, grouped by behavior.</p></div><span class="gx-tuner__pill">Manual + Auto Learn</span></div>
|
||||
<div class="gx-tuner__tune-layout"><nav class="gx-tuner__groups" aria-label="Steering parameter groups"><button v-for="item in groups" :key="item.id" type="button" :aria-pressed="group === item.id" @click="group = item.id"><i :class="['bi', item.icon]" aria-hidden="true"></i><span>{{ item.label }}<small>{{ item.names.length }} parameters · {{ groupChanges(item) }}</small></span><i class="bi bi-chevron-right" aria-hidden="true"></i></button></nav>
|
||||
<section class="gx-card gx-tuner__section gx-tuner__parameters"><div class="gx-tuner__section-heading"><div><h3>{{ selectedGroup.label }}</h3><p>{{ selectedGroup.description }}</p></div></div>
|
||||
<section class="gx-tuner__manual" aria-label="Manual tuning"><h4>Manual tune</h4><p class="gx-tuner__caption">Parked edits apply to the next drive. Auto Learn pauses while manual overrides are set. Zero removes an override.</p>
|
||||
<GxNotice v-if="manualState.error" tone="danger">{{ manualState.error }}</GxNotice>
|
||||
<p v-if="!manualState.data" class="gx-tuner__caption">{{ manualState.status === 'loading' ? 'Loading saved manual tune…' : 'Manual tuning unavailable. Reconnecting…' }}</p>
|
||||
<p v-else-if="!manualRows.length" class="gx-tuner__caption">{{ manualState.data.rows[0]?.reason || 'Manual tuning is unavailable for this controller.' }}</p>
|
||||
<GalaxySettingRow v-for="{ row, index } in manualRows" :key="row.coordinate" :row="{ ...row, label: parameterLabel(row.coordinate), reason: row.available ? '' : row.reason }" :index="index" :busy="manualBusy" :disabled="!!manualState.error" :save-value="saveManual" @reset-default="index => manualFeed.resetDefault(index)" />
|
||||
<GalaxySettingRow v-for="{ row, index } in manualActions" :key="index" :row="row" :index="index" :busy="manualBusy" :disabled="!!manualState.error" :save-value="saveManual" @review="reviewManualReset" />
|
||||
</section>
|
||||
<h4>Auto Learn evidence</h4><p class="gx-tuner__caption">These reported results are separate from your manual settings.</p>
|
||||
<div class="gx-tuner__parameter-head" aria-hidden="true"><span>Parameter</span><span>{{ trackingValues ? 'Accepted' : 'Proposal' }}</span><span>Trial</span><span>Applied now</span></div>
|
||||
<div v-for="row in groupRows" :key="row.name" class="gx-tuner__parameter"><div><h4>{{ parameterLabel(row.name) }}</h4><small v-if="!row.pidSupported">Torque only · PID unsupported</small></div><div><span class="gx-tuner__mobile-label">{{ trackingValues ? 'Accepted' : 'Proposal' }}</span><strong>{{ coordinateText(trackingValues ? row.accepted : row.learned) }}</strong></div><div><span class="gx-tuner__mobile-label">Trial</span><strong :class="{ 'gx-tuner__pending': row.pending !== null && row.pending !== row.accepted }">{{ coordinateText(row.pending) }}</strong></div><div><span class="gx-tuner__mobile-label">Applied now</span><strong :class="{ 'gx-tuner__applied': row.applied !== null && row.applied !== 0 }">{{ coordinateText(row.applied) }}</strong></div></div>
|
||||
<details class="gx-tuner__disclosure"><summary>How to read these values</summary><p>Zero is the original baseline. These are correction coordinates, not raw degrees or torque. Speed-window coordinates scale the original speed range; deadband and angle assistance use the controller's physical normalizer. A trial is an offer, not proof of application. All 30 stages exist for torque; 26 exist for torque-actuated PID.</p></details>
|
||||
|
||||
@@ -43,7 +43,8 @@ export function appendPlotSample(history, payload, observedAt) {
|
||||
if (payload.sampleIndex === (history.at(-1)?.index ?? 0)) return history
|
||||
if (payload.sampleIndex < (history.at(-1)?.index ?? 0)) history = []
|
||||
const sample = { ...payload.values, sessionId: payload.sessionId, index: payload.sampleIndex,
|
||||
at: observedAt - payload.sampleAgeSeconds * 1000 }
|
||||
// Chart client observations; source age remains a separate freshness check.
|
||||
at: observedAt }
|
||||
return [...history, sample].slice(-MAX_POINTS)
|
||||
}
|
||||
|
||||
@@ -146,7 +147,7 @@ export class PlotsFeed {
|
||||
const displayGeneration = ++this.displayGeneration
|
||||
const remaining = data.sampleAgeSeconds === null ? 0 : 1500 - data.sampleAgeSeconds * 1000 - elapsed
|
||||
if (data.state === 'current' && remaining > 0) {
|
||||
this.history = appendPlotSample(this.history, data, this.now() - elapsed)
|
||||
this.history = appendPlotSample(this.history, data, this.now())
|
||||
this.lastData = data
|
||||
this.publish({ status: 'current', data, history: this.history, error: '' })
|
||||
this.expiry = this.later(() => {
|
||||
@@ -232,18 +233,18 @@ export const PlotsPage = {
|
||||
<span class="gx-plots__status" role="status">{{ paused ? 'Paused' : data?.bootStabilizing ? 'Starting vehicle systems' : status === 'current' ? 'Live' : status === 'stale' ? 'Reconnecting · last received values' : status === 'unavailable' ? 'Unavailable' : 'Connecting' }}</span></div>
|
||||
<p class="gx-note gx-plots__hint" role="status">{{ !paused && error ? error : 'Missing samples leave a gap in the graph.' }}</p>
|
||||
<div class="gx-plots__grid">
|
||||
<section class="gx-card gx-plots__panel"><div class="gx-section__header"><i class="bi bi-graph-up" aria-hidden="true"></i><span class="gx-section__title">Lateral Acceleration</span></div><p class="gx-note">Desired / actual · m/s² · {{ source(data?.values?.lateralSource) }}</p>
|
||||
<section class="gx-card gx-plots__panel"><div class="gx-section__header"><i class="bi bi-graph-up" aria-hidden="true"></i><span class="gx-section__title">Lateral Acceleration</span></div><p class="gx-note gx-plots__source">Desired / actual · m/s² · {{ source(data?.values?.lateralSource) }}</p>
|
||||
<plot-graph :chart="lateralChart" title="Lateral acceleration history" />
|
||||
<p>Desired: {{ label(data?.values?.desiredLateralAccel) }} · Actual: {{ label(data?.values?.actualLateralAccel) }}</p>
|
||||
<p class="gx-note">Speed source: {{ source(data?.values?.speedSource) }}</p>
|
||||
<p>30 s match: <strong>{{ !data || data?.bootStabilizing ? 'N/A' : lateralQuality.label }}</strong> · {{ data?.bootStabilizing ? 'Waiting for startup to settle' : status !== 'current' ? 'Last received window' : lateralQuality.detail }}</p></section>
|
||||
<section class="gx-card gx-plots__panel"><div class="gx-section__header"><i class="bi bi-graph-up-arrow" aria-hidden="true"></i><span class="gx-section__title">Longitudinal Acceleration</span></div><p class="gx-note">Target / measured · m/s² · {{ source(data?.values?.longitudinalSource) }}; measured from {{ data?.values?.poseFresh ? 'device motion' : 'unavailable motion source' }}</p>
|
||||
<dl class="gx-plots__readout"><div><dt>Desired</dt><dd>{{ label(data?.values?.desiredLateralAccel) }}</dd></div><div><dt>Actual</dt><dd>{{ label(data?.values?.actualLateralAccel) }}</dd></div></dl>
|
||||
<p class="gx-note gx-plots__source">Speed source: {{ source(data?.values?.speedSource) }}</p>
|
||||
<p class="gx-plots__match">30 s match: <strong>{{ !data || data?.bootStabilizing ? 'N/A' : lateralQuality.label }}</strong> · {{ data?.bootStabilizing ? 'Waiting for startup to settle' : status !== 'current' ? 'Last received window' : lateralQuality.detail }}</p></section>
|
||||
<section class="gx-card gx-plots__panel"><div class="gx-section__header"><i class="bi bi-graph-up-arrow" aria-hidden="true"></i><span class="gx-section__title">Longitudinal Acceleration</span></div><p class="gx-note gx-plots__source">Target / measured · m/s² · {{ source(data?.values?.longitudinalSource) }}; measured from {{ data?.values?.poseFresh ? 'device motion' : 'unavailable motion source' }}</p>
|
||||
<plot-graph :chart="longChart" title="Longitudinal acceleration history" />
|
||||
<p>Target: {{ label(data?.values?.desiredLongitudinalAccel) }} · Measured: {{ label(data?.values?.actualLongitudinalAccel) }}</p>
|
||||
<p>30 s match: <strong>{{ !data || data?.bootStabilizing ? 'N/A' : longQuality.label }}</strong> · {{ data?.bootStabilizing ? 'Waiting for startup to settle' : status !== 'current' ? 'Last received window' : longQuality.detail }}</p></section>
|
||||
<section v-if="advanced" class="gx-card gx-plots__panel"><div class="gx-section__header"><i class="bi bi-sliders" aria-hidden="true"></i><span class="gx-section__title">Lateral Controller Terms</span></div><p class="gx-note">P / I / D / F · controller output units · {{ source(data?.values?.lateralTermsSource) }}</p>
|
||||
<dl class="gx-plots__readout"><div><dt>Target</dt><dd>{{ label(data?.values?.desiredLongitudinalAccel) }}</dd></div><div><dt>Measured</dt><dd>{{ label(data?.values?.actualLongitudinalAccel) }}</dd></div></dl>
|
||||
<p class="gx-plots__match">30 s match: <strong>{{ !data || data?.bootStabilizing ? 'N/A' : longQuality.label }}</strong> · {{ data?.bootStabilizing ? 'Waiting for startup to settle' : status !== 'current' ? 'Last received window' : longQuality.detail }}</p></section>
|
||||
<section v-if="advanced" class="gx-card gx-plots__panel"><div class="gx-section__header"><i class="bi bi-sliders" aria-hidden="true"></i><span class="gx-section__title">Lateral Controller Terms</span></div><p class="gx-note gx-plots__source">P / I / D / F · controller output units · {{ source(data?.values?.lateralTermsSource) }}</p>
|
||||
<plot-graph :chart="lateralTermsChart" title="Lateral controller terms" :terms="true" /></section>
|
||||
<section v-if="advanced" class="gx-card gx-plots__panel"><div class="gx-section__header"><i class="bi bi-sliders" aria-hidden="true"></i><span class="gx-section__title">Longitudinal Controller Terms</span></div><p class="gx-note">P / I / F · m/s² · {{ source(data?.values?.longitudinalTermsSource) }}</p>
|
||||
<section v-if="advanced" class="gx-card gx-plots__panel"><div class="gx-section__header"><i class="bi bi-sliders" aria-hidden="true"></i><span class="gx-section__title">Longitudinal Controller Terms</span></div><p class="gx-note gx-plots__source">P / I / F · m/s² · {{ source(data?.values?.longitudinalTermsSource) }}</p>
|
||||
<plot-graph :chart="longTermsChart" title="Longitudinal controller terms" :terms="true" /></section>
|
||||
</div><p class="gx-note">Blue is target, orange is observed. Missing or stale sources are never plotted as zero.</p>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,7 @@ from dataclasses import dataclass, replace
|
||||
from enum import StrEnum
|
||||
import json
|
||||
import math
|
||||
from typing import cast
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from openpilot.starpilot.lateral.torque_supported import BOLT_VEHICLES, GAIN_VEHICLES, GM_VEHICLES
|
||||
|
||||
@@ -17,6 +17,9 @@ MAX_DOCUMENT_BYTES = 16_384
|
||||
from openpilot.starpilot.flm.torque_surface import GmFlmBinding
|
||||
from opendbc.car.values import PLATFORMS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openpilot.starpilot.flm.manual_coordinates import ManualCoordinates
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FieldChoice:
|
||||
@@ -49,6 +52,7 @@ class PlatformProfile:
|
||||
gain_basis: GainBasis | None = None
|
||||
geometry: GeometryProfile | None = None
|
||||
flm: GmFlmBinding | None = None
|
||||
manual_coordinates: ManualCoordinates | None = None
|
||||
|
||||
|
||||
class LegacyMode(StrEnum):
|
||||
@@ -163,7 +167,7 @@ def parse_document(raw: bytes) -> dict[str, PlatformProfile]:
|
||||
except (RecursionError, OverflowError) as exc:
|
||||
raise ValueError("Malformed torque document") from exc
|
||||
if not isinstance(document, dict) or set(document) != {"schemaVersion", "vehicles"} or \
|
||||
type(document["schemaVersion"]) is not int or document["schemaVersion"] not in (1, 2, 3, 4) or \
|
||||
type(document["schemaVersion"]) is not int or document["schemaVersion"] not in (1, 2, 3, 4, 5) or \
|
||||
not isinstance(document["vehicles"], dict) or len(document["vehicles"]) > len(PLATFORMS):
|
||||
raise ValueError("Malformed torque document")
|
||||
profiles: dict[str, PlatformProfile] = {}
|
||||
@@ -173,8 +177,10 @@ def parse_document(raw: bytes) -> dict[str, PlatformProfile]:
|
||||
expected |= {"proportionalGain", "gainBasis"}
|
||||
if document["schemaVersion"] >= 3 and isinstance(value, dict) and "geometry" in value:
|
||||
expected |= {"geometry"}
|
||||
if document["schemaVersion"] == 4 and isinstance(value, dict) and "flm" in value:
|
||||
if document["schemaVersion"] >= 4 and isinstance(value, dict) and "flm" in value:
|
||||
expected |= {"flm"}
|
||||
if document["schemaVersion"] == 5 and isinstance(value, dict) and "manualCoordinates" in value:
|
||||
expected |= {"manualCoordinates"}
|
||||
if fingerprint not in PLATFORMS or not isinstance(value, dict) or set(value) != expected:
|
||||
raise ValueError("Malformed torque profile")
|
||||
basis_obj = value["basis"]
|
||||
@@ -204,13 +210,21 @@ def parse_document(raw: bytes) -> dict[str, PlatformProfile]:
|
||||
raise ValueError("Unsupported GM FLM profile")
|
||||
flm = GmFlmBinding.from_document(value["flm"])
|
||||
flm.validate_manual(fingerprint)
|
||||
profiles[fingerprint] = PlatformProfile(basis, _choice(value["factor"]), _choice(value["friction"]), gain, gain_basis, geometry, flm)
|
||||
manual = None
|
||||
if "manualCoordinates" in value:
|
||||
from openpilot.starpilot.flm.manual_coordinates import ManualCoordinates
|
||||
manual = ManualCoordinates.from_document(value["manualCoordinates"])
|
||||
if flm is not None:
|
||||
raise ValueError("Only one manual surface may be selected")
|
||||
profiles[fingerprint] = PlatformProfile(basis, _choice(value["factor"]), _choice(value["friction"]), gain, gain_basis, geometry, flm, manual)
|
||||
return profiles
|
||||
|
||||
|
||||
def serialize_document(profiles: dict[str, PlatformProfile]) -> bytes:
|
||||
vehicles: dict[str, dict] = {}
|
||||
version = (4 if any(p.flm is not None for p in profiles.values()) else 3 if any(p.geometry is not None for p in profiles.values())
|
||||
version = (5 if any(p.manual_coordinates is not None for p in profiles.values())
|
||||
else 4 if any(p.flm is not None for p in profiles.values())
|
||||
else 3 if any(p.geometry is not None for p in profiles.values())
|
||||
else 2 if any(p.gain_basis is not None for p in profiles.values()) else 1)
|
||||
for fingerprint, profile in profiles.items():
|
||||
vehicles[fingerprint] = {
|
||||
@@ -235,6 +249,11 @@ def serialize_document(profiles: dict[str, PlatformProfile]) -> bytes:
|
||||
}
|
||||
if profile.flm is not None:
|
||||
vehicles[fingerprint]["flm"] = profile.flm.document()
|
||||
if profile.manual_coordinates is not None:
|
||||
from openpilot.starpilot.flm.manual_coordinates import ManualCoordinates
|
||||
if type(profile.manual_coordinates) is not ManualCoordinates:
|
||||
raise ValueError("Typed manual coordinates required")
|
||||
vehicles[fingerprint]["manualCoordinates"] = profile.manual_coordinates.document()
|
||||
raw = json.dumps({"schemaVersion": version, "vehicles": vehicles}, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
|
||||
parse_document(raw)
|
||||
return raw
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from unittest.mock import Mock
|
||||
from openpilot.cereal.messaging import PubMaster
|
||||
from openpilot.starpilot.lateral.controller_selection import default_selection
|
||||
from openpilot.starpilot.longitudinal.extension import LongitudinalContext
|
||||
from openpilot.starpilot.longitudinal.tests.extension_helpers import extension_state
|
||||
import os
|
||||
@@ -159,7 +160,7 @@ class TestEuvLongitudinal(unittest.TestCase):
|
||||
|
||||
with patch('openpilot.selfdrive.controls.controlsd.Params'), \
|
||||
patch('openpilot.selfdrive.controls.controlsd.messaging.log_from_bytes', return_value=cp), \
|
||||
patch('openpilot.selfdrive.controls.controlsd.read_selection', return_value=NS(policy=None)), \
|
||||
patch('openpilot.selfdrive.controls.controlsd.read_selection', return_value=NS(policy=None, mode=default_selection(cp).mode)), \
|
||||
patch('openpilot.selfdrive.controls.controlsd.learning_allowed', return_value=False), \
|
||||
patch('openpilot.selfdrive.controls.controlsd.feature_enabled', return_value=False), \
|
||||
patch('openpilot.starpilot.longitudinal.inputs.toyota_development_enabled', return_value=False), \
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import patch
|
||||
|
||||
from openpilot.starpilot.lateral.controller_selection import default_selection
|
||||
from openpilot.starpilot.longitudinal.extension import LongitudinalContext
|
||||
from openpilot.starpilot.longitudinal.tests.extension_helpers import extension_state
|
||||
import os
|
||||
@@ -150,7 +151,7 @@ class TestVoltCc(unittest.TestCase):
|
||||
for alpha in (False, True):
|
||||
with patch('openpilot.selfdrive.controls.controlsd.Params'), \
|
||||
patch('openpilot.selfdrive.controls.controlsd.messaging.log_from_bytes', return_value=params(alpha)), \
|
||||
patch('openpilot.selfdrive.controls.controlsd.read_selection', return_value=NS(policy=None)), \
|
||||
patch('openpilot.selfdrive.controls.controlsd.read_selection', return_value=NS(policy=None, mode=default_selection(params(alpha)).mode)), \
|
||||
patch('openpilot.selfdrive.controls.controlsd.learning_allowed', return_value=False), \
|
||||
patch('openpilot.selfdrive.controls.controlsd.feature_enabled', return_value=False), \
|
||||
patch('openpilot.starpilot.longitudinal.inputs.toyota_development_enabled', return_value=False), \
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Default Volt STOP ownership, qualified release, and parsed Controls-to-CAN recurrence."""
|
||||
|
||||
from openpilot.starpilot.lateral.controller_selection import default_selection
|
||||
from openpilot.starpilot.longitudinal.extension import LongitudinalContext
|
||||
from types import SimpleNamespace as NS
|
||||
import unittest
|
||||
@@ -101,7 +102,7 @@ class TestVoltStop(unittest.TestCase):
|
||||
raise Registered
|
||||
with patch('openpilot.selfdrive.controls.controlsd.Params'), \
|
||||
patch('openpilot.selfdrive.controls.controlsd.messaging.log_from_bytes', return_value=cp), \
|
||||
patch('openpilot.selfdrive.controls.controlsd.read_selection', return_value=NS(policy=None)), \
|
||||
patch('openpilot.selfdrive.controls.controlsd.read_selection', return_value=NS(policy=None, mode=default_selection(cp).mode)), \
|
||||
patch('openpilot.selfdrive.controls.controlsd.learning_allowed', return_value=False), \
|
||||
patch('openpilot.selfdrive.controls.controlsd.feature_enabled', return_value=False), \
|
||||
patch('openpilot.starpilot.longitudinal.inputs.toyota_development_enabled', return_value=False), \
|
||||
|
||||
@@ -1029,6 +1029,11 @@ class FeatureSettingsOwner:
|
||||
title = "BluePilot Controller Options"
|
||||
rows.extend(FordBluePilotFeature(self).rows(parked, options=True))
|
||||
rows.extend(FordClusterFeature(self).rows(parked))
|
||||
elif page == FeaturePage.TUNING:
|
||||
title = "Manual steering tune"
|
||||
capability = self._capability("torque")
|
||||
rows.extend(self.torque.manual_rows(capability, parked and self.authority("torque") and
|
||||
self.authority("parked_preferences") and capability is not None))
|
||||
elif page == FeaturePage.TORQUE:
|
||||
title = "Steering and Torque"
|
||||
rows.extend(FordBluePilotFeature(self).rows(parked))
|
||||
@@ -1319,6 +1324,8 @@ class FeatureSettingsOwner:
|
||||
row.key.startswith('conditional:') and not row.key.startswith(BUTTON_PREFIX) else fingerprint)
|
||||
for row in rows]
|
||||
subtitle = (
|
||||
"Parked changes apply after the next drive starts. These are explicit manual overrides, separate from Auto Learn."
|
||||
if page == FeaturePage.TUNING else
|
||||
"Configure features supported by this vehicle. Restart requirements are shown with each setting."
|
||||
if page == FeaturePage.VEHICLE else
|
||||
"Configure automatic Chill and Experimental switching."
|
||||
|
||||
@@ -57,6 +57,7 @@ class FeaturePage(StrEnum):
|
||||
TRAFFIC = "traffic"
|
||||
CURVE = "curve"
|
||||
TORQUE = "torque"
|
||||
TUNING = "tuning"
|
||||
BLUEPILOT = "torque/bluepilot"
|
||||
AOL = "aol"
|
||||
WHEEL = "wheel"
|
||||
@@ -65,7 +66,8 @@ class FeaturePage(StrEnum):
|
||||
CONDITIONAL_CCM = "conditional/ccm"
|
||||
|
||||
|
||||
TORQUE_CONFIRM_ACTIONS = frozenset(("torque_adopt", "torque_reset", "torque_reset_profile", "torque_rebase", "torque_prepare_firestar", "torque_gain_rebase"))
|
||||
TORQUE_CONFIRM_ACTIONS = frozenset(("torque_adopt", "torque_reset", "torque_reset_profile", "torque_rebase",
|
||||
"torque_prepare_firestar", "torque_gain_rebase", "torque:surface:reset"))
|
||||
SLC_CONFIRM_ACTIONS = frozenset(("slc_reset",))
|
||||
SLC_OFFSET_KEYS = tuple(f"slc:offset:{index}" for index in range(1, 8))
|
||||
LANE_CHANGE_CONFIRM_ACTIONS = frozenset(("lane_change:reset",))
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from openpilot.starpilot.ui.home_stats_source import HomeStatsSource, read_home_stats
|
||||
|
||||
@@ -55,7 +55,7 @@ class TestHomeStatsSource(unittest.TestCase):
|
||||
self.assertIsNone(read_home_stats(False, store=path))
|
||||
|
||||
def test_future_read_never_blocks_render_and_changes_units_without_mixing_results(self):
|
||||
pool = unittest.mock.Mock()
|
||||
pool = Mock()
|
||||
first, second = Future(), Future()
|
||||
pool.submit.side_effect = [first, second]
|
||||
with patch('openpilot.starpilot.ui.home_stats_source.ThreadPoolExecutor', return_value=pool):
|
||||
@@ -76,7 +76,7 @@ class TestHomeStatsSource(unittest.TestCase):
|
||||
|
||||
def test_failed_read_retries_and_recovers_instead_of_sticking_unavailable(self):
|
||||
now = [10.]
|
||||
pool = unittest.mock.Mock()
|
||||
pool = Mock()
|
||||
failed, recovered = Future(), Future()
|
||||
failed.set_exception(OSError('cache temporarily unavailable'))
|
||||
recovered.set_result('recovered')
|
||||
|
||||
@@ -28,6 +28,74 @@ class TorqueFeature:
|
||||
self._readable = owner._readable
|
||||
self._dependents = owner._dependents
|
||||
|
||||
|
||||
def _manual_context(self):
|
||||
from openpilot.starpilot.flm.manual_coordinates import context
|
||||
cp = self.owner.vehicle_params()
|
||||
if cp is None or not self._readable(SELECTION_KEY):
|
||||
raise ValueError("Current controller unavailable")
|
||||
selection = selection_from_bytes(cp, self._raw(SELECTION_KEY))
|
||||
if selection.source == "invalid":
|
||||
raise ValueError("Saved controller selection unavailable")
|
||||
return context(cp, selection.mode)
|
||||
|
||||
def _manual_token(self):
|
||||
return json.dumps(self._manual_context(), separators=(",", ":")).encode()
|
||||
|
||||
def manual_rows(self, capability, allowed):
|
||||
from openpilot.starpilot.flm.canonical_surface_adapter import NAMES, coordinate_bounds
|
||||
raw = self._raw(DOCUMENT_KEY)
|
||||
if capability is None or not self._readable(DOCUMENT_KEY):
|
||||
return (FeatureRow("", "Manual tune", "Unavailable", reason="Current vehicle and readable saved settings are required"),)
|
||||
try:
|
||||
profiles = {} if raw is None else parse_document(raw)
|
||||
profile = profiles.get(capability[0])
|
||||
basis = tuple(capability[5:8])
|
||||
token = self._manual_token()
|
||||
current = self._manual_context()
|
||||
saved = None if profile is None else profile.manual_coordinates
|
||||
matches = profile is None or (profile.basis == basis and profile.flm is None
|
||||
and (saved is None or saved.context == current))
|
||||
values = dict(saved.values) if saved is not None else dict.fromkeys(NAMES, 0.)
|
||||
common = {"source": raw, "capability": capability, "related_source": token,
|
||||
"dependencies": self._dependents(SELECTION_KEY, *TORQUE_NUMBERS, "ForceAutoTuneOff")}
|
||||
reason = ("Parked only. Saved for the next drive; zero restores this stage's original baseline. " +
|
||||
"Manual overrides pause Auto Learn and are not learned results.") if matches else (
|
||||
"Vehicle tune, controller or effect definition changed. Reset this manual tune before editing.")
|
||||
rows = []
|
||||
for name in NAMES:
|
||||
low, high = coordinate_bounds(name)
|
||||
rows.append(FeatureRow("torque:surface:" + name, name.replace("_", " ").capitalize(),
|
||||
f"{100 * values[name]:.4f}", step=.25, minimum=100 * low, maximum=100 * high, unit="%",
|
||||
available=allowed and matches, reason=reason, default_value="0", **common))
|
||||
if saved is not None:
|
||||
rows.append(FeatureRow("torque:surface:reset", "Reset manual tune", "Reset", choices=("Reset",),
|
||||
available=allowed, reason="Remove only the thirty manual overrides. Accepted Auto Learn history is preserved.", **common))
|
||||
return tuple(rows)
|
||||
except (AttributeError, KeyError, TypeError, ValueError, OverflowError, UnicodeError):
|
||||
return (FeatureRow("", "Manual tune", "Unavailable", reason="Saved tuning or controller context is invalid; repair it in Toggles"),)
|
||||
|
||||
def _edit_manual(self, profiles, request, fingerprint, basis):
|
||||
from openpilot.starpilot.flm.canonical_surface_adapter import NAMES
|
||||
from openpilot.starpilot.flm.manual_coordinates import ManualCoordinates
|
||||
if not self.authority("parked_preferences") or request.related_source != self._manual_token():
|
||||
raise ValueError("Manual tuning requires the same parked vehicle and controller")
|
||||
prior = profiles.get(fingerprint, PlatformProfile(basis, FieldChoice(), FieldChoice()))
|
||||
name = request.key.removeprefix("torque:surface:")
|
||||
if name == "reset":
|
||||
if not request.confirmation or request.value != "Reset":
|
||||
raise ValueError("Explicit reset confirmation required")
|
||||
return {**profiles, fingerprint: replace(prior, manual_coordinates=None)}
|
||||
current = self._manual_context()
|
||||
saved = prior.manual_coordinates
|
||||
if (name not in NAMES or prior.basis != basis or prior.flm is not None
|
||||
or saved is not None and saved.context != current):
|
||||
raise ValueError("Manual tune context changed")
|
||||
values = dict(saved.values) if saved is not None else dict.fromkeys(NAMES, 0.)
|
||||
values[name] = float(request.value) / 100
|
||||
manual = ManualCoordinates(current, tuple((n, values[n]) for n in NAMES)).validate()
|
||||
return {**profiles, fingerprint: replace(prior, manual_coordinates=manual if any(values.values()) else None)}
|
||||
|
||||
def _factor_editable(self, fingerprint: str) -> bool:
|
||||
if fingerprint not in GM_VEHICLES:
|
||||
return True
|
||||
@@ -289,6 +357,8 @@ class TorqueFeature:
|
||||
amended[fingerprint] = updated
|
||||
resolve_document(amended, fingerprint, basis)
|
||||
profiles = amended
|
||||
elif request.key.startswith("torque:surface:"):
|
||||
profiles = self._edit_manual(profiles, request, fingerprint, basis)
|
||||
elif request.key.startswith(("torque:ratio:", "torque:full_delay:", "torque:automatic_delay:", "torque:learning:", "torque:geometry_reset:")):
|
||||
profiles = self._edit_geometry(profiles, request, fingerprint, basis)
|
||||
elif request.key.startswith("torque:"):
|
||||
@@ -330,6 +400,7 @@ class TorqueFeature:
|
||||
else:
|
||||
return False
|
||||
encoded = serialize_document(profiles)
|
||||
manual_request = request.key.startswith("torque:surface:")
|
||||
geometry_request = request.key.startswith(("torque:ratio:", "torque:full_delay:", "torque:automatic_delay:",
|
||||
"torque:learning:", "torque:geometry_reset:"))
|
||||
# Fresh authority and source evidence immediately before the one Params replacement.
|
||||
@@ -337,9 +408,21 @@ class TorqueFeature:
|
||||
self._capability("torque") != capability or not self._readable(DOCUMENT_KEY) or
|
||||
self._raw(DOCUMENT_KEY) != request.expected or
|
||||
geometry_request and request.related_source != self._geometry_token(self.owner.vehicle_params()) or
|
||||
manual_request and (not self.authority("parked_preferences") or request.related_source != self._manual_token()) or
|
||||
request.key in TORQUE_CONFIRM_ACTIONS and not self.authority("parked_preferences") or
|
||||
any(not self._readable(name) or self._raw(name) != raw for name, raw in request.dependencies)):
|
||||
return False
|
||||
if manual_request:
|
||||
from openpilot.starpilot.saved_document import commit_exact
|
||||
from openpilot.starpilot.lateral.torque_settings import MAX_DOCUMENT_BYTES
|
||||
def authorized():
|
||||
return (self.authority("torque") and self.authority("parked_preferences") and
|
||||
self.vehicle_fingerprint() == request.vehicle_fingerprint and self._capability("torque") == capability and
|
||||
request.related_source == self._manual_token() and
|
||||
all(self._readable(name) and self._raw(name) == raw for name, raw in request.dependencies))
|
||||
result = commit_exact(self.params, key=DOCUMENT_KEY, max_bytes=MAX_DOCUMENT_BYTES, raw=encoded,
|
||||
expected=request.expected, authorized=authorized, temp_prefix=".manual-steering-")
|
||||
return result.committed and result.verified
|
||||
self.params.put(DOCUMENT_KEY, json.loads(encoded), block=True)
|
||||
saved = self._raw(DOCUMENT_KEY)
|
||||
return saved is not None and parse_document(saved) == profiles
|
||||
|
||||
@@ -29,7 +29,9 @@ def test_corrupt_cached_package_is_rejected(tmp_path):
|
||||
|
||||
|
||||
def test_shipped_offline_payload_matches_the_pinned_bundle():
|
||||
offline = Path(setup.__file__).with_name('offline')
|
||||
setup_file = setup.__file__
|
||||
assert setup_file is not None
|
||||
offline = Path(setup_file).with_name('offline')
|
||||
expected = set()
|
||||
for package in setup.approved()['packages']:
|
||||
digest = package.get('sha512', package.get('sha256'))
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"protected_inventory_sha256": "803e3f48381fb605404075f5d247a5830c23bd3ccf6abf12ec3aadd420608976", "schema_version": 1}
|
||||
{"protected_inventory_sha256": "dc0fc34a78b6ecec51ba5a4ecfd601088ed237535b321ef878019980d3b8b465", "schema_version": 1}
|
||||
|
||||
Reference in New Issue
Block a user