mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
487dfc4bf4
Merge bounded control/output FIFO heads using original publisher headers. Preserve original age, native, source, overflow and malformed-input checks; retain producer order after joins. Validated with actual pinned cloud dependencies and fresh-head regression tests.
238 lines
13 KiB
Python
238 lines
13 KiB
Python
"""Actual optional writer/collector and typed application, never control authority."""
|
|
from openpilot.starpilot.flm.tests._drain_fixture import separate_drain_fixture
|
|
from copy import deepcopy
|
|
from dataclasses import replace
|
|
from types import SimpleNamespace
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
from openpilot.cereal import messaging
|
|
from openpilot.starpilot.flm.adaptive_profile import ApplicationOutcome, Parameter, Profile, Result
|
|
from openpilot.starpilot.flm.application_wire import write_application
|
|
from openpilot.starpilot.flm.fullrate_collector import AppliedEvidence, ControlEvidence, FullrateCollector, RawPoint
|
|
from openpilot.starpilot.flm.training_report import TrainingReporter, read_report, READ_MAX_BYTES
|
|
|
|
NOW = 40_000_000_000
|
|
IDENTITY = 'a' * 64
|
|
|
|
|
|
def evidence(now=NOW):
|
|
return ControlEvidence(
|
|
identity=IDENTITY, generation=0, control_ns=now, state_ns=now - 1,
|
|
domain='torque', stage_revision='stage', response_revision='response', requested=.2,
|
|
features=(1.,), baseline_prediction=.18, enabled=True, manual=False, active=True, valid=True,
|
|
override=False, fault=False, saturated=False, limited=False, model_wobble=False, controller_chatter=False,
|
|
applied_coordinates=(.02, 0.), applied_profile_sequence=42,
|
|
source_epoch="epoch", source_receipt_ns=now - 1_000_000, baseline_revision="baseline",
|
|
applied_profile_observed_ns=now - 1_000_000, applied_profile_expires_ns=now + 400_000_000,
|
|
)
|
|
|
|
|
|
def fixture(tmp_path):
|
|
adapter = SimpleNamespace(raw_collection=True, objective=SimpleNamespace(required_cohorts=('high.left.steady',)))
|
|
collector = FullrateCollector(adapter)
|
|
control = evidence()
|
|
applied = AppliedEvidence(NOW, NOW - 1, NOW + 1, 'torque', .2, True, True, False, False, False, True)
|
|
collector.context = (
|
|
control.identity, control.generation, control.domain, control.stage_revision, control.response_revision,
|
|
control.source_epoch, control.controller_mode, control.policy, control.baseline_revision, control.controller_revision,
|
|
control.turn_assist,
|
|
)
|
|
collector.cohort = 'high.left.steady'
|
|
physical_context = ('physical', 'model', 'lag', 0)
|
|
collector.physical_context = physical_context
|
|
# Actual collector seal constructs a distinct RawBlock and consumes its buffer.
|
|
for tick in range(40):
|
|
stamp = NOW - 400_000_000 + tick * 10_000_000
|
|
collector.raw_points.append(RawPoint(stamp, stamp + 1, stamp + 2, control, applied, {}, physical_context))
|
|
collector._seal_raw()
|
|
assert len(collector.blocks) == 1 and not collector.raw_points
|
|
for tick in range(12):
|
|
stamp = NOW - 120_000_000 + tick * 10_000_000
|
|
collector.raw_points.append(RawPoint(stamp, stamp + 1, stamp + 2, control, applied, {}, physical_context))
|
|
collector.ready.append((control, applied))
|
|
collector.reason = 'collecting_maneuver'
|
|
collector.split('test_interruption')
|
|
# Restore the recorded ready sample after the genuine interruption.
|
|
collector.ready.append((control, applied))
|
|
for tick in range(12):
|
|
stamp = NOW - 120_000_000 + tick * 10_000_000
|
|
collector.raw_points.append(RawPoint(stamp, stamp + 1, stamp + 2, control, applied, {}, physical_context))
|
|
path = tmp_path / 'training.json'
|
|
reporter = TrainingReporter('TEST_TORQUE_CAR', path=path)
|
|
source = SimpleNamespace(enabled=True, manual=False, source_epoch='epoch', expires_ns=NOW + 400_000_000,
|
|
calibration_generation='calibration', observed_ns=NOW - 100_000_000)
|
|
return collector, control, reporter, source, path
|
|
|
|
|
|
def message(status='collecting', reason='independent_evidence'):
|
|
event = messaging.new_message('autoSteeringLearning', valid=True, logMonoTime=NOW)
|
|
event.autoSteeringLearning.learning.status = status
|
|
event.autoSteeringLearning.learning.reason = reason
|
|
return event
|
|
|
|
|
|
def application_event(control, *, observed=NOW, profile_sequence=42, coordinates=(.02, 0.), identity=IDENTITY):
|
|
event = messaging.new_message('starpilotLateralState', valid=True, logMonoTime=NOW)
|
|
write_application(event.starpilotLateralState, ApplicationOutcome(identity, profile_sequence, observed, 'applied',
|
|
coordinates, 'qualified_profile', 'baseline', 'physical'))
|
|
return event.as_reader(), control
|
|
|
|
|
|
def publish(reporter, collector, source, *, now=NOW, event=None, native=True, fit=None):
|
|
reporter.publish(now_ns=now, message=message() if event is None else event, collector=collector,
|
|
source=source, objective=('feedforward', 'high', 'steady'), pending=False,
|
|
native_current=native, fit_result=fit)
|
|
|
|
|
|
def test_actual_collector_progress_typed_application_and_bounded_writer_reader(tmp_path):
|
|
collector, control, reporter, source, path = fixture(tmp_path)
|
|
reporter.control = application_event(control)
|
|
before = deepcopy(vars(collector))
|
|
publish(reporter, collector, source)
|
|
assert vars(collector) == before
|
|
assert path.stat().st_size <= READ_MAX_BYTES
|
|
assert not path.stat().st_mode & 0o077
|
|
current = read_report(path, now_ns=NOW)['current']
|
|
assert current['progress'] == {'samples': 52, 'blocks': 1, 'requiredBlocks': 16}
|
|
assert current['application'] == {'applied': True, 'reason': 'qualified_profile'}
|
|
assert current['details']['Applied profile sequence'] == 42
|
|
assert current['details']['Applied correction coordinates'] == [.02, 0.]
|
|
assert current['details']['Capture interruptions'] == {'test_interruption': 1}
|
|
|
|
|
|
@pytest.mark.parametrize('case', ('missing', 'stale_control', 'stale_application', 'native', 'identity', 'sequence', 'coordinates'))
|
|
def test_applied_requires_current_matching_controller_evidence(tmp_path, case):
|
|
collector, control, reporter, source, path = fixture(tmp_path)
|
|
if case == 'stale_control':
|
|
control = replace(control, control_ns=NOW - 100_000_001)
|
|
reporter.control = application_event(control, observed=NOW - 500_000_001 if case == 'stale_application' else NOW,
|
|
profile_sequence=43 if case == 'sequence' else 42,
|
|
coordinates=(.03, 0.) if case == 'coordinates' else (.02, 0.))
|
|
if case == 'missing':
|
|
reporter.control = None
|
|
if case == 'identity':
|
|
collector.ready[-1] = (replace(control, identity='b' * 64), collector.ready[-1][1])
|
|
publish(reporter, collector, source, native=case != 'native')
|
|
current = read_report(path, now_ns=NOW)['current']
|
|
assert not current['application']['applied']
|
|
assert current['details']['Applied profile sequence'] == 0
|
|
assert current['details']['Applied correction coordinates'] == []
|
|
|
|
|
|
def test_qualified_fit_alone_never_claims_applied_and_stale_report_withdraws_current_claim(tmp_path):
|
|
collector, control, reporter, source, path = fixture(tmp_path)
|
|
profile = Profile(IDENTITY, 42, NOW, NOW + 500_000_000,
|
|
(Parameter('ff', 'stage', -.03, .03, .03, .005),), (.02,),
|
|
('left_train', 'right_train'), ('left_held', 'right_held'), 1., .9, (('left', .01), ('right', .01)))
|
|
result = Result('qualified', 'qualified_actual_replay', profile)
|
|
reporter.fitted(result, ('feedforward', 'high', 'steady'))
|
|
reporter.fit_attempts = 1
|
|
publish(reporter, collector, source, fit=result)
|
|
current = read_report(path, now_ns=NOW)['current']
|
|
assert current['fit'] == {'status': 'qualified', 'reason': 'qualified_actual_replay', 'trainWindows': 2, 'heldoutWindows': 2}
|
|
assert not current['application']['applied']
|
|
assert collector.fit_result is collector.result is collector.last_validation is None
|
|
reporter.control = application_event(evidence(NOW + 1_000_000_000), observed=NOW + 1_000_000_000)
|
|
collector.ready[-1] = (reporter.control[1], collector.ready[-1][1])
|
|
source.observed_ns, source.expires_ns = NOW + 1_000_000_000, NOW + 1_400_000_000
|
|
publish(reporter, collector, source, now=NOW + 1_000_000_000, fit=result)
|
|
assert read_report(path, now_ns=NOW + 1_000_000_000)['current']['application']['applied']
|
|
stale = read_report(path, now_ns=NOW + 4_000_000_000)
|
|
assert not stale['current']['fresh'] and not stale['current']['application']['applied']
|
|
assert stale['history'][-1]['applied']
|
|
|
|
|
|
def test_writer_rate_and_history_are_bounded(tmp_path):
|
|
collector, _, reporter, source, path = fixture(tmp_path)
|
|
publish(reporter, collector, source, event=message('rejected', 'rejection_0'))
|
|
before = path.read_bytes(), path.stat().st_mtime_ns, tuple(reporter.history)
|
|
publish(reporter, collector, source, now=NOW + 999_999_999, event=message('rejected', 'must_not_write'))
|
|
assert before == (path.read_bytes(), path.stat().st_mtime_ns, tuple(reporter.history))
|
|
for index in range(1, 25):
|
|
publish(reporter, collector, source, now=NOW + index * 1_000_000_000, event=message('rejected', f'rejection_{index}'))
|
|
actual = read_report(path, now_ns=NOW + 24_000_000_000)
|
|
assert len(actual['history']) == 20
|
|
assert actual['history'][0]['reason'] == 'rejection_5'
|
|
assert actual['history'][-1]['reason'] == 'rejection_24'
|
|
assert path.stat().st_size <= READ_MAX_BYTES
|
|
|
|
|
|
@pytest.mark.parametrize('operation', ('create', 'replace', 'cleanup'))
|
|
def test_reporting_io_failure_never_mutates_or_stops_collection(tmp_path, operation):
|
|
collector, _, reporter, source, _ = fixture(tmp_path)
|
|
before = deepcopy(vars(collector))
|
|
target = {'create': 'tempfile.mkstemp', 'replace': 'os.replace', 'cleanup': 'Path.unlink'}[operation]
|
|
prefix = 'openpilot.starpilot.flm.training_report.'
|
|
with mock.patch(prefix + target, side_effect=OSError('report unavailable')):
|
|
publish(reporter, collector, source)
|
|
assert vars(collector) == before
|
|
|
|
|
|
@pytest.mark.parametrize('field,value', (
|
|
('applied_profile_expires_ns', NOW - 1), ('applied_profile_observed_ns', NOW + 1),
|
|
('source_receipt_ns', NOW - 500_000_001), ('source_epoch', 'replaced'),
|
|
('baseline_revision', 'replaced'), ('enabled', False), ('active', False), ('valid', False),
|
|
('manual', True), ('fault', True),
|
|
))
|
|
def test_original_controller_profile_and_source_guards_are_not_restamped(tmp_path, field, value):
|
|
collector, control, reporter, source, path = fixture(tmp_path)
|
|
reporter.control = application_event(replace(control, **{field: value}))
|
|
publish(reporter, collector, source)
|
|
assert not read_report(path, now_ns=NOW)['current']['application']['applied']
|
|
|
|
|
|
def test_payload_error_isolated_from_learning(tmp_path):
|
|
collector, _, reporter, source, _ = fixture(tmp_path)
|
|
before = deepcopy(vars(collector))
|
|
with mock.patch('openpilot.starpilot.flm.training_report.read_application', side_effect=ValueError('bad report')):
|
|
reporter.control = application_event(evidence())
|
|
publish(reporter, collector, source)
|
|
assert vars(collector) == before
|
|
|
|
|
|
def test_actual_parent_publication_also_writes_training_snapshot(tmp_path):
|
|
from openpilot.starpilot.flm import steerlearnerd
|
|
from openpilot.starpilot.flm.runtime_adapter_registry import SourceState
|
|
from openpilot.starpilot.flm.tests.test_fullrate_pipeline import SyntheticAdapter, snapshots, serialized_control, serialized_output
|
|
|
|
class Adapter(SyntheticAdapter):
|
|
def select_objective(self, key):
|
|
pass
|
|
|
|
def prepare_control(self, row):
|
|
return row
|
|
|
|
control, applied, physical = snapshots(NOW)
|
|
source = SourceState(True, False, '0', control.source_epoch, NOW, NOW + 500_000_000,
|
|
'b' * 64, (0., 0., 0.))
|
|
sm = mock.Mock(updated={'deviceMotion': False}, logMonoTime={'deviceMotion': 0})
|
|
polls = iter((None,))
|
|
sm.update.side_effect = lambda timeout=None: None if timeout == 0 else next(polls)
|
|
fitter = mock.Mock(child=None)
|
|
fitter.poll.return_value = None
|
|
publisher = mock.Mock()
|
|
control_socket, output_socket = object(), object()
|
|
reporter = TrainingReporter('ACTUAL_PARENT', path=tmp_path / 'parent.json')
|
|
|
|
def drain(socket, callback, _receive, sink, now):
|
|
callback(serialized_control(control) if socket is control_socket else serialized_output(applied), sink, now)
|
|
|
|
with mock.patch.object(steerlearnerd, 'drain_pair', separate_drain_fixture(drain)), \
|
|
mock.patch('openpilot.starpilot.flm.native_health.current_native_health', return_value=True):
|
|
with pytest.raises(StopIteration):
|
|
steerlearnerd.run(SimpleNamespace(), sm, control_socket, output_socket, publisher,
|
|
mock.Mock(return_value=Adapter()), mock.Mock(return_value=physical), lambda now: source,
|
|
lambda row: ('ff',), lambda row: 'ff', mock.Mock(), mock.Mock(), fitter, mock.Mock(),
|
|
clock=lambda: NOW + 1_000_000, reporter=reporter)
|
|
assert publisher.send.call_count == 1
|
|
actual = read_report(reporter.path, now_ns=NOW + 1_000_000)
|
|
assert actual['current']['fresh'] and actual['current']['enabled']
|
|
assert actual['current']['progress'] == {'samples': 0, 'blocks': 0, 'requiredBlocks': 8}
|
|
assert not actual['current']['application']['applied']
|
|
assert actual['current']['details']['Vehicle'] == 'ACTUAL_PARENT'
|
|
assert reporter.control is not None
|
|
assert reporter.control[1] == control
|
|
fitter.close.assert_called_once_with('learner_parent_exit')
|