FLM Calibration Lifecycle

Keep bounded automatic learning collection through valid live calibration refinements. Retire generations on resets or invalid calibration, preserve live physical transforms and existing controller tunes, and validate fitted coefficient bounds before publication. Exercise the current controller and historical wire paths.
This commit is contained in:
firestar5683
2026-10-16 09:47:39 -05:00
parent 7e63c553a4
commit abb9646499
52 changed files with 872 additions and 268 deletions
+1
View File
@@ -1233,6 +1233,7 @@ struct AutomaticLearningSourceReceipt {
validUntilMonoTime @6 :UInt64;
cpSha256 @7 :Text;
calibrationBasis @8 :List(Float64);
calibrationGeneration @9 :Text;
}
struct LearningApplication {
+62 -45
View File
@@ -16136,17 +16136,17 @@ const ::capnp::_::RawSchema s_ccffb4ab144bf131 = {
0, 2, i_ccffb4ab144bf131, nullptr, nullptr, { &s_ccffb4ab144bf131, nullptr, nullptr, 0, 0, nullptr }, false
};
#endif // !CAPNP_LITE
static const ::capnp::_::AlignedData<168> b_9fe2d23e75805149 = {
static const ::capnp::_::AlignedData<185> b_9fe2d23e75805149 = {
{ 0, 0, 0, 0, 5, 0, 6, 0,
73, 81, 128, 117, 62, 210, 226, 159,
13, 0, 0, 0, 1, 0, 3, 0,
89, 10, 85, 29, 102, 186, 38, 181,
4, 0, 7, 0, 0, 0, 0, 0,
5, 0, 7, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
21, 0, 0, 0, 98, 1, 0, 0,
41, 0, 0, 0, 7, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
37, 0, 0, 0, 255, 1, 0, 0,
37, 0, 0, 0, 55, 2, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
99, 117, 115, 116, 111, 109, 46, 99,
@@ -16156,70 +16156,77 @@ static const ::capnp::_::AlignedData<168> b_9fe2d23e75805149 = {
117, 114, 99, 101, 82, 101, 99, 101,
105, 112, 116, 0, 0, 0, 0, 0,
0, 0, 0, 0, 1, 0, 1, 0,
36, 0, 0, 0, 3, 0, 4, 0,
40, 0, 0, 0, 3, 0, 4, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 1, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
237, 0, 0, 0, 66, 0, 0, 0,
9, 1, 0, 0, 66, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
232, 0, 0, 0, 3, 0, 1, 0,
244, 0, 0, 0, 2, 0, 1, 0,
4, 1, 0, 0, 3, 0, 1, 0,
16, 1, 0, 0, 2, 0, 1, 0,
1, 0, 0, 0, 16, 0, 0, 0,
0, 0, 1, 0, 1, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
241, 0, 0, 0, 66, 0, 0, 0,
13, 1, 0, 0, 66, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
236, 0, 0, 0, 3, 0, 1, 0,
248, 0, 0, 0, 2, 0, 1, 0,
8, 1, 0, 0, 3, 0, 1, 0,
20, 1, 0, 0, 2, 0, 1, 0,
2, 0, 0, 0, 17, 0, 0, 0,
0, 0, 1, 0, 2, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
245, 0, 0, 0, 58, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
240, 0, 0, 0, 3, 0, 1, 0,
252, 0, 0, 0, 2, 0, 1, 0,
3, 0, 0, 0, 0, 0, 0, 0,
0, 0, 1, 0, 3, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
249, 0, 0, 0, 130, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
248, 0, 0, 0, 3, 0, 1, 0,
4, 1, 0, 0, 2, 0, 1, 0,
4, 0, 0, 0, 1, 0, 0, 0,
0, 0, 1, 0, 4, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
1, 1, 0, 0, 98, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 1, 0, 0, 3, 0, 1, 0,
12, 1, 0, 0, 2, 0, 1, 0,
5, 0, 0, 0, 1, 0, 0, 0,
0, 0, 1, 0, 5, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
9, 1, 0, 0, 138, 0, 0, 0,
17, 1, 0, 0, 58, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
12, 1, 0, 0, 3, 0, 1, 0,
24, 1, 0, 0, 2, 0, 1, 0,
3, 0, 0, 0, 0, 0, 0, 0,
0, 0, 1, 0, 3, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
21, 1, 0, 0, 130, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
20, 1, 0, 0, 3, 0, 1, 0,
32, 1, 0, 0, 2, 0, 1, 0,
4, 0, 0, 0, 1, 0, 0, 0,
0, 0, 1, 0, 4, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
29, 1, 0, 0, 98, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
28, 1, 0, 0, 3, 0, 1, 0,
40, 1, 0, 0, 2, 0, 1, 0,
5, 0, 0, 0, 1, 0, 0, 0,
0, 0, 1, 0, 5, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
37, 1, 0, 0, 138, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
40, 1, 0, 0, 3, 0, 1, 0,
52, 1, 0, 0, 2, 0, 1, 0,
6, 0, 0, 0, 2, 0, 0, 0,
0, 0, 1, 0, 6, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
21, 1, 0, 0, 154, 0, 0, 0,
49, 1, 0, 0, 154, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
24, 1, 0, 0, 3, 0, 1, 0,
36, 1, 0, 0, 2, 0, 1, 0,
52, 1, 0, 0, 3, 0, 1, 0,
64, 1, 0, 0, 2, 0, 1, 0,
7, 0, 0, 0, 2, 0, 0, 0,
0, 0, 1, 0, 7, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
33, 1, 0, 0, 74, 0, 0, 0,
61, 1, 0, 0, 74, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
32, 1, 0, 0, 3, 0, 1, 0,
44, 1, 0, 0, 2, 0, 1, 0,
60, 1, 0, 0, 3, 0, 1, 0,
72, 1, 0, 0, 2, 0, 1, 0,
8, 0, 0, 0, 3, 0, 0, 0,
0, 0, 1, 0, 8, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
41, 1, 0, 0, 138, 0, 0, 0,
69, 1, 0, 0, 138, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
44, 1, 0, 0, 3, 0, 1, 0,
72, 1, 0, 0, 2, 0, 1, 0,
72, 1, 0, 0, 3, 0, 1, 0,
100, 1, 0, 0, 2, 0, 1, 0,
9, 0, 0, 0, 4, 0, 0, 0,
0, 0, 1, 0, 9, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
97, 1, 0, 0, 178, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
100, 1, 0, 0, 3, 0, 1, 0,
112, 1, 0, 0, 2, 0, 1, 0,
118, 101, 114, 115, 105, 111, 110, 0,
7, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
@@ -16303,16 +16310,26 @@ static const ::capnp::_::AlignedData<168> b_9fe2d23e75805149 = {
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
14, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
99, 97, 108, 105, 98, 114, 97, 116,
105, 111, 110, 71, 101, 110, 101, 114,
97, 116, 105, 111, 110, 0, 0, 0,
12, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
12, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, }
};
::capnp::word const* const bp_9fe2d23e75805149 = b_9fe2d23e75805149.words;
#if !CAPNP_LITE
static const uint16_t m_9fe2d23e75805149[] = {8, 7, 1, 2, 5, 3, 4, 6, 0};
static const uint16_t i_9fe2d23e75805149[] = {0, 1, 2, 3, 4, 5, 6, 7, 8};
static const uint16_t m_9fe2d23e75805149[] = {8, 9, 7, 1, 2, 5, 3, 4, 6, 0};
static const uint16_t i_9fe2d23e75805149[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
const ::capnp::_::RawSchema s_9fe2d23e75805149 = {
0x9fe2d23e75805149, b_9fe2d23e75805149.words, 168, nullptr, m_9fe2d23e75805149,
0, 9, i_9fe2d23e75805149, nullptr, nullptr, { &s_9fe2d23e75805149, nullptr, nullptr, 0, 0, nullptr }, false
0x9fe2d23e75805149, b_9fe2d23e75805149.words, 185, nullptr, m_9fe2d23e75805149,
0, 10, i_9fe2d23e75805149, nullptr, nullptr, { &s_9fe2d23e75805149, nullptr, nullptr, 0, 0, nullptr }, false
};
#endif // !CAPNP_LITE
static const ::capnp::_::AlignedData<168> b_82b6c8ea6a6efabd = {
+45 -1
View File
@@ -1502,7 +1502,7 @@ struct AutomaticLearningSourceReceipt {
class Pipeline;
struct _capnpPrivate {
CAPNP_DECLARE_STRUCT_HEADER(9fe2d23e75805149, 3, 4)
CAPNP_DECLARE_STRUCT_HEADER(9fe2d23e75805149, 3, 5)
#if !CAPNP_LITE
static constexpr ::capnp::_::RawBrandedSchema const* brand() { return &schema->defaultBrand; }
#endif // !CAPNP_LITE
@@ -10792,6 +10792,9 @@ public:
inline bool hasCalibrationBasis() const;
inline ::capnp::List<double, ::capnp::Kind::PRIMITIVE>::Reader getCalibrationBasis() const;
inline bool hasCalibrationGeneration() const;
inline ::capnp::Text::Reader getCalibrationGeneration() const;
private:
::capnp::_::StructReader _reader;
template <typename, ::capnp::Kind>
@@ -10864,6 +10867,13 @@ public:
inline void adoptCalibrationBasis(::capnp::Orphan< ::capnp::List<double, ::capnp::Kind::PRIMITIVE>>&& value);
inline ::capnp::Orphan< ::capnp::List<double, ::capnp::Kind::PRIMITIVE>> disownCalibrationBasis();
inline bool hasCalibrationGeneration();
inline ::capnp::Text::Builder getCalibrationGeneration();
inline void setCalibrationGeneration( ::capnp::Text::Reader value);
inline ::capnp::Text::Builder initCalibrationGeneration(unsigned int size);
inline void adoptCalibrationGeneration(::capnp::Orphan< ::capnp::Text>&& value);
inline ::capnp::Orphan< ::capnp::Text> disownCalibrationGeneration();
private:
::capnp::_::StructBuilder _builder;
template <typename, ::capnp::Kind>
@@ -26365,6 +26375,40 @@ inline ::capnp::Orphan< ::capnp::List<double, ::capnp::Kind::PRIMITIVE>> Automa
::capnp::bounded<3>() * ::capnp::POINTERS));
}
inline bool AutomaticLearningSourceReceipt::Reader::hasCalibrationGeneration() const {
return !_reader.getPointerField(
::capnp::bounded<4>() * ::capnp::POINTERS).isNull();
}
inline bool AutomaticLearningSourceReceipt::Builder::hasCalibrationGeneration() {
return !_builder.getPointerField(
::capnp::bounded<4>() * ::capnp::POINTERS).isNull();
}
inline ::capnp::Text::Reader AutomaticLearningSourceReceipt::Reader::getCalibrationGeneration() const {
return ::capnp::_::PointerHelpers< ::capnp::Text>::get(_reader.getPointerField(
::capnp::bounded<4>() * ::capnp::POINTERS));
}
inline ::capnp::Text::Builder AutomaticLearningSourceReceipt::Builder::getCalibrationGeneration() {
return ::capnp::_::PointerHelpers< ::capnp::Text>::get(_builder.getPointerField(
::capnp::bounded<4>() * ::capnp::POINTERS));
}
inline void AutomaticLearningSourceReceipt::Builder::setCalibrationGeneration( ::capnp::Text::Reader value) {
::capnp::_::PointerHelpers< ::capnp::Text>::set(_builder.getPointerField(
::capnp::bounded<4>() * ::capnp::POINTERS), value);
}
inline ::capnp::Text::Builder AutomaticLearningSourceReceipt::Builder::initCalibrationGeneration(unsigned int size) {
return ::capnp::_::PointerHelpers< ::capnp::Text>::init(_builder.getPointerField(
::capnp::bounded<4>() * ::capnp::POINTERS), size);
}
inline void AutomaticLearningSourceReceipt::Builder::adoptCalibrationGeneration(
::capnp::Orphan< ::capnp::Text>&& value) {
::capnp::_::PointerHelpers< ::capnp::Text>::adopt(_builder.getPointerField(
::capnp::bounded<4>() * ::capnp::POINTERS), kj::mv(value));
}
inline ::capnp::Orphan< ::capnp::Text> AutomaticLearningSourceReceipt::Builder::disownCalibrationGeneration() {
return ::capnp::_::PointerHelpers< ::capnp::Text>::disown(_builder.getPointerField(
::capnp::bounded<4>() * ::capnp::POINTERS));
}
inline ::uint16_t LearningApplication::Reader::getVersion() const {
return _reader.getDataField< ::uint16_t>(
::capnp::bounded<0>() * ::capnp::ELEMENTS);
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,109 @@
"""Calibration estimates share a vehicle frame until the producer resets it."""
import math
import uuid
def calibrated_basis(message):
try:
basis_values, spread_values = message.rpyCalib, message.rpyCalibSpread
if len(basis_values) != 3 or len(spread_values) != 3:
return None
basis = tuple(float(v) for v in basis_values)
blocks = message.validBlocks
if type(blocks) is not int:
return None
spread = tuple(float(v) for v in spread_values)
if (str(message.calStatus) != 'calibrated' or blocks < 5 or blocks > 50 or
len(basis) != 3 or not all(math.isfinite(v) for v in basis) or
len(spread) != 3 or not all(math.isfinite(v) and v >= 0 for v in spread) or
spread[1] > math.radians(4) or spread[2] > math.radians(2)):
return None
return basis, blocks
except (AttributeError, TypeError, ValueError, OverflowError):
return None
def generation_valid(value):
return type(value) is str and len(value) == 32 and all(c in '0123456789abcdef' for c in value)
class ProducerCalibrationLifecycle:
def __init__(self, token_factory=lambda: uuid.uuid4().hex):
self.token_factory = token_factory
self.token = ''
self.blocks = None
self.issued = set()
def observe(self, message, *, current):
value = calibrated_basis(message) if current else None
if value is None:
self.token = ''
self.blocks = None
return None
basis, blocks = value
if self.blocks is not None and blocks < self.blocks:
self.token = ''
if not self.token:
if len(self.issued) >= 32:
return None # Bounded reset history; never recycle an old generation.
try:
token = self.token_factory()
except Exception:
return None
if not generation_valid(token) or token in self.issued:
return None
self.token = token
self.issued.add(token)
self.blocks = blocks
return self.token, basis
class ConsumerCalibrationLifecycle:
def __init__(self):
self.generation = ''
self.blocks = None
self.rejected = set()
self.exhausted = False
self.current = False
self.last_invalid_ns = 0
def retire(self, generation):
if generation and generation not in self.rejected:
if len(self.rejected) >= 32:
self.exhausted = True
else:
self.rejected.add(generation)
def capture(self, generation):
if not generation_valid(generation):
return False
if self.generation and generation != self.generation:
self.retire(self.generation)
self.generation = generation
return not self.exhausted and generation not in self.rejected
def reject(self, *, now_ns=0):
self.retire(self.generation)
if type(now_ns) is int and now_ns > self.last_invalid_ns:
self.last_invalid_ns = now_ns
self.blocks = None
self.current = False
def observe(self, message, *, current, now_ns=0):
value = calibrated_basis(message) if current else None
if value is None:
self.reject(now_ns=now_ns)
return False
_, blocks = value
if self.blocks is not None and blocks < self.blocks:
self.reject(now_ns=now_ns)
self.blocks = blocks
self.current = True
return True
def admit(self, generation, observed_ns):
if (not self.current or self.exhausted or not generation_valid(generation) or
type(observed_ns) is not int or observed_ns <= self.last_invalid_ns or
generation in self.rejected):
return False
return self.capture(generation)
+33 -2
View File
@@ -42,6 +42,8 @@ class GlobalRuntime:
def __init__(self, cp, ci, controller):
# Construct AFTER actual LaC selection, startup native/manual configuration.
self.cp, self.ci, self.controller = cp, ci, controller
from openpilot.starpilot.flm.calibration_lifecycle import ConsumerCalibrationLifecycle
self._calibration_lifecycle = ConsumerCalibrationLifecycle()
self.health_services = ('carState', 'modelV2', 'vehicleParameters', 'lateralDelay', 'driverMonitoringState', 'extrinsicsCalibration')
try:
self.descriptor = selected_descriptor(cp, ci, controller)
@@ -114,6 +116,29 @@ class GlobalRuntime:
"""Bounded current source/admission checks; invoked immediately before LaC."""
self.used, self.stage, self.frame, self.eligible = False, None, None, False
self.source = None
# Capture a fresh V2 generation and calibration interruption before ANY
# unrelated adapter/native/vehicle health gate can return.
tracker = self._calibration_lifecycle
early_source = None
try:
if (type(now_ns) is int and now_ns > 0 and self.descriptor is not None and
sm.seen[SERVICE] and sm.alive[SERVICE] and sm.valid[SERVICE] and sm.freq_ok[SERVICE] and
0 <= now_ns - int(sm.recv_time[SERVICE] * 1e9) <= SOURCE_LEASE_NS):
envelope = sm[SERVICE]
if envelope.learning.sourceReceipt.version == 2:
early_source = read_source_receipt(SimpleNamespace(valid=True, autoSteeringLearning=envelope),
now_ns=now_ns, cp_sha256=self.descriptor.cp_sha256)
if early_source is not None and not tracker.generation:
tracker.capture(early_source.calibration_generation)
stamp = int(sm.logMonoTime['extrinsicsCalibration'])
receipt = int(sm.recv_time['extrinsicsCalibration'] * 1e9)
current_calibration = bool(type(now_ns) is int and now_ns > 0 and
sm.all_checks(['extrinsicsCalibration']) and
0 < stamp <= now_ns <= stamp + 500_000_000 and
0 < receipt <= now_ns <= receipt + 500_000_000)
tracker.observe(sm['extrinsicsCalibration'], current=current_calibration, now_ns=now_ns)
except (AttributeError, KeyError, TypeError, ValueError, OverflowError):
tracker.reject(now_ns=now_ns)
self.outcome = None
self.centre_frame = None
self.centre_raw = ()
@@ -167,6 +192,8 @@ class GlobalRuntime:
stamp = int(sm.logMonoTime[service])
receipt = int(sm.recv_time[service] * 1e9)
if not (sm.seen[service] and 0 < stamp <= now_ns <= stamp + maximum and 0 < receipt <= now_ns <= receipt + maximum):
if service == 'extrinsicsCalibration' and hasattr(self, '_calibration_lifecycle'):
self._calibration_lifecycle.reject()
raise ValueError('stale_original_source')
if self.controller.sat_time > 0:
raise ValueError('controller_saturation')
@@ -177,7 +204,7 @@ class GlobalRuntime:
raise ValueError('stale_receipt')
envelope = sm[SERVICE]
event = SimpleNamespace(valid=True, autoSteeringLearning=envelope)
source = read_source_receipt(event, now_ns=now_ns, cp_sha256=self.descriptor.cp_sha256)
source = early_source if early_source is not None else read_source_receipt(event, now_ns=now_ns, cp_sha256=self.descriptor.cp_sha256)
if (
source is None
or source.cp_sha256 != self.descriptor.cp_sha256
@@ -185,7 +212,11 @@ class GlobalRuntime:
):
raise ValueError('source_identity_or_clock')
calibration = sm['extrinsicsCalibration']
if str(calibration.calStatus) != 'calibrated' or source.calibration != tuple(float(value) for value in calibration.rpyCalib):
if source.calibration_generation:
if (len(source.calibration) != 3 or not all(math.isfinite(value) for value in source.calibration) or
not tracker.admit(source.calibration_generation, source.observed_ns)):
raise ValueError('calibration_identity_changed')
elif str(calibration.calStatus) != 'calibrated' or source.calibration != tuple(float(value) for value in calibration.rpyCalib):
raise ValueError('calibration_identity_changed')
if not self.settings_cache.install(source, received_ns=received) or self.settings_cache.current(now_ns=now_ns) is None:
raise ValueError('source_cache')
+23 -4
View File
@@ -223,6 +223,11 @@ def result_message(result, session, sequence, now_ns, *, replay_validation=None)
raise ValueError('profile bound')
if any(not finite(p.excitation_floor) or p.excitation_floor <= 0 for p in profile.parameters):
raise ValueError('profile excitation floor')
if any(not all(finite(v) for v in (p.low, p.high, p.max_step, p.slew_per_s, coefficient))
or p.low > p.high or p.max_step <= 0 or p.slew_per_s <= 0
or not p.low <= coefficient <= p.high or abs(coefficient) > p.max_step
for p, coefficient in zip(profile.parameters, profile.coefficients, strict=True)):
raise ValueError('profile parameter bound')
if not replay_accepts(replay_validation, profile):
raise ValueError('actual replay validation required')
value.status = 'qualifiedFit' if result.status == 'qualified' else 'rejected'
@@ -331,10 +336,12 @@ def write_source_receipt(event, source):
not identity(source.cp_sha256) or type(source.observed_ns) is not int or not 0 < source.observed_ns <= source.expires_ns <= source.observed_ns + 500_000_000
):
raise ValueError('source receipt bounds')
if source.calibration_generation and not _calibration_generation(source.calibration_generation):
raise ValueError('calibration lifecycle generation')
if source.enabled and (not identity(source.source_epoch) or not source.reset):
raise ValueError('enabled source receipt identity')
event.autoSteeringLearning.learning.sourceReceipt = {
'version': VERSION,
'version': 2 if source.calibration_generation else VERSION,
'enabled': source.enabled,
'manual': source.manual,
'resetGeneration': source.reset,
@@ -343,11 +350,13 @@ def write_source_receipt(event, source):
'validUntilMonoTime': source.expires_ns,
'cpSha256': source.cp_sha256,
'calibrationBasis': source.calibration,
'calibrationGeneration': source.calibration_generation,
}
def _read_native_source_receipt(row, now_ns, cp_sha256, source_type):
if row.version != VERSION:
version = row.version
if version not in (VERSION, 2):
return None
cp = str(row.cpSha256)
if cp != cp_sha256 or not identity(cp):
@@ -382,7 +391,14 @@ def _read_native_source_receipt(row, now_ns, cp_sha256, source_type):
manual = bool(row.manual)
if not enabled:
epoch = str(row.sourceEpoch)
return source_type(enabled, manual, reset, epoch, observed, expires, cp, tuple(calibration))
generation = str(row.calibrationGeneration) if version == 2 else ''
if version == 2 and not _calibration_generation(generation):
return None
return source_type(enabled, manual, reset, epoch, observed, expires, cp, tuple(calibration), generation)
def _calibration_generation(value):
return type(value) is str and len(value) == 32 and all(c in '0123456789abcdef' for c in value)
def read_source_receipt(event, *, now_ns, cp_sha256):
@@ -394,8 +410,11 @@ def read_source_receipt(event, *, now_ns, cp_sha256):
if (type(row) is _DynamicStructReader and row.schema == custom.AutomaticLearningSourceReceipt.schema
and type(now_ns) is int and type(cp_sha256) is str):
return _read_native_source_receipt(row, now_ns, cp_sha256, SourceState)
version = row.version
if version == 2:
return _read_native_source_receipt(row, now_ns, cp_sha256, SourceState)
if (
row.version != VERSION
version != VERSION
or str(row.cpSha256) != cp_sha256
or not identity(str(row.cpSha256))
or not 0 < row.observedMonoTime <= now_ns <= row.validUntilMonoTime <= row.observedMonoTime + 500_000_000
@@ -235,9 +235,10 @@ class SourceState:
expires_ns: int
cp_sha256: str
calibration: tuple[float, ...] = ()
calibration_generation: str = ''
def read_source(cp, params, *, now_ns, cp_sha256=None, calibration=(), vehicle_session=""):
def read_source(cp, params, *, now_ns, cp_sha256=None, calibration=(), vehicle_session="", calibration_generation=''):
"""Only startup/separate-process settings work; errors disable learning."""
cp_sha256 = car_params_digest(cp) if cp_sha256 is None else cp_sha256
try:
@@ -294,8 +295,13 @@ def read_source(cp, params, *, now_ns, cp_sha256=None, calibration=(), vehicle_s
unit = vehicle_session
else:
unit = vin
epoch = digest((tuple((key, None if raw is None else raw.hex()) for key, raw in snapshots), calibration, unit))
return SourceState(bool(preference.valid and preference.enabled), manual, reset, epoch, now_ns, now_ns + SOURCE_LEASE_NS, cp_sha256, calibration)
if type(calibration_generation) is not str or (calibration_generation and (
len(calibration_generation) != 32 or any(c not in '0123456789abcdef' for c in calibration_generation))):
raise ValueError('invalid calibration lifecycle')
frame_identity = calibration if not calibration_generation else ('live_vehicle_frame_v2', calibration_generation)
epoch = digest((tuple((key, None if raw is None else raw.hex()) for key, raw in snapshots), frame_identity, unit))
return SourceState(bool(preference.valid and preference.enabled), manual, reset, epoch, now_ns,
now_ns + SOURCE_LEASE_NS, cp_sha256, calibration, calibration_generation)
except (AttributeError, UnicodeError, TypeError, ValueError, OSError, OverflowError):
return SourceState(False, False, '', '', now_ns, now_ns + SOURCE_LEASE_NS, cp_sha256)
@@ -464,12 +470,19 @@ def physical_observer(sm, cp, collector, now_ns):
state_ns, delay_ns = int(sm.logMonoTime['carState']), int(sm.logMonoTime['lateralDelay'])
if not 0 < state_ns <= now_ns or now_ns - state_ns > 100_000_000 or not 0 < delay_ns <= now_ns or now_ns - delay_ns > 500_000_000:
return None
from openpilot.starpilot.flm.calibration_lifecycle import calibrated_basis
generation = digest(tuple(float(v) for v in calibration.rpyCalib))
estimate = generation
lifecycle = getattr(collector, '_calibration_generation', '')
if lifecycle:
if calibrated_basis(calibration) is None:
return None
generation = digest(('live_vehicle_frame_v2', lifecycle))
cache = getattr(collector, '_physical_calibrator', None)
if cache is None or cache[0] != generation:
if cache is None or cache[0] != estimate:
calibrator = PoseCalibrator()
calibrator.feed_extrinsics_calibration(calibration)
collector._physical_calibrator = (generation, calibrator)
collector._physical_calibrator = (estimate, calibrator)
calibrator = collector._physical_calibrator[1]
if not calibrator.calib_valid:
return None
+23 -14
View File
@@ -125,6 +125,7 @@ def run(
unavailable_reason = 'actual_adapter_unavailable'
return
collector = FullrateCollector(adapter)
collector._calibration_generation = source.calibration_generation
collector.adapter.select_objective(active_key)
# This records primitive actual frames/checkpoints ONLY. No selected
# controller update, baseline probes or numerical fitting at100Hz.
@@ -260,26 +261,34 @@ def main():
cp_sha256 = car_params_digest(CP)
source_cache = [None]
vehicle_session = uuid.uuid4().hex
from openpilot.starpilot.flm.calibration_lifecycle import ProducerCalibrationLifecycle
calibration_lifecycle = ProducerCalibrationLifecycle()
def calibration_basis():
calibration = sm["extrinsicsCalibration"]
if not (
sm.seen["extrinsicsCalibration"]
and sm.valid["extrinsicsCalibration"]
and sm.alive["extrinsicsCalibration"]
and str(calibration.calStatus) == "calibrated"
):
return ()
return tuple(float(value) for value in calibration.rpyCalib)
def calibration_frame(now):
stamp = int(sm.logMonoTime['extrinsicsCalibration'])
receipt = int(sm.recv_time['extrinsicsCalibration'] * 1e9)
current = bool(sm.all_checks(['extrinsicsCalibration']) and
0 < stamp <= now <= stamp + 500_000_000 and
0 < receipt <= now <= receipt + 500_000_000)
return calibration_lifecycle.observe(sm['extrinsicsCalibration'], current=current)
def current_source(now, frame):
return read_source(CP, params, now_ns=now, cp_sha256=cp_sha256,
calibration=() if frame is None else frame[1], vehicle_session=vehicle_session,
calibration_generation='' if frame is None else frame[0])
def source_reader(now):
# Read once each4Hz receipt renewal, outside all controller/torqued processes.
if source_cache[0] is None or now - source_cache[0].observed_ns >= 250_000_000:
source_cache[0] = read_source(CP, params, now_ns=now, cp_sha256=cp_sha256, calibration=calibration_basis(), vehicle_session=vehicle_session)
# Observe resets on every loop, but keep settings reads at receipt-renewal rate.
frame = calibration_frame(now)
generation = '' if frame is None else frame[0]
if (source_cache[0] is None or generation != source_cache[0].calibration_generation or
now - source_cache[0].observed_ns >= 250_000_000):
source_cache[0] = current_source(now, frame)
return source_cache[0]
def current_sources(identity, epoch):
current = read_source(CP, params, now_ns=time.monotonic_ns(), cp_sha256=cp_sha256, calibration=calibration_basis(), vehicle_session=vehicle_session)
now = time.monotonic_ns()
current = current_source(now, calibration_frame(now))
return (identity if persistent_unit and epoch == current.source_epoch else '', current.enabled, current.manual, current.reset)
persistence = PersistenceModule(params, current_sources)
@@ -1,225 +1,130 @@
"""Composed automatic fit application; synthetic evidence, actual owners and wire."""
from contextlib import contextmanager
"""Independent physical fit/replay into current bounded Controls learning stages."""
from dataclasses import replace
import json
import math
import os
from pathlib import Path
import struct
import unittest
from unittest import mock
from opendbc.car.car_helpers import interfaces
from opendbc.car.gm.values import CAR as GM
from opendbc.car.hyundai.values import CAR as HKG
from openpilot.cereal import messaging
from openpilot.common.params import Params
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.controls.controlsd import Controls
from openpilot.starpilot.flm.automatic import identity_digest
from openpilot.starpilot.flm.learning_producer import AutomaticLearningProducer
from openpilot.starpilot.flm.learning_source import (
AutomaticLearningConsumer, MAX_AGE_NS, RESET_KEY, SERVICE, learning_payload,
)
from openpilot.starpilot.flm.tests import test_automatic as evidence
from openpilot.starpilot.lateral.controller_selection import ControllerMode, DOCUMENT_KEY as CONTROLLER_KEY, policy_for, replace_mode
from opendbc.car.toyota.values import CAR
from openpilot.starpilot.flm.learning_wire import result_message
from openpilot.starpilot.flm.tests.test_registered_controls_global import controls_for
from openpilot.starpilot.flm.tests.test_registered_accepted_profile import computed_registered_fit
from openpilot.starpilot.flm.tests.test_live_calibration_paths import live_frame, BASE
from openpilot.starpilot.lateral.learning_preferences import KEY as PREFERENCE_KEY, replace_preference
from openpilot.starpilot.lateral.tests.test_lane_runtime import feed, publish_corolla_params
from openpilot.starpilot.lateral.torque_settings import DOCUMENT_KEY as TORQUE_KEY, replace_field, serialize_document
NOW = 400_000_000_000
def current_fit(controls):
for tick in range(30):
live_frame(controls, tick, 'a' * 32)
return computed_registered_fit(controls.CP, controls.global_learning, BASE + 300_000_000)
def float32(value):
"""The controller's CarParams torque fields are Float32, not Python doubles."""
return struct.unpack('<f', struct.pack('<f', value))[0]
class AbsentCollection:
"""Only exercise the real Params/identity refresh; no fabricated pose admission."""
def all_checks(self, _services):
return False
class JoinedLearning:
def __init__(self, test, controls):
self.test, self.controls = test, controls
self.now, self.tick = NOW, 0
self.producer = self.new_producer()
self.last_publication = None
self.wire_readers = []
def new_producer(self):
producer = AutomaticLearningProducer(self.controls.CP, self.controls.params)
with mock.patch('openpilot.starpilot.flm.learning_producer.time.monotonic_ns', return_value=self.now), \
mock.patch('openpilot.starpilot.flm.learning_producer.time.clock_gettime_ns', return_value=self.now):
producer.update(AbsentCollection(), None)
self.test.assertEqual(producer.optional_errors, 0)
return producer
def fit(self, multiplier=1.05):
identity = self.producer.identity
self.test.assertIsNotNone(identity)
factor = self.controls.CP.lateralTuning.torque.latAccelFactor * multiplier
# Reuse the independently timed, direction/speed-balanced physical evidence
# fixture. Bind it to the real current factory identity, not a fabricated fit.
windows = []
for window in evidence.windows():
points = tuple(replace(point, lateral_accel=factor * point.applied_torque +
math.copysign(.02, point.applied_torque)) for point in window.response)
rows = tuple(replace(row, actual_la=point.lateral_accel, desired_la=point.lateral_accel)
for row, point in zip(window.controls, points, strict=True))
windows.append(replace(window, identity_sha256=identity_digest(identity), controls=rows, response=points))
self.producer.windows.extend(windows)
self.producer.block = len(windows)
self.producer.evaluate(self.now)
self.test.assertIsNotNone(self.producer.result)
return self.producer.result
def step(self, *, publish=True, change=None, **health):
controls = self.controls
feed(controls, self.now, self.tick, **health)
if publish and self.tick % 25 == 0:
with mock.patch('openpilot.starpilot.flm.learning_producer.time.monotonic_ns', return_value=self.now):
event = self.producer.get_msg()
self.test.assertIsNotNone(event)
self.test.assertTrue(event.valid, 'The real producer must mark its usable wire valid')
if change is not None:
change(learning_payload(event.autoSteeringLearning))
reader = messaging.log_from_bytes(event.to_bytes())
self.wire_readers.append(reader)
controls.sm.update_msgs(self.now / 1e9, [reader])
self.last_publication = self.now
with mock.patch('openpilot.selfdrive.controls.controlsd.time.monotonic_ns', return_value=self.now):
command, _ = controls.state_control()
self.now += 10_000_000
self.tick += 1
return command
def drive(self, ticks, **kwargs):
for _ in range(ticks):
command = self.step(**kwargs)
return command
@contextmanager
def joined(test, *, native_ack=False):
with OpenpilotPrefix(), mock.patch.dict(os.environ, {'REPLAY': '1', 'TORQUE_REPLAY_RUNTIME': '0',
'AOL_REPLAY_RUNTIME': '1' if native_ack else '0', 'LANE_CENTERING_REPLAY_RUNTIME': '0',
'SIMULATION': '0'}), \
mock.patch('openpilot.selfdrive.controls.controlsd.messaging.PubMaster'):
params = Params()
cp = publish_corolla_params(params)
params.put(CONTROLLER_KEY, json.loads(replace_mode(None, cp, ControllerMode.STANDARD)), block=True)
params.put(PREFERENCE_KEY, replace_preference(None, cp, True), block=True)
controls = Controls()
test.assertTrue(controls.automatic_learning.supported)
test.assertTrue(controls.torque_learning_allowed)
test.assertIsNone(controls.torque_host)
yield JoinedLearning(test, controls)
def apply_fit(controls, result, validation):
command = None
for tick in range(30, 71):
command, _ = live_frame(controls, tick, 'a' * 32, result=result, validation=validation)
return command
class TestAutomaticLearningIntegration(unittest.TestCase):
def params(self):
return interfaces[CAR.TOYOTA_RAV4_TSS2].get_non_essential_params(CAR.TOYOTA_RAV4_TSS2)
def test_produced_fit_reaches_actual_controls_once_without_manual_document(self):
with joined(self) as run:
controls = run.controls
with controls_for(self.params(), 'standard', True) as controls:
before_cp = controls.CP.as_builder().to_bytes()
basis = controls.CP.lateralTuning.torque
offset, friction = basis.latAccelOffset, basis.friction
command = run.drive(50)
tune = controls.LaC.torque_params.to_dict()
result, validation = current_fit(controls)
self.assertIsNotNone(result.profile)
self.assertIsNotNone(validation)
command = apply_fit(controls, result, validation)
runtime = controls.global_learning
self.assertTrue(command.latActive)
self.assertIsNone(controls.torque_host, 'Collecting alone must not instantiate an application owner')
result = run.fit()
self.assertEqual(result.status, 'qualified_fit')
native_factor = controls.LaC.torque_params.latAccelFactor
command = run.drive(100)
self.assertTrue(controls.sm.all_checks([SERVICE]))
self.assertFalse(controls.sm.simulation, 'Exercise actual frequency tracking, not simulation relaxation')
host = controls.torque_host
self.assertIsNotNone(host)
self.assertIs(host.applied_controller, controls.LaC)
self.assertGreater(controls.LaC.torque_params.latAccelFactor, native_factor)
self.assertEqual(controls.LaC.torque_params.latAccelFactor, float32(dict(result.candidate)['latAccelFactor']))
self.assertEqual(controls.LaC.torque_params.latAccelOffset, offset)
self.assertEqual(controls.LaC.torque_params.friction, friction)
self.assertTrue(runtime.used)
self.assertEqual(runtime.reason, 'qualified')
self.assertTrue(any(value != 0 for value in runtime.applied_coordinates))
owner = runtime.torque_owner
for tick in range(71, 80):
live_frame(controls, tick, 'a' * 32, result=result, validation=validation)
self.assertIs(runtime.torque_owner, owner, 'One stage owner survives subsequent receipts')
self.assertEqual(controls.LaC.torque_params.to_dict(), tune)
self.assertEqual(controls.CP.as_builder().to_bytes(), before_cp)
self.assertIsNone(controls.params.get(TORQUE_KEY))
self.assertTrue(command.latActive)
run.drive(30)
self.assertIs(controls.torque_host, host, 'One controller parameter writer must survive subsequent publications')
self.assertFalse(controls.sm.simulation)
self.assertTrue(controls.sm.all_checks(['autoSteeringLearning']))
def test_actual_application_expires_and_reset_restart_or_manual_choice_falls_back(self):
for reason in ('stale', 'restart', 'off', 'corrupt', 'reset', 'manual'):
with self.subTest(reason=reason), joined(self) as run:
controls = run.controls
self.assertEqual(run.fit().status, 'qualified_fit')
run.drive(100)
host = controls.torque_host
self.assertIsNotNone(host)
base = controls.CP.lateralTuning.torque.latAccelFactor
self.assertGreater(controls.LaC.torque_params.latAccelFactor, base)
if reason == 'restart':
previous_session = run.producer.session
run.producer = run.new_producer()
self.assertNotEqual(run.producer.session, previous_session)
self.assertIsNone(run.producer.result)
elif reason == 'off':
with self.subTest(reason=reason), controls_for(self.params(), 'standard', True) as controls:
result, validation = current_fit(controls)
self.assertIsNotNone(result.profile)
apply_fit(controls, result, validation)
runtime = controls.global_learning
self.assertTrue(any(value != 0 for value in runtime.applied_coordinates))
before_cp, tune = controls.CP.as_builder().to_bytes(), controls.LaC.torque_params.to_dict()
if reason == 'off':
controls.params.put(PREFERENCE_KEY, replace_preference(None, controls.CP, False), block=True)
elif reason == 'corrupt':
Path(controls.params.get_param_path(PREFERENCE_KEY)).write_bytes(b'malformed')
elif reason == 'reset':
controls.params.put(RESET_KEY, 'a' * 32, block=True)
controls.params.put('AutoSteeringLearningReset', 'c' * 32, block=True)
elif reason == 'manual':
basis = controls.CP.lateralTuning.torque
profile = replace_field({}, str(controls.CP.carFingerprint),
(basis.latAccelFactor, basis.latAccelOffset, basis.friction), 'factor', 'custom', base * 1.2)
(basis.latAccelFactor, basis.latAccelOffset, basis.friction), 'factor', 'custom', basis.latAccelFactor * 1.2)
controls.params.put(TORQUE_KEY, json.loads(serialize_document(profile)), block=True)
command = run.drive(220, publish=reason != 'stale')
if reason == 'stale':
self.assertGreater(run.now - run.last_publication, MAX_AGE_NS)
self.assertTrue(command.latActive, 'Learning source loss must not manufacture a driving fault')
self.assertIs(controls.torque_host, host)
self.assertEqual(controls.LaC.torque_params.latAccelFactor, float32(base * (1.2 if reason == 'manual' else 1.)))
self.assertEqual(host.selected.source.value, 'user' if reason == 'manual' else host.native_base.source.value)
if reason in ('reset', 'off', 'corrupt', 'manual'):
run.producer = run.new_producer()
self.assertIsNone(run.producer.result, 'Changed saved identity cannot recover an earlier process fit')
if reason in ('off', 'corrupt', 'manual'):
self.assertIsNone(run.producer.identity)
for tick in range(71, 101):
command, _ = live_frame(controls, tick, 'b' * 32 if reason == 'restart' else 'a' * 32,
result=None if reason == 'restart' else result, validation=None if reason == 'restart' else validation,
publish=False if reason == 'stale' else None)
self.assertEqual(runtime.applied_coordinates, (0., 0.))
self.assertTrue(command.latActive, 'Learning loss must not create a driving fault')
self.assertEqual(controls.CP.as_builder().to_bytes(), before_cp)
self.assertEqual(controls.LaC.torque_params.to_dict(), tune)
if reason in ('reset', 'manual'):
self.assertNotEqual(runtime.identity, result.profile.identity)
def test_real_controller_bounds_and_authority_are_independent_of_qualified_wire(self):
with joined(self, native_ack=True) as run:
controls = run.controls
with controls_for(self.params(), 'standard', True) as controls:
before_cp = controls.CP.as_builder().to_bytes()
self.assertEqual(run.fit().status, 'qualified_fit')
commands = [run.step() for _ in range(100)]
self.assertIsNotNone(controls.torque_host)
self.assertTrue(all(math.isfinite(command.actuators.torque) and
abs(command.actuators.torque) <= controls.LaC.steer_max for command in commands))
for health in ({'native_allowed': False}, {'can_valid': False}, {'can_timeout': True}, {'fault': True},
{'active': False, 'enabled': False}):
command = run.step(**health)
result, validation = current_fit(controls)
command = apply_fit(controls, result, validation)
self.assertTrue(command.latActive)
self.assertLessEqual(abs(command.actuators.torque), controls.LaC.steer_max)
runtime = controls.global_learning
# A fitted correction never supplies a missing native authority witness.
runtime.prepare(controls.sm, now_ns=BASE + 700_000_000, active=True, healthy=True,
native_required=True, native_witness=None)
self.assertEqual(runtime.applied_coordinates, (0., 0.))
inactive_integral = controls.LaC.pid.i
for tick, health in enumerate(({'fault': True}, {'active': False, 'enabled': False}), 71):
command, _ = live_frame(controls, tick, 'a' * 32, result=result, validation=validation, **health)
self.assertFalse(command.latActive)
self.assertEqual(command.actuators.torque, 0.)
self.assertEqual(controls.LaC.pid.i, 0.)
# A qualified sender cannot escape consumer factor or identity admission.
for field, value in (('latAccelFactor', float('nan')), ('latAccelFactor', 100.), ('acceptancePolicyId', 'unknown')):
while run.tick % 25:
run.step()
command = run.step(change=lambda state, key=field, new=value: setattr(state, key, new))
self.assertEqual(controls.torque_host.selected, controls.torque_host.native_base)
self.assertEqual(controls.LaC.pid.i, inactive_integral, 'Inactive baseline PID state must not advance')
# A sender cannot escape stage bounds or validation identity.
invalid = replace(result.profile, coefficients=(100., 100.))
invalid_profiles = (
invalid,
replace(result.profile, coefficients=(math.nan, 0.)),
*(replace(result.profile, parameters=(replace(result.profile.parameters[0], **changes),
result.profile.parameters[1]))
for changes in ({'low': math.inf}, {'low': 1., 'high': -1.}, {'max_step': 0.}, {'slew_per_s': 0.})),
)
for invalid in invalid_profiles:
with self.subTest(invalid=invalid), self.assertRaises(ValueError):
result_message(replace(result, profile=invalid), 'invalid-sender', 1, BASE + 750_000_000,
replay_validation=replace(validation, profile=invalid))
def corrupt_wire(event):
event.autoSteeringLearning.learning.runtimeProfile.coefficients = [100., 100.]
for tick in range(73, 80):
command, _ = live_frame(controls, tick, 'a' * 32, result=result,
validation=validation, publish=tick == 75, wire_mutator=corrupt_wire)
self.assertTrue(math.isfinite(command.actuators.torque))
self.assertEqual(controls.CP.as_builder().to_bytes(), before_cp, 'Automatic fitting must never change native safety configuration')
for vehicle, mode in ((HKG.HYUNDAI_IONIQ_6, ControllerMode.STARPILOT),
(GM.CHEVROLET_SILVERADO, ControllerMode.STANDARD)):
with self.subTest(vehicle=vehicle), OpenpilotPrefix():
cp = interfaces[vehicle].get_non_essential_params(vehicle)
params = Params()
if policy_for(cp) is not None:
params.put(CONTROLLER_KEY, json.loads(replace_mode(None, cp, mode)), block=True)
else:
self.assertIsNone(params.get(CONTROLLER_KEY))
params.put(PREFERENCE_KEY, replace_preference(None, cp, True), block=True)
consumer = AutomaticLearningConsumer(cp, interfaces[vehicle](cp), mode, params)
self.assertFalse(consumer.supported, 'Saved On cannot qualify a rich or fixed custom conversion')
self.assertLessEqual(abs(command.actuators.torque), controls.LaC.steer_max)
self.assertEqual(runtime.applied_coordinates, (0., 0.))
self.assertEqual(controls.CP.as_builder().to_bytes(), before_cp)
@@ -9,9 +9,11 @@ import capnp
from openpilot.cereal import custom, log, messaging
from openpilot.starpilot.flm.learning_source import SERVICE
from openpilot.starpilot.flm.tests import test_automatic_integration as integration
from tools.ci.custom_schema_contract import describe, validate
NOW = 400_000_000_000
# Exact occupied prefix from c2109496a72e85ca702ecef7aff53ff45adcfb8c,
# cereal/custom.capnp. No learner pointer existed in these recordings.
HISTORICAL_CAR_PARAMS = """@0xb526ba661d550a59;
@@ -134,7 +136,7 @@ class TestAutomaticLearningWire(unittest.TestCase):
old_bytes = old.to_bytes()
readers = []
for tick in range(100):
now = integration.NOW + tick * 10_000_000
now = NOW + tick * 10_000_000
if tick % 25 == 0:
event = messaging.new_message(SERVICE, valid=True)
with custom.StarPilotCarParams.from_bytes(old_bytes) as envelope:
@@ -178,9 +180,9 @@ class TestAutomaticLearningWire(unittest.TestCase):
self.assertIsNotNone(identity)
old = historical.AutoSteeringLearning.new_message(
version=1, status='qualifiedFit', identity=identity, session='transient-c204', sequence=1,
observedMonoTime=integration.NOW, validUntilMonoTime=integration.NOW + 500_000_000,
sourceWindowStartMonoTime=integration.NOW - 10_000_000_000,
sourceWindowEndMonoTime=integration.NOW - 1_000_000_000,
observedMonoTime=NOW, validUntilMonoTime=NOW + 500_000_000,
sourceWindowStartMonoTime=NOW - 10_000_000_000,
sourceWindowEndMonoTime=NOW - 1_000_000_000,
family='linearFactor', latAccelFactor=native_factor * 1.05,
acceptancePolicyId=next(iter(legacy.policies)),
trainWindows=10, heldoutWindows=8, trainBaselineLoss=.1, trainCandidateLoss=.09,
@@ -191,7 +193,7 @@ class TestAutomaticLearningWire(unittest.TestCase):
old_bytes = old.to_bytes()
readers = []
for tick in range(50):
now = integration.NOW + tick * 10_000_000
now = NOW + tick * 10_000_000
if tick % 25 == 0:
event = messaging.new_message(SERVICE, valid=True)
with custom.StarPilotCarParams.from_bytes(old_bytes) as envelope:
@@ -0,0 +1,263 @@
"""Explicit V2 live calibrated-frame lifecycle; V1 exact-basis remains supported."""
import math
from types import SimpleNamespace
import pytest
from openpilot.cereal import messaging
from openpilot.starpilot.flm.calibration_lifecycle import ProducerCalibrationLifecycle, ConsumerCalibrationLifecycle
from openpilot.starpilot.flm.learning_wire import write_source_receipt, read_source_receipt
from openpilot.starpilot.flm.runtime_adapter_registry import SourceState, read_source
from openpilot.starpilot.flm.tests.test_fixed_manual_baseline import fixed_controls, BASE
def calibration(*, basis=(0., 0., 0.), blocks=50, status='calibrated', spread=(0., 0., 0.)):
return SimpleNamespace(rpyCalib=basis, validBlocks=blocks, calStatus=status, rpyCalibSpread=spread)
def admitted(consumer, message, generation):
consumer.observe(message, current=True)
return consumer.admit(generation, BASE)
def producer():
values = iter(('1' * 32, '2' * 32, '3' * 32))
return ProducerCalibrationLifecycle(lambda: next(values))
def test_live_estimate_drift_does_not_change_frame_generation():
owner = producer()
first = owner.observe(calibration(blocks=5), current=True)[0]
for tick in range(1600):
result = owner.observe(calibration(basis=(0., tick * 1e-6, -tick * 1e-6),
blocks=min(50, 5 + tick // 100)), current=True)
assert result[0] == first
assert result[1][1] == tick * 1e-6 # Actual live estimate still carried.
@pytest.mark.parametrize('message,current', [
(calibration(status='recalibrating', blocks=1), True),
(calibration(status='invalid'), True), (calibration(blocks=4), True),
(calibration(basis=(float('nan'), 0., 0.)), True),
(calibration(spread=(0., math.radians(4) + 1e-9, 0.)), True),
(calibration(spread=(0., 0., math.radians(2) + 1e-9)), True),
(calibration(), False),
])
def test_lifecycle_interruption_rotates_and_consumer_cannot_resume_old_generation(message, current):
owner = producer()
first = owner.observe(calibration(), current=True)[0]
consumer = ConsumerCalibrationLifecycle()
assert admitted(consumer, calibration(), first)
assert owner.observe(message, current=current) is None
if current:
assert not admitted(consumer, message, first)
else:
consumer.reject() # Core original-source stale guard performs this.
second = owner.observe(calibration(), current=True)[0]
assert second != first
assert not admitted(consumer, calibration(), first)
assert admitted(consumer, calibration(), second)
def test_monotonic_blocks_regression_is_material_reset_even_when_status_claims_calibrated():
owner = producer()
first = owner.observe(calibration(), current=True)[0]
consumer = ConsumerCalibrationLifecycle()
assert admitted(consumer, calibration(), first)
next_frame = owner.observe(calibration(blocks=5), current=True)[0]
assert next_frame != first
assert not admitted(consumer, calibration(blocks=5), first)
assert admitted(consumer, calibration(blocks=5), next_frame)
def test_exact_calibrationd_spread_boundary_and_malformed_vectors():
owner = producer()
assert owner.observe(calibration(spread=(0., math.radians(4), math.radians(2))), current=True)
assert owner.observe(calibration(spread=(0., -1., 0.)), current=True) is None
assert owner.observe(calibration(blocks=51), current=True) is None
def test_v1_exact_basis_and_v2_generation_identity_and_real_wire_roundtrip():
with fixed_controls() as (controls, _):
args = {'now_ns': BASE, 'vehicle_session': 'lifecycle-test'}
first = read_source(controls.CP, controls.params, calibration=(0., 0., 0.), **args)
drift = read_source(controls.CP, controls.params, calibration=(0., .001, 0.), **args)
assert first.enabled and first.source_epoch != drift.source_epoch
v2 = read_source(controls.CP, controls.params, calibration=(0., 0., 0.),
calibration_generation='a' * 32, **args)
updated = read_source(controls.CP, controls.params, calibration=(0., .001, 0.),
calibration_generation='a' * 32, **args)
new_drive = read_source(controls.CP, controls.params, calibration=(0., .001, 0.),
calibration_generation='b' * 32, **args)
assert v2.enabled and v2.source_epoch == updated.source_epoch
assert v2.calibration != updated.calibration
assert new_drive.source_epoch != v2.source_epoch
for source, version in ((first, 1), (v2, 2)):
event = messaging.new_message('autoSteeringLearning', valid=True)
write_source_receipt(event, source)
assert event.autoSteeringLearning.learning.sourceReceipt.version == version
# Native serialized reader and original generic builder paths.
with _reader(event) as reader:
for value in (event, reader):
assert read_source_receipt(value, now_ns=BASE, cp_sha256=source.cp_sha256) == source
# Old lifecycle never attaches via current_sources(identity, old_epoch).
assert v2.source_epoch != new_drive.source_epoch
def _reader(event):
from contextlib import nullcontext
return nullcontext(messaging.log_from_bytes(event.to_bytes()))
@pytest.mark.parametrize('generation', ['', 'x' * 32, 'A' * 32, 'a' * 31, 'a' * 33])
def test_malformed_v2_generation_never_admits(generation):
source = SourceState(True, False, '0', 'c' * 64, BASE, BASE + 500_000_000,
'd' * 64, (0., 0., 0.), 'a' * 32)
event = messaging.new_message('autoSteeringLearning', valid=True)
write_source_receipt(event, source)
event.autoSteeringLearning.learning.sourceReceipt.calibrationGeneration = generation
for value in (event, messaging.log_from_bytes(event.to_bytes())):
assert read_source_receipt(value, now_ns=BASE, cp_sha256=source.cp_sha256) is None
def test_retired_generation_cannot_return_after_later_generation_also_resets():
consumer = ConsumerCalibrationLifecycle()
assert admitted(consumer, calibration(), 'a' * 32)
consumer.reject()
assert admitted(consumer, calibration(), 'b' * 32)
consumer.reject()
assert not admitted(consumer, calibration(), 'a' * 32)
assert not admitted(consumer, calibration(), 'b' * 32)
assert admitted(consumer, calibration(), 'c' * 32)
@pytest.mark.parametrize('token', ['', 'a', 'A' * 32, 'x' * 32, None])
def test_producer_cannot_publish_malformed_token(token):
owner = ProducerCalibrationLifecycle(lambda: token)
assert owner.observe(calibration(), current=True) is None
def test_producer_cannot_recycle_retired_token():
owner = ProducerCalibrationLifecycle(lambda: 'a' * 32)
assert owner.observe(calibration(), current=True)
owner.observe(calibration(status='invalid'), current=True)
assert owner.observe(calibration(), current=True) is None
@pytest.mark.parametrize('blocks', [True, 5.1, '5', None])
def test_malformed_block_count_cannot_be_silently_coerced(blocks):
assert producer().observe(calibration(blocks=blocks), current=True) is None
def test_token_factory_error_is_fail_closed():
def failed():
raise RuntimeError('unavailable token')
assert ProducerCalibrationLifecycle(failed).observe(calibration(), current=True) is None
@pytest.mark.parametrize('generation', [None, 0, False, [], 'invalid'])
def test_read_source_does_not_fall_back_to_v1_for_malformed_generation(generation):
with fixed_controls() as (controls, _):
source = read_source(controls.CP, controls.params, now_ns=BASE, calibration=(0., 0., 0.),
vehicle_session='lifecycle-test', calibration_generation=generation)
assert not source.enabled and not source.source_epoch
def test_first_invalid_calibration_captures_fresh_unadmitted_generation():
consumer = ConsumerCalibrationLifecycle()
assert consumer.capture('a' * 32)
consumer.observe(calibration(status='invalid'), current=True, now_ns=BASE)
consumer.observe(calibration(), current=True, now_ns=BASE + 10_000_000)
assert not consumer.admit('a' * 32, BASE + 1)
assert consumer.admit('b' * 32, BASE + 1)
def test_first_invalid_without_receipt_rejects_old_receipt_observation():
consumer = ConsumerCalibrationLifecycle()
consumer.observe(calibration(), current=False, now_ns=BASE)
consumer.observe(calibration(), current=True, now_ns=BASE + 10_000_000)
assert not consumer.admit('a' * 32, BASE)
assert not consumer.admit('a' * 32, BASE - 1)
assert consumer.admit('b' * 32, BASE + 1)
def test_generation_switch_alone_retires_previous_token():
consumer = ConsumerCalibrationLifecycle()
consumer.observe(calibration(), current=True)
assert consumer.admit('a' * 32, BASE)
assert consumer.admit('b' * 32, BASE + 1)
assert not consumer.admit('a' * 32, BASE + 2)
def test_actual_core_first_invalid_receipt_then_same_generation_cannot_resume():
from openpilot.starpilot.flm.tests.test_live_calibration_paths import live_frame
with fixed_controls() as (controls, _):
runtime = controls.global_learning
live_frame(controls, 0, 'a' * 32, status='recalibrating', blocks=1, healthy=False)
assert 'a' * 32 in runtime._calibration_lifecycle.rejected
for tick in range(1, 31):
live_frame(controls, tick, 'a' * 32)
assert not runtime.eligible
assert not runtime._calibration_lifecycle.admit('a' * 32, 1_000_000_000_000)
for tick in range(31, 91):
live_frame(controls, tick, 'b' * 32)
assert runtime._calibration_lifecycle.generation == 'b' * 32
assert 'b' * 32 not in runtime._calibration_lifecycle.rejected
assert runtime.eligible
def test_actual_main_source_reader_reuses_lifecycle_for_cache_and_persistence():
from unittest import mock
from openpilot.starpilot.flm import steerlearnerd
with fixed_controls() as (controls, _):
cp = controls.CP.as_builder()
cp.carVin = 'KMHM54AA6PA000001'
controls.params.put('CarParams', cp.to_bytes(), block=True)
cal = calibration()
sm = type('Sources', (), {
'__getitem__': lambda self, key: cal,
'all_checks': lambda self, keys: True,
})()
sm.logMonoTime = {'extrinsicsCalibration': BASE}
sm.recv_time = {'extrinsicsCalibration': BASE / 1e9}
with mock.patch.object(steerlearnerd, 'Params', return_value=controls.params), \
mock.patch.object(steerlearnerd.messaging, 'SubMaster', return_value=sm), \
mock.patch.object(steerlearnerd.messaging, 'sub_sock'), \
mock.patch.object(steerlearnerd.messaging, 'PubMaster'), \
mock.patch.object(steerlearnerd, 'run') as run, \
mock.patch.object(steerlearnerd.os, 'sched_setscheduler', create=True), \
mock.patch.object(steerlearnerd.os, 'nice'):
steerlearnerd.main()
source_reader, persistence = run.call_args.args[7], run.call_args.args[11]
first = source_reader(BASE)
assert first.enabled and first.calibration_generation
cal.rpyCalib = (0., .001, -.001)
sm.logMonoTime['extrinsicsCalibration'] = BASE + 250_000_000
sm.recv_time['extrinsicsCalibration'] = (BASE + 250_000_000) / 1e9
refined = source_reader(BASE + 250_000_000)
assert refined.source_epoch == first.source_epoch
assert refined.calibration != first.calibration
with mock.patch.object(steerlearnerd.time, 'monotonic_ns', return_value=BASE + 250_000_000):
assert persistence.current_sources('current-identity', first.source_epoch) == ('current-identity', True, False, first.reset)
cal.calStatus, cal.validBlocks = 'recalibrating', 1
assert not source_reader(BASE + 260_000_000).enabled
cal.calStatus, cal.validBlocks = 'calibrated', 50
fresh = source_reader(BASE + 270_000_000)
assert fresh.enabled and fresh.calibration_generation != first.calibration_generation
with mock.patch.object(steerlearnerd.time, 'monotonic_ns', return_value=BASE + 270_000_000):
assert persistence.current_sources('old-identity', first.source_epoch)[0] == ''
assert persistence.current_sources('fresh-identity', fresh.source_epoch)[0] == 'fresh-identity'
def test_oversized_calibration_vectors_are_rejected_before_iteration():
class Oversized:
def __len__(self):
return 1_000_000
def __iter__(self):
raise AssertionError('unbounded calibration work entered the control loop')
owner = producer()
assert owner.observe(calibration(basis=Oversized()), current=True) is None
assert owner.observe(calibration(spread=Oversized()), current=True) is None
@@ -0,0 +1,191 @@
"""Actual Controls admission and physical observer with live calibration estimates."""
from types import SimpleNamespace
import math
from unittest import mock
import pytest
from opendbc.car.car_helpers import interfaces
from opendbc.car.toyota.values import CAR
from openpilot.cereal import messaging
from openpilot.starpilot.flm.learning_wire import result_message, write_source_receipt
from openpilot.starpilot.flm.runtime_adapter_registry import read_source, physical_observer
from openpilot.starpilot.flm.tests.test_registered_controls_global import controls_for, feed
BASE = 600_000_000_000
def live_frame(controls, tick, generation, *, status='calibrated', blocks=50,
basis=(0., 0., 0.), healthy=True, publish=None, result=None, validation=None, wire_mutator=None, **health):
now = BASE + tick * 10_000_000
original_message = messaging.new_message
def packet(service, *args, **kwargs):
event = original_message(service, *args, **kwargs)
if service == 'carState':
event.carState.steeringAngleDeg = math.degrees(controls.VM.get_steer_from_curvature(-.0002, 30., 0.))
if service == 'extrinsicsCalibration':
event.extrinsicsCalibration.calStatus = status
event.extrinsicsCalibration.validBlocks = blocks
event.extrinsicsCalibration.rpyCalib = basis
event.extrinsicsCalibration.rpyCalibSpread = [0., .001, .001]
return event
# Keep the real 4 Hz calibration/source cadence, rather than injecting a
# second 100 Hz calibration stream that correctly fails frequency checks.
with mock.patch.object(messaging, 'new_message', side_effect=packet):
feed(controls, now, tick, speed=30., calibration=basis, **health)
if publish is None:
publish = tick % 25 == 0
if publish:
source = read_source(controls.CP, controls.params, now_ns=now, calibration=basis,
calibration_generation=generation, vehicle_session='current-test-drive')
event = result_message(result, 'calibration-lifecycle-regression', tick + 1, now, replay_validation=validation)
event.logMonoTime = now
write_source_receipt(event, source)
if wire_mutator is not None:
wire_mutator(event)
controls.sm.update_msgs(now / 1e9, [messaging.log_from_bytes(event.to_bytes())])
controls._test_calibration_source_generation = generation
if not healthy:
controls.global_learning.prepare(controls.sm, now_ns=now, active=True, healthy=False)
return
with mock.patch('openpilot.selfdrive.controls.controlsd.time.monotonic_ns', return_value=now):
return controls.state_control()
def test_actual_controls_keeps_identity_through_eighty_seconds_of_calibration_refinement():
cp = interfaces[CAR.TOYOTA_RAV4_TSS2].get_non_essential_params(CAR.TOYOTA_RAV4_TSS2)
with controls_for(cp, 'standard', True) as controls:
identity = None
tune = controls.LaC.torque_params.to_dict()
for tick in range(8000):
# Refine the estimate every five seconds, just as in the reported route.
basis = (0., (tick // 500) * .00002, -(tick // 500) * .00002)
live_frame(controls, tick, 'a' * 32, basis=basis, publish=tick % 25 == 0)
runtime = controls.global_learning
if tick >= 30:
assert runtime.eligible and runtime.reason != 'qualified', (tick, runtime.reason, vars(runtime._calibration_lifecycle))
identity = runtime.identity if identity is None else identity
assert runtime.identity == identity
assert controls.LaC.torque_params.to_dict() == tune
assert controls.global_learning.applied_coordinates == (0., 0.)
@pytest.mark.parametrize('initial', [True, False])
def test_actual_core_rejects_old_generation_after_calibration_failure_even_on_other_health_return(initial):
cp = interfaces[CAR.TOYOTA_RAV4_TSS2].get_non_essential_params(CAR.TOYOTA_RAV4_TSS2)
with controls_for(cp, 'standard', True) as controls:
if not initial:
for tick in range(30):
live_frame(controls, tick, 'a' * 32)
start = 0 if initial else 30
live_frame(controls, start, 'a' * 32, status='recalibrating', blocks=1, healthy=False)
for tick in range(start + 1, start + 31):
live_frame(controls, tick, 'a' * 32)
assert not controls.global_learning.eligible
for tick in range(start + 31, start + 91):
live_frame(controls, tick, 'b' * 32)
assert controls.global_learning.eligible, (controls.global_learning.reason, vars(controls.global_learning._calibration_lifecycle))
live_frame(controls, start + 91, 'a' * 32, publish=True)
assert not controls.global_learning.eligible
def test_actual_core_rejects_calibration_block_regression_until_new_generation():
cp = interfaces[CAR.TOYOTA_RAV4_TSS2].get_non_essential_params(CAR.TOYOTA_RAV4_TSS2)
with controls_for(cp, 'standard', True) as controls:
for tick in range(30):
live_frame(controls, tick, 'a' * 32)
live_frame(controls, 30, 'a' * 32, blocks=5)
assert not controls.global_learning.eligible
for tick in range(31, 201):
live_frame(controls, tick, 'b' * 32, blocks=5)
assert controls.global_learning.eligible, (controls.global_learning.reason, vars(controls.global_learning._calibration_lifecycle))
class PhysicalSources:
def __init__(self, messages, now):
self.messages = messages
self.logMonoTime = dict.fromkeys(messages, now)
self.recv_time = dict.fromkeys(messages, now / 1e9)
def __getitem__(self, name):
return self.messages[name]
def all_checks(self, services):
return True
def physical_frame(collector, basis, now=BASE):
motion = messaging.new_message('deviceMotion').deviceMotion
motion.timestamp = now
motion.inputsOK = motion.sensorsOK = motion.posenetOK = True
for name, xyz in (('angularVelocityDevice', (.05, .1, .02)),
('orientationNED', (.01, .02, .03))):
value = getattr(motion, name)
value.valid = True
value.x, value.y, value.z = xyz
cal = messaging.new_message('extrinsicsCalibration').extrinsicsCalibration
cal.calStatus = 'calibrated'
cal.validBlocks = 50
cal.rpyCalib, cal.rpyCalibSpread = basis, [0., .001, .001]
cs = SimpleNamespace(steeringPressed=False, brakePressed=False, gasPressed=False,
regenBraking=False, buttonEvents=(), canTimeout=False, canValid=True,
steerFaultTemporary=False, steerFaultPermanent=False, accFaulted=False)
sm = PhysicalSources({'deviceMotion': motion, 'extrinsicsCalibration': cal,
'carState': cs, 'lateralDelay': SimpleNamespace(lateralDelay=.1),
'driverMonitoringState': SimpleNamespace(noResponseForceDecel=False)}, now)
cp = SimpleNamespace(steerRatio=14., wheelbase=2.9, centerToFront=1.2, mass=2000.)
with mock.patch('openpilot.starpilot.flm.runtime_adapter_registry.time.monotonic_ns', return_value=now), \
mock.patch('openpilot.starpilot.flm.runtime_adapter_registry.time.clock_gettime_ns', return_value=now):
return physical_observer(sm, cp, collector, now)
def test_actual_physical_observer_updates_live_transform_without_splitting_generation():
command = SimpleNamespace(native_identity='current-native', state_ns=BASE,
replay_inputs=(('vm.sR', 14.), ('vm.cF', 100000.), ('vm.cR', 100000.),
('vEgo', 30.), ('steeringAngleDeg', 0.)))
collector = SimpleNamespace(context=('identity', 1, 'normalized_torque', 'stage', 'response', 'a' * 64),
_calibration_generation='b' * 32, _learning_current_native_allowed=True,
ready=[(command, SimpleNamespace(native_allowed=True, apply_ns=BASE))],
adapter=SimpleNamespace(command_domain='normalized_torque'))
first = physical_frame(collector, (0., 0., 0.))
next_frame = physical_frame(collector, (0., .002, .001))
assert first is not None and next_frame is not None
assert first.calibration == next_frame.calibration
assert first.yaw_rate != next_frame.yaw_rate
assert first.roll_rad != next_frame.roll_rad
assert first.native_allowed and next_frame.native_allowed
def test_new_generation_at_block_regression_is_not_permanently_retired():
cp = interfaces[CAR.TOYOTA_RAV4_TSS2].get_non_essential_params(CAR.TOYOTA_RAV4_TSS2)
with controls_for(cp, 'standard', True) as controls:
for tick in range(30):
live_frame(controls, tick, 'a' * 32)
live_frame(controls, 30, 'b' * 32, blocks=5, publish=True)
# The first reset observation may pause learning, but its new generation
# must be usable on a subsequent fresh receipt.
for tick in range(31, 201):
live_frame(controls, tick, 'b' * 32, blocks=5)
assert controls.global_learning.eligible, (controls.global_learning.reason, vars(controls.global_learning._calibration_lifecycle))
def test_computed_registered_correction_reaches_controller_with_live_calibration_generation():
from openpilot.starpilot.flm.tests.test_registered_accepted_profile import computed_registered_fit
cp = interfaces[CAR.TOYOTA_RAV4_TSS2].get_non_essential_params(CAR.TOYOTA_RAV4_TSS2)
with controls_for(cp, 'standard', True) as controls:
for tick in range(30):
live_frame(controls, tick, 'a' * 32)
runtime = controls.global_learning
identity = runtime.identity
tune = controls.LaC.torque_params.to_dict()
result, validation = computed_registered_fit(controls.CP, runtime, BASE + 300_000_000)
assert result.profile is not None and validation is not None
for tick in range(30, 71):
live_frame(controls, tick, 'a' * 32, basis=(0., .00002, -.00002),
result=result, validation=validation, publish=tick % 25 == 0)
assert runtime.identity == identity
assert runtime.reason == 'qualified'
assert any(value != 0 for value in runtime.applied_coordinates)
assert controls.LaC.torque_params.to_dict() == tune
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,4 +1,4 @@
#pragma once
extern const uint8_t gitversion[29];
const uint8_t gitversion[29] = "DEV-63008e94-P92eb5651-DEBUG";
const uint8_t gitversion[29] = "DEV-7e63c553-P92eb5651-DEBUG";
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
DEV-63008e94-P92eb5651-DEBUG
DEV-7e63c553-P92eb5651-DEBUG
+1 -1
View File
@@ -1 +1 @@
{"protected_inventory_sha256": "0f7ccdbebc3383a1c589b1b73d5e36f6083dc63c14a3d4a25a6f994834ed0fbc", "schema_version": 1}
{"protected_inventory_sha256": "7598dc8a2d426e413747c501920f079c3426a7c37816fcc80fadaccb6ffffcb0", "schema_version": 1}