mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-06 20:43:46 +08:00
395 lines
18 KiB
Python
395 lines
18 KiB
Python
# Copyright (c) 2018, Comma.ai, Inc.
|
|
# Copyright (c) 2026, firestar5683 and StarPilot contributors
|
|
# SPDX-License-Identifier: MIT
|
|
"""Original-bound automatic effects using the existing Dom universal FLM equations."""
|
|
|
|
import hashlib
|
|
import math
|
|
import re
|
|
import struct
|
|
from functools import lru_cache
|
|
from types import MappingProxyType
|
|
|
|
REVISION = 'torque-selected-effect-v2'
|
|
MANIFEST_REVISION = 'dom-universal-30-original-effects-v5'
|
|
DOM_REVISION = 'b220a37f4bd119e402225a7aa16a7cd84878d0af'
|
|
NAMES = (
|
|
'ff_gain_left',
|
|
'ff_gain_right',
|
|
'turn_in_boost_left',
|
|
'turn_in_boost_right',
|
|
'unwind_taper_left',
|
|
'unwind_taper_right',
|
|
'center_taper_max',
|
|
'highway_center_taper_max',
|
|
'center_deadband_crawl_deg',
|
|
'center_deadband_low_deg',
|
|
'center_deadband_mid_deg',
|
|
'center_deadband_fast_deg',
|
|
'center_deadband_highway_deg',
|
|
'turn_in_threshold_reduction_left',
|
|
'turn_in_threshold_reduction_right',
|
|
'unwind_threshold_increase_left',
|
|
'unwind_threshold_increase_right',
|
|
'crawl_turn_in_ff_boost_left',
|
|
'crawl_turn_in_ff_boost_right',
|
|
'low_speed_angle_assist_max_torque',
|
|
'curvy_speed_min',
|
|
'curvy_speed_max',
|
|
'curvy_turn_in_trim_speed_min',
|
|
'curvy_turn_in_trim_speed_max',
|
|
'curvy_turn_in_trim_left',
|
|
'curvy_turn_in_trim_right',
|
|
'curvy_unwind_floor_relief_left',
|
|
'curvy_unwind_floor_relief_right',
|
|
'curvy_unwind_extra_reduction_left',
|
|
'curvy_unwind_extra_reduction_right',
|
|
)
|
|
WINDOWS = ('curvy_speed_min', 'curvy_speed_max', 'curvy_turn_in_trim_speed_min', 'curvy_turn_in_trim_speed_max')
|
|
DEADBANDS = tuple(n for n in NAMES if n.startswith('center_deadband_'))
|
|
GROUPS = {
|
|
'ff.left': ('ff_gain_left', 'turn_in_boost_left', 'unwind_taper_left', 'crawl_turn_in_ff_boost_left'),
|
|
'ff.right': ('ff_gain_right', 'turn_in_boost_right', 'unwind_taper_right', 'crawl_turn_in_ff_boost_right'),
|
|
'center': ('center_taper_max', 'highway_center_taper_max'),
|
|
'friction.left': ('turn_in_threshold_reduction_left', 'unwind_threshold_increase_left'),
|
|
'friction.right': ('turn_in_threshold_reduction_right', 'unwind_threshold_increase_right'),
|
|
'turn.window.left': ('curvy_turn_in_trim_speed_min', 'curvy_turn_in_trim_speed_max', 'curvy_turn_in_trim_left'),
|
|
'turn.window.right': ('curvy_turn_in_trim_speed_min', 'curvy_turn_in_trim_speed_max', 'curvy_turn_in_trim_right'),
|
|
'unwind.window.left': ('curvy_speed_min', 'curvy_speed_max', 'curvy_unwind_floor_relief_left', 'curvy_unwind_extra_reduction_left'),
|
|
'unwind.window.right': ('curvy_speed_min', 'curvy_speed_max', 'curvy_unwind_floor_relief_right', 'curvy_unwind_extra_reduction_right'),
|
|
'angle.assist': ('low_speed_angle_assist_max_torque',),
|
|
'deadband.low': ('center_deadband_crawl_deg', 'center_deadband_low_deg', 'center_deadband_mid_deg'),
|
|
'deadband.high': ('center_deadband_fast_deg', 'center_deadband_highway_deg'),
|
|
}
|
|
# Numerical probe groups are separate from one-coordinate trial ownership.
|
|
PROBE_GROUPS = dict(GROUPS, all30=NAMES)
|
|
|
|
# Exact existing Dom universal raw domains, frozen without controller imports.
|
|
# This prevents adaptive_profile -> canonical -> torque_surface -> policy ->
|
|
# torque_stages -> selected_effects import cycles. Tests compare every row to
|
|
# the actual selected source constructor; changing its bounds changes manifest.
|
|
RAW_DOMAINS = (
|
|
('ff_gain_left', 0.0, (-0.4, 0.6)),
|
|
('ff_gain_right', 0.0, (-0.4, 0.6)),
|
|
('turn_in_boost_left', 0.0, (-0.1, 2.8)),
|
|
('turn_in_boost_right', 0.0, (-0.1, 2.8)),
|
|
('unwind_taper_left', 0.0, (0.0, 12.0)),
|
|
('unwind_taper_right', 0.0, (0.0, 12.0)),
|
|
('center_taper_max', 0.0, (0.0, 0.18)),
|
|
('highway_center_taper_max', 0.0, (0.0, 0.18)),
|
|
('center_deadband_crawl_deg', 0.0, (0.0, 0.3)),
|
|
('center_deadband_low_deg', 0.0, (0.0, 0.3)),
|
|
('center_deadband_mid_deg', 0.0, (0.0, 0.2)),
|
|
('center_deadband_fast_deg', 0.0, (0.0, 0.12)),
|
|
('center_deadband_highway_deg', 0.0, (0.0, 0.08)),
|
|
('turn_in_threshold_reduction_left', 0.0, (0.0, 2.0)),
|
|
('turn_in_threshold_reduction_right', 0.0, (0.0, 2.0)),
|
|
('unwind_threshold_increase_left', 0.0, (0.0, 12.0)),
|
|
('unwind_threshold_increase_right', 0.0, (0.0, 12.0)),
|
|
('crawl_turn_in_ff_boost_left', 0.0, (0.0, 0.5)),
|
|
('crawl_turn_in_ff_boost_right', 0.0, (0.0, 0.5)),
|
|
('low_speed_angle_assist_max_torque', 0.0, (0.0, 0.8)),
|
|
('curvy_speed_min', 7.2, (4.0, 12.0)),
|
|
('curvy_speed_max', 21.5, (14.0, 25.0)),
|
|
('curvy_turn_in_trim_speed_min', 11.5, (8.0, 16.0)),
|
|
('curvy_turn_in_trim_speed_max', 20.5, (14.0, 25.0)),
|
|
('curvy_turn_in_trim_left', 0.0, (0.0, 0.2)),
|
|
('curvy_turn_in_trim_right', 0.0, (0.0, 0.2)),
|
|
('curvy_unwind_floor_relief_left', 0.0, (0.0, 0.45)),
|
|
('curvy_unwind_floor_relief_right', 0.0, (0.0, 0.45)),
|
|
('curvy_unwind_extra_reduction_left', 0.0, (0.0, 0.45)),
|
|
('curvy_unwind_extra_reduction_right', 0.0, (0.0, 0.45)),
|
|
)
|
|
MANIFEST_SHA256 = hashlib.sha256(
|
|
repr(
|
|
(MANIFEST_REVISION, DOM_REVISION, NAMES, GROUPS, RAW_DOMAINS, 'window=original*(1+coordinate);dynamic=coordinate*physical-normalizer;direct=coordinate')
|
|
).encode()
|
|
).hexdigest()
|
|
|
|
|
|
def finite(v):
|
|
return type(v) in (int, float) and math.isfinite(v)
|
|
|
|
|
|
def canonical_names(names):
|
|
if not 1 <= len(names) <= 4 or len(set(names)) != len(names) or any(n not in NAMES for n in names):
|
|
raise ValueError('one to four canonical FLM knobs per trial')
|
|
return tuple(n for n in NAMES if n in names)
|
|
|
|
|
|
def probe_names(names):
|
|
"""Numerical measurements only; actual trial's four-knob cap is unchanged."""
|
|
if (type(names) is not tuple or not 1 <= len(names) <= len(NAMES) or
|
|
any(type(name) is not str for name in names) or len(set(names)) != len(names) or
|
|
any(name not in NAMES for name in names)):
|
|
raise ValueError('one to thirty exact canonical numerical probe names')
|
|
return tuple(name for name in NAMES if name in names)
|
|
|
|
|
|
_RAW_BY_NAME = MappingProxyType({name: (value, limits) for name, value, limits in RAW_DOMAINS})
|
|
|
|
|
|
def _coordinate_bounds_static(name) -> tuple[float, float]:
|
|
if name not in NAMES:
|
|
raise ValueError('unknown canonical coordinate')
|
|
original, (raw_low, raw_high) = _RAW_BY_NAME[name]
|
|
# Dynamic degree/torque fields are normalized against own-vehicle physical
|
|
# units at their actual hook. Their raw domains must be checked THERE; .25 is
|
|
# not itself degrees/normalized torque when the normalizer differs from one.
|
|
if name in DEADBANDS or name == 'low_speed_angle_assist_max_torque':
|
|
return 0.0, 0.25
|
|
if name in WINDOWS:
|
|
return max(-0.25, raw_low / original - 1), min(0.25, raw_high / original - 1)
|
|
return max(-0.25, raw_low), min(0.25, raw_high)
|
|
|
|
|
|
_COORDINATE_BOUNDS = MappingProxyType({name: _coordinate_bounds_static(name) for name in NAMES})
|
|
|
|
|
|
def coordinate_bounds(name) -> tuple[float, float]:
|
|
if type(name) is not str or name not in _COORDINATE_BOUNDS:
|
|
raise ValueError('unknown canonical coordinate')
|
|
return _COORDINATE_BOUNDS[name]
|
|
|
|
|
|
def dynamic_coordinate_bounds(name, physical_normalizer):
|
|
if name not in DEADBANDS and name != 'low_speed_angle_assist_max_torque':
|
|
raise ValueError('not a physically normalized coordinate')
|
|
if not finite(physical_normalizer) or physical_normalizer < 0:
|
|
raise ValueError('actual physical normalizer')
|
|
if physical_normalizer == 0:
|
|
return 0.0, 0.0
|
|
_, (raw_low, raw_high) = _RAW_BY_NAME[name]
|
|
low, high = coordinate_bounds(name)
|
|
return max(low, raw_low / physical_normalizer), min(high, raw_high / physical_normalizer)
|
|
|
|
|
|
def validate_trial(coordinates):
|
|
if type(coordinates) is not dict:
|
|
raise ValueError('keyed original-effect coordinates')
|
|
names = canonical_names(tuple(coordinates))
|
|
for name, value in coordinates.items():
|
|
low, high = coordinate_bounds(name)
|
|
if not finite(value) or not low <= value <= high:
|
|
raise ValueError('original effect envelope')
|
|
return tuple((name, float(coordinates[name])) for name in names)
|
|
|
|
|
|
def parameters_for(names):
|
|
canonical = canonical_names(names)
|
|
if tuple(names) != canonical:
|
|
raise ValueError('noncanonical profile order')
|
|
return _parameters_for_cached(MANIFEST_SHA256, canonical)
|
|
|
|
|
|
@lru_cache(maxsize=64)
|
|
def _parameters_for_cached(manifest, names):
|
|
from openpilot.starpilot.flm.adaptive_profile import Parameter
|
|
|
|
return tuple(Parameter('effect.universal.' + n, REVISION, *coordinate_bounds(n), 0.25, 0.005) for n in names)
|
|
|
|
|
|
def full_original_anchor(baseline_revision):
|
|
if type(baseline_revision) is not str or re.fullmatch('[a-f0-9]{64}', baseline_revision) is None:
|
|
raise ValueError('actual original selected source baseline')
|
|
return _full_original_anchor_cached(MANIFEST_SHA256, baseline_revision)
|
|
|
|
|
|
@lru_cache(maxsize=32)
|
|
def _full_original_anchor_cached(manifest, baseline_revision):
|
|
original = tuple((name, float(value)) for name, value, _ in RAW_DOMAINS)
|
|
return hashlib.sha256(repr((manifest, baseline_revision, original)).encode()).hexdigest()
|
|
|
|
|
|
def original_anchor(baseline_revision, names):
|
|
names = canonical_names(names)
|
|
return _original_anchor_cached(full_original_anchor(baseline_revision), names)
|
|
|
|
|
|
@lru_cache(maxsize=32)
|
|
def _original_anchor_cached(full_anchor, names):
|
|
return hashlib.sha256(repr((full_anchor, names)).encode()).hexdigest()
|
|
|
|
|
|
def full_profile_sha256(baseline_revision, coordinates):
|
|
values = dict(coordinates)
|
|
if set(values) != set(NAMES):
|
|
raise ValueError('exact full30 original coordinates')
|
|
ordered = []
|
|
for name in NAMES:
|
|
value = values[name]
|
|
low, high = coordinate_bounds(name)
|
|
if not finite(value) or not low <= value <= high:
|
|
raise ValueError('full30 original effect envelope')
|
|
ordered.append(float(value))
|
|
# Packed content keys distinguish signed zero and avoid cache aliasing bool/int
|
|
# or retaining caller-owned mutable maps. The original wire digest is unchanged.
|
|
return _full_profile_sha256_cached(full_original_anchor(baseline_revision), struct.pack('!30d', *ordered))
|
|
|
|
|
|
@lru_cache(maxsize=128)
|
|
def _full_profile_sha256_cached(full_anchor, packed):
|
|
ordered = tuple(zip(NAMES, struct.unpack('!30d', packed), strict=True))
|
|
return hashlib.sha256(repr((full_anchor, ordered)).encode()).hexdigest()
|
|
|
|
|
|
class CanonicalSurfaceEffects:
|
|
"""Admitted automatic surface effects for controller execution and replay.
|
|
|
|
Admission binds full_profile_sha256, manifest,
|
|
the selected baseline, native delivery, and trial dimensions in its proof.
|
|
No Params, fitting, JSON, cloned controller or native writes belong here.
|
|
"""
|
|
|
|
def __init__(self, coordinates, source_baseline_revision, *, accumulated=None, allow_limited_profile=False):
|
|
from openpilot.starpilot.flm import torque_surface as surface_math
|
|
|
|
if type(allow_limited_profile) is not bool:
|
|
raise ValueError('explicit bounded profile policy')
|
|
self.allow_limited_profile = allow_limited_profile
|
|
trial = validate_trial(coordinates)
|
|
values = dict.fromkeys(NAMES, 0.0)
|
|
if accumulated is not None:
|
|
if type(accumulated) is not dict or any(n not in NAMES for n in accumulated):
|
|
raise ValueError('accumulated canonical profile')
|
|
for n, v in accumulated.items():
|
|
low, high = coordinate_bounds(n)
|
|
if not finite(v) or not low <= v <= high:
|
|
raise ValueError('accumulated original effect envelope')
|
|
values.update(accumulated)
|
|
values.update(dict(trial))
|
|
self.values = values
|
|
self.names = tuple(n for n, _ in trial)
|
|
self.applied_coordinates = tuple(v for _, v in trial)
|
|
self.original_effect_anchor = original_anchor(source_baseline_revision, self.names)
|
|
self.full_original_anchor = full_original_anchor(source_baseline_revision)
|
|
self.full_profile_sha256 = full_profile_sha256(source_baseline_revision, values)
|
|
self.math = surface_math
|
|
# Existing Dom UNIVERSAL neutral defaults: zero amplitude; shared window
|
|
# domains are exactly the already-global source's domains, not Ioniq gains.
|
|
raw = dict(surface_math._GM_NEUTRAL)
|
|
for n, value in values.items():
|
|
if n in WINDOWS:
|
|
raw[n] = raw[n] * (1 + value)
|
|
elif n in DEADBANDS or n == 'low_speed_angle_assist_max_torque':
|
|
raw[n] = 0.0 # Own-vehicle physical normalizer supplied at actual hook.
|
|
else:
|
|
raw[n] = value
|
|
self.surface = surface_math.GmSurface('torque_universal', raw)
|
|
self.stage_calls = self.friction_calls = self.deadzone_calls = self.output_calls = 0
|
|
self.frame_ns = 0
|
|
|
|
def begin_frame(self, control_ns):
|
|
if type(control_ns) is not int or control_ns <= self.frame_ns:
|
|
raise ValueError('nonmonotonic effect frame')
|
|
self.frame_ns = control_ns
|
|
self.stage_calls = self.friction_calls = self.deadzone_calls = self.output_calls = 0
|
|
|
|
def _check(self, anchor):
|
|
if anchor != self.original_effect_anchor:
|
|
raise ValueError('foreign original FLM anchor')
|
|
|
|
@property
|
|
def pid_error_calls(self):
|
|
return self.deadzone_calls
|
|
|
|
def selected_pid_error(self, frame, *, original_anchor):
|
|
from openpilot.starpilot.flm.pid_stages import canonical_error
|
|
|
|
return canonical_error(self, frame, original_anchor=original_anchor)
|
|
|
|
def selected_pid_stage(self, frame, *, original_anchor):
|
|
from openpilot.starpilot.flm.pid_stages import canonical_feedforward
|
|
|
|
return canonical_feedforward(self, frame, original_anchor=original_anchor)
|
|
|
|
def selected_stage(self, frame, *, original_anchor):
|
|
self._check(original_anchor)
|
|
self.stage_calls += 1
|
|
if self.stage_calls != 1:
|
|
raise ValueError('duplicate actual FLM stage')
|
|
if not frame.valid() or not frame.active or frame.driver_override or (frame.limited and (frame.curvature_limited or not self.allow_limited_profile)):
|
|
return frame.baseline_ff, frame.baseline_threshold
|
|
m, s = self.math, self.surface
|
|
# Exact existing Dom universal controller order; notably no invented reciprocal
|
|
# center compensation or substituted speed/phase/shape equations.
|
|
center = m._gm_get_flm_full_surface_center_taper_scale(s, s.profile, frame.setpoint, frame.speed, True)
|
|
scale = m._gm_get_flm_full_surface_ff_scale(s, s.profile, frame.setpoint, frame.jerk, frame.speed, True) * center
|
|
threshold = m._gm_get_flm_full_surface_friction_threshold(s, s.profile, frame.baseline_threshold, frame.speed, frame.setpoint, frame.jerk, True)
|
|
ff = frame.baseline_ff if scale == 1 else frame.baseline_ff * scale
|
|
if (
|
|
not finite(ff)
|
|
or not finite(threshold)
|
|
or threshold <= 0
|
|
or abs(ff - frame.baseline_ff) > 0.25 * abs(frame.baseline_ff) + 1e-12
|
|
or abs(threshold - frame.baseline_threshold) > 0.25 * frame.baseline_threshold + 1e-12
|
|
):
|
|
raise ValueError('composed original selected effect envelope')
|
|
return ff, threshold
|
|
|
|
def selected_friction(self, baseline, *, original_anchor):
|
|
self._check(original_anchor)
|
|
self.friction_calls += 1
|
|
if self.friction_calls != 1 or not finite(baseline):
|
|
raise ValueError('actual friction stage')
|
|
# All canonical friction controls use actual threshold/deadzone. No invented31st gain.
|
|
return baseline
|
|
|
|
def selected_deadzone(self, speed, baseline_accel, accel_per_degree, friction_accel, requested_accel, *, original_anchor):
|
|
self._check(original_anchor)
|
|
self.deadzone_calls += 1
|
|
if (
|
|
self.deadzone_calls != 1
|
|
or not all(finite(v) for v in (speed, baseline_accel, accel_per_degree, friction_accel, requested_accel))
|
|
or baseline_accel < 0
|
|
or accel_per_degree <= 0
|
|
):
|
|
raise ValueError('actual selected VM/friction deadzone')
|
|
if not any(self.values[n] for n in DEADBANDS):
|
|
return baseline_accel
|
|
raw = dict(self.surface.knobs)
|
|
normalizer = abs(friction_accel) / accel_per_degree
|
|
for n in DEADBANDS:
|
|
low, high = dynamic_coordinate_bounds(n, normalizer)
|
|
if not low <= self.values[n] <= high:
|
|
raise ValueError('actual angular hardbound')
|
|
raw[n] = self.values[n] * normalizer
|
|
surface = self.math.GmSurface('torque_universal', raw) # Exact existing angular hardbounds retained.
|
|
extra_deg = self.math._gm_get_flm_full_surface_center_deadband_deg(surface, surface.profile, speed)
|
|
delta = extra_deg * accel_per_degree
|
|
if abs(delta) > 0.25 * abs(friction_accel) + 1e-12:
|
|
raise ValueError('original friction-scaled deadzone envelope')
|
|
return baseline_accel + delta
|
|
|
|
def selected_output(self, speed, setpoint, desired_angle_deg, actual_angle_deg, baseline_torque, friction_torque, *, original_anchor):
|
|
self._check(original_anchor)
|
|
self.output_calls += 1
|
|
if self.output_calls != 1 or not all(finite(v) for v in (speed, setpoint, desired_angle_deg, actual_angle_deg, baseline_torque, friction_torque)):
|
|
raise ValueError('actual selected angle-assist stage')
|
|
value = self.values['low_speed_angle_assist_max_torque']
|
|
if value == 0 or speed < 0.1 * 0.44704:
|
|
return baseline_torque
|
|
# Additional automatic bound uses own selected torque/friction. Dom raw max
|
|
# and source tracking/unwind gates remain unchanged, including its branch
|
|
# discontinuity. Actual replay must reject a candidate failing this envelope.
|
|
normalizer = max(abs(baseline_torque), abs(friction_torque))
|
|
if normalizer == 0:
|
|
return baseline_torque
|
|
low, high = dynamic_coordinate_bounds('low_speed_angle_assist_max_torque', normalizer)
|
|
if not low <= value <= high:
|
|
raise ValueError('actual assist torque hardbound')
|
|
raw = dict(self.surface.knobs)
|
|
raw['low_speed_angle_assist_max_torque'] = value * normalizer
|
|
surface = self.math.GmSurface('torque_universal', raw)
|
|
# Dom passes its internal output_torque directly into this helper before the
|
|
# final return-sign conversion. Common owner supplies that SAME policy sign.
|
|
candidate = self.math._gm_get_flm_full_surface_low_speed_angle_assist_torque(
|
|
surface, surface.profile, desired_angle_deg, actual_angle_deg, baseline_torque, speed
|
|
)
|
|
if not finite(candidate) or abs(candidate - baseline_torque) > 0.25 * normalizer + 1e-12:
|
|
raise ValueError('actual angle-assist original effect envelope')
|
|
return candidate
|
|
|
|
def metadata(self):
|
|
return (MANIFEST_SHA256, self.original_effect_anchor, self.full_profile_sha256, self.names)
|