This commit is contained in:
firestar5683
2026-07-09 21:44:17 -05:00
parent 6cc8e9b86a
commit f88ef7ecc2
16 changed files with 4310 additions and 49 deletions
Binary file not shown.
+3
View File
@@ -318,6 +318,9 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"ForceStopDistanceOffset", {PERSISTENT, INT, "0", "0", 2}},
{"ForceStandstill", {PERSISTENT, BOOL, "0", "0", 2}},
{"ForceTorqueController", {PERSISTENT, BOOL, "0", "0", 3}},
{"FTMActiveOverrides", {PERSISTENT, JSON, "{}", "{}", 2}},
{"FTMActiveProfileId", {PERSISTENT, STRING, "", "", 2}},
{"FTMTrialApplied", {PERSISTENT, BOOL, "0", "0", 2}},
{"FPSCounter", {PERSISTENT, BOOL, "1", "0", 3}},
{"GalaxyDashboardStats", {PERSISTENT | DONT_LOG, JSON, "{}", "{}"}},
{"StarPilotApiToken", {PERSISTENT | DONT_LOG, STRING, "", "", 0}},
Binary file not shown.
@@ -90,6 +90,7 @@ class LatControlTorque(LatControl):
self.is_silverado = CP.carFingerprint in SILVERADO_CARS
self.is_gm = CP.brand == "gm"
self.is_hkg_canfd_torque = CP.brand == "hyundai" and bool(CP.flags & HyundaiFlags.CANFD)
self.ftm_surface_profile_key = get_ftm_surface_profile_key(CP.carFingerprint, torque_control=True)
if self.is_ioniq_6:
self.low_speed_reset_threshold = min(self.low_speed_reset_threshold, IONIQ_6_LOW_SPEED_PID_RESET_SPEED)
self.use_bolt_ff_scaling = self.is_bolt_2022_2023 or self.is_bolt_2018_2021 or self.is_bolt_2017
@@ -157,6 +158,7 @@ class LatControlTorque(LatControl):
def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, curvature_limited, lat_delay, calibrated_pose, model_data, starpilot_toggles):
pid_log = log.ControlsState.LateralTorqueState.new_message()
pid_log.version = VERSION
set_ftm_runtime_overrides(getattr(starpilot_toggles, "ftm_active_overrides", None))
if not active:
output_torque = 0.0
pid_log.active = False
@@ -324,6 +326,15 @@ class LatControlTorque(LatControl):
friction_threshold = CIVIC_BOSCH_MODIFIED_B_FIXED_FRICTION_THRESHOLD
friction_scale = get_civic_bosch_modified_b_friction_scale(CS.vEgo, setpoint, desired_lateral_jerk)
friction_scale = 1.0 + ((friction_scale - 1.0) * civic_bosch_modified_a_center_taper)
if self.ftm_surface_profile_key and not ioniq_6_active:
universal_ftm_profile = self.ftm_surface_profile_key == FTM_UNIVERSAL_PROFILE_KEY
ftm_full_surface_center_taper = get_ftm_full_surface_center_taper_scale(self.ftm_surface_profile_key, setpoint, CS.vEgo,
include_base_center=universal_ftm_profile)
ff *= get_ftm_full_surface_ff_scale(self.ftm_surface_profile_key, setpoint, desired_lateral_jerk, CS.vEgo,
include_base_ff=universal_ftm_profile) * ftm_full_surface_center_taper
friction_threshold = get_ftm_full_surface_friction_threshold(self.ftm_surface_profile_key, friction_threshold, CS.vEgo,
setpoint, desired_lateral_jerk,
include_base_threshold=universal_ftm_profile)
if trailer_load_kg > 0.0:
ff *= get_trailer_lateral_ff_scale(trailer_load_kg, CS.vEgo, setpoint)
friction_scale *= get_trailer_lateral_friction_scale(trailer_load_kg, CS.vEgo, setpoint)
@@ -353,6 +364,11 @@ class LatControlTorque(LatControl):
actual_angle_no_offset = CS.steeringAngleDeg - params.angleOffsetDeg
output_torque = get_ioniq_6_low_speed_angle_assist_torque(desired_angle_no_offset, actual_angle_no_offset,
output_torque, CS.vEgo)
elif self.ftm_surface_profile_key and not CS.steeringPressed:
desired_angle_no_offset = math.degrees(VM.get_steer_from_curvature(-desired_curvature, CS.vEgo, params.roll))
actual_angle_no_offset = CS.steeringAngleDeg - params.angleOffsetDeg
output_torque = get_ftm_full_surface_low_speed_angle_assist_torque(self.ftm_surface_profile_key, desired_angle_no_offset,
actual_angle_no_offset, output_torque, CS.vEgo)
if ioniq_6_active:
output_torque *= get_ioniq_6_highway_output_taper_scale(setpoint, CS.vEgo)
output_torque *= get_ioniq_6_highway_transition_output_taper_scale(setpoint, desired_lateral_jerk, CS.vEgo)
@@ -1,3 +1,4 @@
import json
import math
import numpy as np
@@ -10,6 +11,8 @@ from openpilot.starpilot.common.testing_grounds import testing_ground
CIVIC_BOSCH_MODIFIED_B_FIXED_FRICTION_THRESHOLD = 0.30
STANDARD_FRICTION_THRESHOLD = 0.30
HKG_CANFD_BASE_FRICTION_THRESHOLD = 0.39
FTM_SCHEMA_VERSION = 1
FTM_FRICTION_SPEED_KNOTS = [0.0, 5.0, 10.0, 15.0, 25.0]
CIVIC_BOSCH_MODIFIED_B_LAT_ACCEL_FACTOR_MULT = 1.20
CIVIC_BOSCH_MODIFIED_A_VARIANT_LAT_ACCEL_FACTOR_MULT = 1.00
CIVIC_BOSCH_MODIFIED_B_VARIANT_LAT_ACCEL_FACTOR_MULT = 1.75
@@ -692,6 +695,9 @@ TRAILER_LATERAL_LAT_RISE = 0.30
TRAILER_LATERAL_FF_GAIN = 0.05
TRAILER_LATERAL_FRICTION_GAIN = 0.03
_FTM_ACTIVE_OVERRIDES_TEXT = ""
_FTM_ACTIVE_OVERRIDES = {}
def _sigmoid(x: float) -> float:
if x >= 0.0:
@@ -702,17 +708,119 @@ def _sigmoid(x: float) -> float:
return z / (1.0 + z)
def get_gm_base_friction_threshold(v_ego: float) -> float:
# GM's speed-scaled base friction threshold behavior.
def _gm_base_friction_threshold_default(v_ego: float) -> float:
return float(np.interp(v_ego, [1 * CV.MPH_TO_MS, 20 * CV.MPH_TO_MS, 75 * CV.MPH_TO_MS], [0.16, 0.19, 0.27]))
def _standard_friction_threshold_default(v_ego: float) -> float:
return max(_gm_base_friction_threshold_default(v_ego), STANDARD_FRICTION_THRESHOLD)
def _hkg_canfd_base_friction_threshold_default(v_ego: float) -> float:
return max(_gm_base_friction_threshold_default(v_ego), HKG_CANFD_BASE_FRICTION_THRESHOLD)
def _ftm_copy_json(value):
return json.loads(json.dumps(value))
def normalize_ftm_overrides(overrides) -> dict:
if overrides in (None, "", b""):
return {}
if isinstance(overrides, bytes):
overrides = overrides.decode("utf-8", errors="replace")
if isinstance(overrides, str):
stripped = overrides.strip()
if not stripped:
return {}
try:
overrides = json.loads(stripped)
except Exception:
return {}
if not isinstance(overrides, dict):
return {}
normalized = {
"schemaVersion": FTM_SCHEMA_VERSION,
"baseFrictionThresholds": {},
"vehicleKnobs": {},
}
for family in ("gm", "standard", "hkg_canfd"):
payload = overrides.get("baseFrictionThresholds", {}).get(family, {})
values = payload.get("values", payload if isinstance(payload, list) else [])
if not isinstance(values, (list, tuple)) or len(values) != len(FTM_FRICTION_SPEED_KNOTS):
continue
try:
normalized["baseFrictionThresholds"][family] = {
"speedKnots": list(FTM_FRICTION_SPEED_KNOTS),
"values": [float(v) for v in values],
}
except Exception:
continue
vehicle_knobs = overrides.get("vehicleKnobs", {})
if isinstance(vehicle_knobs, dict):
for key, value in vehicle_knobs.items():
try:
normalized["vehicleKnobs"][str(key)] = float(value)
except Exception:
continue
if not normalized["baseFrictionThresholds"] and not normalized["vehicleKnobs"]:
return {}
return normalized
def set_ftm_runtime_overrides(overrides) -> None:
global _FTM_ACTIVE_OVERRIDES, _FTM_ACTIVE_OVERRIDES_TEXT
normalized = normalize_ftm_overrides(overrides)
text = json.dumps(normalized, sort_keys=True, separators=(",", ":")) if normalized else ""
if text == _FTM_ACTIVE_OVERRIDES_TEXT:
return
_FTM_ACTIVE_OVERRIDES_TEXT = text
_FTM_ACTIVE_OVERRIDES = normalized
def clear_ftm_runtime_overrides() -> None:
set_ftm_runtime_overrides({})
def get_ftm_runtime_overrides() -> dict:
return _ftm_copy_json(_FTM_ACTIVE_OVERRIDES) if _FTM_ACTIVE_OVERRIDES else {}
def _ftm_base_friction_threshold(family: str, v_ego: float, default_fn) -> float:
payload = _FTM_ACTIVE_OVERRIDES.get("baseFrictionThresholds", {}).get(family, {})
values = payload.get("values", [])
if isinstance(values, list) and len(values) == len(FTM_FRICTION_SPEED_KNOTS):
return float(np.interp(v_ego, FTM_FRICTION_SPEED_KNOTS, values))
return float(default_fn(v_ego))
def _ftm_vehicle_knob(name: str, default_value: float) -> float:
try:
return float(_FTM_ACTIVE_OVERRIDES.get("vehicleKnobs", {}).get(name, default_value))
except Exception:
return float(default_value)
def get_gm_base_friction_threshold(v_ego: float) -> float:
return _ftm_base_friction_threshold("gm", v_ego, _gm_base_friction_threshold_default)
def get_standard_friction_threshold(v_ego: float) -> float:
return max(get_gm_base_friction_threshold(v_ego), STANDARD_FRICTION_THRESHOLD)
return _ftm_base_friction_threshold("standard", v_ego, _standard_friction_threshold_default)
def get_hkg_canfd_base_friction_threshold(v_ego: float) -> float:
return max(get_gm_base_friction_threshold(v_ego), HKG_CANFD_BASE_FRICTION_THRESHOLD)
return _ftm_base_friction_threshold("hkg_canfd", v_ego, _hkg_canfd_base_friction_threshold_default)
def get_trailer_lateral_assist_factor(trailer_load_kg: float, v_ego: float, desired_lateral_accel: float) -> float:
@@ -756,7 +864,11 @@ def get_prius_ff_scale(desired_lateral_accel: float, desired_lateral_jerk: float
if desired_lateral_accel == 0.0:
return 1.0
gain = _prius_side_value(desired_lateral_accel, PRIUS_FF_GAIN_LEFT, PRIUS_FF_GAIN_RIGHT)
gain = _prius_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("toyota_prius.ff_gain_left", PRIUS_FF_GAIN_LEFT),
_ftm_vehicle_knob("toyota_prius.ff_gain_right", PRIUS_FF_GAIN_RIGHT),
)
abs_lateral_accel = abs(desired_lateral_accel)
onset = _prius_sigmoid((abs_lateral_accel - PRIUS_FF_ONSET) / PRIUS_FF_ONSET_WIDTH)
cutoff = _prius_sigmoid((PRIUS_FF_CUTOFF - abs_lateral_accel) / PRIUS_FF_CUTOFF_WIDTH)
@@ -765,9 +877,17 @@ def get_prius_ff_scale(desired_lateral_accel: float, desired_lateral_jerk: float
turn_in_weight = max(phase, 0.0)
unwind_weight = max(-phase, 0.0)
low_speed_factor = _prius_low_speed_factor(v_ego)
turn_in_boost = 1.0 + (_prius_side_value(desired_lateral_accel, PRIUS_TURN_IN_BOOST_LEFT, PRIUS_TURN_IN_BOOST_RIGHT) *
turn_in_boost = 1.0 + (_prius_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("toyota_prius.turn_in_boost_left", PRIUS_TURN_IN_BOOST_LEFT),
_ftm_vehicle_knob("toyota_prius.turn_in_boost_right", PRIUS_TURN_IN_BOOST_RIGHT),
) *
turn_in_weight * (0.35 + 0.65 * low_speed_factor))
unwind_taper = 1.0 - (_prius_side_value(desired_lateral_accel, PRIUS_UNWIND_TAPER_LEFT, PRIUS_UNWIND_TAPER_RIGHT) *
unwind_taper = 1.0 - (_prius_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("toyota_prius.unwind_taper_left", PRIUS_UNWIND_TAPER_LEFT),
_ftm_vehicle_knob("toyota_prius.unwind_taper_right", PRIUS_UNWIND_TAPER_RIGHT),
) *
unwind_weight * (0.35 + 0.65 * low_speed_factor))
return 1.0 + (extra_scale * turn_in_boost * max(unwind_taper, 0.0))
@@ -778,9 +898,17 @@ def get_prius_friction_threshold(v_ego: float, desired_lateral_accel: float = 0.
phase = _prius_transition_phase(desired_lateral_accel, desired_lateral_jerk)
turn_in_weight = max(phase, 0.0)
unwind_weight = max(-phase, 0.0)
threshold_scale = 1.0 - (_prius_side_value(desired_lateral_accel, PRIUS_TURN_IN_THRESHOLD_REDUCTION_LEFT, PRIUS_TURN_IN_THRESHOLD_REDUCTION_RIGHT) *
threshold_scale = 1.0 - (_prius_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("toyota_prius.turn_in_threshold_reduction_left", PRIUS_TURN_IN_THRESHOLD_REDUCTION_LEFT),
_ftm_vehicle_knob("toyota_prius.turn_in_threshold_reduction_right", PRIUS_TURN_IN_THRESHOLD_REDUCTION_RIGHT),
) *
transition_envelope * turn_in_weight)
threshold_scale += (_prius_side_value(desired_lateral_accel, PRIUS_UNWIND_THRESHOLD_INCREASE_LEFT, PRIUS_UNWIND_THRESHOLD_INCREASE_RIGHT) *
threshold_scale += (_prius_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("toyota_prius.unwind_threshold_increase_left", PRIUS_UNWIND_THRESHOLD_INCREASE_LEFT),
_ftm_vehicle_knob("toyota_prius.unwind_threshold_increase_right", PRIUS_UNWIND_THRESHOLD_INCREASE_RIGHT),
) *
transition_envelope * unwind_weight)
return base_threshold * min(max(threshold_scale, 0.86), 1.16)
@@ -801,7 +929,7 @@ def get_prius_friction_scale(v_ego: float, desired_lateral_accel: float, desired
def get_prius_center_taper_scale(desired_lateral_accel: float, v_ego: float) -> float:
speed_weight = _prius_sigmoid((v_ego - PRIUS_CENTER_TAPER_SPEED) / PRIUS_CENTER_TAPER_SPEED_WIDTH)
center_weight = _prius_sigmoid((PRIUS_CENTER_TAPER_LAT - abs(desired_lateral_accel)) / PRIUS_CENTER_TAPER_LAT_WIDTH)
reduction = PRIUS_CENTER_TAPER_MAX * speed_weight * center_weight
reduction = _ftm_vehicle_knob("toyota_prius.center_taper_max", PRIUS_CENTER_TAPER_MAX) * speed_weight * center_weight
return 1.0 - reduction
@@ -1107,23 +1235,35 @@ def get_bolt_2022_2023_ff_scale(desired_lateral_accel: float, desired_lateral_je
if desired_lateral_accel == 0.0:
return 1.0
gain = _bolt_2022_2023_side_value(desired_lateral_accel, BOLT_2022_2023_FF_GAIN_LEFT, BOLT_2022_2023_FF_GAIN_RIGHT)
gain = _bolt_2022_2023_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("gm_bolt_2022_2023.ff_gain_left", BOLT_2022_2023_FF_GAIN_LEFT),
_ftm_vehicle_knob("gm_bolt_2022_2023.ff_gain_right", BOLT_2022_2023_FF_GAIN_RIGHT),
)
abs_lateral_accel = abs(desired_lateral_accel)
onset = _bolt_2022_2023_sigmoid((abs_lateral_accel - BOLT_2022_2023_FF_ONSET) / BOLT_2022_2023_FF_ONSET_WIDTH)
cutoff = _bolt_2022_2023_sigmoid((BOLT_2022_2023_FF_CUTOFF - abs_lateral_accel) / BOLT_2022_2023_FF_CUTOFF_WIDTH)
extra_scale = gain * onset * cutoff
speed_weight = _bolt_2022_2023_sigmoid((v_ego - BOLT_2022_2023_CENTER_TAPER_SPEED) / BOLT_2022_2023_CENTER_TAPER_SPEED_WIDTH)
center_weight = _bolt_2022_2023_sigmoid((BOLT_2022_2023_CENTER_TAPER_LAT - abs_lateral_accel) / BOLT_2022_2023_CENTER_TAPER_LAT_WIDTH)
center_taper = 1.0 - (BOLT_2022_2023_CENTER_TAPER_MAX * speed_weight * center_weight)
center_taper = 1.0 - (_ftm_vehicle_knob("gm_bolt_2022_2023.center_taper_max", BOLT_2022_2023_CENTER_TAPER_MAX) * speed_weight * center_weight)
low_speed_factor = _bolt_2022_2023_low_speed_factor(v_ego)
transition_envelope = _bolt_2022_2023_transition_envelope(v_ego, desired_lateral_accel, desired_lateral_jerk)
phase = _bolt_2022_2023_transition_phase(desired_lateral_accel, desired_lateral_jerk)
turn_in_weight = max(phase, 0.0)
unwind_weight = max(-phase, 0.0)
turn_in_boost = 1.0 + (_bolt_2022_2023_side_value(desired_lateral_accel, BOLT_2022_2023_TURN_IN_BOOST_LEFT, BOLT_2022_2023_TURN_IN_BOOST_RIGHT) *
turn_in_boost = 1.0 + (_bolt_2022_2023_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("gm_bolt_2022_2023.turn_in_boost_left", BOLT_2022_2023_TURN_IN_BOOST_LEFT),
_ftm_vehicle_knob("gm_bolt_2022_2023.turn_in_boost_right", BOLT_2022_2023_TURN_IN_BOOST_RIGHT),
) *
turn_in_weight * low_speed_factor)
unwind_envelope = (0.25 + 0.75 * low_speed_factor) * (1.0 + 0.45 * transition_envelope)
unwind_taper = 1.0 - (_bolt_2022_2023_side_value(desired_lateral_accel, BOLT_2022_2023_UNWIND_TAPER_LEFT, BOLT_2022_2023_UNWIND_TAPER_RIGHT) *
unwind_taper = 1.0 - (_bolt_2022_2023_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("gm_bolt_2022_2023.unwind_taper_left", BOLT_2022_2023_UNWIND_TAPER_LEFT),
_ftm_vehicle_knob("gm_bolt_2022_2023.unwind_taper_right", BOLT_2022_2023_UNWIND_TAPER_RIGHT),
) *
unwind_weight * unwind_envelope)
return 1.0 + (extra_scale * center_taper * turn_in_boost * max(unwind_taper, 0.0))
@@ -1134,9 +1274,17 @@ def get_bolt_2022_2023_friction_threshold(v_ego: float, desired_lateral_accel: f
phase = _bolt_2022_2023_transition_phase(desired_lateral_accel, desired_lateral_jerk)
turn_in_weight = max(phase, 0.0)
unwind_weight = max(-phase, 0.0)
threshold_scale = 1.0 - (_bolt_2022_2023_side_value(desired_lateral_accel, BOLT_2022_2023_TURN_IN_THRESHOLD_REDUCTION_LEFT, BOLT_2022_2023_TURN_IN_THRESHOLD_REDUCTION_RIGHT) *
threshold_scale = 1.0 - (_bolt_2022_2023_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("gm_bolt_2022_2023.turn_in_threshold_reduction_left", BOLT_2022_2023_TURN_IN_THRESHOLD_REDUCTION_LEFT),
_ftm_vehicle_knob("gm_bolt_2022_2023.turn_in_threshold_reduction_right", BOLT_2022_2023_TURN_IN_THRESHOLD_REDUCTION_RIGHT),
) *
transition_envelope * turn_in_weight)
threshold_scale += (_bolt_2022_2023_side_value(desired_lateral_accel, BOLT_2022_2023_UNWIND_THRESHOLD_INCREASE_LEFT, BOLT_2022_2023_UNWIND_THRESHOLD_INCREASE_RIGHT) *
threshold_scale += (_bolt_2022_2023_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("gm_bolt_2022_2023.unwind_threshold_increase_left", BOLT_2022_2023_UNWIND_THRESHOLD_INCREASE_LEFT),
_ftm_vehicle_knob("gm_bolt_2022_2023.unwind_threshold_increase_right", BOLT_2022_2023_UNWIND_THRESHOLD_INCREASE_RIGHT),
) *
transition_envelope * unwind_weight)
return base_threshold * min(max(threshold_scale, 0.84), 1.14)
@@ -1802,14 +1950,18 @@ def _ioniq_6_transition_envelope(v_ego: float, desired_lateral_accel: float, des
def _ioniq_6_curvy_speed_weight(v_ego: float) -> float:
onset = _ioniq_6_sigmoid((max(v_ego, 0.0) - IONIQ_6_CURVY_SPEED_MIN) / IONIQ_6_CURVY_SPEED_MIN_WIDTH)
cutoff = _ioniq_6_sigmoid((IONIQ_6_CURVY_SPEED_MAX - max(v_ego, 0.0)) / IONIQ_6_CURVY_SPEED_MAX_WIDTH)
curvy_speed_min = _ftm_vehicle_knob("hyundai_ioniq_6.curvy_speed_min", IONIQ_6_CURVY_SPEED_MIN)
curvy_speed_max = _ftm_vehicle_knob("hyundai_ioniq_6.curvy_speed_max", IONIQ_6_CURVY_SPEED_MAX)
onset = _ioniq_6_sigmoid((max(v_ego, 0.0) - curvy_speed_min) / IONIQ_6_CURVY_SPEED_MIN_WIDTH)
cutoff = _ioniq_6_sigmoid((curvy_speed_max - max(v_ego, 0.0)) / IONIQ_6_CURVY_SPEED_MAX_WIDTH)
return onset * cutoff
def _ioniq_6_curvy_turn_in_trim_speed_weight(v_ego: float) -> float:
onset = _ioniq_6_sigmoid((max(v_ego, 0.0) - IONIQ_6_CURVY_TURN_IN_TRIM_SPEED_MIN) / IONIQ_6_CURVY_TURN_IN_TRIM_SPEED_WIDTH)
cutoff = _ioniq_6_sigmoid((IONIQ_6_CURVY_TURN_IN_TRIM_SPEED_MAX - max(v_ego, 0.0)) / IONIQ_6_CURVY_TURN_IN_TRIM_SPEED_WIDTH)
curvy_turn_in_speed_min = _ftm_vehicle_knob("hyundai_ioniq_6.curvy_turn_in_trim_speed_min", IONIQ_6_CURVY_TURN_IN_TRIM_SPEED_MIN)
curvy_turn_in_speed_max = _ftm_vehicle_knob("hyundai_ioniq_6.curvy_turn_in_trim_speed_max", IONIQ_6_CURVY_TURN_IN_TRIM_SPEED_MAX)
onset = _ioniq_6_sigmoid((max(v_ego, 0.0) - curvy_turn_in_speed_min) / IONIQ_6_CURVY_TURN_IN_TRIM_SPEED_WIDTH)
cutoff = _ioniq_6_sigmoid((curvy_turn_in_speed_max - max(v_ego, 0.0)) / IONIQ_6_CURVY_TURN_IN_TRIM_SPEED_WIDTH)
return onset * cutoff
@@ -1818,7 +1970,11 @@ def get_ioniq_6_ff_scale(desired_lateral_accel: float, desired_lateral_jerk: flo
if desired_lateral_accel == 0.0:
return 1.0
gain = _ioniq_6_side_value(desired_lateral_accel, IONIQ_6_FF_GAIN_LEFT, IONIQ_6_FF_GAIN_RIGHT)
gain = _ioniq_6_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("hyundai_ioniq_6.ff_gain_left", IONIQ_6_FF_GAIN_LEFT),
_ftm_vehicle_knob("hyundai_ioniq_6.ff_gain_right", IONIQ_6_FF_GAIN_RIGHT),
)
abs_lateral_accel = abs(desired_lateral_accel)
onset = _ioniq_6_sigmoid((abs_lateral_accel - IONIQ_6_FF_ONSET) / IONIQ_6_FF_ONSET_WIDTH)
cutoff = _ioniq_6_sigmoid((IONIQ_6_FF_CUTOFF - abs_lateral_accel) / IONIQ_6_FF_CUTOFF_WIDTH)
@@ -1827,9 +1983,17 @@ def get_ioniq_6_ff_scale(desired_lateral_accel: float, desired_lateral_jerk: flo
turn_in_weight = max(phase, 0.0)
unwind_weight = max(-phase, 0.0)
low_speed_factor = _ioniq_6_low_speed_factor(v_ego)
turn_in_boost = 1.0 + (_ioniq_6_side_value(desired_lateral_accel, IONIQ_6_TURN_IN_BOOST_LEFT, IONIQ_6_TURN_IN_BOOST_RIGHT) *
turn_in_boost = 1.0 + (_ioniq_6_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("hyundai_ioniq_6.turn_in_boost_left", IONIQ_6_TURN_IN_BOOST_LEFT),
_ftm_vehicle_knob("hyundai_ioniq_6.turn_in_boost_right", IONIQ_6_TURN_IN_BOOST_RIGHT),
) *
turn_in_weight * low_speed_factor)
unwind_taper = 1.0 - (_ioniq_6_side_value(desired_lateral_accel, IONIQ_6_UNWIND_TAPER_LEFT, IONIQ_6_UNWIND_TAPER_RIGHT) *
unwind_taper = 1.0 - (_ioniq_6_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("hyundai_ioniq_6.unwind_taper_left", IONIQ_6_UNWIND_TAPER_LEFT),
_ftm_vehicle_knob("hyundai_ioniq_6.unwind_taper_right", IONIQ_6_UNWIND_TAPER_RIGHT),
) *
unwind_weight * (0.30 + 0.70 * low_speed_factor))
crawl_turn_in_scale = 0.0
if desired_lateral_accel * desired_lateral_jerk > 0.0:
@@ -1837,8 +2001,11 @@ def get_ioniq_6_ff_scale(desired_lateral_accel: float, desired_lateral_jerk: flo
IONIQ_6_CRAWL_TURN_IN_FF_SPEED_WIDTH)
crawl_lat_weight = _ioniq_6_sigmoid((abs_lateral_accel - IONIQ_6_CRAWL_TURN_IN_FF_LAT) /
IONIQ_6_CRAWL_TURN_IN_FF_LAT_WIDTH)
crawl_turn_in_scale = _ioniq_6_side_value(desired_lateral_accel, IONIQ_6_CRAWL_TURN_IN_FF_BOOST_LEFT,
IONIQ_6_CRAWL_TURN_IN_FF_BOOST_RIGHT) * crawl_speed_weight * crawl_lat_weight
crawl_turn_in_scale = _ioniq_6_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("hyundai_ioniq_6.crawl_turn_in_ff_boost_left", IONIQ_6_CRAWL_TURN_IN_FF_BOOST_LEFT),
_ftm_vehicle_knob("hyundai_ioniq_6.crawl_turn_in_ff_boost_right", IONIQ_6_CRAWL_TURN_IN_FF_BOOST_RIGHT),
) * crawl_speed_weight * crawl_lat_weight
high_speed_right_turn_in_scale = 0.0
if desired_lateral_accel < 0.0 and desired_lateral_accel * desired_lateral_jerk > 0.0:
high_speed_weight = _ioniq_6_sigmoid((max(v_ego, 0.0) - IONIQ_6_HIGH_SPEED_RIGHT_TURN_IN_FF_SPEED) /
@@ -1861,9 +2028,17 @@ def get_ioniq_6_friction_threshold(v_ego: float, desired_lateral_accel: float =
turn_in_weight = max(phase, 0.0)
unwind_weight = max(-phase, 0.0)
unwind_speed_weight = _ioniq_6_sigmoid((v_ego - IONIQ_6_UNWIND_HIGH_SPEED_SPEED) / IONIQ_6_UNWIND_HIGH_SPEED_SPEED_WIDTH)
threshold_scale = 1.0 - (_ioniq_6_side_value(desired_lateral_accel, IONIQ_6_TURN_IN_THRESHOLD_REDUCTION_LEFT, IONIQ_6_TURN_IN_THRESHOLD_REDUCTION_RIGHT) *
threshold_scale = 1.0 - (_ioniq_6_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("hyundai_ioniq_6.turn_in_threshold_reduction_left", IONIQ_6_TURN_IN_THRESHOLD_REDUCTION_LEFT),
_ftm_vehicle_knob("hyundai_ioniq_6.turn_in_threshold_reduction_right", IONIQ_6_TURN_IN_THRESHOLD_REDUCTION_RIGHT),
) *
transition_envelope * turn_in_weight)
threshold_scale += (_ioniq_6_side_value(desired_lateral_accel, IONIQ_6_UNWIND_THRESHOLD_INCREASE_LEFT, IONIQ_6_UNWIND_THRESHOLD_INCREASE_RIGHT) *
threshold_scale += (_ioniq_6_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("hyundai_ioniq_6.unwind_threshold_increase_left", IONIQ_6_UNWIND_THRESHOLD_INCREASE_LEFT),
_ftm_vehicle_knob("hyundai_ioniq_6.unwind_threshold_increase_right", IONIQ_6_UNWIND_THRESHOLD_INCREASE_RIGHT),
) *
transition_envelope * unwind_weight * unwind_speed_weight)
return base_threshold * min(max(threshold_scale, 0.82), 1.18)
@@ -1891,12 +2066,12 @@ def get_ioniq_6_friction_center_fade_scale(desired_lateral_accel: float, v_ego:
def get_ioniq_6_center_taper_scale(desired_lateral_accel: float, v_ego: float) -> float:
speed_weight = _ioniq_6_sigmoid((v_ego - IONIQ_6_CENTER_TAPER_SPEED) / IONIQ_6_CENTER_TAPER_SPEED_WIDTH)
center_weight = _ioniq_6_sigmoid((IONIQ_6_CENTER_TAPER_LAT - abs(desired_lateral_accel)) / IONIQ_6_CENTER_TAPER_LAT_WIDTH)
high_speed_reduction = IONIQ_6_CENTER_TAPER_MAX * speed_weight * center_weight
high_speed_reduction = _ftm_vehicle_knob("hyundai_ioniq_6.center_taper_max", IONIQ_6_CENTER_TAPER_MAX) * speed_weight * center_weight
highway_speed_weight = _ioniq_6_sigmoid((v_ego - IONIQ_6_HIGHWAY_CENTER_TAPER_SPEED) / IONIQ_6_HIGHWAY_CENTER_TAPER_SPEED_WIDTH)
highway_center_weight = _ioniq_6_sigmoid((IONIQ_6_HIGHWAY_CENTER_TAPER_LAT - abs(desired_lateral_accel)) /
IONIQ_6_HIGHWAY_CENTER_TAPER_LAT_WIDTH)
highway_center_reduction = IONIQ_6_HIGHWAY_CENTER_TAPER_MAX * highway_speed_weight * highway_center_weight
highway_center_reduction = _ftm_vehicle_knob("hyundai_ioniq_6.highway_center_taper_max", IONIQ_6_HIGHWAY_CENTER_TAPER_MAX) * highway_speed_weight * highway_center_weight
low_mid_onset = _ioniq_6_sigmoid((v_ego - IONIQ_6_LOW_MID_CENTER_TAPER_SPEED_MIN) / IONIQ_6_LOW_MID_CENTER_TAPER_SPEED_WIDTH)
low_mid_cutoff = _ioniq_6_sigmoid((IONIQ_6_LOW_MID_CENTER_TAPER_SPEED_MAX - v_ego) / IONIQ_6_LOW_MID_CENTER_TAPER_SPEED_WIDTH)
@@ -1944,8 +2119,8 @@ def get_ioniq_6_directional_taper_scale(desired_lateral_accel: float, desired_la
reduction = band_weight * (base_reduction + unwind_reduction * unwind_weight)
reduction += heavy_band_weight * (heavy_base_reduction + heavy_unwind_reduction * unwind_weight)
reduction += (_ioniq_6_side_value(desired_lateral_accel,
IONIQ_6_CURVY_TURN_IN_TRIM_LEFT,
IONIQ_6_CURVY_TURN_IN_TRIM_RIGHT) *
_ftm_vehicle_knob("hyundai_ioniq_6.curvy_turn_in_trim_left", IONIQ_6_CURVY_TURN_IN_TRIM_LEFT),
_ftm_vehicle_knob("hyundai_ioniq_6.curvy_turn_in_trim_right", IONIQ_6_CURVY_TURN_IN_TRIM_RIGHT)) *
curvy_turn_in_trim_weight)
curvy_unwind_weight = 0.0
curvy_unwind_floor_relief = 0.0
@@ -1957,12 +2132,12 @@ def get_ioniq_6_directional_taper_scale(desired_lateral_accel: float, desired_la
IONIQ_6_CURVY_UNWIND_LAT_CUTOFF_WIDTH)
curvy_unwind_weight = curvy_unwind_speed_weight * curvy_unwind_lat_onset * curvy_unwind_lat_cutoff * unwind_weight
curvy_unwind_floor_relief = (_ioniq_6_side_value(desired_lateral_accel,
IONIQ_6_CURVY_UNWIND_FLOOR_RELIEF_LEFT,
IONIQ_6_CURVY_UNWIND_FLOOR_RELIEF_RIGHT) *
_ftm_vehicle_knob("hyundai_ioniq_6.curvy_unwind_floor_relief_left", IONIQ_6_CURVY_UNWIND_FLOOR_RELIEF_LEFT),
_ftm_vehicle_knob("hyundai_ioniq_6.curvy_unwind_floor_relief_right", IONIQ_6_CURVY_UNWIND_FLOOR_RELIEF_RIGHT)) *
curvy_unwind_weight)
reduction += (_ioniq_6_side_value(desired_lateral_accel,
IONIQ_6_CURVY_UNWIND_EXTRA_REDUCTION_LEFT,
IONIQ_6_CURVY_UNWIND_EXTRA_REDUCTION_RIGHT) *
_ftm_vehicle_knob("hyundai_ioniq_6.curvy_unwind_extra_reduction_left", IONIQ_6_CURVY_UNWIND_EXTRA_REDUCTION_LEFT),
_ftm_vehicle_knob("hyundai_ioniq_6.curvy_unwind_extra_reduction_right", IONIQ_6_CURVY_UNWIND_EXTRA_REDUCTION_RIGHT)) *
curvy_unwind_weight)
floor = _ioniq_6_side_value(desired_lateral_accel, IONIQ_6_DIRECTIONAL_TAPER_FLOOR_LEFT, IONIQ_6_DIRECTIONAL_TAPER_FLOOR_RIGHT)
floor -= _ioniq_6_side_value(desired_lateral_accel, IONIQ_6_DIRECTIONAL_TAPER_UNWIND_FLOOR_LEFT, IONIQ_6_DIRECTIONAL_TAPER_UNWIND_FLOOR_RIGHT) * unwind_weight
@@ -2011,7 +2186,11 @@ def get_ioniq_6_low_speed_angle_assist_torque(desired_angle_deg: float, actual_a
tracking_taper = _ioniq_6_sigmoid((tracking_ratio - IONIQ_6_LOW_SPEED_ANGLE_ASSIST_TRACK_RATIO_START) /
IONIQ_6_LOW_SPEED_ANGLE_ASSIST_TRACK_RATIO_WIDTH)
tracking_scale = max(1.0 - tracking_taper, IONIQ_6_LOW_SPEED_ANGLE_ASSIST_TRACK_RATIO_FLOOR)
assist_torque = math.copysign(IONIQ_6_LOW_SPEED_ANGLE_ASSIST_MAX_TORQUE * speed_weight * error_weight * desired_angle_weight * tracking_scale, -angle_error)
assist_torque = math.copysign(
_ftm_vehicle_knob("hyundai_ioniq_6.low_speed_angle_assist_max_torque", IONIQ_6_LOW_SPEED_ANGLE_ASSIST_MAX_TORQUE) *
speed_weight * error_weight * desired_angle_weight * tracking_scale,
-angle_error,
)
if abs(assist_torque) < 1e-4:
return current_output_torque
@@ -2069,7 +2248,11 @@ def get_kia_ev6_ff_scale(desired_lateral_accel: float, desired_lateral_jerk: flo
if desired_lateral_accel == 0.0:
return 1.0
gain = _kia_ev6_side_value(desired_lateral_accel, KIA_EV6_FF_GAIN_LEFT, KIA_EV6_FF_GAIN_RIGHT)
gain = _kia_ev6_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("hyundai_kia_ev6.ff_gain_left", KIA_EV6_FF_GAIN_LEFT),
_ftm_vehicle_knob("hyundai_kia_ev6.ff_gain_right", KIA_EV6_FF_GAIN_RIGHT),
)
abs_lateral_accel = abs(desired_lateral_accel)
onset = _kia_ev6_sigmoid((abs_lateral_accel - KIA_EV6_FF_ONSET) / KIA_EV6_FF_ONSET_WIDTH)
cutoff = _kia_ev6_sigmoid((KIA_EV6_FF_CUTOFF - abs_lateral_accel) / KIA_EV6_FF_CUTOFF_WIDTH)
@@ -2078,9 +2261,17 @@ def get_kia_ev6_ff_scale(desired_lateral_accel: float, desired_lateral_jerk: flo
turn_in_weight = max(phase, 0.0)
unwind_weight = max(-phase, 0.0)
low_speed_factor = _kia_ev6_low_speed_factor(v_ego)
turn_in_boost = 1.0 + (_kia_ev6_side_value(desired_lateral_accel, KIA_EV6_TURN_IN_BOOST_LEFT, KIA_EV6_TURN_IN_BOOST_RIGHT) *
turn_in_boost = 1.0 + (_kia_ev6_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("hyundai_kia_ev6.turn_in_boost_left", KIA_EV6_TURN_IN_BOOST_LEFT),
_ftm_vehicle_knob("hyundai_kia_ev6.turn_in_boost_right", KIA_EV6_TURN_IN_BOOST_RIGHT),
) *
turn_in_weight * (0.35 + 0.65 * low_speed_factor))
unwind_taper = 1.0 - (_kia_ev6_side_value(desired_lateral_accel, KIA_EV6_UNWIND_TAPER_LEFT, KIA_EV6_UNWIND_TAPER_RIGHT) *
unwind_taper = 1.0 - (_kia_ev6_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("hyundai_kia_ev6.unwind_taper_left", KIA_EV6_UNWIND_TAPER_LEFT),
_ftm_vehicle_knob("hyundai_kia_ev6.unwind_taper_right", KIA_EV6_UNWIND_TAPER_RIGHT),
) *
unwind_weight * (0.35 + 0.65 * low_speed_factor))
return 1.0 + (extra_scale * turn_in_boost * max(unwind_taper, 0.0))
@@ -2091,9 +2282,17 @@ def get_kia_ev6_friction_threshold(v_ego: float, desired_lateral_accel: float =
phase = _kia_ev6_transition_phase(desired_lateral_accel, desired_lateral_jerk)
turn_in_weight = max(phase, 0.0)
unwind_weight = max(-phase, 0.0)
threshold_scale = 1.0 - (_kia_ev6_side_value(desired_lateral_accel, KIA_EV6_TURN_IN_THRESHOLD_REDUCTION_LEFT, KIA_EV6_TURN_IN_THRESHOLD_REDUCTION_RIGHT) *
threshold_scale = 1.0 - (_kia_ev6_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("hyundai_kia_ev6.turn_in_threshold_reduction_left", KIA_EV6_TURN_IN_THRESHOLD_REDUCTION_LEFT),
_ftm_vehicle_knob("hyundai_kia_ev6.turn_in_threshold_reduction_right", KIA_EV6_TURN_IN_THRESHOLD_REDUCTION_RIGHT),
) *
transition_envelope * turn_in_weight)
threshold_scale += (_kia_ev6_side_value(desired_lateral_accel, KIA_EV6_UNWIND_THRESHOLD_INCREASE_LEFT, KIA_EV6_UNWIND_THRESHOLD_INCREASE_RIGHT) *
threshold_scale += (_kia_ev6_side_value(
desired_lateral_accel,
_ftm_vehicle_knob("hyundai_kia_ev6.unwind_threshold_increase_left", KIA_EV6_UNWIND_THRESHOLD_INCREASE_LEFT),
_ftm_vehicle_knob("hyundai_kia_ev6.unwind_threshold_increase_right", KIA_EV6_UNWIND_THRESHOLD_INCREASE_RIGHT),
) *
transition_envelope * unwind_weight)
return base_threshold * min(max(threshold_scale, 0.82), 1.16)
@@ -2114,7 +2313,7 @@ def get_kia_ev6_friction_scale(v_ego: float, desired_lateral_accel: float, desir
def get_kia_ev6_center_taper_scale(desired_lateral_accel: float, v_ego: float) -> float:
speed_weight = _kia_ev6_sigmoid((v_ego - KIA_EV6_CENTER_TAPER_SPEED) / KIA_EV6_CENTER_TAPER_SPEED_WIDTH)
center_weight = _kia_ev6_sigmoid((KIA_EV6_CENTER_TAPER_LAT - abs(desired_lateral_accel)) / KIA_EV6_CENTER_TAPER_LAT_WIDTH)
reduction = KIA_EV6_CENTER_TAPER_MAX * speed_weight * center_weight
reduction = _ftm_vehicle_knob("hyundai_kia_ev6.center_taper_max", KIA_EV6_CENTER_TAPER_MAX) * speed_weight * center_weight
return 1.0 - reduction
@@ -2159,6 +2358,388 @@ def get_volt_plexy_center_taper_scale(desired_lateral_accel: float, v_ego: float
return 1.0 - (standard_reduction * VOLT_PLEXY_CENTER_TAPER_REDUCTION_MULT)
FTM_UNIVERSAL_PROFILE_KEY = "torque_universal"
FTM_FULL_SURFACE_SUFFIX_METADATA = {
"ff_gain_left": {"min": 0.0, "max": 0.60, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"ff_gain_right": {"min": 0.0, "max": 0.60, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"turn_in_boost_left": {"min": -0.10, "max": 2.80, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"turn_in_boost_right": {"min": -0.10, "max": 2.80, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"unwind_taper_left": {"min": 0.0, "max": 12.0, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"unwind_taper_right": {"min": 0.0, "max": 12.0, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"center_taper_max": {"min": 0.0, "max": 0.18, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"highway_center_taper_max": {"min": 0.0, "max": 0.18, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"turn_in_threshold_reduction_left": {"min": 0.0, "max": 2.00, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"turn_in_threshold_reduction_right": {"min": 0.0, "max": 2.00, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"unwind_threshold_increase_left": {"min": 0.0, "max": 12.0, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"unwind_threshold_increase_right": {"min": 0.0, "max": 12.0, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"crawl_turn_in_ff_boost_left": {"min": 0.0, "max": 0.50, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"crawl_turn_in_ff_boost_right": {"min": 0.0, "max": 0.50, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"low_speed_angle_assist_max_torque": {"min": 0.0, "max": 0.80, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"curvy_speed_min": {"min": 4.0, "max": 12.0, "precision": 0.1, "deltaType": "absolute", "safeLiveTrial": True},
"curvy_speed_max": {"min": 14.0, "max": 25.0, "precision": 0.1, "deltaType": "absolute", "safeLiveTrial": True},
"curvy_turn_in_trim_speed_min": {"min": 8.0, "max": 16.0, "precision": 0.1, "deltaType": "absolute", "safeLiveTrial": True},
"curvy_turn_in_trim_speed_max": {"min": 14.0, "max": 25.0, "precision": 0.1, "deltaType": "absolute", "safeLiveTrial": True},
"curvy_turn_in_trim_left": {"min": 0.0, "max": 0.20, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"curvy_turn_in_trim_right": {"min": 0.0, "max": 0.20, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"curvy_unwind_floor_relief_left": {"min": 0.0, "max": 0.45, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"curvy_unwind_floor_relief_right": {"min": 0.0, "max": 0.45, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"curvy_unwind_extra_reduction_left": {"min": 0.0, "max": 0.45, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
"curvy_unwind_extra_reduction_right": {"min": 0.0, "max": 0.45, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
}
FTM_FULL_SURFACE_NEUTRAL_DEFAULTS = {
"ff_gain_left": 0.0,
"ff_gain_right": 0.0,
"turn_in_boost_left": 0.0,
"turn_in_boost_right": 0.0,
"unwind_taper_left": 0.0,
"unwind_taper_right": 0.0,
"center_taper_max": 0.0,
"highway_center_taper_max": 0.0,
"turn_in_threshold_reduction_left": 0.0,
"turn_in_threshold_reduction_right": 0.0,
"unwind_threshold_increase_left": 0.0,
"unwind_threshold_increase_right": 0.0,
"crawl_turn_in_ff_boost_left": 0.0,
"crawl_turn_in_ff_boost_right": 0.0,
"low_speed_angle_assist_max_torque": 0.0,
"curvy_speed_min": IONIQ_6_CURVY_SPEED_MIN,
"curvy_speed_max": IONIQ_6_CURVY_SPEED_MAX,
"curvy_turn_in_trim_speed_min": IONIQ_6_CURVY_TURN_IN_TRIM_SPEED_MIN,
"curvy_turn_in_trim_speed_max": IONIQ_6_CURVY_TURN_IN_TRIM_SPEED_MAX,
"curvy_turn_in_trim_left": 0.0,
"curvy_turn_in_trim_right": 0.0,
"curvy_unwind_floor_relief_left": 0.0,
"curvy_unwind_floor_relief_right": 0.0,
"curvy_unwind_extra_reduction_left": 0.0,
"curvy_unwind_extra_reduction_right": 0.0,
}
def _ftm_profile_symbol(profile_key: str, suffix: str) -> str:
return f"{profile_key}.{suffix}"
def ftm_profile_supports_knob(profile_key: str | None, suffix: str) -> bool:
if not profile_key:
return False
return _ftm_profile_symbol(profile_key, suffix) in FTM_SUPPORTED_VEHICLE_KNOBS
def _ftm_full_surface_side_value(profile_key: str, desired_lateral_accel: float,
suffix: str, left_default: float = 0.0, right_default: float = 0.0) -> float:
if desired_lateral_accel >= 0.0:
return _ftm_vehicle_knob(_ftm_profile_symbol(profile_key, f"{suffix}_left"), left_default)
return _ftm_vehicle_knob(_ftm_profile_symbol(profile_key, f"{suffix}_right"), right_default)
def _ftm_full_surface_low_speed_factor(v_ego: float) -> float:
return 1.0 / (1.0 + (max(v_ego, 0.0) / IONIQ_6_TRANSITION_SPEED) ** 2)
def _ftm_full_surface_transition_phase(desired_lateral_accel: float, desired_lateral_jerk: float) -> float:
return math.tanh((desired_lateral_accel * desired_lateral_jerk) / IONIQ_6_PHASE_SCALE)
def _ftm_full_surface_transition_envelope(v_ego: float, desired_lateral_accel: float, desired_lateral_jerk: float) -> float:
lat_factor = 1.0 - math.exp(-abs(desired_lateral_accel) / IONIQ_6_FRICTION_LAT_RISE)
jerk_factor = 1.0 - math.exp(-abs(desired_lateral_jerk) / IONIQ_6_FRICTION_JERK_RISE)
return _ftm_full_surface_low_speed_factor(v_ego) * lat_factor * jerk_factor
def _ftm_full_surface_curvy_speed_weight(profile_key: str, v_ego: float) -> float:
curvy_speed_min = _ftm_vehicle_knob(_ftm_profile_symbol(profile_key, "curvy_speed_min"), IONIQ_6_CURVY_SPEED_MIN)
curvy_speed_max = _ftm_vehicle_knob(_ftm_profile_symbol(profile_key, "curvy_speed_max"), IONIQ_6_CURVY_SPEED_MAX)
onset = _sigmoid((max(v_ego, 0.0) - curvy_speed_min) / IONIQ_6_CURVY_SPEED_MIN_WIDTH)
cutoff = _sigmoid((curvy_speed_max - max(v_ego, 0.0)) / IONIQ_6_CURVY_SPEED_MAX_WIDTH)
return onset * cutoff
def _ftm_full_surface_curvy_turn_in_trim_speed_weight(profile_key: str, v_ego: float) -> float:
curvy_turn_in_speed_min = _ftm_vehicle_knob(_ftm_profile_symbol(profile_key, "curvy_turn_in_trim_speed_min"), IONIQ_6_CURVY_TURN_IN_TRIM_SPEED_MIN)
curvy_turn_in_speed_max = _ftm_vehicle_knob(_ftm_profile_symbol(profile_key, "curvy_turn_in_trim_speed_max"), IONIQ_6_CURVY_TURN_IN_TRIM_SPEED_MAX)
onset = _sigmoid((max(v_ego, 0.0) - curvy_turn_in_speed_min) / IONIQ_6_CURVY_TURN_IN_TRIM_SPEED_WIDTH)
cutoff = _sigmoid((curvy_turn_in_speed_max - max(v_ego, 0.0)) / IONIQ_6_CURVY_TURN_IN_TRIM_SPEED_WIDTH)
return onset * cutoff
def get_ftm_full_surface_center_taper_scale(profile_key: str | None, desired_lateral_accel: float, v_ego: float,
include_base_center: bool = False) -> float:
if not profile_key:
return 1.0
reduction = 0.0
if include_base_center and ftm_profile_supports_knob(profile_key, "center_taper_max"):
speed_weight = _sigmoid((v_ego - IONIQ_6_CENTER_TAPER_SPEED) / IONIQ_6_CENTER_TAPER_SPEED_WIDTH)
center_weight = _sigmoid((IONIQ_6_CENTER_TAPER_LAT - abs(desired_lateral_accel)) / IONIQ_6_CENTER_TAPER_LAT_WIDTH)
reduction += _ftm_vehicle_knob(_ftm_profile_symbol(profile_key, "center_taper_max"), 0.0) * speed_weight * center_weight
if ftm_profile_supports_knob(profile_key, "highway_center_taper_max"):
speed_weight = _sigmoid((v_ego - IONIQ_6_HIGHWAY_CENTER_TAPER_SPEED) / IONIQ_6_HIGHWAY_CENTER_TAPER_SPEED_WIDTH)
center_weight = _sigmoid((IONIQ_6_HIGHWAY_CENTER_TAPER_LAT - abs(desired_lateral_accel)) / IONIQ_6_HIGHWAY_CENTER_TAPER_LAT_WIDTH)
reduction += _ftm_vehicle_knob(_ftm_profile_symbol(profile_key, "highway_center_taper_max"), 0.0) * speed_weight * center_weight
return 1.0 - min(reduction, 0.20)
def get_ftm_full_surface_ff_scale(profile_key: str | None, desired_lateral_accel: float, desired_lateral_jerk: float, v_ego: float,
include_base_ff: bool = False) -> float:
if not profile_key or desired_lateral_accel == 0.0:
return 1.0
abs_lateral_accel = abs(desired_lateral_accel)
phase = _ftm_full_surface_transition_phase(desired_lateral_accel, desired_lateral_jerk)
turn_in_weight = max(phase, 0.0)
unwind_weight = max(-phase, 0.0)
low_speed_factor = _ftm_full_surface_low_speed_factor(v_ego)
curvy_turn_in_speed_weight = _ftm_full_surface_curvy_turn_in_trim_speed_weight(profile_key, v_ego)
curvy_turn_in_lat_onset = _sigmoid((abs_lateral_accel - IONIQ_6_CURVY_TURN_IN_TRIM_LAT_START) / IONIQ_6_CURVY_TURN_IN_TRIM_LAT_ONSET_WIDTH)
curvy_turn_in_lat_cutoff = _sigmoid((IONIQ_6_CURVY_TURN_IN_TRIM_LAT_END - abs_lateral_accel) / IONIQ_6_CURVY_TURN_IN_TRIM_LAT_CUTOFF_WIDTH)
curvy_turn_in_trim_weight = curvy_turn_in_speed_weight * curvy_turn_in_lat_onset * curvy_turn_in_lat_cutoff * turn_in_weight
curvy_unwind_speed_weight = _ftm_full_surface_curvy_speed_weight(profile_key, v_ego)
curvy_unwind_lat_onset = _sigmoid((abs_lateral_accel - IONIQ_6_CURVY_UNWIND_LAT_START) / IONIQ_6_CURVY_UNWIND_LAT_ONSET_WIDTH)
curvy_unwind_lat_cutoff = _sigmoid((IONIQ_6_CURVY_UNWIND_LAT_END - abs_lateral_accel) / IONIQ_6_CURVY_UNWIND_LAT_CUTOFF_WIDTH)
curvy_unwind_weight = curvy_unwind_speed_weight * curvy_unwind_lat_onset * curvy_unwind_lat_cutoff * unwind_weight
scale = 1.0
if include_base_ff and ftm_profile_supports_knob(profile_key, "ff_gain_left"):
gain = _ftm_full_surface_side_value(profile_key, desired_lateral_accel, "ff_gain")
onset = _sigmoid((abs_lateral_accel - IONIQ_6_FF_ONSET) / IONIQ_6_FF_ONSET_WIDTH)
cutoff = _sigmoid((IONIQ_6_FF_CUTOFF - abs_lateral_accel) / IONIQ_6_FF_CUTOFF_WIDTH)
extra_scale = gain * onset * cutoff
turn_in_boost = 1.0 + (_ftm_full_surface_side_value(profile_key, desired_lateral_accel, "turn_in_boost") * turn_in_weight * low_speed_factor)
unwind_reduction = _ftm_full_surface_side_value(profile_key, desired_lateral_accel, "unwind_taper") * unwind_weight * (0.30 + 0.70 * low_speed_factor)
curvy_unwind_extra = _ftm_full_surface_side_value(profile_key, desired_lateral_accel, "curvy_unwind_extra_reduction") * curvy_unwind_weight
curvy_unwind_floor_relief = _ftm_full_surface_side_value(profile_key, desired_lateral_accel, "curvy_unwind_floor_relief") * curvy_unwind_weight
unwind_floor = 1.0 - (0.55 * unwind_reduction) - curvy_unwind_floor_relief
unwind_scale = max(1.0 - unwind_reduction - curvy_unwind_extra, unwind_floor, 0.0)
scale *= 1.0 + (extra_scale * turn_in_boost * unwind_scale)
else:
curvy_unwind_extra = _ftm_full_surface_side_value(profile_key, desired_lateral_accel, "curvy_unwind_extra_reduction") * curvy_unwind_weight
curvy_unwind_floor_relief = _ftm_full_surface_side_value(profile_key, desired_lateral_accel, "curvy_unwind_floor_relief") * curvy_unwind_weight
scale *= max(1.0 - curvy_unwind_extra - curvy_unwind_floor_relief, 0.55)
if ftm_profile_supports_knob(profile_key, "curvy_turn_in_trim_left"):
curvy_trim = _ftm_full_surface_side_value(profile_key, desired_lateral_accel, "curvy_turn_in_trim") * curvy_turn_in_trim_weight
scale *= max(1.0 - curvy_trim, 0.55)
if ftm_profile_supports_knob(profile_key, "crawl_turn_in_ff_boost_left") and desired_lateral_accel * desired_lateral_jerk > 0.0:
crawl_speed_weight = _sigmoid((IONIQ_6_CRAWL_TURN_IN_FF_SPEED - max(v_ego, 0.0)) / IONIQ_6_CRAWL_TURN_IN_FF_SPEED_WIDTH)
crawl_lat_weight = _sigmoid((abs_lateral_accel - IONIQ_6_CRAWL_TURN_IN_FF_LAT) / IONIQ_6_CRAWL_TURN_IN_FF_LAT_WIDTH)
scale += _ftm_full_surface_side_value(profile_key, desired_lateral_accel, "crawl_turn_in_ff_boost") * crawl_speed_weight * crawl_lat_weight
return scale
def get_ftm_full_surface_friction_threshold(profile_key: str | None, base_threshold: float, v_ego: float,
desired_lateral_accel: float = 0.0, desired_lateral_jerk: float = 0.0,
include_base_threshold: bool = False) -> float:
if not profile_key or not include_base_threshold:
return base_threshold
transition_envelope = _ftm_full_surface_transition_envelope(v_ego, desired_lateral_accel, desired_lateral_jerk)
phase = _ftm_full_surface_transition_phase(desired_lateral_accel, desired_lateral_jerk)
turn_in_weight = max(phase, 0.0)
unwind_weight = max(-phase, 0.0)
unwind_speed_weight = _sigmoid((v_ego - IONIQ_6_UNWIND_HIGH_SPEED_SPEED) / IONIQ_6_UNWIND_HIGH_SPEED_SPEED_WIDTH)
threshold_scale = 1.0
threshold_scale -= (_ftm_full_surface_side_value(profile_key, desired_lateral_accel, "turn_in_threshold_reduction") *
transition_envelope * turn_in_weight)
threshold_scale += (_ftm_full_surface_side_value(profile_key, desired_lateral_accel, "unwind_threshold_increase") *
transition_envelope * unwind_weight * unwind_speed_weight)
return base_threshold * min(max(threshold_scale, 0.82), 1.18)
def get_ftm_full_surface_low_speed_angle_assist_torque(profile_key: str | None, desired_angle_deg: float, actual_angle_deg: float,
current_output_torque: float, v_ego: float) -> float:
if not profile_key or not ftm_profile_supports_knob(profile_key, "low_speed_angle_assist_max_torque"):
return current_output_torque
max_torque = _ftm_vehicle_knob(_ftm_profile_symbol(profile_key, "low_speed_angle_assist_max_torque"), 0.0)
if max_torque <= 1e-4:
return current_output_torque
angle_error = desired_angle_deg - actual_angle_deg
if desired_angle_deg * angle_error > 0.0:
speed_weight = _sigmoid((IONIQ_6_LOW_SPEED_ANGLE_ASSIST_SPEED - max(v_ego, 0.0)) / IONIQ_6_LOW_SPEED_ANGLE_ASSIST_SPEED_WIDTH)
error_weight = _sigmoid((abs(angle_error) - IONIQ_6_LOW_SPEED_ANGLE_ASSIST_ERROR) / IONIQ_6_LOW_SPEED_ANGLE_ASSIST_ERROR_WIDTH)
desired_angle_weight = _sigmoid((abs(desired_angle_deg) - IONIQ_6_LOW_SPEED_ANGLE_ASSIST_DESIRED_ANGLE) / IONIQ_6_LOW_SPEED_ANGLE_ASSIST_DESIRED_ANGLE_WIDTH)
tracking_ratio = abs(actual_angle_deg) / max(abs(desired_angle_deg), 1e-3)
tracking_taper = _sigmoid((tracking_ratio - IONIQ_6_LOW_SPEED_ANGLE_ASSIST_TRACK_RATIO_START) / IONIQ_6_LOW_SPEED_ANGLE_ASSIST_TRACK_RATIO_WIDTH)
tracking_scale = max(1.0 - tracking_taper, IONIQ_6_LOW_SPEED_ANGLE_ASSIST_TRACK_RATIO_FLOOR)
assist_torque = math.copysign(max_torque * speed_weight * error_weight * desired_angle_weight * tracking_scale, -angle_error)
if abs(assist_torque) < 1e-4:
return current_output_torque
if current_output_torque * assist_torque >= 0.0:
add_scale = float(np.interp(abs(current_output_torque), IONIQ_6_LOW_SPEED_ANGLE_ASSIST_ADD_BP, IONIQ_6_LOW_SPEED_ANGLE_ASSIST_ADD_V))
return float(np.clip(current_output_torque + (assist_torque * add_scale), -1.0, 1.0))
return float(np.clip(current_output_torque + assist_torque, -1.0, 1.0))
speed_weight = _sigmoid((IONIQ_6_LOW_SPEED_UNWIND_ASSIST_SPEED - max(v_ego, 0.0)) / IONIQ_6_LOW_SPEED_UNWIND_ASSIST_SPEED_WIDTH)
error_weight = _sigmoid((abs(angle_error) - IONIQ_6_LOW_SPEED_UNWIND_ASSIST_ERROR) / IONIQ_6_LOW_SPEED_UNWIND_ASSIST_ERROR_WIDTH)
actual_angle_weight = _sigmoid((abs(actual_angle_deg) - IONIQ_6_LOW_SPEED_UNWIND_ASSIST_ACTUAL_ANGLE) / IONIQ_6_LOW_SPEED_UNWIND_ASSIST_ACTUAL_ANGLE_WIDTH)
assist_torque = math.copysign(IONIQ_6_LOW_SPEED_UNWIND_ASSIST_MAX_TORQUE * speed_weight * error_weight * actual_angle_weight, -angle_error)
if abs(assist_torque) < 1e-4:
return current_output_torque
if current_output_torque * assist_torque >= 0.0:
assist_torque *= IONIQ_6_LOW_SPEED_UNWIND_ASSIST_BLEND
return float(np.clip(current_output_torque + assist_torque, -1.0, 1.0))
FTM_RICH_PROFILE_CARS = {
"gm_bolt_2022_2023": set(BOLT_2022_2023_CARS),
"hyundai_ioniq_6": set(IONIQ_6_CARS),
"hyundai_kia_ev6": set(KIA_EV6_CARS),
"toyota_prius": set(PRIUS_CARS),
}
FTM_RICH_PROFILE_LABELS = {
"gm_bolt_2022_2023": "Bolt 2022-2023",
"hyundai_ioniq_6": "Ioniq 6",
"hyundai_kia_ev6": "EV6",
"toyota_prius": "Prius",
FTM_UNIVERSAL_PROFILE_KEY: "Torque Controller",
}
FTM_SUPPORTED_VEHICLE_KNOBS = {
"gm_bolt_2022_2023.ff_gain_left": {"profile": "gm_bolt_2022_2023", "min": 0.0, "max": 0.40, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": BOLT_2022_2023_FF_GAIN_LEFT},
"gm_bolt_2022_2023.ff_gain_right": {"profile": "gm_bolt_2022_2023", "min": 0.0, "max": 0.40, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": BOLT_2022_2023_FF_GAIN_RIGHT},
"gm_bolt_2022_2023.turn_in_boost_left": {"profile": "gm_bolt_2022_2023", "min": -0.10, "max": 0.50, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": BOLT_2022_2023_TURN_IN_BOOST_LEFT},
"gm_bolt_2022_2023.turn_in_boost_right": {"profile": "gm_bolt_2022_2023", "min": -0.10, "max": 0.50, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": BOLT_2022_2023_TURN_IN_BOOST_RIGHT},
"gm_bolt_2022_2023.unwind_taper_left": {"profile": "gm_bolt_2022_2023", "min": 0.0, "max": 0.80, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": BOLT_2022_2023_UNWIND_TAPER_LEFT},
"gm_bolt_2022_2023.unwind_taper_right": {"profile": "gm_bolt_2022_2023", "min": 0.0, "max": 0.80, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": BOLT_2022_2023_UNWIND_TAPER_RIGHT},
"gm_bolt_2022_2023.center_taper_max": {"profile": "gm_bolt_2022_2023", "min": 0.0, "max": 0.25, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": BOLT_2022_2023_CENTER_TAPER_MAX},
"gm_bolt_2022_2023.turn_in_threshold_reduction_left": {"profile": "gm_bolt_2022_2023", "min": 0.0, "max": 0.40, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": BOLT_2022_2023_TURN_IN_THRESHOLD_REDUCTION_LEFT},
"gm_bolt_2022_2023.turn_in_threshold_reduction_right": {"profile": "gm_bolt_2022_2023", "min": 0.0, "max": 0.40, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": BOLT_2022_2023_TURN_IN_THRESHOLD_REDUCTION_RIGHT},
"gm_bolt_2022_2023.unwind_threshold_increase_left": {"profile": "gm_bolt_2022_2023", "min": 0.0, "max": 0.60, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": BOLT_2022_2023_UNWIND_THRESHOLD_INCREASE_LEFT},
"gm_bolt_2022_2023.unwind_threshold_increase_right": {"profile": "gm_bolt_2022_2023", "min": 0.0, "max": 0.60, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": BOLT_2022_2023_UNWIND_THRESHOLD_INCREASE_RIGHT},
"hyundai_ioniq_6.ff_gain_left": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 0.60, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_FF_GAIN_LEFT},
"hyundai_ioniq_6.ff_gain_right": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 0.60, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_FF_GAIN_RIGHT},
"hyundai_ioniq_6.turn_in_boost_left": {"profile": "hyundai_ioniq_6", "min": 0.40, "max": 2.80, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_TURN_IN_BOOST_LEFT},
"hyundai_ioniq_6.turn_in_boost_right": {"profile": "hyundai_ioniq_6", "min": 0.40, "max": 2.80, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_TURN_IN_BOOST_RIGHT},
"hyundai_ioniq_6.unwind_taper_left": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 12.0, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_UNWIND_TAPER_LEFT},
"hyundai_ioniq_6.unwind_taper_right": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 12.0, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_UNWIND_TAPER_RIGHT},
"hyundai_ioniq_6.center_taper_max": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 0.18, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_CENTER_TAPER_MAX},
"hyundai_ioniq_6.highway_center_taper_max": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 0.18, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_HIGHWAY_CENTER_TAPER_MAX},
"hyundai_ioniq_6.turn_in_threshold_reduction_left": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 2.00, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_TURN_IN_THRESHOLD_REDUCTION_LEFT},
"hyundai_ioniq_6.turn_in_threshold_reduction_right": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 2.00, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_TURN_IN_THRESHOLD_REDUCTION_RIGHT},
"hyundai_ioniq_6.unwind_threshold_increase_left": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 12.0, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_UNWIND_THRESHOLD_INCREASE_LEFT},
"hyundai_ioniq_6.unwind_threshold_increase_right": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 12.0, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_UNWIND_THRESHOLD_INCREASE_RIGHT},
"hyundai_ioniq_6.crawl_turn_in_ff_boost_left": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 0.50, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_CRAWL_TURN_IN_FF_BOOST_LEFT},
"hyundai_ioniq_6.crawl_turn_in_ff_boost_right": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 0.50, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_CRAWL_TURN_IN_FF_BOOST_RIGHT},
"hyundai_ioniq_6.low_speed_angle_assist_max_torque": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 0.80, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_LOW_SPEED_ANGLE_ASSIST_MAX_TORQUE},
"hyundai_ioniq_6.curvy_speed_min": {"profile": "hyundai_ioniq_6", "min": 4.0, "max": 12.0, "precision": 0.1, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_CURVY_SPEED_MIN},
"hyundai_ioniq_6.curvy_speed_max": {"profile": "hyundai_ioniq_6", "min": 14.0, "max": 25.0, "precision": 0.1, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_CURVY_SPEED_MAX},
"hyundai_ioniq_6.curvy_turn_in_trim_speed_min": {"profile": "hyundai_ioniq_6", "min": 8.0, "max": 16.0, "precision": 0.1, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_CURVY_TURN_IN_TRIM_SPEED_MIN},
"hyundai_ioniq_6.curvy_turn_in_trim_speed_max": {"profile": "hyundai_ioniq_6", "min": 14.0, "max": 25.0, "precision": 0.1, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_CURVY_TURN_IN_TRIM_SPEED_MAX},
"hyundai_ioniq_6.curvy_turn_in_trim_left": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 0.20, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_CURVY_TURN_IN_TRIM_LEFT},
"hyundai_ioniq_6.curvy_turn_in_trim_right": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 0.20, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_CURVY_TURN_IN_TRIM_RIGHT},
"hyundai_ioniq_6.curvy_unwind_floor_relief_left": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 0.45, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_CURVY_UNWIND_FLOOR_RELIEF_LEFT},
"hyundai_ioniq_6.curvy_unwind_floor_relief_right": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 0.45, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_CURVY_UNWIND_FLOOR_RELIEF_RIGHT},
"hyundai_ioniq_6.curvy_unwind_extra_reduction_left": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 0.45, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_CURVY_UNWIND_EXTRA_REDUCTION_LEFT},
"hyundai_ioniq_6.curvy_unwind_extra_reduction_right": {"profile": "hyundai_ioniq_6", "min": 0.0, "max": 0.45, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": IONIQ_6_CURVY_UNWIND_EXTRA_REDUCTION_RIGHT},
"hyundai_kia_ev6.ff_gain_left": {"profile": "hyundai_kia_ev6", "min": 0.0, "max": 0.60, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": KIA_EV6_FF_GAIN_LEFT},
"hyundai_kia_ev6.ff_gain_right": {"profile": "hyundai_kia_ev6", "min": 0.0, "max": 0.60, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": KIA_EV6_FF_GAIN_RIGHT},
"hyundai_kia_ev6.turn_in_boost_left": {"profile": "hyundai_kia_ev6", "min": 0.0, "max": 1.20, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": KIA_EV6_TURN_IN_BOOST_LEFT},
"hyundai_kia_ev6.turn_in_boost_right": {"profile": "hyundai_kia_ev6", "min": 0.0, "max": 1.20, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": KIA_EV6_TURN_IN_BOOST_RIGHT},
"hyundai_kia_ev6.unwind_taper_left": {"profile": "hyundai_kia_ev6", "min": 0.0, "max": 1.20, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": KIA_EV6_UNWIND_TAPER_LEFT},
"hyundai_kia_ev6.unwind_taper_right": {"profile": "hyundai_kia_ev6", "min": 0.0, "max": 1.20, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": KIA_EV6_UNWIND_TAPER_RIGHT},
"hyundai_kia_ev6.center_taper_max": {"profile": "hyundai_kia_ev6", "min": 0.0, "max": 0.20, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": KIA_EV6_CENTER_TAPER_MAX},
"hyundai_kia_ev6.turn_in_threshold_reduction_left": {"profile": "hyundai_kia_ev6", "min": 0.0, "max": 0.40, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": KIA_EV6_TURN_IN_THRESHOLD_REDUCTION_LEFT},
"hyundai_kia_ev6.turn_in_threshold_reduction_right": {"profile": "hyundai_kia_ev6", "min": 0.0, "max": 0.40, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": KIA_EV6_TURN_IN_THRESHOLD_REDUCTION_RIGHT},
"hyundai_kia_ev6.unwind_threshold_increase_left": {"profile": "hyundai_kia_ev6", "min": 0.0, "max": 0.80, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": KIA_EV6_UNWIND_THRESHOLD_INCREASE_LEFT},
"hyundai_kia_ev6.unwind_threshold_increase_right": {"profile": "hyundai_kia_ev6", "min": 0.0, "max": 0.80, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": KIA_EV6_UNWIND_THRESHOLD_INCREASE_RIGHT},
"toyota_prius.ff_gain_left": {"profile": "toyota_prius", "min": 0.0, "max": 0.25, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": PRIUS_FF_GAIN_LEFT},
"toyota_prius.ff_gain_right": {"profile": "toyota_prius", "min": 0.0, "max": 0.25, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": PRIUS_FF_GAIN_RIGHT},
"toyota_prius.turn_in_boost_left": {"profile": "toyota_prius", "min": -0.10, "max": 0.60, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": PRIUS_TURN_IN_BOOST_LEFT},
"toyota_prius.turn_in_boost_right": {"profile": "toyota_prius", "min": -0.10, "max": 0.60, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": PRIUS_TURN_IN_BOOST_RIGHT},
"toyota_prius.unwind_taper_left": {"profile": "toyota_prius", "min": 0.0, "max": 1.20, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": PRIUS_UNWIND_TAPER_LEFT},
"toyota_prius.unwind_taper_right": {"profile": "toyota_prius", "min": 0.0, "max": 1.20, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": PRIUS_UNWIND_TAPER_RIGHT},
"toyota_prius.center_taper_max": {"profile": "toyota_prius", "min": 0.0, "max": 0.25, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": PRIUS_CENTER_TAPER_MAX},
"toyota_prius.turn_in_threshold_reduction_left": {"profile": "toyota_prius", "min": 0.0, "max": 0.50, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": PRIUS_TURN_IN_THRESHOLD_REDUCTION_LEFT},
"toyota_prius.turn_in_threshold_reduction_right": {"profile": "toyota_prius", "min": 0.0, "max": 0.50, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": PRIUS_TURN_IN_THRESHOLD_REDUCTION_RIGHT},
"toyota_prius.unwind_threshold_increase_left": {"profile": "toyota_prius", "min": 0.0, "max": 0.90, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": PRIUS_UNWIND_THRESHOLD_INCREASE_LEFT},
"toyota_prius.unwind_threshold_increase_right": {"profile": "toyota_prius", "min": 0.0, "max": 0.90, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True, "defaultValue": PRIUS_UNWIND_THRESHOLD_INCREASE_RIGHT},
}
def _add_ftm_full_surface_profile_knobs(profile_key: str, defaults: dict[str, float] | None = None) -> None:
knob_defaults = dict(FTM_FULL_SURFACE_NEUTRAL_DEFAULTS)
if defaults:
knob_defaults.update(defaults)
for suffix, meta in FTM_FULL_SURFACE_SUFFIX_METADATA.items():
symbol = _ftm_profile_symbol(profile_key, suffix)
if symbol in FTM_SUPPORTED_VEHICLE_KNOBS:
continue
FTM_SUPPORTED_VEHICLE_KNOBS[symbol] = {
"profile": profile_key,
"min": meta["min"],
"max": meta["max"],
"precision": meta["precision"],
"deltaType": meta["deltaType"],
"safeLiveTrial": meta["safeLiveTrial"],
"defaultValue": knob_defaults[suffix],
}
for _ftm_profile_key in ("gm_bolt_2022_2023", "hyundai_ioniq_6", "hyundai_kia_ev6", "toyota_prius", FTM_UNIVERSAL_PROFILE_KEY):
_add_ftm_full_surface_profile_knobs(_ftm_profile_key)
def get_ftm_supported_vehicle_knobs() -> dict:
return _ftm_copy_json(FTM_SUPPORTED_VEHICLE_KNOBS)
def get_ftm_rich_profile_key(car_fingerprint) -> str | None:
for profile_key, cars in FTM_RICH_PROFILE_CARS.items():
if car_fingerprint in cars:
return profile_key
return None
def get_ftm_surface_profile_key(car_fingerprint, torque_control: bool = True) -> str | None:
profile_key = get_ftm_rich_profile_key(car_fingerprint)
if profile_key is not None or not torque_control:
return profile_key
return FTM_UNIVERSAL_PROFILE_KEY
def get_ftm_capabilities(car_fingerprint, brand: str = "", hyundai_canfd: bool = False, torque_control: bool = True) -> dict:
profile_key = get_ftm_surface_profile_key(car_fingerprint, torque_control=torque_control)
if hyundai_canfd:
friction_family = "hkg_canfd"
elif brand == "gm":
friction_family = "gm"
else:
friction_family = "standard"
dedicated_friction = car_fingerprint in (
set(BOLT_2022_2023_CARS) | set(BOLT_2018_2021_CARS) | set(VOLT_STANDARD_CARS) | set(PALISADE_CARS) |
set(PRIUS_CARS) | set(IONIQ_5_CARS) | set(IONIQ_6_CARS) | set(KIA_EV6_CARS) | set(KIA_FORTE_CARS) |
set(KIA_NIRO_PHEV_2022_CARS) | set(GENESIS_G90_CARS)
)
dedicated_center_taper = car_fingerprint in (
set(PRIUS_CARS) | set(BOLT_CARS) | set(VOLT_STANDARD_CARS) | set(IONIQ_5_CARS) |
set(IONIQ_EV_OLD_CARS) | set(IONIQ_6_CARS) | set(SONATA_CARS) | set(SONATA_HYBRID_CARS) |
set(KIA_XCEED_CARS) | set(KIA_NIRO_PHEV_2022_CARS) | set(KIA_FORTE_CARS) | set(KIA_EV6_CARS) |
set(SILVERADO_CARS)
)
rich_knobs = [name for name, meta in FTM_SUPPORTED_VEHICLE_KNOBS.items() if meta["profile"] == profile_key]
return {
"torqueControl": bool(torque_control),
"frictionFamily": friction_family,
"hasDedicatedFrictionThreshold": bool(dedicated_friction),
"hasDedicatedCenterTaper": bool(dedicated_center_taper),
"richProfileKey": profile_key,
"richProfileLabel": FTM_RICH_PROFILE_LABELS.get(profile_key),
"richKnobs": rich_knobs,
}
__all__ = [name for name in globals() if not name.startswith("_") and name not in {
"math", "np", "GM_CAR", "HYUNDAI_CAR", "TOYOTA_CAR", "CV", "testing_ground",
}]
+43 -1
View File
@@ -19,7 +19,13 @@ from openpilot.selfdrive.controls.lib.latcontrol_pid import (
get_civic_bosch_modified_pid_output_alpha,
get_civic_bosch_modified_pid_output_scale,
)
from openpilot.selfdrive.controls.lib.latcontrol_vehicle_tunes import get_hkg_canfd_base_friction_threshold
from openpilot.selfdrive.controls.lib.latcontrol_vehicle_tunes import (
clear_ftm_runtime_overrides,
get_ftm_runtime_overrides,
get_hkg_canfd_base_friction_threshold,
normalize_ftm_overrides,
set_ftm_runtime_overrides,
)
from openpilot.selfdrive.controls.lib.latcontrol_torque import (
get_civic_bosch_modified_a_center_taper_scale,
LatControlTorque,
@@ -258,6 +264,42 @@ class TestLatControl:
assert unwind_left < steady_left
assert unwind_right < steady_right
def test_ftm_standard_friction_curve_override(self):
base = get_standard_friction_threshold(10.0)
overrides = normalize_ftm_overrides({
"baseFrictionThresholds": {
"standard": {
"values": [0.30, 0.31, 0.32, 0.33, 0.34],
},
},
})
try:
set_ftm_runtime_overrides(overrides)
assert get_ftm_runtime_overrides()["baseFrictionThresholds"]["standard"]["values"] == [0.30, 0.31, 0.32, 0.33, 0.34]
assert get_standard_friction_threshold(10.0) == pytest.approx(0.32)
assert get_standard_friction_threshold(12.5) > get_standard_friction_threshold(10.0)
assert get_standard_friction_threshold(10.0) != pytest.approx(base)
finally:
clear_ftm_runtime_overrides()
assert get_ftm_runtime_overrides() == {}
assert get_standard_friction_threshold(10.0) == pytest.approx(base)
def test_ftm_vehicle_knob_override_ioniq6_center_taper(self):
baseline = get_ioniq_6_center_taper_scale(0.0, 32.0)
overrides = normalize_ftm_overrides({
"vehicleKnobs": {
"hyundai_ioniq_6.center_taper_max": 0.0,
"hyundai_ioniq_6.highway_center_taper_max": 0.0,
},
})
try:
set_ftm_runtime_overrides(overrides)
adjusted = get_ioniq_6_center_taper_scale(0.0, 32.0)
assert adjusted > baseline
assert adjusted <= 1.0
finally:
clear_ftm_runtime_overrides()
def test_sonata_hybrid_center_taper_curve(self):
assert get_sonata_hybrid_center_taper_scale(0.0, 30.0) < get_sonata_hybrid_center_taper_scale(0.0, 15.0)
assert get_sonata_hybrid_center_taper_scale(0.0, 3.0) < get_sonata_hybrid_center_taper_scale(0.0, 10.0)
+10
View File
@@ -229,6 +229,9 @@ EXCLUDED_KEYS = {
"CommunityFavorites",
"CurvatureData",
"ExperimentalLongitudinalEnabled",
"FTMActiveOverrides",
"FTMActiveProfileId",
"FTMTrialApplied",
"InstallDate",
"StarPilotCarParamsPersistent",
"KonikMinutes",
@@ -677,6 +680,13 @@ class StarPilotVariables:
advanced_lateral_tuning = self.get_value("AdvancedLateralTune")
toggle.force_auto_tune = self.get_value("ForceAutoTune", condition=advanced_lateral_tuning and not has_auto_tune and is_torque_car and not is_angle_car)
toggle.force_auto_tune_off = self.get_value("ForceAutoTuneOff", condition=advanced_lateral_tuning and has_auto_tune and is_torque_car and not is_angle_car)
toggle.ftm_active_profile_id = self.params.get("FTMActiveProfileId", encoding="utf-8") or ""
toggle.ftm_trial_applied = self.params.get_bool("FTMTrialApplied")
ftm_overrides_raw = self.params.get("FTMActiveOverrides", encoding="utf-8") or ""
try:
toggle.ftm_active_overrides = json.loads(ftm_overrides_raw) if ftm_overrides_raw else {}
except Exception:
toggle.ftm_active_overrides = {}
toggle.steerActuatorDelay = self.get_value("SteerDelay", cast=float, condition=advanced_lateral_tuning, default=steerActuatorDelay, min=0.01, max=1.0)
toggle.use_custom_steerActuatorDelay = bool(round(toggle.steerActuatorDelay, 2) != round(steerActuatorDelay, 2))
toggle.friction = self.get_value("SteerFriction", cast=float, condition=advanced_lateral_tuning, default=friction, min=0, max=1)
@@ -6,7 +6,6 @@ import { ErrorLogs } from "/assets/components/tools/error_logs.js"
import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js"
import { GalaxyPairing } from "/assets/components/tools/galaxy.js"
import { Home } from "/assets/components/home/home.js"
import { LateralManeuvers } from "/assets/components/tools/lateral_maneuvers.js"
import { LongitudinalManeuvers } from "/assets/components/tools/longitudinal_maneuvers.js"
import { MapsManager } from "/assets/components/tools/maps.js"
import { NavDestination } from "/assets/components/navigation/navigation_destination.js?v=nav-search-context-1"
@@ -14,12 +13,13 @@ import { NavKeys } from "/assets/components/navigation/navigation_keys.js?v=app-
import { RouteRecordings } from "/assets/components/recordings/dashcam_routes.js"
import { SettingsView } from "/assets/components/settings.js"
import { ScreenRecordings } from "/assets/components/recordings/screen_recordings.js"
import { Sidebar } from "/assets/components/sidebar.js?v=app-keys-session-1"
import { Sidebar } from "/assets/components/sidebar.js?v=lateral-tuning-1"
import { SpeedLimits } from "/assets/components/tools/speed_limits.js"
import { ModelManager } from "/assets/components/tools/model_manager.js?v=20260303t"
import { LivePlots } from "/assets/components/tools/plots.js"
import { ThemeMaker } from "/assets/components/tools/theme_maker.js"
import { TestingGround } from "/assets/components/tools/testing_ground.js"
import { Tuning } from "/assets/components/tools/tuning.js?v=ftm-workspace-4"
import { Troubleshoot } from "/assets/components/tools/troubleshoot.js"
import { TmuxLog } from "/assets/components/tools/tmux.js"
import { ToggleControl } from "/assets/components/tools/toggles.js"
@@ -72,7 +72,8 @@ function Root() {
createRoute("settings", "/settings/:section/:subsection?", SettingsView),
createRoute("speed_limits", "/download_speed_limits", SpeedLimits),
createRoute("model_manager", "/manage_models", ModelManager),
createRoute("lateral_maneuvers", "/lateral_maneuvers", LateralManeuvers),
createRoute("tuning", "/tuning", Tuning),
createRoute("lateral_maneuvers", "/lateral_maneuvers", Tuning),
createRoute("longitudinal_maneuvers", "/longitudinal_maneuvers", LongitudinalManeuvers),
createRoute("maps", "/manage_maps", MapsManager),
createRoute("plots", "/plots", LivePlots),
@@ -14,7 +14,7 @@ const MENU_ITEMS = {
{ name: "Download Speed Limits", link: "/download_speed_limits", icon: "bi-download" },
{ name: "Error Logs", link: "/manage_error_logs", icon: "bi-exclamation-triangle" },
{ name: "Galaxy", link: "/galaxy", icon: "bi-globe2" },
{ name: "Lateral Maneuvers", link: "/lateral_maneuvers", icon: "bi-sign-turn-right" },
{ name: "Lateral Tuning", link: "/tuning", icon: "bi-sign-turn-right" },
{ name: "Long Maneuvers", link: "/longitudinal_maneuvers", icon: "bi-signpost-split" },
{ name: "Maps", link: "/manage_maps", icon: "bi-map" },
{ name: "Navigation", link: "/set_navigation_destination", icon: "bi-geo-alt-fill" },
@@ -33,6 +33,7 @@ const MENU_ITEMS = {
function matchesPath(currentPath, link) {
if (link === "/") return currentPath === "/";
if (link === "/tuning" && currentPath === "/lateral_maneuvers") return true;
return currentPath === link || currentPath.startsWith(`${link}/`);
}
@@ -0,0 +1,143 @@
.ftmTwoColumn {
display: grid;
gap: var(--gap-base);
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: var(--margin-base);
}
.ftmCard {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--border-radius-md);
margin-top: var(--margin-base);
padding: var(--padding-base);
}
.ftmCardHeader {
align-items: flex-start;
display: flex;
gap: var(--gap-sm);
justify-content: space-between;
}
.ftmCardHeader h3,
.ftmCardHeader h4,
.ftmCardHeader h5 {
margin: 0;
}
.ftmCardSubsection {
margin-top: var(--margin-base);
}
.ftmRouteList,
.ftmWorkspaceList,
.ftmFindings {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
margin-top: var(--margin-base);
}
.ftmWorkspaceRow {
display: flex;
gap: var(--gap-sm);
}
.ftmRouteList {
max-height: 28rem;
overflow: auto;
padding-right: var(--padding-xs);
}
.ftmRouteItem,
.ftmWorkspaceItem {
align-items: flex-start;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: var(--border-radius-sm);
color: var(--text-color);
cursor: pointer;
display: flex;
gap: var(--gap-sm);
padding: var(--padding-sm);
text-align: left;
}
.ftmRouteItem input {
margin-top: 0.2rem;
}
.ftmRouteItem span,
.ftmWorkspaceItem {
display: flex;
flex-direction: column;
}
.ftmWorkspaceItem {
flex: 1 1 auto;
}
.ftmWorkspaceDelete {
align-self: stretch;
flex: 0 0 auto;
}
.ftmRouteItem small,
.ftmWorkspaceItem small,
.ftmWorkspaceItem span {
color: var(--text-muted);
}
.ftmWorkspaceItem:hover,
.ftmRouteItem:hover,
.ftmCard button.selected {
border-color: var(--main-fg);
}
.ftmProfileGrid {
display: grid;
gap: var(--gap-base);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ftmFeedbackButtons {
display: flex;
flex-wrap: wrap;
gap: var(--gap-xs);
}
.ftmDeltaBox {
background: rgba(255, 255, 255, 0.03);
border-radius: var(--border-radius-sm);
margin-top: var(--margin-sm);
padding: var(--padding-sm);
}
.ftmNotes {
background: var(--secondary-bg);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--border-radius-sm);
color: var(--text-color);
margin-top: var(--margin-base);
min-height: 5.5rem;
padding: var(--padding-sm);
resize: vertical;
width: 100%;
}
.ftmPlotWrap {
margin-top: var(--margin-base);
}
.ftmPlotWrap svg {
height: 140px;
width: 100%;
}
@media only screen and (max-width: 900px) {
.ftmTwoColumn,
.ftmProfileGrid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,846 @@
import { html, reactive } from "/assets/vendor/arrow-core.js"
const MAX_RENDERED_ROUTES = 250
const ROUTE_FLUSH_INTERVAL_MS = 120
const STATUS_POLL_MS = 3000
const state = reactive({
loadingRoutes: true,
loadingWorkspace: true,
runningAction: false,
error: "",
routes: [],
selectedRoutes: [],
truncatedRoutes: false,
routeProgress: 0,
routeTotal: 0,
workspace: { reports: [], activeTrial: null, status: {} },
status: {},
report: null,
feedbackAccepted: [],
feedbackIgnored: [],
feedbackNotes: "",
})
let routesAbortController = null
let routesRequestToken = 0
let pendingRoutes = []
let flushTimerId = null
let seenRouteNames = new Set()
let initialized = false
let statusPollHandle = null
function isTuningRouteActive() {
return window.location.pathname === "/tuning" || window.location.pathname === "/lateral_maneuvers"
}
function formatTimestamp(value) {
if (!value) return "Unknown Route"
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return String(value)
return parsed.toLocaleString()
}
function safeCount(value) {
const n = Number(value)
return Number.isFinite(n) ? n : 0
}
function formatStatusAge(updatedAt) {
const updated = Number(updatedAt)
if (!Number.isFinite(updated) || updated <= 0) return "unknown"
const ageSec = Math.max(0, Math.round(Date.now() / 1000 - updated))
if (ageSec < 5) return "just now"
if (ageSec < 60) return `${ageSec}s ago`
if (ageSec < 3600) return `${Math.round(ageSec / 60)}m ago`
return `${Math.round(ageSec / 3600)}h ago`
}
function resetRouteStreamState() {
pendingRoutes = []
seenRouteNames = new Set()
if (flushTimerId !== null) {
clearTimeout(flushTimerId)
flushTimerId = null
}
}
function sortedRoutes() {
return [...state.routes].sort((a, b) => {
const aTime = Date.parse(a.timestamp)
const bTime = Date.parse(b.timestamp)
if (Number.isFinite(aTime) && Number.isFinite(bTime)) return bTime - aTime
return String(b.timestamp || "").localeCompare(String(a.timestamp || ""))
})
}
function flushPendingRoutes() {
if (!pendingRoutes.length) return
const availableSlots = Math.max(MAX_RENDERED_ROUTES - state.routes.length, 0)
if (availableSlots <= 0) {
pendingRoutes = []
state.truncatedRoutes = true
return
}
const toAppend = pendingRoutes.slice(0, availableSlots)
pendingRoutes = []
if (toAppend.length > 0) {
state.routes = [...state.routes, ...toAppend]
}
if (state.routes.length >= MAX_RENDERED_ROUTES) {
state.truncatedRoutes = true
}
}
function enqueueRoutes(rawRoutes) {
if (!Array.isArray(rawRoutes) || rawRoutes.length === 0) return
const nextRoutes = []
for (const route of rawRoutes) {
const name = String(route?.name || "")
if (!name || seenRouteNames.has(name)) continue
seenRouteNames.add(name)
nextRoutes.push({
...route,
timestampLabel: formatTimestamp(route.timestamp),
})
}
if (!nextRoutes.length) return
pendingRoutes.push(...nextRoutes)
if (flushTimerId === null) {
flushTimerId = setTimeout(() => {
flushTimerId = null
flushPendingRoutes()
}, ROUTE_FLUSH_INTERVAL_MS)
}
}
async function fetchRoutes() {
const requestToken = ++routesRequestToken
if (routesAbortController) routesAbortController.abort()
const controller = new AbortController()
routesAbortController = controller
try {
state.loadingRoutes = true
state.error = ""
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const response = await fetch(`/api/routes?timezone=${encodeURIComponent(userTimezone)}`, { signal: controller.signal })
if (!response.ok) throw new Error("Failed to load local routes.")
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { value, done } = await reader.read()
if (done) break
if (requestToken !== routesRequestToken) return
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split(/\r?\n\r?\n/)
buffer = lines.pop() || ""
for (const line of lines) {
if (!line.startsWith("data:")) continue
try {
const data = JSON.parse(line.substring(5).trim())
if (data.progress !== undefined && data.total !== undefined) {
state.routeProgress = safeCount(data.progress)
state.routeTotal = safeCount(data.total)
}
if (Array.isArray(data.routes)) {
enqueueRoutes(data.routes)
}
} catch (error) {
console.error("[ftm] failed to parse route payload", error)
}
}
}
flushPendingRoutes()
} catch (error) {
if (error?.name !== "AbortError") {
state.error = error?.message || "Failed to load local routes."
}
} finally {
if (requestToken === routesRequestToken) {
flushPendingRoutes()
state.loadingRoutes = false
if (routesAbortController === controller) {
routesAbortController = null
}
}
}
}
async function fetchWorkspace() {
try {
state.loadingWorkspace = true
const response = await fetch("/api/ftm/workspace")
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Failed to load tuning workspace.")
state.workspace = payload
state.status = payload.status || {}
} catch (error) {
state.error = error?.message || "Failed to load tuning workspace."
} finally {
state.loadingWorkspace = false
}
}
function syncFeedbackState(report) {
const feedback = report?.feedback || {}
state.feedbackAccepted = Array.isArray(feedback.acceptedDimensions) ? [...feedback.acceptedDimensions] : []
state.feedbackIgnored = Array.isArray(feedback.ignoredDimensions) ? [...feedback.ignoredDimensions] : []
state.feedbackNotes = typeof feedback.notes === "string" ? feedback.notes : ""
}
async function loadReport(reportId) {
if (!reportId) return
try {
const response = await fetch(`/api/ftm/report/${encodeURIComponent(reportId)}`)
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Failed to load tuning report.")
state.report = payload
syncFeedbackState(payload)
await fetchWorkspace()
} catch (error) {
state.error = error?.message || "Failed to load tuning report."
}
}
async function deleteReport(reportId) {
if (!reportId || state.runningAction) return
if (!window.confirm("Delete this saved tuning report and its generated trial data?")) return
state.runningAction = true
try {
const response = await fetch(`/api/ftm/report/${encodeURIComponent(reportId)}`, { method: "DELETE" })
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Failed to delete tuning report.")
if (state.report?.reportId === reportId) {
state.report = null
syncFeedbackState(null)
}
state.workspace = payload.workspace || state.workspace
state.status = { ...state.status, ...(payload.workspace?.status || {}) }
showSnackbar(payload.message || "Deleted tuning report.")
} catch (error) {
state.error = error?.message || "Failed to delete tuning report."
showSnackbar(state.error, "error")
} finally {
state.runningAction = false
}
}
async function clearWorkspace() {
if (state.runningAction) return
if (!window.confirm("Clear every saved tuning report, feedback entry, generated profile, and snapshot from the device?")) return
state.runningAction = true
try {
const response = await fetch("/api/ftm/workspace/clear", { method: "POST" })
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Failed to clear tuning workspace.")
state.report = null
syncFeedbackState(null)
state.workspace = payload.workspace || { reports: [], activeTrial: null, status: {} }
state.status = { ...state.status, ...(payload.workspace?.status || {}) }
showSnackbar(payload.message || "Cleared tuning workspace.")
} catch (error) {
state.error = error?.message || "Failed to clear tuning workspace."
showSnackbar(state.error, "error")
} finally {
state.runningAction = false
}
}
async function fetchStatus() {
try {
const response = await fetch("/api/ftm/status")
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Failed to load tuning status.")
state.status = {
...(payload.status || {}),
isOnroad: !!payload.isOnroad,
}
if (payload.activeTrial !== undefined) {
state.workspace = { ...state.workspace, activeTrial: payload.activeTrial, reports: payload.reports || state.workspace.reports }
}
const reportId = state.status.reportId
if (reportId && state.report?.reportId !== reportId) {
await loadReport(reportId)
}
} catch (error) {
state.error = error?.message || "Failed to load tuning status."
}
}
function stopPolling() {
if (statusPollHandle) {
clearTimeout(statusPollHandle)
statusPollHandle = null
}
}
function ensurePolling() {
if (statusPollHandle) return
const poll = async () => {
if (!isTuningRouteActive()) {
stopPolling()
return
}
if (document.visibilityState === "visible") {
await fetchStatus()
}
statusPollHandle = setTimeout(poll, STATUS_POLL_MS)
}
statusPollHandle = setTimeout(poll, STATUS_POLL_MS)
}
function toggleRouteSelection(routeName) {
const current = new Set(state.selectedRoutes)
if (current.has(routeName)) {
current.delete(routeName)
} else {
current.add(routeName)
}
state.selectedRoutes = [...current]
}
function clearSelections() {
state.selectedRoutes = []
}
async function runAnalyze() {
if (!state.selectedRoutes.length || state.runningAction) return
state.runningAction = true
try {
const response = await fetch("/api/ftm/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ routes: state.selectedRoutes }),
})
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Failed to start tuning analysis.")
state.status = payload.status || {}
showSnackbar(payload.message || "FTM analysis started.")
} catch (error) {
state.error = error?.message || "Failed to start tuning analysis."
showSnackbar(state.error, "error")
} finally {
state.runningAction = false
}
}
async function stopAnalyze() {
if (state.runningAction) return
state.runningAction = true
try {
const response = await fetch("/api/ftm/analyze/stop", { method: "POST" })
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Failed to stop tuning analysis.")
state.status = payload.status || {}
showSnackbar(payload.message || "FTM analysis stopped.")
} catch (error) {
state.error = error?.message || "Failed to stop tuning analysis."
showSnackbar(state.error, "error")
} finally {
state.runningAction = false
}
}
async function applyProfile(profileId) {
if (!state.report?.reportId || !profileId || state.runningAction) return
state.runningAction = true
try {
const response = await fetch("/api/ftm/trials/apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reportId: state.report.reportId, profileId }),
})
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Failed to apply trial profile.")
await fetchWorkspace()
showSnackbar(payload.message || "Trial profile applied.")
} catch (error) {
state.error = error?.message || "Failed to apply trial profile."
showSnackbar(state.error, "error")
} finally {
state.runningAction = false
}
}
async function revertProfile() {
if (state.runningAction) return
state.runningAction = true
try {
const response = await fetch("/api/ftm/trials/revert", { method: "POST" })
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Failed to revert trial profile.")
await fetchWorkspace()
showSnackbar(payload.message || "Trial profile reverted.")
} catch (error) {
state.error = error?.message || "Failed to revert trial profile."
showSnackbar(state.error, "error")
} finally {
state.runningAction = false
}
}
function setDimensionFeedback(dimensionId, mode) {
const accepted = new Set(state.feedbackAccepted)
const ignored = new Set(state.feedbackIgnored)
accepted.delete(dimensionId)
ignored.delete(dimensionId)
if (mode === "accepted") accepted.add(dimensionId)
if (mode === "ignored") ignored.add(dimensionId)
state.feedbackAccepted = [...accepted]
state.feedbackIgnored = [...ignored]
}
async function saveFeedback() {
if (!state.report?.reportId || state.runningAction) return
state.runningAction = true
try {
const response = await fetch("/api/ftm/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
reportId: state.report.reportId,
acceptedDimensions: state.feedbackAccepted,
ignoredDimensions: state.feedbackIgnored,
notes: state.feedbackNotes,
}),
})
const payload = await response.json()
if (!response.ok) throw new Error(payload.error || "Failed to save tuning feedback.")
state.report = {
...state.report,
feedback: payload.feedback,
profiles: payload.profiles || state.report.profiles,
}
syncFeedbackState(state.report)
await fetchWorkspace()
showSnackbar(payload.message || "Feedback saved.")
} catch (error) {
state.error = error?.message || "Failed to save tuning feedback."
showSnackbar(state.error, "error")
} finally {
state.runningAction = false
}
}
function feedbackStateFor(dimensionId) {
if (state.feedbackAccepted.includes(dimensionId)) return "accepted"
if (state.feedbackIgnored.includes(dimensionId)) return "ignored"
return "unset"
}
function initialize() {
if (initialized) return
initialized = true
resetRouteStreamState()
fetchRoutes()
fetchWorkspace().then(() => {
const latestReport = state.workspace?.reports?.[0]?.reportId
if (latestReport) loadReport(latestReport)
})
fetchStatus()
ensurePolling()
}
function renderCurve(values) {
return `[${(values || []).map((value) => Number(value).toFixed(3)).join(", ")}]`
}
function reportPaths() {
if (Array.isArray(state.report?.paths) && state.report.paths.length) {
return state.report.paths
}
if (!state.report) return []
return [{
key: state.report.primaryPathKey || "cleanup_pass",
title: "Recommendations",
description: "",
whenToUse: "",
whySelected: "",
isPrimary: true,
suggestions: state.report.suggestions || [],
profiles: state.report.profiles || [],
}]
}
function primaryPath() {
const paths = reportPaths()
return paths.find((path) => path.isPrimary) || paths[0] || null
}
function renderProfile(profile) {
const genericEntries = Object.entries(profile.genericParams || {}).filter(([key]) => key !== "AdvancedLateralTune")
const frictionEntries = Object.entries(profile.ftmOverrides?.baseFrictionThresholds || {})
const vehicleKnobEntries = Object.entries(profile.ftmOverrides?.vehicleKnobs || {})
return html`
<div class="ftmCard">
<div class="ftmCardHeader">
<div>
<h4>${profile.label}</h4>
<p class="longManeuverMuted">${profile.description}</p>
</div>
<button
class="longManeuverButton"
disabled="${() => state.runningAction || false}"
@click="${() => applyProfile(profile.id)}">
Apply Trial
</button>
</div>
<div class="ftmProfileGrid">
<div>
<h5>Generic Params</h5>
<ul>
${genericEntries.length
? genericEntries.map(([key, value]) => html`<li><code>${key}</code>: ${String(value)}</li>`)
: html`<li>None</li>`}
</ul>
</div>
<div>
<h5>FTM Overrides</h5>
<ul>
${frictionEntries.map(([family, payload]) => html`<li><code>${family}</code>: ${renderCurve(payload?.values || [])}</li>`)}
${vehicleKnobEntries.map(([key, value]) => html`<li><code>${key}</code>: ${Number(value).toFixed(3)}</li>`)}
${!frictionEntries.length && !vehicleKnobEntries.length ? html`<li>None</li>` : ""}
</ul>
</div>
</div>
</div>
`
}
function renderSuggestion(suggestion) {
const currentVsSuggested = suggestion.currentVsSuggested
const feedbackState = feedbackStateFor(suggestion.dimensionId)
return html`
<div class="ftmCard">
<div class="ftmCardHeader">
<div>
<h4>${suggestion.bucket.replace(/_/g, " ")}</h4>
<p class="longManeuverMuted">
${suggestion.evidence?.speedBand || "mixed"} |
${suggestion.evidence?.directionBias || "center"} |
${safeCount(suggestion.evidence?.eventCount)} event(s)
</p>
</div>
<div class="ftmFeedbackButtons">
<button
class="longManeuverButton ${feedbackState === "accepted" ? "selected" : ""}"
@click="${() => setDimensionFeedback(suggestion.dimensionId, feedbackState === "accepted" ? "unset" : "accepted")}">
Matches Experience
</button>
<button
class="longManeuverButton ${feedbackState === "ignored" ? "danger selected" : "danger"}"
@click="${() => setDimensionFeedback(suggestion.dimensionId, feedbackState === "ignored" ? "unset" : "ignored")}">
Ignore
</button>
</div>
</div>
<p><strong>Observed behavior:</strong> ${suggestion.observedBehavior}</p>
<p><strong>Likely interpretation:</strong> ${suggestion.likelyInterpretation}</p>
<p><strong>Primary adjustment:</strong> ${suggestion.primaryAdjustment}</p>
<p><strong>What not to touch yet:</strong> ${suggestion.whatNotToTouchYet}</p>
<p><strong>If that was wrong, next thing to try:</strong> ${suggestion.ifThatWasWrong}</p>
<p><strong>Strongest segments:</strong> ${(suggestion.evidence?.segments || []).map((segment) => segment.label).join(", ") || "none"}</p>
${currentVsSuggested
? html`
<div class="ftmDeltaBox">
<strong>Current vs suggested:</strong>
${currentVsSuggested.type === "friction_curve"
? html`
<p><code>${currentVsSuggested.family}</code> current: ${renderCurve(currentVsSuggested.current)}</p>
<p><code>${currentVsSuggested.family}</code> suggested: ${renderCurve(currentVsSuggested.suggested)}</p>
`
: html`
<p>
<code>${currentVsSuggested.paramKey || currentVsSuggested.symbol}</code>:
${Number(currentVsSuggested.current).toFixed(3)} -> ${Number(currentVsSuggested.suggested).toFixed(3)}
</p>
`}
</div>
`
: html`<p class="longManeuverMuted">No trial adjustment suggested for this dimension.</p>`}
${suggestion.plotSvg
? html`<div class="ftmPlotWrap"><p class="longManeuverMuted">Saved report includes an inline event plot for this finding.</p></div>`
: ""}
</div>
`
}
function renderPathSummary(path) {
return html`
<div class="ftmCard">
<div class="ftmCardHeader">
<div>
<h4>${path.title}</h4>
<p class="longManeuverMuted">${path.isPrimary ? "Recommended path" : "Alternate path"}</p>
</div>
</div>
<p>${path.description || ""}</p>
<p><strong>Why this path:</strong> ${path.whySelected || "No path note available."}</p>
<p><strong>When to use it:</strong> ${path.whenToUse || "Use the path that best matches the spread of the problem."}</p>
</div>
`
}
export function Tuning() {
initialize()
return html`
<div class="longManeuverPage">
<h2>Lateral Tuning</h2>
<div class="longManeuverCard">
<p class="longManeuverIntro">
Analyze one or more local routes, review deterministic lateral findings, apply a bounded trial, drive, then revert or refine.
</p>
<div class="longManeuverActions">
<button
class="longManeuverButton"
disabled="${() => state.runningAction || state.selectedRoutes.length === 0 || !!state.status?.isOnroad}"
@click="${runAnalyze}">
Analyze Selected Routes
</button>
<button
class="longManeuverButton danger"
disabled="${() => state.runningAction || !state.status?.running}"
@click="${stopAnalyze}">
Stop Analysis
</button>
<button
class="longManeuverButton"
disabled="${() => state.runningAction || !state.workspace?.activeTrial}"
@click="${revertProfile}">
Revert Trial
</button>
<button
class="longManeuverButton"
disabled="${() => state.runningAction}"
@click="${() => {
state.routes = []
state.routeProgress = 0
state.routeTotal = 0
state.truncatedRoutes = false
resetRouteStreamState()
fetchRoutes()
fetchWorkspace()
fetchStatus()
}}">
Refresh
</button>
</div>
${() => state.error ? html`<p class="longManeuverError">${state.error}</p>` : ""}
<div class="longManeuverStatusGrid">
<p><strong>Status:</strong> ${() => state.status?.state || "idle"}</p>
<p><strong>Running:</strong> ${() => state.status?.running ? "Yes" : "No"}</p>
<p><strong>Onroad:</strong> ${() => state.status?.isOnroad ? "Yes" : "No"}</p>
<p><strong>Updated:</strong> ${() => formatStatusAge(state.status?.updatedAt)}</p>
<p><strong>Selected Routes:</strong> ${() => state.selectedRoutes.length}</p>
<p><strong>Progress:</strong> ${() => `${safeCount(state.status?.progress)}/${safeCount(state.status?.total)}`}</p>
<p><strong>Active Trial:</strong> ${() => state.workspace?.activeTrial?.profileId || "None"}</p>
</div>
${() => state.status?.isOnroad ? html`
<p class="longManeuverError">FTM analysis is offroad-only. Stop the car and go offroad before starting a run.</p>
` : ""}
${() => state.status?.currentSegment ? html`
<div class="longManeuverCurrent">
<p><strong>Current Segment:</strong> ${state.status.currentSegment}</p>
</div>
` : ""}
<div class="ftmTwoColumn">
<section class="ftmCard">
<div class="ftmCardHeader">
<div>
<h3>Local Routes</h3>
<p class="longManeuverMuted">
Pick up to 8 routes from the device. The analyzer prefers rlogs and falls back to qlogs when needed.
</p>
</div>
<button class="longManeuverButton" @click="${clearSelections}">Clear</button>
</div>
${() => state.loadingRoutes ? html`<p class="longManeuverMuted">Loading local routes...</p>` : ""}
${() => state.routeTotal ? html`<p class="longManeuverMuted">Route index: ${state.routeProgress}/${state.routeTotal}</p>` : ""}
${() => state.truncatedRoutes ? html`<p class="longManeuverMuted">Showing the first ${MAX_RENDERED_ROUTES} routes only.</p>` : ""}
<div class="ftmRouteList">
${() => sortedRoutes().map((route) => html`
<label class="ftmRouteItem">
<input
type="checkbox"
checked="${() => state.selectedRoutes.includes(route.name)}"
@change="${() => toggleRouteSelection(route.name)}" />
<span>
<strong>${route.timestampLabel}</strong>
<small>${route.name}</small>
</span>
</label>
`)}
</div>
</section>
<section class="ftmCard">
<div class="ftmCardHeader">
<div>
<h3>Workspace</h3>
</div>
<button
class="longManeuverButton danger"
disabled="${() => state.runningAction || !(state.workspace?.reports || []).length}"
@click="${clearWorkspace}">
Clear Workspace
</button>
</div>
${() => state.loadingWorkspace ? html`<p class="longManeuverMuted">Loading workspace...</p>` : ""}
<p class="longManeuverMuted">
Recent reports stay on-device under <code>/data/galaxy/ftm</code>. Loading a report refreshes the suggestion and trial view below.
</p>
<div class="ftmWorkspaceList">
${() => (state.workspace?.reports || []).length
? state.workspace.reports.map((report) => html`
<div class="ftmWorkspaceRow">
<button class="ftmWorkspaceItem" @click="${() => loadReport(report.reportId)}">
<strong>${report.carFingerprint || "Unknown car"}</strong>
<span>${(report.routeNames || []).join(", ")}</span>
<small>${formatTimestamp(report.createdAt ? new Date(report.createdAt * 1000).toISOString() : "")}</small>
</button>
<button
class="longManeuverButton danger ftmWorkspaceDelete"
disabled="${() => state.runningAction}"
@click="${() => deleteReport(report.reportId)}">
Delete
</button>
</div>
`)
: html`<p class="longManeuverMuted">No tuning reports yet.</p>`}
</div>
</section>
</div>
${() => state.report ? html`
<section class="ftmCard">
<div class="ftmCardHeader">
<div>
<h3>Report Summary</h3>
</div>
<button
class="longManeuverButton danger"
disabled="${() => state.runningAction || !state.report?.reportId}"
@click="${() => deleteReport(state.report.reportId)}">
Delete Report
</button>
</div>
<div class="longManeuverStatusGrid">
<p><strong>Car:</strong> ${state.report.car?.carFingerprint || "Unknown"}</p>
<p><strong>Control Path:</strong> ${state.report.car?.controlPath || "unknown"}</p>
<p><strong>Friction Family:</strong> ${state.report.capabilities?.frictionFamily || "standard"}</p>
<p><strong>Recommended Path:</strong> ${primaryPath()?.title || "Recommendations"}</p>
<p><strong>Processed Segments:</strong> ${safeCount(state.report.summary?.processedSegments)}</p>
<p><strong>qlog Fallback:</strong> ${state.report.summary?.usedQlogFallback ? "Yes" : "No"}</p>
<p><strong>Samples:</strong> ${safeCount(state.report.summary?.sampleCount)}</p>
</div>
<div class="ftmFindings">
${reportPaths().map((path) => renderPathSummary(path))}
</div>
${() => (state.report.warnings || []).length ? html`
<div class="ftmCardSubsection">
<h4>Warnings</h4>
<ul>
${(state.report.warnings || []).map((warning) => html`<li>${warning}</li>`)}
</ul>
</div>
` : ""}
${() => (state.report.addTheseParametersAndStartHere || []).length ? html`
<div class="ftmCardSubsection">
<h4>Add These Parameters And Start Here</h4>
<ul>
${(state.report.addTheseParametersAndStartHere || []).map((line) => html`<li>${line}</li>`)}
</ul>
</div>
` : ""}
</section>
<section class="ftmCard">
<div class="ftmCardHeader">
<div>
<h3>Recommended Findings: ${primaryPath()?.title || "Recommendations"}</h3>
<p class="longManeuverMuted">
${primaryPath()?.whySelected || "Mark the dimensions that match what the driver felt."}
</p>
</div>
<button
class="longManeuverButton"
disabled="${() => state.runningAction || !state.report}"
@click="${saveFeedback}">
Save Feedback
</button>
</div>
<textarea
class="ftmNotes"
placeholder="Optional tuning notes"
@input="${(event) => { state.feedbackNotes = event.target.value }}">${() => state.feedbackNotes}</textarea>
<div class="ftmFindings">
${((primaryPath()?.suggestions) || []).map((suggestion) => renderSuggestion(suggestion))}
</div>
</section>
<section class="ftmCard">
<h3>Trial Profiles</h3>
<p class="longManeuverMuted">
Apply one bounded profile at a time. Revert restores the exact advanced-lateral and FTM state that existed before the trial.
</p>
<div class="ftmFindings">
${reportPaths().length
? reportPaths().map((path) => html`
<div>
<h4>${path.title} Profiles</h4>
<p class="longManeuverMuted">${path.whenToUse || ""}</p>
${(path.profiles || []).length
? (path.profiles || []).map((profile) => renderProfile(profile))
: html`<p class="longManeuverMuted">No trial profiles generated for this path.</p>`}
</div>
`)
: html`<p class="longManeuverMuted">No trial profiles generated for this report.</p>`}
</div>
</section>
` : html`
<section class="ftmCard">
<h3>No Active Report</h3>
<p class="longManeuverMuted">
Select local routes, run analysis, or open one of the saved reports from the workspace panel.
</p>
</section>
`}
</div>
</div>
`
}
File diff suppressed because it is too large Load Diff
@@ -34,6 +34,7 @@
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
<link rel="stylesheet" href="/assets/components/tools/testing_ground.css">
<link rel="stylesheet" href="/assets/components/tools/tuning.css?v=ftm-workspace-3">
<link rel="stylesheet" href="/assets/components/tools/troubleshoot.css">
<link rel="stylesheet" href="/assets/components/tools/tmux.css">
<link rel="stylesheet" href="/assets/components/tools/toggles.css">
@@ -44,7 +45,7 @@
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
<script type="module">
import("/assets/components/router.js?v=favorite-slots-6").catch((err) => {
import("/assets/components/router.js?v=ftm-workspace-1").catch((err) => {
console.error("[the_galaxy] bootstrap failed", err);
const target = document.getElementById("app") || document.body;
const pre = document.createElement("pre");
@@ -0,0 +1,483 @@
import importlib.util
import json
import math
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
import pytest
MODULE_PATH = Path(__file__).resolve().parents[1] / "ftm_workspace.py"
def _simple_module(name, **attrs):
module = ModuleType(name)
for attr, value in attrs.items():
setattr(module, attr, value)
return module
def _install_ftm_import_stubs(tmp_path):
class FakeParams:
_store = {}
def __init__(self, return_defaults=False):
self.return_defaults = return_defaults
def get(self, key, block=False, return_default=False, encoding=None, default=None):
del block, return_default
value = self._store.get(key, default)
if encoding and isinstance(value, bytes):
return value.decode(encoding, errors="replace")
return value
def get_bool(self, key, default=False):
value = self._store.get(key, default)
if isinstance(value, bool):
return value
return str(value).strip().lower() in ("1", "true", "yes", "on")
def get_float(self, key, block=False, return_default=False, default=0.0):
del block, return_default
value = self._store.get(key, default)
try:
return float(value)
except Exception:
return default
def put(self, key, value):
self._store[key] = value
def put_bool(self, key, value):
self._store[key] = bool(value)
def put_float(self, key, value):
self._store[key] = float(value)
FakeParams._store = {}
class FakeHyundaiFlags:
CANFD = 1
class FakeSteerControlType:
torque = 0
fake_car_params = SimpleNamespace(SteerControlType=FakeSteerControlType)
cereal_car = _simple_module("cereal.car", CarParams=fake_car_params)
cereal = _simple_module("cereal", car=cereal_car)
sys.modules["cereal"] = cereal
sys.modules["cereal.car"] = cereal_car
sys.modules["opendbc.car.hyundai.values"] = _simple_module("opendbc.car.hyundai.values", HyundaiFlags=FakeHyundaiFlags)
sys.modules["openpilot.common.params"] = _simple_module("openpilot.common.params", Params=FakeParams)
sys.modules["openpilot.selfdrive.controls.lib.latcontrol_torque"] = _simple_module(
"openpilot.selfdrive.controls.lib.latcontrol_torque",
KP=1.0,
)
def normalize_ftm_overrides(payload):
if isinstance(payload, str):
payload = json.loads(payload)
payload = payload or {}
normalized = {
"schemaVersion": 1,
"baseFrictionThresholds": {},
"vehicleKnobs": {},
}
for family, family_payload in payload.get("baseFrictionThresholds", {}).items():
values = family_payload.get("values", [])
if len(values) == 5:
normalized["baseFrictionThresholds"][family] = {
"speedKnots": [0.0, 5.0, 10.0, 15.0, 25.0],
"values": [float(value) for value in values],
}
for key, value in payload.get("vehicleKnobs", {}).items():
normalized["vehicleKnobs"][key] = float(value)
if not normalized["baseFrictionThresholds"] and not normalized["vehicleKnobs"]:
return {}
return normalized
sys.modules["openpilot.selfdrive.controls.lib.latcontrol_vehicle_tunes"] = _simple_module(
"openpilot.selfdrive.controls.lib.latcontrol_vehicle_tunes",
FTM_FRICTION_SPEED_KNOTS=[0.0, 5.0, 10.0, 15.0, 25.0],
get_ftm_capabilities=lambda *args, **kwargs: {"richProfileKey": "hyundai_ioniq_6", "frictionFamily": "hkg_canfd"},
get_ftm_rich_profile_key=lambda *args, **kwargs: "hyundai_ioniq_6",
get_ftm_supported_vehicle_knobs=lambda: {
"hyundai_ioniq_6.turn_in_boost_left": {"min": 0.4, "max": 2.8, "precision": 0.001, "defaultValue": 1.64, "profile": "hyundai_ioniq_6"},
"hyundai_ioniq_6.unwind_taper_left": {"min": 0.0, "max": 1.2, "precision": 0.001, "defaultValue": 0.4, "profile": "hyundai_ioniq_6"},
"hyundai_ioniq_6.low_speed_angle_assist_max_torque": {"min": 0.0, "max": 0.8, "precision": 0.001, "defaultValue": 0.46, "profile": "hyundai_ioniq_6"},
"hyundai_ioniq_6.crawl_turn_in_ff_boost_left": {"min": 0.0, "max": 0.5, "precision": 0.001, "defaultValue": 0.18, "profile": "hyundai_ioniq_6"},
"hyundai_ioniq_6.curvy_turn_in_trim_left": {"min": 0.0, "max": 0.2, "precision": 0.001, "defaultValue": 0.06, "profile": "hyundai_ioniq_6"},
"hyundai_ioniq_6.curvy_unwind_extra_reduction_left": {"min": 0.0, "max": 0.45, "precision": 0.001, "defaultValue": 0.18, "profile": "hyundai_ioniq_6"},
},
get_gm_base_friction_threshold=lambda v_ego: 0.20 + (0.001 * float(v_ego)),
get_hkg_canfd_base_friction_threshold=lambda v_ego: 0.39 + (0.001 * float(v_ego)),
get_standard_friction_threshold=lambda v_ego: 0.30 + (0.001 * float(v_ego)),
normalize_ftm_overrides=normalize_ftm_overrides,
)
sys.modules["openpilot.system.hardware"] = _simple_module("openpilot.system.hardware", PC=True)
sys.modules["openpilot.system.hardware.hw"] = _simple_module(
"openpilot.system.hardware.hw",
Paths=SimpleNamespace(comma_home=lambda: str(tmp_path), log_root=lambda **kwargs: str(tmp_path / "logs")),
)
sys.modules["openpilot.tools.lib.logreader"] = _simple_module("openpilot.tools.lib.logreader", LogReader=lambda *args, **kwargs: [])
sys.modules["openpilot.starpilot.system.the_galaxy.utilities"] = _simple_module(
"openpilot.starpilot.system.the_galaxy.utilities",
get_segments_in_route=lambda route, footage_path: [],
)
return FakeParams
def _load_ftm_workspace_module(tmp_path):
fake_params_cls = _install_ftm_import_stubs(tmp_path)
module_name = f"test_ftm_workspace_{hash(tmp_path)}"
spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module, fake_params_cls
def _sample(module, **kwargs):
base = dict(
route="route",
segment=0,
t=0.0,
v_ego=28.0,
lat_active=True,
steering_pressed=False,
saturated=False,
actual_la=0.0,
desired_la=0.0,
desired_jerk=0.0,
error=0.0,
error_rate=0.0,
p=0.0,
i=0.0,
d=0.0,
f=0.0,
output=0.0,
steering_angle_deg=0.0,
steering_torque=0.0,
cmd_torque=0.0,
out_torque=0.0,
roll_deg=0.0,
)
base.update(kwargs)
return module.FTMSample(**base)
def test_classify_torque_samples_detects_center_chatter(tmp_path):
module, _ = _load_ftm_workspace_module(tmp_path)
samples = []
for idx in range(60):
angle = 0.75 * math.sin(idx * 0.9)
samples.append(_sample(
module,
t=idx * 0.1,
desired_la=0.04 * math.sin(idx * 0.1),
actual_la=0.03 * math.sin(idx * 0.1),
steering_angle_deg=angle,
output=0.02 * math.sin(idx * 0.9),
))
summaries, stats = module.classify_torque_samples(samples)
assert stats["sampleCount"] == len(samples)
assert any(summary["bucket"] == "center_chatter" for summary in summaries)
def test_build_suggestions_prefers_rich_low_speed_turn_in_knob(tmp_path):
module, _ = _load_ftm_workspace_module(tmp_path)
summary = {
"bucket": "low_speed_unwillingness",
"dimensionId": "low_speed_unwillingness:left:low",
"direction": "left",
"speedBand": "low",
"severity": 0.9,
"evidence": {"speedBand": "low", "directionBias": "left", "eventCount": 3, "segments": [{"label": "route/2"}]},
"plotSvg": "",
}
capabilities = {"richProfileKey": "hyundai_ioniq_6", "frictionFamily": "hkg_canfd"}
current = {"SteerLatAccel": 1.8, "SteerFriction": 0.2}
suggestions = module.build_suggestions([summary], capabilities, current)
adjustment = suggestions[0]["primaryAdjustmentRaw"]
assert adjustment["type"] == "vehicle_knob"
assert adjustment["symbol"] == "hyundai_ioniq_6.low_speed_angle_assist_max_torque"
def test_build_suggestions_baseline_prefers_generic_lat_accel_for_understeer(tmp_path):
module, _ = _load_ftm_workspace_module(tmp_path)
summary = {
"bucket": "understeer",
"dimensionId": "understeer:left:mid",
"direction": "left",
"speedBand": "mid",
"severity": 1.0,
"evidence": {"speedBand": "mid", "directionBias": "left", "eventCount": 3, "segments": [{"label": "route/2"}]},
"plotSvg": "",
}
capabilities = {"richProfileKey": "hyundai_ioniq_6", "frictionFamily": "hkg_canfd"}
current = {"SteerLatAccel": 1.8, "SteerFriction": 0.2}
suggestions = module.build_suggestions([summary], capabilities, current, strategy="baseline")
adjustment = suggestions[0]["primaryAdjustmentRaw"]
assert adjustment["type"] == "generic_param"
assert adjustment["paramKey"] == "SteerLatAccel"
assert adjustment["suggested"] > adjustment["current"]
def test_build_suggestions_rebases_rich_knob_against_active_override(tmp_path):
module, _ = _load_ftm_workspace_module(tmp_path)
summary = {
"bucket": "low_speed_unwillingness",
"dimensionId": "low_speed_unwillingness:left:low",
"direction": "left",
"speedBand": "low",
"severity": 1.0,
"evidence": {"speedBand": "low", "directionBias": "left", "eventCount": 3, "segments": [{"label": "route/2"}]},
"plotSvg": "",
}
capabilities = {"richProfileKey": "hyundai_ioniq_6", "frictionFamily": "hkg_canfd"}
current = {
"SteerLatAccel": 1.8,
"SteerFriction": 0.2,
"FTMActiveOverrides": {
"schemaVersion": 1,
"baseFrictionThresholds": {},
"vehicleKnobs": {
"hyundai_ioniq_6.low_speed_angle_assist_max_torque": 0.62,
},
},
}
suggestions = module.build_suggestions([summary], capabilities, current)
adjustment = suggestions[0]["primaryAdjustmentRaw"]
assert adjustment["type"] == "vehicle_knob"
assert adjustment["symbol"] == "hyundai_ioniq_6.low_speed_angle_assist_max_torque"
assert adjustment["current"] == pytest.approx(0.62)
assert adjustment["suggested"] > adjustment["current"]
def test_build_suggestions_prefers_ioniq_6_curvy_trim_for_mid_speed_turn_in(tmp_path):
module, _ = _load_ftm_workspace_module(tmp_path)
summary = {
"bucket": "oversteer",
"dimensionId": "oversteer:left:fast",
"direction": "left",
"speedBand": "fast",
"severity": 1.0,
"evidence": {"speedBand": "fast", "directionBias": "left", "eventCount": 2, "segments": [{"label": "route/4"}]},
"plotSvg": "",
}
capabilities = {"richProfileKey": "hyundai_ioniq_6", "frictionFamily": "hkg_canfd"}
current = {"SteerLatAccel": 1.8, "SteerFriction": 0.2}
suggestions = module.build_suggestions([summary], capabilities, current)
adjustment = suggestions[0]["primaryAdjustmentRaw"]
assert adjustment["type"] == "vehicle_knob"
assert adjustment["symbol"] == "hyundai_ioniq_6.curvy_turn_in_trim_left"
assert adjustment["suggested"] > adjustment["current"]
def test_build_suggestions_rebases_friction_curve_against_active_override(tmp_path):
module, _ = _load_ftm_workspace_module(tmp_path)
summary = {
"bucket": "center_chatter",
"dimensionId": "center_chatter:center:highway",
"direction": "center",
"speedBand": "highway",
"severity": 1.0,
"evidence": {"speedBand": "highway", "directionBias": "center", "eventCount": 4, "segments": [{"label": "route/5"}]},
"plotSvg": "",
}
capabilities = {"richProfileKey": "torque_universal", "frictionFamily": "standard"}
current_curve = [0.34, 0.35, 0.36, 0.37, 0.38]
current = {
"SteerLatAccel": 1.8,
"SteerFriction": 0.2,
"FTMActiveOverrides": {
"schemaVersion": 1,
"baseFrictionThresholds": {
"standard": {
"speedKnots": [0.0, 5.0, 10.0, 15.0, 25.0],
"values": current_curve,
},
},
"vehicleKnobs": {},
},
}
suggestions = module.build_suggestions([summary], capabilities, current)
adjustment = suggestions[0]["primaryAdjustmentRaw"]
assert adjustment["type"] == "friction_curve"
assert adjustment["family"] == "standard"
assert adjustment["current"] == current_curve
assert adjustment["suggested"][2] > current_curve[2]
def test_select_primary_tuning_path_prefers_baseline_for_broad_mismatch(tmp_path):
module, _ = _load_ftm_workspace_module(tmp_path)
summaries = [
{"bucket": "understeer", "severity": 1.0},
{"bucket": "center_chatter", "severity": 0.9},
{"bucket": "unwind_too_slow", "severity": 0.85},
{"bucket": "saturation_limited", "severity": 0.8},
]
stats = {"meanErrorAbs": 0.16}
decision = module.select_primary_tuning_path(summaries, stats)
assert decision["primaryPathKey"] == "baseline_fix"
assert decision["alternatePathKey"] == "cleanup_pass"
def test_select_primary_tuning_path_prefers_cleanup_for_localized_issue(tmp_path):
module, _ = _load_ftm_workspace_module(tmp_path)
summaries = [
{"bucket": "notchy_mid_curve", "severity": 0.7},
{"bucket": "center_chatter", "severity": 0.55},
]
stats = {"meanErrorAbs": 0.07}
decision = module.select_primary_tuning_path(summaries, stats)
assert decision["primaryPathKey"] == "cleanup_pass"
assert decision["alternatePathKey"] == "baseline_fix"
def test_build_trial_profiles_suppresses_ignored_dimensions(tmp_path):
module, _ = _load_ftm_workspace_module(tmp_path)
suggestions = [
{
"dimensionId": "center_chatter:center:highway",
"primaryAdjustmentRaw": {
"type": "friction_curve",
"family": "standard",
"current": [0.30, 0.31, 0.32, 0.33, 0.34],
"suggested": [0.31, 0.32, 0.33, 0.34, 0.35],
"delta": [0.01, 0.01, 0.01, 0.01, 0.01],
},
},
{
"dimensionId": "understeer:left:mid",
"primaryAdjustmentRaw": {
"type": "generic_param",
"paramKey": "SteerLatAccel",
"current": 1.6,
"suggested": 1.7,
"delta": 0.1,
},
},
]
feedback = {"acceptedDimensions": ["understeer:left:mid"], "ignoredDimensions": ["center_chatter:center:highway"]}
profiles = module.build_trial_profiles("report-1", suggestions, feedback, {"richProfileKey": None})
assert profiles
assert profiles[0]["genericParams"]["ForceAutoTuneOff"] is True
assert profiles[0]["genericParams"]["SteerLatAccel"] > 1.6
assert profiles[0]["ftmOverrides"] == {}
def test_merge_primary_adjustments_averages_conflicting_deltas(tmp_path):
module, _ = _load_ftm_workspace_module(tmp_path)
suggestions = [
{
"severity": 1.0,
"primaryAdjustmentRaw": {
"type": "generic_param",
"paramKey": "SteerLatAccel",
"current": 1.6,
"suggested": 1.7,
"delta": 0.1,
},
},
{
"severity": 0.5,
"primaryAdjustmentRaw": {
"type": "generic_param",
"paramKey": "SteerLatAccel",
"current": 1.6,
"suggested": 1.55,
"delta": -0.05,
},
},
]
params_delta, overrides, _ = module._merge_primary_adjustments(suggestions, 1.0)
assert params_delta["SteerLatAccel"] == pytest.approx(1.65, abs=1e-4)
assert overrides == {}
def test_apply_and_revert_trial_profile_round_trip(tmp_path):
module, fake_params_cls = _load_ftm_workspace_module(tmp_path)
workspace = module.ensure_ftm_workspace()
report_id = "report-apply"
profile_id = f"{report_id}:recommended"
profile = {
"id": profile_id,
"reportId": report_id,
"label": "Recommended",
"description": "Recommended trial",
"genericParams": {
"AdvancedLateralTune": True,
"SteerLatAccel": 1.9,
"ForceAutoTuneOff": True,
"ForceAutoTune": False,
},
"ftmOverrides": {
"schemaVersion": 1,
"baseFrictionThresholds": {},
"vehicleKnobs": {
"hyundai_ioniq_6.turn_in_boost_left": 0.08,
},
},
}
(workspace["profiles"] / f"{report_id}.json").write_text(json.dumps([profile]), encoding="utf-8")
fake_params_cls._store = {
"AdvancedLateralTune": False,
"ForceAutoTune": True,
"ForceAutoTuneOff": False,
"SteerLatAccel": 1.5,
"FTMActiveProfileId": "",
"FTMActiveOverrides": {},
"FTMTrialApplied": False,
}
result = module.apply_trial_profile(report_id, profile_id)
assert result["profile"]["id"] == profile_id
assert fake_params_cls._store["SteerLatAccel"] == pytest.approx(1.9)
assert fake_params_cls._store["FTMActiveProfileId"] == profile_id
assert fake_params_cls._store["FTMTrialApplied"] is True
assert fake_params_cls._store["FTMActiveOverrides"]["vehicleKnobs"]["hyundai_ioniq_6.turn_in_boost_left"] == pytest.approx(0.08)
revert_result = module.revert_trial_profile()
assert revert_result["snapshot"]["profileId"] == profile_id
assert fake_params_cls._store["AdvancedLateralTune"] is False
assert fake_params_cls._store["SteerLatAccel"] == pytest.approx(1.5)
assert fake_params_cls._store["FTMTrialApplied"] is False
def test_delete_report_removes_saved_artifacts(tmp_path):
module, _ = _load_ftm_workspace_module(tmp_path)
workspace = module.ensure_ftm_workspace()
report_id = "report-delete"
for path in (
workspace["reports"] / f"{report_id}.json",
workspace["reports"] / f"{report_id}.html",
workspace["profiles"] / f"{report_id}.json",
workspace["feedback"] / f"{report_id}.json",
workspace["snapshots"] / f"{report_id}-recommended.json",
):
path.write_text("{}", encoding="utf-8")
result = module.delete_report(report_id)
assert "Deleted tuning report" in result["message"]
assert not (workspace["reports"] / f"{report_id}.json").exists()
assert not (workspace["reports"] / f"{report_id}.html").exists()
assert not (workspace["profiles"] / f"{report_id}.json").exists()
assert not (workspace["feedback"] / f"{report_id}.json").exists()
assert not (workspace["snapshots"] / f"{report_id}-recommended.json").exists()
+109 -1
View File
@@ -76,7 +76,7 @@ from openpilot.starpilot.common.testing_grounds import (
)
from openpilot.starpilot.navigation.destination_store import normalize_destination_payload, update_recent_destinations
from openpilot.starpilot.system.the_galaxy.factory_reset import remove_path as _run_factory_reset_delete
from openpilot.starpilot.system.the_galaxy import utilities
from openpilot.starpilot.system.the_galaxy import ftm_workspace, utilities
from openpilot.starpilot.system.the_galaxy.update_recovery import inspect_interrupted_update, public_recovery_status, recover_interrupted_update
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL")
@@ -5648,6 +5648,114 @@ def setup(app):
**_serialize_lateral_maneuver_status(status),
}), 200
@app.route("/api/ftm/status", methods=["GET"])
def get_ftm_status():
workspace = ftm_workspace.list_workspace()
return jsonify({
"isOnroad": params.get_bool("IsOnroad"),
"status": ftm_workspace.read_ftm_status(),
"activeTrial": workspace.get("activeTrial"),
"reports": workspace.get("reports", [])[:10],
}), 200
@app.route("/api/ftm/analyze", methods=["POST"])
def start_ftm_analysis():
if params.get_bool("IsOnroad"):
return jsonify({"error": "FTM analysis can only run offroad."}), 409
data = request.get_json(silent=True) or {}
route_names = [str(route).strip() for route in data.get("routes", []) if str(route).strip()]
if not route_names:
return jsonify({"error": "No routes were selected."}), 400
started = ftm_workspace.start_ftm_background_analysis(route_names, FOOTAGE_PATHS)
if not started:
return jsonify({"error": "Failed to start FTM analysis."}), 500
return jsonify({
"message": f"Started FTM analysis for {len(route_names[:ftm_workspace.FTM_ANALYZER_ROUTE_LIMIT])} route(s).",
"status": ftm_workspace.read_ftm_status(),
}), 200
@app.route("/api/ftm/analyze/stop", methods=["POST"])
def stop_ftm_analysis():
stopped = ftm_workspace.stop_ftm_background_analysis()
return jsonify({
"message": "Stopped FTM analysis." if stopped else "No active FTM analysis was running.",
"stopped": bool(stopped),
"status": ftm_workspace.read_ftm_status(),
}), 200
@app.route("/api/ftm/report/<report_id>", methods=["GET"])
def get_ftm_report(report_id):
try:
return jsonify(ftm_workspace.load_report(report_id)), 200
except FileNotFoundError:
return jsonify({"error": "FTM report not found."}), 404
@app.route("/api/ftm/report/<report_id>", methods=["DELETE"])
def delete_ftm_report(report_id):
try:
return jsonify(ftm_workspace.delete_report(report_id)), 200
except FileNotFoundError:
return jsonify({"error": "FTM report not found."}), 404
except RuntimeError as error:
return jsonify({"error": str(error)}), 409
@app.route("/api/ftm/workspace", methods=["GET"])
def get_ftm_workspace():
return jsonify(ftm_workspace.list_workspace()), 200
@app.route("/api/ftm/workspace/clear", methods=["POST"])
def clear_ftm_workspace():
try:
return jsonify(ftm_workspace.clear_workspace()), 200
except RuntimeError as error:
return jsonify({"error": str(error)}), 409
@app.route("/api/ftm/trials/apply", methods=["POST"])
def apply_ftm_trial():
data = request.get_json(silent=True) or {}
report_id = str(data.get("reportId") or "").strip()
profile_id = str(data.get("profileId") or "").strip()
if not report_id or not profile_id:
return jsonify({"error": "Both reportId and profileId are required."}), 400
try:
result = ftm_workspace.apply_trial_profile(report_id, profile_id)
except FileNotFoundError:
return jsonify({"error": "FTM profile not found."}), 404
return jsonify(result), 200
@app.route("/api/ftm/trials/revert", methods=["POST"])
def revert_ftm_trial():
try:
result = ftm_workspace.revert_trial_profile()
except FileNotFoundError:
return jsonify({"error": "No active FTM trial snapshot was found."}), 404
return jsonify(result), 200
@app.route("/api/ftm/feedback", methods=["POST"])
def save_ftm_feedback():
data = request.get_json(silent=True) or {}
report_id = str(data.get("reportId") or "").strip()
if not report_id:
return jsonify({"error": "reportId is required."}), 400
feedback = {
"acceptedDimensions": data.get("acceptedDimensions", []),
"ignoredDimensions": data.get("ignoredDimensions", []),
"notes": data.get("notes", ""),
}
try:
result = ftm_workspace.record_feedback(report_id, feedback)
except FileNotFoundError:
return jsonify({"error": "FTM report not found."}), 404
return jsonify(result), 200
@app.route("/api/update/fast/status", methods=["GET"])
def get_fast_update_status():
state_data = _get_fast_update_state()
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env python3
import argparse
import json
from openpilot.system.hardware.hw import Paths
from openpilot.starpilot.system.the_galaxy import ftm_workspace
def main() -> None:
parser = argparse.ArgumentParser(description="Run the Firestar Tuning Method analyzer against local routes.")
parser.add_argument("routes", nargs="+", help="One or more local route ids.")
parser.add_argument("--report-id", dest="report_id", default=None, help="Optional report id override.")
args = parser.parse_args()
footage_paths = [
str(Paths.log_root(HD=True, raw=True)),
str(Paths.log_root(konik=True, raw=True)),
str(Paths.log_root(raw=True)),
]
report = ftm_workspace.analyze_routes(args.routes, footage_paths, report_id=args.report_id)
print(json.dumps({
"reportId": report["reportId"],
"htmlPath": report["htmlPath"],
"jsonPath": report["jsonPath"],
}, indent=2))
if __name__ == "__main__":
main()