Files
StarPilot/openpilot/starpilot/flm/tests/test_native_controls_acceptance.py
T

160 lines
8.2 KiB
Python

"""Actual selected Controls consumes trusted native updates without identity churn."""
from contextlib import contextmanager
import json
import os
from types import SimpleNamespace
from unittest import mock
import pytest
from opendbc.car.car_helpers import interfaces
from opendbc.car.hyundai.values import CAR as HYUNDAI
from opendbc.car.toyota.values import CAR as TOYOTA
from openpilot.common.params import Params
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.controls.controlsd import Controls
from openpilot.starpilot.flm.controller_replay import SelectedControllerFactory, capture_snapshot
from openpilot.starpilot.flm.learner_factory import TorqueCollectionAdapter, RAW_TORQUE
from openpilot.starpilot.flm.runtime_adapter_registry import baseline_values, baseline_revision, selected_descriptor, selected_identity
from openpilot.starpilot.flm.tests.native_controls_fixture import native_frame
from openpilot.starpilot.flm.tests.test_registered_controls_global import controls_for, published_cp
from openpilot.starpilot.lateral.controller_selection import DOCUMENT_KEY as SELECTION_KEY, ControllerMode, replace_mode
from openpilot.starpilot.lateral.learning_preferences import KEY, replace_preference
from openpilot.starpilot.lateral.torque_settings import DOCUMENT_KEY, FieldChoice, PlatformProfile, serialize_document
NOW = 400_000_000_000
CASES = [(HYUNDAI.HYUNDAI_IONIQ_6, 'starpilot', 1.22), (TOYOTA.TOYOTA_RAV4_TSS2, 'standard', 1.)]
def cp_for(vehicle):
return interfaces[vehicle].get_non_essential_params(vehicle)
@pytest.mark.parametrize('vehicle,mode,multiplier', CASES)
def test_actual_controls_65_seconds_variable_four_hz_preserves_nominal_identity_and_single_transform(vehicle, mode, multiplier):
cp = cp_for(vehicle)
with controls_for(cp, mode, True) as controls:
cp_bytes = controls.CP.as_builder().to_bytes()
anchor = tuple(controls.LaC.native_learning_baseline)
revision = controls.global_learning.baseline_revision
identity = None
for tick in range(6501):
# A bounded staircase of real4Hz native packets; all other inputs retain
# their declared-rate production SubMaster fixtures.
delta = .01 + .001 * ((tick // 25) % 8)
native_frame(controls, NOW + tick * 10_000_000, tick, native_overrides={
'factor': float(cp.lateralTuning.torque.latAccelFactor) * (1. + delta),
'friction': float(cp.lateralTuning.torque.friction) * .98,
})
runtime = controls.global_learning
assert runtime is not None
assert runtime.baseline_revision == revision
assert tuple(controls.LaC.native_learning_baseline) == anchor
row = controls.sm['lateralTorqueParameters']
assert controls.LaC.torque_params.latAccelFactor == pytest.approx(float(row.latAccelFactorFiltered) * multiplier, rel=2e-7)
assert controls.LaC.torque_params.friction == pytest.approx(float(row.frictionCoefficientFiltered), rel=2e-7)
if tick >= 50:
assert runtime.identity
identity = identity or runtime.identity
assert runtime.identity == identity
assert runtime.native_refined
assert controls.CP.as_builder().to_bytes() == cp_bytes
assert controls.params.get(KEY) == replace_preference(None, cp, True)
@pytest.mark.parametrize('vehicle,mode,multiplier', CASES)
@pytest.mark.parametrize('bad', ['inner_invalid', 'version', 'stale', 'future', 'factor', 'friction_nan'])
def test_actual_controls_reject_invalid_native_receipts(vehicle, mode, multiplier, bad):
cp = cp_for(vehicle)
with controls_for(cp, mode, True) as controls:
original = baseline_values(controls.LaC)
values = {'factor': float(cp.lateralTuning.torque.latAccelFactor) * 1.05}
if bad == 'inner_invalid':
values['valid'] = False
elif bad == 'version':
values['version'] = 2
elif bad == 'stale':
values['source_offset_ns'] = -500_000_001
elif bad == 'future':
values['source_offset_ns'] = 1
elif bad == 'factor':
values['factor'] = float(cp.lateralTuning.torque.latAccelFactor) * 2.
else:
values['friction'] = float('nan')
for tick in range(30):
native_frame(controls, NOW + tick * 10_000_000, tick, native_overrides=values)
assert baseline_values(controls.LaC) == original
assert controls.LaC.native_learning_refinement is None
@pytest.mark.parametrize('vehicle,mode,multiplier', CASES)
def test_off_keeps_historical_controller_output_despite_qualified_native_packet(vehicle, mode, multiplier):
cp = cp_for(vehicle)
with controls_for(cp, mode, False) as actual, controls_for(cp, mode, False) as reference:
original = baseline_values(actual.LaC)
for tick in range(80):
now = NOW + tick * 10_000_000
command, _ = native_frame(actual, now, tick, native_overrides={
'factor': float(cp.lateralTuning.torque.latAccelFactor) * 1.1})
baseline, _ = native_frame(reference, now, tick, native=False)
assert command.to_dict() == baseline.to_dict()
assert baseline_values(actual.LaC) == original
assert actual.LaC.native_learning_refinement is None
@contextmanager
def manual_ioniq():
cp = cp_for(HYUNDAI.HYUNDAI_IONIQ_6)
torque = cp.lateralTuning.torque
basis = (float(torque.latAccelFactor), float(torque.latAccelOffset), float(torque.friction))
document = serialize_document({str(cp.carFingerprint): PlatformProfile(basis, FieldChoice('custom', basis[0] * .95), FieldChoice())})
with OpenpilotPrefix(), mock.patch.dict(os.environ, {'REPLAY': '1', 'SIMULATION': '0', 'TORQUE_REPLAY_RUNTIME': '0',
'AOL_REPLAY_RUNTIME': '0', 'LANE_CENTERING_REPLAY_RUNTIME': '0'}), mock.patch('openpilot.selfdrive.controls.controlsd.messaging.PubMaster'):
params = Params()
params.put('CarParams', published_cp(cp, params).to_bytes(), block=True)
params.put(KEY, replace_preference(None, cp, True), block=True)
params.put(SELECTION_KEY, json.loads(replace_mode(None, cp, ControllerMode.STARPILOT)), block=True)
params.put(DOCUMENT_KEY, json.loads(document), block=True)
yield Controls(), document, basis
def test_actual_ioniq_manual_factor_remains_authoritative_over_native():
with manual_ioniq() as (controls, document, basis):
assert controls.torque_host is not None
for tick in range(150):
native_frame(controls, NOW + tick * 10_000_000, tick,
native_overrides={'factor': basis[0] * 1.2, 'friction': basis[2] * 1.1})
assert controls.LaC.torque_params.latAccelFactor == pytest.approx(basis[0] * .95 * 1.22, rel=2e-7)
assert controls.params.get(DOCUMENT_KEY) == json.loads(document)
assert controls.LaC.native_learning_refinement is None
@pytest.mark.parametrize('vehicle,mode,multiplier', CASES)
def test_factory_rejects_in_envelope_tune_forgery_without_native_provenance(vehicle, mode, multiplier):
cp = cp_for(vehicle)
factory = SelectedControllerFactory(cp, mode)
controller, vm = factory.create()
descriptor = selected_descriptor(cp, factory.interface_class(cp), controller)
anchor = baseline_values(controller)
revision = baseline_revision(descriptor, anchor)
epoch = 'a' * 64
snapshot = capture_snapshot(controller, vm).values
fields = dict(snapshot)
fields['tune.latAccelFactor'] *= 1.03
fields['pid.pos_limit'] *= 1.03
fields['pid.neg_limit'] *= 1.03
evidence = SimpleNamespace(stage_schema_revision=RAW_TORQUE, controller_mode=mode, turn_assist=False,
policy=descriptor.policy, controller_revision=descriptor.controller_revision, source_epoch=epoch,
baseline_revision=revision, identity=selected_identity(descriptor, epoch, revision),
replay_snapshot=tuple(fields.items()), control_ns=NOW, generation=0, effect_trace=None, effect_prepare_ns=0)
with pytest.raises(ValueError, match='checkpoint provenance'):
TorqueCollectionAdapter(cp, evidence)
# Changing nominal identity to suit forged values must fail before restore.
forged = list(anchor)
forged[1] *= 1.03
wrong_revision = baseline_revision(descriptor, tuple(forged))
evidence.baseline_revision = wrong_revision
evidence.identity = selected_identity(descriptor, epoch, wrong_revision)
with mock.patch('openpilot.starpilot.flm.controller_replay.restore_snapshot', side_effect=AssertionError('must not restore')):
with pytest.raises(ValueError, match='constructor identity'):
TorqueCollectionAdapter(cp, evidence)