Files
StarPilot/openpilot/starpilot/ui/tests/test_slc_retention.py
T
2026-10-28 01:09:47 -04:00

401 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Saved retention policy through Maps, the native planner, and the driving UI."""
from dataclasses import replace
from pathlib import Path
import struct
from unittest.mock import Mock, patch
import pytest
from openpilot.common.params import Params
from openpilot.selfdrive.controls.lib import longitudinal_planner as native
from openpilot.selfdrive.controls.plannerd import SavedSlcLifecycle
from openpilot.starpilot.speed_limits import offset_document as od
from openpilot.starpilot.speed_limits import physical_actions
from openpilot.selfdrive.car.cruise import VCruiseHelper
from openpilot.starpilot.speed_limits.runtime import Action, Runtime
from openpilot.starpilot.speed_limits.runtime_settings import read_params
from openpilot.starpilot.speed_limits.tests.test_maps_runtime import BOOT, packet
from openpilot.starpilot.ui.onroad_compact_widgets import CompactHudRenderer
from openpilot.starpilot.ui.onroad_state import AlertSize, ObservationKind, OnroadInput, SlcActionKind, slc_controls
from openpilot.starpilot.ui.presentation import Profile
from openpilot.starpilot.ui.shell import ShellMode
from openpilot.starpilot.ui.tests.test_runtime_snapshot import NOW, RuntimeSnapshotAdapter, ui_fake
from openpilot.starpilot.ui.tests.test_slc_ui_runtime import publish_request, replay_inputs
from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed
MPH = .44704
def published_cruise_mph(mph, *, software_target=True, metric=False):
"""Expected card precision, then the Float32 carState wire seen by the planner."""
kph = round(round(mph) * 1.609344, 1) if software_target else mph * 1.609344
if software_target and metric:
kph = round(mph * 1.609344)
wire_kph, = struct.unpack('<f', struct.pack('<f', kph))
return wire_kph * (1 / 3.6) / MPH
@pytest.mark.parametrize('document', [False, True])
@pytest.mark.parametrize('offset,delta', [(-25, 0), (-30, 0), (-24, 2), (-5, 0)])
def test_negative_offsets_cannot_remove_the_native_slc_ceiling(tmp_path, document, offset, delta):
drive = RetentionDrive(tmp_path)
params = Params(str(tmp_path))
if document:
params.put('SLCOffsetSchedule', od.to_value(od.adopt_legacy(False, (offset, 0, 0, 0, 0, 0, 0))), block=True)
else:
params.put('Offset1', float(offset), block=True)
drive.runtime = Runtime(read_params(params))
drive.sm['carState'].vEgoCluster += delta
output, _, target = drive.step(0, limit=25)
adjusted = 20 * MPH if offset == -5 else 25 * MPH
assert not output.result.errors
assert not output.message.slcState.hasCeiling
assert output.message.slcState.acceptedAdjustedLimit == pytest.approx(adjusted)
assert output.message.slcState.effectiveClusterTarget == pytest.approx(adjusted)
assert target == pytest.approx(published_cruise_mph(round(adjusted / MPH)), rel=0, abs=1e-10)
@pytest.mark.parametrize('confirm', [False, True])
@pytest.mark.parametrize('source_gap', [False, True])
def test_live_offset_edits_keep_the_accepted_limit_and_native_ceiling(tmp_path, confirm, source_gap):
drive = RetentionDrive(tmp_path, confirm=confirm)
params = Params(str(tmp_path))
lifecycle = SavedSlcLifecycle(params, drive.cp, {'SLC_REPLAY_RUNTIME': '1'})
runtime, _ = lifecycle.refresh(NOW)
assert runtime is not None
drive.runtime = runtime
first, _, _ = drive.step(0)
output, _, _ = drive.step(50, request=Action(runtime.session_id, 1, first.message.slcState.decisionId,
first.message.slcState.presentationId, 'accept') if confirm else None)
accepted = output.result.state.acceptance.accepted
session = runtime.session_id
for milliseconds, offset in ((1000, 5), (2000, -5)):
params.put('SLCOffsetSchedule', od.to_value(od.adopt_legacy(False, (0, 0, 0, offset, 0, 0, 0))), block=True)
receipt = object()
actions, cruise = [object()], [receipt]
refreshed, changed = lifecycle.refresh(NOW + milliseconds * 1_000_000, pending_actions=actions, pending_cruise=cruise)
assert changed and refreshed is runtime
assert not actions and cruise == [receipt]
output, _, target = drive.step(milliseconds, received=not source_gap)
assert not output.result.errors
assert runtime.session_id == session
assert output.result.state.acceptance.accepted == accepted
assert not output.message.slcState.hasPending
assert not output.message.slcState.hasCeiling
assert target == pytest.approx(published_cruise_mph(45 if source_gap else 45 + offset), rel=0, abs=1e-10)
if source_gap:
assert drive.step(2500)[2] == pytest.approx(published_cruise_mph(40), rel=0, abs=1e-10)
@pytest.mark.parametrize('accepted_first', [False, True])
def test_offset_edits_invalidate_old_prompts_without_accepting_a_new_limit(tmp_path, accepted_first):
drive = RetentionDrive(tmp_path, confirm=True)
params = Params(str(tmp_path))
lifecycle = SavedSlcLifecycle(params, drive.cp, {'SLC_REPLAY_RUNTIME': '1'})
runtime, _ = lifecycle.refresh(NOW)
assert runtime is not None
drive.runtime = runtime
first, _, _ = drive.step(0)
if accepted_first:
drive.step(50, request=Action(runtime.session_id, 1, first.message.slcState.decisionId, first.message.slcState.presentationId, 'accept'))
old, _, _ = drive.step(100, limit=50)
old_id = old.message.slcState.decisionId
params.put('SLCOffsetSchedule', od.to_value(od.adopt_legacy(False, (0, 0, 0, 5, 0, 0, 0))), block=True)
assert lifecycle.refresh(NOW + 1_000_000_000)[0] is runtime
stale = Action(runtime.session_id, 2, old_id, old.message.slcState.presentationId, 'accept')
output, _, target = drive.step(1000, limit=50, request=stale)
assert not output.result.errors
assert output.message.slcState.hasPending
assert output.message.slcState.decisionId != old_id
assert output.message.slcState.presentationId != old.message.slcState.presentationId
assert output.message.slcState.hasAccepted == accepted_first
assert target == pytest.approx(published_cruise_mph(45 if accepted_first else 70, software_target=accepted_first), rel=0, abs=1e-10)
def test_offset_edits_preserve_rejected_limits(tmp_path):
drive = RetentionDrive(tmp_path, confirm=True)
params = Params(str(tmp_path))
lifecycle = SavedSlcLifecycle(params, drive.cp, {'SLC_REPLAY_RUNTIME': '1'})
runtime, _ = lifecycle.refresh(NOW)
assert runtime is not None
drive.runtime = runtime
first, _, _ = drive.step(0)
drive.step(50, request=Action(runtime.session_id, 1, first.message.slcState.decisionId, first.message.slcState.presentationId, 'accept'))
proposed, _, _ = drive.step(100, limit=50)
drive.step(150, limit=50, request=Action(runtime.session_id, 2, proposed.message.slcState.decisionId, proposed.message.slcState.presentationId, 'reject'))
rejected = runtime.state.acceptance.rejected
params.put('SLCOffsetSchedule', od.to_value(od.adopt_legacy(False, (0, 0, 0, 5, 0, 0, 0))), block=True)
assert lifecycle.refresh(NOW + 1_000_000_000)[0] is runtime
output, _, target = drive.step(1000, limit=50)
assert not output.message.slcState.hasPending
assert output.result.state.acceptance.rejected == rejected
assert output.message.slcState.acceptedSpeedLimit == pytest.approx(45 * MPH)
assert target == pytest.approx(published_cruise_mph(45), rel=0, abs=1e-10)
class RetentionDrive:
def __init__(self, path, *, keep=True, confirm=False, dashboard=False):
params = Params(str(path))
for key, value in {'SpeedLimitController': True, 'SLCConfirmation': confirm,
'SLCConfirmationHigher': False, 'SLCConfirmationLower': False,
'SLCPriority1': 'Map Data', 'SLCPriority2': 'Dashboard' if dashboard else 'Map Data',
'SLCFallback': 2 if keep else 0}.items():
params.put(key, value, block=True)
self.runtime = Runtime(read_params(params), session_id='retention-drive')
assert not self.runtime.settings.errors
self.sm, self.cp = replay_inputs()
car = self.sm['carState']
car.vEgo = car.vEgoCluster = 45 * MPH
car.vCruise = car.vCruiseCluster = 70 * MPH * 3.6
self.sm['slcDashboardObservation'].speedMps = 70 * MPH
self.ui = ui_fake()
self.ui.CP = self.cp
self.ui.sm['carState'].canValid, self.ui.sm['carState'].canTimeout = True, False
self.ui.sm['carControl'].enabled = self.ui.sm['selfdriveState'].enabled = True
self.ui.sm['carControl'].longActive = True
self.adapter = RuntimeSnapshotAdapter(self.ui)
self.planner = native.LongitudinalPlanner(self.cp, init_v=45 * MPH)
def step(self, milliseconds, *, received=True, alive=True, status='matchedLimit', limit=45, request=None, **changes):
now, boot = NOW + milliseconds * 1_000_000, BOOT + milliseconds * 1_000_000
previous_stamp = self.sm.logMonoTime.get('mapdOut', BOOT)
self.sm.advance(now)
dashboard = self.sm['slcDashboardObservation']
dashboard.observedMonoTime, dashboard.validUntilMonoTime = now - 1, now + 1_000_000_000
if received:
event = packet(speedLimit=limit * MPH, computedMonoTime=boot, gpsMonoTime=boot - 100_000_000)
event.logMonoTime, event.mapdOut.roadStatus = boot, status
if status in ('noGps', 'noCoverage', 'noMatch'):
event.valid = False
event.mapdOut.speedLimit, event.mapdOut.wayId, event.mapdOut.tileLoaded = 0., 0, False
if status == 'noGps':
event.mapdOut.gpsSource, event.mapdOut.gpsMonoTime, event.mapdOut.sourceGeneration = 'none', 0, 2
if status == 'matchedNoLimit':
event.mapdOut.speedLimit = 0.
for field, value in changes.items():
setattr(event.mapdOut, field, value)
self.sm.data['mapdOut'] = event.mapdOut
self.sm.valid['mapdOut'], self.sm.logMonoTime['mapdOut'] = bool(event.valid), boot
else:
self.sm.logMonoTime['mapdOut'] = previous_stamp
self.sm.updated['mapdOut'], self.sm.alive['mapdOut'] = received, alive
output = self.runtime.step(self.sm, self.cp, now_ns=now, now_boot_ns=boot, request=request)
if output.command is not None:
helper = VCruiseHelper(self.cp)
helper.apply_slc_target(output.command.slcCruiseCommand.targetMps, self.ui.is_metric)
self.sm['carState'].vCruise = self.sm['carState'].vCruiseCluster = helper.v_cruise_kph
self.ui.sm.put('slcState', output.message.slcState)
self.ui.sm['carState'].vCruiseCluster = self.sm['carState'].vCruiseCluster
for service in ('slcState', 'carState', 'carControl', 'selfdriveState'):
self.ui.sm.logMonoTime[service] = now
self.ui.sm.recv_time[service] = now / 1e9
view = self.adapter.build(ShellMode.ONROAD, now_ns=now).onroad
with patch.object(native, 'get_cruise_accel', wraps=native.get_cruise_accel) as cruise:
self.planner.update(self.sm, cruise_ceiling=output.result.ceiling, now_ns=now)
return output, view, cruise.call_args.args[1] / MPH
@pytest.mark.parametrize('answer', ['reject', 'timeout'])
@pytest.mark.parametrize('metric', [False, True])
@pytest.mark.parametrize('limit', [40, 50])
def test_compact_rejection_or_timeout_keeps_set_speed_until_explicit_adoption(tmp_path, answer, metric, limit):
from openpilot.starpilot.ui.tests.test_slc_ui_actions import tap_sign, tap_control
drive = RetentionDrive(tmp_path, confirm=True)
params = Params(str(tmp_path))
params.put('SLCOffsetSchedule', od.to_value(od.adopt_legacy(False, (5.0,) * 7)), block=True)
drive.runtime = Runtime(read_params(params))
drive.ui.is_metric = metric
first, _, _ = drive.step(0)
approved, _, target = drive.step(50, request=Action(drive.runtime.session_id, 1, first.message.slcState.decisionId,
first.message.slcState.presentationId, 'accept'))
accepted = approved.result.state.acceptance.accepted
assert target == pytest.approx(published_cruise_mph(50, metric=metric), rel=0, abs=1e-10)
proposed, view, target = drive.step(100, limit=limit)
assert slc_controls(Profile.COMPACT, view) == ()
assert [control.label for control in slc_controls(Profile.COMPACT, replace(view, slc_prompt_open=True))] == ['Accept', 'Reject']
assert target == pytest.approx(published_cruise_mph(50, metric=metric), rel=0, abs=1e-10)
request = None
if answer == 'reject':
# Use the visible Decline button, including its normal dispatcher validation.
emitted = []
touch = OnroadInput(emitted.append, Profile.COMPACT)
tap_sign(touch, view)
tap_control(touch, view, SlcActionKind.REJECT)
reject, = emitted
assert reject.kind == SlcActionKind.REJECT
request = publish_request(proposed.message.slcState, reject, NOW + 100_000_000)
elapsed = 150 if answer == 'reject' else 30_150
rejected, view, target = drive.step(elapsed, limit=limit, request=request)
assert not rejected.result.errors
assert rejected.result.acceptance.basis == ('driver_reject' if answer == 'reject' else 'timeout_reject')
assert not rejected.message.slcState.hasPending
candidate = rejected.result.state.acceptance.observation.candidate
assert candidate in rejected.result.state.acceptance.rejected
assert view.alert.size == AlertSize.NONE
assert view.speed_limit.kind == ObservationKind.VALID and view.speed_limit.action_enabled
control, = slc_controls(Profile.COMPACT, view)
assert control.request.kind == SlcActionKind.ADOPT
assert control.label == f'Use {round((limit + 5) * MPH * (3.6 if metric else 1 / MPH))}'
assert target == pytest.approx(published_cruise_mph(50, metric=metric), rel=0, abs=1e-10)
# The retired button position cannot claim a tap or adopt the rejected limit.
emitted = []
touch = OnroadInput(emitted.append, Profile.COMPACT)
touch.press(315, 205, view)
assert not touch.claimed
touch.release(315, 205, view)
assert not emitted
for delay, status in ((50, 'matchedLimit'), (100, 'noCoverage'), (150, 'matchedLimit')):
output, view, target = drive.step(elapsed + delay, limit=limit, status=status)
assert not output.result.errors
assert output.result.state.acceptance.accepted == accepted
assert candidate in output.result.state.acceptance.rejected
assert not output.message.slcState.hasPending
controls = slc_controls(Profile.COMPACT, view)
assert [control.request.kind for control in controls] == ([SlcActionKind.ADOPT] if status == 'matchedLimit' else [])
assert target == pytest.approx(published_cruise_mph(50, metric=metric), rel=0, abs=1e-10)
assert physical_actions.pending_confirmation(drive.ui.sm, NOW + (elapsed + delay) * 1_000_000,
long_active=True) is None
# Only the visible Use action changes SET, with the same offset and unit handling.
emitted = []
touch = OnroadInput(emitted.append, Profile.COMPACT)
tap_control(touch, view, SlcActionKind.ADOPT)
adopt, = emitted
request = publish_request(output.message.slcState, adopt, NOW + (elapsed + 150) * 1_000_000)
output, view, target = drive.step(elapsed + 200, limit=limit, request=request)
assert output.message.slcState.actionStatus == 'adopt'
assert slc_controls(Profile.COMPACT, view) == ()
assert target == pytest.approx(published_cruise_mph(limit + 5, metric=metric), rel=0, abs=1e-10)
# A different candidate still gets the normal two-choice confirmation.
output, view, target = drive.step(elapsed + 250, limit=55)
assert output.message.slcState.hasPending
controls = slc_controls(Profile.COMPACT, replace(view, slc_prompt_open=True))
assert [control.request.kind for control in controls] == [SlcActionKind.ACCEPT, SlcActionKind.REJECT]
assert target == pytest.approx(published_cruise_mph(limit + 5, metric=metric), rel=0, abs=1e-10)
@pytest.mark.parametrize('keep', [False, True])
@pytest.mark.parametrize('loss', ['noCoverage', 'noMatch', 'noGps', 'matchedNoLimit', 'missingTile',
'lookahead', 'stopped', 'deadTransport', 'externalGpsStale'])
def test_maps_loss_keeps_latest_display_independent_of_control_fallback(tmp_path, keep, loss):
drive = RetentionDrive(tmp_path, keep=keep)
drive.step(0)
changes = {'status': loss}
if loss in ('stopped', 'deadTransport'):
changes = {'received': False, 'alive': loss == 'stopped'}
elif loss == 'missingTile':
changes = {'tileLoaded': False}
elif loss == 'lookahead':
changes = {'waySelectionType': 'possible'}
elif loss == 'externalGpsStale':
changes = {'gpsMonoTime': BOOT - 300_000_000}
output, view, target = drive.step(251, **changes)
assert target == pytest.approx(published_cruise_mph(45), rel=0, abs=1e-10)
assert output.message.slcState.acceptedSpeedLimit == pytest.approx(45 * MPH)
assert not output.message.slcState.hasCeiling
assert output.message.slcState.hasAcceptedAdjustedLimit
assert output.message.slcState.acceptedAdjustedLimit == pytest.approx(45 * MPH)
assert view.speed_limit.kind != ObservationKind.VALID
shown = resolve_unified_speed(view)
assert (shown.max_text, shown.posted_text, shown.status) == ('45', '45', 'Unavailable')
assert all(slc_controls(profile, view) == () for profile in Profile)
assert not output.message.slcState.hasPending
@pytest.mark.parametrize('answer', ['accept', 'reject', 'timeout'])
@pytest.mark.parametrize('limit', [40, 50])
def test_long_gap_confirmation_and_recovery_keep_the_accepted_limit(tmp_path, answer, limit):
drive = RetentionDrive(tmp_path, confirm=True)
first, _, _ = drive.step(0)
assert first.message.slcState.hasPending and not first.message.slcState.hasAccepted
drive.step(50, request=Action(drive.runtime.session_id, 1, first.message.slcState.decisionId, first.message.slcState.presentationId, 'accept'))
_, _, target = drive.step(100, status='noCoverage')
assert target == pytest.approx(published_cruise_mph(45), rel=0, abs=1e-10)
_, view, target = drive.step(35_000, received=False)
assert target == pytest.approx(published_cruise_mph(45), rel=0, abs=1e-10)
assert resolve_unified_speed(view).status == 'Unavailable'
pending, _, target = drive.step(40_000, limit=limit, wayId=124)
assert target == pytest.approx(published_cruise_mph(45), rel=0, abs=1e-10) and pending.message.slcState.hasPending
action = None if answer == 'timeout' else Action(drive.runtime.session_id, 2, pending.message.slcState.decisionId,
pending.message.slcState.presentationId, answer)
drive.step(71_000 if answer == 'timeout' else 40_050, limit=limit, wayId=124, request=action)
output, view, target = drive.step(300_000, received=False)
assert target == pytest.approx(published_cruise_mph(limit if answer == 'accept' else 45), rel=0, abs=1e-10)
assert not output.message.slcState.hasPending
assert resolve_unified_speed(view).posted_text == str(limit)
# A dead SLC publisher cannot make the widget claim retained control indefinitely.
drive.ui.sm.alive['slcState'] = False
expired = drive.adapter.build(ShellMode.ONROAD, now_ns=NOW + 300_001_000_000).onroad
assert resolve_unified_speed(expired).active_side == 'max'
assert resolve_unified_speed(expired).posted_text == '–'
def test_unchanged_map_speed_does_not_accept_new_identity_or_keep_old_prompt(tmp_path):
drive = RetentionDrive(tmp_path, confirm=True)
first, _, _ = drive.step(0)
accepted, _, _ = drive.step(50, request=Action(drive.runtime.session_id, 1, first.message.slcState.decisionId,
first.message.slcState.presentationId, 'accept'))
unchanged, _, target = drive.step(100, wayId=124)
assert target == pytest.approx(published_cruise_mph(45), rel=0, abs=1e-10)
assert not unchanged.message.slcState.hasPending
assert unchanged.result.acceptance.history_write is None
assert unchanged.result.acceptance.state.accepted == accepted.result.acceptance.state.accepted
pending, _, _ = drive.step(150, limit=50, wayId=124)
assert pending.message.slcState.hasPending
recovered, _, target = drive.step(200)
assert target == pytest.approx(published_cruise_mph(45), rel=0, abs=1e-10)
assert not recovered.message.slcState.hasPending and recovered.command is None
assert recovered.message.slcState.acceptedSpeedLimit == pytest.approx(45 * MPH)
assert recovered.message.slcState.decisionId != pending.message.slcState.decisionId
assert recovered.message.slcState.presentationId != pending.message.slcState.presentationId
assert not drive.step(250)[0].result.errors
def test_returned_map_limit_still_requires_confirmation_when_set_is_unaligned(tmp_path):
drive = RetentionDrive(tmp_path, confirm=True)
first, _, _ = drive.step(0)
pending, _, _ = drive.step(50, limit=50, wayId=124)
returned, _, target = drive.step(100)
assert first.message.slcState.hasPending and pending.message.slcState.hasPending
assert returned.message.slcState.hasPending and not returned.message.slcState.hasAccepted
assert returned.command is None and returned.result.acceptance.history_write is None
assert returned.message.slcState.decisionId != pending.message.slcState.decisionId
assert target == pytest.approx(published_cruise_mph(70, software_target=False), rel=0, abs=1e-10)
def test_retention_respects_lower_max_secondary_source_and_reset(tmp_path):
drive = RetentionDrive(tmp_path / 'max')
drive.step(0)
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 40 * MPH * 3.6
_, view, target = drive.step(300, received=False)
assert target == pytest.approx(published_cruise_mph(40, software_target=False), rel=0, abs=1e-10)
assert resolve_unified_speed(view).status == 'Unavailable'
drive.runtime.reset()
output, _, _ = drive.step(400, received=False)
assert not output.message.slcState.hasAccepted and output.result.ceiling is None
secondary = RetentionDrive(tmp_path / 'secondary', dashboard=True)
secondary.step(0)
output, _, target = secondary.step(300, status='noCoverage')
assert target == pytest.approx(published_cruise_mph(70), rel=0, abs=1e-10)
assert not output.message.slcState.automaticAdoptionPending
assert output.message.slcState.acceptedSource == 'dashboard'
def test_compact_sign_keeps_accepted_value_without_pulsing_on_recovery(tmp_path):
drive = RetentionDrive(tmp_path)
_, before, _ = drive.step(0)
_, held, _ = drive.step(300, received=False)
_, recovered, _ = drive.step(400)
fonts = Mock()
fonts.measure.return_value = Mock(width=20, height=20)
fonts.vertical_ink.return_value = (0, 20)
fonts.horizontal_ink.return_value = (0, 20)
renderer = CompactHudRenderer(fonts, Path('/unused'))
with patch('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.outline'):
for view in (before, held, recovered):
renderer._speed_limit_sign(replace(view, appearance=replace(view.appearance, show_speed_limit_sign=True)))
assert any(call.args[0] == '45' for call in fonts.draw.call_args_list)
assert renderer._speed_limit_widget.pulse.start_time == -float('inf')