Files
StarPilot/openpilot/starpilot/ui/tests/test_slc_ui_runtime.py
T

257 lines
13 KiB
Python

"""UI request messages through the real SLC decision runtime, with no IPC."""
from dataclasses import replace
from types import SimpleNamespace as NS
import unittest
from unittest.mock import Mock
from opendbc.car.honda.interface import CarInterface
from opendbc.car.honda.values import CAR
import openpilot.cereal.messaging as messaging
from openpilot.starpilot.longitudinal.tests.test_cruise_ceiling import messages
from openpilot.starpilot.speed_limits import acceptance as acc
from openpilot.starpilot.speed_limits.runtime import Action, Runtime
from openpilot.starpilot.speed_limits.runtime_settings import parse
from openpilot.starpilot.speed_limits.tests.test_runtime_replay import ReplaySM
from openpilot.starpilot.ui.onroad_state import (OnroadInput, OnroadState, SlcActionKind, SlcUiRequest, slc_controls,
speed_limit_from_message, speed_limit_bounds)
from openpilot.starpilot.ui.presentation import Profile
from openpilot.starpilot.ui.slc_action_dispatch import SlcActionDispatcher
START = 2_000_000_000
def replay_inputs() -> tuple[ReplaySM, object]:
cp = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC)
data, _ = messages()
source = messaging.new_message("slcDashboardObservation").slcDashboardObservation
data["slcDashboardObservation"] = source
sm = ReplaySM(data, START)
car = sm["carState"]
car.vCruise = 60.0
car.vCruiseCluster = 60.0
car.vEgoCluster = 20.0
sm["carControl"].enabled = True
sm["carControl"].longActive = True
source.producerSessionId = "card-producer"
source.carStateLogMonoTime = START
source.status = "valid"
source.speedMps = 25.0
source.observedMonoTime = START - 100_000_000
source.validUntilMonoTime = START + 5_000_000_000
source.episode = 1
return sm, cp
def ui_request(message, *, profile: Profile, kind: SlcActionKind) -> SlcUiRequest:
observation = speed_limit_from_message(message.slcState)
view_state = OnroadState(True, False, 20.0, 60.0, observation,
longitudinal_active=True, slc_system_long_available=True)
emitted: list[SlcUiRequest] = []
def emit(request):
if not isinstance(request, SlcUiRequest):
raise AssertionError("Unexpected non-SLC touch request")
emitted.append(request)
touch = OnroadInput(emit, profile)
if observation.pending_speed_limit_mps is not None:
bounds = speed_limit_bounds(profile, view_state)
assert bounds is not None
left, top, right, bottom = bounds
x, y = (left + right) / 2, (top + bottom) / 2
touch.press(x, y, view_state)
touch.release(x, y, view_state)
control = next(item for item in slc_controls(profile, touch.present(view_state)) if item.request.kind == kind)
left, top, right, bottom = control.bounds
x, y = (left + right) / 2, (top + bottom) / 2
touch.press(x, y, view_state)
touch.release(x, y, view_state)
if len(emitted) != 1:
raise AssertionError("Visible SLC touch emitted no request")
return emitted[0]
def publish_request(state, request: SlcUiRequest, now_ns: int):
sent = []
dispatcher = SlcActionDispatcher(NS(send=lambda service, message: sent.append((service, message))),
lambda _: state, lambda: now_ns)
if not dispatcher.dispatch(request):
raise AssertionError("Current SLC presentation rejected the UI request")
service, message = sent[0]
if service != "slcAction":
raise AssertionError("Wrong SLC action service")
action = message.slcAction
return Action(str(action.sessionId), int(action.sequenceId), int(action.decisionId),
int(action.presentationId), str(action.kind))
def accepted_runtime(sm, cp, settings, *, session_id):
runtime = Runtime(settings, session_id=session_id)
candidate = next(obs.candidate for obs in runtime._observations(sm, cp, START).values() if obs.candidate is not None)
accepted = acc.AcceptedLimit(candidate, session_id, 0)
runtime.state = replace(runtime.state, acceptance=replace(acc.new_session(session_id, accepted), received_speed_mps=candidate.speed_mps))
return runtime
class TestSlcUiRuntime(unittest.TestCase):
def test_confirmation_requires_current_presentation_through_source_handoff(self):
for profile in Profile:
for kind in (SlcActionKind.ACCEPT, SlcActionKind.REJECT):
for change in ('none', 'wrong_id', 'earlier_handoff', 'same_step_handoff', 'new_limit'):
with self.subTest(profile=profile, kind=kind, change=change):
sm, cp = replay_inputs()
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCConfirmation': True,
'SLCPriority1': 'Dashboard'}))
pending = runtime.step(sm, cp, now_ns=START).message
request = ui_request(pending, profile=profile, kind=kind)
action = publish_request(pending.slcState, request, START + 10_000_000)
if change == 'wrong_id':
action = replace(action, presentation_id=action.presentation_id + 1000)
elif change != 'none':
sm['slcDashboardObservation'].episode += 1
sm['slcDashboardObservation'].observedMonoTime = START + 40_000_000
if change == 'new_limit':
sm['slcDashboardObservation'].speedMps = 30.0
if change == 'earlier_handoff':
sm.advance(START + 50_000_000)
renewed = runtime.step(sm, cp, now_ns=START + 50_000_000).message.slcState
self.assertEqual(renewed.decisionId, pending.slcState.decisionId)
self.assertNotEqual(renewed.presentationId, pending.slcState.presentationId)
now_ns = START + (100_000_000 if change == 'earlier_handoff' else 50_000_000)
sm.advance(now_ns)
output = runtime.step(sm, cp, now_ns=now_ns, request=action)
if change == 'none':
self.assertEqual(output.message.slcState.actionStatus, kind.value)
self.assertFalse(output.message.slcState.hasPending)
self.assertEqual(output.command is not None, kind == SlcActionKind.ACCEPT)
else:
self.assertTrue(output.message.slcState.hasPending)
self.assertFalse(output.message.slcState.hasAccepted)
self.assertIsNone(output.command)
self.assertEqual(runtime.ledger.transactions, ())
def test_card_accept_produces_planner_receipt_and_bounded_command(self):
sm, cp = replay_inputs()
runtime = Runtime(parse({"SpeedLimitController": True, "SLCConfirmation": True,
"SLCConfirmationHigher": True, "SLCPriority1": "Dashboard"}), session_id="ui-accept")
pending = runtime.step(sm, cp, now_ns=START).message
self.assertTrue(pending.slcState.hasPending)
request = ui_request(pending, profile=Profile.LARGE, kind=SlcActionKind.ACCEPT)
self.assertEqual(request.kind, SlcActionKind.ACCEPT)
action = publish_request(pending.slcState, request, START)
sm.advance(START + 50_000_000)
result = runtime.step(sm, cp, now_ns=START + 50_000_000, request=action)
self.assertEqual(result.message.slcState.actionStatus, "accept")
self.assertFalse(result.message.slcState.hasPending)
self.assertIsNotNone(result.command)
self.assertGreater(result.command.slcCruiseCommand.targetMps, 60.0 / 3.6)
def test_reject_or_timeout_then_use_limit_restores_rejected_presentation(self):
for profile in Profile:
for response in ('reject', 'timeout'):
with self.subTest(profile=profile, response=response):
sm, cp = replay_inputs()
sm['slcDashboardObservation'].validUntilMonoTime = START + 20_000_000_000
runtime = Runtime(parse({"SpeedLimitController": True, "SLCConfirmation": True,
"SLCPriority1": "Dashboard"}), session_id="ui-reject")
pending = runtime.step(sm, cp, now_ns=START).message
action = None
now_ns = START + 10_000_000_000
if response == 'reject':
reject = ui_request(pending, profile=profile, kind=SlcActionKind.REJECT)
action = publish_request(pending.slcState, reject, START)
now_ns = START + 50_000_000
sm.advance(now_ns)
rejected = runtime.step(sm, cp, now_ns=now_ns, request=action)
self.assertEqual(rejected.result.acceptance.basis, 'driver_reject' if action else 'timeout_reject')
self.assertFalse(rejected.message.slcState.hasPending)
self.assertIsNone(rejected.command)
adopt = ui_request(rejected.message, profile=profile, kind=SlcActionKind.ADOPT)
self.assertEqual(adopt.kind, SlcActionKind.ADOPT)
restore = publish_request(rejected.message.slcState, adopt, now_ns)
sm.advance(now_ns + 50_000_000)
restored = runtime.step(sm, cp, now_ns=now_ns + 50_000_000, request=restore)
self.assertEqual(restored.message.slcState.actionStatus, "adopt")
self.assertIsNotNone(restored.command)
self.assertAlmostEqual(restored.command.slcCruiseCommand.targetMps, 25.0)
def test_same_accepted_limit_can_be_used_with_lower_selected_cruise(self):
sm, cp = replay_inputs()
runtime = accepted_runtime(sm, cp, parse({"SpeedLimitController": True, "SLCPriority1": "Dashboard"}), session_id="ui-readopt")
shown = runtime.step(sm, cp, now_ns=START).message
self.assertTrue(shown.slcState.hasAccepted)
self.assertAlmostEqual(shown.slcState.acceptedSpeedLimit, shown.slcState.speedLimit)
request = ui_request(shown, profile=Profile.LARGE, kind=SlcActionKind.ADOPT)
self.assertEqual(request.kind, SlcActionKind.ADOPT)
action = publish_request(shown.slcState, request, START)
sm.advance(START + 50_000_000)
result = runtime.step(sm, cp, now_ns=START + 50_000_000, request=action)
self.assertEqual(result.message.slcState.actionStatus, "adopt")
self.assertIsNotNone(result.command)
def test_projected_use_limit_disappears_after_card_applies_rounded_set_speed(self):
from openpilot.selfdrive.car.cruise import VCruiseHelper
from openpilot.starpilot.system.starport.projection_onroad import ProjectionOnroad
for metric, limit in ((False, 70 / 2.2369362921), (True, 90.4 / 3.6)):
with self.subTest(metric=metric):
sm, cp = replay_inputs()
sm['slcDashboardObservation'].speedMps = limit
runtime = accepted_runtime(sm, cp, parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard'}), session_id='aa-adopt')
shown = runtime.step(sm, cp, now_ns=START).message
state = OnroadState(True, False, 20.0, 60.0, speed_limit_from_message(shown.slcState),
metric=metric, longitudinal_active=True, slc_system_long_available=True)
control, = slc_controls(Profile.LARGE, state)
left, top, right, bottom = control.bounds
x, y = (left + right) / 2, (top + bottom) / 2
emitted = []
view = ProjectionOnroad.__new__(ProjectionOnroad)
view.native = NS(ui_state=NS(started=True))
view._state = state
view._slc_input = OnroadInput(emitted.append, Profile.LARGE)
self.assertTrue(view._slc_touch('down', x, y))
self.assertTrue(view._slc_touch('up', x, y))
request, = emitted
self.assertEqual(request.kind, SlcActionKind.ADOPT)
action = publish_request(shown.slcState, request, START)
sm.advance(START + 50_000_000)
result = runtime.step(sm, cp, now_ns=START + 50_000_000, request=action)
self.assertEqual(result.message.slcState.actionStatus, 'adopt')
self.assertIsNotNone(result.command)
cruise = VCruiseHelper(cp)
cruise.apply_slc_target(result.command.slcCruiseCommand.targetMps, metric)
applied = replace(state, cruise_kph=cruise.v_cruise_cluster_kph,
speed_limit=speed_limit_from_message(result.message.slcState))
self.assertEqual(slc_controls(Profile.LARGE, applied), ())
# A later driver reduction must still offer the same limit again.
self.assertEqual(slc_controls(Profile.LARGE, replace(applied, cruise_kph=60.0))[0].request.kind,
SlcActionKind.ADOPT)
def test_old_press_cannot_publish_after_new_runtime_presentation_or_staleness(self):
sm, cp = replay_inputs()
runtime = Runtime(parse({"SpeedLimitController": True, "SLCConfirmation": True,
"SLCConfirmationHigher": True, "SLCPriority1": "Dashboard"}), session_id="ui-stale")
pending = runtime.step(sm, cp, now_ns=START).message
old_touch = ui_request(pending, profile=Profile.LARGE, kind=SlcActionKind.ACCEPT)
publisher = Mock()
stale = SlcActionDispatcher(publisher, lambda _: pending.slcState, lambda: START + 200_000_000)
self.assertFalse(stale.dispatch(old_touch))
source = sm["slcDashboardObservation"]
source.speedMps = 30.0
source.observedMonoTime = START + 40_000_000
source.episode = 2
sm.advance(START + 50_000_000)
changed = runtime.step(sm, cp, now_ns=START + 50_000_000).message
self.assertNotEqual(changed.slcState.presentationId, pending.slcState.presentationId)
fresh = SlcActionDispatcher(publisher, lambda _: changed.slcState, lambda: START + 50_000_000)
self.assertFalse(fresh.dispatch(old_touch))
publisher.send.assert_not_called()
if __name__ == "__main__":
unittest.main()