Files
StarPilot/openpilot/starpilot/flm/tests/test_source_receipt.py
T
firestar5683 200632aabd StarPilot Auto Learn
Learn bounded feed-forward corrections on eligible torque controllers behind Use StarPilot Auto Learn, preserving installed tunes and native safety limits.
Co-authored-by: Danny <dannyirwin31@yahoo.com>
Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com>
Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu>
Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>

Co-authored-by: Intelli <contact@intelli.software>
Co-authored-by: haraschax <6804392+haraschax@users.noreply.github.com>
Co-authored-by: pharmacomaniac <blittle65@gmail.com>

Co-authored-by: whoisdomi <dcantu83@gmail.com>
2026-10-14 20:02:39 -05:00

221 lines
9.9 KiB
Python

"""Source receipt leaf decoding and unchanged generic fallback contracts."""
import math
import struct
from types import SimpleNamespace
from typing import TypedDict
import unittest
from capnp.lib.capnp import KjException, _DynamicStructReader
from openpilot.cereal import custom, log, messaging
from openpilot.starpilot.flm import learning_wire
from openpilot.starpilot.flm.runtime_adapter_registry import SourceState
NOW = 10_000_000_000
CP = 'c' * 64
class ReceiptFields(TypedDict):
version: int
enabled: bool
manual: bool
resetGeneration: str
sourceEpoch: str
observedMonoTime: int
validUntilMonoTime: int
cpSha256: str
calibrationBasis: list[float]
FIELDS: ReceiptFields = {'version': 1, 'enabled': True, 'manual': False, 'resetGeneration': 'reset-1', 'sourceEpoch': 'b' * 64,
'observedMonoTime': NOW, 'validUntilMonoTime': NOW + 500_000_000, 'cpSha256': CP,
'calibrationBasis': [0.0, 0.0, 0.0]}
VALID_LOG = [
'event.valid', 'event.autoSteeringLearning', 'outer.learning', 'learning.sourceReceipt',
'row.version', 'row.cpSha256', 'row.cpSha256', 'row.observedMonoTime',
'row.validUntilMonoTime', 'row.observedMonoTime', 'row.enabled', 'row.sourceEpoch',
'row.resetGeneration', 'row.resetGeneration', 'row.calibrationBasis', 'row.calibrationBasis',
'row.enabled', 'row.manual', 'row.resetGeneration', 'row.sourceEpoch', 'row.observedMonoTime',
'row.validUntilMonoTime', 'row.cpSha256', 'row.calibrationBasis',
]
def native(*, reader=True, **changes):
event = messaging.new_message('autoSteeringLearning', valid=True, logMonoTime=NOW)
event.autoSteeringLearning.learning.sourceReceipt = {**FIELDS, **changes}
return event.as_reader() if reader else event
def envelope(row, valid=True):
return SimpleNamespace(valid=valid, autoSteeringLearning=SimpleNamespace(learning=SimpleNamespace(sourceReceipt=row)))
def expected(**changes):
row: ReceiptFields = {**FIELDS, **changes}
return SourceState(row['enabled'], row['manual'], row['resetGeneration'], row['sourceEpoch'],
row['observedMonoTime'], row['validUntilMonoTime'], row['cpSha256'], tuple(row['calibrationBasis']))
def signature(value):
if value is None:
return None
return (type(value), value.enabled, value.manual, value.reset, value.source_epoch,
value.observed_ns, value.expires_ns, value.cp_sha256,
tuple(struct.pack('!d', item) for item in value.calibration))
class Logged:
def __init__(self, values, accesses, label):
self.values, self.accesses, self.label = values, accesses, label
def __getattr__(self, name):
self.accesses.append(self.label + '.' + name)
if name not in self.values:
raise AttributeError(name)
value = self.values[name]
if isinstance(value, Exception):
raise value
return value() if callable(value) else value
def traced(accesses, *, valid=True, **changes):
row = Logged({**FIELDS, **changes}, accesses, 'row')
learning = Logged({'sourceReceipt': row}, accesses, 'learning')
outer = Logged({'learning': learning}, accesses, 'outer')
return Logged({'valid': valid, 'autoSteeringLearning': outer}, accesses, 'event')
class TestSourceReceipt(unittest.TestCase):
def assert_receipt(self, changes, wanted, *, now=NOW, cp=CP):
builder = native(reader=False, **changes)
# Builders retain the original generic body; both paths must meet an explicit result.
for event in (builder.as_reader(), builder):
value = learning_wire.read_source_receipt(event, now_ns=now, cp_sha256=cp)
self.assertEqual(signature(value), signature(wanted))
return value
def test_native_schemas_and_enabled_manual_flags(self):
event = native()
row = event.autoSteeringLearning.learning.sourceReceipt
self.assertIs(type(row), _DynamicStructReader)
self.assertEqual(event.schema, log.Event.schema)
self.assertEqual(event.autoSteeringLearning.schema, custom.StarPilotCarParams.schema)
self.assertEqual(row.schema, custom.AutomaticLearningSourceReceipt.schema)
for enabled, manual in ((True, False), (True, True), (False, False), (False, True)):
with self.subTest(enabled=enabled, manual=manual):
changes = {'enabled': enabled, 'manual': manual}
self.assert_receipt(changes, expected(**changes))
def test_version_identity_reset_and_time_boundaries(self):
rows = (
{'version': 0}, {'version': 2}, {'cpSha256': 'd' * 64}, {'cpSha256': 'invalid'},
{'sourceEpoch': ''}, {'sourceEpoch': 'B' * 64}, {'resetGeneration': ''},
{'resetGeneration': 'r' * 65}, {'enabled': False, 'resetGeneration': 'r' * 65},
{'observedMonoTime': 0}, {'observedMonoTime': NOW + 1},
{'validUntilMonoTime': NOW - 1}, {'validUntilMonoTime': NOW + 500_000_001},
)
for changes in rows:
with self.subTest(changes=changes):
self.assert_receipt(changes, None)
for changes in ({'enabled': False, 'sourceEpoch': '', 'resetGeneration': ''},
{'resetGeneration': 'r' * 64}, {'validUntilMonoTime': NOW}):
self.assert_receipt(changes, expected(**changes))
for now, wanted in ((NOW - 1, None), (NOW, expected()),
(NOW + 500_000_000, expected()), (NOW + 500_000_001, None)):
with self.subTest(now=now):
self.assert_receipt({}, wanted, now=now)
def test_calibration_lengths_nonfinite_and_signed_zero(self):
for basis in ([], [0.0], [0.0, 0.0], [0.0] * 4, [math.nan, 0.0, 0.0],
[0.0, math.inf, 0.0], [0.0, 0.0, -math.inf]):
with self.subTest(basis=basis):
self.assert_receipt({'calibrationBasis': basis}, None)
changes = {'calibrationBasis': [-0.0, 0.0, -0.0]}
self.assert_receipt(changes, expected(**changes))
def test_borrowed_reader_mutations_and_fresh_result_objects(self):
builder = native(reader=False)
borrowed = builder.as_reader()
first = learning_wire.read_source_receipt(borrowed, now_ns=NOW, cp_sha256=CP)
second = learning_wire.read_source_receipt(borrowed, now_ns=NOW, cp_sha256=CP)
self.assertEqual(signature(first), signature(expected()))
self.assertEqual(signature(second), signature(expected()))
self.assertIsNot(first, second)
rows = (('enabled', False), ('manual', True), ('version', 0), ('cpSha256', 'd' * 64),
('sourceEpoch', 'e' * 64), ('resetGeneration', 'reset-2'),
('observedMonoTime', NOW - 1), ('validUntilMonoTime', NOW + 1),
('calibrationBasis', [-0.0, 0.25, -0.5]))
for field, value in rows:
with self.subTest(field=field):
builder.autoSteeringLearning.learning.sourceReceipt = FIELDS
setattr(builder.autoSteeringLearning.learning.sourceReceipt, field, value)
wanted = None if field in ('version', 'cpSha256', 'observedMonoTime') else expected(**{field: value})
actual = learning_wire.read_source_receipt(borrowed, now_ns=NOW, cp_sha256=CP)
self.assertEqual(signature(actual), signature(wanted))
builder.autoSteeringLearning.learning.sourceReceipt = FIELDS
self.assertEqual(signature(learning_wire.read_source_receipt(borrowed, now_ns=NOW, cp_sha256=CP)), signature(expected()))
def test_custom_schema_spoof_and_wrong_native_schema_fallback(self):
row = SimpleNamespace(**FIELDS, schema=custom.AutomaticLearningSourceReceipt.schema)
actual = learning_wire.read_source_receipt(envelope(row), now_ns=NOW, cp_sha256=CP)
self.assertEqual(signature(actual), signature(expected()))
wrong = messaging.new_message('carState').as_reader().carState
with self.assertRaises((AttributeError, ValueError, KjException)):
learning_wire.read_source_receipt(envelope(wrong), now_ns=NOW, cp_sha256=CP)
with self.assertRaises(AttributeError):
learning_wire.read_source_receipt(envelope(None), now_ns=NOW, cp_sha256=CP)
def test_custom_getter_short_circuit_order_and_exception(self):
cases = (({'valid': False}, 1), ({'version': 0}, 5), ({'cpSha256': 'd' * 64}, 6),
({'observedMonoTime': NOW + 1}, 8), ({'sourceEpoch': ''}, 12),
({'resetGeneration': ''}, 13), ({'calibrationBasis': []}, 15))
for changes, length in cases:
with self.subTest(changes=changes):
accesses = []
self.assertIsNone(learning_wire.read_source_receipt(traced(accesses, **changes), now_ns=NOW, cp_sha256=CP))
self.assertEqual(accesses, VALID_LOG[:length])
accesses = []
with self.assertRaisesRegex(RuntimeError, '^manual getter$'):
learning_wire.read_source_receipt(traced(accesses, manual=RuntimeError('manual getter')), now_ns=NOW, cp_sha256=CP)
self.assertEqual(accesses, VALID_LOG[:18])
def test_stateful_custom_field_keeps_original_repeated_reads(self):
accesses = []
values = iter((CP, 'd' * 64, 'e' * 64))
event = traced(accesses, cpSha256=lambda: next(values))
actual = learning_wire.read_source_receipt(event, now_ns=NOW, cp_sha256=CP)
self.assertEqual(signature(actual), signature(expected(cpSha256='e' * 64)))
self.assertEqual(accesses, VALID_LOG)
def test_nonbuiltin_clock_and_cp_arguments(self):
class IntSubclass(int):
pass
class StrSubclass(str):
pass
class Clock:
def __init__(self, accesses):
self.accesses = accesses
def __ge__(self, other):
self.accesses.append(('ge', other))
return NOW >= other
def __le__(self, other):
self.accesses.append(('le', other))
return NOW <= other
for now, cp, wanted in ((float(NOW), CP, expected()), (IntSubclass(NOW), CP, expected()),
(True, CP, None), (0.0, CP, None), (-0.0, CP, None), (math.nan, CP, None),
(NOW, StrSubclass(CP), expected()), (NOW, None, None)):
with self.subTest(now=now, cp=cp):
self.assert_receipt({}, wanted, now=now, cp=cp)
accesses = []
value = learning_wire.read_source_receipt(native(), now_ns=Clock(accesses), cp_sha256=CP)
self.assertEqual(signature(value), signature(expected()))
self.assertEqual(accesses, [('ge', NOW), ('le', NOW + 500_000_000)])
if __name__ == '__main__':
unittest.main()