Files
StarPilot/openpilot/starpilot/longitudinal/profile_runtime.py
T
2026-10-28 01:09:47 -04:00

460 lines
22 KiB
Python

"""Validated saved longitudinal profiles resolved for the native planner."""
from __future__ import annotations
import math
from dataclasses import astuple, dataclass
from openpilot.common.constants import CV
from opendbc.car.structs import car
from opendbc.car.interfaces import ACCEL_MIN, ACCEL_MAX
from openpilot.starpilot.longitudinal.accel_profile import A_CRUISE_MAX_VALS_TRAFFIC_ALL, interpolate_accel_profile
from openpilot.starpilot.longitudinal.profile_document import (
DEFAULT_DECELERATION_PROFILE, active_personality_id, interpolate_category_curve, is_truck_fingerprint,
)
from openpilot.starpilot.longitudinal.profile_preferences import (
FOLLOW_MIN_SECONDS, FOLLOW_MAX_SECONDS, NAMES, SCALARS, TRAFFIC_JERK_SUFFIXES,
read_document_value, read_master_value, read_profile_health, read_traffic_health,
)
JERK_MIN = 0.25
JERK_MAX = 2.0
LEGACY_FOLLOW_BP = (45.0 * CV.MPH_TO_MS, 70.0 * CV.MPH_TO_MS)
TRAFFIC_BP = (0.0, 25.0) # m/s, not mph.
TRAFFIC_CRUISE_BRAKE_MAGNITUDE = 1.2 * 0.35 # Magnitude of A_CRUISE_MIN * 0.35.
@dataclass(frozen=True)
class ProfileSettings:
document: dict | None
profile_enabled: dict[str, bool]
follow: dict[str, tuple[float, float]]
jerk: dict[str, tuple[float, float, float, float, float]]
@dataclass(frozen=True)
class TrafficSettings:
valid: bool
reason: str
document: dict | None
profile_enabled: bool
follow: tuple[float, float]
jerk: tuple[tuple[float, float], ...]
@dataclass(frozen=True)
class ProfileTuning:
personality_id: str
follow_seconds: float
acceleration_jerk: float
deceleration_jerk: float
speed_jerk: float
speed_decrease_jerk: float
danger_jerk: float
acceleration_max: float | None = None
cruise_brake_magnitude: float | None = None
traffic_braking_custom: bool = False
custom_acceleration: bool = False
@dataclass(frozen=True)
class AppliedProfile:
follow_seconds: float
acceleration_max: float
cruise_brake_magnitude: float
acceleration_jerk: float
deceleration_jerk: float
speed_jerk: float
speed_decrease_jerk: float
danger_jerk: float
def _move(current: float, target: float, step: float) -> float:
return min(max(target, current - step), current + step)
class ProfileSmoother:
"""Bound live setting/personality changes and fade invalid input to stock."""
def __init__(self):
self.applied: AppliedProfile | None = None
def sample(self, target: ProfileTuning | None, personality, dt: float, *, traffic_mode: bool | None = False) -> AppliedProfile | None:
if type(dt) not in (int, float) or not math.isfinite(dt) or not 0.0 < dt <= 0.25:
self.applied = None
return None
if type(traffic_mode) is not bool:
self.applied = None
return None
personality_id = active_personality_id(traffic_mode, personality)
if personality_id not in ('traffic', 'aggressive', 'standard', 'relaxed'):
self.applied = None
return None
if target is not None:
values = (target.follow_seconds, target.acceleration_jerk, target.deceleration_jerk,
target.speed_jerk, target.speed_decrease_jerk, target.danger_jerk)
valid = (target.personality_id == personality_id and
type(target.traffic_braking_custom) is bool and
all(type(value) in (int, float) and math.isfinite(value) for value in values) and
FOLLOW_MIN_SECONDS <= target.follow_seconds <= FOLLOW_MAX_SECONDS and
all(JERK_MIN <= value <= JERK_MAX for value in values[1:]) and
(target.acceleration_max is None or
(type(target.acceleration_max) in (int, float) and math.isfinite(target.acceleration_max) and
0.0 <= target.acceleration_max <= ACCEL_MAX)) and
(target.cruise_brake_magnitude is None or
(type(target.cruise_brake_magnitude) in (int, float) and math.isfinite(target.cruise_brake_magnitude) and
(0.35 if traffic_mode else 0.5) <= target.cruise_brake_magnitude <= abs(ACCEL_MIN))))
if not valid:
target = None
if traffic_mode and target is None:
self.applied = None
return None
default_follow = {'traffic': 0.75, 'aggressive': 1.25, 'standard': 1.45, 'relaxed': 1.75}[personality_id]
default_jerk = 0.5 if personality_id == 'aggressive' else 1.0
default = AppliedProfile(default_follow, ACCEL_MAX, 1.2, default_jerk, default_jerk,
default_jerk, default_jerk, 1.0)
if target is None and self.applied is None:
return None
if target is None:
desired = default
else:
desired = AppliedProfile(target.follow_seconds,
min(target.acceleration_max if target.acceleration_max is not None else ACCEL_MAX, ACCEL_MAX),
min(target.cruise_brake_magnitude if target.cruise_brake_magnitude is not None else 1.2,
abs(ACCEL_MIN)),
target.acceleration_jerk, target.deceleration_jerk,
target.speed_jerk, target.speed_decrease_jerk, target.danger_jerk)
if self.applied is None and traffic_mode:
# The first Traffic frame fades from the ordinary native personality,
# not from Traffic's low-speed endpoint (which may be below this target).
ordinary_id = active_personality_id(False, personality)
ordinary_follow = {'aggressive': 1.25, 'standard': 1.45, 'relaxed': 1.75}.get(ordinary_id)
if ordinary_follow is None:
return None
ordinary_jerk = 0.5 if ordinary_id == 'aggressive' else 1.0
previous = AppliedProfile(ordinary_follow, ACCEL_MAX, 1.2, ordinary_jerk, ordinary_jerk,
ordinary_jerk, ordinary_jerk, 1.0)
else:
previous = self.applied or default
follow_step = max(dt, 0.0) * 1.0
accel_step = max(dt, 0.0) * 2.0
jerk_step = max(dt, 0.0) * 2.0
self.applied = AppliedProfile(
_move(previous.follow_seconds, desired.follow_seconds, follow_step),
_move(previous.acceleration_max, desired.acceleration_max, accel_step),
_move(previous.cruise_brake_magnitude, desired.cruise_brake_magnitude, accel_step),
_move(previous.acceleration_jerk, desired.acceleration_jerk, jerk_step),
_move(previous.deceleration_jerk, desired.deceleration_jerk, jerk_step),
_move(previous.speed_jerk, desired.speed_jerk, jerk_step),
_move(previous.speed_decrease_jerk, desired.speed_decrease_jerk, jerk_step),
_move(previous.danger_jerk, desired.danger_jerk, jerk_step),
)
if target is None and all(abs(a - b) < 1e-8 for a, b in zip(astuple(self.applied), astuple(default), strict=True)):
self.applied = None
return self.applied
def read_settings(params) -> ProfileSettings | None:
"""A saved preference is inert unless the host explicitly opts into this runtime."""
master = read_master_value(params)
if not master.valid or master.value is not True:
return None
health = read_profile_health(params, master=master)
if not health.dependencies_valid:
return None
try:
profile_enabled = dict.fromkeys(NAMES, True)
follow = {name: (health.number(SCALARS[name][0]), health.number(SCALARS[name][1])) for name in NAMES}
jerk = {name: (health.number(SCALARS[name][2]) / 100.0, health.number(SCALARS[name][3]) / 100.0,
health.number(SCALARS[name][4]) / 100.0, health.number(SCALARS[name][5]) / 100.0,
health.number(SCALARS[name][6]) / 100.0) for name in NAMES}
return ProfileSettings(health.document, profile_enabled, follow, jerk)
except ValueError:
return None
def read_traffic_settings(params) -> TrafficSettings:
health = read_traffic_health(params)
if not health.valid:
return TrafficSettings(False, health.reason, None, False, (0.75, 1.6), ())
follow = (health.number('TrafficFollow'), health.number('RelaxedFollow'))
jerk = tuple((health.number('Traffic' + suffix) / 100.0,
health.number('Relaxed' + suffix) / 100.0) for suffix in TRAFFIC_JERK_SUFFIXES)
return TrafficSettings(True, 'valid', health.document if health.master_on else None,
health.profile_enabled if health.master_on else False, follow, jerk)
def _traffic_value(points: tuple[float, float], speed: float) -> float:
ratio = min(max((speed - TRAFFIC_BP[0]) / (TRAFFIC_BP[1] - TRAFFIC_BP[0]), 0.0), 1.0)
return float(points[0] + (points[1] - points[0]) * ratio)
def resolve(settings: ProfileSettings | TrafficSettings | None, personality, v_ego: float, CP,
*, traffic_mode: bool | None = False) -> ProfileTuning | None:
if settings is None or type(traffic_mode) is not bool or not math.isfinite(v_ego) or v_ego < 0:
return None
personality_id = active_personality_id(traffic_mode, personality)
if traffic_mode:
if not isinstance(settings, TrafficSettings) or not settings.valid or len(settings.jerk) != 5:
return None
follow = _traffic_value(settings.follow, v_ego)
acceleration_max = interpolate_accel_profile(v_ego, A_CRUISE_MAX_VALS_TRAFFIC_ALL)
cruise_brake_magnitude = TRAFFIC_CRUISE_BRAKE_MAGNITUDE
traffic_braking_custom = False
document = settings.document
if document is not None and document['enabled'] and settings.profile_enabled:
categories = document['profiles']['traffic']
ev_tuning = CP.transmissionType == car.CarParams.TransmissionType.direct
truck_tuning = is_truck_fingerprint(str(CP.carFingerprint)) and not ev_tuning
for category in ('following', 'acceleration', 'braking'):
config = categories[category]
if config['preset'] in ('dom_default', 'selected_profile') or (category != 'following' and not config.get('legacyActivation', False)):
continue
value = interpolate_category_curve(category, v_ego, config, ev_tuning, truck_tuning)
if category == 'following':
follow = value
elif category == 'acceleration':
acceleration_max = value
else:
cruise_brake_magnitude = value
traffic_braking_custom = True
if (not all(math.isfinite(value) for value in (follow, acceleration_max, cruise_brake_magnitude)) or
not FOLLOW_MIN_SECONDS <= follow <= FOLLOW_MAX_SECONDS or
acceleration_max < 0.0 or not 0.35 <= cruise_brake_magnitude <= abs(ACCEL_MIN)):
return None
acceleration, deceleration, speed, speed_decrease, danger = (
_traffic_value(points, v_ego) for points in settings.jerk
)
return ProfileTuning('traffic', follow, acceleration, deceleration, speed, speed_decrease, danger,
min(acceleration_max, ACCEL_MAX), cruise_brake_magnitude,
traffic_braking_custom=traffic_braking_custom)
if not isinstance(settings, ProfileSettings):
return None
if personality_id not in settings.follow:
return None
low, high = settings.follow[personality_id]
follow = float(low + (high - low) * min(max((v_ego - LEGACY_FOLLOW_BP[0]) /
(LEGACY_FOLLOW_BP[1] - LEGACY_FOLLOW_BP[0]), 0.0), 1.0))
acceleration_max = None
cruise_brake_magnitude = None
custom_acceleration = False
document = settings.document
if document is not None and document['enabled'] and settings.profile_enabled[personality_id]:
categories = document['profiles'][personality_id]
ev_tuning = CP.transmissionType == car.CarParams.TransmissionType.direct
truck_tuning = is_truck_fingerprint(str(CP.carFingerprint)) and not ev_tuning
for category in ('following', 'acceleration', 'braking'):
config = categories[category]
if config['preset'] in ('dom_default', 'selected_profile') or (category != 'following' and not config.get('legacyActivation', False)):
continue
value = interpolate_category_curve(category, v_ego, config, ev_tuning, truck_tuning)
if not math.isfinite(value):
return None
if category == 'following':
follow = value
elif category == 'acceleration':
custom_acceleration = config['preset'] == 'custom'
acceleration_max = min(max(value, 0.0), ACCEL_MAX)
else:
cruise_brake_magnitude = min(max(value, 0.5), abs(ACCEL_MIN))
acceleration_jerk, deceleration_jerk, speed_jerk, speed_decrease_jerk, danger_jerk = settings.jerk[personality_id]
return ProfileTuning(personality_id, min(max(follow, FOLLOW_MIN_SECONDS), FOLLOW_MAX_SECONDS),
acceleration_jerk, deceleration_jerk, speed_jerk, speed_decrease_jerk, danger_jerk,
acceleration_max, cruise_brake_magnitude, custom_acceleration=custom_acceleration)
@dataclass(frozen=True)
class SelectedProfileTuning:
acceleration_max: float | None = None
cruise_brake_magnitude: float | None = None
braking_style: str = 'standard'
@dataclass(frozen=True)
class GlobalPowertrainPreset:
ev: bool
truck: bool
def read_global_powertrain_preset(params, CP) -> GlobalPowertrainPreset | None:
from opendbc.car.gm.feature_capabilities import longitudinal_supported
from opendbc.car.gm.truck_longitudinal import truck_tuning_supported
from openpilot.starpilot.saved_source import read_saved
if not longitudinal_supported(CP):
return None
try:
ev, ev_readable = read_saved(params, 'EVTuning', 8)
truck, truck_readable = read_saved(params, 'TruckTuning', 8)
if not ev_readable or not truck_readable or ev not in (None, b'0', b'1') or truck not in (None, b'0', b'1'):
return None
if not truck_tuning_supported(CP):
truck = None # A saved choice from another truck cannot activate this owner.
if ev is None and truck is None:
return None # Absence preserves the existing final-CP response.
truck_selected = truck == b'1' and truck_tuning_supported(CP)
return GlobalPowertrainPreset((CP.transmissionType == car.CarParams.TransmissionType.direct if ev is None else ev == b'1') and not truck_selected,
truck_selected)
except (OSError, RuntimeError, TypeError, ValueError):
return None
def selected_profiles_requested(document: dict | None) -> bool:
if document is None:
return False
if document['selectedAccelerationProfile'] != 'dom_default' or document['selectedDecelerationProfile'] != 'dom_default':
return True
return any(config['preset'] not in ('dom_default', 'selected_profile') and not config.get('legacyActivation', False)
for profile in document['profiles'].values() for category, config in profile.items() if category != 'following')
def resolve_selected_profiles(document: dict | None, personality, v_ego: float, CP, *, traffic_mode: bool | None = False,
legacy: ProfileTuning | None = None, global_powertrain: GlobalPowertrainPreset | None = None) -> SelectedProfileTuning | None:
"""Resolve only acceleration/braking; never enable following or jerk tuning.
Migration provenance preserves the former master/curve gates until the user
explicitly selects inheritance or an override. Global deceleration retains
the historical response scale; explicit personality curves retain theirs.
"""
identity = active_personality_id(traffic_mode, personality)
if document is None or identity is None or not math.isfinite(v_ego) or v_ego < 0:
return None
ev = CP.transmissionType == car.CarParams.TransmissionType.direct
truck = is_truck_fingerprint(str(CP.carFingerprint)) and not ev
acceleration = braking = None
style = 'standard'
for category in ('acceleration', 'braking'):
config = document['profiles'][identity][category]
preset = config['preset']
inherited = preset == 'selected_profile'
if config.get('legacyActivation', False):
# Old active overrides are already applied by the original smoother.
active_value = getattr(legacy, 'acceleration_max' if category == 'acceleration' else 'cruise_brake_magnitude', None)
if active_value is not None or category == 'acceleration' or traffic_mode is True:
continue
inherited = True # Old global response was ordinary-cruise-only.
if inherited:
preset = document['selectedAccelerationProfile' if category == 'acceleration' else 'selectedDecelerationProfile']
if preset == 'dom_default':
if category == 'acceleration' and inherited and traffic_mode is False and global_powertrain is not None:
preset = 'standard' # Original explicit EV/Truck choice owns the global Standard fallback.
else:
continue
if category == 'braking' and inherited:
value = {'standard': 1.2, 'eco': 0.6, 'sport': 2.4}[preset]
else:
selected_ev, selected_truck = ev, truck
if category == 'acceleration' and inherited and traffic_mode is False and global_powertrain is not None:
selected_ev, selected_truck = global_powertrain.ev, global_powertrain.truck
value = interpolate_category_curve(category, v_ego,
{'preset': preset, 'curve': []} if inherited else config, selected_ev, selected_truck)
if not math.isfinite(value):
return None
if category == 'acceleration':
acceleration = min(max(value, 0.0), ACCEL_MAX)
else:
braking = min(max(value, 0.35 if traffic_mode else 0.5), abs(ACCEL_MIN))
style = preset if preset in ('eco', 'sport') else 'standard'
return SelectedProfileTuning(acceleration, braking, style)
class ProfileHost:
"""Bounded local Params refresh; a failed read expires the previous tuning."""
REFRESH_NS = 1_000_000_000
MAX_AGE_NS = 2_000_000_000
def __init__(self, params):
self.params = params
self.disabled = False
self.selected_document: dict | None = None
self.selected_powertrain: GlobalPowertrainPreset | None = None
self.selected_attempt_ns = -self.REFRESH_NS
self.selected_success_ns = -1
self.settings: ProfileSettings | TrafficSettings | None = None
self.last_attempt_ns = -self.REFRESH_NS
self.last_success_ns = -1
self.last_mode: bool | None = None
self.global_braking_response: str | None = None
self.global_last_attempt_ns = -self.REFRESH_NS
self.global_last_success_ns = -1
def sample_selected(self, now_ns: int, personality, v_ego: float, CP, *, traffic_mode: bool | None = False,
legacy: ProfileTuning | None = None) -> SelectedProfileTuning | None:
if now_ns < 0 or now_ns < self.selected_attempt_ns:
self.selected_document = None
self.selected_powertrain = None
self.selected_attempt_ns = now_ns - self.REFRESH_NS
self.selected_success_ns = -1
if now_ns - self.selected_attempt_ns >= self.REFRESH_NS:
self.selected_attempt_ns = now_ns
try:
saved = read_document_value(self.params)
self.selected_document = saved.value if saved.valid and isinstance(saved.value, dict) else None
self.selected_powertrain = read_global_powertrain_preset(self.params, CP)
self.selected_success_ns = now_ns if saved.valid else -1
except (OSError, RuntimeError, ValueError, TypeError, KeyError):
self.selected_document = None
self.selected_powertrain = None
self.selected_success_ns = -1
if self.selected_success_ns < 0 or now_ns - self.selected_success_ns > self.MAX_AGE_NS:
return None
return resolve_selected_profiles(self.selected_document, personality, v_ego, CP,
traffic_mode=traffic_mode, legacy=legacy, global_powertrain=self.selected_powertrain)
def sample_global_braking(self, now_ns: int) -> str | None:
if now_ns < 0 or now_ns < self.global_last_attempt_ns:
self.global_braking_response = None
self.global_last_attempt_ns = now_ns - self.REFRESH_NS
self.global_last_success_ns = -1
if now_ns - self.global_last_attempt_ns >= self.REFRESH_NS:
self.global_last_attempt_ns = now_ns
try:
saved = read_document_value(self.params)
if saved.valid and saved.value is None:
self.global_braking_response = DEFAULT_DECELERATION_PROFILE
elif saved.valid and isinstance(saved.value, dict):
response = saved.value.get("selectedDecelerationProfile")
self.global_braking_response = response if isinstance(response, str) else None
else:
self.global_braking_response = None
self.global_last_success_ns = now_ns if self.global_braking_response is not None else -1
except (OSError, RuntimeError, ValueError, TypeError, KeyError):
self.global_braking_response = None
self.global_last_success_ns = -1
if self.global_last_success_ns < 0 or now_ns - self.global_last_success_ns > self.MAX_AGE_NS:
return None
return self.global_braking_response
def sample(self, now_ns: int, personality, v_ego: float, CP, *, traffic_mode: bool | None = False) -> ProfileTuning | None:
if type(traffic_mode) is not bool:
self.disabled = False
self.settings = None
self.last_attempt_ns = now_ns - self.REFRESH_NS
self.last_success_ns = -1
self.last_mode = None
return None
if self.last_mode is not traffic_mode:
self.settings = None
self.last_attempt_ns = now_ns - self.REFRESH_NS
self.last_success_ns = -1
self.last_mode = traffic_mode
if now_ns < 0 or now_ns < self.last_attempt_ns:
self.disabled = False
self.settings = None
self.last_attempt_ns = now_ns - self.REFRESH_NS
self.last_success_ns = -1
if now_ns - self.last_attempt_ns >= self.REFRESH_NS:
self.last_attempt_ns = now_ns
try:
master = read_master_value(self.params)
self.disabled = (not traffic_mode and master.valid and master.value is False and
read_document_value(self.params).valid)
self.settings = read_traffic_settings(self.params) if traffic_mode else read_settings(self.params)
self.last_success_ns = now_ns
except (OSError, RuntimeError, ValueError, TypeError):
self.disabled = False
self.settings = None
if self.last_success_ns < 0 or now_ns - self.last_success_ns > self.MAX_AGE_NS:
self.disabled = False
return None
return resolve(self.settings, personality, v_ego, CP, traffic_mode=traffic_mode)