Compare commits

...

8 Commits

Author SHA1 Message Date
firestarsdog 68c0b55a00 last 2026-11-04 12:16:09 -05:00
firestarsdog 446801ca74 Match newer Dashboard packets to avoid stale SET 2026-11-04 11:52:42 -05:00
firestarsdog 7fe8dd5e83 Fix SLC standstill SET and stale wheel writes 2026-11-04 11:42:45 -05:00
whoisdomi 72d886cef9 Adopt SLC Speed Limit button fixes 2026-11-04 10:14:11 -06:00
firestarsdog b161b33ae8 Refresh ARM64 device artifacts and release receipt 2026-11-04 01:25:08 -05:00
firestarsdog b0c03bdbda Fixes 2026-11-04 00:46:58 -05:00
firestarsdog 08cfff03af Build 2026-11-03 22:47:22 -05:00
firestarsdog af4d7e5a0d flop 2026-11-03 22:15:26 -05:00
194 changed files with 7074 additions and 6565 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5 -1
View File
@@ -360,6 +360,7 @@ class CarController(CarControllerBase):
self.volt_cc_cancel_pending = False
self.volt_cc_last_cancel_frame = 0
self.volt_cc_metric = False
self.volt_cc_speed_ceiling_mps = None
self.volt_cc_last_button_frame = 0
self.volt_cc_last_direction_frame = None
self.volt_cc_consumed_source_ns = 0
@@ -1396,7 +1397,10 @@ class CarController(CarControllerBase):
self.volt_cc_consumed_source_ns = source_ns
can_sends.append(gmcan.create_buttons(self.packer_pt, CanBus.POWERTRAIN, (CS.buttons_counter + 1) % 4, button))
elif self.frame % 4 == 0:
button, rate = button_request(CS.out.vEgo, CS.out.cruiseState.speed, actuators.accel, CS.out.vCruise,
cruise_kph = CS.out.vCruise
if self.volt_cc_speed_ceiling_mps is not None:
cruise_kph = min(cruise_kph, self.volt_cc_speed_ceiling_mps * CV.MS_TO_KPH)
button, rate = button_request(CS.out.vEgo, CS.out.cruiseState.speed, actuators.accel, cruise_kph,
bool(CC.hudControl.leadVisible), self.volt_cc_metric)
source_ns = physical.button_credit_ns
if ((self.frame - self.volt_cc_last_button_frame) * DT_CTRL > rate and
@@ -335,8 +335,8 @@ def test_actual_card_active_observation_requires_fresh_selfdrive_state(monkeypat
host.observe_ioniq6_long_authority = lambda *args: None
host.update_vehicle_state_context = lambda *args: None
def update_cruise_speed(_state, _pending, *, approved=None):
assert approved is None
def update_cruise_speed(_state, _pending, *, wheel_context=None):
assert wheel_context is None
host.update_cruise_speed = update_cruise_speed
host.v_cruise_helper = SimpleNamespace(slc_consumed_button=None, toyota=None)
host.startup_panda_configured = lambda: True
@@ -870,7 +870,7 @@ class TestIoniq6PrepublicationHandoff(unittest.TestCase):
card_owner.is_metric = True
card_owner.experimental_mode = False
card_owner.initial_cruise_speed = 40
card_owner.v_cruise_helper = SimpleNamespace(update_v_cruise=lambda *_, slc_enabled=False, slc_approved=None: None,
card_owner.v_cruise_helper = SimpleNamespace(update_v_cruise=lambda *_, slc_enabled=False, wheel_context=None: None,
initialize_v_cruise=lambda *_, **kwargs: None, slc_consumed_button=None, toyota=None,
slc_cruise_change=None, v_cruise_kph=60.0, v_cruise_cluster_kph=60.0)
card_owner.RI = SimpleNamespace(update=lambda _: None)
+7 -1
View File
@@ -333,7 +333,7 @@ struct SlcState @0xa1680744031fdb2d {
hasPendingAdjustedLimit @45 :Bool;
presentationAdjustedLimit @46 :Float32;
hasPresentationAdjustedLimit @47 :Bool;
overrideBasis @48 :Text; # none/pedal/persistent; qualified contribution only
overrideBasis @48 :Text; # none/pedal/persistent/wheel; qualified contribution only
retainedOverride @49 :Bool;
commandActionSequenceId @50 :UInt64; # UI sequence that issued this command
automaticAdoptionPending @51 :Bool;
@@ -346,6 +346,10 @@ struct SlcState @0xa1680744031fdb2d {
# Qualified adoption proposal; the current displayed observation stays above.
upcomingSpeedLimit @57 :Float32;
upcomingBoundaryId @58 :Text;
acceptedIsPreview @59 :Bool; # a temporary ceiling, ineligible for initializing SET
wheelOverrideSince @60 :UInt64; # nonzero while a wheel tap may cross the current adjusted limit
wheelAdoptMonoTime @61 :UInt64; # Card's original wheel event timestamp, only on the approval frame
wheelAdoptTargetMps @62 :Float32; # exact adjusted SET approved for that event
struct SourceReading {
source @0 :Text;
enabled @1 :Bool;
@@ -447,6 +451,7 @@ struct SlcAction @0x9ccdc8676701b412 {
conditionalManual @5 :ConditionalManualAction;
controllerCruise @6 :ControllerCruiseAction;
controllerMode @7 :ControllerCruiseAction;
wheelAdoptMonoTime @8 :UInt64; # original timestamp of a qualified Adopt Speed Limit wheel event
struct ControllerCruiseAction {
version @0 :UInt16;
@@ -514,6 +519,7 @@ struct SlcCruiseEvent @0xcd96dafb67a082d0 {
trafficMode @13 :TrafficModeGesture;
wheelAction @14 :SlcAction.ControllerCruiseAction;
teslaStockCruise @15 :TeslaStockCruise;
wheelOverrideSince @16 :UInt64; # SLC eligibility captured by a physical + tap; zero for ordinary SET changes
struct TeslaStockCruise {
version @0 :UInt16;
File diff suppressed because it is too large Load Diff
+117 -3
View File
@@ -730,7 +730,7 @@ struct SlcState {
struct ConditionalModeState;
struct _capnpPrivate {
CAPNP_DECLARE_STRUCT_HEADER(a1680744031fdb2d, 18, 22)
CAPNP_DECLARE_STRUCT_HEADER(a1680744031fdb2d, 21, 22)
#if !CAPNP_LITE
static constexpr ::capnp::_::RawBrandedSchema const* brand() { return &schema->defaultBrand; }
#endif // !CAPNP_LITE
@@ -826,7 +826,7 @@ struct SlcAction {
struct _capnpPrivate {
CAPNP_DECLARE_STRUCT_HEADER(9ccdc8676701b412, 4, 4)
CAPNP_DECLARE_STRUCT_HEADER(9ccdc8676701b412, 5, 4)
#if !CAPNP_LITE
static constexpr ::capnp::_::RawBrandedSchema const* brand() { return &schema->defaultBrand; }
#endif // !CAPNP_LITE
@@ -878,7 +878,7 @@ struct SlcCruiseEvent {
struct ManualModeGesture;
struct _capnpPrivate {
CAPNP_DECLARE_STRUCT_HEADER(cd96dafb67a082d0, 7, 6)
CAPNP_DECLARE_STRUCT_HEADER(cd96dafb67a082d0, 8, 6)
#if !CAPNP_LITE
static constexpr ::capnp::_::RawBrandedSchema const* brand() { return &schema->defaultBrand; }
#endif // !CAPNP_LITE
@@ -4366,6 +4366,14 @@ public:
inline bool hasUpcomingBoundaryId() const;
inline ::capnp::Text::Reader getUpcomingBoundaryId() const;
inline bool getAcceptedIsPreview() const;
inline ::uint64_t getWheelOverrideSince() const;
inline ::uint64_t getWheelAdoptMonoTime() const;
inline float getWheelAdoptTargetMps() const;
private:
::capnp::_::StructReader _reader;
template <typename, ::capnp::Kind>
@@ -4659,6 +4667,18 @@ public:
inline void adoptUpcomingBoundaryId(::capnp::Orphan< ::capnp::Text>&& value);
inline ::capnp::Orphan< ::capnp::Text> disownUpcomingBoundaryId();
inline bool getAcceptedIsPreview();
inline void setAcceptedIsPreview(bool value);
inline ::uint64_t getWheelOverrideSince();
inline void setWheelOverrideSince( ::uint64_t value);
inline ::uint64_t getWheelAdoptMonoTime();
inline void setWheelAdoptMonoTime( ::uint64_t value);
inline float getWheelAdoptTargetMps();
inline void setWheelAdoptTargetMps(float value);
private:
::capnp::_::StructBuilder _builder;
template <typename, ::capnp::Kind>
@@ -5511,6 +5531,8 @@ public:
inline bool hasControllerMode() const;
inline ::cereal::SlcAction::ControllerCruiseAction::Reader getControllerMode() const;
inline ::uint64_t getWheelAdoptMonoTime() const;
private:
::capnp::_::StructReader _reader;
template <typename, ::capnp::Kind>
@@ -5579,6 +5601,9 @@ public:
inline void adoptControllerMode(::capnp::Orphan< ::cereal::SlcAction::ControllerCruiseAction>&& value);
inline ::capnp::Orphan< ::cereal::SlcAction::ControllerCruiseAction> disownControllerMode();
inline ::uint64_t getWheelAdoptMonoTime();
inline void setWheelAdoptMonoTime( ::uint64_t value);
private:
::capnp::_::StructBuilder _builder;
template <typename, ::capnp::Kind>
@@ -5970,6 +5995,8 @@ public:
inline bool hasTeslaStockCruise() const;
inline ::cereal::SlcCruiseEvent::TeslaStockCruise::Reader getTeslaStockCruise() const;
inline ::uint64_t getWheelOverrideSince() const;
private:
::capnp::_::StructReader _reader;
template <typename, ::capnp::Kind>
@@ -6070,6 +6097,9 @@ public:
inline void adoptTeslaStockCruise(::capnp::Orphan< ::cereal::SlcCruiseEvent::TeslaStockCruise>&& value);
inline ::capnp::Orphan< ::cereal::SlcCruiseEvent::TeslaStockCruise> disownTeslaStockCruise();
inline ::uint64_t getWheelOverrideSince();
inline void setWheelOverrideSince( ::uint64_t value);
private:
::capnp::_::StructBuilder _builder;
template <typename, ::capnp::Kind>
@@ -19713,6 +19743,62 @@ inline ::capnp::Orphan< ::capnp::Text> SlcState::Builder::disownUpcomingBoundary
::capnp::bounded<21>() * ::capnp::POINTERS));
}
inline bool SlcState::Reader::getAcceptedIsPreview() const {
return _reader.getDataField<bool>(
::capnp::bounded<141>() * ::capnp::ELEMENTS);
}
inline bool SlcState::Builder::getAcceptedIsPreview() {
return _builder.getDataField<bool>(
::capnp::bounded<141>() * ::capnp::ELEMENTS);
}
inline void SlcState::Builder::setAcceptedIsPreview(bool value) {
_builder.setDataField<bool>(
::capnp::bounded<141>() * ::capnp::ELEMENTS, value);
}
inline ::uint64_t SlcState::Reader::getWheelOverrideSince() const {
return _reader.getDataField< ::uint64_t>(
::capnp::bounded<18>() * ::capnp::ELEMENTS);
}
inline ::uint64_t SlcState::Builder::getWheelOverrideSince() {
return _builder.getDataField< ::uint64_t>(
::capnp::bounded<18>() * ::capnp::ELEMENTS);
}
inline void SlcState::Builder::setWheelOverrideSince( ::uint64_t value) {
_builder.setDataField< ::uint64_t>(
::capnp::bounded<18>() * ::capnp::ELEMENTS, value);
}
inline ::uint64_t SlcState::Reader::getWheelAdoptMonoTime() const {
return _reader.getDataField< ::uint64_t>(
::capnp::bounded<19>() * ::capnp::ELEMENTS);
}
inline ::uint64_t SlcState::Builder::getWheelAdoptMonoTime() {
return _builder.getDataField< ::uint64_t>(
::capnp::bounded<19>() * ::capnp::ELEMENTS);
}
inline void SlcState::Builder::setWheelAdoptMonoTime( ::uint64_t value) {
_builder.setDataField< ::uint64_t>(
::capnp::bounded<19>() * ::capnp::ELEMENTS, value);
}
inline float SlcState::Reader::getWheelAdoptTargetMps() const {
return _reader.getDataField<float>(
::capnp::bounded<40>() * ::capnp::ELEMENTS);
}
inline float SlcState::Builder::getWheelAdoptTargetMps() {
return _builder.getDataField<float>(
::capnp::bounded<40>() * ::capnp::ELEMENTS);
}
inline void SlcState::Builder::setWheelAdoptTargetMps(float value) {
_builder.setDataField<float>(
::capnp::bounded<40>() * ::capnp::ELEMENTS, value);
}
inline bool SlcState::SourceReading::Reader::hasSource() const {
return !_reader.getPointerField(
::capnp::bounded<0>() * ::capnp::POINTERS).isNull();
@@ -21220,6 +21306,20 @@ inline ::capnp::Orphan< ::cereal::SlcAction::ControllerCruiseAction> SlcAction::
::capnp::bounded<3>() * ::capnp::POINTERS));
}
inline ::uint64_t SlcAction::Reader::getWheelAdoptMonoTime() const {
return _reader.getDataField< ::uint64_t>(
::capnp::bounded<4>() * ::capnp::ELEMENTS);
}
inline ::uint64_t SlcAction::Builder::getWheelAdoptMonoTime() {
return _builder.getDataField< ::uint64_t>(
::capnp::bounded<4>() * ::capnp::ELEMENTS);
}
inline void SlcAction::Builder::setWheelAdoptMonoTime( ::uint64_t value) {
_builder.setDataField< ::uint64_t>(
::capnp::bounded<4>() * ::capnp::ELEMENTS, value);
}
inline ::uint16_t SlcAction::ControllerCruiseAction::Reader::getVersion() const {
return _reader.getDataField< ::uint16_t>(
::capnp::bounded<0>() * ::capnp::ELEMENTS);
@@ -22088,6 +22188,20 @@ inline ::capnp::Orphan< ::cereal::SlcCruiseEvent::TeslaStockCruise> SlcCruiseEve
::capnp::bounded<5>() * ::capnp::POINTERS));
}
inline ::uint64_t SlcCruiseEvent::Reader::getWheelOverrideSince() const {
return _reader.getDataField< ::uint64_t>(
::capnp::bounded<7>() * ::capnp::ELEMENTS);
}
inline ::uint64_t SlcCruiseEvent::Builder::getWheelOverrideSince() {
return _builder.getDataField< ::uint64_t>(
::capnp::bounded<7>() * ::capnp::ELEMENTS);
}
inline void SlcCruiseEvent::Builder::setWheelOverrideSince( ::uint64_t value) {
_builder.setDataField< ::uint64_t>(
::capnp::bounded<7>() * ::capnp::ELEMENTS, value);
}
inline ::uint16_t SlcCruiseEvent::TeslaStockCruise::Reader::getVersion() const {
return _reader.getDataField< ::uint16_t>(
::capnp::bounded<0>() * ::capnp::ELEMENTS);
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8 -7
View File
@@ -327,12 +327,12 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"HideDMIcon", {PERSISTENT, BOOL, "0"}},
{"HidePersonalityAlerts", {PERSISTENT, BOOL, "0"}},
{"ShowSpeedLimits", {PERSISTENT, BOOL, "1"}},
{"SLCConfirmation", {PERSISTENT, BOOL, "0"}},
{"SLCAdvanceSeconds", {PERSISTENT, INT, "0"}},
{"SLCConfirmationHigher", {PERSISTENT, BOOL, "0"}},
{"SLCConfirmationLower", {PERSISTENT, BOOL, "0"}},
{"SLCFallback", {PERSISTENT, INT}}, // Retired; preserve saved bytes for older versions.
{"SLCOverride", {PERSISTENT, INT, "1"}},
{"SLCOnEngage", {PERSISTENT, BOOL, "0"}},
{"SLCConfirmation", {PERSISTENT, INT, "0"}},
{"SLCAdvanceHigherSeconds", {PERSISTENT, INT, "0"}},
{"SLCAdvanceLowerSeconds", {PERSISTENT, INT, "0"}},
{"SLCOverrideMode", {PERSISTENT, INT, "1"}},
{"SLCWheelOverride", {PERSISTENT, BOOL, "0"}},
{"SLCQualifiedHistory", {PERSISTENT, JSON}},
{"ConditionalModeConfig", {PERSISTENT | DONT_LOG, JSON}},
{"ConditionalManualState", {PERSISTENT | DONT_LOG, JSON}},
@@ -361,7 +361,8 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"VASMPreferences", {PERSISTENT | DONT_LOG, JSON}},
{"LaneChangePreferences", {PERSISTENT | DONT_LOG, JSON}},
{"SLCPriority1", {PERSISTENT, STRING, "Vision"}},
{"SLCPriority2", {PERSISTENT, STRING, "Dashboard"}},
{"SLCPriority2", {PERSISTENT, STRING, "Map Data"}},
{"SLCPriority3", {PERSISTENT, STRING, "Off"}},
{"SpeedLimitController", {PERSISTENT, BOOL, "0"}},
{"RouteCount", {PERSISTENT, INT, "0"}},
{"SnoozeUpdate", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}},
+70 -92
View File
@@ -4,7 +4,6 @@ import os
import time
import threading
import uuid
from collections import deque
from typing import cast
import openpilot.cereal.messaging as messaging
@@ -32,7 +31,7 @@ from opendbc.car.hyundai.ioniq6_media import Ioniq6MediaButtons
from opendbc.car.hyundai.values import CAR as HYUNDAI_CAR
from opendbc.car.interfaces import CarInterfaceBase, RadarInterfaceBase
from openpilot.selfdrive.pandad import can_capnp_to_list, can_list_to_can_capnp
from openpilot.selfdrive.car.cruise import VCruiseHelper, SlcPendingConfirmation
from openpilot.selfdrive.car.cruise import SlcWheelContext, VCruiseHelper
from openpilot.starpilot.speed_limits import physical_actions as slc_physical
from openpilot.starpilot.aol.intent import AolProcessFaultContext, independent_axis_requested, read_settings
from openpilot.starpilot.aol.transport_pause import TransportPauseFeedback, cp_identity
@@ -93,7 +92,7 @@ from openpilot.starpilot.vehicle_selection import startup_candidate
from openpilot.starpilot.vehicle_preferences import VehicleStartupPreferences
from openpilot.starpilot.controller_extensions import configure_controller, observe_controller_can
from openpilot.starpilot.vehicle_startup import VehicleStartupOwner
from openpilot.starpilot.controllers.wheel_actions import WheelPublisher, key_for
from openpilot.starpilot.controllers.wheel_actions import LIFETIME_NS as WHEEL_ACTION_MAX_AGE_NS, WheelPublisher, key_for
from openpilot.starpilot.controllers.cruise_action import CruiseActionConsumer, eligible as cruise_action_eligible
from openpilot.starpilot.schema_cache import get_cache, prewarm_cache_contracts, put_cache
@@ -139,15 +138,15 @@ def can_comm_callbacks(logcan: messaging.SubSocket, sendcan: messaging.PubSocket
def curve_press_receipt(CS: car.CarState, helper: VCruiseHelper, sm, *, curve_replay: bool,
slc_replay: bool, host_long_active: bool, observed_ns: int):
"""Reserve SLC presentation presses even when their command is stale or rejected."""
"""A confirmation or native standstill RES must not also override Curve control."""
accelerate = frozenset((car.CarState.ButtonEvent.Type.accelCruise,
car.CarState.ButtonEvent.Type.resumeCruise))
pressed = {b.type.raw for b in CS.buttonEvents if b.pressed and b.type.raw in accelerate}
if (not curve_replay or not host_long_active or not CS.canValid or CS.canTimeout or not CS.cruiseState.available or
not pressed):
CS.standstill or CS.cruiseState.standstill or not pressed):
return None
if slc_replay and (helper.slc_consumed_button is not None or bool(pressed & helper.slc_suppressed_buttons) or
bool(sm['slcState'].hasPending)):
if (helper.slc_consumed_button is not None or bool(pressed & helper.slc_suppressed_buttons) or
slc_replay and bool(sm['slcState'].hasPending)):
return None
return ('curveAccelPress', None, float(helper.v_cruise_kph_last / 3.6),
float(helper.v_cruise_kph / 3.6), observed_ns)
@@ -187,10 +186,6 @@ class Car:
ignore_valid=['autoSteeringLearning'], ignore_avg_freq=['autoSteeringLearning'])
self.slc_cruise_event_id = 0
self.slc_producer_session = uuid.uuid4().hex
self.slc_command_sock = messaging.sub_sock('slcCruiseCommand', conflate=False) if self.slc_replay else None
self.slc_commands = deque(maxlen=8)
self.slc_last_command_id = 0
self.slc_command_session = ''
self.slc_receipts: list[tuple[str, object, float, float, int]] = []
self.manual_receipt = None
self.manual_event_sequence = 0
@@ -427,14 +422,13 @@ class Car:
for ignored in (self.sm.ignore_alive, self.sm.ignore_valid, self.sm.ignore_average_freq):
if 'slcState' not in ignored:
ignored.append('slcState')
if self.slc_command_sock is None:
self.slc_command_sock = messaging.sub_sock('slcCruiseCommand', conflate=False)
if 'slcDashboardObservation' not in self.pm.sock:
self.pm.sock['slcDashboardObservation'] = messaging.pub_sock('slcDashboardObservation')
self.slc_requested_configuration = self.read_slc_configuration()
self.slc_configuration = self.slc_requested_configuration
self.slc_replay = self.slc_configuration[0]
self.slc_control_enabled = self.slc_replay and self.slc_configuration[1].enabled
self.slc_match_on_engage = self.slc_transport_available and self.read_slc_match_on_engage()
self.slc_source_floor_ns = 0
# Write previous route's CarParams
@@ -475,6 +469,9 @@ class Car:
feature_enabled(self.params, self.CP, 'slc', os.environ))
return bool(admitted), settings
def read_slc_match_on_engage(self) -> bool:
return read_saved(self.params, 'SLCOnEngage', 1) == (b'1', True)
def refresh_slc_configuration(self, now_ns: int) -> None:
configuration = getattr(self, 'slc_requested_configuration', None)
if configuration is None or configuration == getattr(self, 'slc_configuration', None):
@@ -482,12 +479,8 @@ class Car:
self.slc_configuration = configuration
self.slc_replay = configuration[0]
self.slc_control_enabled = self.slc_replay and configuration[1].enabled
# Invalidate old SLC choices while keeping consumed buttons latched until release.
self.slc_source_floor_ns = max(getattr(self, 'slc_source_floor_ns', 0), now_ns)
self.slc_commands.clear()
self.slc_command_session = ''
self.slc_last_command_id = 0
# Keep the shared event producer and held suppression until physical release.
# Only SLC's sourced/issued work predating this admission is retired.
def observe_distance_personality(self, CS: car.CarState, now_ns: int) -> None:
tracker = getattr(self, 'distance_personality_tracker', None)
@@ -573,18 +566,22 @@ class Car:
0 < control_log_ns <= now_ns and now_ns - control_log_ns <= slc_physical.STATE_MAX_AGE_NS and
self.sm['carControl'].enabled)
host_control_enabled = bool(self.CP.openpilotLongitudinalControl and not self.CP.pcmCruise and host_enabled)
host_long_active = host_control_enabled and self.sm['carControl'].longActive
host_long_active = bool(self.CP.openpilotLongitudinalControl and host_enabled and self.sm['carControl'].longActive)
slc_long_active = bool(getattr(self, 'slc_control_enabled', self.slc_replay) and self.CP.openpilotLongitudinalControl and
host_enabled and (self.sm['carControl'].longActive or CS.gasPressed))
pending = None
if self.slc_replay:
pending = slc_physical.pending_confirmation(
self.sm, now_ns, long_active=slc_long_active, after_ns=floor_ns)
approved = (slc_physical.state_current(self.sm, observation, self.slc_producer_session, now_ns)
if self.v_cruise_helper.toyota is not None and self.slc_control_enabled else None)
if approved is not None and int(approved.frameMonoTime) <= floor_ns:
approved = None
self.update_cruise_speed(CS, pending, approved=approved)
wheel_context = None
slc_settings = getattr(self, 'slc_configuration', (False, None))[1]
cruise_helper = self.v_cruise_helper
if (self.slc_replay and getattr(slc_settings, 'wheel_override', False) and host_long_active and can_rcv_valid and
not self.CP.pcmCruise and pending is None and (cruise_helper.slc_wheel_press is not None or len(CS.buttonEvents) > 0) and
not (CS.brakePressed or CS.gasPressed or CS.standstill or CS.cruiseState.standstill)):
wheel_context = slc_physical.wheel_override_context(self.sm, now_ns, after_ns=floor_ns,
selected_kph=cruise_helper.v_cruise_kph, is_metric=self.is_metric)
self.update_cruise_speed(CS, pending, wheel_context=wheel_context)
if self.controller_cruise_sock is not None:
for _ in range(8):
request = messaging.recv_one_or_none(self.controller_cruise_sock)
@@ -593,6 +590,8 @@ class Car:
self.controller_cruise_consumer.apply(request, self.CP, CS, self.sm, self.v_cruise_helper,
now_ns=now_ns, is_metric=self.is_metric,
enabled=bool(host_control_enabled and can_rcv_valid and not self.params.get_bool("SafeMode")))
if self.slc_replay:
self.apply_slc_adopt(now_ns, host_control_enabled=host_control_enabled, after_ns=floor_ns)
consumed = self.v_cruise_helper.slc_consumed_button
if self.aol_card_intent is not None:
if self.aol_process_fault_context is None:
@@ -784,52 +783,6 @@ class Car:
slc_replay=self.slc_replay, host_long_active=host_long_active, observed_ns=now_ns)
if curve_receipt is not None:
self.slc_receipts.append(curve_receipt)
if self.slc_command_sock is not None:
for _ in range(8):
command_message = messaging.recv_one_or_none(self.slc_command_sock)
if command_message is None:
break
if self.slc_replay and command_message.valid:
self.slc_commands.append(command_message)
for _ in range(len(self.slc_commands)):
command_message = self.slc_commands.popleft()
command = command_message.slcCruiseCommand
if int(command.issuedMonoTime) <= floor_ns:
continue
if now_ns < int(command.issuedMonoTime):
if now_ns <= int(command.expiresMonoTime):
self.slc_commands.append(command_message)
continue
state = slc_physical.state_current(self.sm, observation, self.slc_producer_session, now_ns)
if (state is None or int(self.sm.logMonoTime['slcState']) < int(command.issuedMonoTime)) and now_ns < int(command.expiresMonoTime):
self.slc_commands.append(command_message)
continue
if state is not None and str(state.sessionId) != self.slc_command_session:
self.slc_command_session = str(state.sessionId)
self.slc_last_command_id = 0
if not self.slc_command_session:
self.slc_command_session = str(command.sessionId)
if str(command.sessionId) != self.slc_command_session or int(command.commandId) <= self.slc_last_command_id:
continue
self.slc_last_command_id = int(command.commandId)
previous_mps = float(self.v_cruise_helper.v_cruise_kph / 3.6)
origin = SlcPendingConfirmation(str(command.sessionId), int(command.decisionId), int(command.presentationId))
owners = {**self.v_cruise_helper.slc_suppression_owner, **self.v_cruise_helper.slc_released_owner}
allowed_buttons = {button for button, owner in owners.items() if owner == origin} if int(command.decisionId) > 0 else set()
intervening_button = (bool(self.v_cruise_helper.slc_cruise_change) or
any(b.type.raw not in allowed_buttons for b in CS.buttonEvents) or
any(timer > 0 and button not in allowed_buttons
for button, timer in self.v_cruise_helper.button_timers.items()))
applicable = slc_physical.command_applicable(
command, state, observation, self.slc_producer_session, now_ns, previous_mps,
long_active=slc_long_active,
button_event=intervening_button)
if self.v_cruise_helper.toyota is not None:
applicable = applicable and self.v_cruise_helper.toyota.can_apply(float(command.targetMps))
if applicable:
self.v_cruise_helper.apply_slc_target(float(command.targetMps), self.is_metric)
self.slc_receipts.append(('commandApplied' if applicable else 'commandRejected', command,
previous_mps, float(self.v_cruise_helper.v_cruise_kph / 3.6), now_ns))
blocked_keys = set()
if self.manual_receipt is not None:
blocked_keys.add(key_for(self.manual_receipt[0]))
@@ -845,28 +798,49 @@ class Car:
return CS, RD
def update_cruise_speed(self, CS, pending=None, *, approved=None):
def apply_slc_adopt(self, now_ns: int, *, host_control_enabled: bool, after_ns: int = 0) -> None:
pending = getattr(self, 'slc_wheel_adopt', None)
if pending is None:
return
wheel_ns, selected_kph = pending
helper = self.v_cruise_helper
if (not after_ns < wheel_ns <= now_ns <= wheel_ns + WHEEL_ACTION_MAX_AGE_NS or
not host_control_enabled or not self.slc_control_enabled or
not helper.v_cruise_initialized or helper.v_cruise_kph != selected_kph or any(helper.button_timers.values())):
self.slc_wheel_adopt = None
return
target = slc_physical.adopted_target(self.sm, now_ns, wheel_ns=wheel_ns)
if target is not None:
self.slc_wheel_adopt = None
helper.apply_slc_target(target, self.is_metric)
def update_cruise_speed(self, CS, pending=None, *, wheel_context: SlcWheelContext | None = None):
self.v_cruise_helper.update_v_cruise(CS, self.sm['carControl'].enabled, self.is_metric, pending,
slc_enabled=getattr(self, 'slc_control_enabled', getattr(self, 'slc_replay', False)),
slc_approved=approved)
wheel_context=wheel_context)
if self.sm['carControl'].enabled and not self.CC_prev.enabled:
initial = None
target, initial = None, None
now_ns = int(self.can_log_mono_time) if REPLAY and hasattr(self, 'can_log_mono_time') else time.monotonic_ns()
if (getattr(self, 'slc_control_enabled', False) and self.CP.openpilotLongitudinalControl and
if (getattr(self, 'slc_match_on_engage', False) and getattr(self, 'slc_control_enabled', False) and
not self.CP.pcmCruise and self.CP.openpilotLongitudinalControl and
CS.canValid and not CS.canTimeout and CS.cruiseState.available and
self.sm.valid['carControl'] and self.sm.alive['carControl'] and
0 < int(self.sm.logMonoTime['carControl']) <= now_ns and
now_ns - int(self.sm.logMonoTime['carControl']) <= slc_physical.STATE_MAX_AGE_NS and
(self.sm['carControl'].longActive or CS.gasPressed)):
evidence = getattr(self.CI.CS, 'dashboard_limit', None)
initial = slc_physical.engagement_state(
self.sm, evidence.observation if evidence is not None else None, self.slc_producer_session,
now_ns, after_ns=getattr(self, 'slc_source_floor_ns', 0))
# Initialization owns this edge; evaluate commands against its final speed.
now_ns - int(self.sm.logMonoTime['carControl']) <= slc_physical.STATE_MAX_AGE_NS):
long_requested = self.sm['carControl'].longActive or CS.gasPressed
if not long_requested and 'aolAxisState' in self.sm.logMonoTime:
# AOL can acknowledge the request before longitudinal actuators become active.
axis = current_axis(self.sm, now_ns=now_ns)
long_requested = axis is not None and axis.nativeAcknowledged and axis.desiredLongitudinal
if long_requested:
evidence = getattr(self.CI.CS, 'dashboard_limit', None)
target, initial = slc_physical.engagement_target(
self.sm, evidence.observation if evidence is not None else None, self.slc_producer_session,
now_ns, after_ns=getattr(self, 'slc_source_floor_ns', 0))
adopted = self.v_cruise_helper.initialize_v_cruise(
self.CS_prev, self.experimental_mode, resume=self.vehicle_startup.consume_cruise_resume(), initial_speed=self.initial_cruise_speed,
slc_target_mps=float(initial.presentationAdjustedLimit) if initial is not None else None, is_metric=self.is_metric)
if adopted:
slc_target_mps=target, is_metric=self.is_metric)
if adopted and initial is not None:
self.slc_receipts.append(('engagementAdopt', initial, 0.0, float(self.v_cruise_helper.v_cruise_kph / 3.6), now_ns))
def aol_cancel_source(self, CS, now_ns: int) -> int:
@@ -1135,11 +1109,6 @@ class Car:
elif self.CP.brand == 'hyundai':
from openpilot.starpilot.car.hyundai.dashboard import compose as hyundai_compose
companion = hyundai_compose(self.CP, self.CI.CS, CS, companion, car_state_stamp=int(cs_send.logMonoTime))
if self.v_cruise_helper.toyota is not None:
if companion is None:
companion = messaging.new_message('starpilotCarState', valid=bool(cs_send.valid), logMonoTime=int(cs_send.logMonoTime))
companion.starpilotCarState.sourceCarStateMonoTime = int(cs_send.logMonoTime)
self.v_cruise_helper.toyota.publish(companion.starpilotCarState.toyotaCruise)
self.car_gps_publisher.update(self.CI.CS, self.pm, companion)
self.pm.send('carState', cs_send)
if getattr(self, 'learning_stamp_observer', None) is not None:
@@ -1147,6 +1116,9 @@ class Car:
wheel = getattr(self, 'wheel_publisher', None)
if wheel is not None:
if (getattr(self, 'slc_control_enabled', False) and self.CC_prev.enabled and self.sm['carControl'].enabled and
any(action == 10 for _, action in self.wheel_commands)):
self.slc_wheel_adopt = (int(cs_send.logMonoTime), self.v_cruise_helper.v_cruise_kph)
wheel.publish(self.wheel_commands, self.CP, self.pm, now_ns=int(cs_send.logMonoTime),
drive_id=int(self.sm['deviceState'].startedMonoTime), source_car_ns=int(cs_send.logMonoTime),
source_control_ns=int(self.sm.logMonoTime['carControl']))
@@ -1186,6 +1158,8 @@ class Car:
if kind == 'driverChange':
assert isinstance(detail, tuple) and len(detail) == 4
record.button, record.longPress = detail[2], detail[3]
if self.v_cruise_helper.slc_wheel_tap is not None:
record.sessionId, record.wheelOverrideSince = self.v_cruise_helper.slc_wheel_tap
elif kind in ('confirmationAccept', 'confirmationReject'):
record.button = detail.button
record.sessionId = detail.pending.session_id
@@ -1193,13 +1167,11 @@ class Car:
record.presentationId = detail.pending.presentation_id
elif kind == 'curveAccelPress':
record.button = 'accel'
else:
elif kind == 'engagementAdopt':
record.sessionId = str(detail.sessionId)
record.commandId = 0 if kind == 'engagementAdopt' else int(detail.commandId)
record.decisionId = int(detail.decisionId)
record.presentationId = int(detail.presentationId)
if kind == 'engagementAdopt':
record.button = 'decel'
record.button = 'decel'
self.pm.send('slcCruiseEvent', event)
if getattr(self, 'conditional_replay', False) and self.manual_receipt is not None:
@@ -1619,6 +1591,10 @@ class Car:
if physical.button_credit_ns - self.volt_cc_boot_offset_ns <= max(self.volt_cc_drive_id, self.volt_cc_source_floor_ns):
self.CI.CC.volt_cc_consumed_source_ns = physical.button_credit_ns
self.CI.CC.volt_cc_metric = self.is_metric
self.CI.CC.volt_cc_speed_ceiling_mps = (slc_physical.active_ceiling(
self.sm, self.volt_cc_now_mono_ns,
after_ns=max(self.volt_cc_drive_id, self.volt_cc_source_floor_ns, getattr(self, 'slc_source_floor_ns', 0)))
if getattr(self, 'slc_control_enabled', False) else None)
if getattr(self.CI.CC, "bolt_cc_profile", False):
self.CI.CC.bolt_cc_metric = self.is_metric
@@ -1871,6 +1847,8 @@ class Car:
now = time.monotonic()
if now >= next_cruise_read:
self.slc_requested_configuration = self.read_slc_configuration()
if getattr(self, 'slc_transport_available', False):
self.slc_match_on_engage = self.read_slc_match_on_engage()
self.v_cruise_helper.intervals = read_cruise_intervals(self.params, self.CP)
pitch_input = getattr(self.CI.CC, 'long_pitch_input', None)
if pitch_input is not None:
+40 -59
View File
@@ -43,6 +43,9 @@ class SlcConsumedButton:
button: str
SlcWheelContext = tuple[str, int]
def validated_initial_cruise_speed(value: object) -> int:
if type(value) is int and V_CRUISE_INITIAL <= value <= V_CRUISE_MAX:
return value
@@ -66,35 +69,27 @@ class VCruiseHelper:
self.button_change_states = {btn: {"standstill": False, "enabled": False} for btn in self.button_timers}
self.pcm_slc_sample: tuple[float, int] | None = None
self.pcm_slc_button = ButtonType.unknown
# Set by software cruise steps or a checked Toyota PCM physical +/- change.
self.slc_cruise_change: tuple[float, float, str, bool] | None = None
self.slc_consumed_button: SlcConsumedButton | None = None
self.slc_suppressed_buttons: set[ButtonType] = set()
self.slc_suppression_owner: dict[ButtonType, SlcPendingConfirmation] = {}
self.slc_released_owner: dict[ButtonType, SlcPendingConfirmation] = {}
self.slc_released_suppressed = False
self.slc_pcm = False
from openpilot.starpilot.speed_limits.toyota import eligible, ToyotaCruise
self.toyota = ToyotaCruise() if eligible(CP) else None
self.slc_wheel_press: SlcWheelContext | None = None
self.slc_wheel_tap: SlcWheelContext | None = None
@property
def v_cruise_initialized(self):
return self.v_cruise_kph != V_CRUISE_UNSET
def update_v_cruise(self, CS, enabled, is_metric, slc_pending: SlcPendingConfirmation | None = None, *, slc_enabled: bool = False,
slc_approved=None):
self.slc_pcm = bool(slc_enabled and self.CP.pcmCruise and self.CP.openpilotLongitudinalControl)
def update_v_cruise(self, CS, enabled, is_metric, slc_pending: SlcPendingConfirmation | None = None, *,
slc_enabled: bool = False, wheel_context: SlcWheelContext | None = None):
from opendbc.car.subaru.software_cruise import selected as subaru_software_selected
subaru_software = subaru_software_selected(self.CP)
software_cruise = not self.CP.pcmCruise or self.slc_pcm or subaru_software
software_cruise = not self.CP.pcmCruise or subaru_software
self.slc_cruise_change = None
self.slc_consumed_button = None
self.slc_released_suppressed = False
self.slc_released_owner = {}
self.slc_wheel_tap = None
self.v_cruise_kph_last = self.v_cruise_kph
if self.toyota is not None:
self.slc_pcm = self.toyota.update(self, CS, enabled, slc_enabled, slc_approved, is_metric)
software_cruise = self.slc_pcm
now_ns = monotonic_ns()
if not CS.cruiseState.available or now_ns - self.pending_resume_ns > 500_000_000:
self.pending_resume_ns = 0
@@ -106,21 +101,26 @@ class VCruiseHelper:
button.type in (ButtonType.accelCruise, ButtonType.resumeCruise) and self.v_cruise_initialized):
self.pending_resume_ns = now_ns
if software_cruise or self.slc_suppressed_buttons:
if software_cruise or slc_enabled and self.CP.openpilotLongitudinalControl or self.slc_suppressed_buttons:
self._update_slc_button_latches(CS, enabled, slc_pending)
if (not slc_enabled or not enabled or self.CP.pcmCruise or slc_pending is not None or
not CS.cruiseState.available or ButtonType.accelCruise in self.slc_suppressed_buttons):
wheel_context = None
if wheel_context != self.slc_wheel_press:
self.slc_wheel_press = None
if wheel_context is not None and self.button_timers[ButtonType.accelCruise] == 0:
pressed = {b.type.raw for b in CS.buttonEvents if b.pressed}
if ButtonType.accelCruise in pressed and ButtonType.resumeCruise not in pressed:
self.slc_wheel_press = wheel_context
if CS.cruiseState.available:
if software_cruise:
if self.toyota is None and (self.slc_pcm or subaru_software) and (not enabled or not self.v_cruise_initialized):
# PCM still owns engagement; SLC owns the software set speed while system long is selected.
if subaru_software and (not enabled or not self.v_cruise_initialized):
stock_kph = CS.cruiseState.speed * CV.MS_TO_KPH
self.v_cruise_kph = stock_kph if 0 < stock_kph < V_CRUISE_UNSET else V_CRUISE_UNSET
# if stock cruise is completely disabled, then we can use our own set speed logic
self._update_v_cruise_non_pcm(CS, enabled, is_metric)
if self.toyota is None:
self.v_cruise_cluster_kph = self.v_cruise_kph
if self.toyota is not None:
self.toyota.constrain(self)
self.v_cruise_cluster_kph = self.v_cruise_kph
self.update_button_timers(CS, enabled)
else:
self.v_cruise_kph = CS.cruiseState.speed * CV.MS_TO_KPH
@@ -139,6 +139,8 @@ class VCruiseHelper:
self.pcm_slc_button = ButtonType.unknown
if software_cruise:
self.update_button_timers(CS, enabled)
if self.button_timers[ButtonType.accelCruise] == 0:
self.slc_wheel_press = None
def _update_toyota_pcm_slc_change(self, CS, enabled: bool, now_ns: int) -> None:
"""Observe a physical PCM speed change without writing stock selected speed."""
@@ -165,8 +167,9 @@ class VCruiseHelper:
held = ButtonType.unknown
if continuous:
delta = self.v_cruise_kph - previous[0]
if ((button == ButtonType.accelCruise and delta > 0.) or
(button == ButtonType.decelCruise and delta < 0.)):
if (not self.slc_suppressed_buttons and not self.slc_released_suppressed and
((button == ButtonType.accelCruise and delta > 0.) or
(button == ButtonType.decelCruise and delta < 0.))):
self.slc_cruise_change = (previous[0] / 3.6, self.v_cruise_kph / 3.6,
'accel' if delta > 0. else 'decel', False)
self.pcm_slc_button = held
@@ -179,17 +182,13 @@ class VCruiseHelper:
continue
if not b.pressed and button in self.slc_suppressed_buttons:
self.slc_suppressed_buttons.remove(button)
prior_owner = self.slc_suppression_owner.pop(button, None)
if prior_owner is not None:
self.slc_released_owner[button] = prior_owner
self.slc_released_suppressed = True
self.button_timers[button] = 0
elif (b.pressed and enabled and CS.cruiseState.available and slc_pending is not None and
not (self.toyota is not None and button == ButtonType.accelCruise and
not (self.CP.pcmCruise and button == ButtonType.accelCruise and
(CS.standstill or CS.cruiseState.standstill or not CS.cruiseState.enabled)) and
button not in self.slc_suppressed_buttons):
self.slc_suppressed_buttons.add(button)
self.slc_suppression_owner[button] = slc_pending
self.slc_consumed_button = SlcConsumedButton(slc_pending, 'accel' if button == ButtonType.accelCruise else 'decel')
def _update_v_cruise_non_pcm(self, CS, enabled, is_metric):
@@ -231,26 +230,20 @@ class VCruiseHelper:
if not self.button_change_states[button_type]["enabled"]:
return
self.adjust_v_cruise(button_type, CS, is_metric, long_press=long_press)
changed = self.adjust_v_cruise(button_type, CS, is_metric, long_press=long_press)
if changed and button_type == ButtonType.accelCruise and not long_press and self.button_timers[button_type] > 0:
self.slc_wheel_tap = self.slc_wheel_press
def adjust_v_cruise(self, button_type, CS, is_metric: bool, *, long_press: bool = False) -> bool:
"""Apply the normal software cruise step without synthesizing button events."""
from opendbc.car.subaru.software_cruise import selected as subaru_software_selected
subaru_software = subaru_software_selected(self.CP)
if ((self.CP.pcmCruise and not self.slc_pcm and not subaru_software) or button_type not in (ButtonType.accelCruise, ButtonType.decelCruise) or
if ((self.CP.pcmCruise and not subaru_software) or button_type not in (ButtonType.accelCruise, ButtonType.decelCruise) or
not 0 < self.v_cruise_kph < V_CRUISE_UNSET or
(button_type == ButtonType.accelCruise and CS.cruiseState.standstill)):
return False
v_cruise_delta = 1. if is_metric else IMPERIAL_INCREMENT
previous_kph = self.v_cruise_kph
toyota = self.toyota if self.slc_pcm else None
previous_cluster_kph = self.v_cruise_cluster_kph
if toyota is not None:
if toyota.requested is None:
return False
if button_type == ButtonType.decelCruise:
previous_kph = toyota.requested[0]
self.v_cruise_kph = toyota.requested[1]
interval = self.intervals.held if long_press else self.intervals.short
v_cruise_delta = v_cruise_delta * interval
display_speed = round(self.v_cruise_kph if is_metric else self.v_cruise_kph * CV.KPH_TO_MPH)
@@ -266,11 +259,6 @@ class VCruiseHelper:
self.v_cruise_kph = np.clip(round(self.v_cruise_kph, 1), V_CRUISE_MIN, V_CRUISE_MAX)
self.v_cruise_cluster_kph = self.v_cruise_kph
if toyota is not None:
toyota.requested = (float(self.v_cruise_kph), float(self.v_cruise_cluster_kph))
toyota.manual_step = (previous_kph, previous_cluster_kph, 'accel' if button_type == ButtonType.accelCruise else 'decel', long_press)
toyota.constrain(self)
return self.v_cruise_kph != previous_kph
if 0 < previous_kph < V_CRUISE_UNSET and self.v_cruise_kph != previous_kph:
button = "accel" if button_type == ButtonType.accelCruise else "decel"
self.slc_cruise_change = (float(previous_kph / 3.6), float(self.v_cruise_kph / 3.6), button, long_press)
@@ -291,23 +279,19 @@ class VCruiseHelper:
self.button_change_states[b.type.raw] = {"standstill": CS.cruiseState.standstill, "enabled": enabled}
def apply_slc_target(self, target_mps: float, is_metric: bool) -> None:
"""Apply a qualified planner command to the card-owned software set speed."""
"""Set the software cruise ceiling for engagement or a qualified driver request."""
# Match ordinary button precision so stepping away and back restores this target.
self.v_cruise_kph = slc_target_kph(target_mps, is_metric)
self.v_cruise_cluster_kph = self.v_cruise_kph
if self.toyota is not None:
self.toyota.manual_step = None
self.toyota.constrain(self)
self.toyota.requested = (self.v_cruise_kph, self.v_cruise_cluster_kph)
def initialize_v_cruise(self, CS, experimental_mode: bool, *, resume: bool | None = None,
initial_speed: object = V_CRUISE_INITIAL, slc_target_mps: float | None = None, is_metric: bool = False) -> bool:
"""Return whether engagement adopts the qualified current SLC limit."""
"""Return whether engagement used the supplied SLC target."""
if (not self.CP.openpilotLongitudinalControl or slc_target_mps is None or type(slc_target_mps) not in (int, float) or
not math.isfinite(slc_target_mps) or not V_CRUISE_MIN / 3.6 - 1e-5 <= slc_target_mps <= V_CRUISE_MAX / 3.6 + 1e-5):
slc_target_mps = None
# PCM initializes when SLC does not own SET.
if self.CP.pcmCruise and (not self.slc_pcm or slc_target_mps is None):
# Native cruise owns PCM SET, including when SLC supplies a planner cap.
if self.CP.pcmCruise:
return False
# A car-owned acknowledgment can outlive the physical button event.
if resume is None:
@@ -315,15 +299,12 @@ class VCruiseHelper:
resume = (0 < self.pending_resume_ns and 0 <= age <= 500_000_000) or any(
b.type in (ButtonType.accelCruise, ButtonType.resumeCruise) for b in CS.buttonEvents)
self.pending_resume_ns = 0
if slc_target_mps is None and resume and self.v_cruise_initialized:
if not self.CP.pcmCruise:
self.v_cruise_kph = self.v_cruise_kph_last
self.v_cruise_cluster_kph = self.v_cruise_kph
if resume and self.v_cruise_initialized:
self.v_cruise_kph = self.v_cruise_kph_last
self.v_cruise_cluster_kph = self.v_cruise_kph
return False
elif slc_target_mps is not None:
# Toyota still needs its native capacity/release approval before application.
if self.toyota is None:
self.apply_slc_target(slc_target_mps, is_metric)
self.apply_slc_target(slc_target_mps, is_metric)
return True
else:
self.v_cruise_kph = int(round(np.clip(max(CS.vEgo * CV.MS_TO_KPH, validated_initial_cruise_speed(initial_speed)), V_CRUISE_INITIAL, V_CRUISE_MAX)))
@@ -52,7 +52,7 @@ class TestCruiseIntervals(unittest.TestCase):
Path(self.params.get_param_path("CustomCruiseLong")).write_bytes(b"200")
self.assertEqual(read_cruise_intervals(self.params, self.cp), CruiseIntervals())
def test_slc_uses_saved_intervals_for_every_other_make_and_pcm_mode(self):
def test_slc_preserves_software_intervals_and_native_pcm_set(self):
brands = [brand.__module__.split('.')[-2] for brand in BRANDS if brand.__module__.split('.')[-2] != 'toyota'] + ['future']
self.params.put("CustomCruise", 3.0, block=True)
self.params.put("CustomCruiseLong", 7.0, block=True)
@@ -67,6 +67,11 @@ class TestCruiseIntervals(unittest.TestCase):
helper.v_cruise_kph = helper.v_cruise_cluster_kph = initial
helper.intervals = read_cruise_intervals(self.params, cp)
helper.update_v_cruise(state(button, True, native=140.), True, metric, slc_enabled=True)
if pcm:
helper.update_v_cruise(state(button, False, native=140.), True, metric, slc_enabled=True)
self.assertAlmostEqual(helper.v_cruise_kph, 140., places=4)
self.assertIsNone(helper.slc_cruise_change)
continue
if held:
for _ in range(CRUISE_LONG_PRESS):
helper.update_v_cruise(state(native=140.), True, metric, slc_enabled=True)
@@ -373,12 +373,11 @@ class LongitudinalPlanner:
cruise_profile = replace(cruise_profile, cruise_brake_magnitude=global_magnitude)
if sm['controlsState'].forceDecel:
cruise_profile = None
elif (ceiling.effective_mps is not None or curve.effective_mps is not None) and cruise_profile is not None:
# An eligible lower ceiling must retain at least native approach
# braking even when the saved profile requests a softer cruise response.
elif curve.effective_mps is not None and cruise_profile is not None:
# Curve approach retains native braking when the saved cruise response is softer.
cruise_profile = replace(cruise_profile, cruise_brake_magnitude=max(cruise_profile.cruise_brake_magnitude,
abs(A_CRUISE_MIN)))
if ceiling.effective_mps is not None or curve.effective_mps is not None:
if curve.effective_mps is not None:
global_floor = min(global_floor, A_CRUISE_MIN)
if selected_acceleration is not None and cruise_profile is not None:
# The selected curve owns acceleration; following/jerks retain their owner.
+37 -116
View File
@@ -1,8 +1,6 @@
#!/usr/bin/env python3
import os
import json
from collections import deque
from dataclasses import replace
from opendbc.car.structs import car
from openpilot.common.params import Params
@@ -12,8 +10,9 @@ from openpilot.selfdrive.controls.lib.ldw import LaneDepartureWarning
from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner, NATIVE_PLAN_INPUTS
import openpilot.cereal.messaging as messaging
from openpilot.cereal.services import SERVICE_LIST
from openpilot.starpilot.speed_limits import history as slc_history
from openpilot.starpilot.speed_limits.runtime import Action as SlcAction, CruiseEvent as SlcCruiseEvent, Runtime as SlcRuntime
from openpilot.starpilot.speed_limits.planner_host import (
SLC_ACTIONS, SavedSlcLifecycle, action_from_message, cruise_event_from_message, current_cruise_event, receive_slc_events,
)
from openpilot.starpilot.longitudinal.profile_runtime import ProfileHost
from openpilot.starpilot.longitudinal.lane_change_gap import LaneGapPreferences
from openpilot.starpilot.longitudinal.experimental_release import ConditionalHandoff
@@ -40,62 +39,8 @@ from openpilot.starpilot.feature_capabilities import longitudinal_supported
from openpilot.starpilot.conditional_mode.projection import paired_clocks_ns
from openpilot.starpilot.conditional_mode.ui_action import observation as ui_manual_observation
from openpilot.starpilot.longitudinal.cruise_ceiling import CurveCeiling
from openpilot.starpilot.feature_runtime import (enabled as feature_enabled, requested as feature_requested,
slc_runtime_settings, slc_transport_capable, vision_control_enabled)
from openpilot.starpilot.feature_runtime import enabled as feature_enabled, requested as feature_requested, slc_transport_capable
import time
from openpilot.starpilot.speed_limits.runtime_settings import read_params as read_slc_settings
class SavedSlcLifecycle:
"""Refresh saved admission; offsets and display units preserve driver choices."""
def __init__(self, params, cp, environment):
self.params, self.cp, self.environment = params, cp, dict(environment)
self.runtime = None
self.configuration = None
self.last_refresh_ns = None
def refresh(self, now_ns: int, *, pending_actions=None, pending_cruise=None):
if self.last_refresh_ns is not None and 0 <= now_ns - self.last_refresh_ns < 1_000_000_000:
return self.runtime, False
self.last_refresh_ns = now_ns
# One immutable parsed snapshot owns this refresh. Repeated qualification
# used to reopen the same saved keys five times in a due model iteration.
saved_slc = read_slc_settings(self.params)
admitted = feature_enabled(self.params, self.cp, 'slc', self.environment, saved_slc=saved_slc)
settings = slc_runtime_settings(self.params, self.cp, self.environment, saved_slc=saved_slc)
vision = admitted and feature_enabled(self.params, self.cp, 'vision', self.environment, saved_slc=saved_slc)
configuration = (admitted and not settings.errors, settings, vision,
vision_control_enabled(self.params, self.cp, saved_slc=saved_slc),
feature_enabled(self.params, self.cp, 'vision', {}, saved_slc=saved_slc),
self.params.get_bool('IsMetric'))
if configuration == self.configuration:
return self.runtime, False
previous_runtime = self.runtime
if (self.runtime is not None and self.configuration is not None and
configuration[:-1] == (self.configuration[0], replace(self.runtime.settings, offsets=settings.offsets,
advance_seconds=settings.advance_seconds),
*self.configuration[2:-1])):
if settings.offsets != self.runtime.settings.offsets or configuration[5] != self.configuration[5]:
self.runtime.update_offsets(settings.offsets, is_metric=configuration[5])
if settings.advance_seconds != self.runtime.settings.advance_seconds:
self.runtime.update_advance(settings.advance_seconds)
else:
set_speed_after_ns = self.runtime.set_speed_after_ns if self.runtime is not None else 0
if self.runtime is not None:
self.runtime.reset()
self.runtime = (SlcRuntime(settings, vision_enabled=vision,
vision_control_qualified=configuration[3],
vision_display_qualified=configuration[4], is_metric=configuration[5])
if configuration[0] else None)
if self.runtime is not None:
self.runtime.set_speed_after_ns = set_speed_after_ns
self.configuration = configuration
if pending_actions is not None:
pending_actions.clear()
if pending_cruise is not None and (self.runtime is None or self.runtime is not previous_runtime):
pending_cruise.clear()
return self.runtime, True
def profile_for_frame(host: ProfileHost | None, sm, CP, now_ns: int, *, traffic_mode: bool | None = False):
@@ -159,11 +104,13 @@ def traffic_profile_status(mode: bool | None, target, applied, settings) -> tupl
def curve_for_frame(host: CurveHost | None, sm, CP, now_ns: int, *, drive_id: int = 0,
event: CurveDriverEvent | None = None, follow_time_s: float | None = None):
event: CurveDriverEvent | None = None, follow_time_s: float | None = None,
cruise_ceiling_mps: float | None = None):
"""A diagnostic runtime result is not planner authority by itself."""
if host is None:
return None, None
result = host.sample(sm, CP, now_ns, drive_id=drive_id, event=event, follow_time_s=follow_time_s)
result = host.sample(sm, CP, now_ns, drive_id=drive_id, event=event, follow_time_s=follow_time_s,
cruise_ceiling_mps=cruise_ceiling_mps)
if result.ceiling_mps is None:
return None, result
model_ns = sm.logMonoTime.get('modelV2', 0)
@@ -177,6 +124,12 @@ def confirm_curve_frame(host: CurveHost | None, planner: LongitudinalPlanner, no
host.runtime.confirm_applied(now_ns, applied=bool(planner.last_curve_ceiling_applied))
def wheel_override_blocked(force_stop: ForceStopRuntime | None, curve_host: CurveHost | None, *, curve_backlog: bool = False) -> bool:
# Force Stop can consume RES on the tick it commits, before a forcing plan is published.
return ((force_stop is not None and (force_stop.policy.can_commit_next_frame or force_stop.plan.speed_ceiling_mps is not None)) or
(curve_host is not None and (curve_backlog or curve_host.runtime.was_controlling)))
def conditional_handoff_for_frame(host: ConditionalPlannerHost | None, sm, CP, now_ns: int,
drive_id: int) -> ConditionalHandoff | None:
if host is None or not CP.openpilotLongitudinalControl or CP.passive or CP.dashcamOnly:
@@ -290,7 +243,8 @@ def update_curve_frame(planner: LongitudinalPlanner, sm, CP, now_ns: int, *, hos
results = []
def provider(follow_time_s: float | None):
ceiling, result = curve_for_frame(host, sm, CP, now_ns, drive_id=drive_id,
event=event, follow_time_s=follow_time_s)
event=event, follow_time_s=follow_time_s,
cruise_ceiling_mps=cruise_ceiling.speed_mps if cruise_ceiling is not None else None)
results.append(result)
return ceiling
planner.update(sm, cruise_ceiling=cruise_ceiling, profile_tuning=profile_tuning,
@@ -320,18 +274,7 @@ def queue_cruise_event(event, slc_events, curve_events, conditional_events=None,
curve_events.append(CurveDriverEvent(str(cruise.producerSessionId), int(cruise.eventId),
int(cruise.observedMonoTime), kind, str(cruise.button), int(event.logMonoTime)))
else:
slc_events.append(SlcCruiseEvent(int(cruise.eventId), int(cruise.observedMonoTime),
float(cruise.previousMps), float(cruise.selectedMps), str(cruise.button),
str(cruise.producerSessionId), int(event.logMonoTime), kind,
str(cruise.sessionId), int(cruise.decisionId),
int(cruise.presentationId), int(cruise.commandId)))
def current_cruise_event(events, now_ns: int):
max_event_age_ns = int(2e9 / SERVICE_LIST['modelV2'].frequency)
while events and now_ns - events[0].observed_ns > max_event_age_ns:
events.popleft()
return events.popleft() if events and events[0].observed_ns <= now_ns else None
slc_events.append(cruise_event_from_message(event))
def queue_ui_action(event, slc_actions, conditional_actions, mode_actions=None) -> None:
@@ -343,9 +286,8 @@ def queue_ui_action(event, slc_actions, conditional_actions, mode_actions=None)
mode_actions.append(event)
elif kind == 'conditionalModeCycle':
conditional_actions.append(event)
elif kind in ('accept', 'reject', 'adopt'):
slc_actions.append((int(event.logMonoTime), SlcAction(str(request.sessionId), int(request.sequenceId),
int(request.decisionId), int(request.presentationId), kind)))
elif kind in SLC_ACTIONS:
slc_actions.append(action_from_message(event))
def current_ui_manual_event(events, now_ns: int):
@@ -417,7 +359,7 @@ def starpilot_main():
pending_switchback = deque(maxlen=8)
mode_settings = ConditionalSettingsOwner(params) if mode_owner is not None else None
slc_lifecycle = SavedSlcLifecycle(params, CP, os.environ)
slc_runtime, _ = slc_lifecycle.refresh(0)
slc_lifecycle.refresh(0)
slc_action_sock = (messaging.sub_sock("slcAction", conflate=False)
if slc_available or conditional_host is not None or mode_owner is not None else None)
slc_cruise_sock = messaging.sub_sock("slcCruiseEvent", conflate=False)
@@ -431,18 +373,14 @@ def starpilot_main():
publish_services = ['longitudinalPlan', 'starpilotLongitudinalPlan', 'driverAssistance']
if slc_available or curve_host is not None or conditional_host is not None or traffic_owner is not None or mode_owner is not None:
publish_services.append('slcState')
if slc_available:
publish_services.append('slcCruiseCommand')
pm = messaging.PubMaster(publish_services)
native_plan_checks = list(NATIVE_PLAN_INPUTS)
subscribed_services = list(NATIVE_PLAN_INPUTS)
subscribed_services.append('deviceState')
if conditional_host is not None:
subscribed_services.append('starpilotRadarState')
optional_slc = ['slcDashboardObservation', 'slcVisionObservation', 'mapdOut', 'starpilotCarState'] if slc_available else []
if slc_available and feature_enabled(params, CP, 'aol', os.environ):
optional_slc.append('aolAxisState')
if force_stop_owner is not None and 'starpilotCarState' not in optional_slc:
optional_slc = slc_lifecycle.source_services() if slc_available else []
if force_stop_owner is not None:
optional_slc.append('starpilotCarState')
subscribed_services.extend(optional_slc)
subscribed_services.append('starpilotNavigation')
@@ -459,16 +397,14 @@ def starpilot_main():
# Due saved-setting verification can block on Params. Complete it
# before acquiring the model and fast-source batch, not after that
# batch has already begun aging toward the CEM freshness deadline.
slc_runtime, _ = slc_lifecycle.refresh(time.monotonic_ns(), pending_actions=pending_slc_actions,
pending_cruise=pending_slc_cruise)
slc_lifecycle.refresh(time.monotonic_ns(), pending_actions=pending_slc_actions, pending_cruise=pending_slc_cruise)
sm.update()
if sm.updated['modelV2']:
# Only explicit replay uses the recorded model timeline. In a live host,
# modelV2 may lag newer inputs in the same SubMaster update.
now_ns = int(sm.logMonoTime['modelV2']) if 'REPLAY' in os.environ else time.monotonic_ns()
if 'REPLAY' in os.environ:
slc_runtime, _ = slc_lifecycle.refresh(now_ns, pending_actions=pending_slc_actions,
pending_cruise=pending_slc_cruise)
slc_lifecycle.refresh(now_ns, pending_actions=pending_slc_actions, pending_cruise=pending_slc_cruise)
current_start_ns = (int(sm['deviceState'].startedMonoTime)
if 'deviceState' in subscribed_services and sm.valid['deviceState'] and
sm.alive['deviceState'] and 0 < sm.logMonoTime['deviceState'] <= now_ns else 0)
@@ -479,10 +415,7 @@ def starpilot_main():
planner_status = None
drive_changed = current_start_ns > 0 and drive_start_ns > 0 and current_start_ns != drive_start_ns
if slc_cruise_sock is not None:
for _ in range(8):
event = messaging.recv_one_or_none(slc_cruise_sock)
if event is None:
break
for event in receive_slc_events(slc_cruise_sock):
if str(event.slcCruiseEvent.kind) == 'wheelAction' and event.valid:
pending_wheel.append(event)
continue
@@ -505,10 +438,7 @@ def starpilot_main():
if traffic_owner is not None:
traffic_owner.reset()
if slc_action_sock is not None:
for _ in range(8):
event = messaging.recv_one_or_none(slc_action_sock)
if event is None:
break
for event in receive_slc_events(slc_action_sock):
queue_ui_action(event, pending_slc_actions, pending_conditional_ui, pending_modes)
waiting_wheel = deque(maxlen=32)
while pending_wheel:
@@ -520,25 +450,13 @@ def starpilot_main():
else:
wheel_coast.receive(event, params, CP, sm, now_ns=now_ns)
pending_wheel = waiting_wheel
if slc_runtime is not None:
if drive_changed or slc_runtime.state.reset_required or slc_runtime.ledger.reset_required:
slc_runtime.reset()
pending_slc_actions.clear()
pending_slc_cruise.clear()
max_event_age_ns = int(2e9 / SERVICE_LIST['modelV2'].frequency)
while pending_slc_actions and now_ns - pending_slc_actions[0][0] > max_event_age_ns:
pending_slc_actions.popleft()
cruise_event = current_cruise_event(pending_slc_cruise, now_ns)
request = (pending_slc_actions.popleft()[1] if cruise_event is None and pending_slc_actions and
pending_slc_actions[0][0] <= now_ns else None)
output = slc_runtime.step(sm, CP, now_ns=now_ns, request=request, cruise_event=cruise_event)
planner_status = output.message
if output.command is not None:
pm.send('slcCruiseCommand', output.command)
history_write = output.result.acceptance.history_write if output.result.acceptance is not None else None
# A camera episode has no road-location identity for a later drive.
if history_write is not None and history_write.candidate.source != 'vision':
params.put("SLCQualifiedHistory", json.loads(slc_history.encode(history_write, time.time_ns())))
# Curve observes the press before SLC sees the release. A queued Curve press can still own the tap.
curve_event = current_cruise_event(pending_curve_events, now_ns)
slc_output = slc_lifecycle.step(sm, now_ns, pending_slc_actions, pending_slc_cruise, drive_changed=drive_changed,
wheel_override_blocked=wheel_override_blocked(force_stop_owner, curve_host, curve_backlog=bool(pending_curve_events)))
cruise_ceiling = slc_output.result.ceiling if slc_output is not None else None
if slc_output is not None:
planner_status = slc_output.message
needs_clock = conditional_host is not None or traffic_owner is not None or force_stop_owner is not None or mode_owner is not None
clock_pair = paired_clocks_ns() if needs_clock and 'REPLAY' not in os.environ else None
controller_traffic_toggle = bool(wheel_coast.traffic_toggles % 2)
@@ -598,7 +516,6 @@ def starpilot_main():
lead_approach_key = lead_approach_for_frame(approach_preferences, sm, CP, now_ns)
if curve_preferences is not None and curve_host is not None:
curve_preferences.refresh(curve_host, now_ns)
curve_event = current_cruise_event(pending_curve_events, now_ns)
conditional_handoff = (conditional_handoff_for_frame(conditional_host, sm, CP, clock_pair[0], current_start_ns)
if clock_pair is not None else None)
wheel_coast.conditional_stop = bool(conditional_handoff is not None and conditional_host is not None and
@@ -615,6 +532,7 @@ def starpilot_main():
drive_id=current_start_ns, follow_seconds=follow_seconds,
traffic_mode=traffic_mode, takeoff_enabled=faster_lead_takeoff)
curve_result = update_curve_frame(longitudinal_planner, sm, CP, now_ns, host=curve_host,
cruise_ceiling=cruise_ceiling,
profile_tuning=profile_tuning,
traffic_mode=traffic_mode, lane_change_policy=lane_change_policy,
global_braking_response=global_braking_response, selected_profiles=selected_profiles,
@@ -641,6 +559,7 @@ def starpilot_main():
proposal, snapshot = conditional_host.sample(
sm, CP, longitudinal_planner, now_mono_ns=conditional_now_ns, now_boot_ns=conditional_boot_ns,
sample_skew_ns=conditional_skew_ns, drive_id=current_start_ns,
cruise_ceiling_mps=cruise_ceiling.speed_mps if cruise_ceiling is not None else None,
owner_context=ConditionalOwnerContext(
traffic_mode=traffic_observation(traffic_verdict, model_ns=int(sm.logMonoTime['modelV2']),
owner_present=traffic_owner is not None),
@@ -675,6 +594,8 @@ def starpilot_main():
mode_owner.sample('switchback', sm, CP, now_ns=now_ns), session=mode_session,
sequence=mode_sequence, now_ns=now_ns, source_car_control_ns=int(sm.logMonoTime['carControl']))
if planner_status is not None:
if planner_status.slcState.wheelOverrideSince and wheel_override_blocked(force_stop_owner, curve_host):
planner_status.slcState.wheelOverrideSince = 0
pm.send('slcState', planner_status)
if current_start_ns > 0:
drive_start_ns = current_start_ns
@@ -9,6 +9,7 @@ import unittest
from openpilot.cereal import messaging
from openpilot.common.params import Params
from openpilot.starpilot import feature_runtime
from openpilot.starpilot.speed_limits import planner_host as slc_host
from openpilot.starpilot.speed_limits.runtime_settings import read_params as read_slc_settings
from openpilot.selfdrive.controls import plannerd
from openpilot.starpilot.conditional_mode.tests import test_status_consumer as transport
@@ -98,7 +99,7 @@ class TestCemInputAcquisitionOrder(unittest.TestCase):
def read(params):
reads.append(True)
return read_slc_settings(params)
with patch.object(plannerd, 'read_slc_settings', side_effect=read, create=True), \
with patch.object(slc_host, 'read_slc_settings', side_effect=read), \
patch.object(feature_runtime, 'read_slc_settings', side_effect=read):
runtime, changed = owner.refresh(MONO)
self.assertTrue(changed)
@@ -23,21 +23,21 @@ from openpilot.starpilot.conditional_mode.projection import ConditionalOwnerCont
from openpilot.starpilot.conditional_mode.runtime_settings import DOCUMENT_KEY
from openpilot.starpilot.conditional_mode.status import settings_fingerprint
from openpilot.starpilot.conditional_mode.tests.test_projection import BOOT, MONO, FakeSubMaster, serialized_scene
from openpilot.starpilot.speed_limits.runtime import Action as SlcAction, CruiseEvent as SlcCruiseEvent
class TestConditionalRuntimeLoop(unittest.TestCase):
def test_slc_unit_change_keeps_receipts_for_the_current_command(self):
from openpilot.selfdrive.car.cruise import slc_target_kph
def test_slc_unit_change_preserves_cap_and_queued_driver_event(self):
from openpilot.starpilot.speed_limits import offset_document as od
from openpilot.starpilot.speed_limits.runtime import Action, CruiseEvent
from openpilot.starpilot.speed_limits.tests.test_runtime_replay import RuntimeReplayTests
fixture = RuntimeReplayTests()
from openpilot.starpilot.speed_limits.tests import test_runtime_replay as replay
fixture = replay.RuntimeReplayTests()
fixture.setUp()
sm, cp = fixture.sm, fixture.cp
with tempfile.TemporaryDirectory() as directory:
params = Params(directory)
params.put_bool('SpeedLimitController', True, block=True)
params.put_bool('SLCConfirmation', True, block=True)
params.put('SLCConfirmation', 1, block=True)
params.put('SLCPriority1', 'Dashboard', block=True)
params.put('SLCOffsetSchedule', od.to_value(od.default_document(False)), block=True)
owner = plannerd.SavedSlcLifecycle(params, cp, {})
@@ -46,20 +46,19 @@ class TestConditionalRuntimeLoop(unittest.TestCase):
sm.advance(2_900_000_000)
pending = runtime.step(sm, cp, now_ns=2_900_000_000).message.slcState
sm.advance(2_950_000_000)
issued = runtime.step(sm, cp, now_ns=2_950_000_000,
accepted = runtime.step(sm, cp, now_ns=2_950_000_000,
request=Action(runtime.session_id, 1, pending.decisionId, pending.presentationId, 'accept'))
command = issued.command.slcCruiseCommand
selected = slc_target_kph(command.targetMps, False)
receipts = deque([CruiseEvent(1, 2_990_000_000, 60 / 3.6, selected / 3.6, '', 'card-producer',
2_990_000_000, 'commandApplied', runtime.session_id, command.decisionId, command.presentationId, command.commandId)])
self.assertEqual(accepted.result.ceiling.speed_mps, 25.)
events = deque([CruiseEvent(1, 2_990_000_000, 60/3.6, 65/3.6, 'accel', 'card-producer', 2_990_000_000)])
params.put_bool('IsMetric', True, block=True)
metric, _ = owner.refresh(3_000_000_000, pending_cruise=receipts)
self.assertEqual(len(receipts), 1)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
metric, _ = owner.refresh(3_000_000_000, pending_cruise=events)
self.assertIs(metric, runtime)
self.assertEqual(len(events), 1)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 65.
sm.advance(3_000_000_000)
applied = metric.step(sm, cp, now_ns=3_000_000_000, cruise_event=receipts.popleft())
self.assertEqual(applied.message.slcState.commandStatus, 'applied')
self.assertIsNone(applied.command)
output = metric.step(sm, cp, now_ns=3_000_000_000, cruise_event=events.popleft())
self.assertEqual(output.result.ceiling.speed_mps, 25.)
self.assertEqual(sm['carState'].vCruise, 65.)
def test_slc_unit_changes_preserve_set_speed_and_driver_choices(self):
from openpilot.common.constants import CV
@@ -68,14 +67,14 @@ class TestConditionalRuntimeLoop(unittest.TestCase):
from openpilot.starpilot.speed_limits.runtime import Action, CruiseEvent
from openpilot.starpilot.speed_limits.tests.test_runtime_replay import RuntimeReplayTests
now = 2_000_000_000
for confirmation, choice in ((False, 'applied'), (True, 'applied'), (False, 'override'), (True, 'decline')):
for confirmation, choice in ((False, 'applied'), (True, 'applied'), (False, 'ceiling'), (True, 'decline')):
with self.subTest(confirmation=confirmation, choice=choice), tempfile.TemporaryDirectory() as directory:
fixture = RuntimeReplayTests()
fixture.setUp()
sm, cp = fixture.sm, fixture.cp
params = Params(directory)
params.put_bool('SpeedLimitController', True, block=True)
params.put_bool('SLCConfirmation', confirmation, block=True)
params.put('SLCConfirmation', int(confirmation), block=True)
params.put('SLCPriority1', 'Dashboard', block=True)
params.put('SLCOffsetSchedule', od.to_value(od.default_document(False)), block=True)
owner = plannerd.SavedSlcLifecycle(params, cp, {})
@@ -84,14 +83,14 @@ class TestConditionalRuntimeLoop(unittest.TestCase):
baseline = slc_target_kph(65 * CV.MPH_TO_MS, False)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = baseline
aligned = original.step(sm, cp, now_ns=now)
self.assertIsNone(aligned.command)
if choice == 'override':
self.assertTrue(aligned.message.slcState.hasAccepted)
if choice == 'ceiling':
selected = slc_target_kph(70 * CV.MPH_TO_MS, False)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
sm.advance(now + 50_000_000)
chosen = original.step(sm, cp, now_ns=now + 50_000_000, cruise_event=CruiseEvent(
1, now + 50_000_000, baseline / 3.6, selected / 3.6, 'accel', 'card-producer', now + 50_000_000))
self.assertTrue(chosen.message.slcState.driverOverrideActive)
self.assertFalse(chosen.message.slcState.driverOverrideActive)
elif choice == 'decline':
sm['slcDashboardObservation'].speedMps = 55 * CV.MPH_TO_MS
sm.advance(now + 50_000_000)
@@ -105,11 +104,10 @@ class TestConditionalRuntimeLoop(unittest.TestCase):
self.assertTrue(changed)
sm.advance(now + 1_000_000_000)
output = metric.step(sm, cp, now_ns=now + 1_000_000_000)
self.assertIsNone(output.command)
self.assertFalse(output.message.slcState.hasPending)
self.assertTrue(output.message.slcState.hasAccepted)
self.assertEqual(sm['carState'].vCruise, selected)
self.assertEqual(output.message.slcState.driverOverrideActive, choice == 'override')
self.assertFalse(output.message.slcState.driverOverrideActive)
if choice == 'decline':
self.assertEqual(output.result.acceptance.basis, 'rejected')
@@ -122,13 +120,11 @@ class TestConditionalRuntimeLoop(unittest.TestCase):
owner = plannerd.SavedSlcLifecycle(params, cp, {})
imperial, _ = owner.refresh(MONO)
self.assertFalse(imperial.is_metric)
imperial.set_speed_after_ns = MONO + 1_050_000_000
actions, cruise = deque([object()]), deque([object()])
params.put_bool('IsMetric', True, block=True)
metric, changed = owner.refresh(MONO + 1_000_000_000, pending_actions=actions, pending_cruise=cruise)
self.assertTrue(changed)
self.assertTrue(metric.is_metric)
self.assertEqual(metric.set_speed_after_ns, MONO + 1_050_000_000)
self.assertIs(metric, imperial)
self.assertFalse(actions)
self.assertEqual(len(cruise), 1)
@@ -168,7 +164,7 @@ class TestConditionalRuntimeLoop(unittest.TestCase):
# Status and transport are reserved independently of saved SLC/CEM
# admission, so a later saved change can attach without restarting.
self.assertEqual(publisher.call_args.args[0], ['longitudinalPlan', 'starpilotLongitudinalPlan',
'driverAssistance', 'slcState', 'slcCruiseCommand'])
'driverAssistance', 'slcState'])
self.assertEqual(conditional_ctor.call_count, int(enabled))
if enabled:
self.assertIs(conditional_ctor.call_args.args[0], params)
@@ -200,8 +196,8 @@ class TestConditionalRuntimeLoop(unittest.TestCase):
self.assertFalse(vision.vision_control_qualified)
self.assertEqual(vision.settings.selection.slots[0], Source.VISION)
session = vision.session_id
pending_actions = deque([(3_000_000_000, plannerd.SlcAction(session, 1, 1, 1, 'accept'))])
pending_cruise = deque([plannerd.SlcCruiseEvent(1, 3_000_000_000, 20., 21., 'set', session_id=session)])
pending_actions = deque([(3_000_000_000, SlcAction(session, 1, 1, 1, 'accept'))])
pending_cruise = deque([SlcCruiseEvent(1, 3_000_000_000, 20., 21., 'set', session_id=session)])
self.assertEqual(owner.refresh(3_050_000_000, pending_actions=pending_actions,
pending_cruise=pending_cruise), (vision, False))
self.assertEqual(len(pending_actions), 1)
@@ -217,8 +213,8 @@ class TestConditionalRuntimeLoop(unittest.TestCase):
self.assertNotEqual(vision.session_id, session) # Retired session was reset.
params.put_bool('ShowSpeedLimits', False, block=True)
params.put_bool('SpeedLimitController', False, block=True)
pending_actions.append((4_050_000_000, plannerd.SlcAction(control.session_id, 1, 1, 1, 'accept')))
pending_cruise.append(plannerd.SlcCruiseEvent(2, 4_050_000_000, 20., 21., 'set', session_id=control.session_id))
pending_actions.append((4_050_000_000, SlcAction(control.session_id, 1, 1, 1, 'accept')))
pending_cruise.append(SlcCruiseEvent(2, 4_050_000_000, 20., 21., 'set', session_id=control.session_id))
self.assertEqual(owner.refresh(5_000_000_000, pending_actions=pending_actions,
pending_cruise=pending_cruise), (None, True))
self.assertFalse(pending_actions)
@@ -251,7 +247,7 @@ class TestConditionalRuntimeLoop(unittest.TestCase):
patch.object(messaging, 'SubMaster', return_value=sm) as subscriber, patch.object(messaging, 'sub_sock'):
with self.assertRaises(EndLoop):
plannerd.starpilot_main()
self.assertIn('slcCruiseCommand', publisher.call_args.args[0])
self.assertNotIn('slcCruiseCommand', publisher.call_args.args[0])
for service in ('slcDashboardObservation', 'slcVisionObservation'):
self.assertIn(service, subscriber.call_args.args[0])
for check in ('ignore_alive', 'ignore_valid', 'ignore_avg_freq'):
@@ -156,7 +156,7 @@ class CurveLoopTests(unittest.TestCase):
stamp = NOW + n * 50_000_000
sm = bus(stamp)
result = update_curve_frame(planner, sm, cp, stamp, host=host, cruise_ceiling=slc)
self.assertIsNotNone(result.ceiling_mps)
self.assertIsNone(result.ceiling_mps)
self.assertFalse(planner.last_curve_ceiling_applied)
self.assertFalse(host.runtime.was_controlling)
self.assertEqual(host.runtime.curve.revision, 0)
@@ -45,326 +45,326 @@ const static double MAHA_THRESH_31 = 3.8414588206941227;
* *
* This file is part of 'ekf' *
******************************************************************************/
void err_fun(double *nom_x, double *delta_x, double *out_7353202191454035649) {
out_7353202191454035649[0] = delta_x[0] + nom_x[0];
out_7353202191454035649[1] = delta_x[1] + nom_x[1];
out_7353202191454035649[2] = delta_x[2] + nom_x[2];
out_7353202191454035649[3] = delta_x[3] + nom_x[3];
out_7353202191454035649[4] = delta_x[4] + nom_x[4];
out_7353202191454035649[5] = delta_x[5] + nom_x[5];
out_7353202191454035649[6] = delta_x[6] + nom_x[6];
out_7353202191454035649[7] = delta_x[7] + nom_x[7];
out_7353202191454035649[8] = delta_x[8] + nom_x[8];
void err_fun(double *nom_x, double *delta_x, double *out_7567495437456270106) {
out_7567495437456270106[0] = delta_x[0] + nom_x[0];
out_7567495437456270106[1] = delta_x[1] + nom_x[1];
out_7567495437456270106[2] = delta_x[2] + nom_x[2];
out_7567495437456270106[3] = delta_x[3] + nom_x[3];
out_7567495437456270106[4] = delta_x[4] + nom_x[4];
out_7567495437456270106[5] = delta_x[5] + nom_x[5];
out_7567495437456270106[6] = delta_x[6] + nom_x[6];
out_7567495437456270106[7] = delta_x[7] + nom_x[7];
out_7567495437456270106[8] = delta_x[8] + nom_x[8];
}
void inv_err_fun(double *nom_x, double *true_x, double *out_5273149315250963980) {
out_5273149315250963980[0] = -nom_x[0] + true_x[0];
out_5273149315250963980[1] = -nom_x[1] + true_x[1];
out_5273149315250963980[2] = -nom_x[2] + true_x[2];
out_5273149315250963980[3] = -nom_x[3] + true_x[3];
out_5273149315250963980[4] = -nom_x[4] + true_x[4];
out_5273149315250963980[5] = -nom_x[5] + true_x[5];
out_5273149315250963980[6] = -nom_x[6] + true_x[6];
out_5273149315250963980[7] = -nom_x[7] + true_x[7];
out_5273149315250963980[8] = -nom_x[8] + true_x[8];
void inv_err_fun(double *nom_x, double *true_x, double *out_6277676899296321355) {
out_6277676899296321355[0] = -nom_x[0] + true_x[0];
out_6277676899296321355[1] = -nom_x[1] + true_x[1];
out_6277676899296321355[2] = -nom_x[2] + true_x[2];
out_6277676899296321355[3] = -nom_x[3] + true_x[3];
out_6277676899296321355[4] = -nom_x[4] + true_x[4];
out_6277676899296321355[5] = -nom_x[5] + true_x[5];
out_6277676899296321355[6] = -nom_x[6] + true_x[6];
out_6277676899296321355[7] = -nom_x[7] + true_x[7];
out_6277676899296321355[8] = -nom_x[8] + true_x[8];
}
void H_mod_fun(double *state, double *out_5463203357507594110) {
out_5463203357507594110[0] = 1.0;
out_5463203357507594110[1] = 0.0;
out_5463203357507594110[2] = 0.0;
out_5463203357507594110[3] = 0.0;
out_5463203357507594110[4] = 0.0;
out_5463203357507594110[5] = 0.0;
out_5463203357507594110[6] = 0.0;
out_5463203357507594110[7] = 0.0;
out_5463203357507594110[8] = 0.0;
out_5463203357507594110[9] = 0.0;
out_5463203357507594110[10] = 1.0;
out_5463203357507594110[11] = 0.0;
out_5463203357507594110[12] = 0.0;
out_5463203357507594110[13] = 0.0;
out_5463203357507594110[14] = 0.0;
out_5463203357507594110[15] = 0.0;
out_5463203357507594110[16] = 0.0;
out_5463203357507594110[17] = 0.0;
out_5463203357507594110[18] = 0.0;
out_5463203357507594110[19] = 0.0;
out_5463203357507594110[20] = 1.0;
out_5463203357507594110[21] = 0.0;
out_5463203357507594110[22] = 0.0;
out_5463203357507594110[23] = 0.0;
out_5463203357507594110[24] = 0.0;
out_5463203357507594110[25] = 0.0;
out_5463203357507594110[26] = 0.0;
out_5463203357507594110[27] = 0.0;
out_5463203357507594110[28] = 0.0;
out_5463203357507594110[29] = 0.0;
out_5463203357507594110[30] = 1.0;
out_5463203357507594110[31] = 0.0;
out_5463203357507594110[32] = 0.0;
out_5463203357507594110[33] = 0.0;
out_5463203357507594110[34] = 0.0;
out_5463203357507594110[35] = 0.0;
out_5463203357507594110[36] = 0.0;
out_5463203357507594110[37] = 0.0;
out_5463203357507594110[38] = 0.0;
out_5463203357507594110[39] = 0.0;
out_5463203357507594110[40] = 1.0;
out_5463203357507594110[41] = 0.0;
out_5463203357507594110[42] = 0.0;
out_5463203357507594110[43] = 0.0;
out_5463203357507594110[44] = 0.0;
out_5463203357507594110[45] = 0.0;
out_5463203357507594110[46] = 0.0;
out_5463203357507594110[47] = 0.0;
out_5463203357507594110[48] = 0.0;
out_5463203357507594110[49] = 0.0;
out_5463203357507594110[50] = 1.0;
out_5463203357507594110[51] = 0.0;
out_5463203357507594110[52] = 0.0;
out_5463203357507594110[53] = 0.0;
out_5463203357507594110[54] = 0.0;
out_5463203357507594110[55] = 0.0;
out_5463203357507594110[56] = 0.0;
out_5463203357507594110[57] = 0.0;
out_5463203357507594110[58] = 0.0;
out_5463203357507594110[59] = 0.0;
out_5463203357507594110[60] = 1.0;
out_5463203357507594110[61] = 0.0;
out_5463203357507594110[62] = 0.0;
out_5463203357507594110[63] = 0.0;
out_5463203357507594110[64] = 0.0;
out_5463203357507594110[65] = 0.0;
out_5463203357507594110[66] = 0.0;
out_5463203357507594110[67] = 0.0;
out_5463203357507594110[68] = 0.0;
out_5463203357507594110[69] = 0.0;
out_5463203357507594110[70] = 1.0;
out_5463203357507594110[71] = 0.0;
out_5463203357507594110[72] = 0.0;
out_5463203357507594110[73] = 0.0;
out_5463203357507594110[74] = 0.0;
out_5463203357507594110[75] = 0.0;
out_5463203357507594110[76] = 0.0;
out_5463203357507594110[77] = 0.0;
out_5463203357507594110[78] = 0.0;
out_5463203357507594110[79] = 0.0;
out_5463203357507594110[80] = 1.0;
void H_mod_fun(double *state, double *out_7420380263460044385) {
out_7420380263460044385[0] = 1.0;
out_7420380263460044385[1] = 0.0;
out_7420380263460044385[2] = 0.0;
out_7420380263460044385[3] = 0.0;
out_7420380263460044385[4] = 0.0;
out_7420380263460044385[5] = 0.0;
out_7420380263460044385[6] = 0.0;
out_7420380263460044385[7] = 0.0;
out_7420380263460044385[8] = 0.0;
out_7420380263460044385[9] = 0.0;
out_7420380263460044385[10] = 1.0;
out_7420380263460044385[11] = 0.0;
out_7420380263460044385[12] = 0.0;
out_7420380263460044385[13] = 0.0;
out_7420380263460044385[14] = 0.0;
out_7420380263460044385[15] = 0.0;
out_7420380263460044385[16] = 0.0;
out_7420380263460044385[17] = 0.0;
out_7420380263460044385[18] = 0.0;
out_7420380263460044385[19] = 0.0;
out_7420380263460044385[20] = 1.0;
out_7420380263460044385[21] = 0.0;
out_7420380263460044385[22] = 0.0;
out_7420380263460044385[23] = 0.0;
out_7420380263460044385[24] = 0.0;
out_7420380263460044385[25] = 0.0;
out_7420380263460044385[26] = 0.0;
out_7420380263460044385[27] = 0.0;
out_7420380263460044385[28] = 0.0;
out_7420380263460044385[29] = 0.0;
out_7420380263460044385[30] = 1.0;
out_7420380263460044385[31] = 0.0;
out_7420380263460044385[32] = 0.0;
out_7420380263460044385[33] = 0.0;
out_7420380263460044385[34] = 0.0;
out_7420380263460044385[35] = 0.0;
out_7420380263460044385[36] = 0.0;
out_7420380263460044385[37] = 0.0;
out_7420380263460044385[38] = 0.0;
out_7420380263460044385[39] = 0.0;
out_7420380263460044385[40] = 1.0;
out_7420380263460044385[41] = 0.0;
out_7420380263460044385[42] = 0.0;
out_7420380263460044385[43] = 0.0;
out_7420380263460044385[44] = 0.0;
out_7420380263460044385[45] = 0.0;
out_7420380263460044385[46] = 0.0;
out_7420380263460044385[47] = 0.0;
out_7420380263460044385[48] = 0.0;
out_7420380263460044385[49] = 0.0;
out_7420380263460044385[50] = 1.0;
out_7420380263460044385[51] = 0.0;
out_7420380263460044385[52] = 0.0;
out_7420380263460044385[53] = 0.0;
out_7420380263460044385[54] = 0.0;
out_7420380263460044385[55] = 0.0;
out_7420380263460044385[56] = 0.0;
out_7420380263460044385[57] = 0.0;
out_7420380263460044385[58] = 0.0;
out_7420380263460044385[59] = 0.0;
out_7420380263460044385[60] = 1.0;
out_7420380263460044385[61] = 0.0;
out_7420380263460044385[62] = 0.0;
out_7420380263460044385[63] = 0.0;
out_7420380263460044385[64] = 0.0;
out_7420380263460044385[65] = 0.0;
out_7420380263460044385[66] = 0.0;
out_7420380263460044385[67] = 0.0;
out_7420380263460044385[68] = 0.0;
out_7420380263460044385[69] = 0.0;
out_7420380263460044385[70] = 1.0;
out_7420380263460044385[71] = 0.0;
out_7420380263460044385[72] = 0.0;
out_7420380263460044385[73] = 0.0;
out_7420380263460044385[74] = 0.0;
out_7420380263460044385[75] = 0.0;
out_7420380263460044385[76] = 0.0;
out_7420380263460044385[77] = 0.0;
out_7420380263460044385[78] = 0.0;
out_7420380263460044385[79] = 0.0;
out_7420380263460044385[80] = 1.0;
}
void f_fun(double *state, double dt, double *out_6125045598488451197) {
out_6125045598488451197[0] = state[0];
out_6125045598488451197[1] = state[1];
out_6125045598488451197[2] = state[2];
out_6125045598488451197[3] = state[3];
out_6125045598488451197[4] = state[4];
out_6125045598488451197[5] = dt*((-state[4] + (-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])/(mass*state[4]))*state[6] - 9.8100000000000005*state[8] + stiffness_front*(-state[2] - state[3] + state[7])*state[0]/(mass*state[1]) + (-stiffness_front*state[0] - stiffness_rear*state[0])*state[5]/(mass*state[4])) + state[5];
out_6125045598488451197[6] = dt*(center_to_front*stiffness_front*(-state[2] - state[3] + state[7])*state[0]/(rotational_inertia*state[1]) + (-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])*state[5]/(rotational_inertia*state[4]) + (-pow(center_to_front, 2)*stiffness_front*state[0] - pow(center_to_rear, 2)*stiffness_rear*state[0])*state[6]/(rotational_inertia*state[4])) + state[6];
out_6125045598488451197[7] = state[7];
out_6125045598488451197[8] = state[8];
void f_fun(double *state, double dt, double *out_3045596088569131513) {
out_3045596088569131513[0] = state[0];
out_3045596088569131513[1] = state[1];
out_3045596088569131513[2] = state[2];
out_3045596088569131513[3] = state[3];
out_3045596088569131513[4] = state[4];
out_3045596088569131513[5] = dt*((-state[4] + (-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])/(mass*state[4]))*state[6] - 9.8100000000000005*state[8] + stiffness_front*(-state[2] - state[3] + state[7])*state[0]/(mass*state[1]) + (-stiffness_front*state[0] - stiffness_rear*state[0])*state[5]/(mass*state[4])) + state[5];
out_3045596088569131513[6] = dt*(center_to_front*stiffness_front*(-state[2] - state[3] + state[7])*state[0]/(rotational_inertia*state[1]) + (-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])*state[5]/(rotational_inertia*state[4]) + (-pow(center_to_front, 2)*stiffness_front*state[0] - pow(center_to_rear, 2)*stiffness_rear*state[0])*state[6]/(rotational_inertia*state[4])) + state[6];
out_3045596088569131513[7] = state[7];
out_3045596088569131513[8] = state[8];
}
void F_fun(double *state, double dt, double *out_548031277288491442) {
out_548031277288491442[0] = 1;
out_548031277288491442[1] = 0;
out_548031277288491442[2] = 0;
out_548031277288491442[3] = 0;
out_548031277288491442[4] = 0;
out_548031277288491442[5] = 0;
out_548031277288491442[6] = 0;
out_548031277288491442[7] = 0;
out_548031277288491442[8] = 0;
out_548031277288491442[9] = 0;
out_548031277288491442[10] = 1;
out_548031277288491442[11] = 0;
out_548031277288491442[12] = 0;
out_548031277288491442[13] = 0;
out_548031277288491442[14] = 0;
out_548031277288491442[15] = 0;
out_548031277288491442[16] = 0;
out_548031277288491442[17] = 0;
out_548031277288491442[18] = 0;
out_548031277288491442[19] = 0;
out_548031277288491442[20] = 1;
out_548031277288491442[21] = 0;
out_548031277288491442[22] = 0;
out_548031277288491442[23] = 0;
out_548031277288491442[24] = 0;
out_548031277288491442[25] = 0;
out_548031277288491442[26] = 0;
out_548031277288491442[27] = 0;
out_548031277288491442[28] = 0;
out_548031277288491442[29] = 0;
out_548031277288491442[30] = 1;
out_548031277288491442[31] = 0;
out_548031277288491442[32] = 0;
out_548031277288491442[33] = 0;
out_548031277288491442[34] = 0;
out_548031277288491442[35] = 0;
out_548031277288491442[36] = 0;
out_548031277288491442[37] = 0;
out_548031277288491442[38] = 0;
out_548031277288491442[39] = 0;
out_548031277288491442[40] = 1;
out_548031277288491442[41] = 0;
out_548031277288491442[42] = 0;
out_548031277288491442[43] = 0;
out_548031277288491442[44] = 0;
out_548031277288491442[45] = dt*(stiffness_front*(-state[2] - state[3] + state[7])/(mass*state[1]) + (-stiffness_front - stiffness_rear)*state[5]/(mass*state[4]) + (-center_to_front*stiffness_front + center_to_rear*stiffness_rear)*state[6]/(mass*state[4]));
out_548031277288491442[46] = -dt*stiffness_front*(-state[2] - state[3] + state[7])*state[0]/(mass*pow(state[1], 2));
out_548031277288491442[47] = -dt*stiffness_front*state[0]/(mass*state[1]);
out_548031277288491442[48] = -dt*stiffness_front*state[0]/(mass*state[1]);
out_548031277288491442[49] = dt*((-1 - (-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])/(mass*pow(state[4], 2)))*state[6] - (-stiffness_front*state[0] - stiffness_rear*state[0])*state[5]/(mass*pow(state[4], 2)));
out_548031277288491442[50] = dt*(-stiffness_front*state[0] - stiffness_rear*state[0])/(mass*state[4]) + 1;
out_548031277288491442[51] = dt*(-state[4] + (-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])/(mass*state[4]));
out_548031277288491442[52] = dt*stiffness_front*state[0]/(mass*state[1]);
out_548031277288491442[53] = -9.8100000000000005*dt;
out_548031277288491442[54] = dt*(center_to_front*stiffness_front*(-state[2] - state[3] + state[7])/(rotational_inertia*state[1]) + (-center_to_front*stiffness_front + center_to_rear*stiffness_rear)*state[5]/(rotational_inertia*state[4]) + (-pow(center_to_front, 2)*stiffness_front - pow(center_to_rear, 2)*stiffness_rear)*state[6]/(rotational_inertia*state[4]));
out_548031277288491442[55] = -center_to_front*dt*stiffness_front*(-state[2] - state[3] + state[7])*state[0]/(rotational_inertia*pow(state[1], 2));
out_548031277288491442[56] = -center_to_front*dt*stiffness_front*state[0]/(rotational_inertia*state[1]);
out_548031277288491442[57] = -center_to_front*dt*stiffness_front*state[0]/(rotational_inertia*state[1]);
out_548031277288491442[58] = dt*(-(-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])*state[5]/(rotational_inertia*pow(state[4], 2)) - (-pow(center_to_front, 2)*stiffness_front*state[0] - pow(center_to_rear, 2)*stiffness_rear*state[0])*state[6]/(rotational_inertia*pow(state[4], 2)));
out_548031277288491442[59] = dt*(-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])/(rotational_inertia*state[4]);
out_548031277288491442[60] = dt*(-pow(center_to_front, 2)*stiffness_front*state[0] - pow(center_to_rear, 2)*stiffness_rear*state[0])/(rotational_inertia*state[4]) + 1;
out_548031277288491442[61] = center_to_front*dt*stiffness_front*state[0]/(rotational_inertia*state[1]);
out_548031277288491442[62] = 0;
out_548031277288491442[63] = 0;
out_548031277288491442[64] = 0;
out_548031277288491442[65] = 0;
out_548031277288491442[66] = 0;
out_548031277288491442[67] = 0;
out_548031277288491442[68] = 0;
out_548031277288491442[69] = 0;
out_548031277288491442[70] = 1;
out_548031277288491442[71] = 0;
out_548031277288491442[72] = 0;
out_548031277288491442[73] = 0;
out_548031277288491442[74] = 0;
out_548031277288491442[75] = 0;
out_548031277288491442[76] = 0;
out_548031277288491442[77] = 0;
out_548031277288491442[78] = 0;
out_548031277288491442[79] = 0;
out_548031277288491442[80] = 1;
void F_fun(double *state, double dt, double *out_8700545601531255316) {
out_8700545601531255316[0] = 1;
out_8700545601531255316[1] = 0;
out_8700545601531255316[2] = 0;
out_8700545601531255316[3] = 0;
out_8700545601531255316[4] = 0;
out_8700545601531255316[5] = 0;
out_8700545601531255316[6] = 0;
out_8700545601531255316[7] = 0;
out_8700545601531255316[8] = 0;
out_8700545601531255316[9] = 0;
out_8700545601531255316[10] = 1;
out_8700545601531255316[11] = 0;
out_8700545601531255316[12] = 0;
out_8700545601531255316[13] = 0;
out_8700545601531255316[14] = 0;
out_8700545601531255316[15] = 0;
out_8700545601531255316[16] = 0;
out_8700545601531255316[17] = 0;
out_8700545601531255316[18] = 0;
out_8700545601531255316[19] = 0;
out_8700545601531255316[20] = 1;
out_8700545601531255316[21] = 0;
out_8700545601531255316[22] = 0;
out_8700545601531255316[23] = 0;
out_8700545601531255316[24] = 0;
out_8700545601531255316[25] = 0;
out_8700545601531255316[26] = 0;
out_8700545601531255316[27] = 0;
out_8700545601531255316[28] = 0;
out_8700545601531255316[29] = 0;
out_8700545601531255316[30] = 1;
out_8700545601531255316[31] = 0;
out_8700545601531255316[32] = 0;
out_8700545601531255316[33] = 0;
out_8700545601531255316[34] = 0;
out_8700545601531255316[35] = 0;
out_8700545601531255316[36] = 0;
out_8700545601531255316[37] = 0;
out_8700545601531255316[38] = 0;
out_8700545601531255316[39] = 0;
out_8700545601531255316[40] = 1;
out_8700545601531255316[41] = 0;
out_8700545601531255316[42] = 0;
out_8700545601531255316[43] = 0;
out_8700545601531255316[44] = 0;
out_8700545601531255316[45] = dt*(stiffness_front*(-state[2] - state[3] + state[7])/(mass*state[1]) + (-stiffness_front - stiffness_rear)*state[5]/(mass*state[4]) + (-center_to_front*stiffness_front + center_to_rear*stiffness_rear)*state[6]/(mass*state[4]));
out_8700545601531255316[46] = -dt*stiffness_front*(-state[2] - state[3] + state[7])*state[0]/(mass*pow(state[1], 2));
out_8700545601531255316[47] = -dt*stiffness_front*state[0]/(mass*state[1]);
out_8700545601531255316[48] = -dt*stiffness_front*state[0]/(mass*state[1]);
out_8700545601531255316[49] = dt*((-1 - (-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])/(mass*pow(state[4], 2)))*state[6] - (-stiffness_front*state[0] - stiffness_rear*state[0])*state[5]/(mass*pow(state[4], 2)));
out_8700545601531255316[50] = dt*(-stiffness_front*state[0] - stiffness_rear*state[0])/(mass*state[4]) + 1;
out_8700545601531255316[51] = dt*(-state[4] + (-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])/(mass*state[4]));
out_8700545601531255316[52] = dt*stiffness_front*state[0]/(mass*state[1]);
out_8700545601531255316[53] = -9.8100000000000005*dt;
out_8700545601531255316[54] = dt*(center_to_front*stiffness_front*(-state[2] - state[3] + state[7])/(rotational_inertia*state[1]) + (-center_to_front*stiffness_front + center_to_rear*stiffness_rear)*state[5]/(rotational_inertia*state[4]) + (-pow(center_to_front, 2)*stiffness_front - pow(center_to_rear, 2)*stiffness_rear)*state[6]/(rotational_inertia*state[4]));
out_8700545601531255316[55] = -center_to_front*dt*stiffness_front*(-state[2] - state[3] + state[7])*state[0]/(rotational_inertia*pow(state[1], 2));
out_8700545601531255316[56] = -center_to_front*dt*stiffness_front*state[0]/(rotational_inertia*state[1]);
out_8700545601531255316[57] = -center_to_front*dt*stiffness_front*state[0]/(rotational_inertia*state[1]);
out_8700545601531255316[58] = dt*(-(-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])*state[5]/(rotational_inertia*pow(state[4], 2)) - (-pow(center_to_front, 2)*stiffness_front*state[0] - pow(center_to_rear, 2)*stiffness_rear*state[0])*state[6]/(rotational_inertia*pow(state[4], 2)));
out_8700545601531255316[59] = dt*(-center_to_front*stiffness_front*state[0] + center_to_rear*stiffness_rear*state[0])/(rotational_inertia*state[4]);
out_8700545601531255316[60] = dt*(-pow(center_to_front, 2)*stiffness_front*state[0] - pow(center_to_rear, 2)*stiffness_rear*state[0])/(rotational_inertia*state[4]) + 1;
out_8700545601531255316[61] = center_to_front*dt*stiffness_front*state[0]/(rotational_inertia*state[1]);
out_8700545601531255316[62] = 0;
out_8700545601531255316[63] = 0;
out_8700545601531255316[64] = 0;
out_8700545601531255316[65] = 0;
out_8700545601531255316[66] = 0;
out_8700545601531255316[67] = 0;
out_8700545601531255316[68] = 0;
out_8700545601531255316[69] = 0;
out_8700545601531255316[70] = 1;
out_8700545601531255316[71] = 0;
out_8700545601531255316[72] = 0;
out_8700545601531255316[73] = 0;
out_8700545601531255316[74] = 0;
out_8700545601531255316[75] = 0;
out_8700545601531255316[76] = 0;
out_8700545601531255316[77] = 0;
out_8700545601531255316[78] = 0;
out_8700545601531255316[79] = 0;
out_8700545601531255316[80] = 1;
}
void h_25(double *state, double *unused, double *out_7825793012049214991) {
out_7825793012049214991[0] = state[6];
void h_25(double *state, double *unused, double *out_2824946100795669147) {
out_2824946100795669147[0] = state[6];
}
void H_25(double *state, double *unused, double *out_8986251996152959258) {
out_8986251996152959258[0] = 0;
out_8986251996152959258[1] = 0;
out_8986251996152959258[2] = 0;
out_8986251996152959258[3] = 0;
out_8986251996152959258[4] = 0;
out_8986251996152959258[5] = 0;
out_8986251996152959258[6] = 1;
out_8986251996152959258[7] = 0;
out_8986251996152959258[8] = 0;
void H_25(double *state, double *unused, double *out_2516688438275379545) {
out_2516688438275379545[0] = 0;
out_2516688438275379545[1] = 0;
out_2516688438275379545[2] = 0;
out_2516688438275379545[3] = 0;
out_2516688438275379545[4] = 0;
out_2516688438275379545[5] = 0;
out_2516688438275379545[6] = 1;
out_2516688438275379545[7] = 0;
out_2516688438275379545[8] = 0;
}
void h_24(double *state, double *unused, double *out_3863850671382452828) {
out_3863850671382452828[0] = state[4];
out_3863850671382452828[1] = state[5];
void h_24(double *state, double *unused, double *out_3049085865514909722) {
out_3049085865514909722[0] = state[4];
out_3049085865514909722[1] = state[5];
}
void H_24(double *state, double *unused, double *out_5428326397350636122) {
out_5428326397350636122[0] = 0;
out_5428326397350636122[1] = 0;
out_5428326397350636122[2] = 0;
out_5428326397350636122[3] = 0;
out_5428326397350636122[4] = 1;
out_5428326397350636122[5] = 0;
out_5428326397350636122[6] = 0;
out_5428326397350636122[7] = 0;
out_5428326397350636122[8] = 0;
out_5428326397350636122[9] = 0;
out_5428326397350636122[10] = 0;
out_5428326397350636122[11] = 0;
out_5428326397350636122[12] = 0;
out_5428326397350636122[13] = 0;
out_5428326397350636122[14] = 1;
out_5428326397350636122[15] = 0;
out_5428326397350636122[16] = 0;
out_5428326397350636122[17] = 0;
void H_24(double *state, double *unused, double *out_339474014668229572) {
out_339474014668229572[0] = 0;
out_339474014668229572[1] = 0;
out_339474014668229572[2] = 0;
out_339474014668229572[3] = 0;
out_339474014668229572[4] = 1;
out_339474014668229572[5] = 0;
out_339474014668229572[6] = 0;
out_339474014668229572[7] = 0;
out_339474014668229572[8] = 0;
out_339474014668229572[9] = 0;
out_339474014668229572[10] = 0;
out_339474014668229572[11] = 0;
out_339474014668229572[12] = 0;
out_339474014668229572[13] = 0;
out_339474014668229572[14] = 1;
out_339474014668229572[15] = 0;
out_339474014668229572[16] = 0;
out_339474014668229572[17] = 0;
}
void h_30(double *state, double *unused, double *out_3653635896979304131) {
out_3653635896979304131[0] = state[4];
void h_30(double *state, double *unused, double *out_6312771183888182035) {
out_6312771183888182035[0] = state[4];
}
void H_30(double *state, double *unused, double *out_6467919037645710631) {
out_6467919037645710631[0] = 0;
out_6467919037645710631[1] = 0;
out_6467919037645710631[2] = 0;
out_6467919037645710631[3] = 0;
out_6467919037645710631[4] = 1;
out_6467919037645710631[5] = 0;
out_6467919037645710631[6] = 0;
out_6467919037645710631[7] = 0;
out_6467919037645710631[8] = 0;
void H_30(double *state, double *unused, double *out_1644520231869082) {
out_1644520231869082[0] = 0;
out_1644520231869082[1] = 0;
out_1644520231869082[2] = 0;
out_1644520231869082[3] = 0;
out_1644520231869082[4] = 1;
out_1644520231869082[5] = 0;
out_1644520231869082[6] = 0;
out_1644520231869082[7] = 0;
out_1644520231869082[8] = 0;
}
void h_26(double *state, double *unused, double *out_8655309164640149776) {
out_8655309164640149776[0] = state[7];
void h_26(double *state, double *unused, double *out_6908065673986044634) {
out_6908065673986044634[0] = state[7];
}
void H_26(double *state, double *unused, double *out_5718988758682536134) {
out_5718988758682536134[0] = 0;
out_5718988758682536134[1] = 0;
out_5718988758682536134[2] = 0;
out_5718988758682536134[3] = 0;
out_5718988758682536134[4] = 0;
out_5718988758682536134[5] = 0;
out_5718988758682536134[6] = 0;
out_5718988758682536134[7] = 1;
out_5718988758682536134[8] = 0;
void H_26(double *state, double *unused, double *out_6258191757149435769) {
out_6258191757149435769[0] = 0;
out_6258191757149435769[1] = 0;
out_6258191757149435769[2] = 0;
out_6258191757149435769[3] = 0;
out_6258191757149435769[4] = 0;
out_6258191757149435769[5] = 0;
out_6258191757149435769[6] = 0;
out_6258191757149435769[7] = 1;
out_6258191757149435769[8] = 0;
}
void h_27(double *state, double *unused, double *out_5968325673074535865) {
out_5968325673074535865[0] = state[3];
void h_27(double *state, double *unused, double *out_709679758613972836) {
out_709679758613972836[0] = state[3];
}
void H_27(double *state, double *unused, double *out_4244324966461767414) {
out_4244324966461767414[0] = 0;
out_4244324966461767414[1] = 0;
out_4244324966461767414[2] = 0;
out_4244324966461767414[3] = 1;
out_4244324966461767414[4] = 0;
out_4244324966461767414[5] = 0;
out_4244324966461767414[6] = 0;
out_4244324966461767414[7] = 0;
out_4244324966461767414[8] = 0;
void H_27(double *state, double *unused, double *out_2173118791568555829) {
out_2173118791568555829[0] = 0;
out_2173118791568555829[1] = 0;
out_2173118791568555829[2] = 0;
out_2173118791568555829[3] = 1;
out_2173118791568555829[4] = 0;
out_2173118791568555829[5] = 0;
out_2173118791568555829[6] = 0;
out_2173118791568555829[7] = 0;
out_2173118791568555829[8] = 0;
}
void h_29(double *state, double *unused, double *out_2671551803644006019) {
out_2671551803644006019[0] = state[1];
void h_29(double *state, double *unused, double *out_6982675176999014474) {
out_6982675176999014474[0] = state[1];
}
void H_29(double *state, double *unused, double *out_5957687693331318447) {
out_5957687693331318447[0] = 0;
out_5957687693331318447[1] = 1;
out_5957687693331318447[2] = 0;
out_5957687693331318447[3] = 0;
out_5957687693331318447[4] = 0;
out_5957687693331318447[5] = 0;
out_5957687693331318447[6] = 0;
out_5957687693331318447[7] = 0;
out_5957687693331318447[8] = 0;
void H_29(double *state, double *unused, double *out_511875864546261266) {
out_511875864546261266[0] = 0;
out_511875864546261266[1] = 1;
out_511875864546261266[2] = 0;
out_511875864546261266[3] = 0;
out_511875864546261266[4] = 0;
out_511875864546261266[5] = 0;
out_511875864546261266[6] = 0;
out_511875864546261266[7] = 0;
out_511875864546261266[8] = 0;
}
void h_28(double *state, double *unused, double *out_3845497107703219871) {
out_3845497107703219871[0] = state[0];
void h_28(double *state, double *unused, double *out_2400127593349653796) {
out_2400127593349653796[0] = state[0];
}
void H_28(double *state, double *unused, double *out_7406657363308702595) {
out_7406657363308702595[0] = 1;
out_7406657363308702595[1] = 0;
out_7406657363308702595[2] = 0;
out_7406657363308702595[3] = 0;
out_7406657363308702595[4] = 0;
out_7406657363308702595[5] = 0;
out_7406657363308702595[6] = 0;
out_7406657363308702595[7] = 0;
out_7406657363308702595[8] = 0;
void H_28(double *state, double *unused, double *out_4570523152523269308) {
out_4570523152523269308[0] = 1;
out_4570523152523269308[1] = 0;
out_4570523152523269308[2] = 0;
out_4570523152523269308[3] = 0;
out_4570523152523269308[4] = 0;
out_4570523152523269308[5] = 0;
out_4570523152523269308[6] = 0;
out_4570523152523269308[7] = 0;
out_4570523152523269308[8] = 0;
}
void h_31(double *state, double *unused, double *out_1981073584941794741) {
out_1981073584941794741[0] = state[8];
void h_31(double *state, double *unused, double *out_1304974248711835435) {
out_1304974248711835435[0] = state[8];
}
void H_31(double *state, double *unused, double *out_8955606034275998830) {
out_8955606034275998830[0] = 0;
out_8955606034275998830[1] = 0;
out_8955606034275998830[2] = 0;
out_8955606034275998830[3] = 0;
out_8955606034275998830[4] = 0;
out_8955606034275998830[5] = 0;
out_8955606034275998830[6] = 0;
out_8955606034275998830[7] = 0;
out_8955606034275998830[8] = 1;
void H_31(double *state, double *unused, double *out_6884399859382787245) {
out_6884399859382787245[0] = 0;
out_6884399859382787245[1] = 0;
out_6884399859382787245[2] = 0;
out_6884399859382787245[3] = 0;
out_6884399859382787245[4] = 0;
out_6884399859382787245[5] = 0;
out_6884399859382787245[6] = 0;
out_6884399859382787245[7] = 0;
out_6884399859382787245[8] = 1;
}
#include <eigen3/Eigen/Dense>
#include <iostream>
@@ -518,68 +518,68 @@ void car_update_28(double *in_x, double *in_P, double *in_z, double *in_R, doubl
void car_update_31(double *in_x, double *in_P, double *in_z, double *in_R, double *in_ea) {
update<1, 3, 0>(in_x, in_P, h_31, H_31, NULL, in_z, in_R, in_ea, MAHA_THRESH_31);
}
void car_err_fun(double *nom_x, double *delta_x, double *out_7353202191454035649) {
err_fun(nom_x, delta_x, out_7353202191454035649);
void car_err_fun(double *nom_x, double *delta_x, double *out_7567495437456270106) {
err_fun(nom_x, delta_x, out_7567495437456270106);
}
void car_inv_err_fun(double *nom_x, double *true_x, double *out_5273149315250963980) {
inv_err_fun(nom_x, true_x, out_5273149315250963980);
void car_inv_err_fun(double *nom_x, double *true_x, double *out_6277676899296321355) {
inv_err_fun(nom_x, true_x, out_6277676899296321355);
}
void car_H_mod_fun(double *state, double *out_5463203357507594110) {
H_mod_fun(state, out_5463203357507594110);
void car_H_mod_fun(double *state, double *out_7420380263460044385) {
H_mod_fun(state, out_7420380263460044385);
}
void car_f_fun(double *state, double dt, double *out_6125045598488451197) {
f_fun(state, dt, out_6125045598488451197);
void car_f_fun(double *state, double dt, double *out_3045596088569131513) {
f_fun(state, dt, out_3045596088569131513);
}
void car_F_fun(double *state, double dt, double *out_548031277288491442) {
F_fun(state, dt, out_548031277288491442);
void car_F_fun(double *state, double dt, double *out_8700545601531255316) {
F_fun(state, dt, out_8700545601531255316);
}
void car_h_25(double *state, double *unused, double *out_7825793012049214991) {
h_25(state, unused, out_7825793012049214991);
void car_h_25(double *state, double *unused, double *out_2824946100795669147) {
h_25(state, unused, out_2824946100795669147);
}
void car_H_25(double *state, double *unused, double *out_8986251996152959258) {
H_25(state, unused, out_8986251996152959258);
void car_H_25(double *state, double *unused, double *out_2516688438275379545) {
H_25(state, unused, out_2516688438275379545);
}
void car_h_24(double *state, double *unused, double *out_3863850671382452828) {
h_24(state, unused, out_3863850671382452828);
void car_h_24(double *state, double *unused, double *out_3049085865514909722) {
h_24(state, unused, out_3049085865514909722);
}
void car_H_24(double *state, double *unused, double *out_5428326397350636122) {
H_24(state, unused, out_5428326397350636122);
void car_H_24(double *state, double *unused, double *out_339474014668229572) {
H_24(state, unused, out_339474014668229572);
}
void car_h_30(double *state, double *unused, double *out_3653635896979304131) {
h_30(state, unused, out_3653635896979304131);
void car_h_30(double *state, double *unused, double *out_6312771183888182035) {
h_30(state, unused, out_6312771183888182035);
}
void car_H_30(double *state, double *unused, double *out_6467919037645710631) {
H_30(state, unused, out_6467919037645710631);
void car_H_30(double *state, double *unused, double *out_1644520231869082) {
H_30(state, unused, out_1644520231869082);
}
void car_h_26(double *state, double *unused, double *out_8655309164640149776) {
h_26(state, unused, out_8655309164640149776);
void car_h_26(double *state, double *unused, double *out_6908065673986044634) {
h_26(state, unused, out_6908065673986044634);
}
void car_H_26(double *state, double *unused, double *out_5718988758682536134) {
H_26(state, unused, out_5718988758682536134);
void car_H_26(double *state, double *unused, double *out_6258191757149435769) {
H_26(state, unused, out_6258191757149435769);
}
void car_h_27(double *state, double *unused, double *out_5968325673074535865) {
h_27(state, unused, out_5968325673074535865);
void car_h_27(double *state, double *unused, double *out_709679758613972836) {
h_27(state, unused, out_709679758613972836);
}
void car_H_27(double *state, double *unused, double *out_4244324966461767414) {
H_27(state, unused, out_4244324966461767414);
void car_H_27(double *state, double *unused, double *out_2173118791568555829) {
H_27(state, unused, out_2173118791568555829);
}
void car_h_29(double *state, double *unused, double *out_2671551803644006019) {
h_29(state, unused, out_2671551803644006019);
void car_h_29(double *state, double *unused, double *out_6982675176999014474) {
h_29(state, unused, out_6982675176999014474);
}
void car_H_29(double *state, double *unused, double *out_5957687693331318447) {
H_29(state, unused, out_5957687693331318447);
void car_H_29(double *state, double *unused, double *out_511875864546261266) {
H_29(state, unused, out_511875864546261266);
}
void car_h_28(double *state, double *unused, double *out_3845497107703219871) {
h_28(state, unused, out_3845497107703219871);
void car_h_28(double *state, double *unused, double *out_2400127593349653796) {
h_28(state, unused, out_2400127593349653796);
}
void car_H_28(double *state, double *unused, double *out_7406657363308702595) {
H_28(state, unused, out_7406657363308702595);
void car_H_28(double *state, double *unused, double *out_4570523152523269308) {
H_28(state, unused, out_4570523152523269308);
}
void car_h_31(double *state, double *unused, double *out_1981073584941794741) {
h_31(state, unused, out_1981073584941794741);
void car_h_31(double *state, double *unused, double *out_1304974248711835435) {
h_31(state, unused, out_1304974248711835435);
}
void car_H_31(double *state, double *unused, double *out_8955606034275998830) {
H_31(state, unused, out_8955606034275998830);
void car_H_31(double *state, double *unused, double *out_6884399859382787245) {
H_31(state, unused, out_6884399859382787245);
}
void car_predict(double *in_x, double *in_P, double *in_Q, double dt) {
predict(in_x, in_P, in_Q, dt);
@@ -9,27 +9,27 @@ void car_update_27(double *in_x, double *in_P, double *in_z, double *in_R, doubl
void car_update_29(double *in_x, double *in_P, double *in_z, double *in_R, double *in_ea);
void car_update_28(double *in_x, double *in_P, double *in_z, double *in_R, double *in_ea);
void car_update_31(double *in_x, double *in_P, double *in_z, double *in_R, double *in_ea);
void car_err_fun(double *nom_x, double *delta_x, double *out_7353202191454035649);
void car_inv_err_fun(double *nom_x, double *true_x, double *out_5273149315250963980);
void car_H_mod_fun(double *state, double *out_5463203357507594110);
void car_f_fun(double *state, double dt, double *out_6125045598488451197);
void car_F_fun(double *state, double dt, double *out_548031277288491442);
void car_h_25(double *state, double *unused, double *out_7825793012049214991);
void car_H_25(double *state, double *unused, double *out_8986251996152959258);
void car_h_24(double *state, double *unused, double *out_3863850671382452828);
void car_H_24(double *state, double *unused, double *out_5428326397350636122);
void car_h_30(double *state, double *unused, double *out_3653635896979304131);
void car_H_30(double *state, double *unused, double *out_6467919037645710631);
void car_h_26(double *state, double *unused, double *out_8655309164640149776);
void car_H_26(double *state, double *unused, double *out_5718988758682536134);
void car_h_27(double *state, double *unused, double *out_5968325673074535865);
void car_H_27(double *state, double *unused, double *out_4244324966461767414);
void car_h_29(double *state, double *unused, double *out_2671551803644006019);
void car_H_29(double *state, double *unused, double *out_5957687693331318447);
void car_h_28(double *state, double *unused, double *out_3845497107703219871);
void car_H_28(double *state, double *unused, double *out_7406657363308702595);
void car_h_31(double *state, double *unused, double *out_1981073584941794741);
void car_H_31(double *state, double *unused, double *out_8955606034275998830);
void car_err_fun(double *nom_x, double *delta_x, double *out_7567495437456270106);
void car_inv_err_fun(double *nom_x, double *true_x, double *out_6277676899296321355);
void car_H_mod_fun(double *state, double *out_7420380263460044385);
void car_f_fun(double *state, double dt, double *out_3045596088569131513);
void car_F_fun(double *state, double dt, double *out_8700545601531255316);
void car_h_25(double *state, double *unused, double *out_2824946100795669147);
void car_H_25(double *state, double *unused, double *out_2516688438275379545);
void car_h_24(double *state, double *unused, double *out_3049085865514909722);
void car_H_24(double *state, double *unused, double *out_339474014668229572);
void car_h_30(double *state, double *unused, double *out_6312771183888182035);
void car_H_30(double *state, double *unused, double *out_1644520231869082);
void car_h_26(double *state, double *unused, double *out_6908065673986044634);
void car_H_26(double *state, double *unused, double *out_6258191757149435769);
void car_h_27(double *state, double *unused, double *out_709679758613972836);
void car_H_27(double *state, double *unused, double *out_2173118791568555829);
void car_h_29(double *state, double *unused, double *out_6982675176999014474);
void car_H_29(double *state, double *unused, double *out_511875864546261266);
void car_h_28(double *state, double *unused, double *out_2400127593349653796);
void car_H_28(double *state, double *unused, double *out_4570523152523269308);
void car_h_31(double *state, double *unused, double *out_1304974248711835435);
void car_H_31(double *state, double *unused, double *out_6884399859382787245);
void car_predict(double *in_x, double *in_P, double *in_Q, double dt);
void car_set_mass(double x);
void car_set_rotational_inertia(double x);
File diff suppressed because it is too large Load Diff
@@ -5,18 +5,18 @@ void pose_update_4(double *in_x, double *in_P, double *in_z, double *in_R, doubl
void pose_update_10(double *in_x, double *in_P, double *in_z, double *in_R, double *in_ea);
void pose_update_13(double *in_x, double *in_P, double *in_z, double *in_R, double *in_ea);
void pose_update_14(double *in_x, double *in_P, double *in_z, double *in_R, double *in_ea);
void pose_err_fun(double *nom_x, double *delta_x, double *out_2134324718959906218);
void pose_inv_err_fun(double *nom_x, double *true_x, double *out_7874553077816062449);
void pose_H_mod_fun(double *state, double *out_8129875391940694982);
void pose_f_fun(double *state, double dt, double *out_5920058179701921818);
void pose_F_fun(double *state, double dt, double *out_534012189001621552);
void pose_h_4(double *state, double *unused, double *out_6191552396506608484);
void pose_H_4(double *state, double *unused, double *out_4468786875016897913);
void pose_h_10(double *state, double *unused, double *out_8347226084972414573);
void pose_H_10(double *state, double *unused, double *out_4111169058602364742);
void pose_h_13(double *state, double *unused, double *out_2707578914264125574);
void pose_H_13(double *state, double *unused, double *out_7681060700349230714);
void pose_h_14(double *state, double *unused, double *out_4697260034120461682);
void pose_H_14(double *state, double *unused, double *out_8432027731356382442);
void pose_err_fun(double *nom_x, double *delta_x, double *out_4830772321857990181);
void pose_inv_err_fun(double *nom_x, double *true_x, double *out_5200017052910787602);
void pose_H_mod_fun(double *state, double *out_1712341249416054868);
void pose_f_fun(double *state, double dt, double *out_3100560613383761185);
void pose_F_fun(double *state, double dt, double *out_3350602915939425859);
void pose_h_4(double *state, double *unused, double *out_2074712223981570498);
void pose_H_4(double *state, double *unused, double *out_4135740557335903853);
void pose_h_10(double *state, double *unused, double *out_5949398724887603410);
void pose_H_10(double *state, double *unused, double *out_3347508424845693314);
void pose_h_13(double *state, double *unused, double *out_9220727019356339608);
void pose_H_13(double *state, double *unused, double *out_923466732003571052);
void pose_h_14(double *state, double *unused, double *out_8529780658774629922);
void pose_H_14(double *state, double *unused, double *out_172499700996419324);
void pose_predict(double *in_x, double *in_P, double *in_Q, double dt);
}
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.
+6 -1
View File
@@ -16,6 +16,8 @@ GearShifter = car.CarState.GearShifter
AOL_TOGGLE = 9
PAUSE_LATERAL = 3
PAUSE_LONGITUDINAL = 4
# The only wheel action WheelPublisher delivers from cruise MAIN.
ADOPT_SPEED_LIMIT = 10
@dataclass(frozen=True)
class AolSettings:
@@ -28,6 +30,7 @@ class AolSettings:
mode_actions: tuple[int, int, int] = (0, 0, 0)
custom_actions: tuple[int, int, int] = (0, 0, 0)
default_physical_buttons: bool = True
main_wheel_action: bool = False
def _button_value(params, key: str) -> int | None:
@@ -64,6 +67,7 @@ def read_settings(params) -> AolSettings:
enabled=params.get_bool('AlwaysOnLateral') and valid_pause, pause_brake_mps=pause_mps if valid_pause else 0.0,
lkas_action=_axis_action(lkas), main_action=_axis_action(main),
default_physical_buttons=lkas == 0 and main == 0,
main_wheel_action=main == ADOPT_SPEED_LIMIT,
distance_actions=(_button_action(params, 'DistanceButtonControl'),
_button_action(params, 'LongDistanceButtonControl'),
_button_action(params, 'VeryLongDistanceButtonControl')),
@@ -315,7 +319,8 @@ class AolCardIntent:
independent_buttons = self.independent_cruise_buttons and (main_managed or lkas_managed)
# The opt-in is bound to existing HKG torque owners. Other physical
# owners keep their current event and native contracts.
main_toggle = not independent_buttons or main_managed
main_toggle = (not independent_buttons or main_managed) and not (
self.independent_cruise_buttons and self.settings.main_wheel_action)
lkas_toggle = not independent_buttons or lkas_managed
previously_held = (self._main_held and main_toggle) or (self._lkas_held and lkas_toggle)
cancel_pressed = False
@@ -0,0 +1,73 @@
"""Cruise MAIN assigned to Adopt speed limit is not also the Hyundai physical lateral toggle."""
import pytest
from opendbc.car import structs
from openpilot.starpilot.aol.intent import AolCardIntent, AolSettings, read_settings
from openpilot.starpilot.aol.tests.test_ioniq6_stock_repair import stock
from openpilot.starpilot.car.hyundai.aol import create_intent, qualified_ioniq6
def long_cp():
cp = stock(False)
cp.pcmCruise = False
cp.openpilotLongitudinalControl = True
cp.safetyConfigs[0].safetyParam = 0x8815
assert qualified_ioniq6(cp)
return cp
def press(owner, cs, button, now_ns, *, engaged=False):
cs.buttonEvents = [structs.CarState.ButtonEvent(type=button, pressed=True)]
owner.update(cs, now_ns=now_ns, fault_active=False, standard_enabled=engaged)
cs.buttonEvents = [structs.CarState.ButtonEvent(type=button, pressed=False)]
owner.update(cs, now_ns=now_ns + 1, fault_active=False, standard_enabled=engaged)
def lateral(owner):
return owner.allowed_latch and not owner.pause_lateral
def armed(owner):
cs = structs.CarState(canValid=True, gearShifter='drive')
cs.cruiseState.available = True
owner.update(cs, now_ns=1, fault_active=False)
press(owner, cs, 'lkas', 2)
assert lateral(owner)
return cs
@pytest.mark.parametrize('main_wheel_action', [False, True])
def test_ioniq6_main_assigned_to_adopt_keeps_lkas_armed_lateral(main_wheel_action):
owner = create_intent(long_cp(), AolSettings(True, 0., 0, 0, (0, 0, 0), (0, 0, 0), main_wheel_action=main_wheel_action))
assert owner.independent_cruise_buttons
cs = armed(owner)
press(owner, cs, 'mainCruise', 10, engaged=True)
assert lateral(owner) == main_wheel_action
press(owner, cs, 'lkas', 20, engaged=True)
assert lateral(owner) != main_wheel_action
def test_main_toggle_is_unchanged_without_independent_cruise_buttons():
owner = AolCardIntent(AolSettings(True, 0., 0, 0, (0, 0, 0), (0, 0, 0), main_wheel_action=True), explicit_latch=True)
cs = armed(owner)
press(owner, cs, 'mainCruise', 10, engaged=True)
assert not lateral(owner)
class SavedParams:
def __init__(self, values):
self.values = values
def get(self, key, return_default=False):
return self.values.get(key)
def get_bool(self, key):
return self.values.get(key) == b'1'
def get_param_path(self, key):
return f'/nonexistent/{key}'
@pytest.mark.parametrize('lkas,main,main_wheel', [(b'0', b'10', True), (b'0', b'9', False), (b'10', b'0', False), (b'0', b'8', False)])
def test_only_main_adopt_is_marked_as_a_main_wheel_action(lkas, main, main_wheel):
settings = read_settings(SavedParams({'AlwaysOnLateral': b'1', 'LKASButtonControl': lkas, 'MainCruiseButtonControl': main}))
assert settings.main_wheel_action == main_wheel
@@ -81,7 +81,7 @@
"discriminantOffset": 0
},
"new_storage": {
"dataWordCount": 18,
"dataWordCount": 21,
"pointerCount": 22,
"preferredListEncoding": "inlineComposite",
"isGroup": false,
@@ -232,6 +232,78 @@
"ordinal": {
"explicit": 58
}
},
{
"slot": {
"offset": 141,
"type": {
"bool": null
},
"defaultValue": {
"bool": false
},
"hadExplicitDefault": false
},
"name": "acceptedIsPreview",
"codeOrder": 59,
"discriminantValue": 65535,
"ordinal": {
"explicit": 59
}
},
{
"slot": {
"offset": 18,
"type": {
"uint64": null
},
"defaultValue": {
"uint64": 0
},
"hadExplicitDefault": false
},
"name": "wheelOverrideSince",
"codeOrder": 60,
"discriminantValue": 65535,
"ordinal": {
"explicit": 60
}
},
{
"slot": {
"offset": 19,
"type": {
"uint64": null
},
"defaultValue": {
"uint64": 0
},
"hadExplicitDefault": false
},
"name": "wheelAdoptMonoTime",
"codeOrder": 61,
"discriminantValue": 65535,
"ordinal": {
"explicit": 61
}
},
{
"slot": {
"offset": 40,
"type": {
"float32": null
},
"defaultValue": {
"float32": 0.0
},
"hadExplicitDefault": false
},
"name": "wheelAdoptTargetMps",
"codeOrder": 62,
"discriminantValue": 65535,
"ordinal": {
"explicit": 62
}
}
]
},
@@ -652,6 +724,48 @@
"codeOrder": 12
}
]
},
"0x9ccdc8676701b412": {
"kind": "struct",
"before_name": "SlcAction",
"after_name": "SlcAction",
"old_field_count": 8,
"old_storage": {
"dataWordCount": 4,
"pointerCount": 4,
"preferredListEncoding": "inlineComposite",
"isGroup": false,
"discriminantCount": 0,
"discriminantOffset": 0
},
"new_storage": {
"dataWordCount": 5,
"pointerCount": 4,
"preferredListEncoding": "inlineComposite",
"isGroup": false,
"discriminantCount": 0,
"discriminantOffset": 0
},
"added_fields": [
{
"slot": {
"offset": 4,
"type": {
"uint64": null
},
"defaultValue": {
"uint64": 0
},
"hadExplicitDefault": false
},
"name": "wheelAdoptMonoTime",
"codeOrder": 8,
"discriminantValue": 65535,
"ordinal": {
"explicit": 8
}
}
]
}
},
"published_addition_commits": {
@@ -6,9 +6,8 @@ CEM selects Experimental mode for configured speed, road, signal, curve, lead, s
`ConditionalPlannerHost` joins the current native MPC result, current model/car/radar observations, saved preferences, and qualified wheel-button events. It produces a short-lived proposal on `slcState.conditionalMode`; it does not own the effective driving mode. `selfdrived` independently checks the current vehicle capability, longitudinal axis, drive, saved configuration, and input freshness before accepting that proposal. Its ordinary `ExperimentalMode` request remains the fallback. The UI displays an active conditional reason only from a fresh selfdrived acknowledgment matching the exact current `selfdriveState` frame and active longitudinal axis; the saved family is displayed separately.
Speed-limit availability is independent of mode selection. The retired `SLCFallback=1`
preference cannot request Experimental or veto Chill, including when Maps or Vision
becomes unknown or stale. Other scene triggers, manual mode choices and committed
Speed-limit availability is independent of mode selection. A missing or stale Maps or Vision limit does
not request Experimental or veto Chill. Other scene triggers, manual mode choices and committed
stop handling retain their existing behavior. Historical speed-limit reason labels
remain readable in recorded routes.
@@ -49,6 +48,6 @@ Traffic assignment does not suppress ordinary profile tuning.
Scene inputs distinguish false from unknown. Model filters advance once per original model tick; the oldest required source bounds freshness separately, so a slower fresh radar cannot stall a model filter or renew stale data. Startup, clock discontinuities, and drive changes require fresh observations. Conditional Chill requires every applicable veto to be known false. Disabled build features can be known absent; an enabled producer without a qualified observation remains unknown.
Current producers cover native MPC follow time and stop/throttle intent, model lane geometry, qualified adjacent radar, SLC attribution, pedal override, and the effective mode reported by selfdrived. The exact Ioniq media path supplies source-stamped Traffic Mode to CEM and a bounded Traffic follow/jerk, acceleration and cruise-braking profile. The shared committed-turn evaluator feeds both the stop detector and slower-lead retuning from original car/model stamps. Traffic retains the existing 0.75-second effective headway floor; saved values below it remain unsupported and intact. The native planner shapes the cruise-only floor for a qualified SLC cap, a validated named Eco/Sport braking personality, or the explicit global Braking response. A saved Traffic braking category reverts to its 0.42 m/s² cruise baseline for a present radar lead or standstill, then recovers through the existing profile smoother; MPC and model braking retain priority. The saved lane-change close-gap preference and current lateral minimum-speed policy are separate from conditional scene selection. Frozen weather and other hazard-context braking inputs remain absent. Stop-sign, forced-stop, and dashboard stop-sign producers remain absent. The historical red-light name is already represented by the current stop-light observation, so it does not need a duplicate owner.
Current producers cover native MPC follow time and stop/throttle intent, model lane geometry, qualified adjacent radar, SLC attribution, pedal override, and the effective mode reported by selfdrived. The exact Ioniq media path supplies source-stamped Traffic Mode to CEM and a bounded Traffic follow/jerk, acceleration and cruise-braking profile. The shared committed-turn evaluator feeds both the stop detector and slower-lead retuning from original car/model stamps. Traffic retains the existing 0.75-second effective headway floor; saved values below it remain unsupported and intact. The native planner retains the selected cruise-braking response for an SLC cap. Conditional speed comparisons and curve control receive the same-cycle SLC-capped cruise target while the driver's SET remains unchanged. A saved Traffic braking category reverts to its 0.42 m/s² cruise baseline for a present radar lead or standstill, then recovers through the existing profile smoother; MPC and model braking retain priority. The saved lane-change close-gap preference and current lateral minimum-speed policy are separate from conditional scene selection. Frozen weather and other hazard-context braking inputs remain absent. Stop-sign, forced-stop, and dashboard stop-sign producers remain absent. The historical red-light name is already represented by the current stop-light observation, so it does not need a duplicate owner.
The source contract includes frozen `starpilot/controls/lib/conditional_experimental_mode.py`, `conditional_chill_mode.py`, `starpilot/common/experimental_state.py`, and the planner's mode selection. Tests compare executable frozen policy/detector traces and exercise current serialized messages, native MPC/radar joins, lifecycle boundaries, manual controls, and settings concurrency. These are integration evidence, not vehicle qualification. Normal availability is limited to the exact tagged Ioniq 6 LONG profile; the separate ECU handoff and native safety acknowledgment must succeed first. Missing scene producers and road behavior still require evaluation.
@@ -59,6 +59,7 @@ class ConditionalModeHost:
selected_t_follow_s: float | None = None,
selected_t_follow_observed_mono_ns: int | None = None,
settings_owner: ConditionalSettingsOwner | None = None,
cruise_ceiling_mps: float | None = None,
) -> HostProposal:
signal_options = None
settings_revision = None
@@ -120,6 +121,7 @@ class ConditionalModeHost:
selected_t_follow_observed_mono_ns=selected_t_follow_observed_mono_ns,
owner_context=owner_context,
signal_options=signal_options,
cruise_ceiling_mps=cruise_ceiling_mps,
)
authority = projected.authority
if authority is not None and authority.fresh and (not authority.system_long_capable or authority.safe_mode or
@@ -180,6 +180,7 @@ class ConditionalPlannerHost:
drive_id: int, manual: ManualState | None = None,
owner_context: ConditionalOwnerContext | None = None,
native_plan_valid: bool = False,
cruise_ceiling_mps: float | None = None,
manual_event=None,
ui_event=None,
) -> tuple[HostProposal, SettingsSnapshot | None]:
@@ -289,6 +290,7 @@ class ConditionalPlannerHost:
manual=self.manual_state if manual is None else manual, owner_context=context,
selected_t_follow_s=selected_follow_time(planner),
selected_t_follow_observed_mono_ns=sm.logMonoTime.get('modelV2'),
cruise_ceiling_mps=cruise_ceiling_mps,
settings_owner=self.settings,
)
snapshot = self.settings.current
@@ -406,6 +406,7 @@ class SceneProjector:
selected_t_follow_observed_mono_ns: int | None = None,
owner_context: ConditionalOwnerContext | None = None,
signal_options: CEMOptions | None = None,
cruise_ceiling_mps: float | None = None,
) -> ProjectedScene:
if (
type(now_mono_ns) is not int
@@ -441,6 +442,9 @@ class SceneProjector:
speed = _ego_speed(car) if car is not None and _field(car, 'canValid') is True and _field(car, 'canTimeout') is False else None
cruise_kph = _number(_field(car, 'vCruise'), high=V_CRUISE_UNSET_KPH) if speed is not None else None
cruise = cruise_kph * KPH_TO_MPS if cruise_kph is not None and 0 < cruise_kph < V_CRUISE_UNSET_KPH else None
if cruise_ceiling_mps is not None:
cap = _number(cruise_ceiling_mps, high=V_CRUISE_UNSET_KPH * KPH_TO_MPS)
cruise = min(cruise, cap) if cruise is not None and cap is not None and cap > 0 else None
curvature = _number(_field(controls, 'curvature'), low=-1.0, high=1.0) if controls is not None else None
raw_curve = abs(speed * speed * curvature) >= FROZEN_CURVE_ACCEL_MPS2 if speed is not None and curvature is not None else None
horizon = None
@@ -114,6 +114,20 @@ def owner_context(stamp: int, *, stop_time=8.0, slower=True, stopped=False):
class TestSceneProjector(unittest.TestCase):
def test_conditional_speed_comparisons_use_the_slc_cap_without_mutating_set(self):
for cap, expected in ((None, 20.), (15., 15.), (25., 20.), (float('nan'), None)):
with self.subTest(cap=cap):
projector = SceneProjector()
sm = FakeSubMaster(serialized_scene(), MONO-5_000_000)
projector.project(sm, CP, now_mono_ns=MONO, now_boot_ns=BOOT, sample_skew_ns=1000)
now = MONO+50_000_000
sm.stamp(now)
sm.payloads.update(serialized_scene(stamp=BOOT+50_000_000))
result = projector.project(sm, CP, now_mono_ns=now, now_boot_ns=BOOT+50_000_000,
sample_skew_ns=1000, safe_mode=False, owner_context=owner_context(now), cruise_ceiling_mps=cap)
self.assertEqual(result.scene.set_speed_mps, expected)
self.assertEqual(sm['carState'].vCruise, 72.)
def test_standstill_filter_undershoot_requires_fresh_physical_zero_speed(self):
cases = ((-1.401298464324817e-45, True, 0., True),
(-0.0008391447481699288, True, 0., True),
+7 -1
View File
@@ -80,8 +80,14 @@ class CurveHost:
self.previous_long_active = False
self.previous_active_headway = None
def sample(self, sm, CP, now_ns: int, *, drive_id: int = 0, event=None, follow_time_s: float | None = None):
def sample(self, sm, CP, now_ns: int, *, drive_id: int = 0, event=None, follow_time_s: float | None = None,
cruise_ceiling_mps: float | None = None):
frame = frame_from_submaster(sm, CP, now_ns, drive_id=drive_id)
if cruise_ceiling_mps is not None:
if not math.isfinite(cruise_ceiling_mps) or cruise_ceiling_mps <= 0:
frame = replace(frame, inputs_fresh=False)
else:
frame = replace(frame, cruise_mps=min(frame.cruise_mps, cruise_ceiling_mps))
if drive_id and self.lead_drive_id and drive_id != self.lead_drive_id:
self._reset_leads()
if drive_id:
@@ -44,6 +44,12 @@ class TestCurveHost(unittest.TestCase):
self.sm.logMonoTime['radarState'] = NOW - 1_000_000_000
self.assertIsNone(self.host.sample(self.sm, self.cp, NOW + 50_000_000).ceiling_mps)
def test_curve_uses_same_cycle_slc_capped_cruise_without_mutating_set(self):
with patch.object(self.host.runtime, 'step', wraps=self.host.runtime.step) as step:
self.host.sample(self.sm, self.cp, NOW, follow_time_s=1.45, cruise_ceiling_mps=20.)
self.assertEqual(step.call_args.args[0].cruise_mps, 20.)
self.assertEqual(self.sm['carState'].vCruise, 108.)
def test_stock_owner_and_malformed_field_fail_closed(self):
self.cp.openpilotLongitudinalControl = False
self.assertIsNone(self.host.sample(self.sm, self.cp, NOW).ceiling_mps)
+1 -1
View File
@@ -21,7 +21,7 @@ CYCLE_PERSONALITY = "__starpilot_controller_action__:cycle_driving_personality"
# Saved preference shortcuts retain the current owner's authority and dependencies.
FEATURE_KEYS = {
"slc": ("SpeedLimitController", "ShowSpeedLimits", "SLCConfirmation", "SLCConfirmationHigher", "SLCConfirmationLower"),
"slc": ("SpeedLimitController", "ShowSpeedLimits", "SLCConfirmation"),
"lane": ("LaneCentering", "LaneCenteringPauseOnSignal"),
"curve": ("CurveSpeedController", "CurveSpeedControllerNoLead", "ShowCSCStatus"),
"aol": ("AlwaysOnLateral", "NostalgiaMode"),
@@ -61,12 +61,21 @@ DESCRIPTIONS = {
'CurveSpeedControllerNoLead': 'Pauses curve-speed adjustments while following a lead vehicle.',
'ShowCSCStatus': 'Shows when Curve Speed Controller is adjusting speed on the driving screen.',
'curve_reset': 'Deletes saved cornering learning so Curve Speed Controller can learn your preferences again.',
'SpeedLimitController': 'Adjusts the set speed to accepted speed limits using your source priority and offsets.',
'SpeedLimitController': 'Follows accepted limits plus offsets up to SET speed. Missing data uses the previous accepted limit.',
'SLCOnEngage': 'Starts software cruise SET at the SLC limit plus offset, including a saved limit when live data is missing. RES restores the previous SET.',
'SLCOverrideMode': ('Cruise uses this target when you lift off the accelerator. ' +
'Holding your speed uses the highest speed you reached, up to SET.'),
'SLCWheelOverride': 'Press + to raise SET past the adjusted limit and use your new SET speed.',
'ShowSpeedLimits': 'Shows recognized speed-limit signs on the driving screen.',
'SLCConfirmation': 'Auto adopts new limits immediately. With Automatic adoption off, + accepts and − declines within 10 seconds.',
'SLCAdvanceSeconds': 'Offers mapped limits early using road distance and current speed. Existing adoption and confirmation settings still apply.',
'SLCConfirmation': ('Off adopts new limits automatically. Both, Higher only, and Lower only prompt relative to the last accepted limit. ' +
'+ accepts and − declines within 30 seconds.'),
'SLCAdvanceHigherSeconds': ('Offers higher mapped limits early using road distance and current speed. ' +
'Existing adoption and confirmation settings still apply.'),
'SLCAdvanceLowerSeconds': ('Offers lower mapped limits early using road distance and current speed. ' +
'Existing adoption and confirmation settings still apply.'),
'SLCPriority1': 'Chooses the first source checked for a fresh speed limit.',
'SLCPriority2': 'Chooses the source used when the primary source has no fresh speed limit.',
'SLCPriority3': 'Chooses an optional third source when neither earlier source has a fresh speed limit.',
'slc_reset': 'Resets saved speed-limit offsets while retaining valid configured speed ranges.',
'reset_calibration': 'Clears saved camera and steering calibration. The device recalibrates on the next drive.',
'reset_driver_monitoring': 'Clear saved wheel-side detection. Automatic detection restarts on the next drive.',
@@ -77,10 +77,15 @@
"RelaxedJerkSpeedDecrease",
"RemoteStartBootsComma",
"ReverseCruise",
"SLCAdvanceSeconds",
"SLCAdvanceHigherSeconds",
"SLCAdvanceLowerSeconds",
"SLCConfirmation",
"SLCOnEngage",
"SLCOverrideMode",
"SLCPriority1",
"SLCPriority2",
"SLCPriority3",
"SLCWheelOverride",
"ScreenBrightness",
"ScreenBrightnessOnroad",
"ScreenTimeout",
@@ -63,40 +63,23 @@ class TestCanonicalRecovery(TestCase):
self.assertTrue(recovery.confirm(pending['intent'], self.session, self.generation)['saved'])
self.assertEqual(recovery.snapshot(self.session, self.generation)['count'], 0)
def test_legacy_confirmation_flags_do_not_create_hidden_customization(self):
self.params.put_bool('SLCConfirmation', False, block=True)
self.params.put_bool('SLCConfirmationLower', True, block=True)
self.params.put_bool('SLCConfirmationHigher', True, block=True)
def test_directional_confirmation_resets_to_off(self):
self.params.put('SLCConfirmation', 4, block=True)
recovery = self.recovery()
view = recovery.snapshot(self.session, self.generation)
children = [row for row in view['rows'] if row['label'] in ('Confirm lower limits', 'Confirm higher limits')]
self.assertEqual(children, [])
self.assertFalse(self.params.get_bool('SLCConfirmation'))
self.assertTrue(self.params.get_bool('SLCConfirmationLower'))
self.assertTrue(self.params.get_bool('SLCConfirmationHigher'))
def test_reset_confirmation_master_preserves_ignored_legacy_bytes(self):
for key in ('SLCConfirmation', 'SLCConfirmationLower', 'SLCConfirmationHigher'):
self.params.put_bool(key, True, block=True)
recovery = self.recovery()
view = recovery.snapshot(self.session, self.generation)
selected = [row['id'] for row in view['rows'] if row['label'] in
('Automatic adoption', 'Confirm lower limits', 'Confirm higher limits')]
self.assertEqual(len(selected), 1)
pending = recovery.preview(view['view'], selected, self.session, self.generation)
row = next(row for row in view['rows'] if row['label'] == 'Confirm limits')
pending = recovery.preview(view['view'], [row['id']], self.session, self.generation)
self.assertTrue(recovery.confirm(pending['intent'], self.session, self.generation)['saved'])
self.assertFalse(self.params.get_bool('SLCConfirmation'))
for key in ('SLCConfirmationLower', 'SLCConfirmationHigher'):
self.assertTrue(self.params.get_bool(key))
self.assertFalse(self.params.get('SLCConfirmation'))
def test_unsupported_rows_remain_visible_but_cannot_block_available_reset_all(self):
from dataclasses import replace
self.params.put_bool('SLCConfirmation', True, block=True)
self.params.put('SLCConfirmation', 1, block=True)
self.params.put_bool('ShowBrakeStatus', True, block=True)
self.context.value = replace(self.context.value, cp=None, cp_raw=None)
recovery = self.recovery()
view = recovery.snapshot(self.session, self.generation)
unsupported = next(row for row in view['rows'] if row['label'] == 'Automatic adoption')
unsupported = next(row for row in view['rows'] if row['label'] == 'Confirm limits')
self.assertFalse(unsupported['resetAvailable'])
with self.assertRaises(SettingsChanged):
recovery.preview(view['view'], [unsupported['id']], self.session, self.generation)
@@ -104,7 +87,7 @@ class TestCanonicalRecovery(TestCase):
self.assertTrue(selected)
pending = recovery.preview(view['view'], selected, self.session, self.generation)
self.assertTrue(recovery.confirm(pending['intent'], self.session, self.generation)['saved'])
self.assertTrue(self.params.get_bool('SLCConfirmation'))
self.assertTrue(self.params.get('SLCConfirmation'))
self.assertFalse(self.params.get_bool('ShowBrakeStatus'))
def test_other_canonical_inactive_children_reset_without_turning_their_masters_on(self):
@@ -44,20 +44,17 @@ class TestRecoveryHttp(TestCase):
rows = self.request('/api/settings/non-defaults', cookie=cookie)[1]['rows']
self.assertNotIn('Wheel brake and acceleration colors', [row['label'] for row in rows])
def test_confirmation_reset_uses_only_master_and_preserves_legacy_flags(self):
for key in ('SLCConfirmation', 'SLCConfirmationLower', 'SLCConfirmationHigher'):
self.params.put_bool(key, True, block=True)
def test_confirmation_reset_restores_off(self):
self.params.put('SLCConfirmation', 1, block=True)
cookie = self.login()
payload = self.selection(cookie, ['Automatic adoption'])
payload = self.selection(cookie, ['Confirm limits'])
status, pending, _ = self.request('/api/settings/recovery/preview', cookie=cookie, payload=payload)
self.assertEqual(status, 200)
result = self.request('/api/settings/recovery/confirm', cookie=cookie,
payload={'intent': pending['intent'], 'confirmed': True})
self.assertEqual(result[0], 200)
self.assertTrue(result[1]['saved'])
self.assertFalse(self.params.get_bool('SLCConfirmation'))
for key in ('SLCConfirmationLower', 'SLCConfirmationHigher'):
self.assertTrue(self.params.get_bool(key))
self.assertFalse(self.params.get('SLCConfirmation'))
def test_wrong_selection_and_unconfirmed_requests_are_rejected(self):
self.params.put_bool('ShowBrakeStatus', True, block=True)
@@ -81,13 +78,13 @@ class TestRecoveryHttp(TestCase):
self.assertTrue(self.params.get_bool('ShowBrakeStatus'))
def test_unsupported_rows_are_reported_and_available_reset_all_does_not_touch_them(self):
self.params.put_bool('SLCConfirmation', True, block=True)
self.params.put('SLCConfirmation', 1, block=True)
self.params.put_bool('ShowBrakeStatus', True, block=True)
self.context.value = replace(self.context.value, cp=None, cp_raw=None)
cookie = self.login()
status, view, _ = self.request('/api/settings/non-defaults', cookie=cookie)
self.assertEqual(status, 200)
unsupported = next(row for row in view['rows'] if row['label'] == 'Automatic adoption')
unsupported = next(row for row in view['rows'] if row['label'] == 'Confirm limits')
self.assertFalse(unsupported['resetAvailable'])
self.assertEqual(self.request('/api/settings/recovery/preview', cookie=cookie,
payload={'view': view['view'], 'selected': [unsupported['id']]})[0], 409)
@@ -99,4 +96,4 @@ class TestRecoveryHttp(TestCase):
self.assertEqual(result[0], 200)
self.assertTrue(result[1]['saved'])
self.assertFalse(self.params.get_bool('ShowBrakeStatus'))
self.assertTrue(self.params.get_bool('SLCConfirmation'))
self.assertTrue(self.params.get('SLCConfirmation'))
@@ -107,7 +107,7 @@ class TestOffroadConfigurationPages(unittest.TestCase):
'conditional': ('Saved driving mode',),
'conditional/cem': ('Speed threshold', 'Speed with lead', 'Stop lights'),
'lane_change': ('Allow lane changes', 'Minimum lane-change speed', 'Automatic Lane Changes', 'Lane Change Speed'),
'slc': ('Speed Limit Controller', 'Automatic adoption'),
'slc': ('Speed Limit Controller', 'Confirm limits'),
'profiles': ('Force Stop', 'Selected Acceleration Profile', 'Selected Deceleration Profile'),
'torque': ('Steering Controller', 'Turn Assist', 'Lat Accel', 'Friction'),
'wheel': ('Short press', 'Hold', 'Distance long press', 'MODE press', 'Star button'),
@@ -76,7 +76,7 @@ class TestSavedTorquePage(unittest.TestCase):
self.assertTrue(self.gateway._context('torque').editing_saved_vehicle)
self.assertEqual(self.context.sample().cp.carFingerprint, 'HYUNDAI_IONIQ_6')
self.assertEqual(self.gateway._context('slc').cp.carFingerprint, 'HYUNDAI_IONIQ_6')
slc = next(row for row in self.page('slc')['rows'] if row['label'] == 'Automatic adoption')
slc = next(row for row in self.page('slc')['rows'] if row['label'] == 'Confirm limits')
self.assertTrue(slc['available'])
self.offroad.return_value = False
with patch.object(self.context, 'configuration_allowed', return_value=True):
@@ -461,9 +461,9 @@ class SettingsGatewayTest(unittest.TestCase):
self.assertIn("Vision", primary["choices"])
self.assertIn("Vision", secondary["choices"])
self.assertNotIn("require your confirmation", primary["reason"])
confirmation = next(row for row in rows if row["label"] == "Automatic adoption")
self.assertEqual(confirmation["value"], "On")
self.assertIn("immediately", confirmation["reason"])
confirmation = next(row for row in rows if row["label"] == "Confirm limits")
self.assertEqual(confirmation["value"], "Off")
self.assertIn("automatically", confirmation["reason"])
self.assertIn("accepts", confirmation["reason"])
with mock.patch.dict(os.environ, {"SLC_REPLAY_RUNTIME": "0", "SLC_VISION_DEVELOPMENT": "0"}):
+8 -9
View File
@@ -12,15 +12,14 @@ Policy order matters. Target shaping precedes PID preparation; vehicle output sh
`LongitudinalPlanner.update(sm, *, cruise_ceiling=None, profile_tuning=None)` accepts an optional
`CruiseCeiling(speed_mps, authority)` in addition to the existing driver cruise
message. The live caller supplies no ceiling. The planner still reads
`carState.vCruise` for the driver's selected ceiling and leaves every supplied
Cap'n Proto message unchanged. [SLC](../speed_limits/README.md) adopts accepted
limits into Card's ordinary cruise SET; it supplies no additional planner ceiling.
The optional input remains available to explicit callers and replay tests.
message. The live [SLC](../speed_limits/README.md) caller supplies a qualified cap.
The planner still reads `carState.vCruise` as the driver's selected ceiling and
leaves supplied messages unchanged. Accepted limits affect the planner cap, not SET.
The `CruiseCeiling` dataclass validates no source or acceptance history by
construction. Its caller must first establish a fresh, accepted, normalized
m/s ceiling in the correct speed coordinate. The planner validates its numeric
construction. Its caller must establish an accepted, normalized m/s ceiling in
the correct speed coordinate, including the fixed previous-accepted-limit fallback.
Vehicle coordinates and authority remain fresh. The planner validates its numeric
shape and the shared four-mode `Authority` contract. It applies the lower of
the supplied ceiling and the existing driver ceiling only when native system
longitudinal control is available, the supplied authority grants active system
@@ -33,7 +32,7 @@ publish that diagnostic or alter the longitudinal plan schema.
This optional input changes only the cruise candidate before the planner's
existing jerk limiting. SLC uses the ordinary cruise braking response and selected
profile after its SET adoption. Lead-relevance checks still prevent cruise comfort
profile for its capped target; a cap does not strengthen a softer cruise profile. Lead-relevance checks still prevent cruise comfort
tuning from weakening braking for a close, slow or braking lead; malformed lead
evidence also vetoes that tuning. The native MPC, lead, e2e, stop and acceleration
arbitration remain in place. An inactive planner may still compute a nonzero
@@ -111,7 +110,7 @@ different historical scales:
| Sport (`sport`) | 2.4 m/s² | 2.0 m/s² |
These are positive deceleration magnitudes for ordinary cruise braking,
including an SLC change to cruise SET. Global values preserve the historical
including a changed SLC cap. Global values preserve the historical
1.2 m/s² baseline with half/normal/double responses; explicit personality
presets retain their original physical values. The distinction preserves
existing tuning and saved-document compatibility.
@@ -7,6 +7,8 @@ from openpilot.common.realtime import DT_MDL
STANDSTILL_HOLD_NS = 2_000_000_000
STANDSTILL_CLEAR_NS = 750_000_000
STOP_COMMIT_TIME_S = .5
SIGN_CONFIRM_STEP_S = 2 * DT_MDL
SIGN_MAX_SPEED_MPS = 17.88
SIGN_MODEL_AGREE_M = 50.0
SIGN_SEED_M = 27.0
@@ -102,6 +104,10 @@ class ForceStop:
self.standstill_clear_ns = 0
self.standstill_reason = None
@property
def can_commit_next_frame(self) -> bool:
return self.timer + SIGN_CONFIRM_STEP_S >= STOP_COMMIT_TIME_S
def step(self, frame: StopFrame, tune: StopTune, offset_ft: int = 0) -> StopPlan:
now = frame.model_ns
if (type(now) is not int or now <= 0 or now <= self.last_model_ns or
@@ -177,16 +183,16 @@ class ForceStop:
if not available and not self.forcing:
self.timer = 0
elif active and not frame.standstill:
self.timer = min(2.0, self.timer + DT_MDL * (2 if frame.sign else 1))
self.timer = min(2.0, self.timer + (SIGN_CONFIRM_STEP_S if frame.sign else DT_MDL))
elif turn and not self.forcing and not frame.standstill:
self.timer = 0
elif held:
self.timer = max(self.timer, .5)
self.timer = max(self.timer, STOP_COMMIT_TIME_S)
elif self.forcing and frame.standstill and not frame.sign and not frame.light and not frame.raw_model_stopped:
self.timer = 0
else:
self.timer = max(0, self.timer - DT_MDL * .25)
force = self.timer >= .5 or self.forcing and not frame.standstill or held
force = self.timer >= STOP_COMMIT_TIME_S or self.forcing and not frame.standstill or held
light_cleared = (not held and self.forcing and self.from_light and not frame.standstill and
available and not frame.light and not frame.sign and go_scene)
low_speed_hold = (light_cleared and tune.low_speed_hold_mps is not None and self.entry_speed is not None and
@@ -7,11 +7,12 @@ from opendbc.car.honda.interface import CarInterface
from opendbc.car.honda.values import CAR
from openpilot.common.params import Params
from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner
from openpilot.selfdrive.controls.plannerd import update_curve_frame
from openpilot.selfdrive.controls.plannerd import update_curve_frame, wheel_override_blocked
from openpilot.starpilot.curve_speed.host import CurveHost
from openpilot.starpilot.longitudinal.force_stop import StopPlan
from openpilot.starpilot.longitudinal.force_stop import StopPlan, vehicle_tune
from openpilot.starpilot.longitudinal.force_stop_runtime import ForceStopRuntime, dashboard_stop_sign
from openpilot.starpilot.longitudinal.tests.test_cruise_ceiling import messages, snapshot
from openpilot.starpilot.longitudinal.tests.test_force_stop import frame as stop_frame
from openpilot.starpilot.longitudinal.tests.test_stop_resume import event as button_event
BASE = 100_000_000_000
@@ -61,6 +62,31 @@ class ForceStopRuntimeTests(unittest.TestCase):
self.assertTrue(output.forcing)
return output
def test_wheel_priority_requires_a_stop_cap_or_possible_commit_next_frame(self):
tune = vehicle_tune(str(self.cp.carFingerprint))
sign = {'light': False, 'sign': True, 'horizon_m': 40.}
self.owner.plan = self.owner.policy.step(stop_frame(0, **sign), tune)
self.assertIsNone(self.owner.plan.speed_ceiling_mps)
self.assertFalse(wheel_override_blocked(self.owner, None))
self.owner.plan = self.owner.policy.step(stop_frame(1, light=False, resume_requested=True), tune)
self.assertGreater(self.owner.policy.timer, 0.)
self.assertEqual(self.owner.policy.override_until_ns, 0)
self.assertFalse(wheel_override_blocked(self.owner, None))
self.owner.reset()
for tick in range(4):
self.owner.plan = self.owner.policy.step(stop_frame(tick, **sign), tune)
self.assertIsNone(self.owner.plan.speed_ceiling_mps)
self.assertTrue(wheel_override_blocked(self.owner, None))
self.owner.policy.step(stop_frame(4, resume_requested=True, **sign), tune)
self.assertGreater(self.owner.policy.override_until_ns, 0)
self.owner.reset()
self.owner.plan = self.owner.policy.step(stop_frame(0), tune)
self.assertFalse(self.owner.policy.can_commit_next_frame)
self.assertIsNotNone(self.owner.plan.speed_ceiling_mps)
self.assertTrue(wheel_override_blocked(self.owner, None))
def test_committed_stop_survives_valid_mode_settings_and_traffic_changes(self):
import json
from openpilot.starpilot.conditional_mode.policy import ModeChoice
@@ -245,6 +245,48 @@ class TestVoltCc(unittest.TestCase):
self.assertFalse(any(msg[0] in (0x2cb, 0x315, 0x200, 0x3d1) for msg in sent[-1][0]))
def test_slc_cap_reaches_cruise_down_without_changing_driver_set(self):
for metric in (False, True):
with self.subTest(metric=metric):
controls, card, ci, packer, sent, base, offset = fixture()
card.is_metric, card.slc_control_enabled, card.slc_source_floor_ns = metric, True, 0
state = messaging.new_message('slcState').slcState
state.enabled, state.sessionId, state.hasCeiling, state.effectiveCap = True, 'drive', True, 45 * .44704
controls.sm.data['slcState'] = state
for flags in (controls.sm.seen, controls.sm.valid, controls.sm.alive):
flags['slcState'] = True
flags['carControl'] = True
command = structs.CarControl(enabled=True, latActive=True, longActive=True)
command.actuators.accel = -1.
command.actuators.longControlState = structs.CarControl.Actuators.LongControlState.pid
command.hudControl.setSpeed = 70 * .44704
controls.sm.data['carControl'] = command
controls.sm.logMonoTime['carControl'] = base
by_phase = []
for phase in ('active', 'stale'):
directions = []
for tick in range(100):
now = base + (len(by_phase) * 100 + tick) * 10_000_000
out = ci.update([(now + offset - 2_000_000,
physical_frames(packer, speed=55 * .44704, target_speed=70 * .44704, counter=(tick // 3) % 4))])
self.assertTrue(out.canValid)
out.vCruise = out.vCruiseCluster = 70 * .44704 * 3.6
for name in controls.sm.logMonoTime:
controls.sm.logMonoTime[name] = now - 2_000_000
controls.sm.recv_time[name] = (now - 1_000_000) / 1e9
controls.sm.logMonoTime['slcState'] = state.frameMonoTime = now - (2_000_000 if phase == 'active' else 200_000_000)
with patch('openpilot.selfdrive.car.card.clock_pair_ns', return_value=(now, now + offset)), \
patch('openpilot.selfdrive.car.card.time.monotonic_ns', return_value=now), \
patch('openpilot.selfdrive.car.card.REPLAY', False):
card.controls_update(out, command.as_reader())
directions.extend((data[5] >> 4) & 7 for address, data, _ in sent[-1][0] if address == 0x1e1)
self.assertAlmostEqual(out.vCruise, 70 * .44704 * 3.6, places=4)
self.assertAlmostEqual(command.hudControl.setSpeed, 70 * .44704, places=4)
by_phase.append(directions)
self.assertIn(3, by_phase[0])
self.assertEqual(by_phase[1], [])
self.assertIsNone(ci.CC.volt_cc_speed_ceiling_mps)
def test_actual_card_constructor_subscribes_selected_consumer(self):
class Registered(Exception):
pass
@@ -86,8 +86,9 @@ class TestPlannerSelection(unittest.TestCase):
imports = [node.module for node in planner.body if isinstance(node, ast.ImportFrom)]
self.assertIn('openpilot.starpilot.longitudinal.upstream.mpc', imports)
self.assertNotIn('openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc', imports)
self.assertFalse(any(value and value.startswith('openpilot.starpilot.') and value !=
'openpilot.starpilot.longitudinal.upstream.mpc' for value in imports))
self.assertEqual({value for value in imports if value and value.startswith('openpilot.starpilot.')}, {
'openpilot.starpilot.longitudinal.upstream.mpc', 'openpilot.starpilot.longitudinal.cruise_ceiling',
})
if __name__ == '__main__':
@@ -60,7 +60,7 @@ class TrafficAccelerationPlannerTests(unittest.TestCase):
self.assertLessEqual(traffic.output_a_target, 0.44 + 1e-8)
self.assertLess(traffic.a_cruise, baseline.a_cruise)
def test_soft_saved_traffic_keeps_slc_and_force_decel_braking(self):
def test_slc_cap_preserves_soft_traffic_response_and_native_force_decel(self):
target = self.tuning(acceleration=0.6, braking=0.35)
self.assertAlmostEqual(target.acceleration_max, 0.6)
self.assertAlmostEqual(target.cruise_brake_magnitude, 0.35)
@@ -72,14 +72,15 @@ class TrafficAccelerationPlannerTests(unittest.TestCase):
for _ in range(60):
sm, _ = messages(force=force)
sm['carControl'].longActive = True
baseline.update(sm, cruise_ceiling=ceiling)
traffic.update(sm, cruise_ceiling=ceiling, profile_tuning=target, traffic_mode=True)
sm['carState'].vCruise = 15.0 * 3.6
baseline.update(sm, profile_tuning=target, traffic_mode=True)
self.assertEqual(traffic.mpc.solution_status, 0)
self.assertLessEqual(traffic.a_cruise, baseline.a_cruise + 1e-8)
self.assertEqual(traffic.last_cruise_ceiling_status, 'force_decel' if force else 'applied')
self.assertIsNotNone(traffic.last_profile)
self.assertAlmostEqual(traffic.last_profile.cruise_brake_magnitude, 0.35)
self.assertLessEqual(traffic.a_cruise, -0.5 + 1e-8)
self.assertLessEqual(traffic.a_cruise, (-0.5 if force else -0.35) + 1e-8)
def test_soft_saved_traffic_preserves_lead_and_model_stop_candidates(self):
target = self.tuning(acceleration=0.6, braking=0.5)
@@ -215,8 +216,10 @@ class TrafficAccelerationPlannerTests(unittest.TestCase):
cruise_ceiling=cruise_ceiling, curve_ceiling=curve_ceiling)
self.assertEqual(planner.mpc.solution_status, 0)
self.assertAlmostEqual(planner.output_a_target, baseline.output_a_target, places=5)
if case in ('slc', 'curve'):
if case == 'curve':
self.assertLessEqual(planner.a_cruise, -0.5 + 1e-8)
elif case == 'slc':
self.assertLessEqual(planner.a_cruise, -0.42 + 1e-8)
elif case == 'force_decel':
self.assertLessEqual(planner.a_cruise, -0.5 + 1e-8)
elif case == 'model_stop':
@@ -23,7 +23,7 @@ class EndLoop(Exception):
class TestUpstreamConditional(unittest.TestCase):
def run_loop(self, *, safe=False, stale=False, clocks=True, replay=False):
def run_loop(self, *, safe=False, stale=False, clocks=True, replay=False, show_speed_limits=False):
directory = self.enterContext(tempfile.TemporaryDirectory())
params = Params(directory)
cp = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC_BOSCH)
@@ -32,6 +32,9 @@ class TestUpstreamConditional(unittest.TestCase):
params.put_bool('UseStarPilotLongitudinalPlanner', False, block=True)
params.put_bool('SafeMode', safe, block=True)
params.put_bool('ExperimentalMode', False, block=True)
params.put_bool('ShowSpeedLimits', show_speed_limits, block=True)
if show_speed_limits:
params.put('SLCPriority1', 'Dashboard', block=True)
params.put('ConditionalModeConfig', json.loads(encode_preferences(SavedPreferences(
mode=ModeChoice.CEM, cem=CEMOptions(speed_mps=20., stop_lights=False, lead=False, slower_lead=False)))), block=True)
settings = ConditionalSettingsOwner(params)
@@ -82,6 +85,15 @@ class TestUpstreamConditional(unittest.TestCase):
self.payloads[name] = getattr(messaging.log_from_bytes(event.to_bytes()), name)
if stale:
self.logMonoTime['modelV2'] -= 1_000_000_000
if show_speed_limits:
dashboard = messaging.new_message('slcDashboardObservation', valid=True).slcDashboardObservation
dashboard.carStateLogMonoTime = dashboard.observedMonoTime = stamp
dashboard.validUntilMonoTime = stamp + 100_000_000
dashboard.status, dashboard.speedMps, dashboard.episode = 'valid', 15., 1
dashboard.producerSessionId = 'card'
self.payloads['slcDashboardObservation'] = dashboard
self.logMonoTime['slcDashboardObservation'] = stamp
self.valid['slcDashboardObservation'] = self.alive['slcDashboardObservation'] = True
sm = Scene()
@@ -133,10 +145,19 @@ class TestUpstreamConditional(unittest.TestCase):
self.assertFalse(any(result.accepted for _, result in results))
self.assertFalse(any(str(plan.longitudinalPlanSource) == 'e2e' for plan in plans))
def test_cem_and_speed_limit_share_one_status_per_model_frame(self):
results, statuses, plans, subscriptions = self.run_loop(show_speed_limits=True)
self.assertEqual(subscriptions.count('deviceState'), 1)
self.assertEqual(len(statuses), len(plans))
self.assertTrue(any(result.accepted and result.experimental for _, result in results))
self.assertTrue(all(status.slcState.displayOnly and status.slcState.speedLimit == 15. and
status.slcState.conditionalMode.version == 1 for status in statuses))
def test_safe_mode_retains_upstream_without_conditional_transport(self):
results, statuses, plans, subscriptions = self.run_loop(safe=True)
self.assertFalse(results or statuses)
self.assertNotIn('deviceState', subscriptions)
self.assertNotIn('starpilotRadarState', subscriptions)
self.assertNotIn('starpilotNavigation', subscriptions)
self.assertEqual(len(plans), 100)
def test_missing_live_clock_does_not_publish_conditional_proposals(self):
@@ -2,11 +2,15 @@
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from unittest.mock import Mock, patch
import numpy as np
from openpilot.cereal import log
import pytest
from openpilot.cereal import log, messaging
from openpilot.common.params import Params
from openpilot.selfdrive.controls import plannerd as starpilot_plannerd
from openpilot.starpilot.longitudinal.upstream import mpc, planner, plannerd
from openpilot.starpilot.speed_limits.tests import test_runtime_replay as replay
class Solver:
@@ -51,9 +55,9 @@ class TestUpstreamPlanner(unittest.TestCase):
self.assertEqual(vars(cp), before)
self.assertFalse(hasattr(stock, 'profile_tuning'))
def test_upstream_process_constructs_only_native_publishers_and_planner(self):
def test_upstream_process_without_slc_transport_uses_only_native_services(self):
calls = []
cp = SimpleNamespace(brand='honda', openpilotLongitudinalControl=True)
cp = SimpleNamespace(brand='honda', openpilotLongitudinalControl=True, passive=True)
class SM:
def update(self):
raise InterruptedError('bounded end of mocked loop')
@@ -61,7 +65,8 @@ class TestUpstreamPlanner(unittest.TestCase):
calls.append(('subscribe', services, kwargs))
return SM()
fake_params = SimpleNamespace(get=lambda key, **kwargs: b'CP')
with patch.object(plannerd, 'config_realtime_process'), patch.object(plannerd, 'Params', return_value=fake_params), \
with patch.dict('os.environ', {}, clear=True), patch.object(plannerd, 'config_realtime_process'), \
patch.object(plannerd, 'Params', return_value=fake_params), \
patch.object(plannerd.messaging, 'log_from_bytes', return_value=cp), \
patch.object(plannerd, 'LongitudinalPlanner', side_effect=lambda cp: calls.append(('planner', cp))), \
patch.object(plannerd, 'LaneDepartureWarning', return_value=object()), \
@@ -77,5 +82,87 @@ class TestUpstreamPlanner(unittest.TestCase):
self.assertEqual(Path(plannerd.__file__).resolve(), (root / 'plannerd.py').resolve())
@pytest.mark.parametrize('choice', ['wheel', 'screen', 'stock_cruise'])
def test_upstream_planner_applies_confirmed_slc_with_op_long(tmp_path, monkeypatch, choice):
fixture = replay.RuntimeReplayTests()
fixture.setUp()
cp, sm = fixture.cp, fixture.sm
cp.openpilotLongitudinalControl = choice != 'stock_cruise'
sm['slcDashboardObservation'].speedMps = 15.
sm.data['deviceState'] = messaging.new_message('deviceState').deviceState
sm.valid['deviceState'] = sm.alive['deviceState'] = False
params = Params(str(tmp_path))
params.put('CarParams', cp.to_bytes(), block=True)
params.put_bool('UseStarPilotLongitudinalPlanner', False, block=True)
params.put_bool('SpeedLimitController', True, block=True)
params.put('SLCPriority1', 'Dashboard', block=True)
params.put('SLCConfirmation', 1, block=True)
publisher = Mock()
subscriber = Mock(return_value=sm)
outputs = []
events = {}
start = 2_000_000_000
class EndLoop(Exception):
pass
def update():
sm.frame = len(outputs) + 1
if sm.frame == 3:
raise EndLoop
sm.advance(start + (sm.frame - 1) * 50_000_000)
if sm.frame != 2 or choice == 'stock_cruise':
return
shown = outputs[0].slcState
assert shown.hasPending and not shown.hasCeiling
service = 'slcCruiseEvent' if choice == 'wheel' else 'slcAction'
event = messaging.new_message(service, valid=True)
event.logMonoTime = sm.logMonoTime['modelV2']
action = getattr(event, service)
action.sessionId, action.decisionId, action.presentationId = shown.sessionId, shown.decisionId, shown.presentationId
if choice == 'wheel':
action.kind, action.button = 'confirmationAccept', 'accel'
action.eventId, action.producerSessionId, action.observedMonoTime = 1, 'card-producer', event.logMonoTime
else:
action.kind, action.sequenceId = 'accept', 1
events[service] = event
def send(service, message):
if service == 'slcState':
outputs.append(message)
sm.all_checks = lambda services=None: True
monkeypatch.setattr(sm, 'update', update)
monkeypatch.setenv('REPLAY', '1')
monkeypatch.delenv('SLC_REPLAY_RUNTIME', raising=False)
monkeypatch.setattr(starpilot_plannerd, 'Params', lambda: params)
monkeypatch.setattr(plannerd, 'Params', lambda: params)
monkeypatch.setattr(plannerd, 'config_realtime_process', Mock())
monkeypatch.setattr(plannerd, 'LaneDepartureWarning', Mock(return_value=Mock(left=False, right=False)))
monkeypatch.setattr(messaging, 'SubMaster', subscriber)
monkeypatch.setattr(messaging, 'PubMaster', Mock(return_value=publisher))
monkeypatch.setattr(messaging, 'sub_sock', lambda service, **kwargs: service)
monkeypatch.setattr(messaging, 'recv_one_or_none', lambda service: events.pop(service, None))
publisher.send.side_effect = send
cruise = Mock(wraps=planner.get_cruise_accel)
monkeypatch.setattr(planner, 'get_cruise_accel', cruise)
with pytest.raises(EndLoop):
starpilot_plannerd.main()
assert 'slcDashboardObservation' in subscriber.call_args.kwargs['ignore_valid']
assert cruise.call_args_list[0].args[1] == pytest.approx(100. / 3.6)
assert sm['carState'].vCruise == 100.
state = outputs[1].slcState
if choice == 'stock_cruise':
assert state.displayOnly and not state.hasPending and not state.hasCeiling
assert cruise.call_args.args[1] == pytest.approx(100. / 3.6)
else:
assert state.hasCeiling and state.actionStatus == 'accept'
assert cruise.call_args.args[1] == 15.
assert params.get('SLCQualifiedHistory')['speed_mps'] == 15.
if __name__ == '__main__':
unittest.main()
@@ -14,6 +14,7 @@ from openpilot.starpilot.longitudinal.upstream.mpc import T_IDXS as T_IDXS_MPC
from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N, get_accel_from_plan, should_stop
from openpilot.selfdrive.car.cruise import V_CRUISE_MAX, V_CRUISE_UNSET
from openpilot.common.swaglog import cloudlog
from openpilot.starpilot.longitudinal.cruise_ceiling import select_cruise_ceiling
A_CRUISE_MAX_VALS = [1.6, 1.2, 0.8, 0.6]
A_CRUISE_MAX_BP = [0., 10.0, 25., 40.]
@@ -70,7 +71,7 @@ class LongitudinalPlanner:
self.a_desired_trajectory = np.zeros(CONTROL_N)
self.j_desired_trajectory = np.zeros(CONTROL_N)
def update(self, sm):
def update(self, sm, *, cruise_ceiling=None):
if len(sm['carControl'].orientationNED) == 3:
accel_coast = get_coast_accel(sm['carControl'].orientationNED[1])
else:
@@ -79,11 +80,17 @@ class LongitudinalPlanner:
v_ego = sm['carState'].vEgo
v_cruise_kph = min(sm['carState'].vCruise, V_CRUISE_MAX)
v_cruise = v_cruise_kph * CV.KPH_TO_MS
long_control_off = sm['controlsState'].longControlState == LongCtrlState.off
ceiling = select_cruise_ceiling(
cruise_ceiling, driver_v_cruise_kph=sm['carState'].vCruise, driver_cruise_mps=v_cruise,
system_long_available=self.CP.openpilotLongitudinalControl, long_control_active=not long_control_off,
selfdrive_enabled=sm['selfdriveState'].enabled, force_decel=sm['controlsState'].forceDecel,
)
if ceiling.effective_mps is not None:
v_cruise = ceiling.effective_mps
if sm['controlsState'].forceDecel:
v_cruise = 0.0
long_control_off = sm['controlsState'].longControlState == LongCtrlState.off
# Reset current state when not engaged, or user is controlling the speed
reset_state = long_control_off if self.CP.openpilotLongitudinalControl else not sm['selfdriveState'].enabled
# PCM cruise speed may be updated a few cycles later, check if initialized
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import os
import time
from collections import deque
from opendbc.car.structs import car
@@ -11,7 +12,11 @@ from openpilot.selfdrive.controls.plannerd import current_manual_event, current_
from openpilot.starpilot.conditional_mode.planner_host import ConditionalPlannerHost
from openpilot.starpilot.conditional_mode.projection import paired_clocks_ns
from openpilot.starpilot.feature_runtime import enabled as feature_enabled
from openpilot.starpilot.feature_runtime import slc_transport_capable
from openpilot.starpilot.longitudinal.upstream.planner import LongitudinalPlanner
from openpilot.starpilot.speed_limits.planner_host import (
SLC_ACTIONS, SLC_CRUISE_EVENTS, SavedSlcLifecycle, action_from_message, cruise_event_from_message, receive_slc_events,
)
import openpilot.cereal.messaging as messaging
@@ -25,50 +30,76 @@ def main():
ldw = LaneDepartureWarning()
longitudinal_planner = LongitudinalPlanner(CP)
replay = 'REPLAY' in os.environ
conditional = ConditionalPlannerHost(params) if feature_enabled(params, CP, 'conditional', os.environ) else None
slc = SavedSlcLifecycle(params, CP, os.environ) if slc_transport_capable(CP, os.environ) else None
native_inputs = ['carControl', 'carState', 'controlsState', 'vehicleParameters', 'radarState', 'modelV2', 'selfdriveState']
optional = ['deviceState', 'longitudinalPlan', 'starpilotRadarState', 'starpilotNavigation'] if conditional is not None else []
pm = messaging.PubMaster(['longitudinalPlan', 'driverAssistance'] + (['slcState'] if conditional is not None else []))
optional = ['deviceState'] if slc is not None or conditional is not None else []
if slc is not None:
slc.refresh(0)
optional.extend(slc.source_services())
if conditional is not None:
optional.extend(['longitudinalPlan', 'starpilotRadarState', 'starpilotNavigation'])
pm = messaging.PubMaster(['longitudinalPlan', 'driverAssistance'] + (['slcState'] if optional else []))
sm = messaging.SubMaster(native_inputs + optional, poll='modelV2',
ignore_alive=optional, ignore_valid=optional, ignore_avg_freq=optional)
cruise_sock = messaging.sub_sock('slcCruiseEvent', conflate=False) if conditional is not None else None
action_sock = messaging.sub_sock('slcAction', conflate=False) if conditional is not None else None
cruise_sock = messaging.sub_sock('slcCruiseEvent', conflate=False) if optional else None
action_sock = messaging.sub_sock('slcAction', conflate=False) if optional else None
pending_actions, pending_cruise = deque(maxlen=64), deque(maxlen=64)
pending_manual, pending_ui = deque(maxlen=64), deque(maxlen=64)
drive_start_ns = 0
try:
while True:
if slc is not None and not replay:
slc.refresh(time.monotonic_ns(), pending_actions=pending_actions, pending_cruise=pending_cruise)
sm.update()
if sm.updated['modelV2']:
longitudinal_planner.update(sm)
if conditional is not None and 'REPLAY' not in os.environ:
clocks = paired_clocks_ns()
if clocks is not None:
now_ns, boot_ns, skew_ns = clocks
drive_id = (int(sm['deviceState'].startedMonoTime) if sm.valid['deviceState'] and sm.alive['deviceState'] and
0 < sm.logMonoTime['deviceState'] <= now_ns else 0)
if drive_id > 0 and drive_start_ns > 0 and drive_id != drive_start_ns:
conditional.reset()
pending_manual.clear()
pending_ui.clear()
for sock, service, kind, pending in ((cruise_sock, 'slcCruiseEvent', 'conditionalMode', pending_manual),
(action_sock, 'slcAction', 'conditionalModeCycle', pending_ui)):
for _ in range(8):
event = messaging.recv_one_or_none(sock)
if event is None:
break
if event.valid and str(getattr(event, service).kind) == kind:
pending.append(event)
proposal, snapshot = conditional.sample(
sm, CP, longitudinal_planner, now_mono_ns=now_ns, now_boot_ns=boot_ns, sample_skew_ns=skew_ns,
drive_id=drive_id, native_plan_valid=bool(sm.all_checks(native_inputs)),
manual_event=current_manual_event(pending_manual, now_ns, int(sm.logMonoTime['carState'])),
ui_event=current_ui_manual_event(pending_ui, now_ns))
status = conditional.status.attach(None, proposal, snapshot, now_ns=now_ns, drive_id=drive_id,
model_ns=int(sm.logMonoTime['modelV2']), car_state_ns=int(sm.logMonoTime['carState']))
pm.send('slcState', status)
if drive_id > 0:
drive_start_ns = drive_id
clocks = paired_clocks_ns() if conditional is not None and not replay else None
now_ns = int(sm.logMonoTime['modelV2']) if replay else clocks[0] if clocks is not None else time.monotonic_ns()
drive_id = (int(sm['deviceState'].startedMonoTime) if optional and sm.valid['deviceState'] and sm.alive['deviceState'] and
0 < sm.logMonoTime['deviceState'] <= now_ns else 0)
drive_changed = drive_id > 0 and drive_start_ns > 0 and drive_id != drive_start_ns
if drive_changed and conditional is not None and not replay:
conditional.reset()
pending_manual.clear()
pending_ui.clear()
if slc is not None or clocks is not None:
if slc is not None and replay:
slc.refresh(now_ns, pending_actions=pending_actions, pending_cruise=pending_cruise)
for event in receive_slc_events(cruise_sock):
if event.valid:
kind = str(event.slcCruiseEvent.kind)
if slc is not None and kind in SLC_CRUISE_EVENTS:
pending_cruise.append(cruise_event_from_message(event))
elif clocks is not None and kind == 'conditionalMode':
pending_manual.append(event)
for event in receive_slc_events(action_sock):
if event.valid:
kind = str(event.slcAction.kind)
if slc is not None and kind in SLC_ACTIONS:
pending_actions.append(action_from_message(event))
elif clocks is not None and kind == 'conditionalModeCycle':
pending_ui.append(event)
output = slc.step(sm, now_ns, pending_actions, pending_cruise, drive_changed=drive_changed) if slc is not None else None
ceiling = output.result.ceiling if output is not None else None
status = output.message if output is not None else None
longitudinal_planner.update(sm, cruise_ceiling=ceiling)
if conditional is not None and clocks is not None:
_, boot_ns, skew_ns = clocks
proposal, snapshot = conditional.sample(
sm, CP, longitudinal_planner, now_mono_ns=now_ns, now_boot_ns=boot_ns, sample_skew_ns=skew_ns,
drive_id=drive_id, native_plan_valid=bool(sm.all_checks(native_inputs)),
cruise_ceiling_mps=ceiling.speed_mps if ceiling is not None else None,
manual_event=current_manual_event(pending_manual, now_ns, int(sm.logMonoTime['carState'])),
ui_event=current_ui_manual_event(pending_ui, now_ns))
status = conditional.status.attach(status, proposal, snapshot, now_ns=now_ns, drive_id=drive_id,
model_ns=int(sm.logMonoTime['modelV2']), car_state_ns=int(sm.logMonoTime['carState']))
if status is not None:
pm.send('slcState', status)
drive_start_ns = drive_id or drive_start_ns
longitudinal_planner.publish(sm, pm)
ldw.update(sm.frame, sm['modelV2'], sm['carState'], sm['carControl'])
@@ -7,14 +7,14 @@
"plannerd.py": {
"upstream_path": "openpilot/selfdrive/controls/plannerd.py",
"upstream_sha256": "9c1591c78522e0f89794eb7301113fc616ec6ac079e576a3c9f9ebec64bcbfde",
"adapted_sha256": "0af78b0030a2219f3224e28592715a50bc5620a1e644b75c13eeb0cbeeb2afd5",
"adaptation": "Planner/MPC imports redirected; independent existing conditional-mode owner and bounded transport connected outside unchanged upstream planner/MPC arithmetic"
"adapted_sha256": "c0a305cd934673706aa29166ba3b08ae82894b1d7ee41e5348c4b995ee9a4db2",
"adaptation": "Redirected planner imports and connected independent conditional mode and shared SLC through bounded event transport and a shared status publisher"
},
"planner.py": {
"upstream_path": "openpilot/selfdrive/controls/lib/longitudinal_planner.py",
"upstream_sha256": "d81425700cf7152ef4f617ec1517fb4137fc5af9188047f48b31aff62dc652f4",
"adapted_sha256": "7f79b61e095928100cb1d1d0b5e5e6ee66676d461c2274fc5a542cec2c841e8f",
"adaptation": "Only planner/MPC import paths redirected; no arithmetic/state/publisher changes"
"adapted_sha256": "47967154441baa073e777e9664f3383705ddeaa779da8dc31c3b2909b9ad49b0",
"adaptation": "Redirected MPC imports and applied the optional, authority-checked SLC cap to the cruise target"
},
"mpc.py": {
"upstream_path": "openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py",
+4 -5
View File
@@ -218,18 +218,17 @@ def enabled(started: bool, params: Params, CP: car.CarParams) -> bool:
# Selecting Maps owns producer demand directly. Do not rewrite the separate
# diagnostic opt-in, and do not start control inside the Go shadow provider.
from openpilot.starpilot.saved_source import read_saved
from openpilot.starpilot.speed_limits.runtime_settings import LABELS, read_params
from openpilot.starpilot.speed_limits.runtime_settings import LABELS, SOURCE_PRIORITY_DEFAULTS, SOURCE_PRIORITY_KEYS, SOURCE_PRIORITY_MAX_BYTES, read_params
from openpilot.starpilot.speed_limits.selection import SelectionMode, Source
# Default Vision/Dashboard needs no Maps producer or full settings scan.
# These bounded reads are demand hints only; canonical parsing below owns
# the enabled/display state and rejects malformed complete preferences.
labels = []
for key in ('SLCPriority1', 'SLCPriority2'):
raw, readable = read_saved(params, key, 32)
for key, default in zip(SOURCE_PRIORITY_KEYS, SOURCE_PRIORITY_DEFAULTS, strict=True):
raw, readable = read_saved(params, key, SOURCE_PRIORITY_MAX_BYTES)
if not readable:
return False
try:
labels.append(raw.decode('utf-8') if raw is not None else None)
labels.append(raw.decode('utf-8') if raw is not None else default)
except UnicodeError:
return False
if not any(LABELS.get(label) is Source.MAP for label in labels) and labels[0] not in ('Highest', 'Lowest'):
@@ -16,6 +16,7 @@ from openpilot.common.params import Params
from openpilot.starpilot.maps.artifact import elf_arm64_static, source_digest
from openpilot.starpilot.maps import shadow_lifecycle as shadow
from openpilot.starpilot.maps import package_shadow
from openpilot.starpilot.speed_limits.runtime_settings import SOURCE_PRIORITY_KEYS
def fake_static_elf() -> bytes:
@@ -35,7 +36,7 @@ class FakeParams(Params):
self.path = path
def get_param_path(self, key: str = '') -> str:
assert key in (shadow.OPT_IN_KEY, 'SLCPriority1', 'SLCPriority2')
assert key in (shadow.OPT_IN_KEY, *SOURCE_PRIORITY_KEYS)
return str(self.path if key == shadow.OPT_IN_KEY else self.path.parent / key)
@@ -173,6 +174,7 @@ class TestShadowLifecycle(unittest.TestCase):
path = self.base / 'MapdShadowEnabled'
params = FakeParams(path)
cp = FakeCar()
(self.base / 'SLCPriority2').write_bytes(b'Dashboard')
self.assertFalse(shadow.enabled(True, params, cp))
path.write_bytes(b'1')
self.assertTrue(shadow.enabled(True, params, cp))
@@ -196,6 +198,7 @@ class TestShadowLifecycle(unittest.TestCase):
with mock.patch.dict(os.environ, {'PARAMS_ROOT': str(self.base / 'params')}):
params = Params()
self.assertFalse(params.get_default_value(shadow.OPT_IN_KEY))
params.put('SLCPriority2', 'Dashboard', block=True)
self.assertFalse(shadow.enabled(True, params, FakeCar()))
params.put_bool(shadow.OPT_IN_KEY, True, block=True)
self.assertTrue(shadow.enabled(True, params, FakeCar()))
+11 -11
View File
@@ -1,15 +1,15 @@
# Speed-limit composition
`composition.step` joins current source selection, adoption decisions, configured
offsets, selected cruise coordinates and qualified driver intent. It preserves
validation of sessions, clocks, source observations, current authority and the causal
action ledger. Malformed or contradictory input cannot authorize a command.
`composition.step` joins source selection, acceptance, offsets and qualified raw/cluster
speed pairs into `Result.ceiling`. The planner caps the driver's SET with this value;
SLC never writes SET. Source, action, session, clock and authority validation remain
at their existing boundaries. Ordinary driver SET changes do not create an SLC bypass.
The resulting accepted adjusted limit is adoption data. Runtime sends it to card
as a bounded set-speed command, retrying a failed application only while the same
adoption and expected set speed remain current. `Result.ceiling` is always `None`; SLC does
not compose another speed ceiling with the driver's set speed. The planner uses
card's published set speed and its ordinary selected braking profile.
The previous accepted limit plus the current offset remains the cap through source
gaps and later engagements. Validated saved history supplies the same fallback
after a restart. It never creates a fresh source observation or accepts a pending
proposal. Current vehicle coordinates and system-longitudinal authority remain
required. There is no separate SLC override reducer or retained pedal target.
Lead following, curves, stopping and actuator restrictions retain their independent planner paths.
See the [current controller contract](README.md).
Lead following, curves, stopping, cruise profiles and actuator restrictions retain
their existing owners. See the [controller contract](README.md).
+7 -16
View File
@@ -1,18 +1,9 @@
# Set-speed override
# Driver inputs
Only a qualified driver set-speed change can create an SLC override. Either an
increase or decrease qualifies when the resulting speed differs from the most
recently applied set speed. Card's qualified application receipt records the
rounded value; only a 0.001 m/s transport tolerance applies. The causal ledger
excludes automatic changes, consumed
confirmation presses, duplicated effects and foreign or obsolete contexts.
Ordinary cruise +/- changes the driver's SET ceiling. With **Override speed limit
with +** enabled, a qualifying physical tap past the adjusted limit also lets cruise
use SET. The accelerator override has its own policy. See [SET speed and accelerator](README.md#set-speed-and-accelerator).
Override is presentation state; it never adds another planner target. Card's set
speed remains the sole cruise target. The widget crosses out its latest source
limit while override is active. A new limit is adopted immediately in automatic mode
or follows the ten-second manual confirmation policy. Acceptance clears the old override. Returning
the selected speed to the adopted adjusted limit also clears it.
Accelerator use neither creates an override nor cancels a preexisting set-speed
choice. Authority transitions still suppress commands and invalidate action origins;
reversed clocks and malformed evidence remain fail-closed. See [README](README.md).
Source loss uses the previous accepted limit plus the current offset, up to SET.
That remembered limit survives re-engagement and restarts; applying it still needs
current vehicle coordinates and system-longitudinal authority. See [README](README.md).
+220 -130
View File
@@ -1,49 +1,45 @@
# Speed Limit Controller
SLC uses the ordinary cruise set speed as its only vehicle-speed target. A qualified
limit from the selected Dashboard, Maps, or Vision source is an input to adoption.
Acceptance applies the configured offset within the existing 8–145 km/h cruise
bounds and sends a qualified command to card,
which owns `vCruise` and `vCruiseCluster`. SLC supplies no separate planner ceiling.
Lead, curve, stop and actuator safety constraints retain their existing owners.
SLC supplies a speed-limit cap beneath the driver's cruise SET. The planner uses
the lower of SET and the accepted limit plus its configured offset, converted into
physical-speed coordinates once. Accepting a limit never rewrites SET. Native PCM
cruise owns its setpoint; non-PCM software cruise and the dedicated Subaru software
cruise mode retain their existing owners. Automatic SLC requires system longitudinal
control. Lead, curve, stop and actuator safety constraints retain their existing owners.
SLC works with either the StarPilot or upstream longitudinal planner.
## New limits and adoption
On SET or RES engagement, a currently qualified limit from the selected source
initializes SET with its existing offset and mph/km/h rounding, even with Automatic
adoption off. Sources keep updating while disengaged. This explicit adoption clears
the matching prompt, decline or set-speed override; later limits keep the normal
confirmation policy. Without a qualified current limit, RESUME restores the previous
SET and normal SET initialization applies. An early Maps proposal does not replace
the current road's limit at engagement. Toyota still requires native cruise capacity
and release approval before applying the target.
**Match Speed Limit on Engage** is Off by default. When enabled, a new software SET
engagement uses the SLC limit plus its offset, including a saved accepted limit
during a source gap. A fresh reading takes priority over history from another
drive. An unanswered proposal cannot replace a limit already adopted this drive.
An accepted early Maps preview applies to the SLC ceiling only. A new SET
uses the qualified current limit when available, or ordinary SET initialization
if only the preview is available.
RES restores the previous SET when one exists. Factory cruise keeps its own SET.
A different numeric limit from the selected source starts a new decision, including
while a set-speed override is active. A source/road identity change at the same
numeric limit does not start another decision. A decline remains effective across
a source gap or same-value provider change; a later different limit starts a new
window, including when an earlier numeric limit returns.
A different numeric limit from the selected source can start a new decision, including
after the driver changes SET. A source or road identity change at the same numeric
limit does not start another decision. The most recent decline survives source
gaps and other readings until a different limit is adopted or another limit is
declined. The sign continues to show the road reading.
A different provider proposing a different numeric limit must remain the proposed
replacement for two seconds before confirmation or automatic adoption. A competing
qualified reading cancels that check; missing readings do not restart it. Adoption
still requires fresh evidence at the end. Same-provider limit changes, explicit
driver adoption and latched early Maps proposals keep their existing timing.
A qualified limit from a different provider enters the same confirmation or automatic
adoption flow immediately, without an extra source-switch delay.
While StarPilot owns speed control, a proposed limit whose adjusted, rounded target
already matches both current SET values is recorded without another confirmation
or cruise command. The comparison uses the same mph/km/h rounding as Card, not
actual traveling speed. A presented Accept or Decline takes priority, and a decline
remains effective for that proposal. If SET reaches the target during a prompt,
the prompt closes without changing speed. An earlier command must first settle,
and SET must be observed after its receipt or expiry, before alignment is trusted.
Received-limit memory still manages proposal changes and declined choices.
An expired confirmation stays declined even if SET now matches. Changing only
display units renews pending actions while preserving accepted limits, declines, manual
overrides and the already applied SET.
A proposed adjusted limit matching both displayed SET values is recorded without
another confirmation. The comparison uses the existing mph/km/h display rounding,
not actual traveling speed. A presented Accept or Decline takes priority; an expired
confirmation remains declined. Unit changes renew pending actions while preserving
accepted limits, declines, SET and the active physical-speed cap.
With Automatic adoption on, qualified new limits are adopted immediately.
With it off, the stationary confirmation card gives two gentle purple pulses
With **Confirm limits** set to **Off**, qualified new limits are adopted immediately.
**Both**, **Higher only**, and **Lower only** ask relative to the previous
accepted posted limit. An unselected direction is automatic.
Without a previous limit, any manual policy asks for the first one unless it
already matches SET.
The stationary confirmation card gives two gentle purple pulses
(700 ms each, separated by 200 ms), then rests until the 3.2-second cycle repeats.
Only the lower part of the card pulses; the speed and source remain steady.
The arrow remains relative to vehicle speed.
@@ -53,8 +49,13 @@ US signs share a cached antialiased white outline with a transparent interior;
the offset fits inside the fixed sign height on both screens.
Both sign styles identify the displayed source: DASH for Dashboard, OSM for offline
Maps, and VISION for the vision model. Explicit Mapbox readings in historical
replay use NAV; unidentified sources keep SPEED. Retained readings keep their
original source label.
replay use NAV; unidentified live sources show SPEED. A retained road reading
shows LAST alongside its source when known. For the same source and speed, the
label clears after 150 ms of live updates. Vision keeps a confirmed sign current
after it leaves view while the camera pipeline stays healthy. DASH follows the
current OEM reading, and Maps keeps the current road limit separate from its
upcoming preview. The binding SLC speed is shown separately beside SET, including
during a new-limit confirmation.
Both styles place only this source above the number, beside any pending arrow.
The source and arrow share a centered row; all sign contents use the same geometry
on both screens. Small arrows are baked at double resolution and text is aligned
@@ -65,25 +66,29 @@ while system longitudinal control is engaged, including a temporary accelerator
override. Disengaged and AOL lateral-only modes keep displaying limits without
prompting. Losing longitudinal engagement cancels the pending choice without
declining it; re-engaging starts a fresh choice if needed. A pending manual choice
expires after ten monotonic seconds while engaged, including source gaps. The same
decision, card and expanded controls remain during a gap, with Accept disabled and
an unavailable label. Reject still dismisses that decision. Recovery renews the
action presentation without restarting the decision, deadline or attention cycle.
expires after thirty monotonic seconds while engaged, including source gaps. The same
decision, presentation, card and expanded controls remain actionable during a gap.
Accept applies that remembered proposal and Reject dismisses it. Recovery of the
same proposal preserves the interaction, deadline and attention cycle.
Wheel confirmation presses remain consumed during a gap, so they cannot become
ordinary SET adjustments. An unavailable Accept is never queued for recovery.
Decline and timeout leave set speed unchanged. An action at the exact deadline
can still complete its presented decision; a later action cannot accept it.
ordinary SET adjustments. Acceptance takes effect immediately and is never queued.
Decline and timeout leave set speed unchanged. An action captured by the deadline
can complete its presented decision within the input freshness window. The prompt
closes at expiry, and an action captured after the deadline cannot accept it.
The existing saved `SLCConfirmation` bit is retained: `0` means Automatic adoption
on and `1` means off. Both native settings layouts show the inverse as Automatic
adoption. Legacy directional confirmation and override preferences do not change
this confirmation policy.
The saved `SLCConfirmation` mode is `0` for Off, `1` for Both, `3` for Higher
only, or `4` for Lower only.
## Optional early adoption
The existing SLC settings page adds **Adopt limits early**: Off by default, or
1–10 whole seconds (`SLCAdvanceSeconds`). It applies to both increases and
decreases from offline Maps. Other sources keep their current-limit behavior.
The existing SLC settings page offers **Adopt higher limits early** and
**Adopt lower limits early**: each is Off by default, or 1–10 whole seconds
(`SLCAdvanceHigherSeconds` and `SLCAdvanceLowerSeconds`). Direction follows the
posted upcoming limit relative to the current mapped limit, before offsets.
Other sources keep their current-limit behavior.
An ordered source list without Maps ignores map readings and previews. Removing
Maps retires an in-progress preview and returns to the selected sources' normal
adoption and previous-limit fallback rules.
The upcoming Map proposal must win the configured source-selection policy.
There is no new onroad widget, caption, or countdown. The existing New Limit
confirmation presents the proposed limit and its own offset; the ordinary sign
@@ -96,47 +101,69 @@ estimate arrival. A first offer requires motion and active system speed control.
This is approximate timing, not a promise to reach the target speed at the sign.
The first offer latches one boundary encounter. Slowing or stopping does not
retract it or restart the existing ten-second confirmation deadline. Source loss
removes authority and retains the pending choice; fresh recovery of the same
boundary uses the original deadline and a current presentation. Losing longitudinal
retract it or restart the existing thirty-second confirmation deadline. Source loss
retains the pending choice and its Accept / Reject interaction; recovery of the same
boundary uses the original deadline and presentation. Losing longitudinal
engagement cancels an unanswered choice; re-engaging starts a fresh deadline.
Decline, expiry, manual override and completed application survive the boundary
becoming current.
A fresh preview with a different destination way or limit closes an unanswered
prompt, even before the replacement is close enough to offer. Closing a prompt
preserves the rollback point for an earlier applied preview.
Decline, expiry and the matching decision survive the previewed limit becoming current.
An older preview cap ends at arrival if the selected current reading differs.
Changing the driver's SET does not withdraw an accepted early limit.
An unchanged approach limit cannot be offered again solely because the preview
is lost or disabled. Current-source evidence, command freshness, expected SET,
driver-button checks and Toyota's native capacity/release checks still apply.
is lost or disabled. Current-source evidence, active system-longitudinal authority and driver-button checks still apply.
A fresh road match that contradicts the preview retires the encounter, even if
Maps knows the road but has no posted speed for it.
A newly selected current Maps limit gets its own decision before the next exit
can be offered, including the first Maps reading of a drive.
The same source-selection policy used to offer a preview can retire it when another
qualified source wins. That approach cannot re-offer the same destination and
limit solely because the winning source disappears or a tile refresh changes the ID.
If the preferred live source matches the accepted preview speed, the cap continues.
Otherwise the previous acceptance is restored and the selected source follows normal
confirmation rules.
The configured adopt wheel action continues to adopt the displayed current limit
when no confirmation is pending.
Timing-only edits preserve the runtime session and driver decisions. Off cancels
unapplied advance work and leaves an applied SET intact; normal current-limit
behavior resumes at the boundary. Offset and unit edits retain their existing
unapplied advance work only for that direction and leaves an applied SLC cap
intact; normal current-limit behavior resumes at the boundary. Offset and unit edits retain their existing
meaning. A short lower-limit zone can immediately qualify its exit increase;
there is no hidden minimum dwell time. GPS uncertainty, missing map data and an
unresolved continuation can prevent advance adoption; ordinary current-limit SLC
remains the fallback. No accepted SET is rolled back just because data vanishes.
remains the fallback. An applied cap is retained when source data vanishes during the engagement.
On the wire, `slcState.speedLimit` remains the current selected observation.
`upcomingSpeedLimit` and `upcomingBoundaryId` identify a qualified early proposal;
an early `slcCruiseCommand` must carry the same boundary ID. The boundary record
early acceptance remains bound to that encounter. The boundary record
is in memory for this runtime session, not a saved decision database.
Only a finite positive source observation can propose a limit. Missing, stale and
unknown observations stay distinct. Source loss retains an engaged manual choice
until its original deadline and creates no authority to change set speed. Automatic
adoption retains accepted history and does not reapply an unchanged limit on
recovery. While the publisher remains fresh, the widget retains the latest
received limit, offset and original source with an unavailable status, independently
of accepted history. Cruise continues using its current SET, including a driver's
manual adjustment. Source loss creates no speed target and cannot select a
different Conditional Experimental or Conditional Chill mode. Normal mode triggers
and live braking safeguards remain with their existing owners.
Only a finite positive qualified observation can propose a new limit. Missing,
stale and unknown observations remain distinct. Source loss retains an engaged
manual choice and its interaction until the original deadline. Only an explicit
Accept can adopt that remembered proposal. Missing data uses the previous accepted limit plus the current offset,
bounded by SET. A pending or declined new limit cannot replace that remembered value.
Without a previous accepted limit, ordinary SET remains the target.
Previous accepted limit and its legacy Experimental fallback are retired.
`SLCFallback` remains registered without a default solely to preserve existing
saved bytes for older versions; current code neither reads nor writes it.
Missing, 0, 1, 2 and malformed old values all use the same behavior above.
Old Quick Select assignments remain saved but unavailable.
The previous accepted limit survives disengagement and runtime restarts. The
existing `SLCQualifiedHistory` record stores it with format and schema validation;
restoration retains only the value and source label, never an old source episode
as fresh evidence. An early preview can cap speed during the drive, but only a
qualified current reading can confirm that preview for history. This remembered
value may describe a different road. Current
vehicle coordinates and active system-longitudinal authority are still required
to apply its cap. Disabled SLC and stock longitudinal control apply no cap.
Returning to the already accepted numeric limit clears an obsolete confirmation.
The widget retains the latest received limit, offset and source with an unavailable
status. If the active SLC target differs from the displayed limit with its visible
offset, the steady sign shows both values and labels the active target SLC.
Normal conditional-mode triggers and live braking safeguards retain their
owners; missing limit data cannot select an Experimental or Chill fallback mode.
Previous accepted limit is the fixed fallback. There is no fallback selector or
Experimental fallback. Old Quick Select assignments remain saved but unavailable.
Offsets use only the saved `SLCOffsetSchedule`, with fixed physical speed ranges
and adjustments. Without a saved schedule, all seven ranges start at zero and
@@ -147,42 +174,49 @@ unit changes. There is no separate offset-adoption step.
Converted offsets display as whole mph or km/h, and edits use whole units.
Unit switches retain the saved precision so repeated conversions do not drift.
Offset-only edits preserve accepted and
rejected state and renew action identities. A changed offset can update the set
speed of the accepted current limit; source loss defers this command until that
limit is qualified again.
Offset-only edits preserve accepted and rejected state and renew action identities.
A changed offset updates the previous accepted SLC cap, including during source loss.
Valid source and confirmation edits also preserve the accepted limit and applied
cap while renewing pending actions. Disabling SLC still clears that constraint.
## Set speed and accelerator
## SET speed and accelerator
A qualified manual change to a different set speed marks an override, for increases
and decreases. An X crosses the sign while override is active. Returning to the
applied set speed clears it, using card's receipt to account for whole-unit rounding. Adopting
a new limit also clears it. Adoption retains the ordinary buttons' 0.1 km/h internal
precision, including for mph targets. New-limit decisions continue during override.
Cruise + and − change SET. Confirmation presses remain consumed through hold and
release and do not also apply a software cruise step. Native PCM button effects remain native;
StarPilot does not pretend it can suppress the vehicle's own SET adjustment.
Native standstill RES takes precedence over confirmation.
Ordinary software SET adjustments use the saved short-press and held-press intervals
on every make, including PCM vehicles while SLC owns SET. Toyota uses Swap Toyota
Cruise Steps for both native cruise and software SET: off selects short 1 / hold 5,
and on selects short 5 / hold 1. Other makes use CustomCruise and CustomCruiseLong.
Confirmation presses and native resume keep their existing meanings; they do not
also apply an interval step. Toyota's native cruise ceiling still limits software SET.
Software cruise keeps its saved short/held intervals. PCM vehicles retain their
native steps, including the Toyota Swap Cruise Steps preference. The dedicated
Subaru software-cruise mode remains independent of SLC.
The Volt conventional-cruise controller uses the fresh SLC cap beneath SET for its
cruise-button speed boundary, with its existing physical-source and timing checks.
The accelerator temporarily allows higher vehicle speed without changing set speed
or creating an SLC override. Adoption and confirmation can update software set
speed while accelerator override temporarily suspends actuator output. On release the ordinary planner approaches set speed
using the currently selected braking profile. Existing lead and stopping constraints
continue to apply.
**After an accelerator override** defaults to **Hold the speed you reached**. Pressing
the gas above the accepted limit plus offset lets cruise hold the fastest speed you
reach, up to SET. **Use your SET speed** targets SET. **Resume the adjusted limit**
returns to the accepted limit plus offset on release. The override clears when cruise disengages,
the adjusted limit catches up, or the driver explicitly accepts or adopts a limit.
After an explicit choice, the current accelerator press cannot start another
override. Releasing and pressing again can start one.
SET and the accepted limit are separate from the override. Lead, curve and
stopping constraints still apply.
The onroad screen does not offer a separate button to reapply the displayed limit.
The configured wheel action to adopt a limit remains available, as do the normal
set-speed buttons and confirmation for a different limit.
**Override speed limit with +** defaults to Off and is available when StarPilot
owns SET. A tap must move the displayed SET from at or below the adjusted limit
to above it. Cruise then uses SET until it returns to the adjusted limit, a different
limit is accepted, or engagement ends. A display-unit change or a stop while
cruise stays engaged preserves an active override. Confirmation, Curve Speed and
Force Stop take priority over this gesture.
With SLC enabled and system longitudinal control available, card owns software set
speed even on PCM vehicles; PCM still owns engagement. Native PCM setpoint changes
cannot overwrite an adopted software set speed. When SLC is off, the original PCM
set-speed path is restored. Stock longitudinal control never gains SLC commands.
The configured wheel action can explicitly adopt the displayed current limit. Once
the planner approves that specific press, card moves software SET to the approved
adjusted limit while cruise is engaged. A newer SET change cancels the write. If
card misses the approval frame, only SLC acceptance applies. On Hyundai
independent-button cars, cruise MAIN assigned to adopt is no longer also the
physical AOL lateral toggle. The onroad screen adds no new control.
## Source and command qualification
## Source and action qualification
Source selection, registered vehicle capabilities, strict saved-setting decoding,
Dashboard parser freshness, Maps road qualification, and Vision clock/inference
@@ -192,33 +226,89 @@ sources. See [selection](SELECTION.md), [Maps](MAP_SOURCE.md), and the pinned ca
[model provenance](vision/assets/PROVENANCE.md).
Vision interprets sign numerals using the existing Metric setting: km/h when on,
mph when off. The producer converts once to m/s for both UI layouts and cruise SET.
mph when off. The producer converts once to m/s for both UI layouts and the SLC cap.
Changing Metric clears Vision's retained sign and supporting reads, including
in-flight results and cached heartbeats; fresh evidence must confirm the next limit.
The current applied SET remains unchanged by this reset.
SET and the current engagement's applied cap remain unchanged by this reset.
Every action binds the current session, decision and presentation. The causal ledger
keeps consumed confirmation presses out of the ordinary cruise-button path, through
held repeats and release. Accept and Reject cannot follow a replaced source
candidate, including a replacement at the same numeric speed in the action's
planner cycle. Such a replacement preserves the decision and its deadline but
renews its presentation. Card checks active system-longitudinal authority, command
and state age, matching presentation and source, expected current set speed, legal
speed bounds, and intervening buttons before applying a command once. Dashboard
commands also match the local OEM episode. Maps and Vision commands bind the fresh
qualified planner presentation. A command receipt distinguishes application from
limit acceptance. Rejected or expired commands may retry with current evidence
while the accepted limit and expected set speed remain current, with no pending
decision or manual override. Applied commands are not repeated.
Declining or timing out a returned limit never revives an earlier adoption command;
the historical adoption remains recorded independently of the new rejection.
keeps consumed confirmation presses out of ordinary software cruise, through held
repeats and release. Accept and Reject cannot follow a replaced source candidate.
Source recovery keeps the presentation for an unchanged proposal and its original deadline.
The planner independently checks longitudinal authority, a valid initialized SET
and the numeric cap before applying the lower speed.
Transport changes append to reserved custom messages. `SlcState` retains automatic
pending metadata at ordinals 51–52 and latest-received display metadata at 53–55.
`SlcCruiseCommand` appends its source at 14 and
a bidirectional adoption kind; historical fields and enum ordinals remain reserved.
Official vehicle schemas are unchanged. Historical SLC ceiling fields stay empty.
`SlcState.hasCeiling` and `effectiveCap` report the active physical-speed constraint;
`isLimitingMaxSet` identifies when it is below the driver's ceiling. Observations,
accepted limits and the effective constraint remain separate. The existing custom
wire layout and historical command fields remain reserved for compatibility; live
SLC no longer publishes or consumes `slcCruiseCommand`. Official vehicle schemas
are unchanged. Shared curve, conditional-mode and controller messages remain intact.
Host tests cover decisions, source validation, commands, cruise buttons, profile
## Numeric comparison investigation
Native replay confirms a source-conversion cause of repeated confirmations. This
is a synthetic reproduction through real cereal packets, source qualification and
the runtime, not a diagnosis of a particular recorded drive.
The offline generator's [`maps.ParseMaxSpeed`](../../../mapd_repo/maps/generate_offline.go)
uses `0.277778 * posted_kph`, while Dashboard decoding and Vision use
`posted_kph * (1 / 3.6)`. The generated tile stores this result as Float64.
`CurrentWay.EffectiveMaxSpeed` reaches `State.MatchedLimitMps` and `mapdOut` as
Float32; the discrepancy survives that conversion. Dashboard and Vision also
publish Float32, so ordinary equivalent floating-point arithmetic often collapses
to an identical wire value instead of causing a new decision.
For the same posted **50 km/h**:
| Reading | Float32 m/s seen by SLC |
| --- | --- |
| Dashboard using `1 / 3.6` | `13.88888931274414` |
| Map using `0.277778` | `13.888899803161621` |
| Difference | `0.00001049041748046875` |
Both display as 50 km/h or 31 mph. Before the repair, switching between these
readings could open another confirmation even though the displayed number agreed.
StarPilot now checks current and upcoming Maps readings and restored Maps history
for the exact legacy Float32 encoding of a whole km/h limit through 250 km/h.
Matches are converted to the standard Float32 value before SLC uses them.
[`test_numeric_decisions.py`](tests/test_numeric_decisions.py) records the
following controls against the actual runtime:
- For 30, 50, 80, 100, 130 and 250 km/h, a switch to the legacy Maps value keeps
the accepted cap without another confirmation in either display-unit mode.
- Over 40 seconds at 20 Hz, switching Dashboard availability every half-second
with the two 50 km/h encodings now preserves one decision and its original
timeout. The same replay created 81 decisions before the repair.
- Equivalent mph arithmetic and a Float64 `nextafter` difference disappear at
the shared Float32 wire boundary and cause no new numerical decision.
- A same-number producer restart renews the presentation and retires an old
press, while keeping the decision and deadline. A plain source gap preserves
the presentation as well and counts toward the original deadline.
- Genuine 1 km/h and 1 mph changes remain decisions. In particular, both 40 and
41 km/h display as 25 mph, so current display rounding is insufficient to
establish that two limits mean the same thing.
A separate arithmetic sweep of integer 4–250 km/h found distinct Float32 values
for every shortened conversion; the largest difference was
`0.00006103515625 m/s`. This measures the current converter formula, not installed
map coverage or onroad prompt frequency. Conditional speed rules call the same
Maps parser. The separate `main.ParseMaxSpeed` helper in `speed_limit.go` has no
callers in the reviewed tree and is not the live producer path identified here.
The reference FrogPilot revision checked on 2026-11-03 was
[`98ac7448`](https://github.com/FrogAi/FrogPilot/blob/98ac7448ab542e9fdff72d1ec4d7de2cd31460be/frogpilot/controls/lib/speed_limit_controller.py).
Its controller and reviewed callers matched the earlier `bb8ffa7` source bytes.
Running its actual decision methods with provider/network work isolated confirmed
that its `< 1 m/s` rule updates the target for conversion noise, real 1 km/h and
real 1–2 mph changes without another confirmation; a 3 mph change asks. At 20 Hz,
the alternating-noise scenario retains its nominal timeout instead of restarting
it. These were decision-function checks, not a complete FrogPilot runtime test.
Repairing the value at SLC input covers existing tiles and saved Maps history.
Source identity and driver-action guards still apply after numeric repair.
Host tests cover decisions, source validation, planner caps, cruise buttons, profile
selection and both UI layouts. Synthetic captures and host tests do not establish
camera accuracy, device timing under load, or onroad behavior.
@@ -15,15 +15,16 @@ Geographic, producer-episode and session-value identities pass through unchanged
Selection checks their structure but owns no drive session; the acceptance reducer
must verify session-value applicability against its current session before use.
`SelectionPolicy` has an explicit mode, exactly two primary source slots and an
online-fallback Boolean. A slot may be empty or repeat another slot. Online is
`SelectionPolicy` has an explicit mode, up to three primary source slots and an
online-fallback Boolean. The default order is Vision, Maps, then Off. The saved
second and third slots can be Off. A slot may repeat another slot. Online is
never a primary slot. This module does not activate or translate preferences.
| Mode | Primary candidates | Choice |
| --- | --- | --- |
| Ordered | Only the configured slots | First eligible valid observation |
| Highest | Dashboard and map; vision only if present in either slot | Highest eligible value |
| Lowest | Dashboard and map; vision only if present in either slot | Lowest eligible value |
| Highest | Dashboard and map, plus vision if present in a slot | Highest eligible value |
| Lowest | Dashboard and map, plus vision if present in a slot | Lowest eligible value |
Equal extrema choose dashboard, then map, then vision, independent of input map
iteration order. The minimum eligible limit is explicitly 1 m/s, inclusive.
@@ -42,14 +43,14 @@ If no eligible alternative exists, the result retains unknown or stale state.
Unknown takes precedence over stale, which takes precedence over absence; the
full per-source statuses remain available. Explicit absence, exclusion below the
minimum or an empty configured source set produces an absent result. Acceptance
keeps accepted history but supplies no control target during source loss.
uses the previous accepted limit during source loss, without inventing a current observation.
Malformed input produces `invalid_input`, errors and
an unknown observation without selecting a source.
The tests include composition through the acceptance reducer: accepted 65 mph,
proposed 45, driver rejection, then source loss and reconnect. Last accepted
history remains 65 for both longitudinal-only and combined authority. Source loss
does not change the already applied cruise SET or authorize a new command.
does not change the driver's cruise SET or accept the rejected proposal.
Lateral-only or stock ownership cannot turn a selected
speed into a system longitudinal target. These are
deterministic policy tests, not native producer, planner, CAN or vehicle evidence.
@@ -9,34 +9,25 @@ contains explicit half-open bands with signed m/s offsets;
it supplies no settings defaults or source freshness policy. A missing band or
unavailable target/pair produces `UNAVAILABLE`; malformed values produce
`INVALID`. `SpeedPair` contains measured cluster speed, which may be below raw
speed; resolution clamps each delta to `max(cluster - raw, 0)`. A nonvalid
resolution with a context is malformed and triggers the override session-reset
contract. Only a `VALID` context may enter `overrides.step(domain=...)`.
speed; resolution clamps each delta to `max(cluster - raw, 0)`. Only a `VALID`
resolution supplies a planner coordinate.
A finite negative offset that would make the target nonpositive in cluster or
planner coordinates is ignored for that limit. The unadjusted accepted limit
is used instead; the result stays within the ordinary cruise-speed bounds.
Malformed schedules still fail validation.
Call order is acceptance, domain resolution, override reduction, then
`to_planner_coordinate(context, contribution_raw_mps, basis)`. The reducer
compares current selected and retained intent against `accepted raw + offset`
in cluster coordinates. It keeps retained intent in raw selected-speed units,
even when the current selected value later changes for an unrelated reason.
The conversion adds the *current qualified selected cluster delta* to that
retained raw speed. A current pedal contribution uses the current ego cluster
delta. The larger of limit plus offset and override is converted to planner
coordinates by subtracting the ego cluster delta exactly once. A nonpositive
result gives no coordinate. The coordinate validates the composition; it is not
a separate live planner ceiling. Runtime commands use the accepted adjusted
cluster target to update Card's SET speed. See the [controller contract](README.md).
Call order is acceptance, domain resolution, then
`to_planner_coordinate(context)`. The accepted limit plus offset is
in cluster coordinates; subtracting the current ego cluster delta once produces
the planner cap. A nonpositive result supplies no coordinate. The planner compares
this cap with the independent driver's SET and applies the lower target.
These helpers are pure; the live composition supplies the qualified inputs.
They have no Params access, source freshness policy or vehicle output. Current
SLC overrides come from qualified SET changes. Accelerator use does not create
an SLC override; ordinary actuator gating owns the temporary pedal override.
Ordinary SET changes do not supply an override contribution. During source loss,
the previous accepted limit uses the current offset and fresh vehicle coordinates.
It can be restored from validated saved history, without representing its old
source identity as current evidence. Pending and declined proposals cannot replace it.
The tests compose actual acceptance and causal override decisions, including
retained raw intent with a changed current selected value, offset changes,
negative offsets, missing coordinates, and one native planner/MPC case. These
are deterministic software contracts, not on-road qualification.
These helpers remain pure and have no Params, source-freshness or vehicle-output
ownership. Tests cover units, signed offsets, malformed and missing coordinates,
source-gap continuation, unchanged SET, authority and the native planner.
+63 -82
View File
@@ -6,9 +6,7 @@ import math
from dataclasses import dataclass, replace
from enum import Enum
CONFIRMATION_NS = 10_000_000_000
SOURCE_SWITCH_NS = 2_000_000_000
MAX_REJECTIONS = 256
CONFIRMATION_NS = 30_000_000_000
class ObservationKind(Enum):
@@ -69,7 +67,7 @@ class Authority:
@property
def can_control(self) -> bool:
"""Permission to adopt a software set speed, including accelerator override."""
"""Permission to adopt an SLC limit during a system-longitudinal engagement."""
return (self.mode in (Mode.LONGITUDINAL_ONLY, Mode.COMBINED) and
self.owner is LongitudinalOwner.SYSTEM and (self.longitudinal_active or self.accelerator_override))
@@ -92,6 +90,7 @@ class DriverAction:
sequence_id: int
decision_id: int
kind: ActionKind
observed_ns: int | None = None
@dataclass(frozen=True)
@@ -131,16 +130,6 @@ class Presentation:
candidate: Candidate
@dataclass(frozen=True)
class AdoptionProposal:
session_id: str
action_sequence_id: int
presentation_id: int
accepted: AcceptedLimit
reconciliation_speed_mps: float
clear_override: bool = True
@dataclass(frozen=True)
class State:
session_id: str
@@ -148,18 +137,14 @@ class State:
# A historical adoption may match a returned proposal the driver now declines.
accepted: AcceptedLimit | None = None
pending: PendingDecision | None = None
rejected: frozenset[Candidate] = frozenset()
rejected: Candidate | None = None
last_timestamp_ns: int | None = None
timer_running: bool = False
last_action_sequence: int = -1
next_decision_id: int = 1
rejection_capacity_exhausted: bool = False
presentation: Presentation | None = None
next_presentation_id: int = 1
received_speed_mps: float | None = None
received_source: str | None = None
switch_candidate: Candidate | None = None
switch_started_ns: int | None = None
@dataclass(frozen=True)
@@ -172,7 +157,6 @@ class Decision:
action_receipt: ActionReceipt | None = None
errors: tuple[str, ...] = ()
adoption_receipt: ActionReceipt | None = None
adoption: AdoptionProposal | None = None
authority: Authority | None = None
policy: Policy | None = None
@@ -260,21 +244,12 @@ def _state_errors(state: State) -> list[str]:
errors.append("invalid state clock")
if state.received_speed_mps is not None and not _speed_valid(state.received_speed_mps):
errors.append("invalid received speed")
if state.received_source is not None and not _token(state.received_source):
errors.append("invalid received source")
if state.switch_candidate is not None:
if (not _candidate_valid(state.switch_candidate, state.session_id) or state.switch_started_ns is None or not _integer(state.switch_started_ns) or
state.last_timestamp_ns is None or state.switch_started_ns > state.last_timestamp_ns):
errors.append("invalid source switch")
elif state.switch_started_ns is not None:
errors.append("source switch lacks candidate")
if not _integer(state.last_action_sequence, -1) or not _integer(state.next_decision_id, 1) or not _integer(state.next_presentation_id, 1):
errors.append("invalid state sequence")
if type(state.timer_running) is not bool or type(state.rejection_capacity_exhausted) is not bool:
if type(state.timer_running) is not bool:
errors.append("invalid state flags")
rejections_valid = (type(state.rejected) is frozenset and len(state.rejected) <= MAX_REJECTIONS and
all(_candidate_valid(c, state.session_id) for c in state.rejected))
if not rejections_valid:
rejection_valid = state.rejected is None or _candidate_valid(state.rejected, state.session_id)
if not rejection_valid:
errors.append("invalid rejection history")
if state.pending is not None:
pending = state.pending
@@ -284,7 +259,7 @@ def _state_errors(state: State) -> list[str]:
pending.candidate.speed_mps != state.received_speed_mps or
pending.automatic and state.observation != Observation(ObservationKind.VALID, pending.candidate)):
errors.append("invalid pending decision")
elif rejections_valid and pending.candidate in state.rejected:
elif rejection_valid and state.rejected is not None and pending.candidate.speed_mps == state.rejected.speed_mps:
errors.append("pending candidate is already rejected")
if state.timer_running and (state.pending is None or state.last_timestamp_ns is None):
errors.append("timer lacks pending decision or clock")
@@ -293,7 +268,10 @@ def _state_errors(state: State) -> list[str]:
if (not isinstance(shown, Presentation) or not _integer(shown.presentation_id, 1) or
not _integer(state.next_presentation_id, 1) or shown.presentation_id >= state.next_presentation_id or
not _candidate_valid(shown.candidate, state.session_id) or state.last_timestamp_ns is None or
state.observation != Observation(ObservationKind.VALID, shown.candidate)):
state.observation != Observation(ObservationKind.VALID, shown.candidate) and not (
isinstance(state.pending, PendingDecision) and not state.pending.automatic and
state.pending.candidate == shown.candidate and isinstance(state.observation, Observation) and
state.observation.kind is not ObservationKind.VALID)):
errors.append("invalid presentation context")
elif isinstance(state.observation, Observation) and state.observation.kind is ObservationKind.VALID:
errors.append("valid observation lacks presentation context")
@@ -311,7 +289,13 @@ def new_session(session_id: str, accepted: AcceptedLimit | None = None) -> State
def _action_valid(action: object) -> bool:
return (isinstance(action, DriverAction) and _token(action.session_id) and _integer(action.sequence_id) and
_integer(action.decision_id, 1) and isinstance(action.kind, ActionKind))
_integer(action.decision_id, 1) and isinstance(action.kind, ActionKind) and
(action.observed_ns is None or _integer(action.observed_ns, 1)))
def _timely(action: DriverAction, pending: PendingDecision, now_ns: int) -> bool:
observed_ns = now_ns if action.observed_ns is None else action.observed_ns
return 0 <= pending.active_elapsed_ns - (now_ns - observed_ns) <= CONFIRMATION_NS
def _adopt_valid(adopt: object) -> bool:
@@ -330,6 +314,9 @@ def _acknowledge(state: State, action: DriverAction | AdoptRequest | None) -> tu
def _accept(state: State, candidate: Candidate, now_ns: int) -> tuple[State, AcceptedLimit | None]:
if state.rejected is not None and (state.accepted is None or state.accepted.candidate.speed_mps != candidate.speed_mps or
state.rejected.speed_mps == candidate.speed_mps):
state = replace(state, rejected=None)
if state.accepted is not None and state.accepted.candidate == candidate and state.pending is None:
return replace(state, pending=None, timer_running=False), None
accepted = AcceptedLimit(candidate, state.session_id, now_ns)
@@ -337,14 +324,12 @@ def _accept(state: State, candidate: Candidate, now_ns: int) -> tuple[State, Acc
def _reject(state: State, candidate: Candidate) -> State:
if candidate not in state.rejected and len(state.rejected) == MAX_REJECTIONS:
return replace(state, pending=None, timer_running=False, rejection_capacity_exhausted=True)
return replace(state, rejected=state.rejected | {candidate}, pending=None, timer_running=False)
return replace(state, rejected=candidate, pending=None, timer_running=False)
def step(state: State, observation: Observation, authority: Authority, policy: Policy, *, now_ns: int,
action: DriverAction | None = None, adopt: AdoptRequest | None = None, set_speed_matches: bool = False,
confirmation_started_ns: int | None = None) -> Decision:
confirmation_started_ns: int | None = None, confirmation_grace_ns: int = 0) -> Decision:
"""Return a decision, never a cruise command. Invalid input produces no control target."""
state_errors = _state_errors(state)
if state_errors:
@@ -357,7 +342,7 @@ def step(state: State, observation: Observation, authority: Authority, policy: P
errors.append("invalid policy")
if not _integer(now_ns) or (state.last_timestamp_ns is not None and now_ns < state.last_timestamp_ns):
errors.append("invalid or reversed monotonic clock")
if action is not None and not _action_valid(action):
if action is not None and (not _action_valid(action) or action.observed_ns is not None and action.observed_ns > now_ns):
errors.append("invalid driver action")
if adopt is not None and not _adopt_valid(adopt):
errors.append("invalid adopt request")
@@ -365,6 +350,8 @@ def step(state: State, observation: Observation, authority: Authority, policy: P
errors.append("ambiguous action and adopt request")
if type(set_speed_matches) is not bool:
errors.append("invalid SET alignment")
if not _integer(confirmation_grace_ns):
errors.append("invalid confirmation grace")
if confirmation_started_ns is not None and (not _integer(confirmation_started_ns) or not _integer(now_ns) or confirmation_started_ns > now_ns):
errors.append('invalid confirmation start')
if errors:
@@ -387,43 +374,47 @@ def step(state: State, observation: Observation, authority: Authority, policy: P
state = replace(state, pending=replace(old.pending,
active_elapsed_ns=old.pending.active_elapsed_ns + now_ns - old.last_timestamp_ns))
candidate = observation.candidate
if candidate is None:
if candidate is None and (state.pending is None or state.pending.automatic):
state = replace(state, presentation=None)
elif old.presentation is None or old.presentation.candidate != candidate:
elif candidate is not None and (old.presentation is None or old.presentation.candidate != candidate):
state = replace(state, presentation=Presentation(state.next_presentation_id, candidate), next_presentation_id=state.next_presentation_id + 1)
def finish(basis: str, *, target: float | None = None, history: AcceptedLimit | None = None,
adoption: AdoptionProposal | None = None) -> Decision:
def finish(basis: str, *, target: float | None = None, history: AcceptedLimit | None = None) -> Decision:
nonlocal state
if observation.kind is not ObservationKind.VALID and state.pending is None:
state = replace(state, presentation=None)
final_receipt = replace(receipt, status=basis) if receipt is not None and receipt.status == "seen" else receipt
final_adopt_receipt = replace(adopt_receipt, status=basis) if adopt_receipt is not None and adopt_receipt.status == "seen" else adopt_receipt
return Decision(state, candidate, target, basis, history, final_receipt, adoption_receipt=final_adopt_receipt,
adoption=adoption, authority=authority, policy=policy)
authority=authority, policy=policy)
def previous_target() -> float | None:
return state.accepted.candidate.speed_mps if state.accepted is not None and authority.can_control else None
def unavailable(basis: str) -> Decision:
nonlocal state, receipt
nonlocal state, receipt, candidate
pending = state.pending
if (pending is None or pending.automatic or not authority.can_control or
not (policy.confirm_lower or policy.confirm_higher)):
state = replace(state, pending=None, timer_running=False)
return finish(basis)
# The driver's choice survives missing evidence; its authority does not.
return finish(basis, target=previous_target())
candidate = pending.candidate
# A presented manual choice stays actionable until its original deadline.
if (receipt is not None and receipt.status == 'seen' and action is not None and
action.kind is ActionKind.REJECT and action.decision_id == pending.decision_id and
pending.active_elapsed_ns <= CONFIRMATION_NS):
receipt = replace(receipt, consumed=True, status='reject')
action.decision_id == pending.decision_id and old.presentation is not None and
old.presentation.candidate == pending.candidate and
_timely(action, pending, now_ns)):
receipt = replace(receipt, consumed=True, status=action.kind.value)
if action.kind is ActionKind.ACCEPT:
state, history = _accept(state, pending.candidate, now_ns)
return finish('driver_accept', target=pending.candidate.speed_mps, history=history)
state = _reject(state, pending.candidate)
return finish('driver_reject')
if pending.active_elapsed_ns >= CONFIRMATION_NS:
return finish('driver_reject', target=previous_target())
if pending.active_elapsed_ns >= CONFIRMATION_NS + confirmation_grace_ns:
state = _reject(state, pending.candidate)
return finish('timeout_reject')
return finish(basis)
return finish('timeout_reject', target=previous_target())
return finish(basis, target=previous_target())
if state.rejection_capacity_exhausted:
state = replace(state, pending=None, timer_running=False)
return finish("session_reset_required")
if policy.display_only:
state = replace(state, pending=None, timer_running=False)
return finish("display_only")
@@ -435,41 +426,32 @@ def step(state: State, observation: Observation, authority: Authority, policy: P
explicit_adopt = (adopt is not None and adopt_receipt is not None and adopt_receipt.status == 'seen' and
old.presentation is not None and old.presentation.presentation_id == adopt.presentation_id and
old.presentation.candidate == candidate == adopt.candidate)
if (new_limit and state.received_source is not None and candidate.source != state.received_source and
authority.can_control and not explicit_adopt and confirmation_started_ns is None):
switching = state.switch_candidate
if switching is None or (switching.source, switching.speed_mps) != (candidate.source, candidate.speed_mps):
state = replace(state, switch_candidate=candidate, switch_started_ns=now_ns)
assert state.switch_started_ns is not None
if now_ns - state.switch_started_ns < SOURCE_SWITCH_NS:
return unavailable('source_switch_pending')
state = replace(state, received_source=candidate.source, switch_candidate=None, switch_started_ns=None)
if new_limit:
state = replace(state, received_speed_mps=candidate.speed_mps, pending=None, rejected=frozenset(), timer_running=False)
state = replace(state, received_speed_mps=candidate.speed_mps, pending=None, timer_running=False)
if adopt_receipt is not None and adopt_receipt.status == "seen":
assert adopt is not None
if not explicit_adopt:
adopt_receipt = replace(adopt_receipt, status="different_presentation")
else:
# Recorded for any authority; only an owner of longitudinal control commands a target.
state, history = _accept(replace(state, rejected=frozenset(c for c in state.rejected if c.speed_mps != candidate.speed_mps)), candidate, now_ns)
# Remember explicit adoption even when longitudinal control is inactive.
state, history = _accept(state, candidate, now_ns)
assert state.accepted is not None
adopt_receipt = replace(adopt_receipt, consumed=True, status="adopt")
if not authority.can_control:
return finish("driver_adopt", history=history)
proposal = AdoptionProposal(state.session_id, adopt.sequence_id, adopt.presentation_id, state.accepted, candidate.speed_mps)
return finish("driver_adopt", target=candidate.speed_mps, history=history, adoption=proposal)
return finish("driver_adopt", target=candidate.speed_mps, history=history)
if any(rejected.speed_mps == candidate.speed_mps for rejected in state.rejected):
if state.rejected is not None and state.rejected.speed_mps == candidate.speed_mps:
state = replace(state, pending=None, timer_running=False)
return finish("rejected", target=previous_target())
previous_speed = state.accepted.candidate.speed_mps if state.accepted is not None else None
if not new_limit and old.pending is None and candidate.speed_mps == previous_speed:
if candidate.speed_mps == previous_speed:
state = replace(state, pending=None, timer_running=False)
return finish("accepted", target=previous_target())
confirmation = policy.confirm_higher if previous_speed is None or candidate.speed_mps > previous_speed else (
policy.confirm_lower if candidate.speed_mps < previous_speed else policy.confirm_lower or policy.confirm_higher)
confirmation = policy.confirm_lower or policy.confirm_higher
if previous_speed is not None:
confirmation = policy.confirm_higher if candidate.speed_mps > previous_speed else policy.confirm_lower
if confirmation and not authority.can_control:
state = replace(state, pending=None, timer_running=False)
return finish("inactive")
@@ -490,8 +472,7 @@ def step(state: State, observation: Observation, authority: Authority, policy: P
target=candidate.speed_mps if authority.can_control else None, history=history)
return finish("automatic_pending", target=previous_target())
if receipt is not None and receipt.status == "seen" and pending.active_elapsed_ns <= CONFIRMATION_NS:
assert action is not None
if receipt is not None and receipt.status == "seen" and action is not None and _timely(action, pending, now_ns):
if action.decision_id != pending.decision_id:
receipt = replace(receipt, status="different_decision")
elif old.pending is None or old.pending.decision_id != pending.decision_id:
@@ -506,14 +487,14 @@ def step(state: State, observation: Observation, authority: Authority, policy: P
state, history = _accept(state, candidate, now_ns)
return finish("driver_accept", target=candidate.speed_mps if authority.can_control else None, history=history)
state = _reject(state, candidate)
return finish("driver_reject", target=None if state.rejection_capacity_exhausted else previous_target())
return finish("driver_reject", target=previous_target())
if pending.active_elapsed_ns >= CONFIRMATION_NS:
if pending.active_elapsed_ns >= CONFIRMATION_NS + confirmation_grace_ns:
state = _reject(state, candidate)
return finish("timeout_reject", target=None if state.rejection_capacity_exhausted else previous_target())
return finish("timeout_reject", target=previous_target())
# An explicit choice above wins; alignment is approval only while we own speed control.
if set_speed_matches and authority.can_control:
if set_speed_matches and authority.can_control and pending.active_elapsed_ns < CONFIRMATION_NS:
state, history = _accept(state, candidate, now_ns)
return finish("accepted", target=candidate.speed_mps, history=history)
+109 -20
View File
@@ -13,11 +13,14 @@ class Encounter:
speed_mps: float
way_id: int
started_ns: int | None
approach_way_id: int
crossed: bool = False
declined: bool = False
withdrawn: bool = False
disabled: bool = False
accepted_ns: int | None = None
applied_preview: acc.AcceptedLimit | None = None
retired: bool = False
class Advance:
@@ -25,6 +28,8 @@ class Advance:
self.encounter: Encounter | None = None
self.boundary_id = ''
self.started_ns: int | None = None
self.retired_cap: acc.AcceptedLimit | None = None
self.retired_pending_speed_mps: float | None = None
def disable(self):
if self.encounter is not None and not self.encounter.crossed:
@@ -32,6 +37,8 @@ class Advance:
def use_current(self, candidate):
self.boundary_id, self.started_ns = '', None
self.retired_cap = None
self.retired_pending_speed_mps = None
if self.encounter is not None:
self.encounter.disabled = True
self.encounter.withdrawn = False
@@ -40,41 +47,114 @@ class Advance:
self.encounter.accepted_ns = None
def propose(self, observations, policy, upcoming: Upcoming | None, *, current_way_id: int,
seconds: int, speed_mps: float | None, standstill: bool, can_offer: bool,
confirmation_active: bool, now_ns: int):
higher_seconds: int, lower_seconds: int, speed_mps: float | None, standstill: bool, can_offer: bool,
confirmation_active: bool, now_ns: int, pending: acc.PendingDecision | None, live: acc.Candidate | None,
current_unresolved: bool):
self.boundary_id, self.started_ns = '', None
self.retired_cap = None
self.retired_pending_speed_mps = None
current = observations[sel.Source.MAP].candidate
encounter = self.encounter
pending_preview = (pending is not None and encounter is not None and pending.candidate.source == 'map' and
pending.candidate.speed_mps == encounter.speed_mps)
if encounter is not None and current_way_id:
if not encounter.crossed and current_way_id == encounter.way_id and current is not None:
self.retired_cap = encounter.applied_preview
if encounter.retired or current.speed_mps != encounter.speed_mps:
if pending_preview:
self.retired_pending_speed_mps = encounter.speed_mps
self.encounter = None
return None
encounter.crossed = True
elif not encounter.crossed and (
current_way_id not in (encounter.approach_way_id, encounter.way_id) or
current is not None and not encounter.retired and current.speed_mps not in (encounter.from_mps, encounter.speed_mps)):
if (current is not None and upcoming is not None and upcoming.way_id == encounter.way_id and
upcoming.observation.candidate.speed_mps == encounter.speed_mps and current.speed_mps == encounter.from_mps):
encounter.approach_way_id = current_way_id
else:
self.retired_cap = encounter.applied_preview
if pending_preview:
self.retired_pending_speed_mps = encounter.speed_mps
if current_way_id == encounter.approach_way_id:
encounter.applied_preview = None
encounter.retired = True
else:
self.encounter = None
return None
elif encounter.crossed and current is not None and current.speed_mps != encounter.speed_mps:
self.encounter = encounter = None
held_wins = False
if encounter is not None and not encounter.crossed and (not encounter.retired or encounter.applied_preview is not None):
held = (encounter.applied_preview.candidate if encounter.applied_preview is not None else
pending.candidate if pending_preview and not encounter.retired else None)
if held is not None:
# Compare projected Maps candidates without publishing them as fresh observations.
comparison = dict(observations)
comparison[sel.Source.MAP] = acc.Observation(acc.ObservationKind.VALID, held)
fallback = current is None and live is not None and live.speed_mps != encounter.from_mps
held_wins = not fallback and sel.select_limit(comparison, policy).selected_source is sel.Source.MAP
pending_wins = held_wins
if pending_preview and pending.candidate != held:
comparison[sel.Source.MAP] = acc.Observation(acc.ObservationKind.VALID, pending.candidate)
pending_wins = not fallback and sel.select_limit(comparison, policy).selected_source is sel.Source.MAP
if not held_wins:
self.retired_cap = encounter.applied_preview
encounter.applied_preview = None
if not pending_wins:
encounter.retired = True
if pending_preview and (live is None or live.speed_mps != encounter.speed_mps or held_wins):
self.retired_pending_speed_mps = encounter.speed_mps
if encounter.retired and not held_wins:
return None
if encounter is not None and not encounter.declined and encounter.accepted_ns is None:
if not confirmation_active:
encounter.started_ns = None
elif encounter.started_ns is None:
encounter.started_ns = now_ns
if current is None:
if encounter is not None and not encounter.crossed:
if encounter is not None and not encounter.crossed and (not encounter.retired or held_wins):
return {source: acc.Observation(acc.ObservationKind.UNKNOWN)
if observation.candidate is not None and observation.candidate.speed_mps == encounter.from_mps
else observation for source, observation in observations.items()}
return None
if encounter is not None:
if current.speed_mps == encounter.speed_mps and (encounter.crossed or current_way_id == encounter.way_id):
encounter.crossed = True
elif current.speed_mps not in (encounter.from_mps, encounter.speed_mps):
# A tile refresh can change the boundary ID for the same road and limit.
replaced = (encounter is not None and upcoming is not None and
(upcoming.way_id != encounter.way_id or upcoming.observation.candidate.speed_mps != encounter.speed_mps))
previous = encounter if replaced and not encounter.crossed else None
if replaced and not (encounter.crossed or encounter.declined or encounter.disabled or encounter.retired) and encounter.accepted_ns is None:
if pending_preview:
self.retired_pending_speed_mps = encounter.speed_mps
if encounter.applied_preview is None:
self.encounter = encounter = None
else:
encounter.started_ns = None
seconds = (higher_seconds if upcoming is not None and upcoming.observation.candidate.speed_mps > current.speed_mps
else lower_seconds)
pending_current = (pending is not None and live is not None and
pending.candidate.source == live.source and pending.candidate.speed_mps == live.speed_mps)
# shortcut: use fresh GPS distance without propagation; revisit only if replay shows material timing error.
eligible = (can_offer and type(seconds) is int and 0 < seconds <= 10 and not standstill and
current_first = current_unresolved and (encounter is None or encounter.crossed)
eligible = (can_offer and not (pending_current or current_first) and type(seconds) is int and 0 < seconds <= 10 and not standstill and
speed_mps is not None and type(speed_mps) in (int, float) and math.isfinite(speed_mps) and speed_mps > 0 and
upcoming is not None and upcoming.distance_m <= speed_mps * seconds)
if eligible and (encounter is None or upcoming.boundary_id != encounter.boundary_id):
proposed = dict(observations)
proposed[sel.Source.MAP] = upcoming.observation
if sel.select_limit(proposed, policy).selected_source is sel.Source.MAP:
assert upcoming.observation.candidate is not None
encounter = self.encounter = Encounter(upcoming.boundary_id, current.speed_mps,
upcoming.observation.candidate.speed_mps, upcoming.way_id, now_ns)
if eligible:
if encounter is None or replaced and not (encounter.crossed and pending_preview):
proposed = dict(observations)
proposed[sel.Source.MAP] = upcoming.observation
if sel.select_limit(proposed, policy).selected_source is sel.Source.MAP:
assert upcoming.observation.candidate is not None
encounter = self.encounter = Encounter(upcoming.boundary_id, current.speed_mps,
upcoming.observation.candidate.speed_mps, upcoming.way_id, now_ns, current_way_id,
applied_preview=previous.applied_preview if previous is not None else None)
elif not encounter.crossed:
encounter.boundary_id = upcoming.boundary_id
if encounter is None:
return None
if encounter.retired:
return dict.fromkeys(sel.Source, acc.Observation(acc.ObservationKind.UNKNOWN)) if held_wins else None
proposed = dict(observations)
blocked = encounter.declined or encounter.withdrawn
@@ -87,15 +167,20 @@ class Advance:
if not encounter.disabled and sel.select_limit(observations, policy).selected_source is sel.Source.MAP:
self.started_ns = encounter.started_ns
return None
elif (seconds > 0 and not blocked and not encounter.disabled and upcoming is not None and
elif (not blocked and not encounter.disabled and upcoming is not None and
not (replaced and encounter is previous) and
upcoming.boundary_id == encounter.boundary_id and
upcoming.observation.candidate.speed_mps == encounter.speed_mps):
proposed[sel.Source.MAP] = upcoming.observation
if sel.select_limit(proposed, policy).selected_source is sel.Source.MAP:
self.boundary_id, self.started_ns = encounter.boundary_id, encounter.started_ns
return proposed
else:
# Losing a preview must not offer the still-current approach limit again.
proposed[sel.Source.MAP] = acc.Observation(acc.ObservationKind.UNKNOWN)
if held_wins:
# A losing rival is neither an adoption candidate nor fresh evidence for a held preview.
return dict.fromkeys(sel.Source, acc.Observation(acc.ObservationKind.UNKNOWN))
chosen = sel.select_limit(proposed, policy).observation.candidate
if not encounter.crossed and chosen is not None and chosen.speed_mps == encounter.from_mps:
encounter.withdrawn = True
@@ -110,18 +195,22 @@ class Advance:
if encounter is None or result.acceptance is None:
return
decision = result.acceptance
accepted = decision.state.accepted
if encounter.crossed or accepted != encounter.applied_preview:
encounter.applied_preview = None
if encounter.retired:
return
selected = result.selection.observation.candidate if result.selection is not None else None
selected = selected or decision.display_candidate
if selected is not None and selected.speed_mps == encounter.speed_mps and selected.source == 'map':
if decision.basis in ('driver_reject', 'timeout_reject', 'rejected'):
encounter.declined = True
accepted = decision.state.accepted
if accepted is not None and accepted.candidate.speed_mps == encounter.speed_mps and decision.state.pending is None:
if encounter.accepted_ns is None:
encounter.accepted_ns = accepted.accepted_at_ns
if (not encounter.crossed and decision.history_write == accepted and decision.basis != 'driver_adopt'):
encounter.applied_preview = accepted
elif selected is not None and not encounter.crossed and not encounter.disabled:
encounter.withdrawn = True
if encounter.accepted_ns is not None and result.state.override.persistent_selected_mps is not None:
encounter.withdrawn = True
accepted = decision.state.accepted
if not encounter.disabled and encounter.accepted_ns is not None and accepted is not None and accepted.accepted_at_ns != encounter.accepted_ns:
encounter.withdrawn = True
+95 -64
View File
@@ -1,4 +1,4 @@
"""Pure source, adoption, offset and driver-intent decisions for card's set speed."""
"""Qualified SLC decisions supply a planner cap beneath the driver's SET."""
import math
from collections.abc import Mapping
@@ -7,9 +7,9 @@ from dataclasses import dataclass, replace
from openpilot.starpilot.longitudinal.cruise_ceiling import CruiseCeiling
from openpilot.starpilot.speed_limits import acceptance as acc
from openpilot.starpilot.speed_limits import action_arbitration as arb
from openpilot.starpilot.speed_limits import overrides as ov
from openpilot.starpilot.speed_limits import selection as sel
from openpilot.starpilot.speed_limits import speed_domain as sd
from openpilot.starpilot.speed_limits.runtime_settings import PedalOverride
@dataclass(frozen=True)
@@ -18,7 +18,6 @@ class HostState:
long_control_active: bool
selfdrive_enabled: bool
force_decel: bool
force_stop_active: bool
driver_v_cruise_kph: float
selected_cluster_kph: float | None
@@ -39,22 +38,26 @@ class Frame:
adopt: acc.AdoptRequest | None = None
classified_cruise: arb.Result | None = None
is_metric: bool | None = None
set_speed_ready: bool = True
adoption_observations: Mapping[sel.Source, acc.Observation] | None = None
confirmation_started_ns: int | None = None
pedal_override: PedalOverride = PedalOverride.OFF
preview_reverted: bool = False
confirmation_grace_ns: int = 0
wheel_override_enabled: bool = False
wheel_override_blocked: bool = False
wheel_override_since_ns: int = 0
@dataclass(frozen=True)
class State:
session_id: str
acceptance: acc.State
override: ov.State
control_generation: int = 0
last_now_ns: int | None = None
previous_mode: acc.Mode | None = None
previous_owner: acc.LongitudinalOwner | None = None
was_active_path: bool = False
reset_required: bool = False
pedal_target_mps: float = 0.
pedal_blocked_until_release: bool = False
wheel_override_metric: bool | None = None
wheel_override_since_ns: int = 0
@dataclass(frozen=True)
@@ -63,7 +66,6 @@ class Result:
selection: sel.Selection | None = None
acceptance: acc.Decision | None = None
domain: sd.DomainResolution | None = None
override: ov.Decision | None = None
coordinate: sd.PlannerCoordinate | None = None
ceiling: CruiseCeiling | None = None
status: str = "none"
@@ -85,36 +87,33 @@ def _number(value: object, *, positive: bool = False) -> bool:
def _state_valid(state: object) -> bool:
if not isinstance(state, State) or not isinstance(state.acceptance, acc.State) or not isinstance(state.override, ov.State):
if not isinstance(state, State) or not isinstance(state.acceptance, acc.State):
return False
if (type(state.session_id) is not str or not state.session_id or state.acceptance.session_id != state.session_id or
state.override.session_id != state.session_id or not _integer(state.control_generation) or
(state.last_now_ns is not None and not _integer(state.last_now_ns)) or
type(state.was_active_path) is not bool or type(state.reset_required) is not bool):
return False
if ((state.previous_mode is None) != (state.previous_owner is None) or
(state.previous_mode is not None and not isinstance(state.previous_mode, acc.Mode)) or
(state.previous_owner is not None and not isinstance(state.previous_owner, acc.LongitudinalOwner))):
return False
return True
return (type(state.session_id) is str and bool(state.session_id) and state.acceptance.session_id == state.session_id and
_number(state.pedal_target_mps) and (state.last_now_ns is None or _integer(state.last_now_ns)) and
type(state.reset_required) is bool and type(state.pedal_blocked_until_release) is bool and
(state.wheel_override_metric is None or type(state.wheel_override_metric) is bool) and
_integer(state.wheel_override_since_ns))
def _host_valid(host: object) -> bool:
return (isinstance(host, HostState) and
all(type(value) is bool for value in (host.system_long_available, host.long_control_active,
host.selfdrive_enabled, host.force_decel, host.force_stop_active)) and
host.selfdrive_enabled, host.force_decel)) and
_number(host.driver_v_cruise_kph) and
(host.selected_cluster_kph is None or _number(host.selected_cluster_kph)))
def _override_evidence(kind: sd.DomainStatus) -> ov.EvidenceKind:
return {sd.DomainStatus.VALID: ov.EvidenceKind.VALID,
sd.DomainStatus.ABSENT: ov.EvidenceKind.ABSENT,
sd.DomainStatus.STALE: ov.EvidenceKind.STALE}.get(kind, ov.EvidenceKind.UNKNOWN)
def new_session(session_id: str, qualified_history: acc.AcceptedLimit | None = None) -> State:
return State(session_id, acc.new_session(session_id, qualified_history), ov.new_session(session_id))
def new_session(session_id: str, previous_limit: acc.AcceptedLimit | None = None) -> State:
if previous_limit is not None:
if not isinstance(previous_limit, acc.AcceptedLimit):
raise ValueError('invalid previous speed limit')
acc.new_session(previous_limit.session_id, previous_limit)
# Remember the value without carrying an old source episode into this session.
candidate = replace(previous_limit.candidate,
observation_identity=acc.ObservationIdentity(acc.IdentityKind.SESSION_VALUE, session_id=session_id))
previous_limit = acc.AcceptedLimit(candidate, session_id, 0)
return State(session_id, acc.new_session(session_id, previous_limit))
def step(state: State, frame: Frame) -> Result:
@@ -126,11 +125,14 @@ def step(state: State, frame: Frame) -> Result:
(state.last_now_ns is not None and frame.now_ns < state.last_now_ns) or
not _host_valid(frame.host) or not acc.authority_is_valid(frame.authority) or
not isinstance(frame.acceptance_policy, acc.Policy) or
not isinstance(frame.pedal_override, PedalOverride) or
not _integer(frame.wheel_override_since_ns) or
any(type(value) is not bool for value in (frame.acceptance_policy.confirm_lower,
frame.acceptance_policy.confirm_higher,
frame.acceptance_policy.display_only)) or
frame.acceptance_policy.display_only,
frame.wheel_override_enabled, frame.wheel_override_blocked)) or
not isinstance(frame.selected_kind, sd.DomainStatus) or not isinstance(frame.ego_pair, sd.SpeedPair) or
(frame.is_metric is not None and type(frame.is_metric) is not bool) or type(frame.set_speed_ready) is not bool):
(frame.is_metric is not None and type(frame.is_metric) is not bool)):
return Result(replace(state, reset_required=True), status="invalid_frame", errors=("invalid frame or clock",))
host = frame.host
selected_initialized = host.driver_v_cruise_kph > 0 and host.driver_v_cruise_kph != 255.0
@@ -140,13 +142,10 @@ def step(state: State, frame: Frame) -> Result:
frame.authority.accelerator_override and not (host.system_long_available and host.selfdrive_enabled and frame.pedal_pressed is True)):
return Result(replace(state, last_now_ns=frame.now_ns, reset_required=True),
status="host_authority_disagreement", errors=("host and system-long authority disagree",))
active_path = (frame.authority.can_control and (host_long or frame.authority.accelerator_override) and selected_initialized and
not host.force_decel and not host.force_stop_active and not frame.acceptance_policy.display_only)
mode_changed = state.previous_mode is not None and (state.previous_mode != frame.authority.mode or
state.previous_owner != frame.authority.owner)
generation = state.control_generation + int(active_path and (not state.was_active_path or mode_changed))
base = replace(state, control_generation=generation, last_now_ns=frame.now_ns,
previous_mode=frame.authority.mode, previous_owner=frame.authority.owner, was_active_path=active_path)
engaged_path = (frame.authority.can_control and (host_long or frame.authority.accelerator_override) and
selected_initialized and not frame.acceptance_policy.display_only)
active_path = engaged_path and not host.force_decel
base = replace(state, last_now_ns=frame.now_ns, pedal_target_mps=0., wheel_override_metric=None, wheel_override_since_ns=0)
if frame.classified_cruise is not None:
classified = frame.classified_cruise
@@ -166,7 +165,7 @@ def step(state: State, frame: Frame) -> Result:
ego = frame.ego_pair
candidate = selected.observation.candidate
matches_set = False
if (active_path and frame.set_speed_ready and candidate is not None and frame.selected_kind is sd.DomainStatus.VALID and
if (active_path and candidate is not None and frame.selected_kind is sd.DomainStatus.VALID and
ego.status is sd.DomainStatus.VALID and ego.session_id == state.session_id and ego.timestamp_ns == frame.now_ns and
_number(ego.raw_mps) and _number(ego.cluster_mps)):
assert ego.raw_mps is not None and ego.cluster_mps is not None
@@ -176,13 +175,13 @@ def step(state: State, frame: Frame) -> Result:
target, host.driver_v_cruise_kph, host.selected_cluster_kph, frame.is_metric)
acceptance = acc.step(base.acceptance, selected.observation, frame.authority, frame.acceptance_policy,
now_ns=frame.now_ns, action=frame.action, adopt=frame.adopt, set_speed_matches=matches_set,
confirmation_started_ns=frame.confirmation_started_ns)
confirmation_started_ns=frame.confirmation_started_ns,
confirmation_grace_ns=frame.confirmation_grace_ns)
base = replace(base, acceptance=acceptance.state)
if acceptance.errors:
return Result(replace(base, reset_required=True), selected, acceptance,
status="invalid_acceptance", errors=acceptance.errors)
# The raw selected set speed is derived solely from the actual driver vCruise kph.
if frame.selected_kind is sd.DomainStatus.VALID and selected_initialized and host.selected_cluster_kph is not None:
pair = sd.selected_pair_from_kph(host.driver_v_cruise_kph / 3.6, host.selected_cluster_kph,
session_id=state.session_id, timestamp_ns=frame.now_ns)
@@ -191,30 +190,62 @@ def step(state: State, frame: Frame) -> Result:
else:
pair = sd.SpeedPair(sd.DomainStatus.ABSENT)
domain = sd.resolve(acceptance, frame.offset_schedule, pair, frame.ego_pair)
selected_evidence = ov.SelectedEvidence(_override_evidence(pair.status), pair.raw_mps if pair.status is sd.DomainStatus.VALID else None)
pedal_evidence = ov.PedalEvidence(_override_evidence(frame.ego_pair.status),
frame.pedal_pressed if frame.ego_pair.status is sd.DomainStatus.VALID else None,
frame.ego_pair.raw_mps if frame.ego_pair.status is sd.DomainStatus.VALID else None)
change = frame.classified_cruise.change if frame.classified_cruise is not None else None
override = ov.step(base.override, acceptance, frame.authority, frame.acceptance_policy,
selected_evidence, pedal_evidence, now_ns=frame.now_ns, change=change, domain=domain)
base = replace(base, override=override.state)
if override.errors or domain.status is sd.DomainStatus.INVALID:
return Result(replace(base, reset_required=True), selected, acceptance, domain, override,
status="invalid_domain_or_override", errors=override.errors or (domain.reason,))
if domain.status is sd.DomainStatus.INVALID:
return Result(replace(base, reset_required=True), selected, acceptance, domain,
status="invalid_domain", errors=(domain.reason,))
clear_override = frame.preview_reverted or acceptance.basis in ('driver_accept', 'driver_adopt')
clear_wheel = clear_override or state.acceptance.accepted != acceptance.state.accepted
wheel_metric = state.wheel_override_metric if frame.wheel_override_enabled and not clear_wheel else None
previous = 0. if clear_override else state.pedal_target_mps
pedal_blocked = (state.pedal_blocked_until_release or clear_override) and (
frame.pedal_pressed is not False or ego.status is not sd.DomainStatus.VALID)
base = replace(base, pedal_blocked_until_release=pedal_blocked)
if not active_path or domain.status is not sd.DomainStatus.VALID:
return Result(base, selected, acceptance, domain, override,
status=acceptance.basis if acceptance.basis == 'source_switch_pending' else 'inactive_or_unavailable')
# Longitudinal output can resume one frame after the driver releases the pedal.
if (system_mode and host.selfdrive_enabled and not engaged_path and selected_initialized and
not (host.force_decel or frame.acceptance_policy.display_only) and
ego.status is sd.DomainStatus.VALID and pair.status is sd.DomainStatus.VALID):
base = replace(base, pedal_target_mps=previous if frame.pedal_override is not PedalOverride.OFF else 0.,
wheel_override_metric=wheel_metric)
return Result(base, selected, acceptance, domain, status='inactive_or_unavailable')
context = domain.context
if (not isinstance(context, sd.DomainContext) or override.state.session_id != state.session_id or
override.state.last_timestamp_ns != frame.now_ns or override.state.accepted_mps != context.accepted_raw_mps or
override.state.effective_target_mps != context.effective_cluster_mps):
return Result(replace(base, reset_required=True), selected, acceptance, domain, override,
status="invalid_join", errors=("override and speed domain mismatch",))
coordinate = sd.to_planner_coordinate(context, override.contribution_mps, override.basis)
assert context is not None
selected_cluster = context.selected_raw_mps + context.selected_delta_mps
if frame.wheel_override_enabled and frame.is_metric is not None:
since = 0 if clear_wheel else state.wheel_override_since_ns
pending = acceptance.state.pending
ready = (host_long and not frame.pedal_pressed and not frame.wheel_override_blocked and
(pending is None or pending.automatic))
adjusted_limit = context.effective_cluster_mps
change = frame.classified_cruise.change if frame.classified_cruise is not None else None
# A tap belongs to the eligibility interval at button-down, even if SET changes later.
if (wheel_metric is None and ready and since > 0 and frame.wheel_override_since_ns == since and change is not None and
change.disposition is arb.Disposition.DRIVER_INTENT and change.previous_mps is not None and
not sd.set_above_limit(change.previous_mps, adjusted_limit, frame.is_metric)):
wheel_metric = frame.is_metric
# A display-unit edit can round two different speeds to the same number.
if wheel_metric is not None and not sd.set_above_limit(selected_cluster, adjusted_limit, wheel_metric):
wheel_metric = None
base = replace(base, wheel_override_metric=wheel_metric,
wheel_override_since_ns=(since or frame.now_ns) if ready else 0)
if wheel_metric is not None:
coordinate = sd.PlannerCoordinate(None, 'wheel', selected_cluster, context.ego_delta_mps)
return Result(base, selected, acceptance, domain, coordinate, status=acceptance.basis)
pedal_target = 0.
if frame.pedal_override is not PedalOverride.OFF:
ego_cluster = context.ego_raw_mps + context.ego_delta_mps
gas_above_limit = not pedal_blocked and frame.authority.accelerator_override and ego_cluster > context.effective_cluster_mps
if gas_above_limit or previous > context.effective_cluster_mps:
pedal_target = (selected_cluster if frame.pedal_override is PedalOverride.SET_SPEED else
min(selected_cluster, max(previous, ego_cluster if gas_above_limit else 0.)))
if pedal_target <= context.effective_cluster_mps:
pedal_target = 0.
base = replace(base, pedal_target_mps=pedal_target)
coordinate = sd.to_planner_coordinate(context, override_mps=pedal_target)
if coordinate.cap_mps is None:
return Result(replace(base, reset_required=True), selected, acceptance, domain, override, coordinate,
return Result(replace(base, reset_required=True), selected, acceptance, domain, coordinate,
status="invalid_coordinate", errors=("planner coordinate unavailable",))
# Adoption changes card's set speed; SLC never supplies a second planner ceiling.
return Result(base, selected, acceptance, domain, override, coordinate,
return Result(base, selected, acceptance, domain, coordinate,
ceiling=CruiseCeiling(coordinate.cap_mps, frame.authority),
status=acceptance.basis)
+17 -22
View File
@@ -1,4 +1,4 @@
"""Versioned accepted-limit persistence with explicit identity applicability."""
"""Remember the last accepted limit as a fallback, never as fresh source evidence."""
import json
import math
@@ -6,9 +6,11 @@ import math
from openpilot.cereal import custom
from openpilot.starpilot.schema_cache import schema_fingerprint
from openpilot.starpilot.speed_limits import acceptance as acc
from openpilot.starpilot.speed_limits.map_observation import repair_legacy_map_speed
FORMAT = "starpilot-slc-accepted"
VERSION = 1
MAX_BYTES = 16_384
def encode(accepted: acc.AcceptedLimit, saved_wall_ns: int) -> str:
@@ -24,16 +26,8 @@ def encode(accepted: acc.AcceptedLimit, saved_wall_ns: int) -> str:
"origin_session": accepted.session_id}, sort_keys=True, separators=(",", ":"))
def decode(raw: str | bytes | dict | None, *, session_id: str, current: acc.Observation,
now_wall_ns: int, max_age_ns: int) -> acc.AcceptedLimit | None:
"""Only durable geographic identity can be carried into a new drive.
The caller sets an applicability age from its source contract. Dashboard TSR
producer episodes are drive-local and cannot be restored across a new session.
"""
if raw is None or type(session_id) is not str or not session_id or type(now_wall_ns) is not int or type(max_age_ns) is not int:
return None
if max_age_ns < 0 or now_wall_ns <= 0:
def decode(raw: str | bytes | dict | None, *, session_id: str) -> acc.AcceptedLimit | None:
if raw is None or type(session_id) is not str or not session_id:
return None
try:
value = raw if isinstance(raw, dict) else json.loads(raw)
@@ -41,18 +35,19 @@ def decode(raw: str | bytes | dict | None, *, session_id: str, current: acc.Obse
"speed_mps", "identity_kind", "identity_value", "origin_session"} or
value["format"] != FORMAT or type(value["version"]) is not int or value["version"] != VERSION or
value["source_schema"] != schema_fingerprint(custom.SlcDashboardObservation.schema) or
type(value["saved_wall_ns"]) is not int or value["saved_wall_ns"] > now_wall_ns or
now_wall_ns - value["saved_wall_ns"] > max_age_ns or
value["identity_kind"] != acc.IdentityKind.GEOGRAPHIC.value or
not isinstance(value["identity_value"], str) or not value["identity_value"] or
type(value["saved_wall_ns"]) is not int or value["saved_wall_ns"] <= 0 or
value['source'] not in ('dashboard', 'map', 'vision') or
not isinstance(value["origin_session"], str) or not value["origin_session"] or
type(value["speed_mps"]) not in (int, float) or not math.isfinite(value["speed_mps"])):
return None
candidate = acc.Candidate(value["source"], acc.ObservationIdentity(acc.IdentityKind.GEOGRAPHIC, value["identity_value"]),
float(value["speed_mps"]))
if (not acc.observation_is_valid(acc.Observation(acc.ObservationKind.VALID, candidate), session_id=session_id) or
current != acc.Observation(acc.ObservationKind.VALID, candidate)):
return None
return acc.AcceptedLimit(candidate, session_id, 0)
except (TypeError, ValueError, OverflowError):
kind = acc.IdentityKind(value['identity_kind'])
identity = acc.ObservationIdentity(kind, value['identity_value'],
value['origin_session'] if kind is acc.IdentityKind.SESSION_VALUE else None)
candidate = acc.Candidate(value['source'], identity, float(value['speed_mps']))
acc.new_session(value['origin_session'], acc.AcceptedLimit(candidate, value['origin_session'], 0))
speed = repair_legacy_map_speed(candidate.speed_mps) if candidate.source == 'map' else candidate.speed_mps
remembered = acc.Candidate(candidate.source,
acc.ObservationIdentity(acc.IdentityKind.SESSION_VALUE, session_id=session_id), speed)
return acc.new_session(session_id, acc.AcceptedLimit(remembered, session_id, 0)).accepted
except (TypeError, ValueError, OverflowError, RecursionError):
return None
@@ -2,6 +2,7 @@
import hashlib
import json
import math
import struct
from dataclasses import dataclass
from openpilot.starpilot.speed_limits import acceptance as acc
@@ -9,6 +10,26 @@ from openpilot.starpilot.speed_limits import map_source as ms
from openpilot.starpilot.speed_limits.map_source import copy_frame as copy_frame
_LEGACY_KPH_TO_MPS = .277778
def _float32(value: float) -> float:
return struct.unpack('f', struct.pack('f', value))[0]
_MAX_LEGACY_SPEED = _float32(250 * _LEGACY_KPH_TO_MPS)
def repair_legacy_map_speed(speed_mps: float) -> float:
# shortcut: match the legacy converter through 250 km/h, revisit if higher posted limits need repair.
if not 1 <= speed_mps <= _MAX_LEGACY_SPEED:
return speed_mps
posted_kph = round(speed_mps / _LEGACY_KPH_TO_MPS)
if speed_mps == _float32(posted_kph * _LEGACY_KPH_TO_MPS):
return _float32(posted_kph / 3.6)
return speed_mps
@dataclass(frozen=True)
class Upcoming:
observation: acc.Observation
@@ -39,25 +60,28 @@ class MapSource:
if evidence.observation.kind is acc.ObservationKind.STALE:
return evidence.observation
accepted = self.tracker.diagnostic(now_boot_ns=now_boot_ns).frame
if (evidence.kind != 'matched_limit_unqualified' or accepted is None or
if (evidence.kind not in ('matched_limit_unqualified', 'matched_no_limit_unqualified') or accepted is None or
accepted.way_selection != 'current' or
any(type(value) is not str or not 0 < len(value) <= 256 or not value.strip() or
any(ord(c) < 32 for c in value) for value in (drive_session, car_fingerprint))):
# Missing speed metadata, coverage/GPS loss and ambiguous/lookahead ways
# are incomplete evidence, not a new limit or regulatory absence.
return acc.Observation(acc.ObservationKind.UNKNOWN)
self.way_id = accepted.way_id
if evidence.kind == 'matched_no_limit_unqualified':
return evidence.observation
identity = hashlib.sha256(json.dumps((drive_session, car_fingerprint, accepted.producer_session,
accepted.generation, accepted.source, accepted.way_id), separators=(',', ':')).encode()).hexdigest()
self.way_id = accepted.way_id
boundary = accepted.preview_boundary_id
speed = repair_legacy_map_speed(accepted.speed_mps)
preview_speed = repair_legacy_map_speed(accepted.preview_speed_mps) if type(accepted.preview_speed_mps) is float else 0.
if (type(boundary) is str and len(boundary) == 64 and all(c in '0123456789abcdef' for c in boundary) and
type(accepted.preview_speed_mps) is float and math.isfinite(accepted.preview_speed_mps) and
accepted.preview_speed_mps >= 1.0 and abs(accepted.preview_speed_mps - accepted.speed_mps) > .02 and
math.isfinite(preview_speed) and preview_speed >= 1.0 and abs(preview_speed - speed) > .02 and
type(accepted.preview_distance_m) is float and math.isfinite(accepted.preview_distance_m) and
0 < accepted.preview_distance_m <= 500 and type(accepted.preview_way_id) is int and accepted.preview_way_id > 0):
preview_identity = hashlib.sha256(f'{identity}:{boundary}'.encode()).hexdigest()
observation = acc.Observation(acc.ObservationKind.VALID, acc.Candidate('map',
acc.ObservationIdentity(acc.IdentityKind.GEOGRAPHIC, value=preview_identity), accepted.preview_speed_mps))
acc.ObservationIdentity(acc.IdentityKind.GEOGRAPHIC, value=preview_identity), preview_speed))
self.upcoming = Upcoming(observation, accepted.preview_distance_m, boundary, accepted.preview_way_id)
return acc.Observation(acc.ObservationKind.VALID, acc.Candidate('map',
acc.ObservationIdentity(acc.IdentityKind.GEOGRAPHIC, value=identity), accepted.speed_mps))
acc.ObservationIdentity(acc.IdentityKind.GEOGRAPHIC, value=identity), speed))
@@ -1,312 +0,0 @@
"""Pure SLC override contribution from qualified driver intent and current pedal evidence."""
from typing import cast
import math
from dataclasses import dataclass, replace
from enum import Enum
from openpilot.starpilot.speed_limits import acceptance as acc
from openpilot.starpilot.speed_limits import action_arbitration as arb
from openpilot.starpilot.speed_limits import speed_domain as sd
SUSPENSION_NS = 750_000_000
class EvidenceKind(Enum):
VALID = "valid"
ABSENT = "absent"
UNKNOWN = "unknown"
STALE = "stale"
@dataclass(frozen=True)
class SelectedEvidence:
kind: EvidenceKind
speed_mps: float | None = None
@dataclass(frozen=True)
class PedalEvidence:
kind: EvidenceKind
pressed: bool | None = None
ego_mps: float | None = None
@dataclass(frozen=True)
class Context:
context_id: str
issued_at_ns: int
accepted_target_mps: float
@dataclass(frozen=True)
class State:
session_id: str
persistent_selected_mps: float | None = None
accepted_mps: float | None = None
context: Context | None = None
next_context_id: int = 1
active: bool = False
mode: acc.Mode | None = None
owner: acc.LongitudinalOwner | None = None
suspension_started_ns: int | None = None
last_effect_id: int = -1
last_adoption_sequence: int = -1
last_timestamp_ns: int | None = None
reset_required: bool = False
effective_target_mps: float | None = None
applied_selected_mps: float | None = None
@dataclass(frozen=True)
class EventReceipt:
effect_id: int
acknowledged: bool
consumed: bool
status: str
@dataclass(frozen=True)
class Decision:
state: State
contribution_mps: float | None = None
basis: str = "none"
context: Context | None = None
event_receipt: EventReceipt | None = None
errors: tuple[str, ...] = ()
def _token(value: object) -> bool:
return type(value) is str and 0 < len(value) <= 256 and bool(value.strip()) and not any(ord(c) < 32 for c in value)
def _integer(value: object, minimum: int = 0) -> bool:
return type(value) is int and value >= minimum
def _speed(value: object) -> bool:
if type(value) not in (int, float):
return False
assert isinstance(value, (int, float))
try:
return math.isfinite(value) and value > 0
except OverflowError:
return False
def _ego_speed(value: object) -> bool:
if type(value) not in (int, float):
return False
assert isinstance(value, (int, float))
try:
return math.isfinite(value) and value >= 0
except OverflowError:
return False
def _state_valid(state: object) -> bool:
if not isinstance(state, State) or not _token(state.session_id):
return False
if any(v is not None and not _speed(v) for v in (state.persistent_selected_mps, state.accepted_mps,
state.effective_target_mps, state.applied_selected_mps)):
return False
if (not _integer(state.next_context_id, 1) or type(state.active) is not bool or
not _integer(state.last_effect_id, -1) or not _integer(state.last_adoption_sequence, -1) or
type(state.reset_required) is not bool or
any(v is not None and not _integer(v) for v in (state.suspension_started_ns, state.last_timestamp_ns))):
return False
if (state.mode is not None and not isinstance(state.mode, acc.Mode)) or (state.owner is not None and not isinstance(state.owner, acc.LongitudinalOwner)):
return False
if (state.mode is None) != (state.owner is None):
return False
if state.mode is not None and state.last_timestamp_ns is None:
return False
if state.reset_required and (state.persistent_selected_mps is not None or state.active or state.context is not None or
state.suspension_started_ns is not None):
return False
if state.suspension_started_ns is not None and (state.active or state.last_timestamp_ns is None or
state.suspension_started_ns > state.last_timestamp_ns or
state.mode not in (acc.Mode.LONGITUDINAL_ONLY, acc.Mode.COMBINED) or
state.owner is not acc.LongitudinalOwner.SYSTEM):
return False
# Retained intent is raw selected speed; a negative offset can make it lower
# than the raw accepted limit while still exceeding the effective threshold.
if state.context is not None:
c = state.context
if (not isinstance(c, Context) or not _token(c.context_id) or not _integer(c.issued_at_ns) or
not _speed(c.accepted_target_mps) or state.last_timestamp_ns is None or c.issued_at_ns > state.last_timestamp_ns or
not state.active or state.accepted_mps != c.accepted_target_mps or state.next_context_id <= 1 or
c.context_id != f"override:{state.next_context_id - 1}" or
state.mode not in (acc.Mode.LONGITUDINAL_ONLY, acc.Mode.COMBINED) or state.owner is not acc.LongitudinalOwner.SYSTEM):
return False
if state.active and (state.context is None or state.suspension_started_ns is not None):
return False
if state.active and state.effective_target_mps is None:
return False
return True
def new_session(session_id: str) -> State:
state = State(session_id)
if not _state_valid(state):
raise ValueError("invalid session ID")
return state
def _selected_valid(selected: object) -> bool:
return isinstance(selected, SelectedEvidence) and isinstance(selected.kind, EvidenceKind) and (
_speed(selected.speed_mps) if selected.kind is EvidenceKind.VALID else selected.speed_mps is None)
def _pedal_valid(pedal: object) -> bool:
return isinstance(pedal, PedalEvidence) and isinstance(pedal.kind, EvidenceKind) and (
(type(pedal.pressed) is bool and ((pedal.ego_mps is None or _ego_speed(pedal.ego_mps)) if not pedal.pressed else _ego_speed(pedal.ego_mps)))
if pedal.kind is EvidenceKind.VALID else pedal.pressed is None and pedal.ego_mps is None)
def _change_valid(change: object) -> bool:
return (isinstance(change, arb.ClassifiedChange) and _token(change.session_id) and _integer(change.effect_id) and
(change.action_id is None or _integer(change.action_id)) and
(change.context_id is None or _token(change.context_id)) and
(change.started_at_ns is None or _integer(change.started_at_ns)) and
(change.previous_mps is None or _speed(change.previous_mps)) and _speed(change.selected_mps) and
isinstance(change.disposition, arb.Disposition))
def _acceptance_valid(decision: object, authority: object, policy: object, session_id: str, now_ns: int) -> bool:
if (not isinstance(decision, acc.Decision) or not isinstance(decision.state, acc.State) or decision.errors or
decision.state.session_id != session_id or decision.state.last_timestamp_ns != now_ns or
not isinstance(authority, acc.Authority) or not isinstance(policy, acc.Policy) or
decision.authority != authority or decision.policy != policy or
not acc.observation_is_valid(decision.state.observation, session_id=session_id)):
return False
if policy.display_only or not authority.can_control:
return decision.control_target_mps is None
if decision.control_target_mps is None:
return True
accepted = decision.state.accepted
if (accepted is None or not _token(accepted.session_id) or not _speed(accepted.candidate.speed_mps) or
decision.control_target_mps != accepted.candidate.speed_mps):
return False
return decision.state.observation.kind is acc.ObservationKind.VALID
def step(state: State, acceptance: acc.Decision, authority: acc.Authority, policy: acc.Policy,
selected: SelectedEvidence, pedal: PedalEvidence, *, now_ns: int,
change: arb.ClassifiedChange | None = None, domain: sd.DomainResolution | None = None) -> Decision:
"""Propose an SLC contribution, never an actuator or stock-button command."""
if not _state_valid(state):
return Decision(state, errors=("invalid override state",))
if state.reset_required:
return Decision(state, basis="session_reset_required", errors=("new session required",))
if (not _integer(now_ns) or (state.last_timestamp_ns is not None and now_ns < state.last_timestamp_ns) or
not _selected_valid(selected) or not _pedal_valid(pedal) or
(change is not None and not _change_valid(change)) or
(domain is not None and (not isinstance(domain, sd.DomainResolution) or not isinstance(domain.status, sd.DomainStatus)))):
failed = replace(state, persistent_selected_mps=None, context=None, active=False, suspension_started_ns=None,
reset_required=True)
return Decision(failed, basis="session_reset_required", errors=("invalid evidence or monotonic clock",))
old = state
state = replace(state, last_timestamp_ns=now_ns)
receipt = None
if change is not None:
if change.session_id != state.session_id:
receipt = EventReceipt(change.effect_id, True, False, "foreign_session")
elif change.effect_id <= state.last_effect_id:
receipt = EventReceipt(change.effect_id, True, False, "already_seen")
else:
state = replace(state, last_effect_id=change.effect_id)
receipt = EventReceipt(change.effect_id, True, False, "ineligible")
if not _acceptance_valid(acceptance, authority, policy, state.session_id, now_ns):
failed = replace(state, persistent_selected_mps=None, active=False, context=None, suspension_started_ns=None,
reset_required=True)
return Decision(failed, basis="session_reset_required", event_receipt=receipt,
errors=("acceptance authority, policy, target or clock mismatch",))
assert isinstance(acceptance, acc.Decision) and isinstance(acceptance.state, acc.State)
assert isinstance(authority, acc.Authority) and isinstance(policy, acc.Policy)
accepted = acceptance.state.accepted
accepted_speed = accepted.candidate.speed_mps if accepted is not None else None
domain_unavailable = False
selected_delta = 0.0
effective_target = (old.effective_target_mps if acceptance.control_target_mps is None and accepted_speed == old.accepted_mps
else acceptance.control_target_mps)
if domain is not None:
if domain.status is sd.DomainStatus.INVALID:
failed = replace(state, persistent_selected_mps=None, active=False, context=None, suspension_started_ns=None,
reset_required=True)
return Decision(failed, basis="session_reset_required", event_receipt=receipt, errors=("invalid speed domain",))
if domain.status is not sd.DomainStatus.VALID:
if domain.context is not None:
failed = replace(state, persistent_selected_mps=None, active=False, context=None, suspension_started_ns=None,
reset_required=True)
return Decision(failed, basis="session_reset_required", event_receipt=receipt, errors=("invalid speed domain",))
domain_unavailable = True
else:
c = domain.context
if (not isinstance(c, sd.DomainContext) or not sd.context_is_valid(c) or
c.session_id != state.session_id or c.timestamp_ns != now_ns or
c.accepted_raw_mps != acceptance.control_target_mps or selected.speed_mps != c.selected_raw_mps or
(pedal.pressed and pedal.ego_mps != c.ego_raw_mps) or
not _speed(c.effective_cluster_mps)):
failed = replace(state, persistent_selected_mps=None, active=False, context=None, suspension_started_ns=None,
reset_required=True)
return Decision(failed, basis="session_reset_required", event_receipt=receipt, errors=("speed domain mismatch",))
selected_delta = c.selected_delta_mps
effective_target = c.effective_cluster_mps
mode_changed = old.mode is not None and (old.mode != authority.mode or old.owner != authority.owner)
if acceptance.history_write is not None:
state = replace(state, persistent_selected_mps=None, applied_selected_mps=None)
comparison_target = state.applied_selected_mps + selected_delta if state.applied_selected_mps is not None else effective_target
if (not domain_unavailable and state.persistent_selected_mps is not None and effective_target is not None and
selected.speed_mps is not None and math.isclose(cast(float, comparison_target), selected.speed_mps + selected_delta, abs_tol=0.001, rel_tol=0)):
state = replace(state, persistent_selected_mps=None)
state = replace(state, accepted_mps=accepted_speed, effective_target_mps=effective_target,
mode=authority.mode, owner=authority.owner)
adoption = acceptance.adoption
adopted_now = False
if adoption is not None and adoption.session_id == state.session_id and adoption.clear_override and acceptance.adoption_receipt is not None:
if (acceptance.adoption_receipt.consumed and acceptance.adoption_receipt.sequence_id == adoption.action_sequence_id and
adoption.action_sequence_id > state.last_adoption_sequence):
state = replace(state, persistent_selected_mps=None, last_adoption_sequence=adoption.action_sequence_id)
adopted_now = True
eligible = (authority.can_control and not policy.display_only and accepted_speed is not None and effective_target is not None and
selected.kind is EvidenceKind.VALID and pedal.kind is EvidenceKind.VALID and
(not domain_unavailable or acceptance.control_target_mps is None))
if not eligible:
state = replace(state, active=False, context=None, suspension_started_ns=None)
return Decision(state, basis="display_only" if policy.display_only else "inactive_or_missing_evidence", event_receipt=receipt)
target = accepted_speed
assert target is not None and _speed(target) and selected.speed_mps is not None and pedal.pressed is not None
state = replace(state, suspension_started_ns=None)
rotate = (not old.active or old.context is None or old.context.accepted_target_mps != target or
old.effective_target_mps != effective_target or
mode_changed or adopted_now)
if rotate:
context = Context(f"override:{state.next_context_id}", now_ns, target)
state = replace(state, context=context, next_context_id=state.next_context_id + 1, active=True)
else:
state = replace(state, active=True)
if receipt is not None and receipt.status == "ineligible" and change is not None:
if policy.display_only:
receipt = replace(receipt, status="display_only")
elif (change.disposition is arb.Disposition.DRIVER_INTENT and change.action_id is not None and
change.previous_mps is not None and change.previous_mps != change.selected_mps and
old.active and old.context is not None and not rotate and change.context_id == old.context.context_id and
change.started_at_ns is not None and old.context.issued_at_ns <= change.started_at_ns <= now_ns and
change.selected_mps == selected.speed_mps):
state = replace(state, persistent_selected_mps=(change.selected_mps if comparison_target is not None and
not math.isclose(change.selected_mps + selected_delta, comparison_target, abs_tol=0.001, rel_tol=0) else None))
receipt = replace(receipt, consumed=True, status="driver_intent")
else:
receipt = replace(receipt, status="unqualified_effect")
if state.persistent_selected_mps is not None:
return Decision(state, state.persistent_selected_mps, "persistent", state.context, receipt)
return Decision(state, basis="adoption_clear" if adopted_now else "none", context=state.context, event_receipt=receipt)
@@ -1,15 +1,15 @@
"""Card-side qualification for SLC buttons and selected-speed commands."""
"""Card-side qualification for SLC choices, software SET engagement and active caps."""
import math
from openpilot.cereal.services import SERVICE_LIST
from openpilot.selfdrive.car.cruise import SlcPendingConfirmation, V_CRUISE_MIN, V_CRUISE_MAX
from openpilot.selfdrive.car.cruise import SlcPendingConfirmation, SlcWheelContext, V_CRUISE_MIN, V_CRUISE_MAX
from openpilot.starpilot.speed_limits.acceptance import CONFIRMATION_NS
from openpilot.starpilot.speed_limits.selection import Source
from openpilot.starpilot.speed_limits.speed_domain import set_above_limit
STATE_MAX_AGE_NS = int(2e9 / SERVICE_LIST['slcState'].frequency)
COMMAND_MAX_AGE_NS = int(2e9 / SERVICE_LIST['slcState'].frequency)
def finite_speed(value: object) -> bool:
@@ -30,13 +30,17 @@ def adoption_speed(state):
def source_matches(state, observation, producer_session: str, now_ns: int) -> bool:
observed_ns = int(state.sourceObservedMonoTime)
expiry_ns = int(state.sourceValidUntilMonoTime)
# Card can have a newer packet for the same episode before the planner publishes again.
return (str(state.source) == 'dashboard' and str(state.observationKind) == 'valid' and
str(state.sourceProducerSessionId) == producer_session and producer_session != '' and
getattr(observation.status, 'value', observation.status) == 'valid' and int(state.sourceEpisode) > 0 and
int(state.sourceEpisode) == int(observation.episode) and
int(state.sourceObservedMonoTime) == int(observation.observed_ns) and
int(state.sourceValidUntilMonoTime) == int(observation.valid_until_ns) and
0 < int(observation.observed_ns) <= now_ns <= int(observation.valid_until_ns) and
0 < observed_ns <= state.frameMonoTime and observed_ns <= observation.observed_ns <= now_ns and
observed_ns < expiry_ns and observation.observed_ns < observation.valid_until_ns and
now_ns <= min(expiry_ns, observation.valid_until_ns) and
(observed_ns != observation.observed_ns or expiry_ns == observation.valid_until_ns) and
finite_speed(state.speedLimit) and finite_speed(observation.speed_mps) and
abs(float(state.speedLimit) - float(observation.speed_mps)) <= 0.02)
@@ -60,14 +64,23 @@ def _current_state(sm, now_ns: int):
str(state.source) in tuple(Source) and finite_speed(state.speedLimit) and adoption_speed(state) is not None) else None
def state_current(sm, observation, producer_session: str, now_ns: int):
state = _current_state(sm, now_ns)
if state is None:
def active_ceiling(sm, now_ns: int, *, after_ns: int = 0) -> float | None:
state = _state(sm, now_ns)
if state is None or int(state.frameMonoTime) <= after_ns or not state.hasCeiling:
return None
if (not getattr(state, 'upcomingBoundaryId', '') and str(state.source) == 'dashboard' and
(observation is None or not source_matches(state, observation, producer_session, now_ns))):
cap = float(state.effectiveCap)
return cap if finite_speed(cap) and cap <= V_CRUISE_MAX / 3.6 + 1e-5 else None
def wheel_override_context(sm, now_ns: int, *, selected_kph: float, is_metric: bool,
after_ns: int = 0) -> SlcWheelContext | None:
state = _state(sm, now_ns)
if (state is None or int(state.frameMonoTime) <= after_ns or not state.hasAcceptedAdjustedLimit or
not finite_speed(state.acceptedAdjustedLimit) or not V_CRUISE_MIN <= selected_kph <= V_CRUISE_MAX or
set_above_limit(selected_kph / 3.6, float(state.acceptedAdjustedLimit), is_metric)):
return None
return state
since = int(state.wheelOverrideSince)
return (str(state.sessionId), since) if 0 < since <= int(state.frameMonoTime) else None
def engagement_state(sm, observation, producer_session: str, now_ns: int, *, after_ns: int = 0):
@@ -76,7 +89,7 @@ def engagement_state(sm, observation, producer_session: str, now_ns: int, *, aft
str(state.source) == 'online' or not state.hasPresentationAdjustedLimit):
return None
if str(state.source) == 'dashboard' and (
observation is None or observation.observed_ns <= after_ns or not source_matches(state, observation, producer_session, now_ns)):
observation is None or int(state.sourceObservedMonoTime) <= after_ns or not source_matches(state, observation, producer_session, now_ns)):
return None
if str(state.source) == 'vision' and not (
state.sourceProducerSessionId and state.sourceEpisode > 0 and
@@ -86,58 +99,40 @@ def engagement_state(sm, observation, producer_session: str, now_ns: int, *, aft
return state if finite_speed(target) and V_CRUISE_MIN / 3.6 - 1e-5 <= target <= V_CRUISE_MAX / 3.6 + 1e-5 else None
def engagement_target(sm, observation, producer_session: str, now_ns: int, *, after_ns: int = 0):
current = engagement_state(sm, observation, producer_session, now_ns, after_ns=after_ns)
state = current if current is not None else _state(sm, now_ns)
if state is None or int(state.frameMonoTime) <= after_ns:
return None, None
# A live reading outranks restored history, whose adoption time is zero.
if state.hasAccepted and not state.acceptedIsPreview and state.hasAcceptedAdjustedLimit and (
current is None or state.acceptedMonoTime > 0 and state.acceptedSpeedLimit != current.speedLimit):
# Use the accepted target without confirming a different pending proposal.
return float(state.acceptedAdjustedLimit), None
return (float(current.presentationAdjustedLimit), current) if current is not None else (None, None)
def adopted_target(sm, now_ns: int, *, wheel_ns: int) -> float | None:
"""Read the one-frame SET approval for this Card's still-pending wheel event."""
state = _state(sm, now_ns)
if (state is None or int(state.wheelAdoptMonoTime) != wheel_ns or
not 0 < wheel_ns <= int(state.frameMonoTime)):
return None
target = float(state.wheelAdoptTargetMps)
return target if finite_speed(target) and V_CRUISE_MIN / 3.6 - 1e-5 <= target <= V_CRUISE_MAX / 3.6 + 1e-5 else None
def pending_confirmation(sm, now_ns: int, *, long_active: bool,
after_ns: int = 0) -> SlcPendingConfirmation | None:
if not long_active:
return None
# The planner owns pending choices for every source; Dashboard commands also bind local OEM evidence.
state = _state(sm, now_ns)
if (state is None or int(state.frameMonoTime) <= after_ns or not state.hasPending or state.automaticAdoptionPending or
int(state.decisionId) <= 0 or int(state.presentationId) < 0):
int(state.decisionId) <= 0 or int(state.presentationId) <= 0):
return None
if not finite_speed(state.pendingSpeedLimit):
return None
if int(state.presentationId) == 0:
started = int(state.pendingSinceMonoTime)
# Consume the displayed choice even if its deadline passes in transit.
if str(state.pendingSource) not in tuple(Source) or not 0 < started <= int(state.frameMonoTime) <= started + CONFIRMATION_NS:
return None
elif (_current_state(sm, now_ns) is None or
abs(float(state.pendingSpeedLimit) - adoption_speed(state)) > 0.02):
started = int(state.pendingSinceMonoTime)
# Use the published prompt and its frame time so a late press cannot change SET.
if (str(state.pendingSource) not in tuple(Source) or not finite_speed(state.pendingSpeedLimit) or
not 0 < started <= int(state.frameMonoTime) <= started + CONFIRMATION_NS):
return None
return SlcPendingConfirmation(str(state.sessionId), int(state.decisionId), int(state.presentationId))
def command_applicable(command, state, observation, producer_session: str, now_ns: int,
selected_mps: float, *, long_active: bool, button_event: bool) -> bool:
if state is None or button_event or not long_active or not state.hasAccepted:
return False
boundary = str(getattr(state, 'upcomingBoundaryId', ''))
speed = adoption_speed(state)
if speed is None or str(getattr(command, 'upcomingBoundaryId', '')) != boundary:
return False
source = 'map' if boundary else str(state.source)
producer, episode, observed, valid_until = ('', 0, 0, 0) if boundary else (
str(state.sourceProducerSessionId), int(state.sourceEpisode), int(state.sourceObservedMonoTime), int(state.sourceValidUntilMonoTime))
if (str(command.kind) != 'adoptAcceptedLimit' or not str(command.sessionId) or
str(command.sessionId) != str(state.sessionId) or int(command.commandId) <= 0 or
int(command.actionId) < 0 or int(command.presentationId) <= 0 or
int(command.commandId) != int(state.commandId) or
int(command.presentationId) != int(state.presentationId) or
int(command.sourceEpisode) != episode or
str(command.source) != source or
str(command.sourceProducerSessionId) != producer or
int(command.sourceObservedMonoTime) != observed or
int(command.sourceValidUntilMonoTime) != valid_until or
(source == 'dashboard' and (observation is None or not source_matches(state, observation, producer_session, now_ns)))):
return False
issued, expires = int(command.issuedMonoTime), int(command.expiresMonoTime)
if not (0 < issued <= now_ns <= expires and expires - issued <= COMMAND_MAX_AGE_NS):
return False
expected, target = float(command.expectedSelectedMps), float(command.targetMps)
# Float32 transport can round a legal boundary just outside its double-precision value.
return (finite_speed(expected) and finite_speed(target) and finite_speed(selected_mps) and
abs(expected - selected_mps) <= 0.1 and
abs(float(state.acceptedSpeedLimit) - speed) <= 0.02 and
abs(target - (float(state.acceptedSpeedLimit) + float(state.offset))) <= 0.1 and
V_CRUISE_MIN / 3.6 - 1e-5 <= target <= V_CRUISE_MAX / 3.6 + 1e-5)
@@ -0,0 +1,145 @@
"""Saved SLC lifecycle and model-frame integration shared by both planners."""
import json
import secrets
import time
from dataclasses import replace
from openpilot.cereal import messaging
from openpilot.cereal.services import SERVICE_LIST
from openpilot.starpilot.feature_runtime import enabled as feature_enabled, slc_runtime_settings, vision_control_enabled
from openpilot.starpilot.saved_source import read_saved
from openpilot.starpilot.speed_limits import history
from openpilot.starpilot.speed_limits.runtime import Action, CruiseEvent, Runtime as SlcRuntime
from openpilot.starpilot.speed_limits.runtime_settings import read_params as read_slc_settings
ACTION_MAX_AGE_NS = int(2e9 / SERVICE_LIST['modelV2'].frequency)
SLC_ACTIONS = ('accept', 'reject', 'adopt')
SLC_CRUISE_EVENTS = ('driverChange', 'confirmationAccept', 'confirmationReject', 'engagementAdopt')
class SavedSlcLifecycle:
"""Refresh saved admission; offsets and display units preserve driver choices."""
def __init__(self, params, cp, environment):
self.params, self.cp, self.environment = params, cp, dict(environment)
self.runtime = None
self.configuration = None
self.last_refresh_ns = None
def source_services(self):
services = ['slcDashboardObservation', 'slcVisionObservation', 'mapdOut']
if feature_enabled(self.params, self.cp, 'aol', self.environment):
services.append('aolAxisState')
return services
def refresh(self, now_ns: int, *, pending_actions=None, pending_cruise=None):
if self.last_refresh_ns is not None and 0 <= now_ns - self.last_refresh_ns < 1_000_000_000:
return self.runtime, False
self.last_refresh_ns = now_ns
# One immutable parsed snapshot owns this refresh. Repeated qualification
# used to reopen the same saved keys five times in a due model iteration.
saved_slc = read_slc_settings(self.params)
admitted = feature_enabled(self.params, self.cp, 'slc', self.environment, saved_slc=saved_slc)
settings = slc_runtime_settings(self.params, self.cp, self.environment, saved_slc=saved_slc)
vision = admitted and feature_enabled(self.params, self.cp, 'vision', self.environment, saved_slc=saved_slc)
configuration = (admitted and not settings.errors, settings, vision,
vision_control_enabled(self.params, self.cp, saved_slc=saved_slc),
feature_enabled(self.params, self.cp, 'vision', {}, saved_slc=saved_slc),
self.params.get_bool('IsMetric'))
if configuration == self.configuration:
return self.runtime, False
previous_runtime = self.runtime
continuing_control = bool(self.runtime is not None and self.runtime.settings.enabled and settings.enabled and configuration[0])
timing_only = False
preserve_runtime = continuing_control
if self.runtime is not None and self.configuration is not None:
old_admitted, old_settings, *old_display = self.configuration
retimed = replace(old_settings, advance_higher_seconds=settings.advance_higher_seconds,
advance_lower_seconds=settings.advance_lower_seconds)
timing_only = configuration == (old_admitted, retimed, *old_display)
preserve_runtime |= configuration[:-1] == (old_admitted, replace(retimed, offsets=settings.offsets), *old_display[:-1])
if preserve_runtime:
assert self.runtime is not None and self.configuration is not None
# Policy/source edits renew choices without releasing the engaged speed cap.
choices_changed = (settings.selection != self.runtime.settings.selection or
settings.acceptance != self.runtime.settings.acceptance or configuration[2:5] != self.configuration[2:5])
if choices_changed or settings.offsets != self.runtime.settings.offsets or configuration[5] != self.configuration[5]:
self.runtime.update_offsets(settings.offsets, is_metric=configuration[5])
if (settings.advance_higher_seconds != self.runtime.settings.advance_higher_seconds or
settings.advance_lower_seconds != self.runtime.settings.advance_lower_seconds):
self.runtime.update_advance(settings.advance_higher_seconds, settings.advance_lower_seconds)
if settings.wheel_override != self.runtime.settings.wheel_override:
self.runtime.state = replace(self.runtime.state, wheel_override_metric=None, wheel_override_since_ns=0)
self.runtime.settings = settings
self.runtime.vision_enabled = vision
self.runtime.vision_control_qualified = vision and configuration[3]
self.runtime.vision_display_qualified = bool(vision and configuration[4] and settings.display and not settings.enabled)
else:
if self.runtime is not None:
self.runtime.reset()
session_id = secrets.token_hex(16)
saved_history, _ = read_saved(self.params, 'SLCQualifiedHistory', history.MAX_BYTES)
self.runtime = (SlcRuntime(settings, vision_enabled=vision,
vision_control_qualified=configuration[3],
vision_display_qualified=configuration[4], is_metric=configuration[5],
session_id=session_id, previous_limit=history.decode(saved_history, session_id=session_id))
if configuration[0] else None)
self.configuration = configuration
if pending_actions is not None and not timing_only:
pending_actions.clear()
if pending_cruise is not None and (self.runtime is None or self.runtime is not previous_runtime):
pending_cruise.clear()
return self.runtime, True
def step(self, sm, now_ns, pending_actions, pending_cruise, *, drive_changed, wheel_override_blocked=False):
runtime = self.runtime
if runtime is None:
return None
if drive_changed or runtime.state.reset_required or runtime.ledger.reset_required:
runtime.reset()
pending_actions.clear()
pending_cruise.clear()
while pending_actions and now_ns - pending_actions[0][0] > ACTION_MAX_AGE_NS:
pending_actions.popleft()
cruise_event = current_cruise_event(pending_cruise, now_ns, car_state_ns=sm.logMonoTime['carState'])
request = (pending_actions.popleft()[1] if cruise_event is None and pending_actions and
pending_actions[0][0] <= now_ns else None)
previous_accepted = runtime.history_limit
output = runtime.step(sm, self.cp, now_ns=now_ns, request=request, cruise_event=cruise_event,
confirmation_grace_ns=ACTION_MAX_AGE_NS, wheel_override_blocked=wheel_override_blocked)
accepted = runtime.history_limit
if accepted != previous_accepted:
self.params.put("SLCQualifiedHistory", json.loads(history.encode(accepted, time.time_ns())) if accepted is not None else {})
return output
def receive_slc_events(sock):
for _ in range(8):
event = messaging.recv_one_or_none(sock)
if event is None:
break
yield event
def cruise_event_from_message(event):
cruise = event.slcCruiseEvent
return CruiseEvent(int(cruise.eventId), int(cruise.observedMonoTime), float(cruise.previousMps),
float(cruise.selectedMps), str(cruise.button), str(cruise.producerSessionId),
int(event.logMonoTime), str(cruise.kind), str(cruise.sessionId), int(cruise.decisionId),
int(cruise.presentationId), int(cruise.commandId), int(cruise.wheelOverrideSince), bool(cruise.longPress))
def action_from_message(event):
request = event.slcAction
observed_ns = int(event.logMonoTime)
return observed_ns, Action(str(request.sessionId), int(request.sequenceId), int(request.decisionId),
int(request.presentationId), str(request.kind), observed_ns, int(request.wheelAdoptMonoTime))
def current_cruise_event(events, now_ns: int, *, car_state_ns: int | None = None):
while events and now_ns - events[0].observed_ns > ACTION_MAX_AGE_NS:
events.popleft()
if events and events[0].observed_ns <= now_ns and (car_state_ns is None or events[0].car_state_log_ns <= car_state_ns):
return events.popleft()
return None
+125 -204
View File
@@ -8,12 +8,13 @@ import openpilot.cereal.messaging as messaging
from openpilot.cereal.services import SERVICE_LIST
from openpilot.selfdrive.car.cruise import V_CRUISE_MIN, V_CRUISE_MAX
from openpilot.starpilot.aol.runtime import current_axis
from openpilot.starpilot.controllers.wheel_actions import LIFETIME_NS as WHEEL_ACTION_MAX_AGE_NS
from openpilot.starpilot.speed_limits import acceptance as acc
from openpilot.starpilot.speed_limits import action_arbitration as arb
from openpilot.starpilot.speed_limits import composition as comp
from openpilot.starpilot.speed_limits import selection as sel
from openpilot.starpilot.speed_limits import speed_domain as sd
from openpilot.starpilot.speed_limits.runtime_settings import Settings
from openpilot.starpilot.speed_limits.runtime_settings import ADVANCE_SECONDS, Settings
from openpilot.starpilot.speed_limits.map_observation import MapSource, copy_frame
from openpilot.starpilot.speed_limits.advance import Advance
from openpilot.starpilot.speed_limits.vision.observation import clock_pair_ns, vision_observation
@@ -84,6 +85,8 @@ class Action:
decision_id: int
presentation_id: int
kind: str
observed_ns: int | None = None
wheel_observed_ns: int = 0
@dataclass(frozen=True)
@@ -100,6 +103,8 @@ class CruiseEvent:
decision_id: int = 0
presentation_id: int = 0
command_id: int = 0
wheel_override_since_ns: int = 0
long_press: bool = False
@dataclass(frozen=True)
@@ -107,15 +112,14 @@ class Output:
result: comp.Result
message: object
action_result: arb.Result | None = None
command: object | None = None
settings: Settings | None = None
class Runtime:
"""Own a unique drive session, action ledger and current-frame UI receipt."""
def __init__(self, settings: Settings, *, session_id: str | None = None, vision_enabled: bool = False,
vision_control_qualified: bool = False, vision_display_qualified: bool = False, is_metric: bool = False):
vision_control_qualified: bool = False, vision_display_qualified: bool = False, is_metric: bool = False,
previous_limit: acc.AcceptedLimit | None = None):
if type(is_metric) is not bool:
raise ValueError('SLC display units must be Boolean')
self.settings = settings
@@ -133,30 +137,18 @@ class Runtime:
self._last_vision_eof_boot = 0
self._last_vision_episode = 0
self.session_id = session_id or uuid.uuid4().hex
self.state = comp.new_session(self.session_id)
self.state = comp.new_session(self.session_id, previous_limit)
self.history_limit = self.state.acceptance.accepted
self.ledger = arb.new_ledger(self.session_id)
self.last_action_result: arb.Result | None = None
self.last_ui_sequence = -1
self.last_cruise_event = -1
self.last_cruise_producer: str | None = None
self.last_command_id = 0
self.command_status = ''
self._command_ui_sequence = 0
self._ui_receipt = None
self.command_issued_ns = 0
self.command_expiry_ns = 0
self.set_speed_after_ns = 0
self.command_expected_mps = 0.0
self.command_target_mps = 0.0
self.command_card_producer = ''
self.commanded_acceptance: acc.AcceptedLimit | None = None
self.last_received: acc.Candidate | None = None
self.current_presentation: acc.Presentation | None = None
self.toyota_eligible = False
self.toyota_projection = None
self.commanded_generation = -1
def reset(self) -> None:
previous_limit = self.history_limit
self.map_source.reset()
self.advance = Advance()
self.vision_diagnostic = acc.Observation(acc.ObservationKind.UNKNOWN)
@@ -165,53 +157,39 @@ class Runtime:
self._last_vision_eof_boot = 0
self._last_vision_episode = 0
self.session_id = uuid.uuid4().hex
self.state = comp.new_session(self.session_id)
try:
self.state = comp.new_session(self.session_id, previous_limit)
except ValueError:
self.state = comp.new_session(self.session_id)
self.history_limit = self.state.acceptance.accepted
self.ledger = arb.new_ledger(self.session_id)
self.last_action_result = None
self.last_ui_sequence = -1
self.last_cruise_event = -1
self.last_cruise_producer = None
# Keep the SET deadline: the previous session's command may still be in flight.
self.last_command_id = 0
self.command_status = ''
self._command_ui_sequence = 0
self._ui_receipt = None
self.command_issued_ns = 0
self.command_expiry_ns = 0
self.command_expected_mps = 0.0
self.command_target_mps = 0.0
self.command_card_producer = ''
self.commanded_acceptance = None
self.last_received = None
self.current_presentation = None
self.toyota_projection = None
self.commanded_generation = -1
def update_offsets(self, offsets: sd.OffsetSchedule, *, is_metric: bool | None = None) -> None:
if is_metric is not None:
if type(is_metric) is not bool:
raise ValueError('SLC display units must be Boolean')
self.is_metric = is_metric
changed_offsets = offsets != self.settings.offsets
self.settings = replace(self.settings, offsets=offsets)
# Renew actions for new offsets/units without forgetting driver choices.
acceptance = replace(self.state.acceptance, observation=acc.Observation(acc.ObservationKind.UNKNOWN),
pending=None, presentation=None, timer_running=False)
self.current_presentation = None
self.state = replace(self.state, acceptance=acceptance)
self.state = replace(self.state, acceptance=acceptance, wheel_override_since_ns=0)
self._ui_receipt = None
# Display units alone must not reapply an accepted SET in different rounding.
if changed_offsets:
self.command_status = ''
self.commanded_acceptance = None
def update_advance(self, seconds: int) -> None:
if type(seconds) is not int or not 0 <= seconds <= 10:
def update_advance(self, higher_seconds: int, lower_seconds: int) -> None:
if any(type(seconds) is not int or seconds not in ADVANCE_SECONDS for seconds in (higher_seconds, lower_seconds)):
raise ValueError('invalid SLC advance time')
self.settings = replace(self.settings, advance_seconds=seconds)
if seconds == 0:
encounter, pending = self.advance.encounter, self.state.acceptance.pending
if (encounter is not None and not encounter.crossed and pending is not None and
self.settings = replace(self.settings, advance_higher_seconds=higher_seconds, advance_lower_seconds=lower_seconds)
encounter, pending = self.advance.encounter, self.state.acceptance.pending
if encounter is not None and (higher_seconds if encounter.speed_mps > encounter.from_mps else lower_seconds) == 0:
if (not encounter.crossed and pending is not None and
pending.candidate.source == 'map' and pending.candidate.speed_mps == encounter.speed_mps):
self.state = replace(self.state, acceptance=replace(self.state.acceptance,
observation=acc.Observation(acc.ObservationKind.UNKNOWN), pending=None, presentation=None, timer_running=False))
@@ -237,11 +215,7 @@ class Runtime:
return None
self._mark_event(event)
action_id = self.ledger.last_action_id + 1
context = self.state.override.context
if context is not None and context.issued_at_ns > event.observed_ns:
return None
context_id = context.context_id if context is not None else 'no-slc-context'
begun = arb.step(self.ledger, arb.BeginAction(self.session_id, action_id, arb.Origin.DRIVER_CRUISE, context_id), now_ns=now_ns)
begun = arb.step(self.ledger, arb.BeginAction(self.session_id, action_id, arb.Origin.DRIVER_CRUISE, 'driver-set'), now_ns=now_ns)
self.ledger = begun.state
if begun.errors or begun.status != 'begun':
return None
@@ -267,13 +241,11 @@ class Runtime:
engagement = event.kind == 'engagementAdopt'
pending = self.state.acceptance.pending
shown = self.state.acceptance.presentation
retained_reject = (event.kind == 'confirmationReject' and event.presentation_id == 0 and
pending is not None and event.decision_id == pending.decision_id)
if shown is None and engagement:
shown = self.current_presentation
if (event.session_id != self.session_id or (shown is None and not retained_reject) or
if (event.session_id != self.session_id or shown is None or
event.producer_session != str(sm['slcDashboardObservation'].producerSessionId) or
(not retained_reject and event.presentation_id != shown.presentation_id) or
event.presentation_id != shown.presentation_id or
event.button != ('accel' if event.kind == 'confirmationAccept' else 'decel')):
return None, None, None
if engagement:
@@ -281,7 +253,7 @@ class Runtime:
not _number(float(sm['carState'].vCruise), positive=True) or
abs(float(sm['carState'].vCruise) / 3.6 - event.selected_mps) > 0.1):
return None, None, None
elif pending is None or event.decision_id != pending.decision_id or (not retained_reject and pending.candidate != shown.candidate):
elif pending is None or event.decision_id != pending.decision_id or pending.candidate != shown.candidate:
return None, None, None
action_id = self.ledger.last_action_id + 1
context = f'presentation:{shown.presentation_id}' if engagement else f'pending:{pending.decision_id}'
@@ -293,33 +265,12 @@ class Runtime:
return None, acc.AdoptRequest(self.session_id, action_id, shown.presentation_id, shown.candidate), action_id
assert pending is not None
kind = acc.ActionKind.ACCEPT if event.kind == 'confirmationAccept' else acc.ActionKind.REJECT
return acc.DriverAction(self.session_id, action_id, pending.decision_id, kind), None, action_id
return acc.DriverAction(self.session_id, action_id, pending.decision_id, kind, event.observed_ns), None, action_id
def _command_receipt(self, event: CruiseEvent, sm, now_ns: int) -> None:
if (event.kind not in ('commandApplied', 'commandRejected') or
not self._event_current(event, sm, now_ns)):
return
self._mark_event(event)
if (event.session_id == self.session_id and event.command_id == self.last_command_id and
event.producer_session == self.command_card_producer and self.command_status in ('issued', 'expired') and
self.command_issued_ns <= event.observed_ns <= self.command_expiry_ns):
if event.kind == 'commandApplied':
if (not _number(event.previous_mps, positive=True) or
abs(event.previous_mps - self.command_expected_mps) > 0.1 or
not _number(event.selected_mps, positive=True) or
abs(event.selected_mps - self.command_target_mps) > 0.5):
return
self.command_status = 'applied'
self.set_speed_after_ns = event.observed_ns
if self.commanded_acceptance == self.state.acceptance.accepted:
self.state = replace(self.state, override=replace(self.state.override, applied_selected_mps=event.selected_mps))
else:
self.command_status = 'rejected'
self.set_speed_after_ns = event.observed_ns
def _set_speed_ready(self, sm) -> bool:
# A prior command may apply after the SET sample, even if its receipt has arrived.
return int(sm.logMonoTime.get('carState', 0)) > self.set_speed_after_ns
@property
def _maps_selected(self) -> bool:
policy = self.settings.selection
return sel.Source.MAP in policy.slots or policy.mode is not sel.SelectionMode.ORDERED
def _observations(self, sm, CP, now_ns: int, now_boot_ns: int | None = None) -> dict[sel.Source, acc.Observation]:
unknown = acc.Observation(acc.ObservationKind.UNKNOWN)
@@ -362,8 +313,7 @@ class Runtime:
self.vision_diagnostic = acc.Observation(acc.ObservationKind.STALE)
else:
self.vision_diagnostic = acc.Observation(acc.ObservationKind.UNKNOWN)
if (sel.Source.MAP in self.settings.selection.slots or
self.settings.selection.mode is not sel.SelectionMode.ORDERED):
if self._maps_selected:
pair = clock_pair_ns() if now_boot_ns is None else (now_ns, now_boot_ns)
if pair is not None:
received = bool(sm.updated.get('mapdOut', False))
@@ -382,7 +332,7 @@ class Runtime:
return values
def _frame(self, sm, CP, now_ns: int, now_boot_ns: int | None, action: acc.DriverAction | None, adopt: acc.AdoptRequest | None,
classified: arb.Result | None) -> comp.Frame:
classified: arb.Result | None, *, wheel_override_blocked: bool) -> comp.Frame:
car_fresh = _fresh(sm, "carState", now_ns) and not sm["carState"].canTimeout
controls_fresh = _fresh(sm, "controlsState", now_ns)
selfdrive_fresh = _fresh(sm, "selfdriveState", now_ns)
@@ -398,6 +348,7 @@ class Runtime:
long_enabled = long_enabled and bool(axis is not None and axis.nativeAcknowledged and axis.desiredLongitudinal)
lateral = bool(car_control_fresh and sm['carControl'].latActive and selfdrive_fresh)
authority = _authority(CP, sm, host_long, lateral, long_enabled=long_enabled)
# Native pedal handoff keeps the engagement while the driver controls acceleration.
authority = replace(authority, accelerator_override=bool(CP.openpilotLongitudinalControl and long_enabled and car_state.gasPressed))
selected = float(car_state.vCruise) if car_fresh and _number(car_state.vCruise) else 0.0
selected_cluster = float(car_state.vCruiseCluster) if car_fresh and _number(car_state.vCruiseCluster) else None
@@ -405,31 +356,67 @@ class Runtime:
ego = (sd.SpeedPair(sd.DomainStatus.VALID, float(car_state.vEgo), float(car_state.vEgoCluster), self.session_id, now_ns)
if ego_valid else sd.SpeedPair(sd.DomainStatus.STALE if sm.logMonoTime.get("carState", 0) else sd.DomainStatus.UNKNOWN))
host = comp.HostState(bool(CP.openpilotLongitudinalControl), host_long, bool(selfdrive.enabled and selfdrive_fresh),
bool(controls.forceDecel) if controls_fresh else True, False, selected, selected_cluster)
bool(controls.forceDecel) if controls_fresh else True, selected, selected_cluster)
observations = self._observations(sm, CP, now_ns, now_boot_ns)
wheel_enabled = (self.settings.wheel_override and not CP.pcmCruise and car_fresh and car_state.canValid and
V_CRUISE_MIN <= selected <= V_CRUISE_MAX and selected_cluster == selected and
not car_state.brakePressed)
wheel_blocked = wheel_override_blocked or car_state.standstill or car_state.cruiseState.standstill
frame = comp.Frame(now_ns, observations, self.settings.selection, authority,
self.settings.acceptance, self.settings.offsets,
sd.DomainStatus.VALID if car_fresh and selected_cluster is not None else sd.DomainStatus.STALE,
ego, bool(car_state.gasPressed) if car_fresh else False, host, action=action, adopt=adopt,
classified_cruise=classified, is_metric=self.is_metric, set_speed_ready=self._set_speed_ready(sm))
classified_cruise=classified, is_metric=self.is_metric, pedal_override=self.settings.pedal_override,
wheel_override_enabled=wheel_enabled, wheel_override_blocked=wheel_blocked)
maps_selected = self._maps_selected
encounter = self.advance.encounter
if not maps_selected and encounter is None:
return frame
current = sel.select_limit(observations, self.settings.selection).observation.candidate
accepted = self.state.acceptance.accepted
# Confirm the old cap before a short zone can offer its exit preview.
if (encounter is not None and accepted is not None and accepted == encounter.applied_preview and
current is not None and current.speed_mps == accepted.candidate.speed_mps):
self.history_limit = accepted
shown = self.state.acceptance.presentation
if self.advance.encounter is not None and shown is not None and current != self.last_received:
if encounter is not None and shown is not None and current is not None and current != self.last_received:
identity = self.state.acceptance.next_presentation_id
self.state = replace(self.state, acceptance=replace(self.state.acceptance,
presentation=acc.Presentation(identity, shown.candidate), next_presentation_id=identity+1))
if (adopt is not None and self.advance.encounter is not None and
if not maps_selected:
self.advance = Advance()
self.map_source.reset()
self.current_presentation = None
self.advance.retired_cap = encounter.applied_preview
self.advance.retired_pending_speed_mps = encounter.speed_mps
proposals = None
elif (adopt is not None and encounter is not None and
adopt.candidate == self.last_received == current):
self.advance.use_current(adopt.candidate)
proposals = None
else:
rejected = self.state.acceptance.rejected
# Give the current Maps limit its own decision before previewing the exit.
current_unresolved = (current is not None and current.source == 'map' and current.speed_mps not in (
accepted.candidate.speed_mps if accepted is not None else None,
rejected.speed_mps if rejected is not None else None))
proposals = self.advance.propose(observations, self.settings.selection, self.map_source.upcoming,
current_way_id=self.map_source.way_id, seconds=self.settings.advance_seconds,
current_way_id=self.map_source.way_id, higher_seconds=self.settings.advance_higher_seconds,
lower_seconds=self.settings.advance_lower_seconds,
speed_mps=float(car_state.vEgo) if ego_valid else None,
standstill=bool(getattr(car_state, 'standstill', False)) or str(getattr(car_state, 'gearShifter', '')) == 'reverse',
can_offer=self.settings.enabled and authority.can_control and not host.force_decel,
confirmation_active=authority.can_control, now_ns=now_ns)
return replace(frame, adoption_observations=proposals, confirmation_started_ns=self.advance.started_ns)
confirmation_active=authority.can_control, now_ns=now_ns, pending=self.state.acceptance.pending, live=current,
current_unresolved=current_unresolved)
pending = self.state.acceptance.pending
if pending is not None and pending.candidate.source == 'map' and pending.candidate.speed_mps == self.advance.retired_pending_speed_mps:
self.state = replace(self.state, acceptance=replace(self.state.acceptance,
observation=acc.Observation(acc.ObservationKind.UNKNOWN), pending=None, presentation=None, timer_running=False))
preview_reverted = accepted is not None and accepted == self.advance.retired_cap and accepted != self.history_limit
if preview_reverted:
self.state = replace(self.state, acceptance=replace(self.state.acceptance, accepted=self.history_limit))
return replace(frame, adoption_observations=proposals, confirmation_started_ns=self.advance.started_ns,
preview_reverted=preview_reverted)
def _action(self, request: Action | None, now_ns: int) -> tuple[acc.DriverAction | None, acc.AdoptRequest | None, int | None]:
if request is None or request.session_id != self.session_id or request.kind not in ("accept", "reject", "adopt"):
@@ -440,15 +427,10 @@ class Runtime:
shown = self.state.acceptance.presentation
if shown is None and request.kind == 'adopt':
shown = self.current_presentation
pending = self.state.acceptance.pending
retained_reject = (request.kind == 'reject' and request.presentation_id == 0 and
pending is not None and request.decision_id == pending.decision_id)
if not retained_reject and (shown is None or shown.presentation_id != request.presentation_id):
if shown is None or shown.presentation_id != request.presentation_id:
return None, None, None
action_id = self.ledger.last_action_id + 1
context = self.state.override.context
context_id = context.context_id if context is not None else "slc-presentation"
begun = arb.step(self.ledger, arb.BeginAction(self.session_id, action_id, arb.Origin.DRIVER_OTHER, context_id),
begun = arb.step(self.ledger, arb.BeginAction(self.session_id, action_id, arb.Origin.DRIVER_OTHER, 'slc-presentation'),
now_ns=now_ns)
self.ledger = begun.state
if begun.errors:
@@ -457,33 +439,44 @@ class Runtime:
assert shown is not None
return None, acc.AdoptRequest(self.session_id, action_id, request.presentation_id, shown.candidate), action_id
kind = acc.ActionKind.ACCEPT if request.kind == "accept" else acc.ActionKind.REJECT
return acc.DriverAction(self.session_id, action_id, request.decision_id, kind), None, action_id
return acc.DriverAction(self.session_id, action_id, request.decision_id, kind, request.observed_ns), None, action_id
def step(self, sm, CP, *, now_ns: int, now_boot_ns: int | None = None, request: Action | None = None,
cruise_event: CruiseEvent | None = None) -> Output:
from openpilot.starpilot.speed_limits.toyota import eligible, projection
self.toyota_eligible = eligible(CP)
self.toyota_projection = projection(sm, now_ns) if self.toyota_eligible else None
old_pending = self.state.acceptance.pending
if self.command_status == 'issued' and now_ns > self.command_expiry_ns:
self.command_status = 'expired'
cruise_event: CruiseEvent | None = None, confirmation_grace_ns: int = 0, wheel_override_blocked: bool = False) -> Output:
classified = self._cruise(cruise_event, sm, now_ns) if request is None else None
if cruise_event is not None and request is None:
self._command_receipt(cruise_event, sm, now_ns)
action, adopt, action_id = self._action(request, now_ns)
if cruise_event is not None and request is None:
action, adopt, action_id = self._physical_action(cruise_event, sm, now_ns)
if (adopt is not None and self.advance.encounter is not None and self.last_received is not None and
if (adopt is not None and self._maps_selected and self.advance.encounter is not None and self.last_received is not None and
(request is None or self.state.acceptance.pending is None)):
# Explicit adoption uses the current road, including SET over an old early prompt.
shown = acc.Presentation(adopt.presentation_id, self.last_received)
adopt = replace(adopt, candidate=shown.candidate)
self.state = replace(self.state, acceptance=replace(self.state.acceptance, presentation=shown, pending=None, timer_running=False,
observation=acc.Observation(acc.ObservationKind.VALID, shown.candidate)))
frame = self._frame(sm, CP, now_ns, now_boot_ns, action, adopt, classified)
wheel_since = 0
if (classified is not None and cruise_event is not None and cruise_event.button == 'accel' and
not cruise_event.long_press and cruise_event.selected_mps > cruise_event.previous_mps and
abs(float(sm['carState'].vCruise) / 3.6 - cruise_event.selected_mps) <= 0.0001 and
0 < cruise_event.wheel_override_since_ns <= cruise_event.observed_ns and cruise_event.session_id == self.session_id and
_fresh(sm, 'slcDashboardObservation', now_ns) and
cruise_event.producer_session == str(sm['slcDashboardObservation'].producerSessionId)):
wheel_since = cruise_event.wheel_override_since_ns
frame = self._frame(sm, CP, now_ns, now_boot_ns, action, adopt, classified, wheel_override_blocked=wheel_override_blocked)
frame = replace(frame, confirmation_grace_ns=confirmation_grace_ns, wheel_override_since_ns=wheel_since)
result = comp.step(self.state, frame)
self.state = result.state
self.advance.observe(result)
if not result.errors and result.acceptance is not None:
accepted = self.state.acceptance.accepted
encounter = self.advance.encounter
provisional = encounter is not None and not encounter.crossed and accepted == encounter.applied_preview
if result.acceptance.history_write is not None and not provisional:
self.history_limit = accepted
elif accepted is not None and accepted != self.history_limit and result.selection is not None:
current = sel.select_limit(frame.observations, self.settings.selection).observation.candidate
if current is not None and current == result.selection.observation.candidate and current.speed_mps == accepted.candidate.speed_mps:
self.history_limit = accepted
if self.state.acceptance.presentation is None and self.advance.encounter is not None:
current = sel.select_limit(frame.observations, self.settings.selection).observation.candidate
if current is not None and (self.current_presentation is None or self.current_presentation.candidate != current):
@@ -495,101 +488,22 @@ class Runtime:
if result.acceptance is not None:
action_result = arb.resolve_acceptance(self.ledger, action_id, result.acceptance, now_ns=now_ns)
self.ledger = action_result.state
self.last_action_result = action_result
completed = arb.step(self.ledger, arb.CompleteAction(self.session_id, action_id), now_ns=now_ns)
self.ledger = completed.state
commanded_before = self.commanded_acceptance
command = self._command(result, sm, now_ns, old_pending, action, adopt)
result = replace(result, state=self.state)
message = self._message(now_ns, result, sm, frame)
if command is not None and (request is not None or commanded_before != self.commanded_acceptance):
self._command_ui_sequence = request.sequence_id if request is not None else 0
message.slcState.commandActionSequenceId = getattr(self, '_command_ui_sequence', 0)
if action_result is not None and action_result.status == 'resolved':
self._ui_receipt = (request.sequence_id, str(message.slcState.actionStatus)) if request is not None else None
state = message.slcState
if (request is not None and request.kind == 'adopt' and state.actionStatus == 'adopt' and
0 < request.wheel_observed_ns <= (request.observed_ns or 0) <= now_ns <= request.wheel_observed_ns + WHEEL_ACTION_MAX_AGE_NS and
frame.authority.can_control and not frame.host.force_decel and state.hasAcceptedAdjustedLimit and not state.acceptedIsPreview):
state.wheelAdoptMonoTime = request.wheel_observed_ns
state.wheelAdoptTargetMps = state.acceptedAdjustedLimit
receipt = getattr(self, '_ui_receipt', None)
if receipt is not None:
message.slcState.actionSequenceId, message.slcState.actionStatus = receipt
return Output(result, message, action_result, command, self.settings)
def _command(self, result: comp.Result, sm, now_ns: int, old_pending,
action: acc.DriverAction | None, adopt: acc.AdoptRequest | None):
decision = result.acceptance
if (decision is None or decision.basis not in ('accepted', 'driver_accept', 'driver_adopt') or
decision.state.accepted is None or decision.state.pending is not None or decision.authority is None or
not decision.authority.can_control or result.coordinate is None or
result.domain is None or result.domain.context is None or result.selection is None or
result.selection.observation.candidate is None or
result.selection.observation.candidate.speed_mps != decision.state.accepted.candidate.speed_mps or
result.state.override.persistent_selected_mps is not None):
return None
target = result.domain.context.effective_cluster_mps
if self.toyota_eligible:
row = self.toyota_projection
accepted = decision.state.accepted
if (row is None or row.status != 'ready' or row.sessionId != self.session_id or
int(row.acceptedMonoTime) != accepted.accepted_at_ns or
abs(float(row.approvedTargetMps) - target) > 0.02):
self.commanded_acceptance = None
self.command_status = ''
return None
if self.commanded_generation != result.state.control_generation:
self.commanded_acceptance = None
self.commanded_generation = result.state.control_generation
selected_kph = float(sm['carState'].vCruise)
if (not _number(target, positive=True) or not _number(selected_kph, positive=True) or
selected_kph >= 255 or not V_CRUISE_MIN / 3.6 <= target <= V_CRUISE_MAX / 3.6):
return None
if sd.set_speed_matches(target, selected_kph, float(sm['carState'].vCruiseCluster), self.is_metric):
if not self._set_speed_ready(sm):
return None
self.commanded_acceptance = decision.state.accepted
self.command_status = 'applied'
self.state = replace(self.state, override=replace(self.state.override, applied_selected_mps=selected_kph / 3.6))
return None
if self.commanded_acceptance == decision.state.accepted and decision.adoption is None:
if self.command_status in ('issued', 'applied') or abs(selected_kph / 3.6 - self.command_expected_mps) > 0.1:
return None
evidence = sm['slcDashboardObservation']
expiry = now_ns + int(2e9 / SERVICE_LIST['slcState'].frequency)
if result.selection.selected_source is sel.Source.DASHBOARD:
expiry = min(expiry, int(evidence.validUntilMonoTime))
elif result.selection.selected_source is sel.Source.VISION:
evidence = sm['slcVisionObservation'].vision
expiry = min(expiry, int(evidence.validUntilMonoTime))
if expiry <= now_ns:
return None
self.commanded_acceptance = decision.state.accepted
self.last_command_id += 1
self.command_status = 'issued'
self.command_issued_ns = now_ns
self.command_expiry_ns = expiry
self.set_speed_after_ns = max(self.set_speed_after_ns, expiry)
self.command_expected_mps = selected_kph / 3.6
self.command_target_mps = target
self.command_card_producer = str(sm['slcDashboardObservation'].producerSessionId)
command = messaging.new_message('slcCruiseCommand')
command.valid = True
command.logMonoTime = now_ns
record = command.slcCruiseCommand
record.kind = 'adoptAcceptedLimit'
record.source = result.selection.selected_source.value
record.upcomingBoundaryId = self.advance.boundary_id
record.sessionId = self.session_id
record.commandId = self.last_command_id
record.actionId = action.sequence_id if action is not None else adopt.sequence_id if adopt is not None else 0
record.decisionId = old_pending.decision_id if old_pending is not None else 0
record.presentationId = self.state.acceptance.presentation.presentation_id if self.state.acceptance.presentation is not None else 0
if result.selection.selected_source in (sel.Source.DASHBOARD, sel.Source.VISION):
record.sourceProducerSessionId = str(evidence.producerSessionId)
record.sourceEpisode = int(evidence.episode)
record.sourceObservedMonoTime = int(evidence.observedMonoTime)
record.sourceValidUntilMonoTime = int(evidence.validUntilMonoTime)
record.issuedMonoTime = now_ns
record.expiresMonoTime = expiry
record.expectedSelectedMps = selected_kph / 3.6
record.targetMps = target
return command
return Output(result, message, action_result)
def _message(self, now_ns: int, result: comp.Result, sm, frame: comp.Frame):
message = messaging.new_message("slcState")
@@ -628,16 +542,22 @@ class Runtime:
state.acceptedMonoTime = accepted.accepted_at_ns
state.acceptedSpeedLimit = accepted.candidate.speed_mps
state.acceptedSource = accepted.candidate.source
state.acceptedIsPreview = accepted != self.history_limit and self.advance.encounter is not None and accepted == self.advance.encounter.applied_preview
if result.domain is not None and result.domain.context is not None:
state.offset = result.domain.context.offset_mps
# Publish the qualified domain coordinate; a raw cap is not a cluster value.
context = result.domain.context if result.domain is not None else None
if context is not None:
state.hasEffectiveClusterTarget = True
state.effectiveClusterTarget = context.effective_cluster_mps
# Accelerator use never creates a set-speed override.
state.driverOverrideActive = result.state.override.persistent_selected_mps is not None
state.effectiveClusterTarget = result.coordinate.cluster_target_mps if result.coordinate is not None else context.effective_cluster_mps
if result.ceiling is not None:
state.hasCeiling = True
state.effectiveCap = result.ceiling.speed_mps
state.isLimitingMaxSet = result.ceiling.speed_mps < frame.host.driver_v_cruise_kph / 3.6
pending = result.state.acceptance.pending
# Only an already-captured response can finish an expired presentation.
if pending is not None and not pending.automatic and pending.active_elapsed_ns >= acc.CONFIRMATION_NS:
pending = None
if pending is not None:
state.hasPending = True
state.pendingSpeedLimit = pending.candidate.speed_mps
@@ -645,8 +565,11 @@ class Runtime:
state.decisionId = pending.decision_id
state.automaticAdoptionPending = pending.automatic
state.pendingSinceMonoTime = now_ns - pending.active_elapsed_ns
state.overrideBasis = 'persistent' if state.driverOverrideActive else 'none'
state.retainedOverride = bool(result.state.override.persistent_selected_mps is not None)
wheel_active = result.coordinate is not None and result.coordinate.basis == 'wheel'
state.wheelOverrideSince = result.state.wheel_override_since_ns if not result.errors else 0
state.driverOverrideActive = wheel_active or result.ceiling is not None and result.state.pedal_target_mps > 0.
state.overrideBasis = 'wheel' if wheel_active else (
('pedal' if frame.pedal_pressed else 'persistent') if state.driverOverrideActive else 'none')
shown = result.state.acceptance.presentation
ego = frame.ego_pair
ego_delta = (max(0.0, ego.cluster_mps - ego.raw_mps)
@@ -667,7 +590,7 @@ class Runtime:
setattr(state, flag_field, True)
if state.hasPresentationAdjustedLimit and not self.advance.boundary_id:
state.offset = float(state.presentationAdjustedLimit) - float(state.speedLimit)
# A retained choice gets an Accept presentation only when fresh evidence matches it.
# A manual choice keeps its presentation through source gaps.
if shown is not None and (pending is None or pending.candidate == shown.candidate):
state.presentationId = shown.presentation_id
elif pending is None and self.current_presentation is not None and self.current_presentation.candidate == current_selection.observation.candidate:
@@ -685,8 +608,6 @@ class Runtime:
state.sourceEpisode = int(evidence.episode)
state.sourceObservedMonoTime = int(evidence.observedMonoTime)
state.sourceValidUntilMonoTime = int(evidence.validUntilMonoTime)
state.commandId = self.last_command_id
state.commandStatus = self.command_status
receipt = result.acceptance.action_receipt or result.acceptance.adoption_receipt if result.acceptance else None
if receipt is not None:
state.actionSequenceId = receipt.sequence_id
@@ -2,18 +2,35 @@
from collections.abc import Mapping
from dataclasses import dataclass
from enum import IntEnum
from pathlib import Path
from openpilot.starpilot.saved_source import read_saved
from openpilot.starpilot.speed_limits import acceptance as acc
from openpilot.starpilot.speed_limits import offset_document as od
from openpilot.starpilot.speed_limits import selection as sel
from openpilot.starpilot.speed_limits import speed_domain as sd
MPH_TO_MPS = od.MPH_TO_MPS
KPH_TO_MPS = od.KPH_TO_MPS
IMPERIAL_BOUNDS = od.IMPERIAL_BOUNDS
METRIC_BOUNDS = od.METRIC_BOUNDS
LABELS = {"Dashboard": sel.Source.DASHBOARD, "Map Data": sel.Source.MAP, "Maps": sel.Source.MAP, "Vision": sel.Source.VISION}
SOURCE_PRIORITY_KEYS = ('SLCPriority1', 'SLCPriority2', 'SLCPriority3')
SOURCE_PRIORITY_DEFAULTS = ('Vision', 'Map Data', 'Off')
SOURCE_PRIORITY_MAX_BYTES = 32
CONFIRMATION_CHOICES = {0: 'Off', 1: 'Both', 3: 'Higher only', 4: 'Lower only'}
CONFIRMATION_BYTES = tuple(str(mode).encode() for mode in CONFIRMATION_CHOICES)
ADVANCE_KEYS = ('SLCAdvanceHigherSeconds', 'SLCAdvanceLowerSeconds')
ADVANCE_SECONDS = range(11)
ADVANCE_BYTES = tuple(str(seconds).encode() for seconds in ADVANCE_SECONDS)
PEDAL_OVERRIDE_KEY = 'SLCOverrideMode'
PEDAL_OVERRIDE_CHOICES = ('Resume the adjusted limit', 'Hold the speed you reached', 'Use your SET speed')
PEDAL_OVERRIDE_BYTES = (b'0', b'1', b'2')
WHEEL_OVERRIDE_KEY = 'SLCWheelOverride'
class PedalOverride(IntEnum):
OFF = 0
SPEED_REACHED = 1
SET_SPEED = 2
@dataclass(frozen=True)
@@ -26,7 +43,10 @@ class Settings:
errors: tuple[str, ...] = ()
# Saved intent must still retire pending work when vehicle capabilities disable control.
requested: bool = False
advance_seconds: int = 0
advance_higher_seconds: int = 0
advance_lower_seconds: int = 0
pedal_override: PedalOverride = PedalOverride.OFF
wheel_override: bool = False
def _bool(values: Mapping[str, object], key: str, default: bool = False) -> bool:
@@ -51,37 +71,50 @@ def parse(values: Mapping[str, object]) -> Settings:
od.default_document(_bool(values, 'IsMetric')))
if not isinstance(document, od.OffsetDocument):
raise ValueError('saved SLC offsets need review')
first = values.get("SLCPriority1", "Vision")
second = values.get("SLCPriority2", "Dashboard")
if type(first) is not str or type(second) is not str:
first, second, third = (values.get(key, default) for key, default in
zip(SOURCE_PRIORITY_KEYS, SOURCE_PRIORITY_DEFAULTS, strict=True))
if any(type(source) is not str for source in (first, second, third)):
raise ValueError("invalid saved SLC source priority")
mode = {"Highest": sel.SelectionMode.HIGHEST, "Lowest": sel.SelectionMode.LOWEST}.get(first, sel.SelectionMode.ORDERED)
if first not in (*LABELS, "Highest", "Lowest") or second not in LABELS:
if first not in (*LABELS, "Highest", "Lowest") or any(source not in (*LABELS, 'Off') for source in (second, third)):
raise ValueError("invalid saved SLC source priority")
slots = (LABELS.get(first), LABELS[second])
override = values.get("SLCOverride", 1)
if type(override) is not int or override not in (0, 1, 2):
raise ValueError("invalid saved SLC override")
confirmation = _bool(values, "SLCConfirmation")
advance = values.get('SLCAdvanceSeconds', 0)
if type(advance) is not int or not 0 <= advance <= 10:
advance = 0 # An invalid optional timer cannot enable early adoption.
policy = acc.Policy(confirm_lower=confirmation, confirm_higher=confirmation,
slots = (LABELS.get(first), LABELS.get(second), LABELS.get(third))
confirmation = values.get('SLCConfirmation', 0)
if type(confirmation) is not int or confirmation not in CONFIRMATION_CHOICES:
raise ValueError('invalid saved SLC confirmation mode')
higher = values.get('SLCAdvanceHigherSeconds', 0)
lower = values.get('SLCAdvanceLowerSeconds', 0)
if type(higher) is not int or higher not in ADVANCE_SECONDS:
higher = 0
if type(lower) is not int or lower not in ADVANCE_SECONDS:
lower = 0
policy = acc.Policy(confirm_lower=confirmation in (1, 4),
confirm_higher=confirmation in (1, 3),
display_only=not control)
offsets = document.schedule()
# SLCOverride remains a validated legacy preference; only set-speed changes override SLC.
pedal_override = values.get(PEDAL_OVERRIDE_KEY, PedalOverride.SPEED_REACHED.value)
if type(pedal_override) is not int or pedal_override not in (0, 1, 2):
pedal_override = PedalOverride.OFF.value
return Settings(control, display, sel.SelectionPolicy(mode, slots, False), policy, offsets,
requested=control, advance_seconds=advance)
requested=control, advance_higher_seconds=higher, advance_lower_seconds=lower,
pedal_override=PedalOverride(pedal_override), wheel_override=values.get(WHEEL_OVERRIDE_KEY) is True)
except (ValueError, OverflowError) as error:
return _disabled(str(error))
SAVED_KEYS = ("SpeedLimitController", "ShowSpeedLimits", "IsMetric", "SLCPriority1", "SLCPriority2",
"SLCOverride", "SLCConfirmation", "SLCAdvanceSeconds")
SAVED_KEYS = ("SpeedLimitController", "ShowSpeedLimits", "IsMetric", *SOURCE_PRIORITY_KEYS,
"SLCConfirmation")
def read_params(params) -> Settings:
values = {}
values = {WHEEL_OVERRIDE_KEY: read_saved(params, WHEEL_OVERRIDE_KEY, 1) == (b'1', True)}
raw_override, readable_override = read_saved(params, PEDAL_OVERRIDE_KEY, 1)
if raw_override is not None or not readable_override:
values[PEDAL_OVERRIDE_KEY] = int(raw_override) if readable_override and raw_override in PEDAL_OVERRIDE_BYTES else 0
for key in ADVANCE_KEYS:
raw, readable = read_saved(params, key, 2)
if readable and raw in ADVANCE_BYTES:
values[key] = int(raw)
try:
with Path(params.get_param_path('SLCOffsetSchedule')).open('rb') as stream:
raw_document = stream.read(od.MAX_DOCUMENT_BYTES + 1)
@@ -97,20 +130,23 @@ def read_params(params) -> Settings:
for key in SAVED_KEYS:
if raw_document is not None and key == 'IsMetric':
continue
# Typed Params reads turn malformed numbers/strings into absence and accept
# any non-"1" Boolean as false. Neither may silently alter confirmation or
# units. Decode the saved bytes explicitly; only an absent file uses defaults.
# Only missing files use defaults. Invalid bytes must not change confirmation or units.
try:
with Path(params.get_param_path(key)).open('rb') as stream:
raw = stream.read(8 if key == 'SLCAdvanceSeconds' else -1)
if key == 'SLCAdvanceSeconds':
if raw not in tuple(str(seconds).encode() for seconds in range(11)):
raise ValueError('invalid advance time')
value = int(raw)
elif key == 'SLCOverride':
value = int(raw)
elif key in ('SLCPriority1', 'SLCPriority2'):
if key in (*SOURCE_PRIORITY_KEYS, 'SLCConfirmation'):
raw, readable = read_saved(params, key, 1 if key == 'SLCConfirmation' else SOURCE_PRIORITY_MAX_BYTES)
if not readable:
return _disabled(f'Invalid saved {key}')
if raw is None:
continue
else:
with Path(params.get_param_path(key)).open('rb') as stream:
raw = stream.read()
if key in SOURCE_PRIORITY_KEYS:
value = raw.decode('utf-8')
elif key == 'SLCConfirmation':
if raw not in CONFIRMATION_BYTES:
raise ValueError("invalid confirmation mode")
value = int(raw)
else:
if raw not in (b'0', b'1'):
raise ValueError('invalid Boolean')
@@ -119,8 +155,5 @@ def read_params(params) -> Settings:
except FileNotFoundError:
continue
except (OSError, ValueError, OverflowError):
if key == 'SLCAdvanceSeconds':
values[key] = 0
continue
return _disabled(f'Invalid saved {key}')
return parse(values)
@@ -22,7 +22,7 @@ class SelectionMode(StrEnum):
@dataclass(frozen=True)
class SelectionPolicy:
mode: SelectionMode
slots: tuple[Source | None, Source | None]
slots: tuple[Source | None, ...]
online_fallback: bool
@@ -109,9 +109,9 @@ def _input_errors(observations, policy):
return ["policy must be SelectionPolicy"]
if not isinstance(policy.mode, SelectionMode):
errors.append("selection mode must be explicit")
if (not isinstance(policy.slots, tuple) or len(policy.slots) != 2 or
if (not isinstance(policy.slots, tuple) or len(policy.slots) not in (2, 3) or
any(source is not None and (not isinstance(source, Source) or source == Source.ONLINE) for source in policy.slots)):
errors.append("two primary source slots are required; online is fallback only")
errors.append("two or three source slots are required; online is fallback only")
if type(policy.online_fallback) is not bool:
errors.append("online fallback must be Boolean")
if not isinstance(observations, Mapping):
@@ -5,6 +5,7 @@ import struct
from dataclasses import dataclass
from enum import Enum
from openpilot.common.constants import CV
from openpilot.selfdrive.car.cruise import V_CRUISE_MIN, V_CRUISE_MAX, slc_target_kph
from openpilot.starpilot.speed_limits import acceptance as acc
@@ -165,6 +166,15 @@ def set_speed_matches(target_mps: float, raw_kph: float, cluster_kph: float | No
math.isclose(target_kph, cluster_kph, rel_tol=0, abs_tol=0.001))
def set_above_limit(selected_mps: float, adjusted_limit_mps: float, is_metric: bool) -> bool:
# Use the sign's wire precision and restore software SET's 0.1 km/h after transport.
limit_mps = struct.unpack('f', struct.pack('f', adjusted_limit_mps))[0]
selected_kph = round(selected_mps * CV.MS_TO_KPH, 1)
if is_metric:
return round(selected_kph) > round(limit_mps * CV.MS_TO_KPH)
return round(selected_kph * CV.KPH_TO_MPH) > round(limit_mps * CV.MS_TO_MPH)
def resolve(decision: acc.Decision, schedule: OffsetSchedule, selected: SpeedPair, ego: SpeedPair) -> DomainResolution:
"""Bind an explicit offset and contemporaneous speed pairs to an acceptance output."""
if (not isinstance(decision, acc.Decision) or not isinstance(decision.state, acc.State) or decision.errors or
@@ -198,7 +208,8 @@ def resolve(decision: acc.Decision, schedule: OffsetSchedule, selected: SpeedPai
if (not _number(target, positive=True) or accepted is None or
accepted.candidate.speed_mps != target or not decision.authority.can_control or
decision.policy.display_only or
decision.state.observation.kind is not acc.ObservationKind.VALID):
decision.state.observation.kind is not acc.ObservationKind.VALID and
decision.basis not in ('absent', 'unknown', 'stale', 'driver_accept', 'driver_reject', 'timeout_reject')):
return DomainResolution(DomainStatus.INVALID, reason="accepted target mismatch")
assert selected.raw_mps is not None and selected.cluster_mps is not None
assert ego.raw_mps is not None and ego.cluster_mps is not None
@@ -216,22 +227,12 @@ def resolve(decision: acc.Decision, schedule: OffsetSchedule, selected: SpeedPai
ego.raw_mps, ego_delta))
def to_planner_coordinate(context: DomainContext, contribution_raw_mps: float | None, basis: str) -> PlannerCoordinate:
"""Apply a raw override in cluster space, then remove the ego delta exactly once."""
if (not context_is_valid(context) or basis not in ("none", "adoption_clear", "pedal", "persistent") or
(basis in ("none", "adoption_clear")) != (contribution_raw_mps is None)):
def to_planner_coordinate(context: DomainContext, *, override_mps: float = 0.) -> PlannerCoordinate:
"""Remove the ego delta from the selected cluster target exactly once."""
if not context_is_valid(context) or not _number(override_mps):
return PlannerCoordinate(None, "invalid")
if contribution_raw_mps is not None and not _number(contribution_raw_mps, positive=True):
return PlannerCoordinate(None, "invalid")
cluster_target = context.effective_cluster_mps
if basis == "pedal":
if contribution_raw_mps != context.ego_raw_mps:
return PlannerCoordinate(None, "invalid")
cluster_target = max(cluster_target, context.ego_raw_mps + context.ego_delta_mps)
elif basis == "persistent":
assert contribution_raw_mps is not None
cluster_target = max(cluster_target, contribution_raw_mps + context.selected_delta_mps)
cluster_target = max(context.effective_cluster_mps, override_mps)
cap = cluster_target - context.ego_delta_mps
if not _number(cap, positive=True):
return PlannerCoordinate(None, "nonpositive", cluster_target, context.ego_delta_mps)
return PlannerCoordinate(cap, basis, cluster_target, context.ego_delta_mps)
return PlannerCoordinate(cap, 'none', cluster_target, context.ego_delta_mps)
@@ -5,7 +5,7 @@ from dataclasses import FrozenInstanceError, replace
from typing import Any, cast
from openpilot.starpilot.speed_limits.acceptance import (
CONFIRMATION_NS, MAX_REJECTIONS, AcceptedLimit, ActionKind, AdoptRequest, Authority, Candidate, DriverAction,
CONFIRMATION_NS, AcceptedLimit, ActionKind, AdoptRequest, Authority, Candidate, DriverAction,
IdentityKind, LongitudinalOwner, Mode, Observation, ObservationIdentity, ObservationKind, Policy, State, new_session, step,
)
@@ -96,7 +96,7 @@ class TestAcceptance(unittest.TestCase):
accepted = self.act(ready.state, ActionKind.ACCEPT, now_ns=4 * NS, policy=policy) if confirmation else ready
self.assertEqual(accepted.control_target_mps, candidate.speed_mps)
lost = step(accepted.state, ABSENT, ACTIVE, policy, now_ns=5 * NS)
self.assertIsNone(lost.control_target_mps)
self.assertEqual(lost.control_target_mps, candidate.speed_mps)
returned = step(lost.state, valid(candidate), ACTIVE, policy, now_ns=6 * NS)
self.assertIsNone(returned.state.pending)
self.assertIsNone(returned.history_write)
@@ -127,22 +127,22 @@ class TestAcceptance(unittest.TestCase):
lost = step(rejected.state, ABSENT, ACTIVE, policy, now_ns=4 * NS)
returned = step(lost.state, valid(LOW), ACTIVE, policy, now_ns=5 * NS)
for decision in (rejected, lost, returned):
self.assertEqual(decision.control_target_mps, None if decision is lost else HIGH.speed_mps)
self.assertEqual(decision.control_target_mps, HIGH.speed_mps)
self.assertEqual(decision.state.accepted, initial.history_write)
self.assertIsNone(decision.state.pending)
self.assertIsNone(decision.history_write)
self.assertIn(LOW, decision.state.rejected)
self.assertEqual(decision.state.rejected, LOW)
self.assertTrue(rejected.action_receipt.consumed)
self.assertEqual(returned.basis, "rejected")
def test_source_gaps_keep_accepted_history_without_a_control_target(self):
def test_source_gaps_use_the_previous_accepted_limit_without_inventing_an_observation(self):
for source, kind in itertools.product(('dashboard', 'map', 'vision', 'online', 'future-source'),
(ObservationKind.ABSENT, ObservationKind.UNKNOWN, ObservationKind.STALE)):
with self.subTest(source=source, kind=kind):
prior = saved(replace(HIGH, source=source))
policy = Policy()
result = step(prior, Observation(kind), ACTIVE, policy, now_ns=300 * NS)
self.assertIsNone(result.control_target_mps)
self.assertEqual(result.control_target_mps, prior.accepted.candidate.speed_mps)
self.assertEqual(result.state.accepted, prior.accepted)
self.assertEqual(result.state.observation.kind, kind)
self.assertIsNone(result.display_candidate)
@@ -156,62 +156,79 @@ class TestAcceptance(unittest.TestCase):
previous = saved(first)
for kind in (ObservationKind.ABSENT, ObservationKind.UNKNOWN, ObservationKind.STALE):
lost = step(previous, Observation(kind), ACTIVE, policy, now_ns=NS)
self.assertIsNone(lost.control_target_mps)
self.assertEqual(lost.control_target_mps, first.speed_mps)
self.assertEqual(lost.state.accepted, previous.accepted)
next_sign = replace(LOW, source='vision')
pending = step(lost.state, valid(next_sign), ACTIVE, policy, now_ns=2 * NS)
self.assertEqual(pending.control_target_mps, first.speed_mps)
self.assertIsNotNone(pending.state.pending)
recovered = step(pending.state, valid(first), ACTIVE, policy, now_ns=3 * NS)
self.assertIsNotNone(recovered.state.pending)
self.assertIsNone(recovered.state.pending)
self.assertFalse(step(recovered.state, valid(first), ACTIVE, policy, now_ns=4 * NS).errors)
for kind in ActionKind:
action = DriverAction('drive', 1, pending.state.pending.decision_id, kind)
acted = step(pending.state, valid(next_sign), ACTIVE, policy, now_ns=3 * NS, action=action)
target = next_sign.speed_mps if kind is ActionKind.ACCEPT else first.speed_mps
gap = step(acted.state, Observation(ObservationKind.UNKNOWN), ACTIVE, policy, now_ns=300 * NS)
self.assertIsNone(gap.control_target_mps)
self.assertEqual(gap.control_target_mps, target)
self.assertEqual(gap.state.accepted.candidate.speed_mps, target)
timeout = step(pending.state, valid(next_sign), ACTIVE, policy, now_ns=33 * NS)
self.assertEqual(timeout.basis, 'timeout_reject')
self.assertEqual(timeout.control_target_mps, first.speed_mps)
def test_source_gaps_keep_the_decision_and_original_deadline_without_accept_authority(self):
def test_source_gaps_keep_the_decision_presentation_and_original_deadline(self):
for kind in (ObservationKind.UNKNOWN, ObservationKind.STALE, ObservationKind.ABSENT):
with self.subTest(kind=kind):
before = self.pending()
missing = step(before.state, Observation(kind), ACTIVE, Policy(), now_ns=NS)
after = self.pending(state=missing.state, now_ns=2 * NS)
self.assertEqual(missing.state.pending.decision_id, before.state.pending.decision_id)
self.assertIsNone(missing.state.presentation)
self.assertIsNone(missing.control_target_mps)
self.assertEqual(missing.state.presentation, before.state.presentation)
self.assertEqual(missing.control_target_mps, HIGH.speed_mps)
self.assertEqual(before.state.pending.decision_id, after.state.pending.decision_id)
self.assertEqual(after.state.pending.active_elapsed_ns, 2 * NS)
late = DriverAction("drive", 1, before.state.pending.decision_id, ActionKind.ACCEPT)
result = step(missing.state, valid(LOW), ACTIVE, Policy(), now_ns=3 * NS, action=late)
self.assertFalse(result.action_receipt.consumed)
self.assertEqual(result.action_receipt.status, "different_presentation")
self.assertEqual(result.state.accepted.candidate, HIGH)
self.assertTrue(result.action_receipt.consumed)
self.assertEqual(result.action_receipt.status, "accept")
self.assertEqual(result.state.accepted.candidate, LOW)
expired = step(after.state, Observation(kind), ACTIVE, Policy(), now_ns=CONFIRMATION_NS)
self.assertEqual(expired.basis, 'timeout_reject')
self.assertIsNone(expired.state.pending)
self.assertIsNone(expired.control_target_mps)
returned = step(expired.state, valid(LOW), ACTIVE, Policy(), now_ns=11 * NS)
self.assertEqual(expired.control_target_mps, HIGH.speed_mps)
returned = step(expired.state, valid(LOW), ACTIVE, Policy(), now_ns=CONFIRMATION_NS + NS)
self.assertEqual(returned.basis, 'rejected')
def test_retained_confirmation_can_be_rejected_but_not_accepted_without_a_source(self):
def test_retained_confirmation_can_be_accepted_or_rejected_without_a_source(self):
before = self.pending()
for kind in (ActionKind.ACCEPT, ActionKind.REJECT):
with self.subTest(kind=kind):
action = DriverAction('drive', 1, before.state.pending.decision_id, kind)
result = step(before.state, ABSENT, ACTIVE, Policy(), now_ns=NS, action=action)
self.assertIsNone(result.control_target_mps)
self.assertEqual(result.state.accepted, before.state.accepted)
self.assertEqual(result.action_receipt.consumed, kind is ActionKind.REJECT)
self.assertEqual(result.state.pending is None, kind is ActionKind.REJECT)
expected = LOW if kind is ActionKind.ACCEPT else HIGH
self.assertEqual(result.control_target_mps, expected.speed_mps)
self.assertEqual(result.state.accepted.candidate, expected)
self.assertTrue(result.action_receipt.consumed)
self.assertIsNone(result.state.pending)
self.assertIsNone(result.state.presentation)
self.assertEqual(result.state.observation.kind, ObservationKind.ABSENT)
held = step(result.state, ABSENT, ACTIVE, Policy(), now_ns=2 * NS)
self.assertFalse(held.errors)
self.assertEqual(held.control_target_mps, expected.speed_mps)
inactive = step(before.state, ABSENT, PAUSED, Policy(), now_ns=NS)
self.assertIsNone(inactive.state.pending)
def test_source_gap_actions_respect_the_original_deadline(self):
for kind, late in itertools.product(ActionKind, (False, True)):
with self.subTest(kind=kind, late=late):
before = self.pending()
action = DriverAction('drive', 1, before.state.pending.decision_id, kind)
result = step(before.state, ABSENT, ACTIVE, Policy(), now_ns=CONFIRMATION_NS + int(late), action=action)
self.assertEqual(result.action_receipt.consumed, not late)
self.assertEqual(result.basis, 'timeout_reject' if late else f'driver_{kind.value}')
self.assertEqual(result.control_target_mps, LOW.speed_mps if kind is ActionKind.ACCEPT and not late else HIGH.speed_mps)
self.assertIsNone(result.state.pending)
def test_recorded_vision_expiry_burst_is_one_decision_or_one_automatic_adoption(self):
# Route 00000048: decisions 52, 53 and 54 were the same 35 mph episode.
previous = replace(LOW, source='vision', speed_mps=20 * .44704)
@@ -233,57 +250,51 @@ class TestAcceptance(unittest.TestCase):
if result.history_write is not None:
writes.append(result.history_write)
if not available:
self.assertIsNone(result.control_target_mps)
self.assertEqual(result.control_target_mps, result.state.accepted.candidate.speed_mps)
self.assertEqual(len(set(ids)), 1 if manual else 0)
self.assertEqual(len(writes), 0 if manual else 1)
def test_different_provider_must_settle_before_replacing_the_received_limit(self):
mapped = replace(LOW, source='map', speed_mps=30 * .44704)
vision = replace(HIGH, source='vision', speed_mps=35 * .44704)
def test_provider_changes_use_the_same_immediate_adoption_or_confirmation_flow(self):
mapped = replace(HIGH, source='map')
automatic = Policy(confirm_lower=False, confirm_higher=False)
for manual in (False, True):
with self.subTest(manual=manual):
for source, speed, manual in itertools.product(('map', 'dashboard', 'vision'),
(LOW.speed_mps, HIGH.speed_mps + 5 * .44704), (False, True)):
with self.subTest(source=source, speed=speed, manual=manual):
first = step(new_session('drive'), valid(mapped), ACTIVE, automatic, now_ns=0)
state = first.state
reading = replace(LOW, source=source, speed_mps=speed)
policy = Policy(confirm_lower=manual, confirm_higher=manual)
for timestamp, reading in ((NS, vision), (NS + NS // 2, mapped),
(2 * NS, vision), (2 * NS + NS // 2, mapped)):
result = step(state, valid(reading), ACTIVE, policy, now_ns=timestamp)
self.assertFalse(result.errors)
state = result.state
self.assertEqual(state.accepted, first.state.accepted)
self.assertIsNone(state.pending)
self.assertIsNone(result.history_write)
waiting = step(state, valid(vision), ACTIVE, policy, now_ns=3 * NS)
self.assertEqual(waiting.basis, 'source_switch_pending')
settled = step(waiting.state, valid(vision), ACTIVE, policy, now_ns=5 * NS)
self.assertEqual(settled.state.pending is not None, manual)
self.assertEqual(settled.history_write is not None, not manual)
result = step(first.state, valid(reading), ACTIVE, policy, now_ns=NS)
self.assertFalse(result.errors)
self.assertEqual(result.basis, 'pending' if manual else 'accepted')
self.assertEqual(result.control_target_mps, mapped.speed_mps if manual else speed)
self.assertEqual(result.history_write is not None, not manual)
if manual:
self.assertEqual(result.state.accepted, first.state.accepted)
self.assertEqual(result.state.pending.active_elapsed_ns, 0)
expired = step(result.state, valid(reading), ACTIVE, policy, now_ns=NS + CONFIRMATION_NS)
self.assertEqual(expired.basis, 'timeout_reject')
self.assertEqual(expired.control_target_mps, mapped.speed_mps)
else:
self.assertIsNone(result.state.pending)
self.assertEqual(result.state.accepted.candidate, reading)
def test_only_current_explicit_adoption_can_bypass_provider_verification(self):
def test_explicit_adoption_of_a_replacement_still_requires_its_current_presentation(self):
mapped = replace(LOW, source='map', speed_mps=30 * .44704)
vision = replace(HIGH, source='vision', speed_mps=35 * .44704)
automatic = Policy(confirm_lower=False, confirm_higher=False)
first = step(new_session('drive'), valid(mapped), ACTIVE, automatic, now_ns=0)
waiting = step(first.state, valid(vision), ACTIVE, automatic, now_ns=NS)
request = AdoptRequest('drive', 1, waiting.state.presentation.presentation_id, vision)
shown = step(first.state, valid(vision), ACTIVE, Policy(), now_ns=NS)
request = AdoptRequest('drive', 1, shown.state.presentation.presentation_id, vision)
for invalid in (replace(request, session_id='foreign'), replace(request, presentation_id=request.presentation_id + 1)):
held = step(waiting.state, valid(vision), ACTIVE, automatic, now_ns=NS + 1, adopt=invalid)
self.assertEqual(held.basis, 'source_switch_pending')
held = step(shown.state, valid(vision), ACTIVE, Policy(), now_ns=NS + 1, adopt=invalid)
self.assertEqual(held.basis, 'pending')
self.assertFalse(held.adoption_receipt.consumed)
self.assertIsNone(held.history_write)
self.assertEqual(held.state.accepted, first.state.accepted)
explicit = step(waiting.state, valid(vision), ACTIVE, automatic, now_ns=NS + 1, adopt=request)
explicit = step(shown.state, valid(vision), ACTIVE, Policy(), now_ns=NS + 1, adopt=request)
self.assertTrue(explicit.adoption_receipt.consumed)
self.assertEqual(explicit.state.accepted.candidate, vision)
def test_changed_received_speed_restarts_confirmation_after_a_decline(self):
rejected = self.act(self.pending().state, ActionKind.REJECT)
other = replace(LOW, speed_mps=25.)
changed = self.pending(candidate=other, state=rejected.state, now_ns=2 * NS)
returned = self.pending(candidate=LOW, state=changed.state, now_ns=3 * NS)
self.assertNotEqual(returned.state.pending.decision_id, rejected.state.next_decision_id - 1)
self.assertFalse(returned.state.rejected)
def test_same_received_speed_does_not_prompt_again_for_a_new_zone(self):
rejected = self.act(self.pending().state, ActionKind.REJECT)
next_zone = replace(LOW, observation_identity=geographic('road-3'))
@@ -306,13 +317,15 @@ class TestAcceptance(unittest.TestCase):
self.assertNotEqual(changed.state.pending.decision_id, first.state.pending.decision_id)
self.assertEqual(changed.state.pending.active_elapsed_ns, 0)
def test_confirmation_expires_at_ten_seconds(self):
def test_confirmation_expires_at_thirty_seconds(self):
pending = self.pending()
before = step(pending.state, valid(LOW), ACTIVE, Policy(), now_ns=CONFIRMATION_NS - 1)
ten_seconds = step(pending.state, valid(LOW), ACTIVE, Policy(), now_ns=10 * NS)
self.assertIsNotNone(ten_seconds.state.pending)
before = step(ten_seconds.state, valid(LOW), ACTIVE, Policy(), now_ns=30 * NS - 1)
self.assertIsNotNone(before.state.pending)
expired = step(before.state, valid(LOW), ACTIVE, Policy(), now_ns=CONFIRMATION_NS)
expired = step(before.state, valid(LOW), ACTIVE, Policy(), now_ns=30 * NS)
self.assertEqual(expired.basis, "timeout_reject")
self.assertIn(LOW, expired.state.rejected)
self.assertEqual(expired.state.rejected, LOW)
self.assertIsNone(expired.state.pending)
self.assertIsNone(expired.history_write)
self.assertEqual(expired.control_target_mps, HIGH.speed_mps)
@@ -324,10 +337,10 @@ class TestAcceptance(unittest.TestCase):
inactive = step(pause.state, valid(LOW), PAUSED, Policy(), now_ns=CONFIRMATION_NS)
self.assertFalse(inactive.state.rejected)
self.assertIsNone(inactive.control_target_mps)
resumed = self.pending(state=inactive.state, now_ns=20 * NS)
resumed = self.pending(state=inactive.state, now_ns=CONFIRMATION_NS + NS)
self.assertNotEqual(resumed.state.pending.decision_id, start.state.pending.decision_id)
self.assertEqual(resumed.state.pending.active_elapsed_ns, 0)
expired = step(resumed.state, valid(LOW), ACTIVE, Policy(), now_ns=30 * NS)
expired = step(resumed.state, valid(LOW), ACTIVE, Policy(), now_ns=2 * CONFIRMATION_NS + NS)
self.assertEqual(expired.basis, 'timeout_reject')
self.assertEqual(expired.state.accepted.candidate, HIGH)
@@ -492,7 +505,7 @@ class TestAcceptance(unittest.TestCase):
self.assertFalse(result.action_receipt.consumed)
self.assertEqual(result.action_receipt.status, "different_decision")
self.assertEqual(result.state.last_action_sequence, 3)
self.assertEqual(result.state.rejected, frozenset())
self.assertIsNone(result.state.rejected)
def test_event_cannot_accept_a_decision_not_previously_presented(self):
action = DriverAction("drive", 0, 1, ActionKind.ACCEPT)
@@ -579,7 +592,7 @@ class TestAcceptance(unittest.TestCase):
self.assertIsNone(result.history_write)
def test_pending_candidate_cannot_also_be_rejected(self):
state = replace(self.pending().state, rejected=frozenset({LOW}))
state = replace(self.pending().state, rejected=LOW)
result = step(state, valid(HIGH), ACTIVE, Policy(False, False), now_ns=NS)
self.assertTrue(result.errors)
self.assertIsNone(result.control_target_mps)
@@ -587,26 +600,28 @@ class TestAcceptance(unittest.TestCase):
self.assertIs(result.state, state)
self.assertEqual(result.state.accepted.candidate, HIGH)
def test_rejecting_a_returned_limit_preserves_its_earlier_accepted_history(self):
def test_returning_to_the_previous_limit_clears_the_obsolete_choice(self):
other = self.pending()
returned = self.pending(candidate=HIGH, state=other.state, now_ns=NS)
declined = self.act(returned.state, ActionKind.REJECT, now_ns=2 * NS)
held = step(declined.state, valid(HIGH), ACTIVE, Policy(), now_ns=3 * NS)
returned = step(other.state, valid(HIGH), ACTIVE, Policy(), now_ns=NS)
stale = DriverAction('drive', 1, other.state.pending.decision_id, ActionKind.REJECT)
held = step(returned.state, valid(HIGH), ACTIVE, Policy(), now_ns=2 * NS, action=stale)
self.assertFalse(held.errors)
self.assertEqual(held.basis, 'rejected')
self.assertEqual(held.basis, 'accepted')
self.assertIsNone(returned.state.pending)
self.assertFalse(held.action_receipt.consumed)
self.assertEqual(held.state.accepted, returned.state.accepted)
self.assertIn(HIGH, held.state.rejected)
self.assertFalse(held.state.rejected)
self.assertIsNone(held.state.pending)
def test_changed_limits_do_not_accumulate_obsolete_rejections(self):
state = saved()
for index in range(MAX_REJECTIONS + 1):
candidate = replace(LOW, speed_mps=15. + index / 100)
pending = self.pending(candidate=candidate, state=state, now_ns=2 * index)
result = self.act(pending.state, ActionKind.REJECT, sequence=index, now_ns=2 * index + 1)
state = result.state
self.assertEqual(state.rejected, frozenset({candidate}))
self.assertFalse(state.rejection_capacity_exhausted)
def test_new_decline_replaces_the_previous_decline(self):
first = self.act(self.pending().state, ActionKind.REJECT)
other = replace(LOW, speed_mps=19.)
pending = self.pending(candidate=other, state=first.state, now_ns=2 * NS)
self.assertEqual(pending.state.rejected, LOW)
second = self.act(pending.state, ActionKind.REJECT, sequence=1, now_ns=3 * NS)
self.assertEqual(second.state.rejected, other)
returned = self.pending(state=second.state, now_ns=4 * NS)
self.assertEqual(returned.control_target_mps, HIGH.speed_mps)
def test_state_and_inputs_are_immutable_and_new_session_validates_history(self):
original = saved()
@@ -638,7 +653,7 @@ class TestAcceptance(unittest.TestCase):
alternate = step(absent.state, valid(replace(candidate, source="map")), ACTIVE, Policy(), now_ns=3 * NS)
returned = step(alternate.state, valid(Candidate("dashboard", ObservationIdentity(IdentityKind.SESSION_VALUE, session_id="drive"),
LOW.speed_mps)), ACTIVE, Policy(), now_ns=4 * NS)
self.assertIn(candidate, returned.state.rejected)
self.assertEqual(returned.state.rejected, candidate)
self.assertEqual(returned.basis, "rejected")
self.assertEqual(returned.state.accepted.candidate, HIGH)
self.assertIsNone(alternate.state.pending)
@@ -666,7 +681,7 @@ class TestAcceptance(unittest.TestCase):
qualified = AcceptedLimit(HIGH, "old", 0)
self.assertIs(new_session("drive", qualified).accepted, qualified)
def test_explicit_adopt_clears_only_matching_rejection(self):
def test_explicit_adopt_of_different_limit_clears_rejection(self):
first_reject = self.act(self.pending().state, ActionKind.REJECT)
other = replace(LOW, speed_mps=19.0)
second_pending = self.pending(candidate=other, state=first_reject.state, now_ns=2 * NS)
@@ -677,29 +692,23 @@ class TestAcceptance(unittest.TestCase):
self.assertEqual(adopted.basis, "driver_adopt")
self.assertTrue(adopted.adoption_receipt.consumed)
self.assertIsNone(adopted.action_receipt)
self.assertEqual(adopted.state.rejected, frozenset())
self.assertIsNone(adopted.state.rejected)
self.assertEqual(adopted.history_write.candidate, LOW)
self.assertEqual(adopted.control_target_mps, LOW.speed_mps)
self.assertIsNone(adopted.state.pending)
self.assertTrue(adopted.adoption.clear_override)
self.assertEqual(adopted.adoption.session_id, "drive")
self.assertEqual(adopted.adoption.action_sequence_id, 2)
self.assertEqual(adopted.adoption.presentation_id, request.presentation_id)
self.assertEqual(adopted.adoption.accepted, adopted.state.accepted)
self.assertEqual(adopted.adoption.reconciliation_speed_mps, LOW.speed_mps)
still_rejected = step(adopted.state, valid(other), ACTIVE, Policy(), now_ns=6 * NS)
self.assertEqual(still_rejected.basis, "pending")
self.assertEqual(still_rejected.control_target_mps, LOW.speed_mps)
self.assertEqual(adopted.adoption_receipt.sequence_id, request.sequence_id)
self.assertEqual(adopted.state.accepted, adopted.history_write)
next_offer = self.pending(candidate=other, state=adopted.state, now_ns=6 * NS)
self.assertEqual(next_offer.control_target_mps, LOW.speed_mps)
def test_adopt_already_accepted_candidate_still_emits_reconciliation(self):
def test_adopt_already_accepted_candidate_keeps_control_target(self):
shown = step(saved(), valid(HIGH), ACTIVE, Policy(), now_ns=0)
for authority in (ACTIVE, replace(ACTIVE, mode=Mode.COMBINED, lateral_active=True)):
result = step(shown.state, valid(HIGH), authority, Policy(), now_ns=NS, adopt=self.adopt_request(shown.state))
self.assertTrue(result.adoption_receipt.consumed)
self.assertIsNone(result.history_write)
self.assertEqual(result.state.accepted, shown.state.accepted)
self.assertTrue(result.adoption.clear_override)
self.assertEqual(result.adoption.reconciliation_speed_mps, HIGH.speed_mps)
self.assertEqual(result.control_target_mps, HIGH.speed_mps)
def test_explicit_adopt_clears_same_speed_rejection_after_provider_change(self):
rejected = self.act(self.pending().state, ActionKind.REJECT)
@@ -718,7 +727,7 @@ class TestAcceptance(unittest.TestCase):
result = step(pending.state, valid(LOW), ACTIVE, Policy(), now_ns=CONFIRMATION_NS, adopt=self.adopt_request(pending.state))
self.assertEqual(result.basis, "driver_adopt")
self.assertEqual(result.state.accepted.candidate, LOW)
self.assertNotIn(LOW, result.state.rejected)
self.assertIsNone(result.state.rejected)
def test_adopt_requires_prior_presentation_even_when_new_id_is_guessed(self):
guessed = AdoptRequest("drive", 0, 1, LOW)
@@ -726,27 +735,30 @@ class TestAcceptance(unittest.TestCase):
self.assertEqual(unseen.state.presentation.presentation_id, guessed.presentation_id)
self.assertFalse(unseen.adoption_receipt.consumed)
self.assertEqual(unseen.adoption_receipt.status, "different_presentation")
self.assertIsNone(unseen.adoption)
self.assertIsNone(unseen.history_write)
replay = step(unseen.state, valid(LOW), ACTIVE, Policy(), now_ns=NS, adopt=guessed)
self.assertEqual(replay.adoption_receipt.status, "already_seen")
self.assertIsNone(replay.adoption)
fresh = step(replay.state, valid(LOW), ACTIVE, Policy(), now_ns=2 * NS, adopt=replace(guessed, sequence_id=1))
self.assertTrue(fresh.adoption_receipt.consumed)
def test_adopt_context_expires_on_source_change_or_loss(self):
def test_adopt_requires_live_evidence_and_an_unchanged_presentation(self):
shown = self.pending()
request = self.adopt_request(shown.state)
for observation in (valid(replace(LOW, source="map")), ABSENT, Observation(ObservationKind.UNKNOWN), Observation(ObservationKind.STALE)):
with self.subTest(observation=observation):
changed = step(shown.state, observation, ACTIVE, Policy(), now_ns=NS, adopt=request)
self.assertFalse(changed.adoption_receipt.consumed)
self.assertIsNone(changed.adoption)
self.assertIsNone(changed.history_write)
replayed = step(changed.state, valid(LOW), ACTIVE, Policy(), now_ns=2 * NS, adopt=request)
self.assertFalse(replayed.adoption_receipt.consumed)
returned = step(changed.state, valid(LOW), ACTIVE, Policy(), now_ns=2 * NS, adopt=replace(request, sequence_id=1))
self.assertFalse(returned.adoption_receipt.consumed)
self.assertIsNone(returned.adoption)
self.assertNotEqual(returned.state.presentation.presentation_id, request.presentation_id)
if observation.kind is ObservationKind.VALID:
self.assertFalse(returned.adoption_receipt.consumed)
self.assertNotEqual(returned.state.presentation.presentation_id, request.presentation_id)
else:
self.assertTrue(returned.adoption_receipt.consumed)
self.assertEqual(returned.state.accepted.candidate, LOW)
self.assertEqual(returned.state.presentation.presentation_id, request.presentation_id)
def test_adopt_exact_candidate_cannot_be_swapped_under_existing_context(self):
shown = self.pending()
@@ -756,7 +768,6 @@ class TestAcceptance(unittest.TestCase):
result = step(shown.state, valid(LOW), ACTIVE, Policy(), now_ns=NS, adopt=request)
self.assertFalse(result.adoption_receipt.consumed)
self.assertEqual(result.adoption_receipt.status, "different_presentation")
self.assertIsNone(result.adoption)
def test_adopt_is_recorded_for_any_authority_but_only_commands_with_system_long(self):
rejected = self.act(self.pending().state, ActionKind.REJECT)
@@ -770,16 +781,14 @@ class TestAcceptance(unittest.TestCase):
result = step(rejected.state, valid(LOW), authority, Policy(), now_ns=2 * NS, adopt=request)
self.assertTrue(result.adoption_receipt.consumed)
self.assertEqual(result.adoption_receipt.status, "adopt")
self.assertNotIn(LOW, result.state.rejected)
self.assertIsNone(result.state.rejected)
self.assertEqual(result.state.accepted.candidate, LOW)
self.assertIsNone(result.adoption)
self.assertIsNone(result.control_target_mps)
self.assertEqual(result.state.last_action_sequence, 1)
display = step(rejected.state, valid(LOW), ACTIVE, Policy(display_only=True), now_ns=2 * NS, adopt=request)
self.assertFalse(display.adoption_receipt.consumed)
self.assertEqual(display.adoption_receipt.status, "display_only")
self.assertIn(LOW, display.state.rejected)
self.assertIsNone(display.adoption)
self.assertEqual(display.state.rejected, LOW)
self.assertIsNone(display.history_write)
def test_adopt_foreign_session_does_not_advance_watermark(self):
@@ -788,7 +797,6 @@ class TestAcceptance(unittest.TestCase):
result = step(shown.state, valid(LOW), ACTIVE, Policy(), now_ns=NS, adopt=foreign)
self.assertEqual(result.adoption_receipt.status, "foreign_session")
self.assertEqual(result.state.last_action_sequence, -1)
self.assertIsNone(result.adoption)
accepted = step(result.state, valid(LOW), ACTIVE, Policy(), now_ns=2 * NS, adopt=self.adopt_request(result.state))
self.assertTrue(accepted.adoption_receipt.consumed)
@@ -806,7 +814,6 @@ class TestAcceptance(unittest.TestCase):
self.assertTrue(fresh.action_receipt.consumed)
old_adopt = step(fresh.state, valid(LOW), ACTIVE, Policy(), now_ns=4 * NS, adopt=request)
self.assertFalse(old_adopt.adoption_receipt.consumed)
self.assertIsNone(old_adopt.adoption)
def test_ambiguous_action_and_adopt_fail_closed_and_watermark_both(self):
shown = self.pending()
@@ -820,7 +827,6 @@ class TestAcceptance(unittest.TestCase):
self.assertFalse(result.adoption_receipt.consumed)
self.assertEqual(result.state.last_action_sequence, 7)
self.assertIsNone(result.control_target_mps)
self.assertIsNone(result.adoption)
self.assertIsNone(result.history_write)
self.assertEqual(result.state.accepted.candidate, HIGH)
self.assertIsNone(result.state.presentation)
@@ -833,14 +839,12 @@ class TestAcceptance(unittest.TestCase):
result = step(shown.state, valid(LOW), ACTIVE, Policy(), now_ns=11 * NS, adopt=malformed)
self.assertTrue(result.errors)
self.assertIsNone(result.adoption_receipt)
self.assertIsNone(result.adoption)
self.assertIsNone(result.control_target_mps)
self.assertEqual(result.state.last_action_sequence, -1)
reversed_clock = step(shown.state, valid(LOW), ACTIVE, Policy(), now_ns=9 * NS, adopt=request)
self.assertTrue(reversed_clock.errors)
self.assertFalse(reversed_clock.adoption_receipt.consumed)
self.assertEqual(reversed_clock.state.last_action_sequence, 1)
self.assertIsNone(reversed_clock.adoption)
def test_invalid_presentation_state_is_inert(self):
shown = self.pending()
@@ -849,7 +853,6 @@ class TestAcceptance(unittest.TestCase):
result = step(state, valid(LOW), ACTIVE, Policy(), now_ns=NS, adopt=self.adopt_request(shown.state))
self.assertTrue(result.errors)
self.assertIsNone(result.control_target_mps)
self.assertIsNone(result.adoption)
self.assertIsNone(result.history_write)
@@ -41,14 +41,14 @@ class TestActionArbitration(unittest.TestCase):
self.assertEqual(delayed.change.started_at_ns, 1)
self.assertEqual(delayed.change.context_id, "accepted-context-1")
def test_adoption_proposal_and_effect_keep_originating_action(self):
def test_adoption_receipt_and_effect_keep_originating_action(self):
shown = accept.step(accept.new_session(SESSION), OBSERVATION, AUTHORITY, accept.Policy(), now_ns=0)
request = accept.AdoptRequest(SESSION, 1, shown.state.presentation.presentation_id, CANDIDATE)
adopted = accept.step(shown.state, OBSERVATION, AUTHORITY, accept.Policy(), now_ns=2, adopt=request)
ledger = arb.resolve_acceptance(begin(), 1, adopted, now_ns=2).state
result = change(ledger, previous=25.0, selected=adopted.adoption.reconciliation_speed_mps, now=10_000_000_000)
result = change(ledger, previous=25.0, selected=adopted.control_target_mps, now=10_000_000_000)
self.assertEqual(result.change.disposition, arb.Disposition.SLC_CONSUMED)
self.assertEqual(result.change.action_id, adopted.adoption.action_sequence_id)
self.assertEqual(result.change.action_id, adopted.adoption_receipt.sequence_id)
def test_final_unconsumed_receipt_does_not_swallow_cruise_input(self):
shown = accept.step(accept.new_session(SESSION), OBSERVATION, AUTHORITY, accept.Policy(), now_ns=0)
@@ -1,4 +1,5 @@
"""Mapped previews through the actual runtime, confirmations and Card guards."""
from collections import deque
from dataclasses import replace
from pathlib import Path
import base64
@@ -6,19 +7,22 @@ import json
import pytest
from openpilot.cereal import messaging
from openpilot.common.params import Params
from openpilot.selfdrive.car.cruise import VCruiseHelper
from openpilot.starpilot.speed_limits import acceptance as acc, physical_actions as pa
from openpilot.starpilot.speed_limits import acceptance as acc, physical_actions
from openpilot.starpilot.speed_limits.map_source import decode_event
from openpilot.starpilot.speed_limits.map_observation import MapSource, copy_frame
from openpilot.starpilot.speed_limits.runtime import Action, CruiseEvent, Runtime
from openpilot.starpilot.speed_limits.runtime_settings import parse, read_params
from openpilot.starpilot.speed_limits.runtime_settings import ADVANCE_KEYS, parse, read_params
from openpilot.starpilot.speed_limits.speed_domain import OffsetBand, OffsetSchedule
from openpilot.starpilot.speed_limits.tests.test_maps_runtime import packet
from openpilot.starpilot.speed_limits.tests.test_runtime_replay import ReplaySM
from openpilot.starpilot.speed_limits.vision.observation import MODEL_ID
from openpilot.starpilot.ui.onroad_state import speed_limit_from_message
from openpilot.starpilot.ui.tests.test_slc_ui_runtime import START, replay_inputs
from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed
from opendbc.car.structs import car
BOUNDARY = 'a' * 64
@@ -26,24 +30,26 @@ STEP = 50_000_000
class Drive:
def __init__(self, *, confirm=False, seconds=5):
def __init__(self, *, confirm=False, higher=5, lower=5):
self.sm, self.cp = replay_inputs()
self.now = START
self.sm['carState'].canValid = True
self.sm['carState'].vCruise = self.sm['carState'].vCruiseCluster = 79.2
settings = replace(parse({'SpeedLimitController': True, 'SLCPriority1': 'Maps',
'SLCPriority2': 'Dashboard', 'SLCAdvanceSeconds': seconds}), offsets=OffsetSchedule((OffsetBand(0., None, 2.),)))
'SLCPriority2': 'Dashboard', 'SLCAdvanceHigherSeconds': higher, 'SLCAdvanceLowerSeconds': lower}),
offsets=OffsetSchedule((OffsetBand(0., None, 2.),)))
self.runtime = Runtime(settings, session_id='drive', is_metric=True)
self.tick(target=0.)
assert self.runtime.state.acceptance.accepted.candidate.speed_mps == 20.
self.runtime.settings = replace(settings, acceptance=acc.Policy(confirm_lower=confirm, confirm_higher=confirm))
def tick(self, *, current=20., target=15., distance=60., way=1, target_way=2, speed=20., stopped=False,
alive=True, producer=41, boundary=BOUNDARY, action=None, event=None, at=None, dashboard=None):
alive=True, producer=41, boundary=BOUNDARY, action=None, event=None, at=None, dashboard=None, vision_speed=None,
road_status='matchedLimit'):
self.now = self.now + STEP if at is None else at
self.sm.advance(self.now)
boot = self.now + 4_000_000_000
road = packet(speedLimit=current, previewSpeedLimit=target, previewDistance=distance,
road = packet(roadStatus=road_status, speedLimit=current, previewSpeedLimit=target, previewDistance=distance,
previewBoundaryId=boundary if target else '', previewWayId=target_way, wayId=way, producerSession=producer,
gpsMonoTime=boot-50_000_000, computedMonoTime=boot)
road.logMonoTime = boot
@@ -56,53 +62,79 @@ class Drive:
source = self.sm['slcDashboardObservation']
source.status, source.speedMps = ('unknown', 0.) if dashboard is None else ('valid', dashboard)
source.observedMonoTime, source.validUntilMonoTime = self.now, self.now+1_000_000_000
if vision_speed is not None or 'slcVisionObservation' in self.sm.data:
camera = messaging.new_message('slcVisionObservation').slcVisionObservation
vision = camera.init('vision')
vision.producerSessionId, vision.status, vision.stream, vision.modelId = 'camera', 'valid', 'road', MODEL_ID
vision.speedMps, vision.confidence, vision.supportCount = vision_speed or 0., .9, 2
vision.episode, vision.frameId = 1, self.now // STEP
vision.observedMonoTime, vision.validUntilMonoTime, vision.cameraFrameEofBootTime = self.now, self.now + STEP, boot
self.sm.data['slcVisionObservation'] = camera
self.sm.logMonoTime['slcVisionObservation'] = self.now
for values in (self.sm.valid, self.sm.alive, self.sm.updated):
values['slcVisionObservation'] = vision_speed is not None
return self.runtime.step(self.sm, self.cp, now_ns=self.now, now_boot_ns=boot, request=action, cruise_event=event)
def action(self, state, kind):
return Action(self.runtime.session_id, self.now+STEP, state.decisionId, state.presentationId, kind)
def apply(self, output):
command = output.command.slcCruiseCommand
card_sm = ReplaySM({'slcState': output.message.slcState}, self.now)
approved = pa.state_current(card_sm, None, 'card-producer', self.now)
previous = self.sm['carState'].vCruise / 3.6
assert pa.command_applicable(command, approved, None, 'card-producer', self.now,
previous, long_active=True, button_event=False)
helper = VCruiseHelper(self.cp)
helper.v_cruise_kph = helper.v_cruise_cluster_kph = previous*3.6
helper.apply_slc_target(command.targetMps, True)
self.sm['carState'].vCruise = helper.v_cruise_kph
self.sm['carState'].vCruiseCluster = helper.v_cruise_cluster_kph
event = CruiseEvent(1, self.now+STEP, previous, helper.v_cruise_kph/3.6, 'unknown',
'card-producer', self.now+STEP, 'commandApplied', self.runtime.session_id, command_id=command.commandId)
return self.tick(event=event)
from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner
assert output.result.ceiling is not None
selected = self.sm['carState'].vCruise
LongitudinalPlanner(self.cp).update(self.sm, cruise_ceiling=output.result.ceiling)
assert self.sm['carState'].vCruise == selected
return self.tick()
@pytest.mark.parametrize('target', [15., 25.])
def test_threshold_uses_physical_speed_for_both_directions(target):
drive = Drive()
outside = drive.tick(target=target, distance=100.01)
assert outside.command is None and not outside.message.slcState.upcomingBoundaryId
assert not outside.message.slcState.upcomingBoundaryId
inside = drive.tick(target=target, distance=100.)
state = inside.message.slcState
assert state.speedLimit == 20. and state.upcomingSpeedLimit == target
assert state.acceptedSpeedLimit == target and state.acceptedAdjustedLimit == target+2.
assert inside.command.slcCruiseCommand.targetMps == target+2.
assert inside.result.ceiling.speed_mps == target+2.
@pytest.mark.parametrize('target,higher,lower,threshold', [
(15., 1, 7, 140.), (25., 1, 7, 20.), (15., 0, 7, 140.), (25., 7, 0, 140.),
])
def test_direction_selects_its_own_time_before_offsets(target, higher, lower, threshold):
drive = Drive(higher=higher, lower=lower)
# Reversed offset targets must not change the posted-limit direction.
drive.runtime.update_offsets(OffsetSchedule((OffsetBand(0., 18., 10.), OffsetBand(18., None, -2.))))
outside = drive.tick(target=target, distance=threshold+.01)
assert not outside.message.slcState.upcomingBoundaryId
inside = drive.tick(target=target, distance=threshold)
assert inside.message.slcState.upcomingSpeedLimit == target
assert inside.message.slcState.acceptedSpeedLimit == target
@pytest.mark.parametrize('target,higher,lower', [(15., 5, 0), (25., 0, 5)])
def test_each_direction_can_be_off_without_disabling_current_limit_adoption(target, higher, lower):
drive = Drive(higher=higher, lower=lower)
before = drive.tick(target=target, distance=1.).message.slcState
assert not before.upcomingBoundaryId and before.acceptedSpeedLimit == 20.
crossed = drive.tick(current=target, target=0., way=2).message.slcState
assert crossed.acceptedSpeedLimit == target
@pytest.mark.parametrize('speed,stopped', [(0., True), (0., False), (20., True)])
def test_no_first_offer_at_standstill(speed, stopped):
drive = Drive()
output = drive.tick(speed=speed, stopped=stopped, distance=1.)
assert output.command is None and not output.message.slcState.upcomingBoundaryId
assert not output.message.slcState.upcomingBoundaryId
def test_off_preserves_current_limit_behavior():
drive = Drive(seconds=0)
drive = Drive(higher=0, lower=0)
assert drive.tick().message.slcState.acceptedSpeedLimit == 20.
output = drive.tick(current=15., target=0., way=2)
assert output.message.slcState.acceptedSpeedLimit == 15.
assert output.command is not None
assert output.result.ceiling is not None
@pytest.mark.parametrize('primary,dashboard,target,early', [
@@ -116,10 +148,45 @@ def test_preview_respects_existing_source_selection(primary, dashboard, target,
output = drive.tick(dashboard=dashboard, target=target)
assert bool(output.message.slcState.upcomingBoundaryId) == early
if early:
command, state = output.command.slcCruiseCommand, output.message.slcState
assert command.source == 'map'
assert pa.command_applicable(command, state, None, 'card-producer', drive.now,
22., long_active=True, button_event=False)
assert output.message.slcState.acceptedSource == 'map'
assert output.result.ceiling.speed_mps == target + 2.
def vision_only_settings():
return parse({'SpeedLimitController': True, 'SLCPriority1': 'Vision', 'SLCPriority2': 'Off',
'SLCPriority3': 'Off', 'SLCAdvanceHigherSeconds': 5, 'SLCAdvanceLowerSeconds': 5})
@pytest.mark.parametrize('maps_alive', [False, True])
def test_vision_only_ignores_map_readings_previews_and_road_changes(maps_alive):
drive = Drive()
drive.runtime = Runtime(vision_only_settings(), vision_enabled=True, vision_control_qualified=True)
for vision_speed, expected, source, road in ((16., 16., 'vision', {}),
(None, 16., 'none', {'way': 3, 'target': 25.}),
(18., 18., 'vision', {'way': 4, 'target': 0.})):
output = drive.tick(vision_speed=vision_speed, alive=maps_alive, **road)
state = output.message.slcState
assert state.source == source and state.acceptedSource == 'vision'
assert state.effectiveCap == expected
assert not state.upcomingBoundaryId and drive.runtime.advance.encounter is None
@pytest.mark.parametrize('vision_at_switch', [False, True])
def test_removing_maps_retires_the_preview_and_resumes_vision_decisions(vision_at_switch):
drive = Drive()
early = drive.tick(target=25.).message.slcState
assert early.upcomingBoundaryId and early.acceptedSpeedLimit == 25.
settings = vision_only_settings()
drive.runtime.update_offsets(settings.offsets)
drive.runtime.settings = replace(settings, acceptance=acc.Policy())
drive.runtime.vision_enabled = drive.runtime.vision_control_qualified = True
output = drive.tick(vision_speed=20. if vision_at_switch else None, way=3, target=0.)
state = output.message.slcState
assert drive.runtime.advance.encounter is None and not state.upcomingBoundaryId
assert not state.hasPending and output.result.ceiling.speed_mps == 20.
output = drive.tick(vision_speed=20., way=3, target=0.)
assert output.message.slcState.source == 'vision' and not output.message.slcState.hasPending
assert output.result.ceiling.speed_mps == 20.
def test_reverse_and_inactive_control_do_not_start_advance():
@@ -145,7 +212,7 @@ def test_confirmation_latches_on_slowdown_and_stop_then_times_out_once():
expired = drive.tick(at=start+acc.CONFIRMATION_NS, speed=0., stopped=True).message.slcState
assert expired.actionStatus in ('', 'timeout_reject') and not expired.hasPending
crossed = drive.tick(current=15., target=0., way=2)
assert not crossed.message.slcState.hasPending and crossed.command is None
assert not crossed.message.slcState.hasPending
@pytest.mark.parametrize('kind', ['accept', 'reject'])
@@ -157,25 +224,63 @@ def test_exact_deadline_action_wins_and_crossing_does_not_repeat(kind):
action = drive.action(state, kind)
output = drive.tick(at=start+acc.CONFIRMATION_NS, action=action)
assert output.message.slcState.actionStatus == kind
assert (output.command is not None) == (kind == 'accept')
if kind == 'accept':
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 61.2
assert output.result.ceiling.speed_mps == (17. if kind == 'accept' else 22.)
crossed = drive.tick(current=15., target=0., way=2)
assert not crossed.message.slcState.hasPending and crossed.command is None
assert not crossed.message.slcState.hasPending
def test_recovery_renews_presentation_without_restarting_confirmation():
@pytest.mark.parametrize('producer', [41, 42])
def test_recovery_keeps_deadline_and_retires_actions_only_for_a_new_producer(producer):
drive = Drive(confirm=True)
first = drive.tick().message.slcState
start = drive.runtime.advance.encounter.started_ns
assert start is not None
lost = drive.tick(alive=False)
assert lost.message.slcState.hasPending and lost.command is None
assert lost.message.slcState.decisionId == first.decisionId and lost.message.slcState.presentationId == 0
recovered = drive.tick(at=start+9_000_000_000, producer=42).message.slcState
assert recovered.hasPending and recovered.presentationId != first.presentationId
assert lost.message.slcState.hasPending
assert lost.message.slcState.decisionId == first.decisionId and lost.message.slcState.presentationId == first.presentationId
recovered = drive.tick(at=start+9_000_000_000, producer=producer).message.slcState
assert recovered.hasPending and (recovered.presentationId == first.presentationId) == (producer == 41)
expired = drive.tick(at=start+acc.CONFIRMATION_NS+1)
assert not expired.message.slcState.hasPending and expired.command is None
assert not expired.message.slcState.hasPending
@pytest.mark.parametrize('kind', ['accept', 'reject'])
@pytest.mark.parametrize('gap', [{'alive': False}, {'target': 0.}], ids=['source-lost', 'preview-missing'])
@pytest.mark.parametrize('wheel', [False, True], ids=['touch', 'wheel'])
def test_source_gap_keeps_early_confirmation_actionable_and_does_not_repeat_at_the_boundary(kind, gap, wheel):
drive = Drive(confirm=True)
first = drive.tick().message.slcState
lost = drive.tick(**gap).message.slcState
assert lost.presentationId == first.presentationId
if wheel:
pending = physical_actions.pending_confirmation(ReplaySM({'slcState': lost}, drive.now), drive.now, long_active=True)
assert pending is not None and pending.presentation_id == first.presentationId
drive.cp.pcmCruise = False
helper = VCruiseHelper(drive.cp)
helper.v_cruise_kph = drive.sm['carState'].vCruise
button = 'accelCruise' if kind == 'accept' else 'decelCruise'
cs = car.CarState(cruiseState={'available': True}, buttonEvents=[car.CarState.ButtonEvent(type=button, pressed=True)])
helper.update_v_cruise(cs, True, True, pending, slc_enabled=True)
consumed = helper.slc_consumed_button
assert consumed is not None
cs.buttonEvents = [car.CarState.ButtonEvent(type=button, pressed=False)]
helper.update_v_cruise(cs, True, True, slc_enabled=True)
assert helper.v_cruise_kph == drive.sm['carState'].vCruise
stamp = drive.now + STEP
event = CruiseEvent(1, stamp, 0., 0., consumed.button, 'card-producer', stamp,
'confirmationAccept' if kind == 'accept' else 'confirmationReject',
consumed.pending.session_id, consumed.pending.decision_id, consumed.pending.presentation_id)
output = drive.tick(**gap, event=event)
else:
output = drive.tick(**gap, action=drive.action(first, kind))
assert not output.result.errors and output.message.slcState.actionStatus == kind
assert not output.message.slcState.hasPending
assert output.result.ceiling.speed_mps == (17. if kind == 'accept' else 22.)
encounter = drive.runtime.advance.encounter
assert (encounter.accepted_ns is not None) == (kind == 'accept')
assert encounter.declined == (kind == 'reject')
crossed = drive.tick(current=15., target=0., way=2)
assert not crossed.message.slcState.hasPending
def test_disengagement_cancels_early_confirmation_and_reengagement_gets_a_fresh_deadline():
@@ -186,7 +291,7 @@ def test_disengagement_cancels_early_confirmation_and_reengagement_gets_a_fresh_
drive.sm['selfdriveState'].enabled = False
drive.sm['controlsState'].longControlState = 'off'
inactive = drive.tick(at=start+2_000_000_000)
assert not inactive.message.slcState.hasPending and inactive.command is None
assert not inactive.message.slcState.hasPending
assert not drive.runtime.advance.encounter.declined
drive.sm['carControl'].enabled = drive.sm['carControl'].longActive = True
drive.sm['selfdriveState'].enabled = True
@@ -196,18 +301,44 @@ def test_disengagement_cancels_early_confirmation_and_reengagement_gets_a_fresh_
assert resumed.pendingSinceMonoTime == drive.now
restarted = drive.now
stale_action = drive.tick(action=drive.action(first, 'accept'))
assert stale_action.message.slcState.hasPending and stale_action.command is None
assert stale_action.message.slcState.hasPending
assert drive.tick(at=restarted+9_000_000_000).message.slcState.hasPending
assert not drive.tick(at=restarted+acc.CONFIRMATION_NS).message.slcState.hasPending
assert drive.runtime.advance.encounter.declined
def test_fresh_different_boundary_supersedes_without_accepting_stale_action():
@pytest.mark.parametrize('distance', [60., 160.])
def test_fresh_different_boundary_supersedes_without_accepting_stale_action(distance):
drive = Drive(confirm=True)
first = drive.tick().message.slcState
changed = drive.tick(boundary='b'*64, target=25., target_way=3, action=drive.action(first, 'accept'))
assert changed.message.slcState.hasPending and changed.message.slcState.decisionId != first.decisionId
assert changed.message.slcState.pendingSpeedLimit == 25. and changed.command is None
changed = drive.tick(boundary='b'*64, target=25., target_way=3, distance=distance, action=drive.action(first, 'accept'))
state = changed.message.slcState
assert state.acceptedSpeedLimit == 20.
assert state.hasPending == (distance == 60.)
assert state.decisionId != first.decisionId
if state.hasPending:
assert state.pendingSpeedLimit == 25.
else:
nearer = drive.tick(boundary='b'*64, target=25., target_way=3).message.slcState
assert nearer.hasPending and nearer.pendingSpeedLimit == 25.
assert nearer.decisionId != first.decisionId
def test_boundary_id_change_alone_does_not_disprove_the_latched_limit():
drive = Drive(confirm=True)
first = drive.tick().message.slcState
changed = drive.tick(boundary='b'*64, speed=5., distance=55.).message.slcState
assert changed.hasPending and changed.decisionId == first.decisionId
assert changed.pendingSinceMonoTime == first.pendingSinceMonoTime
def test_far_replacement_does_not_reopen_a_declined_limit_if_it_returns():
drive = Drive(confirm=True)
first = drive.tick().message.slcState
declined = drive.tick(action=drive.action(first, 'reject')).message.slcState
assert declined.actionStatus == 'reject'
assert not drive.tick(boundary='b'*64, target=25., target_way=3, distance=160.).message.slcState.hasPending
assert not drive.tick().message.slcState.hasPending
def test_short_zone_can_qualify_exit_immediately_without_hidden_dwell():
@@ -216,30 +347,36 @@ def test_short_zone_can_qualify_exit_immediately_without_hidden_dwell():
exit_offer = drive.tick(current=15., way=2, target=20., target_way=3, boundary='b'*64, distance=10.)
assert exit_offer.message.slcState.speedLimit == 15.
assert exit_offer.message.slcState.acceptedSpeedLimit == 20.
assert exit_offer.command.slcCruiseCommand.targetMps == 22.
assert exit_offer.result.ceiling.speed_mps == 22.
assert drive.runtime.history_limit.candidate.speed_mps == 15.
drive.runtime.reset()
assert drive.tick(alive=False).message.slcState.acceptedSpeedLimit == 15.
def test_time_edits_keep_decision_and_off_withdraws_without_set_rollback():
drive = Drive(confirm=True)
first = drive.tick().message.slcState
drive.runtime.update_advance(1)
drive.runtime.update_advance(5, 1)
second = drive.tick(distance=60.).message.slcState
assert second.decisionId == first.decisionId and second.pendingSinceMonoTime == first.pendingSinceMonoTime
drive.runtime.update_advance(0)
drive.runtime.update_advance(5, 0)
assert not drive.tick().message.slcState.hasPending
drive.runtime.update_advance(10)
drive.runtime.update_advance(5, 10)
assert not drive.tick().message.slcState.hasPending
assert drive.tick(current=15., target=0., way=2).message.slcState.hasPending
def test_applied_set_survives_off_source_loss_and_crossing_without_reissue():
@pytest.mark.parametrize('target', [15., 25.])
def test_applied_cap_survives_advance_off_source_loss_and_crossing(target):
drive = Drive()
applied = drive.apply(drive.tick())
assert applied.command is None and applied.message.slcState.commandStatus == 'applied'
drive.runtime.update_advance(0)
for changes in ({}, {'alive': False}, {'current': 15., 'target': 0., 'way': 2}):
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 140.
applied = drive.tick(target=target)
assert applied.result.ceiling.speed_mps == target+2.
drive.runtime.update_advance(0 if target > 20. else 5, 0 if target < 20. else 5)
for changes in ({'target': target}, {'target': target, 'alive': False}, {'current': target, 'target': 0., 'way': 2}):
output = drive.tick(**changes)
assert output.command is None and drive.sm['carState'].vCruise == 61.
assert drive.sm['carState'].vCruise == 140.
assert output.result.ceiling.speed_mps == target+2.
def test_wheel_adopt_keeps_meaning_the_current_displayed_limit():
@@ -249,7 +386,7 @@ def test_wheel_adopt_keeps_meaning_the_current_displayed_limit():
assert not adopted.result.errors
assert adopted.message.slcState.acceptedSpeedLimit == 20.
assert adopted.message.slcState.actionStatus == 'adopt'
assert adopted.command.slcCruiseCommand.targetMps == 22.
assert adopted.result.ceiling.speed_mps == 22.
assert not adopted.message.slcState.upcomingBoundaryId
@@ -257,20 +394,21 @@ def test_stale_current_adopt_cannot_cancel_or_replace_a_fresh_producer_proposal(
drive = Drive()
applied = drive.apply(drive.tick())
rejected = drive.tick(producer=42, action=drive.action(applied.message.slcState, 'adopt'))
assert not rejected.result.errors and rejected.command is None
assert not rejected.result.errors
assert rejected.message.slcState.acceptedSpeedLimit == 15.
assert rejected.message.slcState.upcomingBoundaryId == BOUNDARY
assert not drive.runtime.advance.encounter.disabled
def test_manual_set_override_and_current_adoption_survive_boundary():
def test_driver_ceiling_changes_keep_advance_and_explicit_current_adoption():
drive = Drive()
drive.apply(drive.tick())
previous = drive.sm['carState'].vCruise / 3.6
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 80.
event = CruiseEvent(2, drive.now+STEP, previous, 80./3.6, 'accel', 'card-producer', drive.now+STEP)
overridden = drive.tick(event=event)
assert overridden.message.slcState.driverOverrideActive and overridden.command is None
assert not overridden.message.slcState.driverOverrideActive
assert overridden.result.ceiling.speed_mps == 17.
withdrawn = drive.tick()
assert withdrawn.message.slcState.presentationId > 0
current = drive.tick(action=drive.action(withdrawn.message.slcState, 'adopt'))
@@ -289,25 +427,14 @@ def test_offset_edit_uses_upcoming_band_without_changing_decision_or_current_sig
state = output.message.slcState
assert state.acceptedMonoTime == accepted_at and not state.hasPending
assert state.speedLimit == 20. and state.presentationAdjustedLimit == 23.
assert state.acceptedAdjustedLimit == 16. and output.command.slcCruiseCommand.targetMps == 16.
assert state.acceptedAdjustedLimit == 16. and output.result.ceiling.speed_mps == 16.
def test_upcoming_commands_bind_boundary_and_preserve_card_fences():
def test_upcoming_acceptance_never_commands_card_set_speed():
drive = Drive()
output = drive.tick()
state, command = output.message.slcState, output.command.slcCruiseCommand
def applies(**changes):
return pa.command_applicable(command, state, None, 'card-producer', drive.now,
changes.pop('selected', 22.), long_active=changes.pop('active', True), button_event=changes.pop('button', False))
assert applies()
assert not applies(selected=23.) and not applies(active=False) and not applies(button=True)
command.upcomingBoundaryId = 'b' * 64
assert not applies()
command.upcomingBoundaryId = ''
assert not applies()
command.upcomingBoundaryId = BOUNDARY
state.upcomingSpeedLimit = float('nan')
assert not applies()
assert output.result.ceiling.speed_mps == 17.
assert drive.sm['carState'].vCruise == pytest.approx(79.2)
def test_existing_confirmation_resolver_shows_proposal_without_changing_current_sign():
@@ -349,48 +476,71 @@ def test_actual_go_fixture_qualifies_current_and_upcoming_fields_with_host_schem
assert source.upcoming.boundary_id == BOUNDARY and source.upcoming.way_id == 2 and source.upcoming.distance_m == 60.
@pytest.mark.parametrize('raw', [b'0', b'5', b'10', b'-1', b'11', b'1.5', b'broken', b'\xff', b'5\n', b'05'])
def test_saved_advance_values_are_optional_bounded_and_not_rewritten(tmp_path, raw):
@pytest.mark.parametrize('key', ADVANCE_KEYS)
@pytest.mark.parametrize('raw', [b'0', b'1', b'5', b'10', b'-1', b'11', b'1.5', b'broken', b'\xff', b'5\n', b'05'])
def test_saved_advance_values_are_independent_bounded_and_not_rewritten(tmp_path, key, raw):
params = Params(str(tmp_path))
params.put_bool('SpeedLimitController', True, block=True)
path = Path(params.get_param_path('SLCAdvanceSeconds'))
other = next(name for name in ADVANCE_KEYS if name != key)
params.put(other, 7, block=True)
path = Path(params.get_param_path(key))
path.write_bytes(raw)
settings = read_params(params)
assert settings.enabled and not settings.errors
assert settings.advance_seconds == ({b'5': 5, b'10': 10}.get(raw, 0))
values = dict(zip(ADVANCE_KEYS, (settings.advance_higher_seconds, settings.advance_lower_seconds), strict=True))
assert values[key] == ({b'1': 1, b'5': 5, b'10': 10}.get(raw, 0))
assert values[other] == 7
assert path.read_bytes() == raw
def test_time_only_lifecycle_refresh_keeps_session_decision_deadline_and_decline(tmp_path):
def test_timer_rejects_symlink_without_disabling_other_direction(tmp_path):
params = Params(str(tmp_path))
params.put_bool('SpeedLimitController', True, block=True)
params.put('SLCAdvanceLowerSeconds', 6, block=True)
source = tmp_path / 'linked-value'
source.write_bytes(b'9')
Path(params.get_param_path('SLCAdvanceHigherSeconds')).symlink_to(source)
settings = read_params(params)
assert settings.enabled
assert (settings.advance_higher_seconds, settings.advance_lower_seconds) == (0, 6)
@pytest.mark.parametrize('target,key', [(15., 'SLCAdvanceLowerSeconds'), (25., 'SLCAdvanceHigherSeconds')])
def test_time_only_lifecycle_refresh_keeps_session_decision_deadline_and_decline(tmp_path, target, key):
from openpilot.selfdrive.controls.plannerd import SavedSlcLifecycle
params = Params(str(tmp_path))
for key, value in {'SpeedLimitController': True, 'SLCConfirmation': True, 'IsMetric': True}.items():
params.put_bool(key, value, block=True)
for name, value in {'SpeedLimitController': True, 'IsMetric': True}.items():
params.put_bool(name, value, block=True)
params.put('SLCConfirmation', 1, block=True)
params.put('SLCPriority1', 'Map Data', block=True)
params.put('SLCAdvanceSeconds', 5, block=True)
params.put(key, 5, block=True)
drive = Drive()
lifecycle = SavedSlcLifecycle(params, drive.cp, {'SLC_REPLAY_RUNTIME': '1'})
runtime, _ = lifecycle.refresh(START)
drive.runtime = runtime
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 72.
drive.tick(target=0.)
first = drive.tick().message.slcState
first = drive.tick(target=target).message.slcState
assert first.hasPending
params.put('SLCAdvanceSeconds', 1, block=True)
reject = drive.action(first, 'reject')
queued = deque([reject])
params.put(key, 1, block=True)
now = drive.now+1_000_000_000
assert lifecycle.refresh(now)[0] is runtime
pending = drive.tick(at=now).message.slcState
assert lifecycle.refresh(now, pending_actions=queued)[0] is runtime
pending = drive.tick(at=now, target=target).message.slcState
assert pending.decisionId == first.decisionId and pending.pendingSinceMonoTime == first.pendingSinceMonoTime
drive.tick(action=drive.action(pending, 'reject'))
params.put('SLCAdvanceSeconds', 10, block=True)
assert list(queued) == [reject]
drive.tick(target=target, action=queued.popleft())
params.put(key, 10, block=True)
now = drive.now+1_000_000_000
assert lifecycle.refresh(now)[0] is runtime
assert not drive.tick(at=now).message.slcState.hasPending
crossed = drive.tick(current=15., target=0., way=2)
assert not crossed.message.slcState.hasPending and crossed.command is None
assert not drive.tick(at=now, target=target).message.slcState.hasPending
crossed = drive.tick(current=target, target=0., way=2)
assert not crossed.message.slcState.hasPending
def test_existing_settings_owner_saves_guards_and_repairs_one_timing_option(tmp_path):
@pytest.mark.parametrize('key', ADVANCE_KEYS)
def test_existing_settings_owner_saves_guards_and_repairs_each_timing_option(tmp_path, key):
from openpilot.starpilot.ui.feature_settings_owner import FeatureSettingsOwner
from openpilot.starpilot.ui.feature_settings_state import row_change, row_default
params = Params(str(tmp_path))
@@ -403,22 +553,422 @@ def test_existing_settings_owner_saves_guards_and_repairs_one_timing_option(tmp_
def rows():
return owner.snapshot('slc', parked=True, system_long=True, lateral_context=True, metric=True).rows
def row():
return next(item for item in rows() if item.key == 'SLCAdvanceSeconds')
return next(item for item in rows() if item.key == key)
keys = [item.key for item in rows()]
assert keys.index('SLCAdvanceSeconds') == keys.index('SLCConfirmation')+1
assert keys.index(ADVANCE_KEYS[0]) == keys.index('SLCConfirmation')+1
assert keys.index(ADVANCE_KEYS[1]) == keys.index(ADVANCE_KEYS[0])+1
assert 'SLCAdvanceSeconds' not in keys
assert row().available and row().value == 'Off'
request = row_change(row())
assert request is not None
assert owner.apply(request)
assert read_params(params).advance_seconds == 1
settings = read_params(params)
assert (settings.advance_higher_seconds, settings.advance_lower_seconds) == ((1, 0) if key == ADVANCE_KEYS[0] else (0, 1))
assert not owner.apply(request)
request = row_change(row())
assert request is not None
allowed[0] = False
assert not owner.apply(request)
allowed[0] = True
Path(params.get_param_path('SLCAdvanceSeconds')).write_bytes(b'broken')
Path(params.get_param_path(key)).write_bytes(b'broken')
assert row().choices == ('Off',)
request = row_default(row())
assert request is not None and owner.apply(request)
assert read_params(params).advance_seconds == 0 and read_params(params).enabled
settings = read_params(params)
assert settings.advance_higher_seconds == settings.advance_lower_seconds == 0 and settings.enabled
@pytest.mark.parametrize('target', [15., 25.])
def test_editing_the_other_direction_does_not_cancel_a_latched_choice(target):
drive = Drive(confirm=True)
first = drive.tick(target=target).message.slcState
drive.runtime.update_advance(5 if target > 20. else 0, 5 if target < 20. else 0)
retained = drive.tick(target=target, speed=0., stopped=True).message.slcState
assert retained.hasPending and retained.decisionId == first.decisionId
assert retained.pendingSinceMonoTime == first.pendingSinceMonoTime
drive.runtime.update_advance(0 if target > 20. else 5, 0 if target < 20. else 5)
assert not drive.tick(target=target).message.slcState.hasPending
@pytest.mark.parametrize('higher', [0, 5])
def test_revised_limit_at_same_boundary_retires_old_choice(higher):
drive = Drive(confirm=True, higher=higher, lower=5)
first = drive.tick(target=15.).message.slcState
assert first.hasPending
revised = drive.tick(target=25., action=drive.action(first, 'accept')).message.slcState
assert revised.acceptedSpeedLimit == 20.
assert revised.hasPending == bool(higher)
if higher:
assert revised.pendingSpeedLimit == 25.
assert revised.decisionId != first.decisionId
@pytest.mark.parametrize('boundary,way', [(BOUNDARY, 2), ('b'*64, 3)])
def test_ineligible_boundary_revision_keeps_the_applied_cap(boundary, way):
drive = Drive(higher=0, lower=5)
assert drive.tick(target=15.).message.slcState.acceptedSpeedLimit == 15.
revised = drive.tick(target=25., boundary=boundary, target_way=way).message.slcState
assert revised.acceptedSpeedLimit == 15.
assert not revised.upcomingBoundaryId
assert drive.tick(current=25., target=0., way=way).message.slcState.acceptedSpeedLimit == 25.
@pytest.mark.parametrize('target', [15., 25.])
@pytest.mark.parametrize('confirm', [False, True])
def test_confirmed_destination_corrects_an_early_cap_even_at_the_approach_speed(target, confirm):
drive = Drive(confirm=confirm)
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 108.
early = drive.tick(target=target).message.slcState
if confirm:
early = drive.tick(target=target, action=drive.action(early, 'accept')).message.slcState
assert early.acceptedSpeedLimit == target
for inputs in ({'alive': False}, {'target': 0.}):
held = drive.tick(**inputs).message.slcState
assert held.effectiveCap == target + 2.
assert not held.hasPending
arrived = drive.tick(current=20., target=0., way=2).message.slcState
assert not arrived.hasPending
assert arrived.acceptedSpeedLimit == 20.
assert arrived.effectiveCap == 22.
@pytest.mark.parametrize('confirm', [False, True])
def test_fresh_other_road_reverts_applied_preview_even_at_the_same_approach_speed(confirm):
drive = Drive(confirm=confirm)
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 108.
preview = drive.tick(target=25.).message.slcState
if confirm:
preview = drive.tick(target=25., action=drive.action(preview, 'accept')).message.slcState
assert preview.effectiveCap == 27.
assert drive.tick(target=25., way=3).message.slcState.effectiveCap == 27.
assert drive.tick(alive=False).message.slcState.effectiveCap == 27.
assert drive.tick(target=0., way=3).message.slcState.effectiveCap == 27.
departed = drive.tick(target=0., way=4)
assert departed.message.slcState.effectiveCap == 22.
assert not departed.message.slcState.hasPending
assert departed.message.slcState.acceptedSpeedLimit == 20.
def test_departure_keeps_the_preview_limit_when_a_preferred_live_source_confirms_it():
drive = Drive(confirm=True)
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 108.
drive.runtime.settings = replace(drive.runtime.settings,
selection=parse({'SLCPriority1': 'Dashboard', 'SLCPriority2': 'Maps'}).selection)
preview = drive.tick(target=15.).message.slcState
assert drive.tick(target=15., action=drive.action(preview, 'accept')).message.slcState.effectiveCap == 17.
for _ in range(2):
state = drive.tick(way=3, target=0., dashboard=15.).message.slcState
assert state.source == 'dashboard' and state.acceptedSpeedLimit == 15.
assert state.effectiveCap == 17. and not state.hasPending
def test_departure_restores_the_previous_cap_while_a_different_road_limit_waits():
drive = Drive(confirm=True)
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 108.
preview = drive.tick(target=25.).message.slcState
assert drive.tick(target=25., action=drive.action(preview, 'accept')).message.slcState.effectiveCap == 27.
# The preview still describes the same approach after a tile refresh.
assert drive.tick(target=25., way=3, boundary='b'*64).message.slcState.effectiveCap == 27.
state = drive.tick(current=16., way=4, target=0.).message.slcState
assert state.acceptedSpeedLimit == 20. and state.effectiveCap == 22.
assert state.hasPending and state.pendingSpeedLimit == 16.
accepted = drive.tick(current=16., way=4, target=0., action=drive.action(state, 'accept')).message.slcState
assert accepted.acceptedSpeedLimit == 16. and accepted.effectiveCap == 18.
def test_departure_without_previous_acceptance_drops_the_preview_cap():
drive = Drive(confirm=True)
drive.runtime = Runtime(drive.runtime.settings)
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 108.
current = drive.tick(target=0.).message.slcState
declined = drive.tick(target=0., action=drive.action(current, 'reject')).message.slcState
assert declined.actionStatus == 'reject' and not declined.hasAccepted
preview = drive.tick(target=25.).message.slcState
assert drive.tick(target=25., action=drive.action(preview, 'accept')).message.slcState.hasCeiling
state = drive.tick(current=16., way=3, target=0.).message.slcState
assert not state.hasAccepted and not state.hasCeiling
assert state.hasPending and state.pendingSpeedLimit == 16.
@pytest.mark.parametrize('accept_replacement', [False, True], ids=['pending', 'accepted'])
def test_departure_restores_72_after_90_preview_is_replaced_by_108(accept_replacement):
drive = Drive(confirm=True)
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 126.
drive.runtime.update_offsets(vision_only_settings().offsets)
first = drive.tick(target=25.).message.slcState
assert drive.tick(target=25., action=drive.action(first, 'accept')).message.slcState.effectiveCap == 25.
revised = {'target': 30., 'target_way': 3, 'boundary': 'b'*64}
replacement = drive.tick(**revised).message.slcState
assert replacement.hasPending and replacement.pendingSpeedLimit == 30.
if accept_replacement:
assert drive.tick(**revised, action=drive.action(replacement, 'accept')).message.slcState.effectiveCap == 30.
# A late response to the old preview must not accept the new road's limit.
state = drive.tick(current=16., way=4, target=0., action=drive.action(replacement, 'accept')).message.slcState
assert state.acceptedSpeedLimit == state.effectiveCap == 20.
assert state.hasPending and state.pendingSpeedLimit == 16.
assert drive.tick(current=16., way=4, target=0.).message.slcState.decisionId == state.decisionId
def test_far_revision_retires_only_the_unanswered_preview():
drive = Drive(confirm=True)
first = drive.tick(target=25.).message.slcState
drive.tick(target=25., action=drive.action(first, 'accept'))
pending = drive.tick(target=30., target_way=3, boundary='b'*64).message.slcState
assert pending.hasPending
far = drive.tick(target=32., target_way=5, boundary='c'*64, distance=160., action=drive.action(pending, 'accept')).message.slcState
assert not far.hasPending and far.acceptedSpeedLimit == 25.
departed = drive.tick(current=16., way=4, target=0.).message.slcState
assert departed.acceptedSpeedLimit == 20. and departed.pendingSpeedLimit == 16.
@pytest.mark.parametrize('maps_alive', [False, True])
def test_preferred_dashboard_can_restore_the_approach_limit(maps_alive):
drive = Drive()
drive.runtime.settings = replace(drive.runtime.settings,
selection=parse({'SLCPriority1': 'Dashboard', 'SLCPriority2': 'Maps'}).selection)
assert drive.tick(target=25.).message.slcState.acceptedSpeedLimit == 25.
assert drive.tick(alive=False).message.slcState.acceptedSpeedLimit == 25.
state = drive.tick(alive=maps_alive, target=25., dashboard=20.).message.slcState
assert state.source == 'dashboard' and state.acceptedSpeedLimit == 20.
assert not state.hasPending and not state.upcomingBoundaryId
@pytest.mark.parametrize('confirm_higher', [False, True])
def test_corrected_arrival_compares_direction_to_the_accepted_road(confirm_higher):
drive = Drive(confirm=True)
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 126.
preview = drive.tick(target=25.).message.slcState
drive.tick(target=25., action=drive.action(preview, 'accept'))
drive.runtime.settings = replace(drive.runtime.settings, acceptance=acc.Policy(confirm_lower=not confirm_higher,
confirm_higher=confirm_higher))
arrived = drive.tick(current=22.5, way=2, target=0.).message.slcState
assert arrived.hasPending == confirm_higher
assert arrived.acceptedSpeedLimit == (20. if confirm_higher else 22.5)
def test_same_approach_correction_does_not_reopen_the_old_preview():
drive = Drive(confirm=True)
preview = drive.tick(target=25.).message.slcState
drive.tick(target=25., action=drive.action(preview, 'accept'))
corrected = drive.tick(current=16., target=25.).message.slcState
assert corrected.effectiveCap == 22. and corrected.pendingSpeedLimit == 16.
next_frame = drive.tick(current=16., target=25.).message.slcState
assert next_frame.decisionId == corrected.decisionId and not next_frame.upcomingBoundaryId
@pytest.mark.parametrize('mode,preview', [('Dashboard', 25.), ('Lowest', 25.), ('Highest', 15.)])
@pytest.mark.parametrize('confirm', [False, True])
def test_source_recovery_does_not_reopen_the_same_early_offer(mode, preview, confirm):
drive = Drive(confirm=confirm)
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 126.
drive.runtime.settings = replace(drive.runtime.settings, selection=parse({'SLCPriority1': mode, 'SLCPriority2': 'Maps'}).selection)
first = drive.tick(target=preview).message.slcState
assert first.hasPending == confirm and first.upcomingBoundaryId
for dashboard, boundary in ((20., BOUNDARY), (None, 'b'*64), (20., 'b'*64), (None, BOUNDARY)):
shown = drive.tick(target=preview, dashboard=dashboard, boundary=boundary).message.slcState
assert not shown.hasPending and not shown.upcomingBoundaryId and shown.effectiveCap == 22.
arrived = drive.tick(current=preview, way=2, target=0.).message.slcState
assert arrived.hasPending == confirm
assert arrived.acceptedSpeedLimit == (20. if confirm else preview)
@pytest.mark.parametrize('mode,preview,rival', [('Lowest', 25., 26.), ('Highest', 15., 14.)])
def test_extrema_keep_preview_when_maps_still_wins(mode, preview, rival):
drive = Drive()
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 126.
drive.runtime.settings = replace(drive.runtime.settings, selection=parse({'SLCPriority1': mode}).selection)
assert drive.tick(target=preview).message.slcState.acceptedSpeedLimit == preview
assert drive.tick(target=preview, dashboard=rival).message.slcState.acceptedSpeedLimit == preview
def test_map_gap_allows_a_qualified_different_fallback_limit():
drive = Drive()
assert drive.tick(target=25.).message.slcState.acceptedSpeedLimit == 25.
assert drive.tick(alive=False, dashboard=20.).message.slcState.acceptedSpeedLimit == 25.
fallback = drive.tick(alive=False, dashboard=16.).message.slcState
assert fallback.acceptedSpeedLimit == 16. and fallback.source == 'dashboard'
def test_pending_replacement_keeps_its_deadline_on_arrival_and_retires_the_old_cap():
drive = Drive(confirm=True)
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 126.
first = drive.tick(target=25.).message.slcState
drive.tick(target=25., action=drive.action(first, 'accept'))
replacement = drive.tick(target=30., target_way=3, boundary='b'*64).message.slcState
arrived = drive.tick(current=30., way=3, target=20., target_way=4, boundary='c'*64, distance=10.).message.slcState
assert arrived.effectiveCap == 22. and arrived.pendingSpeedLimit == 30.
assert (arrived.decisionId, arrived.pendingSinceMonoTime) == (replacement.decisionId, replacement.pendingSinceMonoTime)
def test_dashboard_only_ignores_maps_and_vision_during_a_source_gap():
drive = Drive()
drive.runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCPriority2': 'Off',
'SLCAdvanceHigherSeconds': 5}), vision_enabled=True, vision_control_qualified=True)
for dashboard, expected in ((18., 18.), (None, 18.), (16., 16.)):
state = drive.tick(dashboard=dashboard, vision_speed=22., target=25., distance=10.).message.slcState
assert state.effectiveCap == expected and state.acceptedSource == 'dashboard'
assert not state.upcomingBoundaryId and drive.runtime.advance.encounter is None
@pytest.mark.parametrize('mode,preview,rival', [('Lowest', 25., 26.), ('Highest', 15., 14.)])
def test_losing_rival_cannot_replace_a_held_cap_while_maps_is_current(mode, preview, rival):
drive = Drive()
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 126.
drive.runtime.settings = replace(drive.runtime.settings, selection=parse({'SLCPriority1': mode}).selection)
assert drive.tick(target=preview).message.slcState.acceptedSpeedLimit == preview
state = drive.tick(target=0., dashboard=rival).message.slcState
assert state.source == 'map' and state.acceptedSpeedLimit == preview
@pytest.mark.parametrize('mode', ['Highest', 'Lowest'])
def test_applied_cap_and_pending_replacement_compete_independently(mode):
drive = Drive(confirm=True)
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 126.
drive.runtime.settings = replace(drive.runtime.settings, selection=parse({'SLCPriority1': mode}).selection)
first = drive.tick(target=25.).message.slcState
drive.tick(target=25., action=drive.action(first, 'accept'))
revised = {'target': 15., 'target_way': 3, 'boundary': 'b'*64}
pending = drive.tick(**revised).message.slcState
assert pending.pendingSpeedLimit == 15.
if mode == 'Highest':
state = drive.tick(**revised, dashboard=20., action=drive.action(pending, 'accept')).message.slcState
assert state.acceptedSpeedLimit == 25. and not state.hasPending
assert not drive.tick(**revised).message.slcState.hasPending
else:
state = drive.tick(**revised, dashboard=20.).message.slcState
assert state.acceptedSpeedLimit == 20. and state.pendingSpeedLimit == 15.
assert (state.decisionId, state.pendingSinceMonoTime) == (pending.decisionId, pending.pendingSinceMonoTime)
assert drive.tick(**revised, dashboard=20., action=drive.action(state, 'accept')).message.slcState.acceptedSpeedLimit == 15.
def test_corrected_current_limit_prompt_precedes_the_exit_preview():
drive = Drive(confirm=True)
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 126.
preview = drive.tick(target=25.).message.slcState
drive.tick(target=25., action=drive.action(preview, 'accept'))
zone = {'current': 16., 'way': 2, 'target': 30., 'target_way': 3, 'boundary': 'b'*64, 'distance': 10.}
corrected = drive.tick(**zone).message.slcState
next_frame = drive.tick(**zone).message.slcState
assert next_frame.effectiveCap == 22. and next_frame.pendingSpeedLimit == 16.
assert (next_frame.decisionId, next_frame.pendingSinceMonoTime) == (corrected.decisionId, corrected.pendingSinceMonoTime)
accepted = drive.tick(**zone, action=drive.action(next_frame, 'accept')).message.slcState
assert accepted.acceptedSpeedLimit == 16.
assert drive.tick(**zone).message.slcState.pendingSpeedLimit == 30.
def test_early_limit_replaces_history_only_after_current_road_confirms_it():
drive = Drive()
early = drive.tick(target=25.).message.slcState
assert early.acceptedSpeedLimit == 25. and early.acceptedIsPreview
assert drive.runtime.history_limit.candidate.speed_mps == 20.
gap = drive.tick(alive=False).message.slcState
assert gap.acceptedSpeedLimit == 25. and gap.acceptedIsPreview
assert drive.runtime.history_limit.candidate.speed_mps == 20.
drive.runtime.reset()
assert drive.tick(alive=False).message.slcState.acceptedSpeedLimit == 20.
assert drive.tick(target=25.).message.slcState.acceptedSpeedLimit == 25.
arrived = drive.tick(current=25., way=2, target=0.).message.slcState
assert arrived.acceptedSpeedLimit == 25. and not arrived.acceptedIsPreview
assert drive.runtime.history_limit.candidate.speed_mps == 25.
drive.runtime.reset()
assert drive.tick(alive=False).message.slcState.acceptedSpeedLimit == 25.
@pytest.mark.parametrize('choice', [None, 'accept', 'reject'])
@pytest.mark.parametrize('cold_start', [False, True], ids=['known-limit', 'first-map-reading'])
def test_first_current_road_decision_precedes_its_exit(choice, cold_start):
drive = Drive(confirm=choice is not None)
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 126.
if cold_start:
drive.runtime = Runtime(drive.runtime.settings)
unavailable = drive.tick(alive=False).message.slcState
assert not unavailable.hasAccepted and not unavailable.hasPending
zone = {'current': 16., 'way': 2, 'target': 30., 'target_way': 3, 'distance': 10.}
entered = drive.tick(**zone).message.slcState
assert not entered.upcomingBoundaryId
if choice is None:
assert entered.acceptedSpeedLimit == 16.
assert drive.tick(**zone).message.slcState.acceptedSpeedLimit == 30.
else:
assert entered.pendingSpeedLimit == 16.
held = drive.tick(**zone).message.slcState
assert held.decisionId == entered.decisionId and held.pendingSpeedLimit == 16.
resolved = drive.tick(**zone, action=drive.action(held, choice)).message.slcState
assert resolved.actionStatus == choice
assert drive.tick(**zone).message.slcState.pendingSpeedLimit == 30.
@pytest.mark.parametrize('accepted', [False, True])
def test_known_turn_away_without_posted_speed_retires_preview(accepted):
drive = Drive(confirm=True)
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 126.
early = drive.tick(target=25.).message.slcState
if accepted:
drive.tick(target=25., action=drive.action(early, 'accept'))
gap = drive.tick(alive=False).message.slcState
assert gap.acceptedSpeedLimit == (25. if accepted else 20.) and gap.hasPending != accepted
same_road = drive.tick(current=0., target=0., road_status='matchedNoLimit').message.slcState
assert same_road.acceptedSpeedLimit == gap.acceptedSpeedLimit and same_road.hasPending == gap.hasPending
turned = drive.tick(current=0., target=0., way=3, road_status='matchedNoLimit',
action=drive.action(early, 'accept') if not accepted else None).message.slcState
assert turned.observationKind == 'unknown'
assert turned.acceptedSpeedLimit == 20. and not turned.hasPending
def test_retired_cap_cannot_hide_later_current_limit_on_same_way():
drive = Drive()
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 126.
assert drive.tick(target=25.).message.slcState.acceptedSpeedLimit == 25.
assert drive.tick(current=27., target=0.).message.slcState.acceptedSpeedLimit == 27.
assert drive.tick(current=20., target=0.).message.slcState.acceptedSpeedLimit == 20.
def test_current_reading_matching_the_older_cap_retires_the_replacement():
drive = Drive(confirm=True)
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 126.
first = drive.tick(target=25.).message.slcState
drive.tick(target=25., action=drive.action(first, 'accept'))
assert drive.tick(target=30., target_way=3).message.slcState.pendingSpeedLimit == 30.
current = drive.tick(current=25., target=0.).message.slcState
assert current.acceptedSpeedLimit == 25. and not current.hasPending and not current.acceptedIsPreview
assert drive.tick(current=20., target=0.).message.slcState.pendingSpeedLimit == 20.
@pytest.mark.parametrize('mode,preview,dashboard', [('Highest', 25., 22.), ('Lowest', 15., 18.)])
def test_adopt_current_releases_preview_priority(mode, preview, dashboard):
drive = Drive()
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 126.
drive.runtime.settings = replace(drive.runtime.settings, selection=parse({'SLCPriority1': mode}).selection)
early = drive.tick(target=preview).message.slcState
assert drive.tick(target=preview, action=drive.action(early, 'adopt')).message.slcState.acceptedSpeedLimit == 20.
assert drive.tick(target=preview, dashboard=dashboard).message.slcState.acceptedSpeedLimit == dashboard
def test_losing_replacement_does_not_reopen_approach_during_map_gap():
drive = Drive(confirm=True)
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 126.
drive.runtime.settings = replace(drive.runtime.settings, selection=parse({'SLCPriority1': 'Highest'}).selection)
early = drive.tick(target=25.).message.slcState
drive.tick(target=25., action=drive.action(early, 'accept'))
replacement = {'target': 15., 'target_way': 3, 'boundary': 'b'*64}
assert drive.tick(**replacement).message.slcState.pendingSpeedLimit == 15.
assert not drive.tick(**replacement, dashboard=20.).message.slcState.hasPending
gap = drive.tick(alive=False, dashboard=20.).message.slcState
assert gap.acceptedSpeedLimit == 25. and not gap.hasPending
fallback = drive.tick(alive=False, dashboard=18.).message.slcState
assert fallback.acceptedSpeedLimit == 20. and fallback.pendingSpeedLimit == 18.
def test_same_speed_replacement_gets_a_fresh_decision_and_deadline():
drive = Drive(confirm=True)
first = drive.tick().message.slcState
deadline = first.pendingSinceMonoTime + acc.CONFIRMATION_NS
replacement = {'target_way': 3, 'boundary': 'b'*64}
revised = drive.tick(**replacement, at=deadline-100_000_000, action=drive.action(first, 'accept')).message.slcState
assert revised.pendingSpeedLimit == first.pendingSpeedLimit and revised.acceptedSpeedLimit == 20.
assert revised.decisionId != first.decisionId and revised.pendingSinceMonoTime == drive.now
assert drive.tick(**replacement, at=deadline+1).message.slcState.decisionId == revised.decisionId
@@ -14,7 +14,7 @@ ACTIVE = acc.Authority(acc.Mode.LONGITUDINAL_ONLY, acc.LongitudinalOwner.SYSTEM,
POLICY = acc.Policy(confirm_lower=False, confirm_higher=False)
SELECT = sel.SelectionPolicy(sel.SelectionMode.ORDERED, (sel.Source.DASHBOARD, sel.Source.MAP), False)
OFFSET = sd.OffsetSchedule((sd.OffsetBand(0.0, None, 2.0),))
HOST = comp.HostState(True, True, True, False, False, 100.0, 100.0)
HOST = comp.HostState(True, True, True, False, 100.0, 100.0)
def observations(speed: float | None = 15.0, *, kind: acc.ObservationKind = acc.ObservationKind.VALID):
@@ -60,20 +60,19 @@ class CompositionTests(unittest.TestCase):
self.assertEqual(result.acceptance.control_target_mps, 15.0)
self.assertEqual(result.domain.context.offset_mps, 2.0)
self.assertEqual(result.coordinate.cap_mps, 16.0)
self.assertIsNone(result.ceiling)
self.assertEqual(result.state.control_generation, 1)
self.assertEqual(result.ceiling.speed_mps, 16.0)
def test_source_gaps_keep_history_without_a_control_target_or_ceiling(self):
def test_source_gaps_keep_the_applied_ceiling_without_accepting_a_new_limit(self):
self.run_frame(frame(0))
accepted = self.state.acceptance.accepted
for now, kind in enumerate((acc.ObservationKind.ABSENT, acc.ObservationKind.UNKNOWN, acc.ObservationKind.STALE), 1):
result = self.run_frame(frame(now, source_kind=kind))
self.assertEqual(result.acceptance.basis, kind.value)
self.assertIsNone(result.acceptance.control_target_mps)
self.assertEqual(result.acceptance.control_target_mps, 15.)
self.assertEqual(result.state.acceptance.accepted, accepted)
self.assertIsNone(result.domain.context)
self.assertIsNone(result.coordinate)
self.assertIsNone(result.ceiling)
self.assertEqual(result.domain.context.accepted_raw_mps, 15.)
self.assertEqual(result.coordinate.cap_mps, 17.)
self.assertEqual(result.ceiling.speed_mps, 17.0)
def test_uninitialized_driver_and_missing_coordinates_suppress(self):
unset = self.run_frame(frame(0, host=replace(HOST, driver_v_cruise_kph=255.0)))
@@ -97,7 +96,7 @@ class CompositionTests(unittest.TestCase):
self.state = comp.new_session(SESSION)
host = HOST if authority.can_control else replace(HOST, system_long_available=False, long_control_active=False)
result = self.run_frame(frame(0, authority=authority, host=host))
self.assertIsNone(result.ceiling)
self.assertEqual(result.ceiling is not None, authority.can_control)
self.state = comp.new_session(SESSION)
self.run_frame(frame(0))
lost = self.run_frame(frame(1, authority=replace(ACTIVE, longitudinal_active=False),
@@ -105,7 +104,7 @@ class CompositionTests(unittest.TestCase):
self.assertIsNone(lost.ceiling)
self.assertFalse(lost.state.reset_required)
resumed = self.run_frame(frame(2))
self.assertEqual(resumed.state.control_generation, 2)
self.assertEqual(resumed.ceiling.speed_mps, 17.)
def test_host_authority_disagreement_does_not_advance_acceptance_time(self):
self.run_frame(frame(0))
@@ -134,7 +133,7 @@ class CompositionTests(unittest.TestCase):
def test_rejected_lower_limit_survives_source_loss_and_reconnect(self):
confirmation = replace(POLICY, confirm_lower=True)
initial = self.run_frame(frame(0, speed=25.0, policy=confirmation))
self.assertIsNone(initial.ceiling)
self.assertEqual(initial.ceiling.speed_mps, 27.0)
initial_accepted = acc.AcceptedLimit(observations(25.)[sel.Source.DASHBOARD].candidate, SESSION, 0)
self.state = replace(self.state, acceptance=replace(self.state.acceptance, accepted=initial_accepted, pending=None, timer_running=False))
pending = self.run_frame(frame(1, speed=15.0, policy=confirmation))
@@ -149,15 +148,12 @@ class CompositionTests(unittest.TestCase):
for result in (rejected, absent, reconnected):
self.assertFalse(result.errors)
self.assertEqual(result.acceptance.state.accepted.candidate.speed_mps, 25.0)
if result is absent:
self.assertIsNone(result.domain.context)
else:
self.assertEqual(result.domain.context.accepted_raw_mps, 25.0)
self.assertIsNone(result.ceiling)
self.assertEqual(result.domain.context.accepted_raw_mps, 25.0)
self.assertEqual(result.ceiling.speed_mps, 27.0)
def test_reversed_monotonic_clock_latches_without_ceiling(self):
first = self.run_frame(frame(10))
self.assertIsNone(first.ceiling)
self.assertEqual(first.ceiling.speed_mps, 17.0)
prior_acceptance = self.state.acceptance
reversed_time = self.run_frame(frame(9))
self.assertEqual(reversed_time.status, "invalid_frame")
@@ -168,32 +164,23 @@ class CompositionTests(unittest.TestCase):
self.assertEqual(latched.status, "session_reset_required")
self.assertIsNone(latched.ceiling)
def test_actual_causal_ledger_change_retains_raw_driver_intent(self):
first = self.run_frame(frame(0))
context = first.override.context
assert context is not None
def test_causal_driver_cruise_change_does_not_bypass_the_slc_cap(self):
self.run_frame(frame(0))
ledger = arb.step(arb.new_ledger(SESSION),
arb.BeginAction(SESSION, 1, arb.Origin.DRIVER_CRUISE, context.context_id), now_ns=0).state
arb.BeginAction(SESSION, 1, arb.Origin.DRIVER_CRUISE, 'driver-set'), now_ns=0).state
ledger = arb.step(ledger, arb.ResolveAction(SESSION, 1, arb.Disposition.DRIVER_INTENT), now_ns=0).state
changed = arb.step(ledger, arb.CruiseChange(SESSION, 1, 1, 100.0 / 3.6, 110.0 / 3.6), now_ns=1)
second_frame = replace(frame(1, host=replace(HOST, driver_v_cruise_kph=110.0, selected_cluster_kph=110.0)),
classified_cruise=changed)
second = self.run_frame(second_frame)
self.assertEqual(second.override.event_receipt.status, "driver_intent")
self.assertTrue(second.override.event_receipt.consumed)
assert second.override.state.persistent_selected_mps is not None
self.assertAlmostEqual(second.override.state.persistent_selected_mps, 110.0 / 3.6)
self.assertEqual(second.ceiling.speed_mps, 17.0)
third = self.run_frame(frame(2, host=replace(HOST, driver_v_cruise_kph=105.0, selected_cluster_kph=106.0)))
assert third.override.state.persistent_selected_mps is not None
self.assertAlmostEqual(third.override.state.persistent_selected_mps, 110.0 / 3.6)
self.assertIsNone(third.ceiling)
self.assertEqual(third.ceiling.speed_mps, 17.0)
def test_old_ledger_result_does_not_become_a_current_speed_action(self):
first = self.run_frame(frame(0))
context = first.override.context
assert context is not None
self.run_frame(frame(0))
ledger = arb.step(arb.new_ledger(SESSION),
arb.BeginAction(SESSION, 1, arb.Origin.DRIVER_CRUISE, context.context_id), now_ns=0).state
arb.BeginAction(SESSION, 1, arb.Origin.DRIVER_CRUISE, 'driver-set'), now_ns=0).state
ledger = arb.step(ledger, arb.ResolveAction(SESSION, 1, arb.Disposition.DRIVER_INTENT), now_ns=0).state
old = arb.step(ledger, arb.CruiseChange(SESSION, 1, 1, 100.0 / 3.6, 110.0 / 3.6), now_ns=1)
rejected = self.run_frame(replace(frame(2, host=replace(HOST, driver_v_cruise_kph=110.0)),
@@ -202,11 +189,11 @@ class CompositionTests(unittest.TestCase):
self.assertIsNone(rejected.ceiling)
self.assertEqual(rejected.state.acceptance.last_timestamp_ns, 0)
def test_new_limit_never_creates_a_hidden_planner_target(self):
def test_new_limit_supplies_a_separate_planner_cap(self):
accepted = self.run_frame(frame(0, speed=20.))
self.assertEqual(accepted.acceptance.basis, 'accepted')
self.assertEqual(accepted.domain.context.effective_cluster_mps, 22.)
self.assertIsNone(accepted.ceiling)
self.assertEqual(accepted.ceiling.speed_mps, 22.)
def test_actual_native_planner_default_and_optional_cap_transitions(self):
from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner
@@ -260,8 +247,11 @@ class CompositionTests(unittest.TestCase):
self.assertEqual(deviations, [])
self.assertEqual(last[2], "absent")
else:
self.assertEqual(deviations, [])
self.assertEqual(last[2], "absent")
self.assertEqual(last[2], "applied")
if scenario == 'ordinary':
self.assertEqual(deviations[0], 12)
else:
self.assertEqual(last[0][0], last[1][0])
if scenario == "lead":
self.assertEqual(last[1][2], "1")
elif scenario == "e2e":
@@ -18,18 +18,17 @@ def test_runtime_confirms_only_with_engaged_longitudinal_control(mode):
sm['carControl'].latActive = mode in ('aol', 'combined')
sm['controlsState'].longControlState = 'pid' if active else 'off'
sm['carState'].gasPressed = mode == 'accelerator'
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCConfirmation': True, 'SLCPriority1': 'Dashboard'}))
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCConfirmation': 1, 'SLCPriority1': 'Dashboard'}))
output = runtime.step(sm, cp, now_ns=START)
assert not output.result.errors
assert output.message.slcState.hasPending == engaged
assert output.message.slcState.observationKind == 'valid'
assert output.message.slcState.presentationId > 0
assert output.command is None
def test_runtime_cancels_old_action_and_restarts_confirmation_after_disengagement():
sm, cp = replay_inputs()
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCConfirmation': True, 'SLCPriority1': 'Dashboard'}))
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCConfirmation': 1, 'SLCPriority1': 'Dashboard'}))
first = runtime.step(sm, cp, now_ns=START).message.slcState
action = Action(runtime.session_id, 1, first.decisionId, first.presentationId, 'accept')
sm.advance(START+50_000_000)
@@ -38,7 +37,7 @@ def test_runtime_cancels_old_action_and_restarts_confirmation_after_disengagemen
sm['controlsState'].longControlState = 'off'
inactive = runtime.step(sm, cp, now_ns=START+50_000_000, request=action)
assert not inactive.message.slcState.hasPending and not inactive.message.slcState.hasAccepted
assert inactive.command is None and not inactive.action_result.change
assert not inactive.action_result.change
now = START+20_000_000_000
sm.advance(now)
sm['slcDashboardObservation'].observedMonoTime = now
@@ -67,7 +66,7 @@ def test_aol_pedal_override_requires_current_longitudinal_engagement(desired, ac
sm.seen = {'aolAxisState': True}
sm.valid['aolAxisState'] = sm.alive['aolAxisState'] = True
sm.logMonoTime['aolAxisState'] = START if fresh else START-100_000_000
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCConfirmation': True, 'SLCPriority1': 'Dashboard'}))
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCConfirmation': 1, 'SLCPriority1': 'Dashboard'}))
output = runtime.step(sm, cp, now_ns=START)
assert not output.result.errors
assert output.message.slcState.hasPending == (desired and acknowledged and fresh)
@@ -0,0 +1,478 @@
"""Live SLC caps the planner while the driver and native cruise retain SET."""
from collections import deque
from dataclasses import replace
import pytest
from openpilot.common.constants import CV
from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner
from openpilot.starpilot.speed_limits import acceptance as acc
from openpilot.starpilot.speed_limits.runtime import Action, CruiseEvent, Runtime
from openpilot.starpilot.speed_limits.runtime_settings import PedalOverride, parse
from openpilot.starpilot.speed_limits.speed_domain import OffsetBand, OffsetSchedule
from openpilot.starpilot.speed_limits.tests import test_runtime_replay as replay
START = 2_000_000_000
@pytest.fixture
def drive():
fixture = replay.RuntimeReplayTests()
fixture.setUp()
return fixture.sm, fixture.cp
def tick(runtime, sm, cp, step, **kwargs):
now = START + step * 50_000_000
sm.advance(now)
sm['slcDashboardObservation'].validUntilMonoTime = now + 1_000_000_000
output = runtime.step(sm, cp, now_ns=now, **kwargs)
assert not output.result.errors
return output
def controller(offset=0., *, confirm=False):
settings = parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCPriority2': 'Off', 'SLCConfirmation': int(confirm)})
return Runtime(replace(settings, offsets=OffsetSchedule((OffsetBand(0., None, offset),))))
@pytest.mark.parametrize('pcm', [False, True])
@pytest.mark.parametrize('metric', [False, True])
def test_limits_follow_below_driver_ceiling_without_rewriting_set(drive, pcm, metric):
sm, cp = drive
cp.pcmCruise = pcm
unit = CV.KPH_TO_MS if metric else CV.MPH_TO_MS
selected = 65 * unit * 3.6
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
selected = float(sm['carState'].vCruise)
runtime = controller(5 * unit)
planner = LongitudinalPlanner(cp)
for step, limit, status in ((0, 55, 'applied'), (1, 70, 'above_driver'), (2, 40, 'applied')):
sm['slcDashboardObservation'].speedMps = limit * unit
output = tick(runtime, sm, cp, step)
assert output.result.ceiling.speed_mps == pytest.approx((limit + 5) * unit)
planner.update(sm, cruise_ceiling=output.result.ceiling)
assert planner.last_cruise_ceiling_status == status
assert sm['carState'].vCruise == sm['carState'].vCruiseCluster == selected
@pytest.mark.parametrize('selected', [50., 110.])
def test_cruise_adjustments_change_only_the_driver_ceiling(drive, selected):
sm, cp = drive
runtime = controller()
tick(runtime, sm, cp, 0)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected
event = CruiseEvent(1, START+50_000_000, 100/3.6, selected/3.6,
'accel' if selected > 100 else 'decel', 'card-producer', START+50_000_000)
changed = tick(runtime, sm, cp, 1, cruise_event=event)
assert changed.result.ceiling.speed_mps == 25.
assert not changed.message.slcState.driverOverrideActive
assert sm['carState'].vCruise == selected
def wheel_crossing(drive, *, metric=True, enabled=True, previous_kph=90., selected_kph=91., offset=0.):
sm, cp = drive
cp.pcmCruise = False
car_state = sm['carState']
car_state.canValid = True
car_state.vCruise = car_state.vCruiseCluster = previous_kph
settings = replace(controller(offset).settings, wheel_override=enabled)
runtime = Runtime(settings, is_metric=metric)
first = tick(runtime, sm, cp, 0).message.slcState
car_state.vCruise = car_state.vCruiseCluster = selected_kph
event = CruiseEvent(1, START + 50_000_000, previous_kph / 3.6, selected_kph / 3.6, 'accel',
'card-producer', START + 50_000_000, session_id=runtime.session_id,
wheel_override_since_ns=first.wheelOverrideSince)
return runtime, event
@pytest.mark.parametrize('metric,previous_kph,selected_kph,limit,offset', [
(True, 90., 91., 25., 0.),
(True, 54.5, 55.5, 54. / 3.6, 0.),
(False, 89., 90.6, 55. * CV.MPH_TO_MS, 0.),
(False, 86.9, 88.5, 55. * CV.MPH_TO_MS, -.5 * CV.MPH_TO_MS),
])
def test_wheel_tap_crosses_displayed_limit_and_uses_native_set(drive, metric, previous_kph, selected_kph, limit, offset):
from struct import pack, unpack
sm, cp = drive
sm['slcDashboardObservation'].speedMps = limit
sm['carState'].vEgoCluster += 1.0
runtime, event = wheel_crossing(drive, metric=metric, previous_kph=previous_kph, selected_kph=selected_kph, offset=offset)
event = replace(event, previous_mps=unpack('f', pack('f', event.previous_mps))[0])
assert event.wheel_override_since_ns == START
# Card publishes carState before the event. The new SET can be sampled first.
assert tick(runtime, sm, cp, 1).result.ceiling is not None
crossed = tick(runtime, sm, cp, 2, cruise_event=event)
status = crossed.message.slcState
assert crossed.result.ceiling is None and status.driverOverrideActive and status.overrideBasis == 'wheel'
assert status.effectiveClusterTarget == pytest.approx(selected_kph / 3.6)
assert not status.isLimitingMaxSet
planner = LongitudinalPlanner(cp)
planner.update(sm, cruise_ceiling=crossed.result.ceiling)
assert planner.last_cruise_ceiling_status == 'absent'
sm['carState'].vCruise = sm['carState'].vCruiseCluster = previous_kph
lowered = tick(runtime, sm, cp, 3)
assert lowered.result.ceiling is not None and not lowered.message.slcState.driverOverrideActive
sm['carState'].vCruise = sm['carState'].vCruiseCluster = selected_kph
assert not tick(runtime, sm, cp, 4, cruise_event=event).message.slcState.driverOverrideActive
@pytest.mark.parametrize('scenario', ['off', 'already above', 'controller', 'hold', 'foreign producer', 'foreign session',
'different SET', 'stop or curve', 'confirmation', 'new limit', 'disengaged', 'pcm',
'standstill', 'units changed'])
def test_wheel_override_requires_current_unconsumed_physical_crossing(drive, scenario):
sm, cp = drive
prior, selected = (100., 101.) if scenario == 'already above' else (90., 91.)
runtime, event = wheel_crossing(drive, enabled=scenario != 'off', previous_kph=prior, selected_kph=selected)
changes = {
'controller': {'wheel_override_since_ns': 0},
'hold': {'long_press': True},
'foreign producer': {'producer_session': 'old-card'},
'foreign session': {'session_id': 'old-slc'},
'different SET': {'selected_mps': event.selected_mps - .05},
}
event = replace(event, **changes.get(scenario, {}))
if scenario in ('confirmation', 'new limit'):
sm['slcDashboardObservation'].speedMps = 23.
if scenario == 'confirmation':
runtime.settings = replace(runtime.settings, acceptance=acc.Policy(confirm_lower=True))
if scenario == 'disengaged':
sm['carControl'].enabled = sm['carControl'].longActive = sm['selfdriveState'].enabled = False
if scenario == 'pcm':
cp.pcmCruise = True
if scenario == 'standstill':
sm['carState'].standstill = True
if scenario == 'units changed':
runtime.update_offsets(runtime.settings.offsets, is_metric=False)
output = tick(runtime, sm, cp, 1, cruise_event=event, wheel_override_blocked=scenario == 'stop or curve')
assert not output.message.slcState.driverOverrideActive
if scenario in ('stop or curve', 'standstill'):
assert output.message.slcState.wheelOverrideSince == 0
sm['carState'].standstill = False
assert not tick(runtime, sm, cp, 2, cruise_event=replace(event, event_id=2)).message.slcState.driverOverrideActive
@pytest.mark.parametrize('limit_kph', [51., 90.])
def test_wheel_override_survives_units_change_until_set_returns_to_limit(drive, limit_kph):
sm, cp = drive
sm['slcDashboardObservation'].speedMps = limit_kph / 3.6
runtime, event = wheel_crossing(drive, previous_kph=limit_kph, selected_kph=limit_kph + 1.)
assert tick(runtime, sm, cp, 1, cruise_event=event).message.slcState.overrideBasis == 'wheel'
# At 51 km/h both speeds round to 32 mph, even though SET remains higher.
runtime.update_offsets(runtime.settings.offsets, is_metric=False)
changed = tick(runtime, sm, cp, 2)
assert changed.result.ceiling is None
assert changed.message.slcState.overrideBasis == 'wheel'
assert changed.message.slcState.wheelOverrideSince != event.wheel_override_since_ns
sm['carState'].vCruise = sm['carState'].vCruiseCluster = limit_kph
returned = tick(runtime, sm, cp, 3)
assert returned.result.ceiling.speed_mps == pytest.approx(limit_kph / 3.6)
assert not returned.message.slcState.driverOverrideActive
@pytest.mark.parametrize('cruise_standstill', [False, True])
def test_wheel_override_survives_engaged_stop(drive, cruise_standstill):
sm, cp = drive
runtime, event = wheel_crossing(drive)
assert tick(runtime, sm, cp, 1, cruise_event=event).message.slcState.overrideBasis == 'wheel'
stopped = sm['carState'].cruiseState if cruise_standstill else sm['carState']
stopped.standstill = True
sm['carState'].vEgo = sm['carState'].vEgoCluster = 0.
sm['controlsState'].longControlState = 'stopping'
held = tick(runtime, sm, cp, 2)
assert held.message.slcState.overrideBasis == 'wheel'
assert held.message.slcState.wheelOverrideSince == 0
stopped.standstill = False
sm['controlsState'].longControlState = 'pid'
assert tick(runtime, sm, cp, 3).message.slcState.overrideBasis == 'wheel'
def test_wheel_override_survives_gas_handoff_without_latching_pedal_speed(drive):
sm, cp = drive
runtime, event = wheel_crossing(drive)
assert tick(runtime, sm, cp, 1, cruise_event=event).message.slcState.overrideBasis == 'wheel'
sm['carState'].vEgo = sm['carState'].vEgoCluster = 91. / 3.6
sm['carState'].gasPressed = True
sm['carControl'].longActive = False
sm['controlsState'].longControlState = 'off'
assert tick(runtime, sm, cp, 2).result.state.pedal_target_mps == 0.
sm['carState'].gasPressed = False
assert tick(runtime, sm, cp, 3).result.state.wheel_override_metric is True
sm['carControl'].longActive = True
sm['controlsState'].longControlState = 'pid'
assert tick(runtime, sm, cp, 4).message.slcState.overrideBasis == 'wheel'
sm['slcDashboardObservation'].speedMps = 23.
accepted = tick(runtime, sm, cp, 5)
assert accepted.result.ceiling.speed_mps == 23. and not accepted.message.slcState.driverOverrideActive
@pytest.mark.parametrize('missing', ['absent', 'unknown', 'stale'])
def test_source_gap_uses_the_previous_limit_and_current_offset(drive, missing):
sm, cp = drive
runtime = controller(2.)
first = tick(runtime, sm, cp, 0)
assert first.result.ceiling.speed_mps == 27.
sm['slcDashboardObservation'].status = missing
runtime.update_offsets(OffsetSchedule((OffsetBand(0., None, 4.),)))
gap = tick(runtime, sm, cp, 1)
assert gap.result.ceiling.speed_mps == 29.
assert gap.message.slcState.hasCeiling and gap.message.slcState.observationKind == missing
sm['slcDashboardObservation'].status = 'valid'
recovered = tick(runtime, sm, cp, 2)
assert recovered.result.ceiling.speed_mps == 29.
assert sm['carState'].vCruise == 100.
def test_previous_limit_resumes_after_disengagement_and_session_reset(drive):
sm, cp = drive
runtime = controller()
tick(runtime, sm, cp, 0)
sm['slcDashboardObservation'].status = 'unknown'
sm['selfdriveState'].enabled = sm['carControl'].enabled = sm['carControl'].longActive = False
sm['controlsState'].longControlState = 'off'
assert tick(runtime, sm, cp, 1).result.ceiling is None
sm['selfdriveState'].enabled = sm['carControl'].enabled = sm['carControl'].longActive = True
sm['controlsState'].longControlState = 'pid'
assert tick(runtime, sm, cp, 2).result.ceiling.speed_mps == 25.
runtime.reset()
assert tick(runtime, sm, cp, 3).result.ceiling.speed_mps == 25.
def test_force_deceleration_suppresses_a_cap_without_forgetting_source_gap_continuity(drive):
sm, cp = drive
runtime = controller()
tick(runtime, sm, cp, 0)
sm['slcDashboardObservation'].status = 'unknown'
sm['controlsState'].forceDecel = True
assert tick(runtime, sm, cp, 1).result.ceiling is None
sm['controlsState'].forceDecel = False
assert tick(runtime, sm, cp, 2).result.ceiling.speed_mps == 25.
assert sm['carState'].vCruise == 100.
@pytest.mark.parametrize('choice', ['accept', 'reject'])
def test_confirmation_changes_the_slc_target_and_preserves_set(drive, choice):
sm, cp = drive
runtime = controller()
tick(runtime, sm, cp, 0)
runtime.settings = replace(runtime.settings, acceptance=acc.Policy(confirm_lower=True, confirm_higher=True))
sm['slcDashboardObservation'].speedMps = 15.
pending = tick(runtime, sm, cp, 1)
assert pending.result.ceiling.speed_mps == 25.
state = pending.message.slcState
action = Action(runtime.session_id, 1, state.decisionId, state.presentationId, choice)
chosen = tick(runtime, sm, cp, 2, request=action)
assert chosen.result.ceiling.speed_mps == (15. if choice == 'accept' else 25.)
assert not chosen.message.slcState.hasPending
assert sm['carState'].vCruise == 100.
def test_pedal_release_reapplies_limit_without_a_sticky_override(drive):
sm, cp = drive
runtime = controller()
runtime.settings = replace(runtime.settings, pedal_override=PedalOverride.OFF)
tick(runtime, sm, cp, 0)
sm['carState'].gasPressed = True
sm['carState'].vEgo = sm['carState'].vEgoCluster = 30.
sm['carControl'].longActive = False
sm['controlsState'].longControlState = 'off'
pressed = tick(runtime, sm, cp, 1)
assert not pressed.message.slcState.driverOverrideActive
sm['carState'].gasPressed = False
sm['carControl'].longActive = True
sm['controlsState'].longControlState = 'pid'
released = tick(runtime, sm, cp, 2)
assert released.result.ceiling.speed_mps == 25.
assert not released.message.slcState.driverOverrideActive
assert sm['carState'].vCruise == 100.
@pytest.mark.parametrize('mode,target', [(PedalOverride.SPEED_REACHED, 26.), (PedalOverride.SET_SPEED, 100. / 3.6)])
def test_gas_above_limit_holds_the_selected_override_after_release(drive, mode, target):
sm, cp = drive
runtime = controller()
runtime.settings = replace(runtime.settings, pedal_override=mode)
assert tick(runtime, sm, cp, 0).result.ceiling.speed_mps == 25.
sm['carState'].gasPressed = True
sm['carState'].vEgo = sm['carState'].vEgoCluster = 24.
assert not tick(runtime, sm, cp, 1).message.slcState.driverOverrideActive
sm['carState'].vEgo = sm['carState'].vEgoCluster = 26.
sm['carControl'].longActive = False
sm['controlsState'].longControlState = 'off'
pressed = tick(runtime, sm, cp, 2).message.slcState
assert pressed.overrideBasis == 'pedal'
assert pressed.effectiveClusterTarget == pytest.approx(target)
sm['carState'].gasPressed = False
assert tick(runtime, sm, cp, 3).result.ceiling is None
sm['carControl'].longActive = True
sm['controlsState'].longControlState = 'pid'
released = tick(runtime, sm, cp, 4)
assert released.message.slcState.overrideBasis == 'persistent'
assert released.result.ceiling.speed_mps == pytest.approx(target)
assert released.message.slcState.acceptedSpeedLimit == 25.
assert released.message.slcState.speedLimit == 25.
assert sm['carState'].vCruise == 100.
planner = LongitudinalPlanner(cp)
planner.update(sm, cruise_ceiling=released.result.ceiling)
assert planner.last_cruise_ceiling_status == ('applied' if mode is PedalOverride.SPEED_REACHED else 'above_driver')
sm['carControl'].enabled = sm['carControl'].longActive = sm['selfdriveState'].enabled = False
assert tick(runtime, sm, cp, 5).result.ceiling is None
sm['carControl'].enabled = sm['carControl'].longActive = sm['selfdriveState'].enabled = True
resumed = tick(runtime, sm, cp, 6)
assert resumed.result.ceiling.speed_mps == 25.
assert not resumed.message.slcState.driverOverrideActive
@pytest.mark.parametrize('clear_with', ['lower SET', 'higher limit', 'explicit accept'])
def test_override_clears_when_the_driver_or_limit_takes_back_control(drive, clear_with):
sm, cp = drive
runtime = controller()
tick(runtime, sm, cp, 0)
sm['carState'].vEgo = sm['carState'].vEgoCluster = 29.
sm['carState'].gasPressed = True
assert tick(runtime, sm, cp, 1).result.ceiling.speed_mps == pytest.approx(100. / 3.6)
sm['carState'].gasPressed = False
action = None
expected = 25.
if clear_with == 'lower SET':
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 90.
elif clear_with == 'higher limit':
sm['slcDashboardObservation'].speedMps = expected = 28.
else:
runtime.settings = replace(runtime.settings, acceptance=acc.Policy())
sm['slcDashboardObservation'].speedMps = expected = 20.
pending = tick(runtime, sm, cp, 2).message.slcState
assert pending.hasPending and pending.driverOverrideActive
action = Action(runtime.session_id, 1, pending.decisionId, pending.presentationId, 'accept')
cleared = tick(runtime, sm, cp, 3, request=action)
assert cleared.result.ceiling.speed_mps == expected
assert not cleared.message.slcState.driverOverrideActive
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 100.
assert tick(runtime, sm, cp, 4).result.ceiling.speed_mps == expected
@pytest.mark.parametrize('mode', [PedalOverride.SPEED_REACHED, PedalOverride.SET_SPEED])
def test_explicit_accept_blocks_override_until_the_held_pedal_is_released(drive, mode):
sm, cp = drive
runtime = controller()
tick(runtime, sm, cp, 0)
runtime.settings = replace(runtime.settings, acceptance=acc.Policy(), pedal_override=mode)
sm['slcDashboardObservation'].speedMps = 15.
sm['carState'].gasPressed = True
sm['carState'].vEgo = sm['carState'].vEgoCluster = 26.
prompt = tick(runtime, sm, cp, 1).message.slcState
action = Action(runtime.session_id, 1, prompt.decisionId, prompt.presentationId, 'accept')
for step, request in ((2, action), (3, None)):
accepted = tick(runtime, sm, cp, step, request=request).message.slcState
assert accepted.acceptedSpeedLimit == accepted.effectiveCap == 15.
assert not accepted.driverOverrideActive
sm['carState'].gasPressed = False
assert tick(runtime, sm, cp, 4).result.ceiling.speed_mps == 15.
sm['carState'].gasPressed = True
assert tick(runtime, sm, cp, 5).message.slcState.driverOverrideActive
@pytest.mark.parametrize('wheel', [False, True])
@pytest.mark.parametrize('in_time', [False, True])
def test_host_keeps_in_flight_confirmation_bound_to_its_capture_time(drive, tmp_path, wheel, in_time):
from openpilot.common.params import Params
from openpilot.starpilot.speed_limits.planner_host import SavedSlcLifecycle
sm, cp = drive
host = SavedSlcLifecycle(Params(str(tmp_path)), cp, {})
host.runtime = runtime = controller()
tick(runtime, sm, cp, 0)
runtime.settings = replace(runtime.settings, acceptance=acc.Policy())
sm['slcDashboardObservation'].speedMps = 15.
prompt = tick(runtime, sm, cp, 1).message.slcState
deadline = prompt.pendingSinceMonoTime + acc.CONFIRMATION_NS
observed = deadline + (-5_000_000 if in_time else 1_000_000)
actions, cruise = deque(), deque()
if wheel:
cruise.append(CruiseEvent(1, observed, 0., 0., 'accel', 'card-producer', deadline + 2_000_000,
'confirmationAccept', runtime.session_id, prompt.decisionId, prompt.presentationId))
def host_tick(now, *, card_ns=None):
sm.advance(card_ns if card_ns is not None else now)
sm['slcDashboardObservation'].validUntilMonoTime = now + 1_000_000_000
return host.step(sm, now, actions, cruise, drive_changed=False).message.slcState
expired = host_tick(deadline + 3_000_000, card_ns=deadline)
assert not expired.hasPending and expired.acceptedSpeedLimit == 25.
if wheel:
assert len(cruise) == 1
else:
actions.append((observed, Action(runtime.session_id, 1, prompt.decisionId, prompt.presentationId, 'accept', observed)))
joined = host_tick(deadline + 50_000_000)
assert not cruise and not actions
assert joined.acceptedSpeedLimit == (15. if in_time else 25.)
if not in_time:
assert host_tick(deadline + 100_000_000).status == 'timeout_reject'
def test_offset_edit_applies_to_the_previous_limit_without_accepting_the_proposal(drive):
sm, cp = drive
runtime = controller()
tick(runtime, sm, cp, 0)
runtime.settings = replace(runtime.settings, acceptance=acc.Policy(confirm_lower=True, confirm_higher=True))
sm['slcDashboardObservation'].speedMps = 30.
assert tick(runtime, sm, cp, 1).message.slcState.hasPending
runtime.update_offsets(OffsetSchedule((OffsetBand(0., None, 2.),)))
assert tick(runtime, sm, cp, 2).result.ceiling.speed_mps == 27.
sm['slcDashboardObservation'].speedMps = 25.
assert tick(runtime, sm, cp, 3).result.ceiling.speed_mps == 27.
def test_raw_cluster_conversion_is_applied_once_including_during_source_loss(drive):
sm, cp = drive
runtime = controller(2.)
sm['carState'].vEgoCluster = sm['carState'].vEgo + 1.
assert tick(runtime, sm, cp, 0).result.ceiling.speed_mps == 26.
sm['slcDashboardObservation'].status = 'unknown'
sm['carState'].vEgoCluster = sm['carState'].vEgo + .5
assert tick(runtime, sm, cp, 1).result.ceiling.speed_mps == 26.5
@pytest.mark.parametrize('pcm', [False, True])
def test_factory_longitudinal_never_gets_an_slc_cap(drive, pcm):
sm, cp = drive
cp.pcmCruise, cp.openpilotLongitudinalControl = pcm, False
sm['carControl'].longActive = False
sm['controlsState'].longControlState = 'off'
output = tick(controller(), sm, cp, 0)
assert output.result.ceiling is None
assert not output.message.slcState.hasCeiling
@pytest.mark.parametrize('pcm', [False, True])
def test_curve_press_remains_available_and_cannot_reuse_an_slc_confirmation(drive, pcm):
from opendbc.car.structs import car
from openpilot.selfdrive.car.card import curve_press_receipt
from openpilot.selfdrive.car.cruise import VCruiseHelper, SlcPendingConfirmation
sm, cp = drive
cp.pcmCruise = pcm
helper = VCruiseHelper(cp)
cs = car.CarState(canValid=True, cruiseState={'available': True, 'enabled': True, 'speed': 25.},
buttonEvents=[car.CarState.ButtonEvent(type='accelCruise', pressed=True)])
helper.update_v_cruise(cs, True, True, slc_enabled=True)
def receipt():
return curve_press_receipt(cs, helper, sm, curve_replay=True, slc_replay=False,
host_long_active=True, observed_ns=START)
assert receipt()[0] == 'curveAccelPress'
helper.update_v_cruise(cs, True, True, SlcPendingConfirmation('drive', 1, 1), slc_enabled=True)
assert receipt() is None
cs.buttonEvents = [car.CarState.ButtonEvent(type='accelCruise', pressed=False)]
helper.update_v_cruise(cs, True, True, slc_enabled=False)
assert receipt() is None
cs.standstill = True
cs.buttonEvents = [car.CarState.ButtonEvent(type='accelCruise', pressed=True)]
assert receipt() is None
@@ -1,19 +1,23 @@
"""SET and RES adopt qualified current limits, with normal resume as fallback."""
"""Fresh software SET can match a qualified limit; RES preserves the previous SET."""
from collections import deque
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
import math
import pytest
from opendbc.car.dashboard_speed_limit import Status
from opendbc.car.dashboard_speed_limit import Status, Tracker
from opendbc.car.structs import car
from openpilot.cereal import messaging
from openpilot.common.constants import CV
from openpilot.common.params import Params
from openpilot.selfdrive.car import card, cruise
from openpilot.selfdrive.controls.plannerd import queue_cruise_event
from openpilot.starpilot.speed_limits.acceptance import AcceptedLimit, Candidate, IdentityKind, ObservationIdentity
from openpilot.starpilot.speed_limits import physical_actions
from openpilot.starpilot.speed_limits.planner_host import action_from_message
from openpilot.starpilot.speed_limits.runtime import Action, CruiseEvent, Runtime
from openpilot.starpilot.speed_limits.runtime_settings import parse
from openpilot.starpilot.speed_limits.speed_domain import OffsetBand, OffsetSchedule
@@ -35,7 +39,7 @@ def drive(*, metric=False, limit=45., offset=5., confirmation=True):
sm['carControl'].enabled = sm['carControl'].longActive = False
units = CV.KPH_TO_MS if metric else CV.MPH_TO_MS
sm['slcDashboardObservation'].speedMps = limit * units
settings = replace(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': confirmation}),
settings = replace(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': int(confirmation)}),
offsets=OffsetSchedule((OffsetBand(0., None, offset * units),)))
runtime = Runtime(settings, is_metric=metric)
state = runtime.step(sm, cp, now_ns=START).message.slcState
@@ -46,13 +50,14 @@ def drive(*, metric=False, limit=45., offset=5., confirmation=True):
return runtime, sm, cp
def owner(sm, cp, *, metric=False, resume=None):
def owner(sm, cp, *, metric=False, resume=None, match=True):
instance = card.Car.__new__(card.Car)
instance.CP, instance.sm = cp, sm
instance.CC_prev = car.CarControl(enabled=False)
instance.CS_prev = car.CarState(vEgo=sm['carState'].vEgo)
instance.is_metric, instance.experimental_mode, instance.initial_cruise_speed = metric, False, 40
instance.slc_control_enabled = instance.slc_replay = True
instance.slc_match_on_engage = match
instance.slc_source_floor_ns = 0
instance.slc_producer_session, instance.slc_cruise_event_id = 'card-producer', 0
instance.slc_receipts = []
@@ -84,13 +89,75 @@ def receipt(instance, *, now=START + 10_000_000, event_id=1):
kind, str(state.sessionId), int(state.decisionId), int(state.presentationId))
@pytest.mark.parametrize('raw,match', [(None, False), (b'0', False), (b'1', True), (b'yes', False)])
def test_engagement_matching_requires_an_enabled_saved_toggle(tmp_path, raw, match):
_, sm, cp = drive()
instance = owner(sm, cp)
instance.params = Params(str(tmp_path))
assert not instance.params.get_default_value('SLCOnEngage')
if raw is not None:
Path(instance.params.get_param_path('SLCOnEngage')).write_bytes(raw)
instance.slc_match_on_engage = instance.read_slc_match_on_engage()
assert instance.slc_match_on_engage == match
engage(instance)
assert instance.v_cruise_helper.v_cruise_kph == (80.5 if match else 72.)
@pytest.mark.parametrize('resume', [False, True])
@pytest.mark.parametrize('live', [False, True])
def test_match_on_engage_uses_saved_history_as_a_fallback(resume, live):
runtime, sm, cp = drive()
previous = AcceptedLimit(Candidate('map', ObservationIdentity(IdentityKind.GEOGRAPHIC, value='old-road'), 25 * CV.MPH_TO_MS),
'previous-drive', 0)
runtime = Runtime(runtime.settings, previous_limit=previous)
if not live:
sm['slcDashboardObservation'].status = 'unknown'
prepared = runtime.step(sm, cp, now_ns=START).message.slcState
assert prepared.hasAcceptedAdjustedLimit and prepared.hasPresentationAdjustedLimit == live
sm.data['slcState'] = prepared
instance = owner(sm, cp, resume=resume)
if resume:
instance.v_cruise_helper.v_cruise_kph = instance.v_cruise_helper.v_cruise_cluster_kph = 105.
engage(instance)
assert instance.v_cruise_helper.v_cruise_kph == (105. if resume else 80.5 if live else 48.3)
assert bool(instance.slc_receipts) == (live and not resume)
def test_new_dashboard_packet_in_same_episode_matches_set_over_saved_history():
runtime, sm, cp = drive(limit=45., offset=0.)
previous = AcceptedLimit(Candidate('dashboard', ObservationIdentity(IdentityKind.PRODUCER_EPISODE, value='old-road'), 30 * CV.MPH_TO_MS),
'previous-drive', 0)
runtime = Runtime(runtime.settings, previous_limit=previous)
state = runtime.step(sm, cp, now_ns=START).message.slcState
sm.data['slcState'] = state
assert state.hasPresentationAdjustedLimit and state.acceptedMonoTime == 0
assert state.acceptedAdjustedLimit == pytest.approx(30 * CV.MPH_TO_MS)
tracker = Tracker()
tracker.update(state.sourceObservedMonoTime, Status.VALID, 45 * CV.MPH_TO_MS, valid_until_ns=state.sourceValidUntilMonoTime)
newer = tracker.update(START + 5_000_000, Status.VALID, 45 * CV.MPH_TO_MS, valid_until_ns=START + 300_000_000)
assert newer.episode == state.sourceEpisode and newer.observed_ns > state.sourceObservedMonoTime
instance = owner(sm, cp, resume=False)
instance.CI.CS.dashboard_limit.observation = newer
engage(instance)
assert instance.v_cruise_helper.v_cruise_kph == cruise.slc_target_kph(45 * CV.MPH_TO_MS, False)
event = receipt(instance)
assert event.presentation_id == state.presentationId
evidence = sm['slcDashboardObservation']
evidence.observedMonoTime, evidence.validUntilMonoTime = newer.observed_ns, newer.valid_until_ns
adopted = runtime.step(sm, cp, now_ns=event.observed_ns, cruise_event=event).message.slcState
assert adopted.actionStatus == 'adopt' and adopted.acceptedSpeedLimit == pytest.approx(45 * CV.MPH_TO_MS)
@pytest.mark.parametrize('confirmation', [False, True])
@pytest.mark.parametrize('pcm', [False, True])
@pytest.mark.parametrize('resume,previous', [(False, cruise.V_CRUISE_UNSET), (True, 30.), (None, 105.)],
ids=['set', 'resume-hint', 'resume-button'])
@pytest.mark.parametrize('metric,limit,offset', [(False, 45., 5.), (False, 20., -2.), (False, 55., -.4),
(True, 72., 5.), (True, 30., -2.), (True, 72., -.4)])
def test_prepared_limit_initializes_set_once_and_adopts_it(metric, limit, offset, confirmation, pcm, resume, previous):
def test_limit_matches_only_new_software_set_and_resume_preserves_ceiling(metric, limit, offset, confirmation, pcm, resume, previous):
runtime, sm, cp = drive(metric=metric, limit=limit, offset=offset, confirmation=confirmation)
cp.pcmCruise = pcm
prepared = sm['slcState']
@@ -101,18 +168,22 @@ def test_prepared_limit_initializes_set_once_and_adopts_it(metric, limit, offset
if resume is None:
instance.CS_prev.buttonEvents = [car.CarState.ButtonEvent(type='accelCruise', pressed=False)]
engage(instance)
expected = cruise.slc_target_kph(float(prepared.presentationAdjustedLimit), metric)
assert instance.v_cruise_helper.v_cruise_kph == expected
assert instance.v_cruise_helper.v_cruise_cluster_kph == expected
adopted = runtime.step(sm, cp, now_ns=START + 10_000_000, cruise_event=receipt(instance))
matched = not pcm and resume is False
expected = cruise.slc_target_kph(float(prepared.presentationAdjustedLimit), metric) if matched else 60. if pcm else previous
assert instance.v_cruise_helper.v_cruise_kph == pytest.approx(expected)
assert instance.v_cruise_helper.v_cruise_cluster_kph == pytest.approx(expected)
adopted = runtime.step(sm, cp, now_ns=START + 10_000_000, cruise_event=receipt(instance) if matched else None)
if not matched:
assert not instance.slc_receipts
return
assert not adopted.result.errors and adopted.message.slcState.hasAccepted
assert not adopted.message.slcState.hasPending and not adopted.message.slcState.driverOverrideActive
assert adopted.message.slcState.actionStatus == 'adopt' and adopted.command is None
assert adopted.message.slcState.actionStatus == 'adopt'
assert runtime.ledger.transactions == ()
instance.CC_prev.enabled = True
instance.slc_receipts.clear()
engage(instance, now=START + 20_000_000)
assert instance.v_cruise_helper.v_cruise_kph == expected and instance.slc_receipts == []
assert instance.v_cruise_helper.v_cruise_kph == pytest.approx(expected) and instance.slc_receipts == []
@pytest.mark.parametrize('invalid', ['disabled', 'stale', 'unknown', 'stock-long', 'can-fault', 'wrong-episode', 'configuration'])
@@ -182,12 +253,14 @@ def test_card_publication_routes_engagement_to_runtime_and_preserves_later_confi
events = deque()
queue_cruise_event(published.as_reader(), events, deque())
assert len(events) == 1 and events[0].kind == 'engagementAdopt'
assert events[0].command_id == 0
first = runtime.step(sm, cp, now_ns=START + 10_000_000, cruise_event=events.popleft())
assert first.message.slcState.hasAccepted and first.command is None
assert first.message.slcState.hasAccepted
assert first.message.slcState.wheelAdoptMonoTime == 0
sm.advance(START + 50_000_000)
sm['slcDashboardObservation'].speedMps = 55 * CV.MPH_TO_MS
later = runtime.step(sm, cp, now_ns=START + 50_000_000)
assert later.message.slcState.hasPending and later.command is None
assert later.message.slcState.hasPending
@pytest.mark.parametrize('change', ['session', 'presentation', 'producer', 'replaced-source', 'changed-set', 'stale', 'replay'])
@@ -212,7 +285,7 @@ def test_engagement_receipt_cannot_adopt_unrelated_or_stale_work(change):
else:
runtime.step(sm, cp, now_ns=START + 10_000_000, cruise_event=event)
output = runtime.step(sm, cp, now_ns=START + 10_000_000, cruise_event=event)
assert output.message.slcState.actionStatus != 'adopt' and output.command is None
assert output.message.slcState.actionStatus != 'adopt'
assert runtime.ledger.transactions == ()
@@ -238,31 +311,13 @@ def test_reengagement_clears_previous_decline(resume):
if resume:
instance.v_cruise_helper.v_cruise_kph = instance.v_cruise_helper.v_cruise_cluster_kph = 105.
engage(instance)
if resume:
assert instance.v_cruise_helper.v_cruise_kph == 105. and not instance.slc_receipts
assert runtime.state.acceptance.rejected
return
adopted = runtime.step(sm, cp, now_ns=START + 10_000_000, cruise_event=receipt(instance))
assert adopted.message.slcState.actionStatus == 'adopt' and adopted.message.slcState.hasAccepted
assert not adopted.message.slcState.hasPending and adopted.command is None
@pytest.mark.parametrize('resume', [False, True])
def test_reengagement_clears_override_even_after_previous_application(resume):
runtime, sm, cp = drive()
instance = owner(sm, cp, resume=resume)
engage(instance)
runtime.step(sm, cp, now_ns=START + 10_000_000, cruise_event=receipt(instance))
previous = float(sm['carState'].vCruise) / 3.6
sm.advance(START + 20_000_000)
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 105.
overridden = runtime.step(sm, cp, now_ns=START + 20_000_000,
cruise_event=CruiseEvent(2, START + 20_000_000, previous, 105 / 3.6, 'accel', 'card-producer', START + 20_000_000))
assert overridden.message.slcState.driverOverrideActive
sm.data['slcState'] = overridden.message.slcState
instance.CC_prev.enabled = False
instance.slc_receipts.clear()
engage(instance, now=START + 30_000_000)
adopted = runtime.step(sm, cp, now_ns=START + 30_000_000,
cruise_event=receipt(instance, now=START + 30_000_000, event_id=3))
assert adopted.message.slcState.actionStatus == 'adopt' and not adopted.message.slcState.driverOverrideActive
assert adopted.message.slcState.commandStatus == 'applied' and adopted.command is None
assert not adopted.message.slcState.hasPending
@pytest.mark.parametrize('source', ['Maps', 'Vision'])
@@ -296,8 +351,11 @@ def test_engagement_uses_existing_priority_and_qualified_secondary_source(source
if resume:
instance.v_cruise_helper.v_cruise_kph = instance.v_cruise_helper.v_cruise_cluster_kph = 105.
engage(instance)
expected = cruise.slc_target_kph(float(prepared.presentationAdjustedLimit), False)
assert instance.v_cruise_helper.v_cruise_kph == expected
expected = 105. if resume else cruise.slc_target_kph(float(prepared.presentationAdjustedLimit), False)
assert instance.v_cruise_helper.v_cruise_kph == pytest.approx(expected)
if resume:
assert not instance.slc_receipts
return
# ReplaySM.advance uses monotonic stamps for every service; mapd uses boottime.
if source == 'Maps':
sm.logMonoTime['mapdOut'] = boot
@@ -307,14 +365,17 @@ def test_engagement_uses_existing_priority_and_qualified_secondary_source(source
@pytest.mark.parametrize('confirmation', [False, True])
@pytest.mark.parametrize('resume', [False, True])
def test_engagement_uses_current_limit_over_previous_early_proposal(confirmation, resume):
@pytest.mark.parametrize('live', [False, True])
def test_engagement_never_initializes_set_from_a_preview(confirmation, resume, live):
from openpilot.starpilot.speed_limits.tests.test_advance import Drive
preview = Drive(confirm=confirmation)
output = preview.tick()
assert output.message.slcState.upcomingBoundaryId
preview.sm['carControl'].enabled = preview.sm['carControl'].longActive = False
prepared = preview.tick().message.slcState
prepared = preview.tick(alive=live).message.slcState
assert prepared.acceptedIsPreview == (not confirmation)
sm, cp = preview.sm, preview.cp
cp.pcmCruise = False
sm.data['slcState'] = prepared
sm.valid['slcState'] = sm.alive['slcState'] = True
sm.logMonoTime['slcState'] = preview.now
@@ -323,11 +384,22 @@ def test_engagement_uses_current_limit_over_previous_early_proposal(confirmation
instance.v_cruise_helper.v_cruise_kph = instance.v_cruise_helper.v_cruise_cluster_kph = 105.
stamp = preview.now + 10_000_000
engage(instance, now=stamp)
if resume:
assert instance.v_cruise_helper.v_cruise_kph == 105. and not instance.slc_receipts
return
if not live:
assert instance.v_cruise_helper.v_cruise_kph == (79. if confirmation else 72.)
assert not instance.slc_receipts
return
assert instance.v_cruise_helper.v_cruise_kph == 79.
adopted = preview.tick(at=stamp, event=receipt(instance, now=stamp))
assert adopted.message.slcState.acceptedSpeedLimit == 20.
assert not adopted.message.slcState.hasPending and not adopted.message.slcState.upcomingBoundaryId
assert adopted.message.slcState.actionStatus == 'adopt' and adopted.command is None
assert adopted.message.slcState.actionStatus == 'adopt'
turned = preview.tick(way=3, target=0.).message.slcState
assert not turned.acceptedIsPreview and turned.acceptedSpeedLimit == 20.
assert sm['carState'].vCruise == 79.
@pytest.mark.parametrize('current', [20., 25.])
@@ -339,6 +411,7 @@ def test_delayed_engagement_is_bound_to_current_limit(current, resume):
preview.sm['carControl'].enabled = preview.sm['carControl'].longActive = False
prepared = preview.tick().message.slcState
sm, cp = preview.sm, preview.cp
cp.pcmCruise = False
sm.data['slcState'] = prepared
sm.valid['slcState'] = sm.alive['slcState'] = True
sm.logMonoTime['slcState'] = preview.now
@@ -347,11 +420,14 @@ def test_delayed_engagement_is_bound_to_current_limit(current, resume):
instance.v_cruise_helper.v_cruise_kph = instance.v_cruise_helper.v_cruise_cluster_kph = 105.
stamp = preview.now + 10_000_000
engage(instance, now=stamp)
if resume:
assert instance.v_cruise_helper.v_cruise_kph == 105. and not instance.slc_receipts
return
event = receipt(instance, now=stamp)
pending = preview.tick(at=stamp + 40_000_000, current=current).message.slcState
delayed = preview.tick(at=stamp + 90_000_000, current=current, event=event)
state = delayed.message.slcState
assert not delayed.result.errors and delayed.command is None
assert not delayed.result.errors
assert sm['carState'].vCruise == 79. and state.acceptedSpeedLimit == 20.
assert (state.actionStatus == 'adopt') == (current == 20.)
assert state.hasPending == (current != 20.)
@@ -359,3 +435,94 @@ def test_delayed_engagement_is_bound_to_current_limit(current, resume):
assert state.presentationId != event.presentation_id
assert state.decisionId == pending.decisionId
assert state.pendingSinceMonoTime == pending.pendingSinceMonoTime
def wheel_approval(runtime, sm, cp, *, wheel_ns, now):
request = messaging.new_message('slcAction', logMonoTime=now)
request.slcAction = {'sessionId': runtime.session_id, 'sequenceId': now, 'kind': 'adopt',
'presentationId': sm['slcState'].presentationId, 'wheelAdoptMonoTime': wheel_ns}
sm.advance(now)
sm['slcDashboardObservation'].validUntilMonoTime = now + 1_000_000_000
state = runtime.step(sm, cp, now_ns=now, request=action_from_message(request)[1]).message.slcState
assert state.actionStatus == 'adopt' and state.wheelAdoptMonoTime == wheel_ns
return state
@pytest.mark.parametrize('case', ['valid', 'disengaged', 'newer-set', 'held-set-button', 'expired', 'different-press', 'no-local-press'])
def test_wheel_adopt_only_applies_to_its_own_eligible_press(case):
runtime, sm, cp = drive(limit=45., offset=5., confirmation=True)
instance = owner(sm, cp, match=False)
engage(instance)
helper = instance.v_cruise_helper
wheel_ns = START + 20_000_000
if case != 'no-local-press':
instance.slc_wheel_adopt = (wheel_ns + int(case == 'different-press'), helper.v_cruise_kph)
now = wheel_ns + (240_000_000 if case == 'expired' else 30_000_000)
sm.data['slcState'] = wheel_approval(runtime, sm, cp, wheel_ns=wheel_ns, now=now)
if case == 'newer-set':
helper.v_cruise_kph -= 1.6
elif case == 'held-set-button':
sm['carState'].buttonEvents = [car.CarState.ButtonEvent(type='decelCruise', pressed=True)]
helper.update_v_cruise(sm['carState'], True, False)
before = helper.v_cruise_kph
read_ns = wheel_ns + 250_000_001 if case == 'expired' else now
instance.apply_slc_adopt(read_ns, host_control_enabled=case != 'disengaged')
assert helper.v_cruise_kph == (cruise.slc_target_kph(50 * CV.MPH_TO_MS, False) if case == 'valid' else before)
if case == 'valid':
assert instance.slc_wheel_adopt is None
helper.v_cruise_kph = before
instance.apply_slc_adopt(read_ns, host_control_enabled=True)
assert helper.v_cruise_kph == before
def test_next_automatic_limit_cannot_reuse_wheel_approval():
runtime, sm, cp = drive(limit=45., offset=5., confirmation=False)
instance = owner(sm, cp, match=False)
engage(instance)
before = instance.v_cruise_helper.v_cruise_kph
wheel_ns = START + 20_000_000
instance.slc_wheel_adopt = (wheel_ns, before)
approved = wheel_approval(runtime, sm, cp, wheel_ns=wheel_ns, now=START + 50_000_000)
assert approved.wheelAdoptTargetMps == pytest.approx(50 * CV.MPH_TO_MS)
now = START + 100_000_000
sm.advance(now)
sm['slcDashboardObservation'].speedMps = 50 * CV.MPH_TO_MS
sm['slcDashboardObservation'].episode += 1
state = runtime.step(sm, cp, now_ns=now).message.slcState
assert state.actionSequenceId == approved.actionSequenceId and state.actionStatus == 'adopt'
assert state.acceptedAdjustedLimit == pytest.approx(55 * CV.MPH_TO_MS)
assert state.wheelAdoptMonoTime == 0
sm.data['slcState'] = state
instance.apply_slc_adopt(now, host_control_enabled=True)
assert instance.v_cruise_helper.v_cruise_kph == before
@pytest.mark.parametrize('proof', ['qualified', 'expired', 'unacknowledged', 'lateral-only', 'missing'])
def test_stopped_set_matches_limit_before_longitudinal_activates(proof):
_, sm, cp = drive(limit=40., offset=0., confirmation=False)
assert sm['slcState'].hasAccepted
now = START + 10_000_000
if proof != 'missing':
sm.data['aolAxisState'] = messaging.new_message('aolAxisState').aolAxisState
axis = sm['aolAxisState']
axis.sessionId, axis.qualified = 'axis', True
axis.nativeAcknowledged = proof != 'unacknowledged'
axis.desiredLongitudinal = proof != 'lateral-only'
axis.observedMonoTime = START
axis.validUntilMonoTime = now - 1 if proof == 'expired' else now + 10_000_000
sm.valid['aolAxisState'] = sm.alive['aolAxisState'] = True
sm.seen = {'aolAxisState': True}
sm.advance(now)
if proof != 'missing':
sm.logMonoTime['aolAxisState'] = START
sm['carControl'].enabled = True
sm['carState'].vEgo = sm['carState'].vEgoCluster = 0.
sm['carState'].standstill = sm['carState'].brakePressed = True
instance = owner(sm, cp, resume=False)
instance.v_cruise_helper.v_cruise_kph = instance.v_cruise_helper.v_cruise_cluster_kph = 64.4
with patch.object(card.time, 'monotonic_ns', return_value=now):
instance.update_cruise_speed(sm['carState'])
expected = 64.4 if proof == 'qualified' else cruise.V_CRUISE_INITIAL
assert instance.v_cruise_helper.v_cruise_kph == expected
assert bool(instance.slc_receipts) == (proof == 'qualified')
@@ -0,0 +1,76 @@
"""The fixed previous-limit fallback survives restarts without inventing source evidence."""
import json
from dataclasses import replace
import pytest
from openpilot.common.params import Params
from openpilot.selfdrive.controls.plannerd import SavedSlcLifecycle
from openpilot.starpilot.speed_limits import acceptance as acc, history
from openpilot.starpilot.speed_limits.runtime import Action
from openpilot.starpilot.ui.tests.test_slc_retention import MPH, NOW, RetentionDrive
from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed
@pytest.mark.parametrize('source', ['dashboard', 'map', 'vision'])
def test_saved_previous_limit_caps_native_planner_after_restart_without_a_current_source(tmp_path, source):
drive = RetentionDrive(tmp_path)
accepted, _, _ = drive.step(0)
previous = accepted.result.state.acceptance.accepted
previous = replace(previous, candidate=replace(previous.candidate, source=source))
params = Params(str(tmp_path))
params.put('SLCQualifiedHistory', json.loads(history.encode(previous, 1)), block=True)
params.put('SLCConfirmation', 1, block=True)
lifecycle = SavedSlcLifecycle(params, drive.cp, {})
drive.runtime, _ = lifecycle.refresh(NOW)
drive.sm['slcDashboardObservation'].status = 'unknown'
output, view, target = drive.step(1000, received=False)
assert not output.result.errors
assert target == pytest.approx(45, abs=1e-4)
assert output.message.slcState.observationKind != 'valid'
assert output.message.slcState.presentationId == 0
assert output.message.slcState.hasCeiling and not output.message.slcState.hasPending
shown = resolve_unified_speed(view)
assert (shown.posted_text, shown.status) == ('45', 'Unavailable')
assert shown.source == source
proposed, _, target = drive.step(1050, limit=50)
assert proposed.message.slcState.hasPending
assert target == pytest.approx(45, abs=1e-4)
state = proposed.message.slcState
action = Action(drive.runtime.session_id, 1, state.decisionId, state.presentationId, 'accept')
adopted, _, target = drive.step(1100, limit=50, request=action)
assert not adopted.message.slcState.hasPending
assert target == pytest.approx(50, abs=1e-4)
assert drive.sm['carState'].vCruise == pytest.approx(70 * MPH * 3.6)
@pytest.mark.parametrize('field,value', [
('source_schema', 'wrong'), ('version', True), ('version', 2), ('saved_wall_ns', 0),
('source', 'online'), ('source', 'unknown'), ('speed_mps', 0), ('speed_mps', -1),
('speed_mps', True), ('speed_mps', float('nan')), ('speed_mps', float('inf')),
('identity_kind', 'invalid'), ('identity_value', ''), ('origin_session', ' '),
])
def test_invalid_saved_previous_limit_cannot_authorize_a_cap(field, value):
candidate = acc.Candidate('dashboard', acc.ObservationIdentity(acc.IdentityKind.PRODUCER_EPISODE, 'card:1'), 20.)
saved = json.loads(history.encode(acc.AcceptedLimit(candidate, 'drive', 1), 1))
saved[field] = value
assert history.decode(saved, session_id='next-drive') is None
@pytest.mark.parametrize('raw', [None, b'', b'{', b'null', b'[]', b'{}', b'[' * 1500 + b']' * 1500])
def test_unreadable_previous_limit_is_absent(raw):
assert history.decode(raw, session_id='next-drive') is None
def test_oversized_saved_previous_limit_does_not_stop_runtime_startup(tmp_path):
drive = RetentionDrive(tmp_path)
params = Params(str(tmp_path))
with open(params.get_param_path('SLCQualifiedHistory'), 'wb') as stream:
stream.write(b' ' * (history.MAX_BYTES + 1))
drive.runtime, _ = SavedSlcLifecycle(params, drive.cp, {}).refresh(NOW)
output, _, target = drive.step(1000, received=False)
assert not output.result.errors
assert not output.message.slcState.hasCeiling
assert target == pytest.approx(70, abs=1e-4)
@@ -261,7 +261,7 @@ class MapSourceTests(unittest.TestCase):
authority = acc.Authority(acc.Mode.COMBINED, acc.LongitudinalOwner.SYSTEM, True, True, False, False)
decision = acc.step(acc.new_session('drive', prior), chosen.observation, authority,
acc.Policy(), now_ns=456_100_000_000)
self.assertIsNone(decision.control_target_mps)
self.assertEqual(decision.control_target_mps, prior.candidate.speed_mps)
self.assertEqual(decision.basis, 'unknown')
self.assertEqual(decision.state.accepted, prior)
self.assertIsNone(decision.history_write)
@@ -54,18 +54,20 @@ class SM:
def test_maps_priority_slots_and_defaults_use_actual_runtime_projection():
sm = SM(packet())
cp = NS(carFingerprint='vehicle')
for first, second, expected in (('Maps', 'Dashboard', sel.Source.MAP),
('Dashboard', 'Maps', sel.Source.DASHBOARD),
('Vision', 'Maps', sel.Source.MAP)):
settings = parse({'SpeedLimitController': True, 'SLCPriority1': first, 'SLCPriority2': second})
for first, second, third, expected in (('Maps', 'Dashboard', 'Off', sel.Source.MAP),
('Dashboard', 'Maps', 'Off', sel.Source.DASHBOARD),
('Vision', 'Maps', 'Off', sel.Source.MAP),
('Vision', 'Off', 'Maps', sel.Source.MAP),
('Vision', 'Off', 'Off', None)):
settings = parse({'SpeedLimitController': True, 'SLCPriority1': first, 'SLCPriority2': second, 'SLCPriority3': third})
runtime = Runtime(settings, session_id='drive')
observations = runtime._observations(sm, cp, MONO, BOOT)
chosen = sel.select_limit(observations, settings.selection)
assert chosen.selected_source is expected
settings = parse({})
assert settings.selection.slots == (sel.Source.VISION, sel.Source.DASHBOARD)
assert settings.selection.slots == (sel.Source.VISION, sel.Source.MAP, None)
chosen = sel.select_limit(Runtime(settings)._observations(sm, cp, MONO, BOOT), settings.selection)
assert chosen.selected_source is sel.Source.DASHBOARD
assert chosen.selected_source is sel.Source.MAP
assert parse({'SLCPriority1': 'Map Data'}).selection.slots[0] is sel.Source.MAP
@@ -83,21 +85,25 @@ def test_expired_map_falls_through_to_current_dashboard_without_renewal():
assert values[sel.Source.MAP].candidate is None
def test_map_loss_no_metadata_and_lookahead_keep_history_without_control_target():
def test_map_loss_no_metadata_and_lookahead_reuse_only_the_previous_accepted_limit():
authority = acc.Authority(acc.Mode.COMBINED, acc.LongitudinalOwner.SYSTEM, True, True, False, False)
prior = acc.AcceptedLimit(acc.Candidate('map', acc.ObservationIdentity(acc.IdentityKind.GEOGRAPHIC, value='previous-way'), 20.), 'drive', 0)
frame = ms.decode_event(packet().to_bytes())
without_limit = replace(frame, status='matchedNoLimit', speed_mps=0.)
for changed in (replace(frame, way_selection='possible'), replace(frame, way_selection='predicted'),
replace(frame, way_selection='extended'), replace(frame, version=1),
replace(frame, tile_loaded=False), replace(frame, event_mono_ns=BOOT+1),
replace(frame, status='matchedNoLimit', speed_mps=0.),
without_limit, replace(without_limit, way_selection='possible'),
replace(without_limit, event_valid=False), replace(without_limit, event_mono_ns=BOOT+1),
replace(frame, status='noCoverage', event_valid=False, tile_loaded=False, way_id=0, speed_mps=0.)):
observation = MapSource().step(changed, received=True, now_boot_ns=BOOT,
source = MapSource()
observation = source.step(changed, received=True, now_boot_ns=BOOT,
transport_alive=True, drive_session='drive', car_fingerprint='vehicle')
assert source.way_id == (frame.way_id if changed is without_limit else 0)
assert observation.candidate is None
assert observation.kind is not acc.ObservationKind.ABSENT
decision = acc.step(acc.new_session('drive', prior), observation, authority, acc.Policy(), now_ns=MONO)
assert decision.control_target_mps is None
assert decision.control_target_mps == prior.candidate.speed_mps
assert decision.state.accepted == prior
assert decision.history_write is None
@@ -138,9 +144,13 @@ def test_actual_saved_maps_priority_starts_existing_readonly_provider_without_ne
with tempfile.TemporaryDirectory() as directory:
params = Params(directory)
cp = NS(notCar=False)
assert read_params(params).selection.slots == (sel.Source.VISION, sel.Source.DASHBOARD)
assert params.get_default_value('SLCPriority2') == 'Map Data'
assert read_params(params).selection.slots == (sel.Source.VISION, sel.Source.MAP, None)
assert shadow_lifecycle.enabled(True, params, cp)
params.put('SLCPriority2', 'Dashboard', block=True)
assert not shadow_lifecycle.enabled(True, params, cp)
params.put('SLCPriority2', 'Map Data', block=True)
params.put('SLCPriority2', 'Off', block=True)
params.put('SLCPriority3', 'Map Data', block=True)
assert shadow_lifecycle.enabled(True, params, cp)
assert not Path(params.get_param_path('MapdShadowEnabled')).exists()
params.put_bool('ShowSpeedLimits', False, block=True)
@@ -152,7 +162,7 @@ def test_actual_saved_maps_priority_starts_existing_readonly_provider_without_ne
assert not shadow_lifecycle.enabled(True, params, cp)
def test_shared_settings_owner_maps_labels_both_slots_defaults_and_ready_change_guard():
def test_shared_settings_owner_maps_labels_defaults_and_ready_change_guard():
with tempfile.TemporaryDirectory() as directory:
params = Params(directory)
ready = True
@@ -162,14 +172,22 @@ def test_shared_settings_owner_maps_labels_both_slots_defaults_and_ready_change_
state = owner.snapshot('slc', parked=True, system_long=False, lateral_context=False, metric=False)
return next(r for r in state.rows if r.key == key)
assert row('SLCPriority1').value == 'Vision'
assert row('SLCPriority2').value == 'Dashboard'
for key in ('SLCPriority1', 'SLCPriority2'):
assert row('SLCPriority2').value == row('SLCPriority2').default_value == 'Maps'
assert row('SLCPriority3').value == row('SLCPriority3').default_value == 'Off'
params.put('SLCPriority2', 'Dashboard', block=True)
for key in ('SLCPriority1', 'SLCPriority2', 'SLCPriority3'):
assert 'Maps' in row(key).choices
assert ('Off' in row(key).choices) == (key != 'SLCPriority1')
while row(key).value != 'Maps':
request = row_change(row(key))
assert request is not None
assert owner.apply(request)
assert params.get(key) == 'Map Data'
if key != 'SLCPriority1':
request = row_change(row(key))
assert request is not None and request.value == 'Off'
assert owner.apply(request)
assert params.get(key) == 'Off'
request = row_change(row('SLCPriority1'))
assert request is not None
ready = False
@@ -181,11 +199,10 @@ def test_shared_settings_owner_maps_labels_both_slots_defaults_and_ready_change_
assert pages.index('curve') < pages.index('slc')
def test_manager_default_demand_does_not_read_full_slc_settings():
def test_manager_skips_full_slc_settings_read_when_saved_priorities_exclude_maps():
with tempfile.TemporaryDirectory() as directory:
params = Params(directory)
with patch('openpilot.starpilot.speed_limits.runtime_settings.read_params', side_effect=AssertionError('full scan')):
assert not shadow_lifecycle.enabled(True, params, NS(notCar=False))
params.put('SLCPriority1', 'Vision', block=True)
params.put('SLCPriority2', 'Dashboard', block=True)
assert not shadow_lifecycle.enabled(True, params, NS(notCar=False))
@@ -283,7 +300,6 @@ def test_actual_runtime_maps_display_only_publishes_sign_without_ceiling_or_comm
assert state.observationKind == 'valid' and state.source == 'map'
assert abs(state.speedLimit-13.4112) < .001
assert not state.hasCeiling and result.result.ceiling is None
assert result.command is None
assert not state.hasPending and not state.hasAccepted
# Keep the real cereal envelopes live for the complete reader lifetime.
assert len(envelopes) == 7 and dashboard.which() == 'slcDashboardObservation'
@@ -0,0 +1,155 @@
"""Protect limit decisions against legacy Maps conversion noise on the Float32 wire."""
from dataclasses import replace
import math
import struct
import pytest
from openpilot.starpilot.speed_limits import acceptance as acc, history
from openpilot.starpilot.speed_limits.map_observation import repair_legacy_map_speed
from openpilot.starpilot.speed_limits.runtime_settings import parse
from openpilot.starpilot.speed_limits.speed_domain import OffsetBand, OffsetSchedule
from openpilot.starpilot.speed_limits.tests.test_advance import Drive
def numeric_drive(metric=True):
drive = Drive(higher=0, lower=0)
drive.sm['carState'].vCruise = drive.sm['carState'].vCruiseCluster = 140.
drive.runtime.is_metric = metric
drive.runtime.settings = replace(drive.runtime.settings,
selection=parse({'SLCPriority1': 'Dashboard', 'SLCPriority2': 'Maps'}).selection,
offsets=OffsetSchedule((OffsetBand(0., None, 0.),)))
return drive
@pytest.mark.parametrize('metric', [False, True])
@pytest.mark.parametrize('posted', [30, 50, 80, 100, 130, 250])
def test_legacy_map_conversion_does_not_reprompt_for_the_same_limit(metric, posted):
drive = numeric_drive(metric)
exact = posted / 3.6
# Frozen maps.ParseMaxSpeed conversion; source correction must also account for existing tiles.
mapped = posted * .277778
first = drive.tick(current=mapped, dashboard=exact, target=0.)
accepted = first.result.acceptance.state.accepted.candidate.speed_mps
drive.runtime.settings = replace(drive.runtime.settings, acceptance=acc.Policy())
switched = drive.tick(current=mapped, dashboard=None, target=0.)
state = switched.message.slcState
assert state.source == 'map' and not state.hasPending
assert state.speedLimit == accepted
assert switched.result.ceiling == first.result.ceiling
@pytest.mark.parametrize('exact,mapped', [
(50 / 3.6, 50 * (1 / 3.6)),
(45 * .44704, 45 * 1.609344 / 3.6),
(45 * .44704, math.nextafter(45 * .44704, math.inf)),
])
def test_equivalent_arithmetic_collapses_to_the_same_live_wire_value(exact, mapped):
drive = numeric_drive()
first = drive.tick(current=mapped, dashboard=exact, target=0.)
accepted = first.result.acceptance.state.accepted.candidate.speed_mps
drive.runtime.settings = replace(drive.runtime.settings, acceptance=acc.Policy())
switched = drive.tick(current=mapped, dashboard=None, target=0.)
assert switched.message.slcState.speedLimit == accepted
assert not switched.message.slcState.hasPending
@pytest.mark.parametrize('abbreviated', [False, True])
def test_alternating_same_posted_sources_keeps_the_original_deadline(abbreviated):
drive = numeric_drive()
drive.runtime.settings = replace(drive.runtime.settings, acceptance=acc.Policy())
exact = 50 / 3.6
mapped = 50 * .277778 if abbreviated else exact
decision_ids, starts, timeout_frames = set(), set(), []
for index in range(801):
output = drive.tick(current=mapped, dashboard=exact if (index // 10) % 2 == 0 else None,
target=0., at=drive.now+50_000_000)
state = output.message.slcState
assert not output.result.errors
if state.hasPending:
decision_ids.add(state.decisionId)
starts.add(state.pendingSinceMonoTime)
if output.result.acceptance.basis == 'timeout_reject':
timeout_frames.append(index)
assert not state.hasPending and len(decision_ids) == len(starts) == 1
assert timeout_frames == [600]
def test_legacy_signature_sweep():
def f32(value):
return struct.unpack('f', struct.pack('f', value))[0]
for posted in range(4, 251):
assert repair_legacy_map_speed(f32(posted * .277778)) == f32(posted / 3.6)
assert repair_legacy_map_speed(f32(posted / 3.6)) == f32(posted / 3.6)
for posted in range(3, 251):
assert repair_legacy_map_speed(f32(posted * .44704)) == f32(posted * .44704)
for speed in (f32(50 / 3.6 + .00004), 1e300):
assert repair_legacy_map_speed(speed) == speed
def test_upcoming_legacy_limit_matches_at_crossing():
drive = numeric_drive()
drive.runtime.update_advance(5, 5)
drive.tick(current=50 * .277778, target=0.)
drive.runtime.settings = replace(drive.runtime.settings, acceptance=acc.Policy())
preview = drive.tick(current=50 * .277778, target=55 * .277778).message.slcState
assert preview.hasPending and preview.pendingSpeedLimit == preview.upcomingSpeedLimit
crossed = drive.tick(current=55 / 3.6, target=0., way=2).message.slcState
assert crossed.speedLimit == preview.upcomingSpeedLimit
assert crossed.decisionId == preview.decisionId
assert crossed.pendingSinceMonoTime == preview.pendingSinceMonoTime
@pytest.mark.parametrize('source', ['map', 'dashboard', 'vision'])
def test_restore_repairs_only_saved_maps_limits(source):
drive = numeric_drive()
road = drive.tick(current=50 * .277778, target=0.)
saved = road.result.acceptance.state.accepted
# The saved record predates the repair and carries the original wire value.
legacy = float(drive.sm['mapdOut'].speedLimit)
saved = replace(saved, candidate=replace(saved.candidate, source=source, speed_mps=legacy))
restored = history.decode(history.encode(saved, 1), session_id=drive.runtime.session_id)
assert restored is not None
if source != 'map':
assert restored.candidate.speed_mps == legacy
return
drive.runtime.state = replace(drive.runtime.state, acceptance=acc.new_session(drive.runtime.session_id, restored))
drive.runtime.settings = replace(drive.runtime.settings, acceptance=acc.Policy())
state = drive.tick(current=50 / 3.6, dashboard=50 / 3.6, target=0.).message.slcState
assert state.speedLimit == restored.candidate.speed_mps and not state.hasPending
def test_same_numeric_producer_restart_keeps_decision_but_retires_the_old_press():
drive = numeric_drive()
drive.runtime.settings = replace(drive.runtime.settings, acceptance=acc.Policy())
first = drive.tick(current=50 / 3.6, target=0.).message.slcState
replacement = drive.tick(current=50 / 3.6, target=0., producer=42).message.slcState
assert replacement.decisionId == first.decisionId
assert replacement.pendingSinceMonoTime == first.pendingSinceMonoTime
assert replacement.presentationId != first.presentationId
acted = drive.tick(current=50 / 3.6, target=0., producer=42, action=drive.action(first, 'accept'))
assert acted.message.slcState.hasPending
assert acted.message.slcState.acceptedSpeedLimit == 20.
def test_plain_source_gap_keeps_numeric_decision_and_original_expiry():
drive = numeric_drive()
drive.runtime.settings = replace(drive.runtime.settings, acceptance=acc.Policy())
first = drive.tick(current=50 / 3.6, target=0.).message.slcState
lost = drive.tick(current=50 / 3.6, target=0., alive=False).message.slcState
assert lost.decisionId == first.decisionId and lost.presentationId == first.presentationId
expired = drive.tick(current=50 / 3.6, target=0., at=first.pendingSinceMonoTime+acc.CONFIRMATION_NS)
assert not expired.message.slcState.hasPending
assert expired.result.acceptance.basis == 'timeout_reject'
@pytest.mark.parametrize('before,after,metric', [(40 / 3.6, 41 / 3.6, False), (45 * .44704, 46 * .44704, True)])
def test_real_one_unit_changes_still_ask_even_when_a_display_can_hide_the_difference(before, after, metric):
drive = numeric_drive(metric)
first = drive.tick(current=before, dashboard=before, target=0.)
drive.runtime.settings = replace(drive.runtime.settings, acceptance=acc.Policy())
changed = drive.tick(current=after, dashboard=after, target=0.)
assert changed.message.slcState.hasPending
assert changed.message.slcState.pendingSpeedLimit != first.message.slcState.acceptedSpeedLimit
if not metric:
assert round(before / .44704) == round(after / .44704) == 25
@@ -1,373 +0,0 @@
"""Override intent requires the acceptance decision and causal ledger together."""
import unittest
from dataclasses import replace
from openpilot.starpilot.speed_limits import acceptance as acc
from openpilot.starpilot.speed_limits import action_arbitration as arb
from openpilot.starpilot.speed_limits import overrides as ov
SESSION = "drive"
HIGH = acc.Candidate("dashboard", acc.ObservationIdentity(acc.IdentityKind.SESSION_VALUE, session_id=SESSION), 20.0)
LOW = acc.Candidate("map", acc.ObservationIdentity(acc.IdentityKind.GEOGRAPHIC, value="road-2"), 15.0)
SAME = acc.Candidate("map", acc.ObservationIdentity(acc.IdentityKind.GEOGRAPHIC, value="road-3"), 20.0)
ACTIVE = acc.Authority(acc.Mode.LONGITUDINAL_ONLY, acc.LongitudinalOwner.SYSTEM, False, True, False, False)
POLICY = acc.Policy(confirm_lower=False, confirm_higher=False)
PEDAL_OFF = ov.PedalEvidence(ov.EvidenceKind.VALID, False)
def observation(candidate=HIGH):
return acc.Observation(acc.ObservationKind.VALID, candidate)
class OverrideTests(unittest.TestCase):
def setUp(self):
state = replace(acc.new_session(SESSION, acc.AcceptedLimit(HIGH, SESSION, 0)), received_speed_mps=HIGH.speed_mps)
self.accepted = acc.step(state, observation(), ACTIVE, POLICY, now_ns=0)
self.state = ov.new_session(SESSION)
self.selected = ov.SelectedEvidence(ov.EvidenceKind.VALID, 20.0)
self.first = self.reduce(0)
def decision(self, now, *, candidate=HIGH, authority=ACTIVE, policy=POLICY, source=None, adopt=None):
obs = observation(candidate) if source is None else acc.Observation(source)
result = acc.step(self.accepted.state, obs, authority, policy, now_ns=now, adopt=adopt)
self.accepted = result
return result
def reduce(self, now, *, acceptance=None, authority=ACTIVE, policy=POLICY, selected=None, pedal=PEDAL_OFF, change=None):
result = ov.step(self.state, self.accepted if acceptance is None else acceptance, authority, policy,
self.selected if selected is None else selected, pedal, now_ns=now, change=change)
self.state = result.state
return result
def classified(self, *, effect=1, now=2, previous=20.0, chosen=25.0, context=None, disposition=arb.Disposition.DRIVER_INTENT):
ledger = arb.new_ledger(SESSION)
context = self.first.context if context is None else context
ledger = arb.step(ledger, arb.BeginAction(SESSION, 1, arb.Origin.DRIVER_CRUISE, context.context_id), now_ns=1).state
ledger = arb.step(ledger, arb.ResolveAction(SESSION, 1, disposition), now_ns=1).state
result = arb.step(ledger, arb.CruiseChange(SESSION, effect, 1, previous, chosen), now_ns=now)
return result.change
def arm(self, now=2, chosen=25.0):
change = self.classified(now=now, chosen=chosen)
self.decision(now)
return self.reduce(now, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, chosen), change=change)
def test_fresh_causal_intent_persists_and_scalar_does_not_rearm(self):
armed = self.arm()
self.assertEqual(armed.contribution_mps, 25.0)
self.assertEqual(armed.event_receipt.status, "driver_intent")
self.assertTrue(armed.event_receipt.consumed)
self.decision(3)
self.assertEqual(self.reduce(3, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0)).contribution_mps, 25.0)
self.decision(4, candidate=replace(LOW, source=HIGH.source))
self.assertIsNone(self.reduce(4, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0)).contribution_mps)
self.decision(5, candidate=LOW)
self.assertIsNone(self.reduce(5, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0)).contribution_mps)
def test_decrease_only_reduces_an_existing_override(self):
for existing, previous, chosen, expected in (
(False, 30.0, 29.0, 29.0), (False, 30.0, 20.0, None), (False, 30.0, 19.0, 19.0),
(True, 25.0, 24.0, 24.0), (True, 25.0, 20.0, None), (True, 25.0, 19.0, 19.0),
(True, 30.0, 29.0, 29.0), (True, 30.0, 25.0, 25.0),
):
with self.subTest(existing=existing, chosen=chosen):
self.setUp()
if existing:
self.arm()
self.decision(2)
self.reduce(2, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, previous))
self.decision(3)
result = self.reduce(3, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, chosen),
change=self.classified(effect=2, now=3, previous=previous, chosen=chosen))
self.assertFalse(result.errors)
self.assertTrue(result.event_receipt.consumed)
self.assertEqual(result.state.persistent_selected_mps, expected)
self.assertEqual(result.contribution_mps, expected)
self.assertEqual(self.accepted.control_target_mps, HIGH.speed_mps)
def test_new_adoption_clears_override_and_equal_received_speed_does_not(self):
self.arm(chosen=25.)
self.decision(3, candidate=SAME)
self.assertEqual(self.reduce(3, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.)).contribution_mps, 25.)
mid = replace(SAME, speed_mps=22.)
self.decision(4, candidate=mid)
self.assertIsNone(self.reduce(4, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.)).contribution_mps)
def test_pedal_tracks_current_ego_and_release_restores_persistence(self):
self.arm()
self.decision(3)
self.assertEqual(self.reduce(3, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0),
pedal=ov.PedalEvidence(ov.EvidenceKind.VALID, True, 28.0)).contribution_mps, 25.0)
self.decision(4)
self.assertEqual(self.reduce(4, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0),
pedal=ov.PedalEvidence(ov.EvidenceKind.VALID, True, 23.0)).contribution_mps, 25.0)
self.decision(5)
self.assertEqual(self.reduce(5, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0)).contribution_mps, 25.0)
def test_temporary_inactivity_invalidates_origin_and_grace_expires(self):
self.arm()
paused = replace(ACTIVE, longitudinal_active=False)
self.decision(3, authority=paused)
self.assertIsNone(self.reduce(3, authority=paused, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0)).context)
self.decision(3 + ov.SUSPENSION_NS - 1, authority=paused)
self.reduce(3 + ov.SUSPENSION_NS - 1, authority=paused, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0))
self.decision(3 + ov.SUSPENSION_NS - 1)
resumed = self.reduce(3 + ov.SUSPENSION_NS - 1, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0))
self.assertEqual(resumed.contribution_mps, 25.0)
self.assertNotEqual(resumed.context.context_id, self.first.context.context_id)
self.decision(ov.SUSPENSION_NS + 10, authority=paused)
self.reduce(ov.SUSPENSION_NS + 10, authority=paused, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0))
self.decision(2 * ov.SUSPENSION_NS + 10)
self.assertEqual(self.reduce(2 * ov.SUSPENSION_NS + 10, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0)).contribution_mps, 25.)
def test_stale_missing_and_display_only_suppress_output_and_watermark(self):
self.arm()
change = self.classified(effect=2, now=3, chosen=26.0)
self.decision(3, source=acc.ObservationKind.STALE)
stale = self.reduce(3, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 26.0), change=change)
self.assertEqual(stale.contribution_mps, 26.)
self.assertEqual(stale.state.last_effect_id, 2)
self.decision(4)
self.assertEqual(self.reduce(4, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0), change=change).event_receipt.status, "already_seen")
display = acc.Policy(confirm_lower=False, confirm_higher=False, display_only=True)
self.decision(5, policy=display)
self.assertIsNone(self.reduce(5, policy=display, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0)).contribution_mps)
def test_source_gaps_preserve_the_manual_choice(self):
for kind in (acc.ObservationKind.ABSENT, acc.ObservationKind.UNKNOWN, acc.ObservationKind.STALE):
with self.subTest(kind=kind):
self.setUp()
self.arm()
self.decision(3, source=kind)
result = self.reduce(3, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0))
self.assertEqual(result.contribution_mps, 25.0)
self.assertEqual(result.context, self.first.context)
def test_delayed_acceptance_consumed_effect_cannot_arm_after_release(self):
pending_policy = acc.Policy(confirm_lower=True, confirm_higher=True)
state = replace(acc.new_session(SESSION, acc.AcceptedLimit(HIGH, SESSION, 0)), received_speed_mps=HIGH.speed_mps)
pending = acc.step(state, observation(LOW), ACTIVE, pending_policy, now_ns=0)
action = acc.DriverAction(SESSION, 1, pending.state.pending.decision_id, acc.ActionKind.ACCEPT)
ledger = arb.step(arb.new_ledger(SESSION), arb.BeginAction(SESSION, 1, arb.Origin.DRIVER_CRUISE,
self.first.context.context_id), now_ns=1).state
accepted = acc.step(pending.state, observation(LOW), ACTIVE, pending_policy, now_ns=2, action=action)
ledger = arb.resolve_acceptance(ledger, 1, accepted, now_ns=2).state
delayed = arb.step(ledger, arb.CruiseChange(SESSION, 1, 1, 20.0, 25.0), now_ns=60_000_000_000).change
self.assertEqual(delayed.disposition, arb.Disposition.SLC_CONSUMED)
self.decision(60_000_000_000)
result = self.reduce(60_000_000_000, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0), change=delayed)
self.assertIsNone(result.contribution_mps)
self.assertEqual(result.state.last_effect_id, 1)
def test_adoption_clears_once_and_current_pedal_still_contributes(self):
self.arm()
shown = self.decision(3, candidate=LOW)
adopt = acc.AdoptRequest(SESSION, 2, shown.state.presentation.presentation_id, LOW)
adopted = self.decision(4, candidate=LOW, adopt=adopt)
result = self.reduce(4, acceptance=adopted, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0),
pedal=ov.PedalEvidence(ov.EvidenceKind.VALID, True, 27.0))
self.assertIsNone(result.state.persistent_selected_mps)
self.assertIsNone(result.contribution_mps)
self.assertNotEqual(result.context.context_id, self.first.context.context_id)
duplicate = self.reduce(4, acceptance=adopted, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0))
self.assertEqual(duplicate.context, result.context)
def test_mismatched_snapshot_and_inactive_origin_fail_closed(self):
change = self.classified()
self.decision(2)
mismatch = self.reduce(2, policy=replace(POLICY, confirm_lower=True), change=change)
self.assertTrue(mismatch.errors)
self.assertEqual(mismatch.state.last_effect_id, 1)
self.assertTrue(mismatch.state.reset_required)
self.decision(3)
self.assertIsNone(self.reduce(3).context)
self.state = ov.new_session(SESSION)
old = self.reduce(3).context
paused = replace(ACTIVE, longitudinal_active=False)
self.decision(4, authority=paused)
self.reduce(4, authority=paused)
self.decision(5)
late = replace(change, effect_id=2, context_id=old.context_id, started_at_ns=4)
result = self.reduce(5, change=late, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0))
self.assertIsNone(result.contribution_mps)
self.assertEqual(result.event_receipt.status, "unqualified_effect")
def test_pending_lower_and_rejection_keep_accepted_target_and_intent(self):
self.arm()
confirmation = acc.Policy(confirm_lower=True, confirm_higher=True)
lower = replace(LOW, source=HIGH.source)
pending = self.decision(3, candidate=lower, policy=confirmation)
self.assertEqual(pending.basis, "pending")
self.assertEqual(self.reduce(3, policy=confirmation, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0)).contribution_mps, 25.0)
action = acc.DriverAction(SESSION, 2, pending.state.pending.decision_id, acc.ActionKind.REJECT)
rejected = acc.step(pending.state, observation(lower), ACTIVE, confirmation, now_ns=4, action=action)
self.accepted = rejected
self.assertEqual(self.reduce(4, policy=confirmation, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0)).contribution_mps, 25.0)
self.assertIn(lower, rejected.state.rejected)
def test_provider_flapping_preserves_override_until_the_new_limit_qualifies(self):
self.arm()
for now, candidate in ((3, LOW), (4, HIGH), (5, LOW)):
self.decision(now, candidate=candidate)
retained = self.reduce(now, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.))
self.assertEqual(retained.contribution_mps, 25.)
now = 5 + acc.SOURCE_SWITCH_NS
self.decision(now, candidate=LOW)
adopted = self.reduce(now, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.))
self.assertIsNone(adopted.contribution_mps)
def test_mode_owner_change_suppresses_output_without_forgetting_manual_choice(self):
self.arm()
stock = acc.Authority(acc.Mode.LONGITUDINAL_ONLY, acc.LongitudinalOwner.STOCK, False, False, True, False)
self.decision(3, authority=stock)
self.reduce(3, authority=stock, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0))
self.assertEqual(self.state.persistent_selected_mps, 25.)
self.decision(4)
self.assertEqual(self.reduce(4, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0)).contribution_mps, 25.)
def test_previous_session_history_does_not_arm_overrides_without_a_live_limit(self):
prior = acc.AcceptedLimit(acc.Candidate("map", acc.ObservationIdentity(acc.IdentityKind.GEOGRAPHIC, value="road"), 20.0),
"prior-drive", 0)
policy = acc.Policy()
decision = acc.step(acc.new_session(SESSION, prior), acc.Observation(acc.ObservationKind.ABSENT), ACTIVE, policy, now_ns=1)
result = ov.step(ov.new_session(SESSION), decision, ACTIVE, policy, self.selected, PEDAL_OFF, now_ns=1)
self.assertFalse(result.errors)
self.assertIsNone(result.context)
def test_automatic_foreign_and_completed_effects_are_watermarked_without_intent(self):
self.decision(2)
automatic = replace(self.classified(now=2), disposition=arb.Disposition.AUTOMATIC)
got = self.reduce(2, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0), change=automatic)
self.assertIsNone(got.contribution_mps)
self.assertEqual(got.state.last_effect_id, 1)
self.decision(3)
foreign = replace(automatic, session_id="other", effect_id=2, disposition=arb.Disposition.DRIVER_INTENT)
self.assertEqual(self.reduce(3, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0), change=foreign).event_receipt.status,
"foreign_session")
self.decision(4)
completed = replace(automatic, effect_id=2, action_id=1, context_id=None, disposition=arb.Disposition.UNRESOLVED)
result = self.reduce(4, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0), change=completed)
self.assertIsNone(result.contribution_mps)
self.assertEqual(result.state.last_effect_id, 2)
def test_inconsistent_acceptance_target_and_reversed_clock_fail_closed(self):
self.decision(2)
forged = replace(self.accepted, control_target_mps=21.0)
bad = self.reduce(2, acceptance=forged)
self.assertTrue(bad.errors)
self.assertIsNone(bad.contribution_mps)
self.assertTrue(bad.state.reset_required)
reversed_clock = self.reduce(1)
self.assertTrue(reversed_clock.errors)
def test_zero_ego_speed_is_valid_but_negative_bool_and_nan_are_not(self):
self.arm()
for now, pressed in ((3, True), (4, False)):
self.decision(now)
zero = self.reduce(now, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0),
pedal=ov.PedalEvidence(ov.EvidenceKind.VALID, pressed, 0.0))
self.assertFalse(zero.errors)
self.assertEqual(zero.contribution_mps, 25.0)
for invalid in (-0.1, True, float("nan")):
with self.subTest(invalid=invalid):
self.decision(5)
bad = ov.step(self.state, self.accepted, ACTIVE, POLICY, ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0),
ov.PedalEvidence(ov.EvidenceKind.VALID, True, invalid), now_ns=5)
self.assertTrue(bad.errors)
self.assertTrue(bad.state.reset_required)
def test_malformed_effect_latches_session_and_cannot_replay_after_repair(self):
self.arm()
fresh = self.classified(effect=2, now=4, chosen=26.0)
self.decision(3)
malformed = replace(fresh, selected_mps=float("nan"))
bad = self.reduce(3, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 26.0), change=malformed)
self.assertTrue(bad.state.reset_required)
self.assertIsNone(bad.state.persistent_selected_mps)
self.assertIsNone(bad.state.context)
self.decision(4)
retry = self.reduce(4, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 26.0), change=fresh)
self.assertIsNone(retry.contribution_mps)
self.assertEqual(retry.basis, "session_reset_required")
next_candidate = acc.Candidate("map", acc.ObservationIdentity(acc.IdentityKind.GEOGRAPHIC, value="new-road"), 20.0)
next_acceptance = acc.step(acc.new_session("next-drive"), observation(next_candidate), ACTIVE, POLICY, now_ns=0)
next_state = ov.step(ov.new_session("next-drive"), next_acceptance, ACTIVE, POLICY, self.selected, PEDAL_OFF, now_ns=0)
next_acceptance = acc.step(next_acceptance.state, observation(next_candidate), ACTIVE, POLICY, now_ns=1)
replay = ov.step(next_state.state, next_acceptance, ACTIVE, POLICY, ov.SelectedEvidence(ov.EvidenceKind.VALID, 26.0),
PEDAL_OFF, now_ns=1, change=fresh)
self.assertIsNone(replay.contribution_mps)
self.assertEqual(replay.event_receipt.status, "foreign_session")
def test_malformed_state_is_inert(self):
forged = replace(self.state, context=replace(self.first.context, context_id="caller-proof"))
self.decision(2)
result = ov.step(forged, self.accepted, ACTIVE, POLICY, self.selected, PEDAL_OFF, now_ns=2)
self.assertTrue(result.errors)
self.assertIsNone(result.contribution_mps)
self.assertIs(result.state, forged)
def test_reversed_clock_clears_previously_armed_state_and_stays_latched(self):
self.arm()
bad = self.reduce(1)
self.assertTrue(bad.state.reset_required)
self.assertIsNone(bad.state.persistent_selected_mps)
self.assertIsNone(bad.context)
self.decision(3)
recovered_input = self.reduce(3)
self.assertEqual(recovered_input.basis, "session_reset_required")
self.assertIsNone(recovered_input.contribution_mps)
def test_target_change_requires_new_action_from_the_new_eligible_context(self):
self.arm()
old_effect = self.classified(effect=2, now=3, chosen=26.0)
self.decision(3, candidate=replace(LOW, source=HIGH.source))
lower = self.reduce(3, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 26.0), change=old_effect)
self.assertIsNone(lower.contribution_mps)
self.assertFalse(lower.event_receipt.consumed)
self.assertNotEqual(lower.context, self.first.context)
ledger = arb.step(arb.new_ledger(SESSION), arb.BeginAction(SESSION, 2, arb.Origin.DRIVER_CRUISE,
lower.context.context_id), now_ns=4).state
ledger = arb.step(ledger, arb.ResolveAction(SESSION, 2, arb.Disposition.DRIVER_INTENT), now_ns=4).state
fresh = arb.step(ledger, arb.CruiseChange(SESSION, 3, 2, 26.0, 27.0), now_ns=5).change
self.decision(5, candidate=LOW)
rearmed = self.reduce(5, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 27.0), change=fresh)
self.assertTrue(rearmed.event_receipt.consumed)
self.assertEqual(rearmed.contribution_mps, 27.0)
def test_missing_selected_or_pedal_evidence_suppresses_output(self):
self.arm()
for now, selected, pedal in (
(3, ov.SelectedEvidence(ov.EvidenceKind.STALE), PEDAL_OFF),
(4, ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0), ov.PedalEvidence(ov.EvidenceKind.UNKNOWN)),
(5, ov.SelectedEvidence(ov.EvidenceKind.ABSENT), PEDAL_OFF),
):
self.decision(now)
result = self.reduce(now, selected=selected, pedal=pedal)
self.assertIsNone(result.contribution_mps)
self.assertIsNone(result.context)
def test_same_value_adoption_rotates_once_and_delayed_effect_is_consumed(self):
self.arm()
shown = self.decision(3)
request = acc.AdoptRequest(SESSION, 2, shown.state.presentation.presentation_id, HIGH)
ledger = arb.step(arb.new_ledger(SESSION), arb.BeginAction(SESSION, 2, arb.Origin.DRIVER_CRUISE,
self.first.context.context_id), now_ns=3).state
adopted = self.decision(4, adopt=request)
ledger = arb.resolve_acceptance(ledger, 2, adopted, now_ns=4).state
result = self.reduce(4, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0))
self.assertIsNone(result.state.persistent_selected_mps)
self.assertNotEqual(result.context.context_id, self.first.context.context_id)
again = self.reduce(4, acceptance=adopted, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 25.0))
self.assertEqual(again.context, result.context)
delayed = arb.step(ledger, arb.CruiseChange(SESSION, 3, 2, 25.0, 20.0), now_ns=10_000_000_000).change
self.assertEqual(delayed.disposition, arb.Disposition.SLC_CONSUMED)
self.decision(10_000_000_000)
no_rearm = self.reduce(10_000_000_000, selected=ov.SelectedEvidence(ov.EvidenceKind.VALID, 20.0), change=delayed)
self.assertIsNone(no_rearm.contribution_mps)
self.assertEqual(no_rearm.event_receipt.status, "unqualified_effect")
if __name__ == "__main__":
unittest.main()
@@ -1,8 +1,9 @@
"""Pending choices are source independent; selected-speed commands are not."""
"""Card-side validation for confirmation, engagement, and active caps."""
import math
import pytest
from opendbc.car.dashboard_speed_limit import Observation, Status
from openpilot.cereal import messaging
from openpilot.selfdrive.car.cruise import SlcPendingConfirmation
@@ -11,7 +12,7 @@ from openpilot.starpilot.speed_limits.selection import Source
from openpilot.starpilot.speed_limits.tests.test_runtime_replay import ReplaySM
NOW = 2_000_000_000
NOW = 60_000_000_000
@pytest.fixture
@@ -20,37 +21,36 @@ def sm():
state.enabled, state.observationKind, state.source = True, 'valid', 'map'
state.sessionId, state.frameMonoTime = 'drive', NOW
state.hasPending, state.pendingSpeedLimit, state.speedLimit = True, 25.0, 25.0
state.pendingSource, state.pendingSinceMonoTime = 'map', NOW
state.decisionId, state.presentationId = 1, 2
return ReplaySM({'slcState': state}, NOW)
@pytest.mark.parametrize('source', tuple(Source))
def test_confirm_any_source_without_dashboard_evidence(sm, source):
sm['slcState'].source = source.value
sm['slcState'].pendingSource = source.value
assert physical_actions.pending_confirmation(sm, NOW, long_active=True) == SlcPendingConfirmation('drive', 1, 2)
# A pending choice alone must never qualify a selected-speed command.
current = physical_actions.state_current(sm, None, 'card', NOW)
assert (current is not None) == (source is not Source.DASHBOARD)
def test_retained_choice_still_consumes_a_press_as_its_published_deadline_passes(sm):
state = sm['slcState']
state.observationKind, state.source, state.pendingSource = 'unknown', 'none', 'map'
state.presentationId, state.pendingSinceMonoTime = 0, NOW
published = NOW + 9_990_000_000
state.pendingSinceMonoTime = NOW
published = NOW + physical_actions.CONFIRMATION_NS - 10_000_000
state.frameMonoTime = sm.logMonoTime['slcState'] = published
# The fresh displayed frame still has the choice; a late press must not adjust SET.
assert physical_actions.pending_confirmation(sm, published + 20_000_000, long_active=True) is not None
assert physical_actions.pending_confirmation(sm, published + physical_actions.STATE_MAX_AGE_NS + 1, long_active=True) is None
assert physical_actions.state_current(sm, None, 'card', published) is None
@pytest.mark.parametrize('field,value', [
('enabled', False), ('displayOnly', True), ('observationKind', 'unknown'), ('observationKind', 'absent'),
('observationKind', 'stale'), ('source', 'none'), ('source', ''), ('source', 'unsupported'), ('sessionId', ''),
('enabled', False), ('displayOnly', True), ('sessionId', ''), ('automaticAdoptionPending', True),
('pendingSource', 'none'), ('pendingSource', ''), ('pendingSource', 'unsupported'),
('pendingSinceMonoTime', 0), ('pendingSinceMonoTime', NOW + 1),
('pendingSinceMonoTime', NOW - physical_actions.CONFIRMATION_NS - 1),
('frameMonoTime', 0), ('frameMonoTime', NOW + 1), ('frameMonoTime', NOW - physical_actions.STATE_MAX_AGE_NS - 1),
('hasPending', False), ('decisionId', 0), ('presentationId', 0), ('pendingSpeedLimit', 0.0),
('pendingSpeedLimit', math.nan), ('pendingSpeedLimit', math.inf), ('pendingSpeedLimit', 26.0), ('speedLimit', math.nan),
('pendingSpeedLimit', math.nan), ('pendingSpeedLimit', math.inf),
])
def test_reject_invalid_or_unavailable_pending_decision(sm, field, value):
setattr(sm['slcState'], field, value)
@@ -64,6 +64,28 @@ def test_reject_invalid_or_unavailable_pending_decision(sm, field, value):
def test_reject_invalid_or_stale_transport(sm, valid, alive, stamp):
sm.valid['slcState'], sm.alive['slcState'], sm.logMonoTime['slcState'] = valid, alive, stamp
assert physical_actions.pending_confirmation(sm, NOW, long_active=True) is None
sm['slcState'].hasCeiling, sm['slcState'].effectiveCap = True, 20.
assert physical_actions.active_ceiling(sm, NOW) is None
@pytest.mark.parametrize('kind', ['valid', 'unknown', 'stale', 'absent'])
def test_active_ceiling_includes_a_qualified_retained_cap(sm, kind):
state = sm['slcState']
state.hasCeiling, state.effectiveCap, state.observationKind = True, 20., kind
assert physical_actions.active_ceiling(sm, NOW) == 20.
assert physical_actions.active_ceiling(sm, NOW, after_ns=NOW) is None
@pytest.mark.parametrize('field,value', [
('enabled', False), ('displayOnly', True), ('sessionId', ''), ('hasCeiling', False),
('frameMonoTime', 0), ('frameMonoTime', NOW + 1), ('frameMonoTime', NOW - physical_actions.STATE_MAX_AGE_NS - 1),
('effectiveCap', 0.), ('effectiveCap', -1.), ('effectiveCap', math.nan), ('effectiveCap', math.inf), ('effectiveCap', 146 / 3.6),
])
def test_active_ceiling_rejects_unqualified_caps(sm, field, value):
state = sm['slcState']
state.hasCeiling, state.effectiveCap = True, 20.
setattr(state, field, value)
assert physical_actions.active_ceiling(sm, NOW) is None
@pytest.mark.parametrize('long_active,after_ns', [(False, 0), (True, NOW)])
@@ -92,3 +114,33 @@ def test_engagement_rejects_unqualified_current_target(sm, field, value):
def test_engagement_accepts_float32_cruise_boundaries(sm, target):
sm['slcState'].hasPresentationAdjustedLimit, sm['slcState'].presentationAdjustedLimit = True, target
assert physical_actions.engagement_state(sm, None, 'card', NOW) is not None
@pytest.mark.parametrize('state_changes, local_changes, after_ns, matches', [
pytest.param({}, {}, 0, True, id='newer-same-episode'),
pytest.param({}, {'observed_ns': NOW - 100_000_000, 'valid_until_ns': NOW + 50_000_000}, 0, True, id='same-packet'),
pytest.param({}, {'observed_ns': NOW - 100_000_000}, 0, False, id='inconsistent-same-packet'),
pytest.param({'sourceValidUntilMonoTime': NOW}, {}, 0, True, id='at-planner-expiry'),
pytest.param({}, {'valid_until_ns': NOW}, 0, True, id='at-local-expiry'),
pytest.param({'sourceValidUntilMonoTime': NOW - 1}, {}, 0, False, id='expired-planner-sample'),
pytest.param({}, {'valid_until_ns': NOW - 1}, 0, False, id='expired-local-sample'),
pytest.param({}, {'observed_ns': NOW - 100_000_001}, 0, False, id='older-local-sample'),
pytest.param({}, {'observed_ns': NOW + 1}, 0, False, id='future-local-sample'),
pytest.param({'sourceObservedMonoTime': NOW - 10_000_000}, {}, 0, False, id='sample-after-planner-frame'),
pytest.param({}, {'episode': 2}, 0, False, id='new-episode'),
pytest.param({}, {'speed_mps': 24.}, 0, False, id='different-speed'),
pytest.param({'sourceProducerSessionId': 'old-card'}, {}, 0, False, id='different-producer'),
pytest.param({}, {}, NOW - 100_000_000, False, id='planner-sample-before-settings-change'),
])
def test_dashboard_engagement_qualifies_both_samples(sm, state_changes, local_changes, after_ns, matches):
state = sm['slcState']
state.source, state.sourceProducerSessionId, state.sourceEpisode = 'dashboard', 'card', 1
state.frameMonoTime = sm.logMonoTime['slcState'] = NOW - 20_000_000
state.sourceObservedMonoTime, state.sourceValidUntilMonoTime = NOW - 100_000_000, NOW + 50_000_000
state.hasPresentationAdjustedLimit, state.presentationAdjustedLimit = True, 25.
for field, value in state_changes.items():
setattr(state, field, value)
local = Observation(**({'status': Status.VALID, 'speed_mps': 25., 'episode': 1,
'observed_ns': NOW - 5_000_000, 'valid_until_ns': NOW + 145_000_000} | local_changes))
assert (physical_actions.engagement_state(sm, local, 'card', NOW, after_ns=after_ns) is not None) == matches
@@ -186,7 +186,7 @@ class RuntimeReplayTests(unittest.TestCase):
self.assertIn('slcState', process.sm.ignore_alive)
self.assertIn('slcState', process.sm.ignore_valid)
self.assertIn('slcState', process.sm.ignore_average_freq)
self.assertIsNotNone(process.slc_command_sock)
self.assertFalse(hasattr(process, 'slc_command_sock'))
self.assertIn('slcDashboardObservation', process.pm.sock)
shared_producer = process.slc_producer_session
params.put_bool('SpeedLimitController', True, block=True)
@@ -196,18 +196,12 @@ class RuntimeReplayTests(unittest.TestCase):
self.assertEqual(process.slc_control_enabled, active)
self.assertEqual(process.CP.openpilotLongitudinalControl, active)
self.assertTrue(params.get_bool('SpeedLimitController'))
process.slc_commands.append(object())
process.slc_command_session = 'retired-planner-session'
process.slc_last_command_id = 4
params.put_bool('SpeedLimitController', False, block=True)
params.put_bool('ShowSpeedLimits', True, block=True)
process.slc_requested_configuration = process.read_slc_configuration()
process.refresh_slc_configuration(2_000_000_000)
self.assertTrue(process.slc_replay)
self.assertFalse(process.slc_control_enabled)
self.assertFalse(process.slc_commands)
self.assertEqual(process.slc_command_session, '')
self.assertEqual(process.slc_last_command_id, 0)
self.assertEqual(process.slc_producer_session, shared_producer)
params.put('SLCPriority1', 'broken source', block=True)
process.slc_requested_configuration = process.read_slc_configuration()
@@ -256,6 +250,8 @@ class RuntimeReplayTests(unittest.TestCase):
state.slcState.speedLimit = 25.0
state.slcState.hasPending = True
state.slcState.pendingSpeedLimit = 25.0
state.slcState.pendingSource = 'dashboard'
state.slcState.pendingSinceMonoTime = base_ns
state.slcState.decisionId = 1
state.slcState.presentationId = 1
state.slcState.sourceProducerSessionId = 'ipc-card'
@@ -305,6 +301,8 @@ class RuntimeReplayTests(unittest.TestCase):
snapshot.speedLimit = 25.0
snapshot.hasPending = True
snapshot.pendingSpeedLimit = 25.0
snapshot.pendingSource = 'dashboard'
snapshot.pendingSinceMonoTime = base_ns
snapshot.decisionId = 1
snapshot.presentationId = 1
snapshot.sourceProducerSessionId = 'ipc-card'
@@ -349,157 +347,9 @@ class RuntimeReplayTests(unittest.TestCase):
self.assertEqual(helper.v_cruise_kph, 70.0)
self.assertFalse(helper.slc_suppressed_buttons)
def test_physical_accept_creates_bounded_card_command_and_receipt(self):
settings = parse({'SpeedLimitController': True, 'SLCConfirmation': True,
'SLCConfirmationHigher': True, 'SLCPriority1': 'Dashboard'})
runtime = Runtime(settings, session_id='physical-drive')
self.sm['carState'].vCruise = 60.0
self.sm['carState'].vCruiseCluster = 60.0
first = runtime.step(self.sm, self.cp, now_ns=2_000_000_000)
self.assertTrue(first.message.slcState.hasPending)
self.sm.advance(2_010_000_000)
initial = runtime.step(self.sm, self.cp, now_ns=2_010_000_000,
request=Action(runtime.session_id, 1, first.message.slcState.decisionId,
first.message.slcState.presentationId, 'accept'))
self.assertTrue(initial.message.slcState.hasAccepted)
source = self.sm['slcDashboardObservation']
source.speedMps = 30.0
source.observedMonoTime = 2_040_000_000
source.episode = 2
self.sm.advance(2_050_000_000)
state = runtime.step(self.sm, self.cp, now_ns=2_050_000_000).message.slcState
self.assertTrue(state.hasPending)
self.assertEqual(state.sourceProducerSessionId, 'card-producer')
self.sm.advance(2_100_000_000)
accepted = runtime.step(self.sm, self.cp, now_ns=2_100_000_000,
cruise_event=CruiseEvent(1, 2_100_000_000, 0.0, 0.0, 'accel', 'card-producer',
2_100_000_000, 'confirmationAccept', runtime.session_id,
state.decisionId, state.presentationId))
self.assertEqual(accepted.action_result.status, 'resolved')
self.assertTrue(accepted.message.slcState.hasAccepted)
self.assertFalse(accepted.message.slcState.hasPending)
self.assertEqual(accepted.message.slcState.actionStatus, 'accept')
self.assertEqual(runtime.ledger.transactions, ())
self.assertIsNotNone(accepted.command)
command = accepted.command.slcCruiseCommand
self.assertLessEqual(command.expiresMonoTime - command.issuedMonoTime, physical_actions.COMMAND_MAX_AGE_NS)
card_sm = ReplaySM({'slcState': accepted.message.slcState}, 2_100_000_000)
observation = SimpleNamespace(status=Status.VALID, speed_mps=30.0, observed_ns=2_040_000_000,
valid_until_ns=9_000_000_000, episode=2)
current = physical_actions.state_current(card_sm, observation, 'card-producer', 2_100_000_000)
self.assertIsNotNone(current)
self.assertTrue(physical_actions.command_applicable(command, current, observation, 'card-producer',
2_100_000_000, 60.0 / 3.6,
long_active=True, button_event=False))
self.assertFalse(physical_actions.command_applicable(command, current, observation, 'card-producer',
2_100_000_000, 65.0 / 3.6,
long_active=True, button_event=False))
self.assertFalse(physical_actions.command_applicable(command, current, observation, 'card-producer',
2_100_000_000, 60.0 / 3.6,
long_active=True, button_event=True))
self.assertFalse(physical_actions.command_applicable(command, current, observation, 'card-producer',
2_100_000_000, 60.0 / 3.6,
long_active=False, button_event=False))
command.commandId += 1
self.assertFalse(physical_actions.command_applicable(command, current, observation, 'card-producer',
2_100_000_000, 60.0 / 3.6,
long_active=True, button_event=False))
command.commandId -= 1
command.presentationId += 1
self.assertFalse(physical_actions.command_applicable(command, current, observation, 'card-producer',
2_100_000_000, 60.0 / 3.6,
long_active=True, button_event=False))
command.presentationId -= 1
command.sourceEpisode = 99
self.assertFalse(physical_actions.command_applicable(command, current, observation, 'card-producer',
2_100_000_000, 60.0 / 3.6,
long_active=True, button_event=False))
command.sourceEpisode = 2
command.expiresMonoTime = 2_099_000_000
self.assertFalse(physical_actions.command_applicable(command, current, observation, 'card-producer',
2_100_000_000, 60.0 / 3.6,
long_active=True, button_event=False))
command.expiresMonoTime = 2_200_000_000
command.targetMps = math.nan
self.assertFalse(physical_actions.command_applicable(command, current, observation, 'card-producer',
2_100_000_000, 60.0 / 3.6,
long_active=True, button_event=False))
command.targetMps = 30.0
self.assertIsNone(physical_actions.state_current(card_sm, observation, 'card-producer', 2_210_000_000))
# The latest conflated CarState can already contain a newer driver choice
# when the historical card-owned application receipt reaches plannerd.
self.sm.advance(2_160_000_000)
self.sm['carState'].vCruise = 115.0
self.sm['carState'].vCruiseCluster = 115.0
def receipt(event_id, observed_ns, previous_mps, selected_mps):
return CruiseEvent(event_id, observed_ns, previous_mps, selected_mps, 'unknown', 'card-producer',
2_150_000_000, 'commandApplied', runtime.session_id, state.decisionId,
state.presentationId, command.commandId)
wrong_time = runtime.step(self.sm, self.cp, now_ns=2_160_000_000,
cruise_event=receipt(2, 2_090_000_000, 60.0 / 3.6, 30.0))
self.assertEqual(wrong_time.message.slcState.commandStatus, 'issued')
wrong_previous = runtime.step(self.sm, self.cp, now_ns=2_160_000_000,
cruise_event=receipt(3, 2_150_000_000, 20.0, 30.0))
self.assertEqual(wrong_previous.message.slcState.commandStatus, 'issued')
wrong_target = runtime.step(self.sm, self.cp, now_ns=2_160_000_000,
cruise_event=receipt(4, 2_150_000_000, 60.0 / 3.6, 20.0))
self.assertEqual(wrong_target.message.slcState.commandStatus, 'issued')
applied = runtime.step(self.sm, self.cp, now_ns=2_160_000_000,
cruise_event=receipt(5, 2_150_000_000, 60.0 / 3.6, 30.0))
self.assertEqual(applied.message.slcState.commandStatus, 'applied')
self.sm.advance(2_170_000_000)
newer_driver = runtime.step(self.sm, self.cp, now_ns=2_170_000_000,
cruise_event=CruiseEvent(6, 2_160_000_000, 30.0, 115.0 / 3.6, 'accel',
'card-producer', 2_160_000_000))
self.assertEqual(newer_driver.message.slcState.commandStatus, 'applied')
def test_first_action_zero_can_issue_and_apply_command(self):
settings = parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True, 'SLCConfirmationHigher': True})
runtime = Runtime(settings, session_id='first-action')
self.sm['carState'].vCruise = 60.0
self.sm['carState'].vCruiseCluster = 60.0
pending = runtime.step(self.sm, self.cp, now_ns=2_000_000_000).message.slcState
self.assertTrue(pending.hasPending)
self.sm.advance(2_050_000_000)
accepted = runtime.step(self.sm, self.cp, now_ns=2_050_000_000,
cruise_event=CruiseEvent(1, 2_050_000_000, 0.0, 0.0, 'accel', 'card-producer',
2_050_000_000, 'confirmationAccept', runtime.session_id,
pending.decisionId, pending.presentationId))
self.assertIsNotNone(accepted.command)
self.assertEqual(accepted.command.slcCruiseCommand.actionId, 0)
self.assertEqual(accepted.message.slcState.commandId, accepted.command.slcCruiseCommand.commandId)
card_sm = ReplaySM({'slcState': accepted.message.slcState}, 2_050_000_000)
observation = SimpleNamespace(status=Status.VALID, speed_mps=25.0, observed_ns=1_900_000_000,
valid_until_ns=9_000_000_000, episode=1)
self.assertTrue(physical_actions.command_applicable(
accepted.command.slcCruiseCommand, card_sm['slcState'], observation, 'card-producer',
2_050_000_000, 60.0 / 3.6, long_active=True, button_event=False))
def test_late_bounded_card_receipt_supersedes_clock_expiry(self):
settings = parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True, 'SLCConfirmationHigher': True})
runtime = Runtime(settings, session_id='late-receipt')
self.sm['carState'].vCruise = 60.0
self.sm['carState'].vCruiseCluster = 60.0
pending = runtime.step(self.sm, self.cp, now_ns=2_000_000_000).message.slcState
self.sm.advance(2_010_000_000)
accepted = runtime.step(self.sm, self.cp, now_ns=2_010_000_000,
cruise_event=CruiseEvent(1, 2_010_000_000, 0.0, 0.0, 'accel', 'card-producer',
2_010_000_000, 'confirmationAccept', runtime.session_id,
pending.decisionId, pending.presentationId))
self.assertIsNotNone(accepted.command)
command = accepted.command.slcCruiseCommand
after_expiry = int(command.expiresMonoTime) + 10_000_000
actual_application = int(command.expiresMonoTime) - 10_000_000
self.sm.advance(after_expiry)
result = runtime.step(self.sm, self.cp, now_ns=after_expiry,
cruise_event=CruiseEvent(2, actual_application, 60.0 / 3.6, 25.0, 'unknown',
'card-producer', actual_application, 'commandApplied',
runtime.session_id, pending.decisionId, pending.presentationId,
command.commandId))
self.assertEqual(result.message.slcState.commandStatus, 'applied')
def test_physical_decel_rejects_without_selected_speed_command(self):
settings = parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True, 'SLCConfirmationLower': True})
settings = parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': 1})
runtime = Runtime(settings, session_id='physical-reject')
first = runtime.step(self.sm, self.cp, now_ns=2_000_000_000)
self.assertTrue(first.message.slcState.hasPending)
@@ -524,255 +374,18 @@ class RuntimeReplayTests(unittest.TestCase):
self.assertEqual(rejected.result.acceptance.basis, 'driver_reject')
self.assertEqual(rejected.result.acceptance.state.accepted, first.result.acceptance.state.accepted)
self.assertFalse(rejected.message.slcState.hasPending)
self.assertIsNone(rejected.command)
self.assertEqual(runtime.ledger.transactions, ())
def test_identified_ui_adopt_uses_same_card_owned_command_path(self):
runtime = self.accepted_runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard'}), session_id='ui-adopt-drive')
self.sm['carState'].vCruise = 60.0
self.sm['carState'].vCruiseCluster = 60.0
def test_identified_ui_adopt_updates_the_cap_without_changing_set(self):
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCConfirmation': 1, 'SLCPriority1': 'Dashboard'}))
shown = runtime.step(self.sm, self.cp, now_ns=2_000_000_000).message.slcState
self.assertTrue(shown.hasAccepted)
self.sm.advance(2_050_000_000)
adopted = runtime.step(self.sm, self.cp, now_ns=2_050_000_000,
request=Action(runtime.session_id, 1, 0, shown.presentationId, 'adopt'))
request=Action(runtime.session_id, 1, 0, shown.presentationId, 'adopt'))
self.assertEqual(adopted.message.slcState.actionStatus, 'adopt')
self.assertIsNotNone(adopted.command)
self.assertEqual(adopted.command.slcCruiseCommand.presentationId, shown.presentationId)
self.assertEqual(adopted.command.slcCruiseCommand.actionId, 0)
self.assertEqual(adopted.command.slcCruiseCommand.decisionId, 0)
card_sm = ReplaySM({'slcState': adopted.message.slcState}, 2_050_000_000)
observation = SimpleNamespace(status=Status.VALID, speed_mps=25.0, observed_ns=1_900_000_000,
valid_until_ns=9_000_000_000, episode=1)
self.assertTrue(physical_actions.command_applicable(
adopted.command.slcCruiseCommand, card_sm['slcState'], observation, 'card-producer',
2_050_000_000, 60.0 / 3.6, long_active=True, button_event=False))
self.assertEqual(adopted.result.ceiling.speed_mps, 25.)
self.assertEqual(self.sm['carState'].vCruise, 100.)
def test_native_card_applies_one_shot_command_only_with_current_state(self):
from openpilot.cereal import messaging
from openpilot.selfdrive.car import card
now_ns = time.monotonic_ns()
# Advance logical source time explicitly below, independent of host scheduling.
self.enterContext(mock.patch.object(card.time, 'monotonic_ns', return_value=now_ns))
observation = SimpleNamespace(status=Status.VALID, speed_mps=30.0, observed_ns=now_ns - 20_000_000,
valid_until_ns=now_ns + 1_000_000_000, episode=4)
state = messaging.new_message('slcState')
state.valid = True
state.logMonoTime = now_ns - 10_000_000
state.slcState.frameMonoTime = state.logMonoTime
state.slcState.sessionId = 'card-drive'
state.slcState.enabled = True
state.slcState.source = 'dashboard'
state.slcState.observationKind = 'valid'
state.slcState.speedLimit = 30.0
state.slcState.hasAccepted = True
state.slcState.acceptedSpeedLimit = 30.0
state.slcState.commandId = 1
state.slcState.presentationId = 1
state.slcState.sourceProducerSessionId = 'card-producer'
state.slcState.sourceEpisode = 4
state.slcState.sourceObservedMonoTime = observation.observed_ns
state.slcState.sourceValidUntilMonoTime = observation.valid_until_ns
command = messaging.new_message('slcCruiseCommand')
command.valid = True
command.slcCruiseCommand.kind = 'adoptAcceptedLimit'
command.slcCruiseCommand.source = 'dashboard'
command.slcCruiseCommand.sessionId = 'card-drive'
command.slcCruiseCommand.commandId = 1
command.slcCruiseCommand.actionId = 1
command.slcCruiseCommand.presentationId = 1
command.slcCruiseCommand.sourceProducerSessionId = 'card-producer'
command.slcCruiseCommand.sourceEpisode = 4
command.slcCruiseCommand.sourceObservedMonoTime = observation.observed_ns
command.slcCruiseCommand.sourceValidUntilMonoTime = observation.valid_until_ns
command.slcCruiseCommand.issuedMonoTime = now_ns - 10_000_000
command.slcCruiseCommand.expiresMonoTime = now_ns + 80_000_000
command.slcCruiseCommand.expectedSelectedMps = 60.0 / 3.6
command.slcCruiseCommand.targetMps = 30.0
fake = card.Car.__new__(card.Car)
fake.conditional_replay = False
fake.switchback_button_tracker = None
fake.switchback_settings_owner = None
from openpilot.starpilot.controllers.wheel_actions import WheelPublisher
fake.params = Params()
from openpilot.starpilot.vehicle_startup import VehicleStartupOwner
fake.vehicle_startup = VehicleStartupOwner()
fake.wheel_publisher = WheelPublisher(fake.params)
fake.car_gps_publisher = card.CarGpsPublisher()
fake.pm = mock.Mock()
fake.ioniq6_long_prearmed = False
fake.slc_replay = True
fake.slc_control_enabled = True
fake.slc_source_floor_ns = 0
fake.slc_configuration = (True, parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard'}))
fake.slc_requested_configuration = fake.slc_configuration
fake.aol_card_intent = None
fake.slc_producer_session = 'card-producer'
fake.slc_command_sock = object()
fake.slc_commands = deque(maxlen=8)
fake.slc_command_session = ''
fake.slc_last_command_id = 0
fake.slc_receipts = []
fake.can_sock = object()
fake.can_rcv_cum_timeout_counter = 0
fake.is_metric = False
fake.CP = car.CarParams(openpilotLongitudinalControl=True, pcmCruise=False)
fake.CC_prev = car.CarControl(enabled=True)
fake.v_cruise_helper = VCruiseHelper(fake.CP)
fake.v_cruise_helper.v_cruise_kph = 60.0
fake.sm = cast(messaging.SubMaster, ReplaySM(
{'carControl': car.CarControl(enabled=True, longActive=True), 'slcState': state.slcState,
'deviceState': SimpleNamespace(startedMonoTime=1)}, now_ns - 10_000_000))
self.enterContext(mock.patch.object(fake, 'CI', SimpleNamespace(
CC=None, CS=SimpleNamespace(dashboard_limit=SimpleNamespace(observation=observation)),
update=lambda packets: car.CarState(canValid=True, cruiseState={'available': True})), create=True))
self.enterContext(mock.patch.object(fake, 'RI', SimpleNamespace(update=lambda packets: None), create=True))
with (mock.patch.object(card.messaging, 'drain_sock_raw', return_value=[]),
mock.patch.object(card.messaging, 'recv_one_or_none', side_effect=[command.as_reader(), None, command.as_reader(), None])):
selected, _ = fake.state_update()
self.assertGreater(selected.vCruise, 60.0)
self.assertEqual(fake.slc_receipts[0][0], 'commandApplied')
first_selected = selected.vCruise
selected_again, _ = fake.state_update()
self.assertEqual(selected_again.vCruise, first_selected)
self.assertEqual(fake.slc_receipts, [])
fake.CC_prev.enabled = False
fake.CS_prev = car.CarState(vEgo=20.0)
fake.experimental_mode = False
fake.initial_cruise_speed = 40
fake.slc_last_command_id = 0
fake.slc_command_session = ''
fake.v_cruise_helper.v_cruise_kph = 60.0
with (mock.patch.object(card.messaging, 'drain_sock_raw', return_value=[]),
mock.patch.object(card.messaging, 'recv_one_or_none', side_effect=[command.as_reader(), None])):
edge, _ = fake.state_update()
self.assertNotEqual(edge.vCruise, first_selected)
self.assertEqual(fake.slc_receipts[0][0], 'commandRejected')
# The command socket can beat the conflated slcState socket by one card tick.
fake.CC_prev.enabled = True
fake.slc_last_command_id = 0
fake.slc_command_session = 'card-drive'
fake.v_cruise_helper.v_cruise_kph = 60.0
fake.slc_commands.clear()
stale_snapshot = messaging.new_message('slcState')
stale_snapshot.slcState = state.slcState
stale_snapshot.slcState.commandId = 0
fake.sm.data['slcState'] = stale_snapshot.slcState
fake.sm.logMonoTime['slcState'] = command.slcCruiseCommand.issuedMonoTime - 1
with (mock.patch.object(card.messaging, 'drain_sock_raw', return_value=[]),
mock.patch.object(card.messaging, 'recv_one_or_none', side_effect=[command.as_reader(), None])):
waiting, _ = fake.state_update()
self.assertEqual(waiting.vCruise, 60.0)
self.assertEqual(fake.slc_receipts, [])
self.assertEqual(len(fake.slc_commands), 1)
fake.sm.data['slcState'] = state.slcState
fake.sm.logMonoTime['slcState'] = state.logMonoTime
with (mock.patch.object(card.messaging, 'drain_sock_raw', return_value=[]),
mock.patch.object(card.messaging, 'recv_one_or_none', return_value=None)):
caught_up, _ = fake.state_update()
self.assertGreater(caught_up.vCruise, 60.0)
self.assertEqual(fake.slc_receipts[0][0], 'commandApplied')
# A saved admission replacement retires only SLC work, not shared producers
# or the physical suppression needed to consume a confirmation release.
fake.v_cruise_helper.v_cruise_kph = 60.0
press = car.CarState(cruiseState={'available': True},
buttonEvents=[car.CarState.ButtonEvent(type='accelCruise', pressed=True)])
release = car.CarState(cruiseState={'available': True},
buttonEvents=[car.CarState.ButtonEvent(type='accelCruise', pressed=False)])
fake.v_cruise_helper.update_v_cruise(press, True, False, SlcPendingConfirmation('card-drive', 1, 1))
self.assertTrue(fake.v_cruise_helper.slc_suppressed_buttons)
fake.slc_configuration = (True, parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard'}))
fake.slc_requested_configuration = (False, parse({'ShowSpeedLimits': False}))
fake.slc_commands.append(command.as_reader())
floor = time.monotonic_ns()
fake.refresh_slc_configuration(floor)
self.assertFalse(fake.slc_commands)
self.assertEqual(fake.slc_producer_session, 'card-producer')
self.assertTrue(fake.v_cruise_helper.slc_suppressed_buttons)
fake.v_cruise_helper.update_v_cruise(release, True, False)
self.assertFalse(fake.v_cruise_helper.slc_suppressed_buttons)
self.assertEqual(fake.v_cruise_helper.v_cruise_kph, 60.0)
with (mock.patch.object(card.messaging, 'drain_sock_raw', return_value=[]),
mock.patch.object(card.messaging, 'recv_one_or_none', side_effect=[command.as_reader(), None])):
disabled, _ = fake.state_update()
self.assertEqual(disabled.vCruise, 60.0)
self.assertFalse(fake.slc_commands)
self.assertEqual(fake.slc_receipts, [])
fake.slc_requested_configuration = (True, parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard'}))
fake.refresh_slc_configuration(floor + 1)
with (mock.patch.object(card.messaging, 'drain_sock_raw', return_value=[]),
mock.patch.object(card.messaging, 'recv_one_or_none', side_effect=[command.as_reader(), None])):
retired, _ = fake.state_update()
self.assertEqual(retired.vCruise, 60.0)
self.assertFalse(fake.slc_commands)
fresh_ns = floor + 30_000_000
observation.observed_ns = floor + 10_000_000
observation.valid_until_ns = floor + 100_000_000
state.slcState.frameMonoTime = floor + 20_000_000
state.slcState.sessionId = 'new-card-drive'
state.slcState.commandId = 2
state.slcState.sourceObservedMonoTime = observation.observed_ns
state.slcState.sourceValidUntilMonoTime = observation.valid_until_ns
command.slcCruiseCommand.sessionId = 'new-card-drive'
command.slcCruiseCommand.commandId = 2
command.slcCruiseCommand.sourceObservedMonoTime = observation.observed_ns
command.slcCruiseCommand.sourceValidUntilMonoTime = observation.valid_until_ns
command.slcCruiseCommand.issuedMonoTime = floor + 20_000_000
command.slcCruiseCommand.expiresMonoTime = floor + 90_000_000
fake.sm.data['slcState'] = state.slcState
fake.sm.logMonoTime['slcState'] = floor + 20_000_000
fake.sm.logMonoTime['carControl'] = fresh_ns
with (mock.patch.object(card.time, 'monotonic_ns', return_value=fresh_ns),
mock.patch.object(card.messaging, 'drain_sock_raw', return_value=[]),
mock.patch.object(card.messaging, 'recv_one_or_none', side_effect=[command.as_reader(), None])):
resumed, _ = fake.state_update()
self.assertGreater(resumed.vCruise, 60.0)
self.assertEqual(fake.slc_receipts[0][0], 'commandApplied')
# A replay Card observes recorded CAN time, not the host process clock.
replay = card.Car.__new__(card.Car)
replay.__dict__.update(fake.__dict__)
replay.slc_source_floor_ns = 0
replay.slc_configuration = (False, parse({'ShowSpeedLimits': False}))
replay.slc_requested_configuration = (True, parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard'}))
replay.slc_commands = deque(maxlen=8)
replay.v_cruise_helper.v_cruise_kph = 60.0
recorded_ns, host_ns = 2_000_000_000, 90_000_000_000
packet = messaging.new_message('can', 1)
packet.logMonoTime = recorded_ns
packet.can[0] = {'address': 0x100, 'dat': b'\x00' * 8, 'src': 0}
with (mock.patch.object(card, 'REPLAY', True),
mock.patch.object(card.time, 'monotonic_ns', return_value=host_ns),
mock.patch.object(card.messaging, 'drain_sock_raw', return_value=[packet.to_bytes()]),
mock.patch.object(card.messaging, 'recv_one_or_none', return_value=None)):
replay.state_update()
self.assertEqual(replay.can_log_mono_time, recorded_ns)
self.assertEqual(replay.slc_source_floor_ns, recorded_ns)
self.assertLess(replay.slc_source_floor_ns, host_ns)
observation.observed_ns = recorded_ns + 10_000_000
observation.valid_until_ns = recorded_ns + 100_000_000
state.slcState.frameMonoTime = recorded_ns + 20_000_000
state.slcState.sourceObservedMonoTime = observation.observed_ns
state.slcState.sourceValidUntilMonoTime = observation.valid_until_ns
command.slcCruiseCommand.sourceObservedMonoTime = observation.observed_ns
command.slcCruiseCommand.sourceValidUntilMonoTime = observation.valid_until_ns
command.slcCruiseCommand.issuedMonoTime = recorded_ns + 20_000_000
command.slcCruiseCommand.expiresMonoTime = recorded_ns + 90_000_000
replay.sm.logMonoTime['slcState'] = recorded_ns + 20_000_000
replay.sm.logMonoTime['carControl'] = recorded_ns + 30_000_000
packet.logMonoTime = recorded_ns + 30_000_000
packet.clear_write_flag()
with (mock.patch.object(card, 'REPLAY', True),
mock.patch.object(card.time, 'monotonic_ns', return_value=host_ns),
mock.patch.object(card.messaging, 'drain_sock_raw', return_value=[packet.to_bytes()]),
mock.patch.object(card.messaging, 'recv_one_or_none', side_effect=[command.as_reader(), None])):
replayed, _ = replay.state_update()
self.assertGreater(replayed.vCruise, 60.0)
self.assertEqual(replay.slc_receipts[0][0], 'commandApplied')
self.assertEqual(replay.slc_producer_session, 'card-producer')
def _card_with_slc_state(self, pending, observation, now_ns):
from openpilot.selfdrive.car import card
@@ -849,7 +462,7 @@ class RuntimeReplayTests(unittest.TestCase):
for metadata in (self.sm.valid, self.sm.alive, self.sm.updated):
metadata[service] = True
settings = parse({'SpeedLimitController': True, 'SLCPriority1': source_name,
'SLCConfirmation': True, 'SLCConfirmationLower': True, 'SLCConfirmationHigher': True})
'SLCConfirmation': 1})
runtime = Runtime(settings, session_id='wheel-drive', vision_enabled=True, vision_control_qualified=True)
wire = runtime.step(self.sm, self.cp, now_ns=start, now_boot_ns=BOOT).message.slcState
self.assertTrue(wire.hasPending)
@@ -910,111 +523,68 @@ class RuntimeReplayTests(unittest.TestCase):
if has_pending:
self.assertEqual(result.message.slcState.actionStatus, outcome)
def test_card_runtime_consumed_press_hold_command_release(self):
def test_physical_wheel_override_receipt_through_card_and_planner_queue(self):
from openpilot.cereal import messaging
from openpilot.selfdrive.car import card
now_ns = time.monotonic_ns()
source = self.sm['slcDashboardObservation']
source.producerSessionId = 'card-producer'
source.observedMonoTime = now_ns - 20_000_000
source.validUntilMonoTime = now_ns + 1_000_000_000
source.carStateLogMonoTime = now_ns
self.sm.advance(now_ns)
self.sm['carState'].vCruise = 60.0
self.sm['carState'].vCruiseCluster = 60.0
runtime = Runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True,
'SLCConfirmationHigher': True}), session_id='held-press-drive')
pending = runtime.step(self.sm, self.cp, now_ns=now_ns).message.slcState
self.assertTrue(pending.hasPending)
observation = SimpleNamespace(status=Status.VALID, speed_mps=25.0, observed_ns=source.observedMonoTime,
valid_until_ns=source.validUntilMonoTime, episode=1)
fake, current_cs = self._card_with_slc_state(pending, observation, now_ns)
with (mock.patch.object(card.messaging, 'drain_sock_raw', return_value=[]),
mock.patch.object(card.messaging, 'recv_one_or_none', return_value=None)):
pressed, _ = fake.state_update()
self.assertEqual(pressed.vCruise, 60.0)
consumed = fake.v_cruise_helper.slc_consumed_button
self.assertIsNotNone(consumed)
self.assertEqual(consumed.pending.decision_id, pending.decisionId)
from openpilot.selfdrive.controls.plannerd import current_cruise_event, queue_cruise_event, wheel_override_blocked
from openpilot.starpilot.curve_speed.host import CurveHost
press_ns = time.monotonic_ns()
self.sm.advance(press_ns)
accepted = runtime.step(self.sm, self.cp, now_ns=press_ns,
cruise_event=CruiseEvent(1, press_ns, 0.0, 0.0, 'accel', 'card-producer', press_ns,
'confirmationAccept', runtime.session_id,
pending.decisionId, pending.presentationId))
self.assertIsNotNone(accepted.command)
self.assertEqual(accepted.command.slcCruiseCommand.actionId, 0)
fake.sm.data['slcState'] = accepted.message.slcState
fake.sm.logMonoTime['slcState'] = press_ns
current_cs[0] = car.CarState(canValid=True, cruiseState={'available': True})
with (mock.patch.object(card.messaging, 'drain_sock_raw', return_value=[]),
mock.patch.object(card.messaging, 'recv_one_or_none', side_effect=[accepted.command.as_reader(), None])):
applied, _ = fake.state_update()
self.assertGreater(applied.vCruise, 60.0)
self.assertEqual([receipt[0] for receipt in fake.slc_receipts], ['commandApplied'])
applied_speed = applied.vCruise
current_cs[0] = car.CarState(canValid=True, cruiseState={'available': True},
buttonEvents=[car.CarState.ButtonEvent(type='accelCruise', pressed=False)])
with (mock.patch.object(card.messaging, 'drain_sock_raw', return_value=[]),
mock.patch.object(card.messaging, 'recv_one_or_none', return_value=None)):
released, _ = fake.state_update()
self.assertEqual(released.vCruise, applied_speed)
self.assertFalse(fake.v_cruise_helper.slc_suppressed_buttons)
self.assertEqual(fake.slc_receipts, [])
self.cp.pcmCruise = False
self.sm['carState'].canValid = True
self.sm['carState'].vCruise = self.sm['carState'].vCruiseCluster = 90.
settings = parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCWheelOverride': True})
for curve_claims, curve_backlog in ((False, False), (True, False), (False, True)):
with self.subTest(curve_claims=curve_claims, curve_backlog=curve_backlog):
start = 2_000_000_000
self.sm.advance(start)
self.sm['carState'].vCruise = self.sm['carState'].vCruiseCluster = 90.
runtime = Runtime(settings, is_metric=True)
state = runtime.step(self.sm, self.cp, now_ns=start).message.slcState
fake, current_cs = self._card_with_slc_state(state, None, start)
fake.slc_configuration = fake.slc_requested_configuration = (True, settings)
fake.v_cruise_helper.v_cruise_kph = fake.v_cruise_helper.v_cruise_cluster_kph = 90.
fake.is_metric = True
fake.curve_replay = True
fake.car_params_published = True
fake.sm.frame = 1
fake.sm.all_checks = lambda services: True
fake.last_actuators_output = car.CarControl.Actuators()
fake.slc_cruise_event_id = 0
fake.radar_publish = mock.Mock()
fake.rk = SimpleNamespace(remaining=0.0)
fake.aol_replay = False
clock = [start + 10_000_000]
def accept_next_limit(speed_mps, episode):
stamp = time.monotonic_ns()
source.speedMps = speed_mps
source.observedMonoTime = stamp
source.validUntilMonoTime = stamp + 1_000_000_000
source.episode = episode
observation.speed_mps = speed_mps
observation.observed_ns = stamp
observation.valid_until_ns = source.validUntilMonoTime
observation.episode = episode
self.sm['carState'].vCruise = fake.v_cruise_helper.v_cruise_kph
self.sm['carState'].vCruiseCluster = fake.v_cruise_helper.v_cruise_kph
self.sm.advance(stamp)
next_pending = runtime.step(self.sm, self.cp, now_ns=stamp).message.slcState
self.assertTrue(next_pending.hasPending)
fake.sm.data['slcState'] = next_pending
fake.sm.logMonoTime['slcState'] = stamp
fake.sm.logMonoTime['carControl'] = stamp
current_cs[0] = car.CarState(canValid=True, cruiseState={'available': True},
buttonEvents=[car.CarState.ButtonEvent(type='accelCruise', pressed=True)])
with (mock.patch.object(card.messaging, 'drain_sock_raw', return_value=[]),
mock.patch.object(card.messaging, 'recv_one_or_none', return_value=None)):
fake.state_update()
accepted_ns = time.monotonic_ns()
self.sm.advance(accepted_ns)
next_accepted = runtime.step(self.sm, self.cp, now_ns=accepted_ns,
cruise_event=CruiseEvent(episode, accepted_ns, 0.0, 0.0, 'accel', 'card-producer',
accepted_ns, 'confirmationAccept', runtime.session_id,
next_pending.decisionId, next_pending.presentationId))
self.assertIsNotNone(next_accepted.command)
fake.sm.data['slcState'] = next_accepted.message.slcState
fake.sm.logMonoTime['slcState'] = accepted_ns
return next_accepted.command
def can_batch(*args, clock=clock, **kwargs):
return [messaging.new_message('can', size=0, logMonoTime=clock[0]).to_bytes()]
release_command = accept_next_limit(30.0, 2)
current_cs[0] = car.CarState(canValid=True, cruiseState={'available': True},
buttonEvents=[car.CarState.ButtonEvent(type='accelCruise', pressed=False)])
with (mock.patch.object(card.messaging, 'drain_sock_raw', return_value=[]),
mock.patch.object(card.messaging, 'recv_one_or_none', side_effect=[release_command.as_reader(), None])):
same_tick_release, _ = fake.state_update()
self.assertGreater(same_tick_release.vCruise, applied_speed)
self.assertEqual([receipt[0] for receipt in fake.slc_receipts], ['commandApplied'])
self.assertFalse(fake.v_cruise_helper.slc_suppressed_buttons)
unrelated_command = accept_next_limit(35.0, 3)
before_unrelated = fake.v_cruise_helper.v_cruise_kph
current_cs[0] = car.CarState(canValid=True, cruiseState={'available': True},
buttonEvents=[car.CarState.ButtonEvent(type='decelCruise', pressed=True)])
with (mock.patch.object(card.messaging, 'drain_sock_raw', return_value=[]),
mock.patch.object(card.messaging, 'recv_one_or_none', side_effect=[unrelated_command.as_reader(), None])):
blocked, _ = fake.state_update()
self.assertAlmostEqual(blocked.vCruise, before_unrelated, delta=1e-4)
self.assertEqual([receipt[0] for receipt in fake.slc_receipts], ['commandRejected'])
with (mock.patch.object(card, 'REPLAY', True),
mock.patch.object(card.messaging, 'drain_sock_raw', side_effect=can_batch)):
for pressed in (True, False):
current_cs[0] = car.CarState(canValid=True, cruiseState={'available': True},
buttonEvents=[car.CarState.ButtonEvent(type='accelCruise', pressed=pressed)])
cs, radar = fake.state_update()
fake.state_publish(cs, radar)
clock[0] += 10_000_000
fake.sm.logMonoTime['carControl'] = clock[0]
slc_events, curve_events = deque(), deque()
for call in fake.pm.send.call_args_list:
name, message = call.args
if name == 'slcCruiseEvent':
queue_cruise_event(messaging.log_from_bytes(message.to_bytes()), slc_events, curve_events)
self.assertEqual(len(curve_events), 1)
self.assertEqual(len(slc_events), 1)
self.assertEqual(cs.vCruise, 91.)
self.sm['carState'].vCruise = self.sm['carState'].vCruiseCluster = cs.vCruise
self.sm.advance(clock[0])
event = current_cruise_event(slc_events, clock[0])
self.assertEqual(event.wheel_override_since_ns, start)
curve = CurveHost()
curve.runtime.was_controlling = curve_claims
output = runtime.step(self.sm, self.cp, now_ns=clock[0], cruise_event=event,
wheel_override_blocked=wheel_override_blocked(None, curve, curve_backlog=curve_backlog))
self.assertEqual(output.message.slcState.driverOverrideActive, not (curve_claims or curve_backlog))
def test_malformed_saved_priorities_disable_runtime_without_crashing(self):
for key in ('SLCPriority1', 'SLCPriority2'):
@@ -1079,14 +649,14 @@ class RuntimeReplayTests(unittest.TestCase):
self.assertIn('slcState', services)
self.assertIn('longitudinalPlan', services)
state = next(message.slcState for service, message in publisher.sent if service == 'slcState')
self.assertFalse(state.hasCeiling)
self.assertTrue(state.hasCeiling)
self.assertTrue(state.hasAccepted)
self.assertFalse(state.hasPending)
self.assertFalse(state.automaticAdoptionPending)
self.assertEqual(state.observationKind, 'valid')
self.assertEqual(state.frameMonoTime, 2_010_000_000)
def test_stored_history_is_schema_bound_and_not_reused_for_new_dashboard_episode(self):
def test_stored_history_is_schema_bound_and_restored_as_a_value_not_a_dashboard_episode(self):
candidate = acc.Candidate('dashboard', acc.ObservationIdentity(acc.IdentityKind.PRODUCER_EPISODE,
value='drive:card:car:1'), 20.0)
raw = history.encode(acc.AcceptedLimit(candidate, 'drive', 2), 1000)
@@ -1095,17 +665,14 @@ class RuntimeReplayTests(unittest.TestCase):
params = Params(directory)
params.put('SLCQualifiedHistory', json.loads(raw), block=True)
self.assertEqual(params.get('SLCQualifiedHistory')['source_schema'], json.loads(raw)['source_schema'])
self.assertIsNone(history.decode(params.get('SLCQualifiedHistory'), session_id='next-drive',
current=acc.Observation(acc.ObservationKind.VALID, candidate),
now_wall_ns=1000, max_age_ns=1_000_000))
self.assertIsNone(history.decode(raw, session_id='next-drive',
current=acc.Observation(acc.ObservationKind.VALID, candidate),
now_wall_ns=1000, max_age_ns=1_000_000))
remembered = history.decode(params.get('SLCQualifiedHistory'), session_id='next-drive')
self.assertEqual(remembered.candidate.speed_mps, 20.)
self.assertEqual(remembered.candidate.observation_identity.kind, acc.IdentityKind.SESSION_VALUE)
self.assertEqual(remembered.candidate.observation_identity.session_id, 'next-drive')
self.assertNotEqual(remembered.candidate.observation_identity, candidate.observation_identity)
tampered = json.loads(raw)
tampered['source_schema'] = 'wrong'
self.assertIsNone(history.decode(json.dumps(tampered), session_id='next-drive',
current=acc.Observation(acc.ObservationKind.VALID, candidate),
now_wall_ns=1000, max_age_ns=1_000_000))
self.assertIsNone(history.decode(json.dumps(tampered), session_id='next-drive'))
def test_native_saved_settings_default_off_and_preserve_choices(self):
with tempfile.TemporaryDirectory() as directory:
@@ -1113,9 +680,7 @@ class RuntimeReplayTests(unittest.TestCase):
fresh = read_params(params)
self.assertFalse(fresh.enabled)
params.put_bool('SpeedLimitController', True, block=True)
params.put_bool('SLCConfirmation', True, block=True)
params.put_bool('SLCConfirmationLower', True, block=True)
params.put('SLCFallback', 2, block=True)
params.put('SLCConfirmation', 1, block=True)
params.put('SLCPriority1', 'Map Data', block=True)
params.put('SLCPriority2', 'Dashboard', block=True)
restored = read_params(params)
@@ -1155,151 +720,45 @@ class RuntimeReplayTests(unittest.TestCase):
source.episode = 1
def test_saved_choice_and_native_planner_replay(self):
settings = parse({'SpeedLimitController': True, 'SLCFallback': 2, 'SLCPriority1': 'Dashboard'})
runtime = self.accepted_runtime(settings, session_id='replay-drive')
planner = LongitudinalPlanner(self.cp, init_v=20.0)
runtime = self.accepted_runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard'}), session_id='ceiling-drive')
planner = LongitudinalPlanner(self.cp, init_v=20.)
first = runtime.step(self.sm, self.cp, now_ns=2_000_000_000)
self.assertEqual(first.message.slcState.observationKind, 'valid')
self.assertFalse(first.message.slcState.hasCeiling)
self.assertIsNone(first.result.ceiling)
self.assertTrue(first.message.slcState.hasCeiling)
planner.update(self.sm, cruise_ceiling=first.result.ceiling)
self.assertEqual(planner.last_cruise_ceiling_status, 'absent')
self.sm['carState'].vCruise = 105.0
self.sm['carState'].vCruiseCluster = 105.0
self.assertEqual(planner.last_cruise_ceiling_status, 'applied')
self.sm['carState'].vCruise = self.sm['carState'].vCruiseCluster = 105.
self.sm.advance(2_050_000_000)
button = runtime.step(self.sm, self.cp, now_ns=2_050_000_000,
cruise_event=CruiseEvent(1, 2_010_000_000, 100 / 3.6, 105 / 3.6, 'accel',
'replay-card', 2_020_000_000))
self.assertFalse(button.result.errors)
self.assertTrue(button.result.override.event_receipt.consumed)
self.assertEqual(button.result.override.event_receipt.status, 'driver_intent')
changed = runtime.step(self.sm, self.cp, now_ns=2_050_000_000,
cruise_event=CruiseEvent(1, 2_010_000_000, 100/3.6, 105/3.6, 'accel', 'replay-card', 2_020_000_000))
self.assertFalse(changed.message.slcState.driverOverrideActive)
self.assertEqual(changed.result.ceiling.speed_mps, 25.)
self.sm.advance(9_100_000_000)
stale = runtime.step(self.sm, self.cp, now_ns=9_100_000_000)
self.assertEqual(runtime._observations(self.sm, self.cp, 9_100_000_000)[Source.DASHBOARD].kind, ObservationKind.STALE)
self.assertEqual(stale.message.slcState.observationKind, 'stale') # both configured slots use the expired dashboard
self.assertFalse(stale.message.slcState.hasCeiling)
self.assertEqual(stale.result.acceptance.basis, 'stale')
self.assertEqual(stale.result.override.contribution_mps, 105 / 3.6)
planner.update(self.sm, cruise_ceiling=stale.result.ceiling)
self.assertEqual(planner.last_cruise_ceiling_status, 'absent')
self.assertIsNone(stale.result.ceiling)
for sequence, kind, previous, selected in ((2, 'accel', 105, 110), (3, 'decel', 110, 100)):
now = 9_100_000_000 + sequence * 50_000_000
self.sm['carState'].vCruise = self.sm['carState'].vCruiseCluster = float(selected)
self.sm.advance(now)
adjusted = runtime.step(self.sm, self.cp, now_ns=now,
cruise_event=CruiseEvent(sequence, now - 40_000_000, previous / 3.6, selected / 3.6, kind,
'replay-card', now - 30_000_000))
self.assertFalse(adjusted.result.errors)
self.assertFalse(adjusted.message.slcState.hasPending)
self.assertEqual(adjusted.result.override.event_receipt.status, 'driver_intent')
self.assertEqual(adjusted.result.override.contribution_mps, selected / 3.6)
self.assertEqual(adjusted.result.acceptance.state.accepted, first.result.acceptance.state.accepted)
self.assertEqual(stale.message.slcState.observationKind, 'stale')
self.assertEqual(stale.result.ceiling.speed_mps, 25.)
self.assertEqual(stale.result.acceptance.control_target_mps, 25.)
self.assertEqual(self.sm['carState'].vCruise, 105.)
def test_decrease_keeps_lower_slc_ceiling_in_native_planner(self):
mph_to_mps = 0.44704
self.sm['slcDashboardObservation'].speedMps = 45 * mph_to_mps
self.sm['carState'].vCruise = self.sm['carState'].vCruiseCluster = 70 * mph_to_mps * 3.6
runtime = self.accepted_runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard'}), session_id='decrease-drive')
runtime = self.accepted_runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard'}), session_id='ceiling-drive')
runtime.step(self.sm, self.cp, now_ns=2_000_000_000)
previous_mps = self.sm['carState'].vCruise / 3.6
self.sm['carState'].vCruise = self.sm['carState'].vCruiseCluster = 69 * mph_to_mps * 3.6
self.sm.advance(2_050_000_000)
result = runtime.step(self.sm, self.cp, now_ns=2_050_000_000,
cruise_event=CruiseEvent(1, 2_010_000_000, previous_mps, self.sm['carState'].vCruise / 3.6, 'decel',
'replay-card', 2_020_000_000))
cruise_event=CruiseEvent(1, 2_010_000_000, 70*mph_to_mps, 69*mph_to_mps, 'decel', 'replay-card', 2_020_000_000))
self.assertFalse(result.result.errors)
self.assertTrue(result.result.override.event_receipt.consumed)
self.assertAlmostEqual(result.result.override.state.persistent_selected_mps, self.sm['carState'].vCruise / 3.6)
self.assertTrue(result.message.slcState.driverOverrideActive)
self.assertIsNone(result.result.ceiling)
planner = LongitudinalPlanner(self.cp, init_v=45 * mph_to_mps)
self.assertFalse(result.message.slcState.driverOverrideActive)
self.assertAlmostEqual(result.result.ceiling.speed_mps, 45*mph_to_mps, places=5)
planner = LongitudinalPlanner(self.cp, init_v=45*mph_to_mps)
planner.update(self.sm, cruise_ceiling=result.result.ceiling)
self.assertEqual(planner.last_cruise_ceiling_status, 'absent')
self.assertEqual(planner.last_cruise_ceiling_status, 'applied')
def _runtime_with_set_speed_override(self, lateral):
self.setUp()
self.sm['carControl'].latActive = lateral
runtime = self.accepted_runtime(parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard'}), session_id='pedal-drive')
runtime.step(self.sm, self.cp, now_ns=2_000_000_000)
self.sm['carState'].vCruise = self.sm['carState'].vCruiseCluster = 105.0
self.sm.advance(2_050_000_000)
armed = runtime.step(self.sm, self.cp, now_ns=2_050_000_000,
cruise_event=CruiseEvent(1, 2_010_000_000, 100 / 3.6, 105 / 3.6, 'accel', 'replay-card', 2_020_000_000))
self.assertEqual(armed.result.override.contribution_mps, 105 / 3.6)
return runtime
def test_accelerator_release_restores_saved_override_after_long_hold(self):
for lateral, delayed_resume in product((False, True), (False, True)):
with self.subTest(lateral=lateral, delayed_resume=delayed_resume):
runtime = self._runtime_with_set_speed_override(lateral)
self.sm['carState'].gasPressed = True
self.sm['carState'].vEgo = self.sm['carState'].vEgoCluster = 32.0
self.sm['carControl'].longActive = False
self.sm['controlsState'].longControlState = 'off'
for now in (2_100_000_000, 3_100_000_000, 5_100_000_000):
self.sm.advance(now)
held = runtime.step(self.sm, self.cp, now_ns=now)
self.assertFalse(held.result.errors)
self.assertIsNone(held.result.ceiling)
self.assertEqual(held.result.override.contribution_mps, 105 / 3.6)
self.assertIsNotNone(held.result.override.context)
self.assertEqual(held.result.state.override.persistent_selected_mps, 105 / 3.6)
self.assertTrue(held.message.slcState.retainedOverride)
self.assertTrue(held.result.acceptance.authority.accelerator_override)
self.assertFalse(held.result.acceptance.authority.longitudinal_active)
self.sm['carState'].gasPressed = False
if delayed_resume:
self.sm.advance(5_200_000_000)
waiting = runtime.step(self.sm, self.cp, now_ns=5_200_000_000)
self.assertIsNone(waiting.result.ceiling)
self.assertEqual(waiting.result.state.override.persistent_selected_mps, 105 / 3.6)
self.sm['carControl'].longActive = True
self.sm['controlsState'].longControlState = 'pid'
self.sm.advance(5_300_000_000)
resumed = runtime.step(self.sm, self.cp, now_ns=5_300_000_000)
self.assertFalse(resumed.result.errors)
self.assertEqual(resumed.result.override.basis, 'persistent')
self.assertIsNone(resumed.result.ceiling)
def test_disengagement_preserves_manual_set_speed_without_an_slc_ceiling(self):
for lateral, pressed, disabled_service in product((False, True), (False, True), ('selfdriveState', 'carControl')):
with self.subTest(lateral=lateral, pressed=pressed, disabled_service=disabled_service):
runtime = self._runtime_with_set_speed_override(lateral)
self.sm['carState'].gasPressed = pressed
self.sm['carControl'].longActive = False
self.sm['controlsState'].longControlState = 'off'
self.sm[disabled_service].enabled = False
self.sm.advance(2_100_000_000)
disengaged = runtime.step(self.sm, self.cp, now_ns=2_100_000_000)
self.assertIsNone(disengaged.result.ceiling)
self.assertEqual(disengaged.result.state.override.persistent_selected_mps, 105 / 3.6)
self.sm[disabled_service].enabled = True
self.sm['carState'].gasPressed = False
self.sm['carControl'].longActive = True
self.sm['controlsState'].longControlState = 'pid'
self.sm.advance(2_200_000_000)
resumed = runtime.step(self.sm, self.cp, now_ns=2_200_000_000)
self.assertEqual(resumed.result.override.contribution_mps, 105 / 3.6)
self.assertIsNone(resumed.result.ceiling)
def test_stale_engagement_suppresses_commands_and_keeps_the_manual_choice(self):
for lateral, service in product((False, True), ('carState', 'carControl', 'controlsState', 'selfdriveState')):
with self.subTest(lateral=lateral, service=service):
runtime = self._runtime_with_set_speed_override(lateral)
self.sm['carState'].gasPressed = True
self.sm['carControl'].longActive = False
self.sm['controlsState'].longControlState = 'off'
self.sm.advance(2_100_000_000)
self.sm.valid[service] = False
stale = runtime.step(self.sm, self.cp, now_ns=2_100_000_000)
self.assertIsNone(stale.result.ceiling)
self.assertEqual(stale.result.state.override.persistent_selected_mps, 105 / 3.6)
def test_pending_action_receipt_and_stock_gate(self):
settings = parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': True, 'SLCConfirmationLower': True})
settings = parse({'SpeedLimitController': True, 'SLCPriority1': 'Dashboard', 'SLCConfirmation': 1})
runtime = Runtime(settings, session_id='receipt-drive')
runtime.step(self.sm, self.cp, now_ns=2_000_000_000)
source = self.sm['slcDashboardObservation']
@@ -4,27 +4,45 @@ from pathlib import Path
import tempfile
import unittest
from openpilot.common.params import Params, UnknownKeyName
from openpilot.common.params import ParamKeyFlag, Params, UnknownKeyName
from openpilot.starpilot.speed_limits import acceptance as acc
from openpilot.starpilot.speed_limits import offset_document as od
from openpilot.starpilot.speed_limits.runtime_settings import MPH_TO_MPS, parse, read_params
from openpilot.starpilot.speed_limits.runtime_settings import PedalOverride, parse, read_params
class RuntimeSettingsTests(unittest.TestCase):
def test_confirmation_master_controls_both_directions_regardless_of_legacy_flags(self):
for enabled in (False, True):
for legacy in ({}, {'SLCConfirmationLower': False, 'SLCConfirmationHigher': False},
{'SLCConfirmationLower': True, 'SLCConfirmationHigher': False},
{'SLCConfirmationLower': False, 'SLCConfirmationHigher': True}):
with self.subTest(enabled=enabled, legacy=legacy):
settings = parse({'SpeedLimitController': True, 'SLCConfirmation': enabled, **legacy})
self.assertFalse(settings.errors)
self.assertEqual((settings.acceptance.confirm_lower, settings.acceptance.confirm_higher), (enabled, enabled))
def test_old_splits_and_renames_do_not_seed_current_settings(self):
from openpilot.starpilot.settings_retirement import retire_settings
with tempfile.TemporaryDirectory() as directory:
params = Params(directory)
params.put('SLCConfirmation', 1, block=True)
old_values = {'SLCAdvanceSeconds': b'9', 'SLCConfirmationDirection': b'Lower only',
'SetSpeedLimit': b'1', 'SLCPedalOverride': b'2'}
for key, raw in old_values.items():
self.assertNotIn(key.encode(), params.all_keys())
Path(params.get_param_path(key)).write_bytes(raw)
retire_settings(params)
settings = read_params(params)
self.assertTrue(settings.acceptance.confirm_lower and settings.acceptance.confirm_higher)
self.assertEqual((settings.advance_higher_seconds, settings.advance_lower_seconds), (0, 0))
self.assertIs(settings.pedal_override, PedalOverride.SPEED_REACHED)
self.assertFalse(params.get_default_value('SLCOnEngage'))
self.assertFalse(Path(params.get_param_path('SLCOnEngage')).exists())
def test_confirmation_modes_select_each_direction(self):
for mode, lower, higher in ((0, False, False), (1, True, True), (3, False, True), (4, True, False)):
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory:
params = Params(directory)
params.put_bool('SpeedLimitController', True, block=True)
params.put('SLCConfirmation', mode, block=True)
settings = read_params(params)
self.assertFalse(settings.errors)
self.assertEqual((settings.acceptance.confirm_lower, settings.acceptance.confirm_higher), (lower, higher))
def test_confirmation_on_never_autoaccepts_first_changed_or_disengaged_limits(self):
active = acc.Authority(acc.Mode.COMBINED, acc.LongitudinalOwner.SYSTEM, True, True, False, False)
stopped = acc.Authority(acc.Mode.COMBINED, acc.LongitudinalOwner.SYSTEM, False, False, False, True)
policy = parse({'SpeedLimitController': True, 'SLCConfirmation': True}).acceptance
policy = parse({'SpeedLimitController': True, 'SLCConfirmation': 1}).acceptance
for source in ('dashboard', 'map', 'vision', 'online'):
previous = acc.Candidate(source, acc.ObservationIdentity(acc.IdentityKind.GEOGRAPHIC, value='accepted'), 20.)
history = acc.AcceptedLimit(previous, 'drive', 0)
@@ -47,21 +65,24 @@ class RuntimeSettingsTests(unittest.TestCase):
settings = read_params(params)
self.assertTrue(settings.display)
self.assertFalse(settings.enabled)
self.assertEqual(settings.selection.slots, (Source.VISION, Source.DASHBOARD))
self.assertEqual(settings.selection.slots, (Source.VISION, Source.MAP, None))
self.assertTrue(settings.acceptance.display_only)
self.assertFalse(settings.acceptance.confirm_lower or settings.acceptance.confirm_higher)
self.assertEqual((settings.advance_higher_seconds, settings.advance_lower_seconds), (0, 0))
params.put_bool('ShowSpeedLimits', False, block=True)
params.put('SLCPriority1', 'Dashboard', block=True)
params.put('SLCPriority2', 'Map Data', block=True)
params.put('SLCPriority2', 'Off', block=True)
saved = read_params(params)
self.assertFalse(saved.display)
self.assertFalse(saved.enabled)
self.assertEqual(saved.selection.slots, (Source.DASHBOARD, Source.MAP))
self.assertEqual(saved.selection.slots, (Source.DASHBOARD, None, None))
self.assertEqual(Path(params.get_param_path('ShowSpeedLimits')).read_bytes(), b'0')
self.assertEqual(Path(params.get_param_path('SLCPriority1')).read_bytes(), b'Dashboard')
self.assertEqual(Path(params.get_param_path('SLCPriority2')).read_bytes(), b'Map Data')
self.assertEqual(Path(params.get_param_path('SLCPriority2')).read_bytes(), b'Off')
def test_corrupt_saved_values_disable_control_without_rewriting(self):
cases = {'SLCConfirmation': b'invalid', 'IsMetric': b'2', 'SLCPriority1': b'\xff'}
cases = {'SLCConfirmation': b'2',
'IsMetric': b'2', 'SLCPriority1': b'\xff', 'SLCPriority3': b'M' * 33}
for key, raw in cases.items():
with self.subTest(key=key), tempfile.TemporaryDirectory() as directory:
params = Params(directory)
@@ -87,33 +108,40 @@ class RuntimeSettingsTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as directory:
params = Params(directory)
params.put_bool('SpeedLimitController', True, block=True)
params.put_bool('SLCConfirmation', True, block=True)
params.put_bool('SLCConfirmationLower', True, block=True)
params.put('SLCFallback', 0, block=True)
document = od.OffsetDocument(od.IMPERIAL_BOUNDS, (0, 0, -5 * MPH_TO_MPS, 0, 0, 0, 0))
params.put('SLCConfirmation', 1, block=True)
document = od.OffsetDocument(od.IMPERIAL_BOUNDS, (0, 0, -5 * od.MPH_TO_MPS, 0, 0, 0, 0))
params.put('SLCOffsetSchedule', od.to_value(document), block=True)
settings = read_params(params)
self.assertTrue(settings.enabled)
self.assertFalse(settings.errors)
self.assertTrue(settings.acceptance.confirm_lower)
self.assertAlmostEqual(settings.offsets.bands[2].offset_mps, -5 * MPH_TO_MPS)
self.assertAlmostEqual(settings.offsets.bands[2].offset_mps, -5 * od.MPH_TO_MPS)
def test_retired_fallback_bytes_are_ignored_without_creating_or_rewriting_them(self):
def test_startup_clears_retired_slc_keys_and_preserves_current_mode(self):
with tempfile.TemporaryDirectory() as directory:
params = Params(directory)
retired = ('SLCOverride', 'SLCFallback', 'SLCConfirmationHigher', 'SLCConfirmationLower')
paths = [Path(params.get_param_path(key)) for key in retired]
for path in paths:
path.write_bytes(b'1')
params.put('SLCOverrideMode', 2, block=True)
params.clear_all(ParamKeyFlag.CLEAR_ON_MANAGER_START)
self.assertTrue(all(not path.exists() for path in paths))
self.assertIs(read_params(params).pedal_override, PedalOverride.SET_SPEED)
def test_pedal_override_modes_and_bad_saved_value(self):
with tempfile.TemporaryDirectory() as directory:
params = Params(directory)
params.put_bool('SpeedLimitController', True, block=True)
path = Path(params.get_param_path('SLCFallback'))
baseline = read_params(params)
self.assertTrue(baseline.enabled)
self.assertFalse(path.exists())
self.assertIsNone(params.get_default_value('SLCFallback'))
for raw in (b'0', b'1', b'2', b'', b'broken', b'999', b'\xff'):
self.assertIs(read_params(params).pedal_override, PedalOverride.SPEED_REACHED)
path = Path(params.get_param_path('SLCOverrideMode'))
for raw, mode in ((b'0', PedalOverride.OFF), (b'1', PedalOverride.SPEED_REACHED),
(b'2', PedalOverride.SET_SPEED), (b'broken', PedalOverride.OFF)):
with self.subTest(raw=raw):
path.write_bytes(raw)
self.assertEqual(read_params(params), baseline)
self.assertEqual(path.read_bytes(), raw)
for old in (0, 1, 2, None, 'broken'):
self.assertEqual(parse({'SpeedLimitController': True, 'SLCFallback': old}), baseline)
settings = read_params(params)
self.assertTrue(settings.enabled)
self.assertIs(settings.pedal_override, mode)
def test_nonfinite_or_unrepresentable_current_offset_disables_control(self):
for value in (float('nan'), float('inf'), 10 ** 1000):
@@ -123,6 +151,19 @@ class RuntimeSettingsTests(unittest.TestCase):
self.assertFalse(settings.enabled)
self.assertTrue(settings.errors)
def test_wheel_override_is_opt_in_and_bad_bytes_keep_slc_enabled(self):
with tempfile.TemporaryDirectory() as directory:
params = Params(directory)
params.put_bool('SpeedLimitController', True, block=True)
self.assertFalse(params.get_default_value('SLCWheelOverride'))
self.assertFalse(read_params(params).wheel_override)
params.put_bool('SLCWheelOverride', True, block=True)
self.assertTrue(read_params(params).wheel_override)
Path(params.get_param_path('SLCWheelOverride')).write_bytes(b'bad')
settings = read_params(params)
self.assertTrue(settings.enabled)
self.assertFalse(settings.wheel_override)
def test_retired_offsets_are_unregistered_and_ignored_with_zero_defaults(self):
for metric in (False, True):
with self.subTest(metric=metric), tempfile.TemporaryDirectory() as directory:
@@ -45,10 +45,17 @@ class TestSourceSelection(unittest.TestCase):
selected = select_limit(observations(dashboard=30, map=20, vision=vision), policy(mode, slots))
self.assertEqual(selected.selected_source, Source.DASHBOARD if mode == SelectionMode.HIGHEST else Source.MAP)
self.assertNotIn(Source.VISION, selected.considered)
for slots in ((Source.VISION, None), (None, Source.VISION)):
for slots in ((Source.VISION, None), (None, Source.VISION), (None, None, Source.VISION)):
selected = select_limit(observations(dashboard=30, map=20, vision=vision), policy(mode, slots))
self.assertEqual(selected.selected_source, Source.VISION)
def test_three_ranked_sources_fall_through_in_order(self):
slots = (Source.MAP, Source.DASHBOARD, Source.VISION)
supplied = observations(map=35, dashboard=45, vision=25)
for source in slots:
self.assertIs(select_limit(supplied, policy(slots=slots)).selected_source, source)
supplied[source] = Observation(ObservationKind.STALE)
def test_extrema_ties_keep_stable_dashboard_map_vision_order(self):
original = observations(dashboard=20, map=20, vision=20)
for mode in (SelectionMode.HIGHEST, SelectionMode.LOWEST):
@@ -164,7 +171,7 @@ class TestSourceSelection(unittest.TestCase):
action = DriverAction(state.session_id, 1, state.pending.decision_id, ActionKind.REJECT) if reject else None
decision = step(state, chosen.observation, authority, acceptance_policy, now_ns=index * 50_000_000, action=action)
self.assertFalse(decision.errors)
self.assertEqual(decision.control_target_mps, 29.0576 if speed is not None else None)
self.assertEqual(decision.control_target_mps, 29.0576)
self.assertEqual(decision.state.accepted.candidate.speed_mps, 29.0576)
self.assertEqual(decision.state.pending is not None, index == 1)
if index > 0:
@@ -191,7 +198,7 @@ class TestSourceSelection(unittest.TestCase):
selected = select_limit(supplied, source_policy)
decision = step(accepted.state, selected.observation, system, acceptance_policy, now_ns=1)
self.assertEqual(decision.state.accepted, accepted.state.accepted)
self.assertIsNone(decision.control_target_mps)
self.assertEqual(decision.control_target_mps, accepted.state.accepted.candidate.speed_mps)
self.assertEqual(decision.state.observation.kind, kind)
for authority in (Authority(Mode.LATERAL_ONLY, LongitudinalOwner.NONE, True, False, False, False),
Authority(Mode.LATERAL_ONLY, LongitudinalOwner.STOCK, True, False, True, False),
@@ -239,7 +246,7 @@ class TestSourceSelection(unittest.TestCase):
action = DriverAction("drive", 0, state.pending.decision_id, ActionKind.REJECT) if index == 2 else None
decision = step(state, selected.observation, authority, acceptance_policy, now_ns=index, action=action)
self.assertEqual(decision.errors, ())
self.assertEqual(decision.control_target_mps, 29.0576 if speed is not None else None)
self.assertEqual(decision.control_target_mps, 29.0576)
state = decision.state
self.assertEqual(decision.basis, "rejected")
shown = state.presentation
@@ -247,8 +254,8 @@ class TestSourceSelection(unittest.TestCase):
adopted = step(state, selected.observation, authority, acceptance_policy, now_ns=5, adopt=request)
self.assertTrue(adopted.adoption_receipt.consumed)
self.assertEqual(adopted.control_target_mps, 20.1168)
self.assertEqual(adopted.state.rejected, frozenset())
self.assertEqual(adopted.adoption.action_sequence_id, request.sequence_id)
self.assertIsNone(adopted.state.rejected)
self.assertEqual(adopted.adoption_receipt.sequence_id, request.sequence_id)
self.assertEqual(adopted.history_write.candidate, shown.candidate)

Some files were not shown because too many files have changed in this diff Show More