"""Published automatic fits must bind the real factory and respect saved intent.""" import json from pathlib import Path from types import SimpleNamespace import struct import unittest from opendbc.car.car_helpers import interfaces from opendbc.car.structs import car from openpilot.starpilot.lateral.learning_preferences import KEY as LEARNING_KEY, LEGACY_KEY as LEARNING_LEGACY_KEY, replace_preference from openpilot.system.manager.process_config import managed_processes from opendbc.car.gm.values import CAR as GM from opendbc.car.hyundai.values import CAR as HKG from opendbc.car.toyota.values import CAR as TOYOTA from openpilot.cereal import messaging 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.learning_source import ( AdmittedPolicy, AutomaticLearningConsumer, MAX_AGE_NS, RESET_KEY, SERVICE, conversion_identity, identity_for, learning_payload, ) from openpilot.starpilot.lateral.controller_selection import ControllerMode, DOCUMENT_KEY, policy_for, replace_mode from openpilot.starpilot.lateral.torque_runtime import TorqueHost from openpilot.starpilot.lateral.torque_settings import DOCUMENT_KEY as TORQUE_KEY, replace_field, serialize_document VEHICLES = (GM.CHEVROLET_BOLT_CC_2018_2021, HKG.HYUNDAI_IONIQ_6, TOYOTA.TOYOTA_COROLLA_TSS2) NOW = 20_000_000_000 def float32(value): """The controller's CarParams torque fields are Float32, not Python doubles.""" return struct.unpack(' 50: self.assertIsNone(automatic) self.assertGreater(controller.torque_params.latAccelFactor, prior) # Missing native updates retain the old owner's scalar base during fallback. sm.alive['lateralTorqueParameters'] = False prior = host.applied.lat_accel_factor host.apply(controller, host.sample(sm, now_ns=NOW + 550_000_000, lat_active=False)) self.assertLessEqual(abs(host.applied.lat_accel_factor - prior), step + 1e-12) self.assertEqual(controller.torque_params.latAccelFactor, float32(host.applied.lat_accel_factor)) self.assertEqual(controller.torque_params.latAccelOffset, offset) self.assertEqual(controller.torque_params.friction, friction) # A fresh native update may change its own nonfactor parameters normally. sm.alive['lateralTorqueParameters'] = True sm.logMonoTime['lateralTorqueParameters'] = NOW + 560_000_000 sm.recv_time['lateralTorqueParameters'] = (NOW + 560_000_000) / 1e9 sm.states['lateralTorqueParameters'].latAccelOffsetFiltered = float32(-.02) sm.states['lateralTorqueParameters'].frictionCoefficientFiltered = float32(basis.friction * 1.2) host.apply(controller, host.sample(sm, now_ns=NOW + 560_000_000, lat_active=False)) self.assertEqual(controller.torque_params.latAccelOffset, float32(-.02)) self.assertEqual(controller.torque_params.friction, float32(basis.friction * 1.2)) def test_wrong_identity_expiry_sequence_and_unadmitted_policy_do_not_apply(self): with OpenpilotPrefix(): cp, _, params, source = context(HKG.HYUNDAI_IONIQ_6) sm = input_state(cp, params) self.assertIsNotNone(source.sample(sm, now_ns=NOW, lat_active=True)) wrong = learning_payload(sm[SERVICE]).identity.to_dict() wrong['vehicle'] = 'different-vehicle' for sequence, overrides in enumerate(({'identity': wrong}, {'version': 2}, {'latAccelFactor': float('nan')}, {'latAccelFactor': 100.}, {'acceptancePolicyId': 'unqualified'}, {'trainWindows': 7}, {'heldoutWindows': 7}, {'trainCandidateLoss': .1}, {'heldoutCandidateLoss': .1}, {'sequence': 0}, {'validUntilMonoTime': NOW - 1}), start=2): with self.subTest(overrides=overrides): bad = input_state(cp, params, **({'sequence': sequence} | overrides)) self.assertIsNone(source.sample(bad, now_ns=NOW, lat_active=True)) self.assertIsNone(source.sample(sm, now_ns=NOW + MAX_AGE_NS + 1, lat_active=True)) def test_saved_off_malformed_manual_and_reset_invalidate_existing_result(self): for changed_key, raw in (('ForceAutoTuneOff', b'1'), (TORQUE_KEY, b'malformed'), (RESET_KEY, b'1234')): with self.subTest(key=changed_key), OpenpilotPrefix(): cp, _, params, source = context(HKG.HYUNDAI_IONIQ_6) sm = input_state(cp, params) self.assertIsNotNone(source.sample(sm, now_ns=NOW, lat_active=True)) Path(params.get_param_path(changed_key)).write_bytes(raw) self.assertIsNone(source.sample(sm, now_ns=NOW + 1_000_000_000, lat_active=True)) self.assertNotEqual(source.identity, learning_payload(sm[SERVICE]).identity.to_dict()) def test_saved_manual_factor_overrides_automatic_overlay_after_adoption(self): with OpenpilotPrefix(): cp, ci, params, source = context(HKG.HYUNDAI_IONIQ_6) basis = cp.lateralTuning.torque sm = input_state(cp, params) controller = LatControlTorque(cp, ci, .01, controller_mode=ControllerMode.STANDARD) controller.update_torque_parameters(basis.latAccelFactor * 1.1, .03, basis.friction * 1.1) host = TorqueHost(params, cp, allow_learning=False, automatic_interface=ci, automatic_controller=ControllerMode.STANDARD) host.adopt_linear_controller(controller) automatic = source.sample(sm, now_ns=NOW, lat_active=True) assert automatic is not None host.apply(controller, host.sample(sm, now_ns=NOW, lat_active=True, automatic=automatic)) selected = replace_field({}, str(cp.carFingerprint), (basis.latAccelFactor, basis.latAccelOffset, basis.friction), 'factor', 'custom', basis.latAccelFactor * 1.2) params.put(TORQUE_KEY, json.loads(serialize_document(selected)), block=True) # Refresh discovers the saved manual identity; the old automatic wire cannot match it. self.assertIsNone(source.sample(sm, now_ns=NOW + 1_000_000_000, lat_active=True)) host.sample(sm, now_ns=NOW + 1_000_000_000, lat_active=True, automatic=automatic) self.assertAlmostEqual(host.selected.lat_accel_factor, basis.latAccelFactor * 1.2) self.assertEqual(host.selected.lat_accel_offset, basis.latAccelOffset) self.assertEqual(host.selected.source.value, 'user') host.sample(sm, now_ns=NOW + 1_010_000_000, lat_active=True, automatic=automatic) self.assertAlmostEqual(host.selected.lat_accel_factor, basis.latAccelFactor * 1.2) def test_malformed_source_at_host_refresh_cannot_bypass_with_previously_accepted_auto(self): with OpenpilotPrefix(): cp, ci, params, source = context(GM.CHEVROLET_BOLT_CC_2018_2021) sm = input_state(cp, params) automatic = source.sample(sm, now_ns=NOW, lat_active=True) assert automatic is not None for adopted in (False, True): with self.subTest(adopted=adopted): controller = LatControlTorque(cp, ci, .01, controller_mode=ControllerMode.STANDARD) host = TorqueHost(params, cp, allow_learning=False, automatic_interface=ci, automatic_controller=ControllerMode.STANDARD) if adopted: host.adopt_linear_controller(controller) host.sample(sm, now_ns=NOW, lat_active=True, automatic=automatic) self.assertTrue(host.settings.valid) # Genuine absence takes the strict default parser. Path(params.get_param_path(TORQUE_KEY)).write_bytes(b'malformed') # A due host refresh may precede the consumer's independently phased refresh. host.last_refresh_ns = NOW - 1_000_000_000 selected = host.sample(sm, now_ns=NOW + 10_000_000, lat_active=True, automatic=automatic) self.assertFalse(host.settings.valid) self.assertEqual(host.selected.lat_accel_factor, cp.lateralTuning.torque.latAccelFactor) self.assertNotEqual(selected.lat_accel_factor, automatic.lat_accel_factor) params.remove(TORQUE_KEY) def test_fixed_custom_and_nonlinear_conversion_are_not_inferred_from_preference(self): with OpenpilotPrefix(): cp, ci, params, _ = context(GM.CHEVROLET_BOLT_CC_2018_2021) self.assertIsNone(conversion_identity(cp, ci, ControllerMode.STARPILOT)) previous_document = Path(params.get_param_path(DOCUMENT_KEY)).read_bytes() cp, ci, params, _ = context(GM.CHEVROLET_SILVERADO) self.assertEqual(Path(params.get_param_path(DOCUMENT_KEY)).read_bytes(), previous_document) self.assertNotIn(str(cp.carFingerprint), json.loads(previous_document)['vehicles']) self.assertIsNone(conversion_identity(cp, ci, ControllerMode.STANDARD)) cp, ci, _, _ = context(HKG.HYUNDAI_IONIQ_6) self.assertIsNone(conversion_identity(cp, ci, ControllerMode.STARPILOT)) class TestDisabledLearnerProcess(unittest.TestCase): def setUp(self): self.prefix = OpenpilotPrefix() self.prefix.__enter__() self.addCleanup(self.prefix.__exit__, None, None, None) self.params = Params() self.cp = car.CarParams.new_message(brand='hyundai', carFingerprint='HYUNDAI_IONIQ_6') self.process = managed_processes['steerlearnerd'] def test_default_still_runs_onroad(self): assert self.process.should_run(True, self.params, self.cp) assert not self.process.should_run(False, self.params, self.cp) assert self.process.restart_on_exit def test_explicit_off_does_not_start_learner(self): self.params.put(LEARNING_KEY, replace_preference(None, self.cp, False), block=True) assert not self.process.should_run(True, self.params, self.cp) def test_toggle_can_stop_and_restart_learner(self): for enabled in (False, True, False, True): self.params.put(LEARNING_KEY, replace_preference(self.params.get(LEARNING_KEY), self.cp, enabled), block=True) assert self.process.should_run(True, self.params, self.cp) is enabled def test_legacy_off_does_not_start_learner(self): self.params.put_bool(LEARNING_LEGACY_KEY, True, block=True) assert not self.process.should_run(True, self.params, self.cp) def test_scoped_on_retains_precedence_over_legacy_off(self): self.params.put_bool(LEARNING_LEGACY_KEY, True, block=True) self.params.put(LEARNING_KEY, replace_preference(None, self.cp, True), block=True) assert self.process.should_run(True, self.params, self.cp) def test_invalid_preference_does_not_start_learner(self): for raw in (b'', b'not json', b'{"version":1,"vehicles":{"hyundai":{"HYUNDAI_IONIQ_6":1}}}'): self.params.put(LEARNING_KEY, raw, block=True) assert not self.process.should_run(True, self.params, self.cp) def test_other_vehicle_preference_is_not_reused(self): self.params.put(LEARNING_KEY, replace_preference(None, self.cp, False), block=True) other = car.CarParams.new_message(brand='ford', carFingerprint='FORD_MUSTANG_MACH_E_MK1') assert self.process.should_run(True, self.params, other) def test_offroad_does_not_read_preferences(self): from unittest.mock import patch with patch('openpilot.starpilot.lateral.learning_preferences.read_preference', side_effect=AssertionError('Offroad learner should not read settings')): assert not self.process.should_run(False, self.params, self.cp) class TestLearningProjectionPreference(unittest.TestCase): def make_car(self, vehicle, preference=None, legacy=False): from openpilot.selfdrive.car.card import Car params = Params() params.put_bool('OpenpilotEnabledToggle', True, block=True) params.put_bool(LEARNING_LEGACY_KEY, legacy, block=True) cp = interfaces[vehicle].get_non_essential_params(vehicle) if preference is not None: raw = replace_preference(None, cp, preference) if type(preference) is bool else preference params.put(LEARNING_KEY, raw, block=True) ci = interfaces[vehicle](cp) return Car(CI=ci, RI=interfaces[vehicle].RadarInterface(cp)) def test_explicit_off_skips_actual_factory_observer(self): for vehicle in VEHICLES: with self.subTest(vehicle=vehicle), OpenpilotPrefix(): host = self.make_car(vehicle, False) assert host.learning_stamp_observer is None assert getattr(host.CI.CC, 'learning_projection_observer', None) is None assert host.learning_applied_evidence is None def test_default_on_keeps_actual_factory_observer(self): for vehicle in VEHICLES: with self.subTest(vehicle=vehicle), OpenpilotPrefix(): host = self.make_car(vehicle) assert host.learning_stamp_observer is not None assert host.CI.CC.learning_projection_observer is not None def test_scoped_on_still_overrides_legacy_off(self): with OpenpilotPrefix(): host = self.make_car(HKG.HYUNDAI_IONIQ_6, True, legacy=True) assert host.learning_stamp_observer is not None def test_legacy_off_and_invalid_preference_do_not_install(self): for preference, legacy in ((None, True), (b'not json', False)): with self.subTest(preference=preference, legacy=legacy), OpenpilotPrefix(): host = self.make_car(HKG.HYUNDAI_IONIQ_6, preference, legacy) assert host.learning_stamp_observer is None assert getattr(host.CI.CC, 'learning_projection_observer', None) is None class TestControlsLearningPreference(unittest.TestCase): def make_controls(self, vehicle, preference=None, legacy=False): from openpilot.selfdrive.controls.controlsd import Controls params = Params() cp = interfaces[vehicle].get_non_essential_params(vehicle) params.put('CarParams', cp.to_bytes(), block=True) params.put_bool('OpenpilotEnabledToggle', True, block=True) params.put_bool(LEARNING_LEGACY_KEY, legacy, block=True) if preference is not None: params.put(LEARNING_KEY, replace_preference(None, cp, preference) if type(preference) is bool else preference, block=True) before = (params.get(LEARNING_KEY), params.get(TORQUE_KEY)) host = Controls() assert before == (params.get(LEARNING_KEY), params.get(TORQUE_KEY)) return host def test_saved_off_skips_actual_controls_learning_runtime(self): from unittest.mock import patch from openpilot.selfdrive.controls import controlsd for vehicle in VEHICLES: with self.subTest(vehicle=vehicle), OpenpilotPrefix(), patch.object(controlsd, 'GlobalRuntime', wraps=controlsd.GlobalRuntime) as runtime: host = self.make_controls(vehicle, False) assert host.global_learning is None runtime.assert_not_called() def test_default_and_explicit_on_keep_actual_runtime(self): from unittest.mock import patch from openpilot.selfdrive.controls import controlsd for vehicle, preference in ((HKG.HYUNDAI_IONIQ_6, None), (GM.CHEVROLET_BOLT_CC_2018_2021, True), (TOYOTA.TOYOTA_COROLLA_TSS2, True)): with self.subTest(vehicle=vehicle, preference=preference), OpenpilotPrefix(), \ patch.object(controlsd, 'GlobalRuntime', wraps=controlsd.GlobalRuntime) as runtime: host = self.make_controls(vehicle, preference) assert host.global_learning is not None and type(host.global_learning).__name__ == 'GlobalRuntime' runtime.assert_called_once() def test_scoped_on_wins_over_legacy_off(self): with OpenpilotPrefix(): host = self.make_controls(HKG.HYUNDAI_IONIQ_6, True, legacy=True) assert host.global_learning is not None def test_legacy_off_and_invalid_preference_skip_runtime(self): for preference, legacy in ((None, True), (b'not json', False)): with self.subTest(preference=preference, legacy=legacy), OpenpilotPrefix(): host = self.make_controls(HKG.HYUNDAI_IONIQ_6, preference, legacy) assert host.global_learning is None