Files
StarPilot/openpilot/starpilot/flm/tests/test_torque_surface.py
T
firestar5683 5deb059c54 Learning Workers
Process drive evidence and fit bounded steering profiles off the controls loop.
2026-10-14 20:02:39 -05:00

282 lines
18 KiB
Python

import math
from operator import setitem
import unittest
from unittest import mock
from openpilot.starpilot.flm.torque_surface import Ioniq6Surface, SurfaceInput, _BOUNDS, directional_taper_target, evaluate
from openpilot.starpilot.lateral import ioniq6_policy as baseline
class TestIoniq6Surface(unittest.TestCase):
def test_profile_validation_and_immutability(self):
surface = Ioniq6Surface.validated("standard", {"ff_gain_left": 0.217})
self.assertEqual(surface.value("ff_gain_left"), 0.217)
with self.assertRaises(TypeError):
mock.Mock(wraps=setitem)(surface.knobs, "ff_gain_left", 0.3)
source = {"ff_gain_left": 0.24}
direct = Ioniq6Surface("standard", source)
source["ff_gain_left"] = 0.50
self.assertEqual(direct.value("ff_gain_left"), 0.24)
for variant, knobs in (("2023", {}), ("standard", {"unknown": 1.0}),
("standard", {"ff_gain_left": True}),
("standard", {"ff_gain_left": math.nan}),
("standard", {"ff_gain_left": 10 ** 1000}),
("standard", {"turn_in_boost_left": 0.2}),
("standard", {"ff_gain_left": 0.601}),
("standard", {"curvy_speed_min": 25.0})):
with self.subTest(variant=variant, knobs=knobs), self.assertRaises(ValueError):
Ioniq6Surface.validated(variant, knobs)
def test_neutral_matches_existing_ioniq_stages(self):
for variant in ("standard", "firmware_2025"):
surface = Ioniq6Surface.validated(variant, {})
for speed, setpoint, jerk, desired, actual in ((3.0, 0.7, 1.1, 11.0, 1.0),
(14.0, -0.9, -1.5, -12.0, -3.0),
(27.0, -0.8, 1.7, -3.0, -12.0),
(26.0, 0.0, 0.0, 0.0, 0.0)):
with self.subTest(variant=variant, speed=speed, setpoint=setpoint, jerk=jerk):
taper = baseline.get_ioniq_6_directional_taper_scale(setpoint, jerk, speed)
i = SurfaceInput(speed, setpoint, jerk, desired, actual, 0.2, False, taper)
shape = evaluate(surface, i)
center = baseline.get_ioniq_6_center_taper_scale(setpoint, speed)
self.assertAlmostEqual(shape.directional_taper_target, taper, places=12)
self.assertAlmostEqual(shape.center_taper, center, places=12)
self.assertAlmostEqual(shape.feedforward_scale,
baseline.get_ioniq_6_ff_scale(setpoint, jerk, speed, directional_taper_scale=taper), places=12)
self.assertAlmostEqual(shape.friction_threshold,
baseline.get_ioniq_6_friction_threshold(speed, setpoint, jerk) / max(center, 1e-3), places=12)
self.assertAlmostEqual(shape.output_after_angle_assist,
baseline.get_ioniq_6_low_speed_angle_assist_torque(desired, actual, 0.2, speed), places=12)
self.assertEqual(shape.center_deadband_deg, 0.0)
def test_nondefault_direction_center_unwind_and_pose(self):
surface = Ioniq6Surface.validated("standard", {
"ff_gain_left": 0.30, "ff_gain_right": 0.12,
"turn_in_boost_left": 1.2, "unwind_taper_right": 2.5,
"center_taper_max": 0.16, "highway_center_taper_max": 0.15,
"unwind_threshold_increase_right": 3.0,
"curvy_turn_in_trim_left": 0.15, "curvy_unwind_extra_reduction_right": 0.25,
"curvy_unwind_floor_relief_right": 0.40,
"center_deadband_low_deg": 0.1, "center_deadband_mid_deg": 0.15,
"low_speed_angle_assist_max_torque": 0.7,
})
neutral = Ioniq6Surface.validated("standard", {})
turn = SurfaceInput(3.0, 0.8, 1.2, 12.0, 1.0, 0.0, False, 0.9)
unwind = SurfaceInput(14.0, -0.8, 1.2, -2.0, -10.0, 0.3, False, 0.8)
shaped_turn = evaluate(surface, turn)
shaped_unwind = evaluate(surface, unwind)
self.assertNotEqual(shaped_turn.feedforward_scale, evaluate(neutral, turn).feedforward_scale)
self.assertNotEqual(shaped_turn.center_taper, evaluate(neutral, turn).center_taper)
self.assertNotEqual(shaped_turn.output_after_angle_assist, evaluate(neutral, turn).output_after_angle_assist)
self.assertNotEqual(shaped_unwind.directional_taper_target, evaluate(neutral, unwind).directional_taper_target)
self.assertNotEqual(shaped_unwind.friction_threshold, evaluate(neutral, unwind).friction_threshold)
self.assertGreater(shaped_turn.center_deadband_deg, 0.0)
pressed = SurfaceInput(3.0, 0.8, 1.2, 12.0, 1.0, 0.2, True, 0.9)
self.assertEqual(evaluate(surface, pressed).output_after_angle_assist, 0.2)
def test_filtered_taper_is_separate_controller_owned_state(self):
surface = Ioniq6Surface.validated("standard", {})
i = SurfaceInput(14.0, -0.8, 1.2, -6.0, -2.0, 0.2, False, filtered_directional_taper=0.91)
shape = evaluate(surface, i)
self.assertAlmostEqual(shape.feedforward_scale,
baseline.get_ioniq_6_ff_scale(i.setpoint, i.jerk, i.speed, directional_taper_scale=0.91))
self.assertNotEqual(shape.directional_taper_target, 0.91)
for bad in (math.nan, math.inf, -2.1):
with self.assertRaises(ValueError):
SurfaceInput(i.speed, i.setpoint, i.jerk, i.desired_angle_deg, i.actual_angle_deg, i.output_torque, False, bad)
for bad in (True, 10 ** 1000):
with self.assertRaises(ValueError):
SurfaceInput(bad, i.setpoint, i.jerk, i.desired_angle_deg, i.actual_angle_deg, i.output_torque, False, 0.9)
unchecked_input = mock.Mock(wraps=SurfaceInput)
with self.assertRaises(ValueError):
unchecked_input(i.speed, i.setpoint, i.jerk, i.desired_angle_deg, i.actual_angle_deg, i.output_torque, "false", 0.9)
with self.assertRaises(ValueError):
unchecked_input(i.speed, i.setpoint, i.jerk, i.desired_angle_deg, i.actual_angle_deg, i.output_torque, False, None)
def test_frozen_ioniq_stage_vectors(self):
# Compact values from frozen 678af783 latcontrol_vehicle_tunes.py SHA256
# 2a7793c6ee4b53b45070d3267a01e8c7105dfd8df9d38cdfc96cfe4b672a125b.
# Generated with private AST oracle, not by importing the frozen checkout.
surface = Ioniq6Surface.validated("standard", {
"ff_gain_left": 0.11, "ff_gain_right": 0.09,
"turn_in_boost_left": 2.2, "turn_in_boost_right": 2.4,
"unwind_taper_left": 5.0, "unwind_taper_right": 10.0,
"center_taper_max": 0.13, "highway_center_taper_max": 0.08,
"turn_in_threshold_reduction_left": 1.1, "turn_in_threshold_reduction_right": 1.6,
"unwind_threshold_increase_left": 5.0, "unwind_threshold_increase_right": 11.0,
"crawl_turn_in_ff_boost_left": 0.33, "crawl_turn_in_ff_boost_right": 0.38,
"low_speed_angle_assist_max_torque": 0.60,
})
# speed, accel, jerk, desired angle, actual angle; then frozen direction,
# center, combined pre-2023 FF, threshold and pose output from 0.2 torque.
vectors = (
(3.0, 0.18, 0.42, 11.0, 2.0, 0.988394829429103, 0.9978605797219613,
1.4749555335910585, 0.32048565350593106, -0.13901469137077216),
(14.0, -0.32, 0.55, -9.0, -15.0, 0.49398782437927313, 0.9746594981685954,
0.4814699250108986, 0.40460794823460716, 0.2),
(27.0, 0.19, 0.50, 9.0, 4.0, 0.9444309506239349, 0.8839299279089594,
0.9260528746519242, 0.4179582730864161, 0.2),
)
for speed, accel, jerk, desired, actual, direction, center, ff, threshold, pose in vectors:
with self.subTest(speed=speed, accel=accel, jerk=jerk):
shape = evaluate(surface, SurfaceInput(speed, accel, jerk, desired, actual, 0.2, False, direction))
self.assertAlmostEqual(shape.directional_taper_target, direction, places=11)
self.assertAlmostEqual(shape.center_taper, center, places=11)
self.assertAlmostEqual(shape.feedforward_scale * shape.center_taper, ff, places=11)
self.assertAlmostEqual(shape.friction_threshold, threshold, places=11)
self.assertAlmostEqual(shape.output_after_angle_assist, pose, places=11)
def test_historical_combined_extreme_is_preserved_but_unqualified(self):
# Every individual value is inside frozen metadata, yet the combination
# reverses the directional multiplier. Pure math must expose it, not clip it.
surface = Ioniq6Surface.validated("standard", {name: high for name, (_, high) in _BOUNDS.items()})
target = directional_taper_target(surface, 18.0, 0.9, -3.0)
self.assertAlmostEqual(target, -0.13824656992744921, places=11)
shape = evaluate(surface, SurfaceInput(18.0, 0.9, -3.0, 8.0, 4.0, 0.2, False, target))
self.assertEqual(shape.directional_taper_target, target)
self.assertLess(shape.feedforward_scale, 0.0)
if __name__ == "__main__":
unittest.main()
class TestGmSurface(unittest.TestCase):
def test_selected_neutral_and_rich_default_laws(self):
from openpilot.starpilot.flm.torque_surface import GmSurface
from openpilot.starpilot.lateral import bolt_shaping as gm
neutral = GmSurface('torque_universal', {})
rich = GmSurface('gm_bolt_2022_2023', {})
for speed, setpoint, jerk in ((3., .8, 1.2), (14., -.8, 1.2), (27., .8, -1.2), (26., 0., 0.)):
sample = SurfaceInput(speed, setpoint, jerk, 0., 0., 0., False, 1.)
ff, threshold = neutral.stages(sample, setpoint, gm.get_gm_base_friction_threshold(speed))
self.assertEqual(ff, setpoint)
self.assertEqual(threshold, gm.get_gm_base_friction_threshold(speed))
self.assertEqual(neutral.deadband(speed), 0.)
rich_ff, rich_threshold = rich.stages(sample, setpoint, threshold, rich=True)
self.assertAlmostEqual(rich_ff, setpoint * gm.get_bolt_2022_2023_ff_scale(setpoint, jerk, speed), places=12)
self.assertAlmostEqual(rich_threshold, gm.get_bolt_2022_2023_friction_threshold(speed, setpoint, jerk), places=12)
self.assertEqual(rich.angle_assist(sample), 0.)
def test_explicit_surface_bounds_composition_and_immutable_document(self):
from openpilot.starpilot.flm.torque_surface import GmSurface, GmFlmBinding
knobs = {'ff_gain_left': .3, 'ff_gain_right': -.1, 'center_deadband_low_deg': .1}
surface = GmSurface('torque_universal', knobs, [.16, .18, .20, .23, .27])
knobs['ff_gain_left'] = .6
self.assertEqual(surface.knobs['ff_gain_left'], .3)
with self.assertRaises(TypeError):
mock.Mock(wraps=setitem)(surface.knobs, 'ff_gain_left', .5)
self.assertEqual(GmSurface.from_document(surface.document()).document(), surface.document())
self.assertGreater(surface.deadband(5.), 0.)
self.assertAlmostEqual(surface.base_threshold(10.), .20)
for speed in (3., 14., 27.):
for sign in (-1., 1.):
sample = SurfaceInput(speed, sign*.8, sign*1.2, 0., 0., 0., False, 1.)
ff, threshold = surface.stages(sample, sign*.8, .2)
self.assertGreater(ff * sign, 0.)
if speed == 3.:
self.assertGreater(ff, .8) if sign > 0 else self.assertLess(abs(ff), .8)
self.assertTrue(math.isfinite(threshold) and threshold > 0.)
for knobs, curve in (({'ff_gain_left': -.4, 'turn_in_boost_left': 2.8}, None),
({'ff_gain_left': math.nan}, None), ({'ff_gain_left': 10**1000}, None),
({'ff_gain_left': True}, None), ({'curvy_speed_min': 25.}, None),
({}, [.2]*4), ({}, [.2, .2, math.inf, .2, .2]), ({}, [10**1000]*5)):
with self.assertRaises(ValueError):
GmSurface('torque_universal', knobs, curve)
binding = GmFlmBinding('starpilot', 'ordinary_ascm', (2., 0., .1), (('one', 'Surface', surface),), 'one', True)
self.assertEqual(GmFlmBinding.from_document(binding.document()), binding)
for value in ({**binding.document(), 'active': 'missing'}, {**binding.document(), 'baselineActive': 'one', 'baselineApplied': True}):
with self.assertRaises(ValueError):
GmFlmBinding.from_document(value)
def test_actual_selected_controller_neutral_surface_has_no_double_application(self):
from types import SimpleNamespace
from opendbc.car.gm.interface import CarInterface
from opendbc.car.gm.tests.test_ascm_intercept import params as ordinary_params
from opendbc.car.gm.tests.test_bolt_cc import params as bolt_params
from opendbc.car.gm.values import CAR
from opendbc.car.vehicle_model import VehicleModel
from openpilot.common.params import Params
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque
from openpilot.starpilot.flm.live import gm_capability
from openpilot.starpilot.flm import live as flm
from openpilot.starpilot.flm.torque_surface import GmFlmBinding, GmSurface
from openpilot.starpilot.lateral.controller_selection import ControllerMode, replace_mode
from openpilot.starpilot.lateral.torque_settings import DOCUMENT_KEY, PlatformProfile, FieldChoice, serialize_document
import json
cases = (ordinary_params(CAR.CHEVROLET_MALIBU_ASCM), bolt_params(CAR.CHEVROLET_BOLT_CC_2022_2023, alpha=True))
for cp_builder in cases:
cp = cp_builder.as_reader()
for mode in ControllerMode:
with OpenpilotPrefix():
saved = Params()
saved.put('LateralControllerSelection', json.loads(replace_mode(None, cp, mode)), block=True)
baseline = LatControlTorque(cp, CarInterface(cp), .01, controller_mode=mode)
overlay = LatControlTorque(cp, CarInterface(cp), .01, controller_mode=mode)
cap = gm_capability(cp, mode)
surface = GmSurface(cap['profile'], {})
cs = SimpleNamespace(vEgo=20., steeringAngleDeg=0., steeringPressed=False, steeringRateDeg=0., standstill=False)
params = SimpleNamespace(angleOffsetDeg=0., roll=0.)
vm = VehicleModel(cp)
with mock.patch('openpilot.starpilot.flm.live.time.monotonic_ns', return_value=1_000_000_000):
baseline.flm_source.sample(active=True, speed=cs.vEgo)
binding = GmFlmBinding(str(mode), cap['policy'], cap['basis'], (('neutral', 'Neutral', surface),), 'neutral', True)
saved.put(DOCUMENT_KEY, json.loads(serialize_document({str(cp.carFingerprint): PlatformProfile(cap['basis'], FieldChoice(),
FieldChoice(), flm=binding)})), block=True)
with mock.patch('openpilot.starpilot.flm.live.stages', wraps=flm.stages) as stages:
for tick in range(100):
curvature = .001 if tick < 50 else -.001
before = baseline.update(True, cs, vm, params, False, curvature, False, .2)
after = overlay.update(True, cs, vm, params, False, curvature, False, .2)
self.assertEqual(before[:2], after[:2], (cp.carFingerprint, mode, tick))
self.assertEqual(before[2].to_dict(), after[2].to_dict(), (cp.carFingerprint, mode, tick))
self.assertEqual(stages.call_count, 200, 'exactly one generic stage call per actual controller update')
self.assertIsNotNone(overlay.flm_surface)
self.assertIsNone(baseline.flm_surface)
self.assertEqual(overlay.update(False, cs, vm, params, False, .001, False, .2)[0], 0.)
self.assertIsNone(overlay.flm_surface)
def test_standard_bolt_explicit_universal_angle_assist_reaches_actual_generic_controller(self):
import json
from types import SimpleNamespace
from opendbc.car.gm.interface import CarInterface
from opendbc.car.gm.tests.test_bolt_cc import params as bolt_params
from opendbc.car.gm.values import CAR
from opendbc.car.vehicle_model import VehicleModel
from openpilot.common.params import Params
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque
from openpilot.starpilot.flm import live as flm
from openpilot.starpilot.flm.live import gm_capability
from openpilot.starpilot.flm.torque_surface import GmFlmBinding, GmSurface
from openpilot.starpilot.lateral.controller_selection import ControllerMode, replace_mode
from openpilot.starpilot.lateral.torque_settings import DOCUMENT_KEY, PlatformProfile, FieldChoice, serialize_document
cp = bolt_params(CAR.CHEVROLET_BOLT_CC_2022_2023, alpha=True).as_reader()
mode = ControllerMode.STANDARD
with OpenpilotPrefix():
params = Params()
params.put('LateralControllerSelection', json.loads(replace_mode(None, cp, mode)), block=True)
controller = LatControlTorque(cp, CarInterface(cp), .01, controller_mode=mode)
self.assertIsNone(controller.starpilot_extension)
cap = gm_capability(cp, mode)
surface = GmSurface(cap['profile'], {'low_speed_angle_assist_max_torque': .1})
binding = GmFlmBinding(str(mode), cap['policy'], cap['basis'], (('assist', 'Assist', surface),), 'assist', True)
params.put(DOCUMENT_KEY, json.loads(serialize_document({cap['fingerprint']: PlatformProfile(cap['basis'],
FieldChoice(), FieldChoice(), flm=binding)})), block=True)
cs = SimpleNamespace(vEgo=3., steeringAngleDeg=0., steeringPressed=False, steeringRateDeg=0., standstill=False)
learned = SimpleNamespace(angleOffsetDeg=0., roll=0.)
observed = []
original_angle = flm.angle_assist
def actual_angle(*args):
output = original_angle(*args)
observed.append((args[-1], output))
return output
with mock.patch('openpilot.starpilot.flm.live.time.monotonic_ns', return_value=1_000_000_000), \
mock.patch('openpilot.starpilot.flm.live.angle_assist', side_effect=actual_angle):
result = controller.update(True, cs, VehicleModel(cp), learned, False, .003, False, .2)
self.assertTrue(result[2].active)
self.assertEqual(len(observed), 1)
self.assertGreater(abs(observed[0][1] - observed[0][0]), 1e-6)
self.assertTrue(math.isfinite(result[0]) and abs(result[0]) <= 1.)