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

792 lines
43 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.
"""The live adoption path owns one card set speed, including PCM system long."""
from types import SimpleNamespace
from dataclasses import replace
from unittest.mock import patch
import pytest
from opendbc.car.dashboard_speed_limit import Status
from opendbc.car.structs import car
from openpilot.common.constants import CV
from openpilot.selfdrive.car.cruise import VCruiseHelper, slc_target_kph
from openpilot.selfdrive.controls.lib import longitudinal_planner as planner
from openpilot.starpilot.speed_limits import acceptance as acc, physical_actions
from openpilot.starpilot.speed_limits.runtime import Action, CruiseEvent, Runtime
from openpilot.starpilot.speed_limits.runtime_settings import parse
from openpilot.starpilot.speed_limits.tests.test_runtime_replay import ReplaySM
from openpilot.starpilot.speed_limits.tests import test_runtime_replay as replay_fixture
from openpilot.starpilot.speed_limits.speed_domain import OffsetBand, OffsetSchedule
START = 2_000_000_000
@pytest.fixture
def drive():
fixture = replay_fixture.RuntimeReplayTests()
fixture.setUp()
fixture.sm['carState'].vCruise = fixture.sm['carState'].vCruiseCluster = 60.
return fixture.sm, fixture.cp
def tick(runtime, sm, cp, seconds, **kwargs):
now = START + round(seconds * 1e9)
sm.advance(now)
sm['slcDashboardObservation'].validUntilMonoTime = now + 1_000_000_000
return runtime.step(sm, cp, now_ns=now, **kwargs)
def command_receipt(runtime, command, seconds, previous, selected, *, applied=True, event_id=1):
stamp = START + round(seconds * 1e9)
return CruiseEvent(event_id, stamp, previous / 3.6, selected / 3.6, '', 'card-producer', stamp,
'commandApplied' if applied else 'commandRejected', runtime.session_id,
command.decisionId, command.presentationId, command.commandId)
@pytest.mark.parametrize('button,kind', [('accel', 'confirmationAccept'), ('decel', 'confirmationReject')])
def test_wheel_confirmation_does_not_follow_same_step_source_replacement(drive, button, kind):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
pending = tick(runtime, sm, cp, 0).message.slcState
sm['slcDashboardObservation'].episode += 1
sm['slcDashboardObservation'].observedMonoTime = START + 40_000_000
event = CruiseEvent(1, START + 50_000_000, 0.0, 0.0, button, 'card-producer', START + 50_000_000,
kind, runtime.session_id, pending.decisionId, pending.presentationId)
output = tick(runtime, sm, cp, .05, cruise_event=event)
assert output.message.slcState.hasPending and not output.message.slcState.hasAccepted
assert output.message.slcState.actionStatus == 'different_presentation'
assert output.command is None and runtime.ledger.transactions == ()
@pytest.mark.parametrize('confirmation', [False, True])
@pytest.mark.parametrize('metric,limit,offset', [(False, 45., 0.), (False, 45., 5.), (False, 45., -.4),
(True, 72., 0.), (True, 72., 5.), (True, 72., -.4)])
def test_matching_set_needs_neither_confirmation_nor_command(drive, confirmation, metric, limit, offset):
sm, cp = drive
conversion = CV.KPH_TO_MS if metric else CV.MPH_TO_MS
settings = replace(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': confirmation}),
offsets=OffsetSchedule((OffsetBand(0., None, offset * conversion),)))
runtime = Runtime(settings, is_metric=metric)
sm['slcDashboardObservation'].speedMps = limit * conversion
selected = slc_target_kph((limit + offset) * conversion, metric)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
selected = float(sm['carState'].vCruise)
for seconds in (0, .05, 11):
output = tick(runtime, sm, cp, seconds)
assert not output.result.errors and not output.message.slcState.hasPending
assert output.message.slcState.hasAccepted and output.command is None
assert output.message.slcState.commandStatus == 'applied'
assert sm['carState'].vCruise == selected
def test_half_unit_offset_uses_the_rounding_card_gets_after_float32_transport(drive):
sm, cp = drive
settings = replace(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}),
offsets=OffsetSchedule((OffsetBand(0., None, .5 / 3.6),)))
runtime = Runtime(settings, is_metric=True)
sm['slcDashboardObservation'].speedMps = 72 / 3.6
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 72.
pending = tick(runtime, sm, cp, 0).message.slcState
assert pending.hasPending and not pending.hasAccepted
accepted = tick(runtime, sm, cp, .05,
request=Action(runtime.session_id, 1, pending.decisionId, pending.presentationId, 'accept'))
assert accepted.command is not None
helper = VCruiseHelper(cp)
helper.apply_slc_target(accepted.command.slcCruiseCommand.targetMps, True)
assert helper.v_cruise_kph == 73.
@pytest.mark.parametrize('confirmation', [False, True])
def test_commute_changes_only_set_speed_and_keeps_confirmation_policy(drive, confirmation):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': confirmation}))
helper = VCruiseHelper(cp)
selected = slc_target_kph(35 * CV.MPH_TO_MS, False)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
for index, mph in enumerate((35, 45, 65, 55, 65, 35, 30)):
source = sm['slcDashboardObservation']
source.speedMps = mph * CV.MPH_TO_MS
source.episode = index + 1
source.observedMonoTime = START + index * 1_000_000_000
output = tick(runtime, sm, cp, index)
if index == 0:
assert not output.message.slcState.hasPending and output.command is None
continue
if confirmation:
pending = output.message.slcState
assert pending.hasPending and output.command is None
assert sm['carState'].vCruise == pytest.approx(selected)
output = tick(runtime, sm, cp, index + .01,
request=Action(runtime.session_id, index, pending.decisionId, pending.presentationId, 'accept'))
assert output.command is not None
command = output.command.slcCruiseCommand
helper.apply_slc_target(command.targetMps, False)
previous, selected = selected, helper.v_cruise_kph
assert round(selected * CV.KPH_TO_MPH) == mph
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
applied = tick(runtime, sm, cp, index + .02,
cruise_event=command_receipt(runtime, command, index + .02, previous, selected, event_id=index))
assert not applied.message.slcState.hasPending and applied.command is None
@pytest.mark.parametrize('source_name', ['Maps', 'Vision'])
def test_secondary_source_matching_set_skips_confirmation_and_command(drive, source_name):
from openpilot.cereal import messaging
from openpilot.starpilot.speed_limits.selection import Source
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': source_name, 'SLCConfirmation': True}),
vision_enabled=True, vision_control_qualified=True)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = slc_target_kph(65 * CV.MPH_TO_MS, False)
source = Source.MAP if source_name == 'Maps' else Source.VISION
observations = dict.fromkeys(Source, acc.Observation(acc.ObservationKind.UNKNOWN))
observations[source] = acc.Observation(acc.ObservationKind.VALID, acc.Candidate(source.value,
acc.ObservationIdentity(acc.IdentityKind.GEOGRAPHIC, value='current-road'), 65 * CV.MPH_TO_MS))
vision = messaging.new_message('slcVisionObservation').slcVisionObservation
sm.data['slcVisionObservation'] = vision
with patch.object(runtime, '_observations', return_value=observations):
aligned = tick(runtime, sm, cp, 0)
assert aligned.message.slcState.acceptedSource == source.value
assert not aligned.message.slcState.hasPending and aligned.command is None
@pytest.mark.parametrize('selected', [65., 70.])
def test_returned_limit_only_prompts_when_it_would_change_set(drive, selected):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
sm['slcDashboardObservation'].speedMps = 65 * CV.MPH_TO_MS
sm['carState'].vCruise = sm['carState'].vCruiseCluster = slc_target_kph(65 * CV.MPH_TO_MS, False)
first = tick(runtime, sm, cp, 0).message.slcState
tick(runtime, sm, cp, .05, request=Action(runtime.session_id, 1, 0, first.presentationId, 'adopt'))
sm['slcDashboardObservation'].speedMps = 55 * CV.MPH_TO_MS
assert tick(runtime, sm, cp, .1).message.slcState.hasPending
assert tick(runtime, sm, cp, 10.1).result.acceptance.basis == 'timeout_reject'
sm['carState'].vCruise = sm['carState'].vCruiseCluster = slc_target_kph(selected * CV.MPH_TO_MS, False)
sm['slcDashboardObservation'].speedMps = 65 * CV.MPH_TO_MS
returned = tick(runtime, sm, cp, 10.2)
assert returned.message.slcState.hasPending == (selected != 65.)
assert returned.command is None
@pytest.mark.parametrize('action_kind', ['accept', 'reject'])
@pytest.mark.parametrize('seconds', [.05, 10.])
def test_presented_driver_choice_wins_when_set_reaches_the_proposal(drive, action_kind, seconds):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
sm['slcDashboardObservation'].speedMps = 55 * CV.MPH_TO_MS
pending = tick(runtime, sm, cp, 0).message.slcState
selected = slc_target_kph(55 * CV.MPH_TO_MS, False)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
output = tick(runtime, sm, cp, seconds,
request=Action(runtime.session_id, 1, pending.decisionId, pending.presentationId, action_kind))
assert output.result.acceptance.action_receipt.consumed
assert output.result.acceptance.basis == ('driver_accept' if action_kind == 'accept' else 'driver_reject')
assert output.command is None and sm['carState'].vCruise == selected
held = tick(runtime, sm, cp, seconds + .05)
assert not held.message.slcState.hasPending and held.command is None
if action_kind == 'reject':
assert held.result.acceptance.basis == 'rejected' and not held.message.slcState.hasAccepted
@pytest.mark.parametrize('seconds', [10., 10.001, 11.])
def test_set_alignment_cannot_accept_an_expired_confirmation(drive, seconds):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
sm['slcDashboardObservation'].speedMps = 55 * CV.MPH_TO_MS
pending = tick(runtime, sm, cp, 0)
assert pending.message.slcState.hasPending
sm['carState'].vCruise = sm['carState'].vCruiseCluster = slc_target_kph(55 * CV.MPH_TO_MS, False)
expired = tick(runtime, sm, cp, seconds)
assert expired.result.acceptance.basis == 'timeout_reject'
assert not expired.message.slcState.hasAccepted and not expired.message.slcState.hasPending
assert expired.command is None
@pytest.mark.parametrize('resolution', ['applied', 'rejected', 'expired'])
def test_alignment_waits_for_the_previous_command_and_newer_set_evidence(drive, resolution):
from openpilot.starpilot.speed_limits.selection import Source
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Maps', 'SLCConfirmation': True}))
sm['carState'].vCruise = sm['carState'].vCruiseCluster = slc_target_kph(65 * CV.MPH_TO_MS, False)
previous = sm['carState'].vCruise
observations = dict.fromkeys(Source, acc.Observation(acc.ObservationKind.UNKNOWN))
def limit(mph):
observations[Source.MAP] = acc.Observation(acc.ObservationKind.VALID, acc.Candidate('map',
acc.ObservationIdentity(acc.IdentityKind.GEOGRAPHIC, value=f'road-{mph}'), mph * CV.MPH_TO_MS))
with patch.object(runtime, '_observations', return_value=observations):
limit(55)
pending = tick(runtime, sm, cp, 0).message.slcState
issued = tick(runtime, sm, cp, .05,
request=Action(runtime.session_id, 1, pending.decisionId, pending.presentationId, 'accept'))
command = issued.command.slcCruiseCommand
limit(65)
waiting = tick(runtime, sm, cp, .06)
assert waiting.command is None and waiting.message.slcState.commandStatus == 'issued'
assert runtime.commanded_acceptance.candidate.speed_mps == pytest.approx(55 * CV.MPH_TO_MS)
if resolution != 'expired':
selected = slc_target_kph(command.targetMps, False) if resolution == 'applied' else previous
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
received = tick(runtime, sm, cp, .07, cruise_event=command_receipt(
runtime, command, .07, previous, selected, applied=resolution == 'applied'))
assert received.command is None
settled = tick(runtime, sm, cp, .08)
else:
# An expired command could have applied after this older (still fresh) SET sample.
now = START + 151_000_000
sm.advance(now)
sm.logMonoTime['carState'] = command.expiresMonoTime
sm.logMonoTime['slcDashboardObservation'] = command.expiresMonoTime
sm['slcDashboardObservation'].carStateLogMonoTime = command.expiresMonoTime
stale_set = runtime.step(sm, cp, now_ns=now)
assert stale_set.command is None and stale_set.message.slcState.commandStatus == 'expired'
settled = tick(runtime, sm, cp, .16)
if resolution == 'applied':
assert settled.message.slcState.hasPending and settled.message.slcState.commandStatus == 'applied'
assert settled.message.slcState.acceptedSpeedLimit == pytest.approx(55 * CV.MPH_TO_MS)
else:
assert not settled.message.slcState.hasPending and settled.command is None
assert settled.message.slcState.acceptedSpeedLimit == pytest.approx(65 * CV.MPH_TO_MS)
def test_session_reset_does_not_forget_an_in_flight_speed_change(drive):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
sm['carState'].vCruise = sm['carState'].vCruiseCluster = slc_target_kph(65 * CV.MPH_TO_MS, False)
sm['slcDashboardObservation'].speedMps = 55 * CV.MPH_TO_MS
pending = tick(runtime, sm, cp, 0).message.slcState
issued = tick(runtime, sm, cp, .05,
request=Action(runtime.session_id, 1, pending.decisionId, pending.presentationId, 'accept'))
assert issued.command is not None
runtime.reset()
sm['slcDashboardObservation'].speedMps = 65 * CV.MPH_TO_MS
waiting = tick(runtime, sm, cp, .06)
assert not waiting.message.slcState.hasAccepted and waiting.command is None
settled = tick(runtime, sm, cp, .16)
assert settled.message.slcState.hasAccepted and not settled.message.slcState.hasPending
assert settled.command is None
@pytest.mark.parametrize('unavailable', ['off', 'lateral', 'stock', 'stale_set', 'stale_ego', 'unset', 'force_decel'])
def test_set_alignment_requires_current_system_speed_authority(drive, unavailable):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
sm['slcDashboardObservation'].speedMps = 65 * CV.MPH_TO_MS
sm['carState'].vCruise = sm['carState'].vCruiseCluster = slc_target_kph(65 * CV.MPH_TO_MS, False)
sm.advance(START)
if unavailable in ('off', 'lateral', 'stock'):
sm['carControl'].longActive = False
sm['controlsState'].longControlState = 'off'
if unavailable == 'stock':
cp.openpilotLongitudinalControl = False
sm['carState'].cruiseState.enabled = True
else:
sm['carControl'].enabled = sm['selfdriveState'].enabled = False
sm['carControl'].latActive = unavailable == 'lateral'
elif unavailable == 'stale_set':
sm.valid['carState'] = False
elif unavailable == 'stale_ego':
sm['carState'].vEgoCluster = float('nan')
elif unavailable == 'unset':
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 255.
else:
sm['controlsState'].forceDecel = True
output = runtime.step(sm, cp, now_ns=START)
assert not output.message.slcState.hasAccepted and output.command is None
def test_reaching_actual_speed_does_not_accept_a_different_set_target(drive):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
sm['slcDashboardObservation'].speedMps = 45 * CV.MPH_TO_MS
sm['carState'].vEgo = sm['carState'].vEgoCluster = 45 * CV.MPH_TO_MS
sm['carState'].vCruise = sm['carState'].vCruiseCluster = slc_target_kph(65 * CV.MPH_TO_MS, False)
output = tick(runtime, sm, cp, 0)
assert output.message.slcState.hasPending and not output.message.slcState.hasAccepted and output.command is None
def test_matching_new_limit_reconciles_an_old_manual_override_without_changing_set(drive):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
sm['slcDashboardObservation'].speedMps = 65 * CV.MPH_TO_MS
baseline = slc_target_kph(65 * CV.MPH_TO_MS, False)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = baseline
first = tick(runtime, sm, cp, 0)
assert not first.message.slcState.hasPending
selected = slc_target_kph(70 * CV.MPH_TO_MS, False)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
selected = float(sm['carState'].vCruise)
changed = tick(runtime, sm, cp, .05, cruise_event=CruiseEvent(1, START + 50_000_000,
baseline / 3.6, selected / 3.6, 'accel', 'card-producer', START + 50_000_000))
assert changed.message.slcState.driverOverrideActive and changed.command is None
sm['slcDashboardObservation'].speedMps = 70 * CV.MPH_TO_MS
aligned = tick(runtime, sm, cp, .1)
assert not aligned.message.slcState.hasPending and not aligned.message.slcState.driverOverrideActive
assert aligned.command is None and sm['carState'].vCruise == selected
@pytest.mark.parametrize('direction', [-1, 1])
def test_matching_limit_keeps_wheel_buttons_available_for_normal_set_changes(drive, direction):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
sm['slcDashboardObservation'].speedMps = 65 * CV.MPH_TO_MS
selected = slc_target_kph(65 * CV.MPH_TO_MS, False)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
output = tick(runtime, sm, cp, 0)
pending = physical_actions.pending_confirmation(ReplaySM({'slcState': output.message.slcState}, START), START, long_active=True)
assert pending is None
helper = VCruiseHelper(cp)
helper.v_cruise_kph = selected
button = 'accelCruise' if direction > 0 else 'decelCruise'
cs = car.CarState(cruiseState={'available': True}, buttonEvents=[car.CarState.ButtonEvent(type=button, pressed=True)])
helper.update_v_cruise(cs, True, False, pending, slc_enabled=True)
assert helper.slc_consumed_button is None
cs.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)]
helper.update_v_cruise(cs, True, False, slc_enabled=True)
assert helper.v_cruise_kph == pytest.approx(round(selected + direction * round(CV.MPH_TO_KPH, 1), 1))
@pytest.mark.parametrize('failure', ['source_refresh', 'expiry'])
def test_failed_adoption_retries_with_current_evidence_and_stops_after_application(drive, failure):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
pending = tick(runtime, sm, cp, 0).message.slcState
first = tick(runtime, sm, cp, 1, request=Action(runtime.session_id, 17, pending.decisionId, pending.presentationId, 'accept'))
command = first.command.slcCruiseCommand
sm['slcDashboardObservation'].observedMonoTime = START + 1_010_000_000
if failure == 'source_refresh':
retry = tick(runtime, sm, cp, 1.05, cruise_event=command_receipt(runtime, command, 1.05, 60., 60., applied=False))
else:
retry = tick(runtime, sm, cp, 1.15)
assert retry.command is not None
current = retry.message.slcState
retried = retry.command.slcCruiseCommand
assert retried.commandId > command.commandId
assert current.commandActionSequenceId == 17
evidence = sm['slcDashboardObservation']
observation = SimpleNamespace(status=Status.VALID, speed_mps=evidence.speedMps, episode=evidence.episode,
observed_ns=evidence.observedMonoTime, valid_until_ns=evidence.validUntilMonoTime)
assert physical_actions.command_applicable(retried, current, observation, 'card-producer',
int(retry.message.logMonoTime), 60 / 3.6, long_active=True, button_event=False)
applied_at = 1.2 if failure == 'expiry' else 1.1
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 90.
applied = tick(runtime, sm, cp, applied_at,
cruise_event=command_receipt(runtime, retried, applied_at, 60., 90., event_id=2))
assert applied.message.slcState.commandStatus == 'applied' and applied.command is None
assert tick(runtime, sm, cp, 2).command is None
@pytest.mark.parametrize('change', ['manual', 'unclassified', 'source', 'returned_source', 'disengaged'])
def test_expired_adoption_does_not_revive_superseded_commands(drive, change):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard'}))
tick(runtime, sm, cp, 0)
old_command = tick(runtime, sm, cp, 3).command.slcCruiseCommand
kwargs = {}
if change in ('manual', 'unclassified'):
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 61.
if change == 'manual':
kwargs['cruise_event'] = CruiseEvent(1, START + 3_200_000_000, 60 / 3.6, 61 / 3.6,
'accel', 'card-producer', START + 3_200_000_000)
elif change == 'source':
sm['slcDashboardObservation'].speedMps = 20.
elif change == 'returned_source':
sm['slcDashboardObservation'].speedMps = 20.
tick(runtime, sm, cp, 3.05)
sm['slcDashboardObservation'].speedMps = 25.
else:
sm['selfdriveState'].enabled = sm['carControl'].enabled = sm['carControl'].longActive = False
sm['controlsState'].longControlState = 'off'
result = tick(runtime, sm, cp, 3.2, **kwargs)
if change in ('source', 'returned_source'):
assert result.message.slcState.acceptedMonoTime == START + 3_200_000_000
assert result.command.slcCruiseCommand.commandId > old_command.commandId
assert result.command.slcCruiseCommand.targetMps == (20. if change == 'source' else 25.)
else:
assert result.command is None
@pytest.mark.parametrize('failure', ['rejected', 'expired'])
@pytest.mark.parametrize('response', ['reject', 'timeout'])
def test_returned_limit_cannot_retry_an_older_adoption_after_decline(drive, failure, response):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True}))
pending = tick(runtime, sm, cp, 0).message.slcState
first = tick(runtime, sm, cp, 1, request=Action(runtime.session_id, 1, pending.decisionId, pending.presentationId, 'accept'))
command = first.command.slcCruiseCommand
sm['slcDashboardObservation'].speedMps = 20.
receipt = command_receipt(runtime, command, 1.05, 60., 60., applied=False) if failure == 'rejected' else None
tick(runtime, sm, cp, 1.05, cruise_event=receipt)
sm['slcDashboardObservation'].speedMps = 25.
pending = tick(runtime, sm, cp, 4).message.slcState
action = Action(runtime.session_id, 2, pending.decisionId, pending.presentationId, 'reject') if response == 'reject' else None
seconds = 5 if action else 14
declined = tick(runtime, sm, cp, seconds, request=action)
assert declined.result.acceptance.basis == ('driver_reject' if action else 'timeout_reject')
assert declined.command is None
held = tick(runtime, sm, cp, seconds + .1)
assert not held.result.errors and not runtime.state.reset_required and held.command is None
assert sm['carState'].vCruise == 60.
# An explicit later adoption remains available after declining the new decision.
current = declined.message.slcState
readopted = tick(runtime, sm, cp, seconds + .2,
request=Action(runtime.session_id, 3, 0, current.presentationId, 'adopt'))
assert readopted.command is not None and readopted.command.slcCruiseCommand.targetMps == 25.
@pytest.mark.parametrize('limit,offset,bounded', [(140., 10., 145.), (10., -5., 8.)])
@pytest.mark.parametrize('metric', [False, True])
def test_adoption_and_display_use_existing_cruise_bounds(drive, limit, offset, bounded, metric):
sm, cp = drive
sm['slcDashboardObservation'].speedMps = limit / 3.6
settings = replace(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard'}),
offsets=OffsetSchedule((OffsetBand(0., None, offset / 3.6),)))
runtime = Runtime(settings)
tick(runtime, sm, cp, 0)
output = tick(runtime, sm, cp, 3)
assert not output.result.errors and output.command is not None
command, state = output.command.slcCruiseCommand, output.message.slcState
assert command.targetMps == pytest.approx(bounded / 3.6)
assert state.acceptedAdjustedLimit == pytest.approx(bounded / 3.6)
assert state.offset == pytest.approx((bounded - limit) / 3.6)
evidence = sm['slcDashboardObservation']
observation = SimpleNamespace(status=Status.VALID, speed_mps=evidence.speedMps, episode=evidence.episode,
observed_ns=evidence.observedMonoTime, valid_until_ns=evidence.validUntilMonoTime)
assert physical_actions.command_applicable(command, state, observation, 'card-producer', START + 3_000_000_000,
60 / 3.6, long_active=True, button_event=False)
helper = VCruiseHelper(cp)
helper.apply_slc_target(command.targetMps, metric)
assert 8 <= helper.v_cruise_kph <= 145
@pytest.mark.parametrize('metric,display', [(True, 72), (False, 55), (False, 60), (False, 65), (False, 70)])
@pytest.mark.parametrize('direction', [-1, 1])
def test_manual_override_compares_against_the_applied_rounded_set_speed(drive, metric, display, direction):
sm, cp = drive
cp.pcmCruise = False
sm['slcDashboardObservation'].speedMps = 45 * CV.MPH_TO_MS if metric else display * CV.MPH_TO_MS
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'IsMetric': metric}))
tick(runtime, sm, cp, 0)
adopted = tick(runtime, sm, cp, 3)
helper = VCruiseHelper(cp)
helper.apply_slc_target(adopted.command.slcCruiseCommand.targetMps, metric)
baseline = helper.v_cruise_kph
conversion = 1 if metric else CV.KPH_TO_MPH
assert round(baseline * conversion) == display
sm['carState'].vCruise = sm['carState'].vCruiseCluster = baseline
tick(runtime, sm, cp, 3.05, cruise_event=command_receipt(runtime, adopted.command.slcCruiseCommand, 3.05, 60., baseline))
for event_id, (step, distance) in enumerate(zip((direction, direction, -direction, -direction), (1, 2, 1, 0), strict=True), 2):
previous = float(helper.v_cruise_kph)
helper.adjust_v_cruise(car.CarState.ButtonEvent.Type.accelCruise if step > 0 else
car.CarState.ButtonEvent.Type.decelCruise, sm['carState'], metric)
chosen = float(helper.v_cruise_kph)
assert round(chosen * conversion) == display + direction * distance
sm['carState'].vCruise = sm['carState'].vCruiseCluster = chosen
seconds = 3.05 + event_id * .05
stamp = START + round(seconds * 1e9)
output = tick(runtime, sm, cp, seconds, cruise_event=CruiseEvent(event_id, stamp, previous / 3.6,
chosen / 3.6, 'accel' if chosen > previous else 'decel', 'card-producer', stamp))
assert output.result.override.event_receipt.consumed
assert output.message.slcState.driverOverrideActive == (distance != 0)
assert output.command is None
assert helper.v_cruise_kph == pytest.approx(baseline, abs=1e-6)
@pytest.mark.parametrize('fallback', [0, 2])
def test_source_loss_preserves_latest_received_limit_and_offset_without_actions(drive, fallback):
from openpilot.starpilot.ui.onroad_state import OnroadState, speed_limit_from_message, slc_controls
from openpilot.starpilot.ui.presentation import Profile
from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed
sm, cp = drive
settings = replace(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True, 'SLCFallback': fallback}),
offsets=OffsetSchedule((OffsetBand(0., None, 2 / 3.6),)))
runtime = Runtime(settings)
first = tick(runtime, sm, cp, 0).message.slcState
tick(runtime, sm, cp, 1, request=Action(runtime.session_id, 1, first.decisionId, first.presentationId, 'accept'))
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 92.
sm['slcDashboardObservation'].speedMps = 20.
newer = tick(runtime, sm, cp, 2).message.slcState
tick(runtime, sm, cp, 2.1, request=Action(runtime.session_id, 2, newer.decisionId, newer.presentationId, 'reject'))
sm['slcDashboardObservation'].status = 'unknown'
lost = tick(runtime, sm, cp, 2.2)
observation = speed_limit_from_message(lost.message.slcState)
screen = OnroadState(True, False, 20., 92., observation, metric=True)
shown = resolve_unified_speed(screen)
assert (shown.posted_text, shown.offset_text, shown.source, shown.status) == ('72', '+2', 'dashboard', 'Unavailable')
assert not shown.pending and lost.command is None
for profile in Profile:
assert not slc_controls(profile, replace(screen, slc_prompt_open=True))
runtime.reset()
reset = tick(runtime, sm, cp, 2.3)
assert resolve_unified_speed(replace(screen, speed_limit=speed_limit_from_message(reset.message.slcState))).posted_text == '–'
@pytest.mark.parametrize('limit', [12., 25.])
@pytest.mark.parametrize('pcm', [False, True])
def test_automatic_adoption_updates_set_speed_up_or_down_immediately(drive, limit, pcm):
from openpilot.starpilot.ui.onroad_state import OnroadState, speed_limit_from_message
from openpilot.starpilot.ui.speed_limit_pulse import SpeedLimitPulse
sm, cp = drive
cp.pcmCruise = pcm
sm['slcDashboardObservation'].speedMps = limit
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard'}))
adopted = tick(runtime, sm, cp, 0)
assert adopted.message.slcState.hasAccepted
assert not adopted.message.slcState.automaticAdoptionPending
assert adopted.message.slcState.pendingSinceMonoTime == 0
assert adopted.command is not None
assert adopted.command.slcCruiseCommand.targetMps == pytest.approx(limit)
assert adopted.result.ceiling is None and not adopted.message.slcState.hasCeiling
assert not adopted.message.slcState.hasPending
state = adopted.message.slcState
evidence = sm['slcDashboardObservation']
observation = SimpleNamespace(status=Status.VALID, speed_mps=limit, episode=evidence.episode,
observed_ns=evidence.observedMonoTime, valid_until_ns=evidence.validUntilMonoTime)
current = physical_actions.state_current(ReplaySM({'slcState': state}, START), observation, 'card-producer', START)
assert physical_actions.command_applicable(adopted.command.slcCruiseCommand, current, observation, 'card-producer',
START, 60 / 3.6, long_active=True, button_event=False)
helper = VCruiseHelper(cp)
helper.v_cruise_kph = 60.
helper.apply_slc_target(limit, True)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = helper.v_cruise_kph
assert helper.v_cruise_kph == round(limit * 3.6)
pulse = SpeedLimitPulse()
pulse.update(OnroadState(True, False, 20., helper.v_cruise_kph, speed_limit_from_message(state)), START / 1e9)
assert not pulse.display_pulsing and pulse.start_time == -float('inf')
assert tick(runtime, sm, cp, 0.05).command is None
@pytest.mark.parametrize('response', ['accept', 'reject', 'timeout'])
def test_confirmation_outcomes_preserve_set_speed_until_acceptance(drive, response):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCConfirmation': True, 'SLCPriority1': 'Dashboard'}))
pending = tick(runtime, sm, cp, 0).message.slcState
assert pending.hasPending and not pending.automaticAdoptionPending
action = Action(runtime.session_id, 1, pending.decisionId, pending.presentationId, response) if response != 'timeout' else None
output = tick(runtime, sm, cp, 10 if action is None else 1, request=action)
assert not output.message.slcState.hasPending
assert (output.command is not None) == (response == 'accept')
assert sm['carState'].vCruise == 60.
if response == 'accept':
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 90.
tick(runtime, sm, cp, 1.05, cruise_event=command_receipt(runtime, output.command.slcCruiseCommand, 1.05, 60., 90.))
assert tick(runtime, sm, cp, 11).command is None
assert not tick(runtime, sm, cp, 12).message.slcState.hasPending
@pytest.mark.parametrize('selected', [50., 100.])
def test_new_limits_still_adopt_during_manual_override_in_either_direction(drive, selected):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard'}))
tick(runtime, sm, cp, 0)
tick(runtime, sm, cp, 3)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
changed = tick(runtime, sm, cp, 3.1, cruise_event=CruiseEvent(1, START + 3_100_000_000, 90 / 3.6,
selected / 3.6, 'accel' if selected > 90 else 'decel', 'card-producer', START + 3_100_000_000))
assert changed.message.slcState.driverOverrideActive
sm['slcDashboardObservation'].speedMps = 18.
adopted = tick(runtime, sm, cp, 3.2)
assert not adopted.message.slcState.hasPending
assert adopted.command.slcCruiseCommand.targetMps == pytest.approx(18.)
assert not adopted.message.slcState.driverOverrideActive
def test_accelerator_does_not_change_set_speed_or_manual_override_and_release_keeps_profile(drive):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard'}))
tick(runtime, sm, cp, 0)
tick(runtime, sm, cp, 3)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 90.
sm['carState'].gasPressed = True
sm['carState'].vEgo = sm['carState'].vEgoCluster = 30.
pressed = tick(runtime, sm, cp, 4)
assert not pressed.message.slcState.driverOverrideActive
assert sm['carState'].vCruise == 90. and pressed.command is None
sm['carState'].gasPressed = False
released = tick(runtime, sm, cp, 5)
assert not released.message.slcState.driverOverrideActive and released.result.ceiling is None
native = planner.LongitudinalPlanner(cp)
with patch.object(planner, 'get_cruise_accel', wraps=planner.get_cruise_accel) as cruise:
native.update(sm, global_braking_response='eco')
assert cruise.call_args.args[1] == 25.
assert cruise.call_args.kwargs['base_brake_floor'] == -.6
assert native.last_cruise_ceiling_status == 'absent'
def test_pcm_software_set_speed_survives_native_speed_and_consumes_confirmation(drive):
_, cp = drive
cp.pcmCruise = True
helper = VCruiseHelper(cp)
cs = car.CarState(cruiseState={'available': True, 'enabled': True, 'speed': 60 / 3.6})
helper.update_v_cruise(cs, True, True, slc_enabled=True)
helper.apply_slc_target(25., True)
helper.update_v_cruise(cs, True, True, slc_enabled=True)
assert helper.v_cruise_kph == helper.v_cruise_cluster_kph == 90.
from openpilot.selfdrive.car.cruise import SlcPendingConfirmation
pending = SlcPendingConfirmation('drive', 1, 1)
cs.buttonEvents = [car.CarState.ButtonEvent(type='decelCruise', pressed=True)]
helper.update_v_cruise(cs, True, True, pending, slc_enabled=True)
assert helper.slc_consumed_button.button == 'decel'
cs.cruiseState.speed = 59 / 3.6
cs.buttonEvents = [car.CarState.ButtonEvent(type='decelCruise', pressed=False)]
helper.update_v_cruise(cs, True, True, slc_enabled=True)
assert helper.v_cruise_kph == 90.
helper.update_v_cruise(cs, True, True, slc_enabled=False)
assert helper.v_cruise_kph == pytest.approx(59.)
@pytest.mark.parametrize('pending', [False, True])
@pytest.mark.parametrize('signal,direction,normal_speed', [
('CcAslButtnSetIncPress', 'accel', 61.), ('CcAslButtnSetDecPress', 'decel', 59.),
])
def test_ford_parser_buttons_adjust_set_speed_or_confirm_without_a_second_step(pending, signal, direction, normal_speed):
from opendbc.can import CANPacker
from opendbc.car import Bus, gen_empty_fingerprint
from opendbc.car.ford.carstate import CarState
from opendbc.car.ford.interface import CarInterface
from opendbc.car.ford.values import CAR, DBC
from openpilot.selfdrive.car.cruise import CRUISE_LONG_PRESS, SlcPendingConfirmation
cp = CarInterface.get_params(CAR.FORD_ESCAPE_MK4, gen_empty_fingerprint(), [], True, False, False)
source = CarState(cp)
parsers = source.get_can_parsers(cp)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
helper = VCruiseHelper(cp)
choice = SlcPendingConfirmation('drive', 1, 1) if pending else None
now_ns = START
def frame(pressed, decision=None):
nonlocal now_ns
parsers[Bus.pt].update([(now_ns, [
packer.make_can_msg('Steering_Data_FD1', parsers[Bus.pt].bus, {signal: pressed}),
packer.make_can_msg('EngBrakeData', parsers[Bus.pt].bus, {'CcStat_D_Actl': 4, 'Veh_V_DsplyCcSet': 60}),
])])
now_ns += 10_000_000
helper.update_v_cruise(source.update(parsers), True, True, decision, slc_enabled=True)
for _ in range(3):
frame(0)
helper.apply_slc_target(60. / 3.6, True)
frame(1, choice)
consumed = helper.slc_consumed_button
assert (consumed is not None) == pending
if pending:
assert consumed.pending == choice and consumed.button == direction
if direction == 'accel':
helper.apply_slc_target(25., True)
for _ in range(CRUISE_LONG_PRESS + 1):
frame(1)
assert helper.slc_consumed_button is None and helper.slc_cruise_change is None
frame(0)
assert helper.v_cruise_kph == (90. if pending and direction == 'accel' else 60. if pending else normal_speed)
assert (helper.slc_cruise_change is None) == pending
def test_automatic_adoption_does_not_consume_confirmation_actions_and_late_manual_accept_is_declined():
candidate = acc.Candidate('dashboard', acc.ObservationIdentity(acc.IdentityKind.SESSION_VALUE, session_id='drive'), 20.)
observation = acc.Observation(acc.ObservationKind.VALID, candidate)
authority = acc.Authority(acc.Mode.LONGITUDINAL_ONLY, acc.LongitudinalOwner.SYSTEM, False, True, False, False)
for automatic in (False, True):
policy = acc.Policy(not automatic, not automatic)
first = acc.step(acc.new_session('drive'), observation, authority, policy, now_ns=0)
action = acc.DriverAction('drive', 1, first.state.next_decision_id - 1, acc.ActionKind.ACCEPT)
output = acc.step(first.state, observation, authority, policy, now_ns=1 if automatic else acc.CONFIRMATION_NS + 1, action=action)
assert not output.action_receipt.consumed
assert (output.state.accepted is not None) == automatic
@pytest.mark.parametrize('source_name', ['map', 'vision'])
def test_map_and_vision_adoption_commands_bind_the_current_source_without_dashboard_limit(drive, source_name):
from openpilot.cereal import messaging
from openpilot.starpilot.speed_limits.selection import Source
sm, cp = drive
sm['slcDashboardObservation'].status = 'unknown'
evidence = messaging.new_message('slcVisionObservation').slcVisionObservation
evidence.vision.producerSessionId = 'vision-producer'
evidence.vision.episode = 1
evidence.vision.observedMonoTime = START
evidence.vision.validUntilMonoTime = START + 10_000_000_000
sm.data['slcVisionObservation'] = evidence
source = Source(source_name)
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Maps' if source == Source.MAP else 'Vision'}),
vision_enabled=True, vision_control_qualified=True)
candidate = acc.Candidate(source_name, acc.ObservationIdentity(acc.IdentityKind.GEOGRAPHIC, value='qualified-source'), 20.)
observations = dict.fromkeys(Source, acc.Observation(acc.ObservationKind.ABSENT))
observations[source] = acc.Observation(acc.ObservationKind.VALID, candidate)
with patch.object(runtime, '_observations', return_value=observations):
tick(runtime, sm, cp, 0)
output = tick(runtime, sm, cp, 3)
assert output.command is not None, (output.result.errors, output.result.status, output.result.acceptance, runtime.settings.errors)
command = output.command.slcCruiseCommand
assert command.source == source_name
current = physical_actions.state_current(ReplaySM({'slcState': output.message.slcState}, START + 3_000_000_000),
None, 'card-producer', START + 3_000_000_000)
assert physical_actions.command_applicable(command, current, None, 'card-producer', START + 3_000_000_000,
60 / 3.6, long_active=True, button_event=False)
command.source = 'dashboard'
assert not physical_actions.command_applicable(command, current, None, 'card-producer', START + 3_000_000_000,
60 / 3.6, long_active=True, button_event=False)
def test_offset_edit_does_not_cancel_manual_override(drive):
from openpilot.starpilot.speed_limits.speed_domain import OffsetBand, OffsetSchedule
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard'}))
tick(runtime, sm, cp, 0)
tick(runtime, sm, cp, 3)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 100.
tick(runtime, sm, cp, 3.1, cruise_event=CruiseEvent(1, START + 3_100_000_000, 90 / 3.6, 100 / 3.6, 'accel',
'card-producer', START + 3_100_000_000))
runtime.update_offsets(OffsetSchedule((OffsetBand(0., None, 1.),)))
output = tick(runtime, sm, cp, 3.2)
assert output.command is None and output.message.slcState.driverOverrideActive
assert sm['carState'].vCruise == 100.
@pytest.mark.parametrize('confirmation', [False, True])
def test_new_limit_can_be_adopted_while_accelerator_temporarily_suspends_longitudinal_output(drive, confirmation):
sm, cp = drive
sm['carState'].gasPressed = True
sm['carControl'].longActive = False
sm['controlsState'].longControlState = 'off'
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': confirmation}))
first = tick(runtime, sm, cp, 0).message.slcState
action = Action(runtime.session_id, 1, first.decisionId, first.presentationId, 'accept') if confirmation else None
output = tick(runtime, sm, cp, 1 if confirmation else 3, request=action)
assert output.command is not None
assert output.command.slcCruiseCommand.targetMps == 25.
assert not output.message.slcState.driverOverrideActive
assert not output.result.acceptance.authority.longitudinal_active
assert output.result.acceptance.authority.accelerator_override
assert output.result.ceiling is None
def test_manual_changes_during_source_loss_remain_overrides_after_reconnection(drive):
sm, cp = drive
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCFallback': 0}))
tick(runtime, sm, cp, 0)
tick(runtime, sm, cp, 3)
sm['slcDashboardObservation'].status = 'unknown'
tick(runtime, sm, cp, 3.1)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 100.
tick(runtime, sm, cp, 3.2, cruise_event=CruiseEvent(1, START + 3_200_000_000, 90 / 3.6, 100 / 3.6, 'accel',
'card-producer', START + 3_200_000_000))
sm['slcDashboardObservation'].status = 'valid'
output = tick(runtime, sm, cp, 3.3)
assert output.message.slcState.driverOverrideActive and output.command is None
assert sm['carState'].vCruise == 100.
def test_pcm_confirmation_release_is_retired_even_if_slc_is_switched_off(drive):
from openpilot.selfdrive.car.cruise import SlcPendingConfirmation
_, cp = drive
cp.pcmCruise = True
helper = VCruiseHelper(cp)
cs = car.CarState(cruiseState={'available': True, 'speed': 20.},
buttonEvents=[car.CarState.ButtonEvent(type='accelCruise', pressed=True)])
helper.update_v_cruise(cs, True, True, SlcPendingConfirmation('drive', 1, 1), slc_enabled=True)
assert helper.slc_suppressed_buttons
cs.buttonEvents = [car.CarState.ButtonEvent(type='accelCruise', pressed=False)]
helper.update_v_cruise(cs, True, True, slc_enabled=False)
assert not helper.slc_suppressed_buttons
assert not any(helper.button_timers.values())