Compare commits

...

19 Commits

Author SHA1 Message Date
rav4kumar 960eb70950 fast 2026-08-16 22:25:52 -07:00
rav4kumar bdff333046 simplify 2026-08-16 21:21:53 -07:00
rav4kumar 71cfc383b2 long: reduce takeoff delay 2026-08-16 13:55:16 -07:00
rav4kumar da907078b0 oscillation 2026-08-16 09:21:01 -07:00
rav4kumar d4b11c2a77 Smooth longitudinal braking transitions 2026-08-15 21:22:57 -07:00
rav4kumar 61eb684030 ref 2026-08-15 18:21:35 -07:00
rav4kumar c32a88d4f8 feat(mici): add the on-road information pager 2026-08-15 18:21:31 -07:00
rav4kumar ca3dc65e72 fix(mici): restore lead-chevron rendering 2026-08-15 18:21:23 -07:00
rav4kumar be88a29ace feat(toyota): let supported cars own cruise set speed 2026-08-15 18:20:51 -07:00
rav4kumar 35d2485525 fix(toyota): stabilize TSS2 longitudinal commands 2026-08-15 18:20:39 -07:00
rav4kumar 0b49a2c62b feat(toyota): hold stopped hybrids without driver brake 2026-08-15 18:20:34 -07:00
rav4kumar ec02d9fc05 feat(toyota): extend blind-spot coverage 2026-08-15 18:20:28 -07:00
rav4kumar 7c8a89aa01 feat(toyota): mirror drive mode in acceleration profiles 2026-08-15 18:20:23 -07:00
rav4kumar 4d7d9571e7 feat(toyota): make TSS2 longitudinal response configurable 2026-08-15 18:20:19 -07:00
rav4kumar e5490cb3bd fix(scc): release curve targets without acceleration steps 2026-08-15 18:20:13 -07:00
rav4kumar fce7f62c32 fix(long): preserve smooth braking through the final stop 2026-08-15 18:20:08 -07:00
rav4kumar 136984d43d feat(long): smooth lead following with acceleration profiles 2026-08-15 18:20:02 -07:00
rav4kumar cde8d73c41 feat(sunnylink): expose PlanPlus recentering control 2026-08-15 18:19:54 -07:00
rav4kumar d7d73234e4 feat(toyota): expose sunnypilot diagnostic 2026-08-15 18:19:44 -07:00
60 changed files with 5537 additions and 145 deletions
+1
View File
@@ -4,6 +4,7 @@
[submodule "opendbc"]
path = opendbc_repo
url = https://github.com/sunnypilot/opendbc.git
branch = tn
[submodule "msgq"]
path = msgq_repo
url = https://github.com/sunnypilot/msgq.git
+30
View File
@@ -203,6 +203,7 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 {
aTarget @5 :Float32;
events @6 :List(OnroadEventSP.Event);
e2eAlerts @7 :E2eAlerts;
accelController @8 :AccelController;
struct DynamicExperimentalControl {
state @0 :DynamicExperimentalControlState;
@@ -305,6 +306,35 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 {
greenLightAlert @0 :Bool;
leadDepartAlert @1 :Bool;
}
struct AccelController {
enabled @0 :Bool;
active @1 :Bool;
shadowOnlyDEPRECATED @2 :Bool;
profile @3 :Profile;
state @4 :State;
enum Profile {
eco @0;
normal @1;
sport @2;
}
enum State {
inactive @0;
free @1;
restrict @2;
hold @3;
release @4;
stopHold @5;
}
}
enum AccelerationPersonality {
eco @0;
normal @1;
sport @2;
}
}
struct OnroadEventSP @0xda96579883444c35 {
+11
View File
@@ -187,6 +187,12 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"StandstillTimer", {PERSISTENT | BACKUP, BOOL, "0"}},
{"TrueVEgoUI", {PERSISTENT | BACKUP, BOOL, "0"}},
// toyota specific params
{"ToyotaAutoHold", {PERSISTENT | BACKUP, BOOL, "0"}},
{"ToyotaEnhancedBsm", {PERSISTENT | BACKUP, BOOL, "0"}},
{"ToyotaTSS2Long", {PERSISTENT | BACKUP, BOOL, "0"}},
{"ToyotaDriveMode", {PERSISTENT | BACKUP, BOOL, "0"}},
// MADS params
{"Mads", {PERSISTENT | BACKUP, BOOL, "1"}},
{"MadsMainCruiseAllowed", {PERSISTENT | BACKUP, BOOL, "1"}},
@@ -227,10 +233,15 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"TeslaMadsScreenButton", {PERSISTENT | BACKUP, INT, "0"}},
{"ToyotaEnforceStockLongitudinal", {PERSISTENT | BACKUP, BOOL, "0"}},
{"ToyotaStopAndGoHack", {PERSISTENT | BACKUP, BOOL, "0"}},
{"ToyotaVirtualCruiseSpeed", {PERSISTENT | BACKUP, BOOL, "0"}},
{"DynamicExperimentalControl", {PERSISTENT | BACKUP, BOOL, "0"}},
{"BlindSpot", {PERSISTENT | BACKUP, BOOL, "0"}},
// Accel Controller profiles (Eco / Normal / Sport)
{"AccelPersonalityEnabled", {PERSISTENT | BACKUP, BOOL, "0"}},
{"AccelPersonality", {PERSISTENT | BACKUP, INT, "1"}},
// sunnypilot model params
{"CameraOffset", {PERSISTENT | BACKUP, FLOAT, "0.0"}},
{"LagdToggle", {PERSISTENT | BACKUP, BOOL, "1"}},
+4
View File
@@ -117,12 +117,16 @@ class TestParams(OpenpilotTestCase):
def test_params_default_value(self):
self.params.remove("LanguageSetting")
self.params.remove("LongitudinalPersonality")
self.params.remove("AccelPersonalityEnabled")
self.params.remove("AccelPersonality")
self.params.remove("LiveParametersV2")
assert self.params.get("LanguageSetting") is None
assert self.params.get("LanguageSetting", return_default=False) is None
assert isinstance(self.params.get("LanguageSetting", return_default=True), str)
assert isinstance(self.params.get("LongitudinalPersonality", return_default=True), int)
assert self.params.get("AccelPersonalityEnabled", return_default=True) is False
assert self.params.get("AccelPersonality", return_default=True) == 1
assert self.params.get("LiveParametersV2") is None
assert self.params.get("LiveParametersV2", return_default=True) is None
+4 -1
View File
@@ -11,13 +11,13 @@ from opendbc.car.structs import car
from openpilot.common.params import Params
from openpilot.common.realtime import config_realtime_process, Priority, Ratekeeper
from openpilot.common.swaglog import cloudlog, ForwardingHandler
from opendbc.car import DT_CTRL, structs
from opendbc.car.can_definitions import CanData, CanRecvCallable, CanSendCallable
from opendbc.car.carlog import carlog
from opendbc.car.fw_versions import ObdCallback
from opendbc.car.car_helpers import get_car, interfaces
from opendbc.car.interfaces import CarInterfaceBase, RadarInterfaceBase
from opendbc.safety import ALTERNATIVE_EXPERIENCE
from openpilot.selfdrive.pandad import can_capnp_to_list, can_list_to_can_capnp
from openpilot.selfdrive.car.cruise import VCruiseHelper
from openpilot.selfdrive.car.helpers import convert_carControlSP, convert_to_capnp
@@ -123,6 +123,9 @@ class Car:
self.RI = RI
self.CP.alternativeExperience = 0
if self.params.get_bool("ToyotaAutoHold"):
self.CP.alternativeExperience |= ALTERNATIVE_EXPERIENCE.ALLOW_AEB
# mads
set_alternative_experience(self.CP, self.CP_SP, self.params)
set_car_specific_params(self.CP, self.CP_SP, self.params)
+64 -7
View File
@@ -19,6 +19,7 @@ IMPERIAL_INCREMENT = round(CV.MPH_TO_KPH, 1) # round here to avoid rounding err
ButtonEvent = car.CarState.ButtonEvent
ButtonType = car.CarState.ButtonEvent.Type
CRUISE_LONG_PRESS = 50
TOYOTA_VIRTUAL_CRUISE_LONG_PRESS = 65
CRUISE_NEAREST_FUNC = {
ButtonType.accelCruise: math.ceil,
ButtonType.decelCruise: math.floor,
@@ -43,6 +44,30 @@ class VCruiseHelper(VCruiseHelperSP):
def v_cruise_initialized(self):
return self.v_cruise_kph != V_CRUISE_UNSET
@property
def software_pcm_cruise_speed(self) -> bool:
return self.CP.brand == "toyota" and self.CP.pcmCruise and self.CP.openpilotLongitudinalControl and not self.CP_SP.pcmCruiseSpeed
@property
def cruise_long_press_frames(self) -> int:
return TOYOTA_VIRTUAL_CRUISE_LONG_PRESS if self.software_pcm_cruise_speed else CRUISE_LONG_PRESS
@property
def software_pcm_cruise_initialized(self) -> bool:
return 0 < self.v_cruise_kph < V_CRUISE_UNSET and 0 < self.v_cruise_cluster_kph < V_CRUISE_UNSET
def _apply_software_pcm_cruise_delta(self, delta_kph: float, is_metric: bool) -> None:
"""Move Toyota's planner/display targets together while respecting both targets' bounds."""
cluster_min_kph = self.v_cruise_min if is_metric else self.v_cruise_min * CV.MPH_TO_KPH
min_delta = max(V_CRUISE_MIN - self.v_cruise_kph, cluster_min_kph - self.v_cruise_cluster_kph)
max_delta = min(V_CRUISE_MAX - self.v_cruise_kph, V_CRUISE_MAX - self.v_cruise_cluster_kph)
if delta_kph > 0:
applied_delta = min(delta_kph, max(0., max_delta))
else:
applied_delta = max(delta_kph, min(0., min_delta))
self.v_cruise_kph = round(self.v_cruise_kph + applied_delta, 1)
self.v_cruise_cluster_kph = round(self.v_cruise_cluster_kph + applied_delta, 1)
def update_v_cruise(self, CS, enabled, is_metric):
self.v_cruise_kph_last = self.v_cruise_kph
@@ -51,11 +76,21 @@ class VCruiseHelper(VCruiseHelperSP):
_enabled = self.update_enabled_state(CS, enabled)
if CS.cruiseState.available:
if not self.CP.pcmCruise or (not self.CP_SP.pcmCruiseSpeed and _enabled):
software_pcm_enabled = not self.CP_SP.pcmCruiseSpeed and _enabled
if self.software_pcm_cruise_speed:
software_pcm_enabled = software_pcm_enabled and self.software_pcm_cruise_initialized
if not self.CP.pcmCruise or software_pcm_enabled:
# 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)
v_cruise_kph_before_sla = self.v_cruise_kph
self.update_speed_limit_assist_v_cruise_non_pcm()
self.v_cruise_cluster_kph = self.v_cruise_kph
if self.software_pcm_cruise_speed:
sla_delta_kph = self.v_cruise_kph - v_cruise_kph_before_sla
self.v_cruise_kph = v_cruise_kph_before_sla
self._apply_software_pcm_cruise_delta(sla_delta_kph, is_metric)
else:
self.v_cruise_cluster_kph = self.v_cruise_kph
else:
self.v_cruise_kph = CS.cruiseState.speed * CV.MS_TO_KPH
self.v_cruise_cluster_kph = CS.cruiseState.speedCluster * CV.MS_TO_KPH
@@ -85,13 +120,13 @@ class VCruiseHelper(VCruiseHelperSP):
for b in CS.buttonEvents:
if b.type.raw in self.button_timers and not b.pressed:
if self.button_timers[b.type.raw] > CRUISE_LONG_PRESS:
if self.button_timers[b.type.raw] > self.cruise_long_press_frames:
return # end long press
button_type = b.type.raw
break
else:
for k, timer in self.button_timers.items():
if timer and timer % CRUISE_LONG_PRESS == 0:
if timer and timer % self.cruise_long_press_frames == 0:
button_type = k
long_press = True
break
@@ -115,10 +150,26 @@ class VCruiseHelper(VCruiseHelperSP):
return
long_press, v_cruise_delta = VCruiseHelperSP.update_v_cruise_delta(self, long_press, v_cruise_delta)
if long_press and self.v_cruise_kph % v_cruise_delta != 0: # partial interval
self.v_cruise_kph = CRUISE_NEAREST_FUNC[button_type](self.v_cruise_kph / v_cruise_delta) * v_cruise_delta
# Toyota's canonical PCM set speed and displayed cluster set speed can differ. In
# software-owned PCM mode, round the value the driver sees and apply the same delta
# to both targets so the planner/cluster calibration offset remains intact.
v_cruise_reference = self.v_cruise_cluster_kph if self.software_pcm_cruise_speed else self.v_cruise_kph
if long_press and v_cruise_reference % v_cruise_delta != 0: # partial interval
v_cruise_reference_new = CRUISE_NEAREST_FUNC[button_type](v_cruise_reference / v_cruise_delta) * v_cruise_delta
else:
self.v_cruise_kph += v_cruise_delta * CRUISE_INTERVAL_SIGN[button_type]
v_cruise_reference_new = v_cruise_reference + v_cruise_delta * CRUISE_INTERVAL_SIGN[button_type]
if self.software_pcm_cruise_speed:
delta_kph = v_cruise_reference_new - v_cruise_reference
# If SET is pressed while overriding, do not lower the target below the current speed.
if CS.gasPressed and button_type in (ButtonType.decelCruise, ButtonType.setCruise):
delta_kph = max(delta_kph, CS.vEgo * CV.MS_TO_KPH - self.v_cruise_kph)
self._apply_software_pcm_cruise_delta(delta_kph, is_metric)
return
self.v_cruise_kph += v_cruise_reference_new - v_cruise_reference
# If set is pressed while overriding, clip cruise speed to minimum of vEgo
if CS.gasPressed and button_type in (ButtonType.decelCruise, ButtonType.setCruise):
@@ -127,6 +178,12 @@ class VCruiseHelper(VCruiseHelperSP):
self.v_cruise_kph = np.clip(round(self.v_cruise_kph, 1), self.v_cruise_min, V_CRUISE_MAX)
def update_button_timers(self, CS, enabled):
if self.software_pcm_cruise_speed and (not enabled or not CS.cruiseState.available or not self.software_pcm_cruise_initialized):
for k in self.button_timers:
self.button_timers[k] = 0
self.button_change_states[k] = {"standstill": False, "enabled": False}
return
# increment timer for buttons still pressed
for k in self.button_timers:
if self.button_timers[k] > 0:
@@ -4,6 +4,7 @@ from openpilot.common.realtime import DT_CTRL
from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N
from openpilot.common.pid import PIDController
from openpilot.selfdrive.modeld.constants import ModelConstants
from openpilot.sunnypilot.selfdrive.controls.lib.longcontrol import LongControlSP
CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N]
@@ -39,8 +40,9 @@ def long_control_state_trans(CP_SP, active, long_control_state,
return long_control_state
class LongControl:
class LongControl(LongControlSP):
def __init__(self, CP, CP_SP):
LongControlSP.__init__(self)
self.CP = CP
self.CP_SP = CP_SP
self.long_control_state = LongCtrlState.off
@@ -60,16 +62,17 @@ class LongControl:
self.long_control_state = long_control_state_trans(self.CP_SP, active, self.long_control_state,
should_stop, CS.brakePressed,
CS.cruiseState.standstill)
LongControlSP.update_state(self, self.long_control_state == LongCtrlState.stopping, active, CS)
if self.long_control_state == LongCtrlState.off:
self.reset()
output_accel = 0.
elif self.long_control_state == LongCtrlState.stopping:
output_accel = self.last_output_accel
output_accel = LongControlSP.stopping_accel(self, self.last_output_accel, CS)
if output_accel > self.CP.stopAccel:
output_accel = min(output_accel, 0.0)
# TODO: can we just go straight to stopAccel?
output_accel -= 1.0 * DT_CTRL # m/s^2/s while trying to stop
output_accel -= LongControlSP.stopping_decel_rate(self, CS, a_target, output_accel) * DT_CTRL
self.reset()
else: # LongCtrlState.pid
@@ -77,6 +77,7 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
def update(self, sm):
LongitudinalPlannerSP.update(self, sm)
self.previous_plan_accel = self.output_a_target
if len(sm['carControl'].orientationNED) == 3:
accel_coast = get_coast_accel(sm['carControl'].orientationNED[1])
@@ -99,7 +100,8 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
throttle_probs = sm['modelV2'].meta.disengagePredictions.gasPressProbs
throttle_prob = throttle_probs[1] if len(throttle_probs) > 1 else 1.0
self.allow_throttle = throttle_prob > ALLOW_THROTTLE_THRESHOLD or v_ego <= MIN_ALLOW_THROTTLE_SPEED
stock_allow_throttle = throttle_prob > ALLOW_THROTTLE_THRESHOLD or v_ego <= MIN_ALLOW_THROTTLE_SPEED
self.allow_throttle = stock_allow_throttle
steer_angle_without_offset = sm['carState'].steeringAngleDeg - sm['vehicleParameters'].angleOffsetDeg
@@ -140,6 +142,10 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
output_should_stop_e2e = sm['modelV2'].action.shouldStop
is_e2e = self.is_e2e(sm)
output_a_target_model = self.select_model_accel(
output_a_target_mpc, output_a_target_e2e, blended=is_e2e,
should_stop=output_should_stop_e2e or output_should_stop_mpc, fcw=self.fcw, reset=reset_state,
)
self.a_cruise = get_cruise_accel(is_e2e, v_cruise, v_ego,
self.a_cruise, steer_angle_without_offset, self.CP, self.dt,
@@ -149,8 +155,11 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
candidates = [(output_a_target_mpc, self.mpc.source, output_should_stop_mpc),
(self.a_cruise, LongitudinalPlanSource.cruise, cruise_should_stop)]
if is_e2e:
candidates.append((output_a_target_e2e, LongitudinalPlanSource.e2e, output_should_stop_e2e))
candidates.append((output_a_target_model, LongitudinalPlanSource.e2e, output_should_stop_e2e))
elif self.model_accel_transition.active:
candidates[0] = (output_a_target_model, self.mpc.source, output_should_stop_mpc)
candidates = self.update_accel_controller(sm, candidates)
output_a_target, self.mpc.source, _ = min(candidates, key=lambda c: c[0])
self.output_should_stop = any(should_stop for _, _, should_stop in candidates)
self.output_a_target = np.clip(output_a_target, ACCEL_MIN, ACCEL_MAX)
@@ -11,6 +11,15 @@ from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPl
from openpilot.selfdrive.controls.radard import _LEAD_ACCEL_TAU
class PlannerSM(dict):
def __init__(self, radar_frame: int, services: dict):
super().__init__(services)
self.frame = radar_frame
self.logMonoTime = {"radarState": radar_frame}
self.valid = {"radarState": True}
self.alive = {"radarState": True}
class Plant:
messaging_initialized = False
@@ -132,7 +141,7 @@ class Plant:
car_control.carControl.orientationNED = [0., float(pitch), 0.]
# ******** get controlsState messages for plotting ***
sm = {'radarState': radar.radarState,
sm = PlannerSM(self.rk.frame, {'radarState': radar.radarState,
'carState': car_state.carState,
'carControl': car_control.carControl,
'controlsState': control.controlsState,
@@ -141,7 +150,7 @@ class Plant:
'modelV2': model.modelV2,
'carStateSP': car_state_sp.carStateSP,
'liveMapDataSP': live_map_data_sp.liveMapDataSP,
'gpsLocation': gps_data.gpsLocation}
'gpsLocation': gps_data.gpsLocation})
self.planner.update(sm)
self.acceleration = self.planner.output_a_target
if self.planner.output_should_stop:
@@ -27,6 +27,12 @@ DESCRIPTIONS = {
"In relaxed mode sunnypilot will stay further away from lead cars. On supported cars, you can cycle through these personalities with " +
"your steering wheel distance button."
),
"AccelPersonalityEnabled": tr_noop(
"Use the Accel Controller for smooth, early lead following and stop-and-go. Stock emergency braking remains available as a safety backstop."
),
"AccelPersonality": tr_noop(
"Select the vehicle acceleration response. Chauffeur braking and stopping behavior remain the same across profiles."
),
"IsLdwEnabled": tr_noop(
"Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line " +
"without a turn signal activated while driving over 31 mph (50 km/h)."
@@ -106,6 +112,24 @@ class TogglesLayout(Widget):
icon="speed_limit.png"
)
self._accel_controller_enabled = toggle_item(
lambda: tr("Enable Accel Controller"),
lambda: tr(DESCRIPTIONS["AccelPersonalityEnabled"]),
self._params.get_bool("AccelPersonalityEnabled"),
callback=self._set_accel_controller_enabled,
icon="speed_limit.png",
)
self._accel_personality_setting = multiple_button_item(
lambda: tr("Acceleration Profile"),
lambda: tr(DESCRIPTIONS["AccelPersonality"]),
buttons=[lambda: tr("Eco"), lambda: tr("Normal"), lambda: tr("Sport")],
button_width=300,
callback=self._set_accel_personality,
selected_index=self._params.get("AccelPersonality", return_default=True),
icon="speed_limit.png"
)
self._toggles = {}
self._locked_toggles = set()
for param, (title, desc, icon, needs_restart) in self._toggle_defs.items():
@@ -135,9 +159,11 @@ class TogglesLayout(Widget):
self._toggles[param] = toggle
# insert longitudinal personality after NDOG toggle
# insert longitudinal personality and Accel Controller settings after NDOG toggle
if param == "DisengageOnAccelerator":
self._toggles["LongitudinalPersonality"] = self._long_personality_setting
self._toggles["AccelPersonalityEnabled"] = self._accel_controller_enabled
self._toggles["AccelPersonality"] = self._accel_personality_setting
self._update_experimental_mode_icon()
self._scroller = Scroller(list(self._toggles.values()), line_separator=True, spacing=0)
@@ -158,6 +184,7 @@ class TogglesLayout(Widget):
def _update_toggles(self):
ui_state.update_params()
accel_controller_enabled = self._params.get_bool("AccelPersonalityEnabled")
e2e_description = tr(
"sunnypilot defaults to driving in chill mode. Experimental mode enables alpha-level features that aren't ready for chill mode. " +
@@ -176,11 +203,15 @@ class TogglesLayout(Widget):
self._toggles["ExperimentalMode"].action_item.set_enabled(True)
self._toggles["ExperimentalMode"].set_description(e2e_description)
self._long_personality_setting.action_item.set_enabled(True)
self._accel_controller_enabled.action_item.set_enabled(True)
self._accel_personality_setting.action_item.set_enabled(True)
else:
# no long for now
self._toggles["ExperimentalMode"].action_item.set_enabled(False)
self._toggles["ExperimentalMode"].action_item.set_state(False)
self._long_personality_setting.action_item.set_enabled(False)
self._accel_controller_enabled.action_item.set_enabled(False)
self._accel_personality_setting.action_item.set_enabled(False)
self._params.remove("ExperimentalMode")
unavailable = tr("Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control.")
@@ -203,6 +234,8 @@ class TogglesLayout(Widget):
# refresh toggles from params to mirror external changes
for param in self._toggle_defs:
self._toggles[param].action_item.set_state(self._params.get_bool(param))
self._accel_controller_enabled.action_item.set_state(accel_controller_enabled)
self._accel_personality_setting.action_item.set_selected_button(self._params.get("AccelPersonality", return_default=True))
# these toggles need restart, block while engaged
for toggle_def in self._toggle_defs:
@@ -247,3 +280,9 @@ class TogglesLayout(Widget):
def _set_longitudinal_personality(self, button_index: int):
self._params.put("LongitudinalPersonality", button_index, block=True)
def _set_accel_personality(self, button_index: int):
self._params.put("AccelPersonality", button_index, block=True)
def _set_accel_controller_enabled(self, state: bool):
self._params.put_bool("AccelPersonalityEnabled", state, block=True)
+8 -2
View File
@@ -14,6 +14,7 @@ from openpilot.system.ui.lib.application import gui_app
if gui_app.sunnypilot_ui():
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.settings import SettingsLayoutSP as SettingsLayout
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.home import MiciHomeLayoutSP as MiciHomeLayout
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onroad import OnroadViewContainerSP as AugmentedRoadView
ONROAD_DELAY = 2.5 # seconds
@@ -72,6 +73,9 @@ class MiciMainLayout(Scroller):
# For scroll_to
return self._body_onroad_layout if ui_state.is_body else self._car_onroad_layout
def _should_auto_scroll_to_onroad(self) -> bool:
return True
def _setup_callbacks(self):
self._home_layout.set_callbacks(
on_settings=lambda: gui_app.push_widget(self._settings_layout),
@@ -122,13 +126,15 @@ class MiciMainLayout(Scroller):
# FIXME: these two pops can interrupt user interacting in the settings
if self._onroad_time_delay is not None and rl.get_time() - self._onroad_time_delay >= ONROAD_DELAY:
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
if not gui_app.sunnypilot_ui() or self._should_auto_scroll_to_onroad():
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
self._onroad_time_delay = None
# When car leaves standstill, pop nav stack and scroll to onroad
CS = ui_state.sm["carState"]
if not CS.standstill and self._prev_standstill:
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
if not gui_app.sunnypilot_ui() or self._should_auto_scroll_to_onroad():
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
self._prev_standstill = CS.standstill
def _on_interactive_timeout(self):
@@ -42,6 +42,8 @@ class TogglesLayoutMici(NavScroller):
super().__init__()
self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"])
self._accel_controller_enabled = BigParamControl("enable accel controller", "AccelPersonalityEnabled")
self._accel_personality_toggle = BigMultiParamToggle("acceleration profile", "AccelPersonality", ["eco", "normal", "sport"])
self._experimental_btn = BigToggle("experimental mode", initial_state=ui_state.params.get_bool("ExperimentalMode"),
toggle_callback=self._on_experimental_mode)
is_metric_toggle = BigParamControl("use metric units", "IsMetric")
@@ -53,6 +55,8 @@ class TogglesLayoutMici(NavScroller):
self._scroller.add_widgets([
self._personality_toggle,
self._accel_controller_enabled,
self._accel_personality_toggle,
self._experimental_btn,
is_metric_toggle,
ldw_toggle,
@@ -65,6 +69,7 @@ class TogglesLayoutMici(NavScroller):
# Toggle lists
self._refresh_toggles = (
("ExperimentalMode", self._experimental_btn),
("AccelPersonalityEnabled", self._accel_controller_enabled),
("IsMetric", is_metric_toggle),
("IsLdwEnabled", ldw_toggle),
("AlwaysOnDM", always_on_dm_toggle),
@@ -104,17 +109,23 @@ class TogglesLayoutMici(NavScroller):
if ui_state.has_longitudinal_control:
self._experimental_btn.set_visible(True)
self._personality_toggle.set_visible(True)
self._accel_controller_enabled.set_visible(True)
self._accel_personality_toggle.set_visible(True)
else:
# no long for now
self._experimental_btn.set_visible(False)
self._experimental_btn.set_checked(False)
self._personality_toggle.set_visible(False)
self._accel_controller_enabled.set_visible(False)
self._accel_personality_toggle.set_visible(False)
ui_state.params.remove("ExperimentalMode")
# Refresh toggles from params to mirror external changes
for key, item in self._refresh_toggles:
item.set_checked(ui_state.params.get_bool(key))
self._accel_personality_toggle.refresh()
def _on_experimental_mode(self, state: bool):
if state and not ui_state.params.get_bool("ExperimentalModeConfirmed"):
# Don't show enabled state until confirm
@@ -154,8 +154,8 @@ class ModelRenderer(Widget, ModelRendererSP):
self._draw_lane_lines()
self._draw_path(sm)
# if render_lead_indicator and radar_state:
# self._draw_lead_indicator()
if render_lead_indicator and radar_state:
self._draw_lead_indicator()
def _update_raw_points(self, model):
"""Update raw 3D points from model data"""
@@ -385,13 +385,18 @@ class BigMultiParamToggle(BigMultiToggle):
self._load_value()
def _load_value(self):
self.set_value(self._options[self._params.get(self._param) or 0])
value = self._params.get(self._param, return_default=True)
index = value if isinstance(value, int) else 0
self.set_value(self._options[max(0, min(index, len(self._options) - 1))])
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
new_idx = self._options.index(self.value)
self._params.put(self._param, new_idx)
def refresh(self):
self._load_value()
class BigParamControl(BigToggle):
def __init__(self, text: str, param: str, toggle_callback: Callable | None = None):
@@ -143,7 +143,8 @@ class CruiseLayout(Widget):
self.icbm_toggle.show_description(True)
if has_long or has_icbm:
self.custom_acc_toggle.action_item.set_enabled(((has_long and not ui_state.CP.pcmCruise) or has_icbm) and ui_state.is_offroad())
software_cruise_speed = has_long and (not ui_state.CP.pcmCruise or not ui_state.CP_SP.pcmCruiseSpeed)
self.custom_acc_toggle.action_item.set_enabled((software_cruise_speed or has_icbm) and ui_state.is_offroad())
self.dec_toggle.action_item.set_enabled(has_long)
self.scc_v_toggle.action_item.set_enabled(True)
self.scc_m_toggle.action_item.set_enabled(True)
@@ -169,7 +170,7 @@ class CruiseLayout(Widget):
show_custom_acc_desc = True
else:
if has_long or has_icbm:
if has_long and ui_state.CP.pcmCruise:
if has_long and ui_state.CP.pcmCruise and ui_state.CP_SP.pcmCruiseSpeed:
new_custom_acc_desc = tr(ACC_PCMCRUISE_DISABLED_DESCRIPTION)
show_custom_acc_desc = True
else:
@@ -11,10 +11,12 @@ from openpilot.system.ui.lib.multilang import tr, tr_noop
from openpilot.system.ui.widgets import DialogResult
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
from opendbc.sunnypilot.car.toyota.values import ToyotaFlagsSP
ONROAD_ONLY_DESCRIPTION = tr_noop("Start the vehicle to check vehicle compatibility.")
SNG_HACK_UNAVAILABLE = tr_noop("sunnypilot Longitudinal Control must be available and enabled for your vehicle to use this feature.")
VIRTUAL_CRUISE_UNAVAILABLE = tr_noop("Virtual Cruise Speed is available only on supported Toyota TSS2 configurations with sunnypilot Longitudinal Control.")
DESCRIPTIONS = {
'enforce_stock_longitudinal': tr_noop(
@@ -23,7 +25,14 @@ DESCRIPTIONS = {
'stop_and_go_hack': tr_noop(
'sunnypilot will allow some Toyota/Lexus cars to auto resume during stop and go traffic. ' +
'This feature is only applicable to certain models that are able to use longitudinal control. This is an alpha feature. Use at your own risk.'
)
),
'virtual_cruise_speed': tr_noop(
'Use a sunnypilot-owned cruise target with the Toyota RES/SET buttons while sunnypilot longitudinal control is active. ' +
'This unlocks Custom ACC Speed Increments; set the short interval to 5 for next-5-unit tap behavior. ' +
'The Toyota cluster will continue to show the factory target and may differ from sunnypilot. ' +
'The direct button signals are route-validated on Corolla Cross and Prius TSS2, but held-button timing differs by platform. ' +
'This is an alpha feature; validate acceleration above the factory target in a controlled setting.'
),
}
@@ -47,8 +56,17 @@ class ToyotaSettings(BrandSettings):
enabled=lambda: not ui_state.engaged,
)
self.virtual_cruise_speed = toggle_item_sp(
lambda: tr("Virtual Cruise Speed (Alpha)"),
description=lambda: tr(DESCRIPTIONS["virtual_cruise_speed"]),
initial_state=ui_state.params.get_bool("ToyotaVirtualCruiseSpeed"),
callback=self._on_enable_virtual_cruise_speed,
enabled=lambda: not ui_state.engaged,
)
self.items = [
self.enforce_stock_longitudinal,
self.virtual_cruise_speed,
self.stop_and_go_hack,
]
@@ -60,7 +78,9 @@ class ToyotaSettings(BrandSettings):
if ui_state.params.get_bool("AlphaLongitudinalEnabled"):
ui_state.params.put_bool("AlphaLongitudinalEnabled", False)
ui_state.params.put_bool("ToyotaStopAndGoHack", False)
ui_state.params.put_bool("ToyotaVirtualCruiseSpeed", False)
self.stop_and_go_hack.action_item.set_state(False)
self.virtual_cruise_speed.action_item.set_state(False)
ui_state.params.put_bool("OnroadCycleRequested", True)
else:
self.enforce_stock_longitudinal.action_item.set_state(False)
@@ -94,10 +114,46 @@ class ToyotaSettings(BrandSettings):
ui_state.params.put_bool("ToyotaStopAndGoHack", False)
ui_state.params.put_bool("OnroadCycleRequested", True)
def _on_enable_virtual_cruise_speed(self, state: bool):
if state:
def confirm_callback(result: int):
enabled = result == DialogResult.CONFIRM
ui_state.params.put_bool("ToyotaVirtualCruiseSpeed", enabled)
self.virtual_cruise_speed.action_item.set_state(enabled)
if enabled:
ui_state.params.put_bool("OnroadCycleRequested", True)
content = (f"<h1>{self.virtual_cruise_speed.title}</h1><br>" +
f"<p>{self.virtual_cruise_speed.description}</p>")
dlg = ConfirmDialog(content, tr("Enable"), rich=True, callback=confirm_callback)
gui_app.push_widget(dlg)
else:
ui_state.params.put_bool("ToyotaVirtualCruiseSpeed", False)
ui_state.params.put_bool("OnroadCycleRequested", True)
def update_settings(self):
if ui_state.CP is not None:
longitudinal = ui_state.CP.openpilotLongitudinalControl
enforce_stock = self.enforce_stock_longitudinal.action_item.get_state()
virtual_cruise_available = bool(ui_state.CP_SP is not None and
ui_state.CP_SP.flags & ToyotaFlagsSP.VIRTUAL_CRUISE_SPEED_AVAILABLE)
if longitudinal and virtual_cruise_available:
self.virtual_cruise_speed.action_item.set_enabled(not ui_state.engaged)
virtual_cruise_desc = tr(DESCRIPTIONS["virtual_cruise_speed"])
show_virtual_cruise_desc = False
else:
self.virtual_cruise_speed.action_item.set_enabled(False)
if self.virtual_cruise_speed.action_item.get_state():
self.virtual_cruise_speed.action_item.set_state(False)
ui_state.params.put_bool("ToyotaVirtualCruiseSpeed", False)
virtual_cruise_desc = "<b>" + tr(VIRTUAL_CRUISE_UNAVAILABLE) + "</b>\n\n" + tr(DESCRIPTIONS["virtual_cruise_speed"])
show_virtual_cruise_desc = True
if self.virtual_cruise_speed.description != virtual_cruise_desc:
self.virtual_cruise_speed.set_description(virtual_cruise_desc)
if show_virtual_cruise_desc:
self.virtual_cruise_speed.show_description(True)
if longitudinal and not enforce_stock:
self.stop_and_go_hack.action_item.set_enabled(not ui_state.engaged)
@@ -114,6 +170,12 @@ class ToyotaSettings(BrandSettings):
if show_desc:
self.stop_and_go_hack.show_description(True)
else:
self.virtual_cruise_speed.action_item.set_enabled(False)
virtual_cruise_desc = "<b>" + tr(ONROAD_ONLY_DESCRIPTION) + "</b>\n\n" + tr(DESCRIPTIONS["virtual_cruise_speed"])
if self.virtual_cruise_speed.description != virtual_cruise_desc:
self.virtual_cruise_speed.set_description(virtual_cruise_desc)
self.virtual_cruise_speed.show_description(True)
self.stop_and_go_hack.action_item.set_enabled(False)
new_desc = "<b>" + tr(ONROAD_ONLY_DESCRIPTION) + "</b>\n\n" + tr(DESCRIPTIONS["stop_and_go_hack"])
if self.stop_and_go_hack.description != new_desc:
@@ -0,0 +1,19 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
from openpilot.selfdrive.ui.sunnypilot.mici.widgets.scroll_panel_sp import GuiScrollPanel2SP
class MiciMainLayoutSP(MiciMainLayout):
def __init__(self):
super().__init__()
scroller = self._scroller
scroller.scroll_panel = GuiScrollPanel2SP(scroller._horizontal, handle_out_of_bounds=not scroller._snap_items)
def _should_auto_scroll_to_onroad(self) -> bool:
return not self._onroad_layout.is_on_info_panel()
@@ -0,0 +1,64 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from collections.abc import Callable
import pyray as rl
from openpilot.system.ui.lib.application import gui_app
from openpilot.selfdrive.ui.sunnypilot.mici.widgets.scroller_sp import ScrollerSP
from openpilot.selfdrive.ui.sunnypilot.mici.onroad.augmented_road_view import AugmentedRoadViewSP
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onroad_info_panel import OnroadInfoPanel
CONFIDENCE_BALL_VISIBLE_RATIO = 0.4
HORIZONTAL_SETTLE_PX = 5
HORIZONTAL_RESET_RATIO = 0.5
class OnroadViewContainerSP(ScrollerSP):
def __init__(self, bookmark_callback=None):
super().__init__(horizontal=False, snap_items=True, spacing=0, pad=0, scroll_indicator=False, edge_shadows=False)
self.road_view = AugmentedRoadViewSP(bookmark_callback=bookmark_callback)
self.onroad_info_panel = OnroadInfoPanel(bookmark_callback=bookmark_callback)
self._scroller.add_widgets([
self.road_view,
self.onroad_info_panel,
])
self._scroller.set_reset_scroll_at_show(False)
self._scroller.set_scrolling_enabled(lambda: abs(self.rect.x) < HORIZONTAL_SETTLE_PX)
for child in (self.road_view, self.onroad_info_panel):
inner_touch_valid = child._touch_valid_callback
child.set_touch_valid_callback(
lambda inner=inner_touch_valid: self._touch_valid() and (inner() if inner else True)
)
def set_rect(self, rect: rl.Rectangle):
super().set_rect(rect)
self.road_view.set_rect(rect)
self.onroad_info_panel.set_rect(rect)
return self
def is_swiping_left(self) -> bool:
return self.road_view.is_swiping_left() or self.onroad_info_panel.is_swiping_left()
def set_click_callback(self, click_callback: Callable[[], None] | None) -> None:
self.road_view.set_click_callback(click_callback)
self.onroad_info_panel.set_click_callback(click_callback)
def is_on_info_panel(self) -> bool:
"""True when scrolled past halfway toward onroad_info_panel (used by main layout
to skip auto-pop-back-to-camera while user is reading the info panel)."""
return abs(self._scroller.scroll_panel.get_offset()) > self._rect.height / 2
def _render(self, rect: rl.Rectangle):
if abs(self.rect.x) > gui_app.width * HORIZONTAL_RESET_RATIO:
self._scroller.scroll_panel.set_offset(0)
vertical_offset = self._scroller.scroll_panel.get_offset()
show_ball = abs(vertical_offset) < rect.height * CONFIDENCE_BALL_VISIBLE_RATIO
self.road_view.set_show_confidence_ball(show_ball)
super()._render(rect)
@@ -0,0 +1,403 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import pyray as rl
from dataclasses import dataclass, field
from openpilot.common.constants import CV
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
from openpilot.selfdrive.ui.mici.onroad.alert_renderer import AlertRenderer
from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import BookmarkIcon
METER_TO_KM = 0.001
METER_TO_MILE = 0.000621371
CONTENT_MARGIN = 16
SPEED_LIMIT_SIGN_WIDTH = 146
VIENNA_SIGN_SIZE = 146
MUTCD_SIGN_HEIGHT = 178
OFFSET_BADGE_SIZE = 50
OFFSET_BADGE_PANEL_PADDING = 4
MUTCD_OFFSET_SIGN_Y_SHIFT = 6
VIENNA_BADGE_X_RATIO = 0.80
VIENNA_BADGE_UPCOMING_X_RATIO = 0.70
VIENNA_BADGE_Y_RATIO = -0.82
UPCOMING_SIGN_SIZE_RATIO = 0.76
UPCOMING_SIGN_OVERLAP_RATIO = 0.05
UNIT_FONT_SIZE = 40
SPEED_FONT_SIZE = 114
ROAD_FONT_SIZE = 32
SCC_TAG_WIDTH = 78
SCC_TAG_HEIGHT = 30
SCC_TAG_GAP = 5
COLUMN_GAP = 12
@dataclass(frozen=True)
class OnroadInfoPanelColors:
white: rl.Color = rl.WHITE
black: rl.Color = rl.BLACK
red: rl.Color = field(default_factory=lambda: rl.Color(255, 0, 0, 255))
green: rl.Color = field(default_factory=lambda: rl.Color(0, 255, 0, 255))
grey: rl.Color = field(default_factory=lambda: rl.Color(190, 195, 190, 255))
light_grey: rl.Color = field(default_factory=lambda: rl.Color(200, 200, 200, 255))
dark_grey: rl.Color = field(default_factory=lambda: rl.Color(100, 100, 100, 255))
bg_dark: rl.Color = field(default_factory=lambda: rl.Color(0, 0, 0, 255))
card_bg: rl.Color = field(default_factory=lambda: rl.Color(50, 50, 50, 200))
badge_bg: rl.Color = field(default_factory=lambda: rl.Color(60, 60, 60, 255))
COLORS = OnroadInfoPanelColors()
class OnroadInfoPanel(Widget):
def __init__(self, bookmark_callback=None):
super().__init__()
self.speed_limit: float = 0.0
self.speed_limit_valid: bool = False
self.speed_limit_offset: float = 0.0
self.next_speed_limit: float = 0.0
self.next_speed_limit_distance: float = 0.0
self.road_name: str = ""
self.current_speed: float = 0.0
self.set_speed: float = 0.0
self.cruise_enabled: bool = False
self._sign_slide: float = 0.0
self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD)
self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM)
self._marquee_offset: float = 0.0
self._marquee_direction: int = 1
self._marquee_pause_timer: float = 0.0
self._marquee_speed: float = 40.0
self._marquee_pause_duration: float = 1.5
self._alert_renderer = AlertRenderer()
self._alert_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
self._bookmark_icon = BookmarkIcon(bookmark_callback)
def is_swiping_left(self) -> bool:
return self._bookmark_icon.is_swiping_left()
def _handle_mouse_release(self, mouse_pos: MousePos) -> None:
# Mirror stock AugmentedRoadView: suppress click while bookmark gesture active
if not self._bookmark_icon.interacting():
super()._handle_mouse_release(mouse_pos)
def _update_state(self) -> None:
sm = ui_state.sm
speed_conv = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
if sm.valid["longitudinalPlanSP"]:
lp_sp = sm["longitudinalPlanSP"]
resolver = lp_sp.speedLimit.resolver
self.speed_limit = resolver.speedLimit * speed_conv
self.speed_limit_valid = resolver.speedLimitValid
self.speed_limit_offset = resolver.speedLimitOffset * speed_conv
if sm.valid["liveMapDataSP"]:
lmd = sm["liveMapDataSP"]
self.next_speed_limit = lmd.speedLimitAhead * speed_conv
self.next_speed_limit_distance = lmd.speedLimitAheadDistance
self.road_name = lmd.roadName
if sm.updated["carState"]:
self.current_speed = sm["carState"].vEgo * speed_conv
if sm.valid["carState"] and sm.valid["controlsState"]:
self.cruise_enabled = sm["carState"].cruiseState.enabled
v_cruise_cluster = sm["carState"].vCruiseCluster
set_speed_kph = sm["controlsState"].vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster
self.set_speed = set_speed_kph * (METER_TO_MILE / METER_TO_KM) if not ui_state.is_metric else set_speed_kph
def _render(self, rect: rl.Rectangle) -> None:
self._update_state()
rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), int(rect.height), COLORS.bg_dark)
left_x = rect.x + CONTENT_MARGIN
if self.cruise_enabled:
unit = tr("MAX")
display_speed = self.set_speed
else:
unit = tr("km/h") if ui_state.is_metric else tr("MPH")
display_speed = self.current_speed
display_speed_text = str(round(display_speed))
if self.speed_limit_valid and display_speed > self.speed_limit:
speed_color = COLORS.red
else:
speed_color = COLORS.white
sign_width = min(SPEED_LIMIT_SIGN_WIDTH, rect.width * 0.30)
sign_height = VIENNA_SIGN_SIZE if ui_state.is_metric else MUTCD_SIGN_HEIGHT
has_upcoming_limit = self.next_speed_limit > 0 and self.next_speed_limit != self.speed_limit
target_sign_slide = 1.0 if has_upcoming_limit else 0.0
slide_speed = 3.0 * rl.get_frame_time()
if self._sign_slide < target_sign_slide:
self._sign_slide = min(self._sign_slide + slide_speed, target_sign_slide)
elif self._sign_slide > target_sign_slide:
self._sign_slide = max(self._sign_slide - slide_speed, target_sign_slide)
upcoming_width = int(sign_width * UPCOMING_SIGN_SIZE_RATIO)
upcoming_height = int(sign_height * UPCOMING_SIGN_SIZE_RATIO)
upcoming_reserved_width = int(upcoming_width * 0.85) + 5
sign_x_without_upcoming = rect.x + rect.width - sign_width - CONTENT_MARGIN
sign_x_with_upcoming = rect.x + rect.width - sign_width - CONTENT_MARGIN - upcoming_reserved_width
sign_x = sign_x_without_upcoming + (sign_x_with_upcoming - sign_x_without_upcoming) * self._sign_slide
sign_y = rect.y + (rect.height - sign_height) / 2
if not ui_state.is_metric and self.speed_limit_offset != 0 and self.speed_limit_valid:
sign_y += MUTCD_OFFSET_SIGN_Y_SHIFT
readout_right = sign_x - COLUMN_GAP
readout_width = max(1, readout_right - left_x)
road_y = rect.y + rect.height - 44
unit_font_size = self._fit_font_size(self._font_semi_bold, unit, readout_width, 46, UNIT_FONT_SIZE, 28)
speed_font_size = self._fit_font_size(self._font_bold, display_speed_text, readout_width, road_y - (rect.y + 54) - 8,
SPEED_FONT_SIZE, 76)
speed_size = measure_text_cached(self._font_bold, display_speed_text, speed_font_size)
speed_y = min(rect.y + 54, road_y - speed_size.y - 8)
unit_y = max(rect.y + 14, speed_y - unit_font_size - 6)
rl.draw_text_ex(self._font_semi_bold, unit, rl.Vector2(left_x, unit_y), unit_font_size, 0, COLORS.grey)
rl.draw_text_ex(self._font_bold, display_speed_text, rl.Vector2(left_x, speed_y), speed_font_size, 0, speed_color)
self._draw_road_name(left_x, road_y, readout_width)
if has_upcoming_limit and self._sign_slide > 0.01:
upcoming_speed_text = str(round(self.next_speed_limit))
distance_text = self._format_distance(self.next_speed_limit_distance)
upcoming_x = sign_x + sign_width - int(upcoming_width * UPCOMING_SIGN_OVERLAP_RATIO)
upcoming_y = sign_y + (sign_height - upcoming_height) / 2
upcoming_speed_color = COLORS.black
if ui_state.is_metric:
self._draw_vienna_sign(upcoming_x, upcoming_y, upcoming_width, upcoming_height, upcoming_speed_text, upcoming_speed_color, is_upcoming=True)
else:
self._draw_mutcd_sign(upcoming_x, upcoming_y, upcoming_width, upcoming_height, upcoming_speed_text, upcoming_speed_color, is_upcoming=True)
distance_font_size = self._fit_font_size(self._font_medium, distance_text, upcoming_width, 30, 24, 16)
distance_size = measure_text_cached(self._font_medium, distance_text, distance_font_size)
rl.draw_text_ex(self._font_medium, distance_text, rl.Vector2(upcoming_x + upcoming_width / 2 - distance_size.x / 2, upcoming_y + upcoming_height),
distance_font_size, 0, COLORS.grey)
self._draw_speed_limit_sign(sign_x, sign_y, sign_width, sign_height)
if self.speed_limit_offset != 0 and self.speed_limit_valid:
offset_text = str(abs(round(self.speed_limit_offset)))
badge_size = OFFSET_BADGE_SIZE
badge_rect = self._offset_badge_rect(rect, sign_x, sign_y, sign_width, sign_height, badge_size, has_upcoming_limit)
if ui_state.is_metric:
badge_radius = badge_size / 2
badge_center_x = badge_rect.x + badge_radius
badge_center_y = badge_rect.y + badge_radius
rl.draw_circle(int(badge_center_x), int(badge_center_y), badge_radius + 2, COLORS.dark_grey)
rl.draw_circle(int(badge_center_x), int(badge_center_y), badge_radius, COLORS.badge_bg)
self._draw_text_centered_fit(self._font_bold, offset_text, 32, rl.Vector2(badge_center_x, badge_center_y), COLORS.white,
badge_size - 10, badge_size - 8, min_size=24)
else:
rl.draw_rectangle_rounded(badge_rect, 0.25, 10, COLORS.badge_bg)
rl.draw_rectangle_rounded_lines_ex(badge_rect, 0.25, 10, 2, COLORS.dark_grey)
self._draw_text_centered_fit(self._font_bold, offset_text, 32, rl.Vector2(badge_rect.x + badge_size / 2, badge_rect.y + badge_size / 2),
COLORS.white, badge_size - 10, badge_size - 8, min_size=24)
scc_tag_x = min(left_x + speed_size.x + COLUMN_GAP, readout_right - SCC_TAG_WIDTH)
scc_tag_y = speed_y + (speed_size.y - (SCC_TAG_HEIGHT * 2 + SCC_TAG_GAP)) / 2
if scc_tag_x >= left_x + speed_size.x + 8:
self._draw_scc_icons(scc_tag_x, scc_tag_y, readout_right)
self._bookmark_icon.render(rect)
if ui_state.started:
alert_obj, no_alert = self._alert_renderer.will_render()
self._alert_alpha_filter.update(0 if no_alert else 1)
alpha = self._alert_alpha_filter.x
if alpha > 0.01:
rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), int(rect.height), rl.Color(0, 0, 0, int(150 * alpha)))
self._alert_renderer.render(rect)
def _draw_scc_icons(self, x: float, y: float, right_limit: float) -> None:
sm = ui_state.sm
if not sm.valid["longitudinalPlanSP"]:
return
scc = sm["longitudinalPlanSP"].smartCruiseControl
drawn = 0
for label, active in [("SCC-V", scc.vision.active), ("SCC-M", scc.map.active)]:
if not active:
continue
tag_x = x
if tag_x + SCC_TAG_WIDTH > right_limit:
return
tag_y = y + drawn * (SCC_TAG_HEIGHT + SCC_TAG_GAP)
rl.draw_rectangle_rounded(rl.Rectangle(tag_x, tag_y, SCC_TAG_WIDTH, SCC_TAG_HEIGHT), 0.3, 10, COLORS.green)
self._draw_text_centered_fit(self._font_bold, label, 18, rl.Vector2(tag_x + SCC_TAG_WIDTH / 2, tag_y + SCC_TAG_HEIGHT / 2), COLORS.black,
SCC_TAG_WIDTH - 10, SCC_TAG_HEIGHT - 4, min_size=14)
drawn += 1
def _draw_speed_limit_sign(self, x: float, y: float, sign_width: float, sign_height: float) -> None:
speed_str = str(round(self.speed_limit)) if self.speed_limit_valid and self.speed_limit > 0 else "--"
speed_color = COLORS.black if not self.speed_limit_valid or self.current_speed <= self.speed_limit else COLORS.red
if ui_state.is_metric:
self._draw_vienna_sign(x, y, sign_width, sign_height, speed_str, speed_color, is_upcoming=False)
else:
self._draw_mutcd_sign(x, y, sign_width, sign_height, speed_str, speed_color, is_upcoming=False)
def _draw_road_name(self, x: float, y: float, width: float) -> None:
if width <= 0:
return
road_display = self.road_name if self.road_name else "--"
font_size = self._fit_font_size(self._font_semi_bold, road_display, width, 38, ROAD_FONT_SIZE, 28)
road_size = measure_text_cached(self._font_semi_bold, road_display, font_size)
text_width = road_size.x
if text_width <= width:
self._marquee_offset = 0.0
self._marquee_direction = 1
self._marquee_pause_timer = 0.0
rl.draw_text_ex(self._font_semi_bold, road_display, rl.Vector2(x, y), font_size, 0, COLORS.white)
else:
overflow = text_width - width
dt = rl.get_frame_time()
if self._marquee_pause_timer > 0:
self._marquee_pause_timer -= dt
else:
self._marquee_offset += self._marquee_direction * self._marquee_speed * dt
if self._marquee_offset >= overflow:
self._marquee_offset = overflow
self._marquee_direction = -1
self._marquee_pause_timer = self._marquee_pause_duration
elif self._marquee_offset <= 0:
self._marquee_offset = 0
self._marquee_direction = 1
self._marquee_pause_timer = self._marquee_pause_duration
rl.begin_scissor_mode(int(x), int(y), int(width), int(road_size.y + 4))
text_pos = rl.Vector2(x - self._marquee_offset, y)
rl.draw_text_ex(self._font_semi_bold, road_display, text_pos, font_size, 0, COLORS.white)
rl.end_scissor_mode()
def _draw_vienna_sign(self, x: float, y: float, width: float, height: float, speed_str: str, speed_color: rl.Color, is_upcoming: bool = False) -> None:
center = rl.Vector2(x + width / 2, y + height / 2)
outer_radius = min(width, height) / 2
rl.draw_circle_v(center, outer_radius, COLORS.white)
ring_width = outer_radius * 0.18
rl.draw_ring(center, outer_radius - ring_width, outer_radius, 0, 360, 36, COLORS.red)
font_size = outer_radius * (0.7 if len(speed_str) >= 3 else 0.9)
self._draw_text_centered_fit(self._font_bold, speed_str, int(font_size), center, speed_color, width * 0.72, height * 0.50, min_size=24)
def _draw_mutcd_sign(self, x: float, y: float, width: float, height: float, speed_str: str, speed_color: rl.Color, is_upcoming: bool = False) -> None:
sign_rect = rl.Rectangle(x, y, width, height)
rl.draw_rectangle_rounded(sign_rect, 0.35, 10, COLORS.white)
inset = max(4, width * 0.05)
inner_rect = rl.Rectangle(x + inset, y + inset, width - inset * 2, height - inset * 2)
outer_radius = 0.35 * width / 2.0
inner_radius = outer_radius - inset
inner_roundness = inner_radius / (inner_rect.width / 2.0)
rl.draw_rectangle_rounded_lines_ex(inner_rect, inner_roundness, 10, 3, COLORS.black)
mid_x = x + width / 2
label_size = max(18, int(width * 0.26))
if is_upcoming:
self._draw_text_centered_fit(self._font_bold, tr("AHEAD"), int(width * 0.34), rl.Vector2(mid_x, y + height * 0.28), COLORS.black,
width * 0.94, height * 0.32, min_size=20)
else:
self._draw_text_centered_fit(self._font_bold, tr("SPEED"), label_size, rl.Vector2(mid_x, y + height * 0.20), COLORS.black,
width * 0.84, height * 0.24, min_size=16)
self._draw_text_centered_fit(self._font_bold, tr("LIMIT"), label_size, rl.Vector2(mid_x, y + height * 0.40), COLORS.black,
width * 0.84, height * 0.24, min_size=16)
speed_font_size = int(width * 0.60) if len(speed_str) >= 3 else int(width * 0.72)
self._draw_text_centered_fit(self._font_bold, speed_str, speed_font_size, rl.Vector2(mid_x, y + height * 0.72), speed_color,
width * 0.90, height * 0.52, min_size=32)
def _draw_text_centered(self, font, text, size, pos_center, color):
sz = measure_text_cached(font, text, size)
rl.draw_text_ex(font, text, rl.Vector2(pos_center.x - sz.x / 2, pos_center.y - sz.y / 2), size, 0, color)
def _draw_text_centered_fit(self, font, text, size, pos_center, color, max_width: float, max_height: float, min_size: int = 10):
size = self._fit_font_size(font, text, max_width, max_height, size, min_size)
self._draw_text_centered(font, text, size, pos_center, color)
def _fit_font_size(self, font, text: str, max_width: float, max_height: float, max_size: int | float, min_size: int) -> int:
size = int(max_size)
while size > min_size:
text_size = measure_text_cached(font, text, size)
if text_size.x <= max_width and text_size.y <= max_height:
return size
size -= 2
return min_size
def _offset_badge_rect(self, panel_rect: rl.Rectangle, sign_x: float, sign_y: float, sign_width: float, sign_height: float,
badge_size: float, has_upcoming_limit: bool) -> rl.Rectangle:
if ui_state.is_metric:
radius = min(sign_width, sign_height) / 2
center_x = sign_x + sign_width / 2
center_y = sign_y + sign_height / 2
badge_x_ratio = VIENNA_BADGE_UPCOMING_X_RATIO if has_upcoming_limit else VIENNA_BADGE_X_RATIO
badge_center_x = center_x + radius * badge_x_ratio
badge_center_y = center_y + radius * VIENNA_BADGE_Y_RATIO
badge_x = badge_center_x - badge_size / 2
badge_y = badge_center_y - badge_size / 2
else:
badge_x = sign_x + sign_width - badge_size * 0.45
badge_y = sign_y - badge_size * 0.75
return rl.Rectangle(
self._clamp(
badge_x,
panel_rect.x + OFFSET_BADGE_PANEL_PADDING,
panel_rect.x + panel_rect.width - badge_size - OFFSET_BADGE_PANEL_PADDING,
),
self._clamp(
badge_y,
panel_rect.y + OFFSET_BADGE_PANEL_PADDING,
panel_rect.y + panel_rect.height - badge_size - OFFSET_BADGE_PANEL_PADDING,
),
badge_size,
badge_size,
)
@staticmethod
def _clamp(value: float, min_value: float, max_value: float) -> float:
return max(min_value, min(max_value, value))
def _format_distance(self, distance: float) -> str:
if ui_state.is_metric:
if distance < 50:
return tr("Near")
if distance >= 1000:
return f"{distance * METER_TO_KM:.1f}" + tr("km")
if distance < 200:
rounded = max(10, int(distance / 10) * 10)
else:
rounded = int(distance / 100) * 100
return str(rounded) + tr("m")
else:
distance_mi = distance * METER_TO_MILE
if distance_mi < 0.1:
return tr("Near")
return f"{distance_mi:.1f}" + tr("mi")
@@ -0,0 +1,29 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView
class _SuppressedConfidenceBall:
def render(self, *_):
pass
class AugmentedRoadViewSP(AugmentedRoadView):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._show_confidence_ball: bool = True
self._real_confidence_ball = self._confidence_ball
self._confidence_ball = _SuppressedConfidenceBall()
def set_show_confidence_ball(self, show: bool) -> None:
self._show_confidence_ball = show
def _render(self, _) -> None:
super()._render(_)
if self._show_confidence_ball:
self._real_confidence_ball.render(self.rect)
@@ -0,0 +1,83 @@
import pyray as rl
from openpilot.common.test import OpenpilotTestCase
from openpilot.system.ui.lib.application import MouseEvent, MousePos, gui_app
from openpilot.system.ui.lib.scroll_panel2 import ScrollState
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets import scroller as scroller_mod
from openpilot.selfdrive.ui.sunnypilot.mici.widgets.scroll_panel_sp import GuiScrollPanel2SP
class DummyScrollIndicator:
def update(self, *_) -> None:
pass
def render(self) -> None:
pass
class DummyWidget(Widget):
def __init__(self, rect: rl.Rectangle):
super().__init__()
self.set_rect(rect)
def _render(self, _) -> None:
pass
def _mouse_event(x: float, y: float, *, pressed: bool = False, released: bool = False,
down: bool = True, t: float = 0.0) -> MouseEvent:
return MouseEvent(MousePos(x, y), 0, pressed, released, down, t)
class TestScrollerSP(OpenpilotTestCase):
def test_vertical_snap_items_are_supported(self, monkeypatch):
monkeypatch.setattr(scroller_mod, "ScrollIndicator", DummyScrollIndicator)
scroller = scroller_mod._Scroller([], horizontal=False, snap_items=True, scroll_indicator=False)
scroller.set_rect(rl.Rectangle(0, 0, 100, 100))
scroller.scroll_panel.set_offset(-60)
captured_snap_target = None
def update(_, __, snap_target=None):
nonlocal captured_snap_target
captured_snap_target = snap_target
return scroller.scroll_panel.get_offset()
monkeypatch.setattr(scroller.scroll_panel, "update", update)
visible_items: list[Widget] = [
DummyWidget(rl.Rectangle(0, -60, 100, 100)),
DummyWidget(rl.Rectangle(0, 40, 100, 100)),
]
scroller._get_scroll(visible_items, 200)
assert captured_snap_target == -100
def test_scroll_panel_sp_rejects_orthogonal_drags(self, monkeypatch):
panel = GuiScrollPanel2SP(horizontal=True)
bounds = rl.Rectangle(0, 0, 100, 100)
monkeypatch.setattr(gui_app, "_mouse_events", [_mouse_event(10, 10, pressed=True, t=1.0)])
panel.update(bounds, 200)
assert panel.state == ScrollState.PRESSED
monkeypatch.setattr(gui_app, "_mouse_events", [_mouse_event(23, 60, t=1.1)])
panel.update(bounds, 200)
assert panel.state == ScrollState.STEADY
assert panel.get_offset() == 0
def test_scroll_panel_sp_can_disable_out_of_bounds_handling(self, monkeypatch):
panel = GuiScrollPanel2SP(horizontal=False, handle_out_of_bounds=False)
bounds = rl.Rectangle(0, 0, 100, 100)
monkeypatch.setattr(gui_app, "_mouse_events", [])
panel.set_offset(20)
panel.update(bounds, 200)
assert panel.get_offset() == 0
panel.set_offset(-150)
panel.update(bounds, 200)
assert panel.get_offset() == -100
@@ -0,0 +1,33 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import pyray as rl
from openpilot.system.ui.lib.application import MouseEvent
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState
class GuiScrollPanel2SP(GuiScrollPanel2):
"""Scroll panel behavior for nested Mici pagers."""
def __init__(self, horizontal: bool = True, handle_out_of_bounds: bool = True) -> None:
super().__init__(horizontal, handle_out_of_bounds=handle_out_of_bounds)
def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, bounds_size: float,
content_size: float) -> None:
state_before_update = self._state
super()._handle_mouse_event(mouse_event, bounds, bounds_size, content_size)
if self._state == ScrollState.MANUAL_SCROLL and state_before_update == ScrollState.PRESSED and \
self._initial_click_event is not None:
drag_x = abs(mouse_event.pos.x - self._initial_click_event.pos.x)
drag_y = abs(mouse_event.pos.y - self._initial_click_event.pos.y)
primary_drag = drag_x if self._horizontal else drag_y
cross_drag = drag_y if self._horizontal else drag_x
if cross_drag > primary_drag:
self._state = ScrollState.STEADY
self._velocity = 0.0
self._velocity_buffer.clear()
@@ -0,0 +1,16 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.selfdrive.ui.sunnypilot.mici.widgets.scroll_panel_sp import GuiScrollPanel2SP
class ScrollerSP(Scroller):
def __init__(self, **kwargs):
super().__init__(**kwargs)
inner = self._scroller
inner.scroll_panel = GuiScrollPanel2SP(inner._horizontal, handle_out_of_bounds=not inner._snap_items)
+3
View File
@@ -10,6 +10,9 @@ from openpilot.selfdrive.ui.layouts.main import MainLayout
from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
from openpilot.selfdrive.ui.ui_state import ui_state
if gui_app.sunnypilot_ui():
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.main import MiciMainLayoutSP as MiciMainLayout
BIG_UI = gui_app.big_ui()
@@ -115,7 +115,7 @@ class IntelligentCruiseButtonManagement:
self.is_ready = ready and not button_pressed
def run(self, CS: car.CarState, CC: car.CarControl, LP_SP: custom.LongitudinalPlanSP, is_metric: bool) -> None:
if self.CP_SP.pcmCruiseSpeed:
if self.CP_SP.pcmCruiseSpeed or not self.CP_SP.intelligentCruiseButtonManagementAvailable:
return
self.is_metric = is_metric
@@ -136,6 +136,10 @@ def initialize_params(params) -> list[dict[str, Any]]:
keys.extend([
"ToyotaEnforceStockLongitudinal",
"ToyotaStopAndGoHack",
"ToyotaTSS2Long",
"ToyotaEnhancedBsm",
"ToyotaAutoHold",
"ToyotaVirtualCruiseSpeed",
])
return [{k: params.get(k, return_default=True)} for k in keys]
@@ -1,14 +1,26 @@
from opendbc.can.parser import CANParser
from opendbc.car import create_button_events
from opendbc.car.structs import car
from opendbc.car.toyota.carstate import get_virtual_cruise_button, VIRTUAL_CRUISE_BUTTONS
from openpilot.cereal import custom
from openpilot.common.constants import CV
from openpilot.common.parameterized import parameterized, parameterized_class
from openpilot.common.params import Params
from openpilot.selfdrive.car.cruise import V_CRUISE_INITIAL
from openpilot.common.test import OpenpilotTestCase
from openpilot.selfdrive.car.cruise import TOYOTA_VIRTUAL_CRUISE_LONG_PRESS, VCruiseHelper, V_CRUISE_INITIAL, V_CRUISE_UNSET
from openpilot.selfdrive.car.tests.test_cruise_speed import TestVCruiseHelper
from openpilot.sunnypilot.selfdrive.car.interfaces import initialize_params
ButtonEvent = car.CarState.ButtonEvent
ButtonType = car.CarState.ButtonEvent.Type
class TestToyotaParamsHandoff(OpenpilotTestCase):
def test_tss2_long_tuning_param_is_forwarded_to_opendbc(self):
keys = {next(iter(entry)) for entry in initialize_params(Params())}
assert "ToyotaTSS2Long" in keys
# TODO: test pcmCruise and pcmCruiseSpeed
@parameterized_class(('pcm_cruise', 'pcm_cruise_speed'), [(False, True)])
class TestCustomAccIncrements(TestVCruiseHelper):
@@ -114,8 +126,8 @@ class TestCustomAccIncrements(TestVCruiseHelper):
def test_rounding_behavior(self):
"""Test rounding behavior for 5 and 10 increments"""
test_cases = [
(47, 5, 50), # 47 -> 50 (round up to next 5)
(45, 5, 50), # 45 -> 50 (already at 5, increment by 5)
(47, 5, 50), # 47 -> 50 (round up to next 5)
(45, 5, 50), # 45 -> 50 (already at 5, increment by 5)
(43, 10, 50), # 43 -> 50 (round up to next 10)
(40, 10, 50), # 40 -> 50 (already at 10, increment by 10)
]
@@ -146,3 +158,302 @@ class TestCustomAccIncrements(TestVCruiseHelper):
initial_speed = self.v_cruise_helper.v_cruise_kph
self.press_button_long(ButtonType.accelCruise)
assert self.v_cruise_helper.v_cruise_kph == initial_speed + 10 # Should fallback to 10
class TestToyotaVirtualCruiseSpeed(OpenpilotTestCase):
def setup_method(self):
self.params = Params()
self.params.put_bool("CustomAccIncrementsEnabled", True, block=True)
self.params.put("CustomAccShortPressIncrement", 5, block=True)
self.params.put("CustomAccLongPressIncrement", 5, block=True)
CP = car.CarParams(brand="toyota", pcmCruise=True, openpilotLongitudinalControl=True)
CP_SP = custom.CarParamsSP(pcmCruiseSpeed=False)
self.v_cruise_helper = VCruiseHelper(CP, CP_SP)
self.v_cruise_helper.read_custom_set_speed_params()
self.route_parser = CANParser("toyota_nodsu_pt_generated", [("CLUTCH", 16)], 0)
self.route_button = 0
@staticmethod
def car_state(canonical_kph, cluster_kph, *, available=True, standstill=False, gas_pressed=False, v_ego_kph=0.0, button_events=None):
CS = car.CarState(
gasPressed=gas_pressed,
vEgo=v_ego_kph * CV.KPH_TO_MS,
cruiseState={
"available": available,
"speed": canonical_kph * CV.KPH_TO_MS,
"speedCluster": cluster_kph * CV.KPH_TO_MS,
"standstill": standstill,
},
)
CS.buttonEvents = button_events or []
return CS
def seed_enabled(self, canonical_kph, cluster_kph, *, is_metric=True):
CS = self.car_state(canonical_kph, cluster_kph)
self.v_cruise_helper.update_v_cruise(CS, enabled=False, is_metric=is_metric)
self.v_cruise_helper.update_v_cruise(CS, enabled=True, is_metric=is_metric)
self.v_cruise_helper.update_v_cruise(CS, enabled=True, is_metric=is_metric)
assert self.v_cruise_helper.v_cruise_kph == canonical_kph
assert self.v_cruise_helper.v_cruise_cluster_kph == cluster_kph
def press(self, button_type, canonical_kph, cluster_kph, hold_frames=0, *, standstill=False, gas_pressed=False, v_ego_kph=0.0, is_metric=True):
pressed = [ButtonEvent(type=button_type, pressed=True)]
self.v_cruise_helper.update_v_cruise(
self.car_state(canonical_kph, cluster_kph, standstill=standstill, gas_pressed=gas_pressed, v_ego_kph=v_ego_kph, button_events=pressed),
enabled=True,
is_metric=is_metric,
)
for _ in range(hold_frames):
self.v_cruise_helper.update_v_cruise(
self.car_state(canonical_kph, cluster_kph, standstill=standstill, gas_pressed=gas_pressed, v_ego_kph=v_ego_kph),
enabled=True,
is_metric=is_metric,
)
released = [ButtonEvent(type=button_type, pressed=False)]
self.v_cruise_helper.update_v_cruise(
self.car_state(canonical_kph, cluster_kph, standstill=standstill, gas_pressed=gas_pressed, v_ego_kph=v_ego_kph, button_events=released),
enabled=True,
is_metric=is_metric,
)
def set_increments(self, short_increment, long_increment):
self.params.put("CustomAccShortPressIncrement", short_increment, block=True)
self.params.put("CustomAccLongPressIncrement", long_increment, block=True)
self.v_cruise_helper.read_custom_set_speed_params()
def assert_kph_almost_equal(self, actual, expected):
self.assertAlmostEqual(actual, expected, delta=abs(expected) * 1e-6)
def route_button_events(self, payload):
self.route_parser.update((1, [(0x361, bytes.fromhex(payload), 0)]))
current = get_virtual_cruise_button(
self.route_parser.vl["CLUTCH"]["CRUISE_RES"],
self.route_parser.vl["CLUTCH"]["CRUISE_SET"],
)
events = create_button_events(current, self.route_button, VIRTUAL_CRUISE_BUTTONS)
self.route_button = current
return events
def test_short_press_rounds_display_target_and_preserves_offset(self):
self.seed_enabled(27, 31)
self.press(ButtonType.accelCruise, 28, 32)
assert self.v_cruise_helper.v_cruise_kph == 31
assert self.v_cruise_helper.v_cruise_cluster_kph == 35
def test_decel_at_display_minimum_does_not_increase_target(self):
self.seed_enabled(26, 30)
self.press(ButtonType.decelCruise, 25, 29)
assert self.v_cruise_helper.v_cruise_kph == 26
assert self.v_cruise_helper.v_cruise_cluster_kph == 30
@parameterized.expand((52, TOYOTA_VIRTUAL_CRUISE_LONG_PRESS - 1))
def test_route_length_short_press_is_not_a_long_press(self, hold_frames):
self.set_increments(short_increment=2, long_increment=5)
self.seed_enabled(27, 31)
self.press(ButtonType.accelCruise, 28, 32, hold_frames=hold_frames)
assert self.v_cruise_helper.v_cruise_kph == 29
assert self.v_cruise_helper.v_cruise_cluster_kph == 33
def test_toyota_long_press_uses_route_validated_cadence_and_suppresses_release(self):
self.set_increments(short_increment=2, long_increment=5)
self.seed_enabled(27, 31)
pressed = [ButtonEvent(type=ButtonType.accelCruise, pressed=True)]
self.v_cruise_helper.update_v_cruise(self.car_state(31, 35, button_events=pressed), enabled=True, is_metric=True)
for _ in range(TOYOTA_VIRTUAL_CRUISE_LONG_PRESS):
self.v_cruise_helper.update_v_cruise(self.car_state(31, 35), enabled=True, is_metric=True)
assert self.v_cruise_helper.v_cruise_kph == 31
assert self.v_cruise_helper.v_cruise_cluster_kph == 35
released = [ButtonEvent(type=ButtonType.accelCruise, pressed=False)]
self.v_cruise_helper.update_v_cruise(self.car_state(31, 35, button_events=released), enabled=True, is_metric=True)
assert self.v_cruise_helper.v_cruise_kph == 31
assert self.v_cruise_helper.v_cruise_cluster_kph == 35
def test_route_4_32_second_hold_repeats_six_times(self):
self.seed_enabled(26, 30)
self.press(ButtonType.accelCruise, 30, 34, hold_frames=432)
assert self.v_cruise_helper.v_cruise_kph == 56
assert self.v_cruise_helper.v_cruise_cluster_kph == 60
def test_maximum_boundary_caps_pair_and_preserves_offset(self):
self.seed_enabled(141, 145)
self.press(ButtonType.accelCruise, 142, 146)
assert self.v_cruise_helper.v_cruise_kph == 141
assert self.v_cruise_helper.v_cruise_cluster_kph == 145
self.press(ButtonType.accelCruise, 143, 147)
assert self.v_cruise_helper.v_cruise_kph == 141
assert self.v_cruise_helper.v_cruise_cluster_kph == 145
@parameterized.expand(
(
(25, 29, ButtonType.decelCruise),
(141, 147, ButtonType.accelCruise),
)
)
def test_out_of_range_raw_pair_is_not_moved_in_opposite_direction(self, canonical_kph, cluster_kph, button_type):
self.seed_enabled(canonical_kph, cluster_kph)
self.press(button_type, canonical_kph, cluster_kph)
assert self.v_cruise_helper.v_cruise_kph == canonical_kph
assert self.v_cruise_helper.v_cruise_cluster_kph == cluster_kph
def test_imperial_increment_preserves_canonical_cluster_pair(self):
self.seed_enabled(45, 50, is_metric=False)
self.press(ButtonType.accelCruise, 46, 51, is_metric=False)
assert self.v_cruise_helper.v_cruise_kph == 51
assert self.v_cruise_helper.v_cruise_cluster_kph == 56
def test_engagement_button_held_does_not_change_target(self):
initial = self.car_state(27, 31)
self.v_cruise_helper.update_v_cruise(initial, enabled=False, is_metric=True)
pressed = [ButtonEvent(type=ButtonType.decelCruise, pressed=True)]
self.v_cruise_helper.update_v_cruise(self.car_state(27, 31, button_events=pressed), enabled=False, is_metric=True)
for _ in range(TOYOTA_VIRTUAL_CRUISE_LONG_PRESS + 10):
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=True, is_metric=True)
released = [ButtonEvent(type=ButtonType.decelCruise, pressed=False)]
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32, button_events=released), enabled=True, is_metric=True)
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=True, is_metric=True)
assert self.v_cruise_helper.v_cruise_kph == 28
assert self.v_cruise_helper.v_cruise_cluster_kph == 32
def test_delayed_pcm_target_seeds_before_software_ownership(self):
invalid = self.car_state(0, 0)
self.v_cruise_helper.update_v_cruise(invalid, enabled=False, is_metric=True)
release = [ButtonEvent(type=ButtonType.decelCruise, pressed=False)]
for _ in range(4):
self.v_cruise_helper.update_v_cruise(self.car_state(0, 0, button_events=release), enabled=True, is_metric=True)
assert self.v_cruise_helper.v_cruise_kph == V_CRUISE_UNSET
assert self.v_cruise_helper.v_cruise_cluster_kph == V_CRUISE_UNSET
self.v_cruise_helper.update_v_cruise(self.car_state(27, 31), enabled=True, is_metric=True)
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_kph, 27)
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_cluster_kph, 31)
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=True, is_metric=True)
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_kph, 27)
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_cluster_kph, 31)
def test_route_payload_short_press_drives_virtual_target(self):
self.seed_enabled(27, 31)
pressed = self.route_button_events("a61a0000561a1a81")
self.v_cruise_helper.update_v_cruise(self.car_state(27, 31, button_events=pressed), enabled=True, is_metric=True)
for _ in range(52):
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=True, is_metric=True)
released = self.route_button_events("861a0000561b1a81")
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32, button_events=released), enabled=True, is_metric=True)
assert self.v_cruise_helper.v_cruise_kph == 31
assert self.v_cruise_helper.v_cruise_cluster_kph == 35
def test_prius_route_payload_short_set_drives_virtual_target(self):
self.seed_enabled(31, 35)
pressed = self.route_button_events("965f000056666585")
self.v_cruise_helper.update_v_cruise(self.car_state(31, 35, button_events=pressed), enabled=True, is_metric=True)
for _ in range(45):
self.v_cruise_helper.update_v_cruise(self.car_state(30, 34), enabled=True, is_metric=True)
released = self.route_button_events("865f000056666585")
self.v_cruise_helper.update_v_cruise(self.car_state(30, 34, button_events=released), enabled=True, is_metric=True)
assert self.v_cruise_helper.v_cruise_kph == 26
assert self.v_cruise_helper.v_cruise_cluster_kph == 30
def test_prius_route_payload_standstill_res_does_not_change_target(self):
self.seed_enabled(27, 31)
pressed = self.route_button_events("a61b0000561c1c80")
self.v_cruise_helper.update_v_cruise(
self.car_state(27, 31, standstill=True, button_events=pressed),
enabled=True,
is_metric=True,
)
for _ in range(TOYOTA_VIRTUAL_CRUISE_LONG_PRESS):
self.v_cruise_helper.update_v_cruise(self.car_state(27, 31, standstill=True), enabled=True, is_metric=True)
released = self.route_button_events("865f000056666585")
self.v_cruise_helper.update_v_cruise(
self.car_state(27, 31, standstill=True, button_events=released),
enabled=True,
is_metric=True,
)
assert self.v_cruise_helper.v_cruise_kph == 27
assert self.v_cruise_helper.v_cruise_cluster_kph == 31
def test_route_payload_disengage_mid_hold_clears_pending_action(self):
self.seed_enabled(27, 31)
pressed = self.route_button_events("a61a0000561a1a81")
self.v_cruise_helper.update_v_cruise(self.car_state(27, 31, button_events=pressed), enabled=True, is_metric=True)
for _ in range(30):
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=True, is_metric=True)
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=False, is_metric=True)
released = self.route_button_events("861a0000561b1a81")
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32, available=False, button_events=released), enabled=False, is_metric=True)
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=False, is_metric=True)
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=True, is_metric=True)
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=True, is_metric=True)
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_kph, 28)
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_cluster_kph, 32)
def test_standstill_resume_does_not_change_target(self):
self.seed_enabled(27, 31)
self.press(ButtonType.accelCruise, 27, 31, standstill=True)
assert self.v_cruise_helper.v_cruise_kph == 27
assert self.v_cruise_helper.v_cruise_cluster_kph == 31
def test_disengagement_discards_virtual_target_and_reseeds_raw_pair(self):
self.seed_enabled(27, 31)
self.press(ButtonType.accelCruise, 28, 32)
assert self.v_cruise_helper.v_cruise_kph == 31
raw = self.car_state(28, 32)
self.v_cruise_helper.update_v_cruise(raw, enabled=False, is_metric=True)
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_kph, 28)
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_cluster_kph, 32)
self.v_cruise_helper.update_v_cruise(raw, enabled=True, is_metric=True)
self.v_cruise_helper.update_v_cruise(raw, enabled=True, is_metric=True)
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_kph, 28)
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_cluster_kph, 32)
def test_unavailable_and_mads_handback_discard_virtual_target(self):
self.seed_enabled(27, 31)
self.press(ButtonType.accelCruise, 28, 32)
assert self.v_cruise_helper.v_cruise_kph == 31
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=False, is_metric=True)
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_kph, 28)
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_cluster_kph, 32)
self.v_cruise_helper.update_v_cruise(self.car_state(0, 0, available=False), enabled=False, is_metric=True)
assert self.v_cruise_helper.v_cruise_kph == V_CRUISE_UNSET
assert self.v_cruise_helper.v_cruise_cluster_kph == V_CRUISE_UNSET
self.v_cruise_helper.update_v_cruise(self.car_state(29, 33), enabled=False, is_metric=True)
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_kph, 29)
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_cluster_kph, 33)
def test_set_during_gas_override_clips_target_to_ego_speed(self):
self.seed_enabled(27, 31)
self.press(ButtonType.decelCruise, 26, 30, gas_pressed=True, v_ego_kph=50)
assert self.v_cruise_helper.v_cruise_kph == 50
assert self.v_cruise_helper.v_cruise_cluster_kph == 54
@@ -0,0 +1,392 @@
import math
from dataclasses import dataclass
import numpy as np
from openpilot.cereal import custom, log
from openpilot.common.realtime import DT_MDL
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.constants import (
ACCEL_V, BRAKE_BUILD_JERK, BRAKE_ONSET_JERK, DECEL_V, DESIRED_STOP_DISTANCE, FOLLOW_HEADWAY, GAP_DEADBAND_METERS,
GAP_DEADBAND_SECONDS, LAUNCH_ACCEL, NEUTRAL_ACCEL, PACE_BUFFER_TIME, PACE_GAIN, PACE_MAX_CLOSING_SPEED, PACE_MAX_OPENING_SPEED,
PACE_STABILITY_MARGIN, RELEASE_JERK, ROUTINE_DECEL, SPEED_BP, SPEED_RESPONSE_TIME, STOP_MARGIN_BP, STOP_MARGIN_V, TERMINAL_MAX_DECEL,
TERMINAL_PREVIEW_TIME, TERMINAL_TIME_CONSTANT, URGENT_BRAKE_JERK, AccelProfile,
)
AccelControllerState = custom.LongitudinalPlanSP.AccelController.State
@dataclass(frozen=True)
class AccelDecision:
a_target: float | None = None
should_stop: bool = False
stock_safety_required: bool = False
@dataclass(frozen=True)
class LeadObservation:
index: int
distance: float
speed: float
accel: float
track_id: int
model_prob: float
@dataclass(frozen=True)
class ProjectedLead:
observation: LeadObservation
distance: float
speed: float
relative_speed: float
relative_speed_target: float
accel: float
class AccelController:
def __init__(self, CP, dt: float = DT_MDL):
if not math.isfinite(dt) or dt <= 0.0:
raise ValueError("dt must be finite and positive")
self.dt = float(dt)
self.action_time = float(np.clip(float(CP.longitudinalActuatorDelay) + DT_MDL, DT_MDL, 1.0))
self._a_command: float | None = None
self._last_primary: LeadObservation | None = None
self._last_secondary: LeadObservation | None = None
self._primary_frames = 0
self._secondary_frames = 0
self._lead_clear_frames = 0
self._braking_latched = False
self._radar_faulted = False
self._radar_recovery_frames = 0
self.state = AccelControllerState.inactive
self.selected_lead = -1
@property
def is_active(self) -> bool:
return self.state != AccelControllerState.inactive
def reset(self) -> None:
self._a_command = None
self._last_primary = None
self._last_secondary = None
self._primary_frames = 0
self._secondary_frames = 0
self._lead_clear_frames = 0
self._braking_latched = False
self._radar_faulted = False
self._radar_recovery_frames = 0
self.state = AccelControllerState.inactive
self.selected_lead = -1
@staticmethod
def _read_lead(lead, index: int) -> LeadObservation | None:
try:
if not bool(lead.present):
return None
distance = float(lead.dRel)
speed = float(lead.vLeadK)
if not math.isfinite(distance) or distance < 0.0 or not math.isfinite(speed) or not -1.0 <= speed <= 100.0:
return None
accel = float(lead.aLeadK)
accel = float(np.clip(accel, -5.0, 3.0)) if math.isfinite(accel) else 0.0
track_id_value = float(lead.radarTrackId)
track_id = int(track_id_value) if math.isfinite(track_id_value) else -1
model_prob = float(lead.modelProb)
model_prob = float(np.clip(model_prob, 0.0, 1.0)) if math.isfinite(model_prob) else 0.0
except (AttributeError, TypeError, ValueError):
return None
return LeadObservation(index, distance, max(speed, 0.0), accel, max(track_id, -1), model_prob)
@staticmethod
def _claims_present(lead) -> bool:
try:
return bool(lead.present)
except (AttributeError, TypeError, ValueError):
return False
@staticmethod
def _project_lead(lead: LeadObservation, action_time: float) -> tuple[float, float]:
accel = min(lead.accel, 0.0)
travel_time = min(action_time, -lead.speed / accel) if accel < 0.0 else action_time
speed = max(lead.speed + accel * travel_time, 0.0)
travel = (lead.speed + speed) * travel_time * 0.5
return max(lead.distance + travel, 0.0), speed
@staticmethod
def _same_track(previous: LeadObservation | None, current: LeadObservation) -> bool:
if previous is None or previous.index != current.index:
return False
if previous.track_id >= 0 and current.track_id >= 0:
return previous.track_id == current.track_id
return abs(current.distance - previous.distance) <= 5.0
@staticmethod
def _same_physical_lead(first: ProjectedLead, second: ProjectedLead) -> bool:
first_track = first.observation.track_id
second_track = second.observation.track_id
if first_track >= 0 and second_track >= 0:
return first_track == second_track
return abs(first.distance - second.distance) <= 0.5 and abs(first.speed - second.speed) <= 0.5
def _update_lead_continuity(self, primary: LeadObservation | None, secondary: LeadObservation | None) -> None:
if primary is None:
self._primary_frames = 0
self._lead_clear_frames = self._lead_clear_frames + 1 if self._last_primary is not None else 0
if self._lead_clear_frames >= 3:
self._last_primary = None
elif self._same_track(self._last_primary, primary):
self._primary_frames += 1
self._lead_clear_frames = 0
else:
self._primary_frames = 1
self._lead_clear_frames = 0
if secondary is not None and self._same_track(self._last_secondary, secondary):
self._secondary_frames += 1
else:
self._secondary_frames = int(secondary is not None)
@staticmethod
def _pace_target(lead: LeadObservation, ego_distance: float, v_ego: float, headway: float, action_time: float) -> ProjectedLead:
lead_distance, lead_speed = AccelController._project_lead(lead, action_time)
projected_gap = max(lead_distance - ego_distance, 0.0)
desired_gap = DESIRED_STOP_DISTANCE + headway * v_ego
gap_error = projected_gap - desired_gap
gap_deadband = GAP_DEADBAND_METERS + GAP_DEADBAND_SECONDS * v_ego
effective_gap_error = math.copysign(max(abs(gap_error) - gap_deadband, 0.0), gap_error)
relative_speed = lead_speed - v_ego
relative_speed_target = -float(np.clip(effective_gap_error / PACE_BUFFER_TIME, -PACE_MAX_OPENING_SPEED, PACE_MAX_CLOSING_SPEED))
nominal_denominator = headway ** 2 / PACE_BUFFER_TIME + 2.0 * headway
pace_gain = max(PACE_GAIN, PACE_STABILITY_MARGIN / nominal_denominator)
accel = pace_gain * (relative_speed - relative_speed_target)
return ProjectedLead(lead, projected_gap, lead_speed, relative_speed, relative_speed_target, accel)
@staticmethod
def _terminal_target(projected: ProjectedLead, v_ego: float) -> float | None:
usable_distance = max(projected.distance - DESIRED_STOP_DISTANCE, 0.0)
if v_ego > 0.0 and usable_distance / v_ego > TERMINAL_PREVIEW_TIME:
return None
brake_tau = ROUTINE_DECEL * TERMINAL_TIME_CONSTANT
stop_speed = math.sqrt(brake_tau ** 2 + 2.0 * ROUTINE_DECEL * usable_distance) - brake_tau
if v_ego <= stop_speed:
return 0.0
curve_decel = ROUTINE_DECEL * v_ego / (v_ego + brake_tau) if v_ego > 0.0 else 0.0
braking_distance = max(usable_distance, 0.1)
required_decel = v_ego ** 2 / (2.0 * braking_distance) * float(np.interp(v_ego, STOP_MARGIN_BP, STOP_MARGIN_V))
return -min(max(curve_decel, required_decel), TERMINAL_MAX_DECEL)
@staticmethod
def _follow_target(projected: ProjectedLead, max_decel: float) -> float:
demand = max(-projected.accel, 0.0)
limit = min(ROUTINE_DECEL + 0.15 * max(demand - ROUTINE_DECEL, 0.0), max_decel)
return max(projected.accel, -limit)
@staticmethod
def _stopped(speed: float) -> bool:
return speed < 0.3
def _departure_confirmed(self, primary: LeadObservation | None, standstill: bool) -> bool:
if not standstill or self._radar_faulted:
return False
if primary is None:
return self._last_primary is None
if self._last_primary is None or not self._same_track(self._last_primary, primary):
return False
range_opening = primary.distance - self._last_primary.distance
return self._primary_frames >= 2 and not self._stopped(primary.speed) and range_opening >= 0.02
@staticmethod
def _lead_clear_for_launch(lead: ProjectedLead) -> bool:
distance_clear = lead.distance >= DESIRED_STOP_DISTANCE + 1.0 or not AccelController._stopped(lead.speed)
return lead.accel >= -NEUTRAL_ACCEL and distance_clear
def _fault_fallback(self) -> AccelDecision:
self.reset()
self._radar_faulted = True
return AccelDecision()
def _govern_accel(self, raw_target: float, max_accel: float, previous_plan_accel: float, *, launch: bool, terminal: bool) -> float:
if launch:
self._a_command = raw_target
return self._a_command
if self._a_command is None:
initial = previous_plan_accel if math.isfinite(previous_plan_accel) else 0.0
self._a_command = min(initial, max_accel)
previous = self._a_command
if raw_target < previous:
if raw_target < -ROUTINE_DECEL - 0.75:
jerk = URGENT_BRAKE_JERK
elif previous > -0.5:
jerk = BRAKE_ONSET_JERK
else:
jerk = BRAKE_BUILD_JERK
updated = max(raw_target, previous - jerk * self.dt)
else:
if terminal:
jerk = min(RELEASE_JERK, 0.6)
else:
jerk = RELEASE_JERK
updated = min(raw_target, previous + jerk * self.dt)
self._a_command = float(updated)
return self._a_command
def update(self, radar_state, *, v_ego: float, a_ego: float, v_cruise: float, follow_personality,
radar_valid: bool, stock_cruise_accel: float, stock_mpc_accel: float,
stock_should_stop: bool, fcw: bool, standstill: bool, stock_mpc_lead: int = -1,
previous_plan_accel: float = 0.0, profile: int = AccelProfile.normal) -> AccelDecision:
numeric_context = (v_ego, a_ego, v_cruise, stock_cruise_accel, stock_mpc_accel, self.action_time)
valid_context = (radar_valid and -0.1 <= v_ego <= 100.0 and 0.0 <= v_cruise <= 100.0
and abs(a_ego) <= 10.0 and abs(stock_cruise_accel) <= 10.0
and abs(stock_mpc_accel) <= 10.0 and all(math.isfinite(value) for value in numeric_context))
if not valid_context:
return self._fault_fallback()
if self._a_command is not None and math.isfinite(previous_plan_accel):
self._a_command = min(self._a_command, previous_plan_accel)
try:
raw_leads = (radar_state.leadOne, radar_state.leadTwo)
leads = [self._read_lead(raw_leads[0], 0), self._read_lead(raw_leads[1], 1)]
except AttributeError:
return self._fault_fallback()
if any(self._claims_present(raw_lead) and lead is None for raw_lead, lead in zip(raw_leads, leads, strict=True)):
return self._fault_fallback()
primary = leads[0]
secondary = leads[1]
if standstill and primary is None and secondary is not None:
return self._fault_fallback()
if self._radar_faulted:
if primary is None:
self._radar_recovery_frames += 1
self._radar_faulted = self._radar_recovery_frames < 3
else:
self._radar_faulted = False
self._radar_recovery_frames = 0
self._update_lead_continuity(primary, secondary)
primary_recently_missing = primary is None and self._last_primary is not None
current_v_ego = max(v_ego, 0.0)
projected_v_ego = max(current_v_ego + a_ego * self.action_time, 0.0)
ego_distance = (current_v_ego + projected_v_ego) * self.action_time * 0.5
accel_v = ACCEL_V.get(profile, ACCEL_V[AccelProfile.normal])
max_accel = float(np.interp(projected_v_ego, SPEED_BP, accel_v))
max_decel = float(np.interp(projected_v_ego, SPEED_BP, DECEL_V))
headway = FOLLOW_HEADWAY.get(follow_personality, FOLLOW_HEADWAY[log.LongitudinalPersonality.standard])
projected = [None if lead is None else self._pace_target(lead, ego_distance, projected_v_ego, headway, self.action_time) for lead in leads]
primary_projected, secondary_projected = projected
observed_leads = [lead for lead in projected if lead is not None]
free_accel = float(np.clip((v_cruise - projected_v_ego) / SPEED_RESPONSE_TIME, -ROUTINE_DECEL, max_accel))
raw_target = free_accel
selected_projected = None
secondary_trusted = secondary is not None and self._secondary_frames >= 3 and secondary.model_prob >= 0.5
trusted_leads = [lead for lead in (primary_projected, secondary_projected if secondary_trusted else None) if lead is not None]
terminal = False
for lead in trusted_leads:
if self._stopped(lead.speed):
lead_target = self._terminal_target(lead, projected_v_ego)
if lead_target is None:
continue
terminal = True
else:
lead_target = self._follow_target(lead, max_decel)
if lead_target < raw_target:
raw_target = lead_target
selected_projected = lead
if secondary_projected is not None and not secondary_trusted and secondary_projected.accel < -NEUTRAL_ACCEL:
raw_target = min(raw_target, 0.0)
all_leads_clear = all(self._lead_clear_for_launch(lead) for lead in observed_leads)
departure_confirmed = self._departure_confirmed(primary, bool(standstill)) and (primary is not None or secondary is None)
launch = bool(standstill and departure_confirmed and all_leads_clear and v_cruise > 0.3 and stock_cruise_accel > 0.0)
if launch:
self._braking_latched = False
raw_target = min(LAUNCH_ACCEL, v_cruise - projected_v_ego)
selected_projected = primary_projected
elif standstill:
raw_target = 0.0
self._a_command = 0.0
if primary_projected is not None:
pace_margin = primary_projected.relative_speed - primary_projected.relative_speed_target
if self._braking_latched and raw_target > 0.0 and pace_margin < 0.2 and not launch:
raw_target = 0.0
elif pace_margin >= 0.2:
self._braking_latched = False
elif not primary_recently_missing:
self._braking_latched = False
if primary_recently_missing:
raw_target = min(raw_target, 0.0)
if abs(raw_target) < NEUTRAL_ACCEL and not launch and not terminal:
raw_target = 0.0
raw_target = float(np.clip(raw_target, -(TERMINAL_MAX_DECEL if terminal else max_decel), max_accel))
if not standstill:
raw_target = min(raw_target, stock_cruise_accel)
a_target = self._govern_accel(raw_target, max_accel, previous_plan_accel, launch=launch, terminal=terminal)
if not standstill:
a_target = min(a_target, stock_cruise_accel)
self._a_command = a_target
if a_target <= -0.15 and primary_projected is not None:
self._braking_latched = True
should_stop = bool(standstill and not launch or
not launch and primary_projected is not None and self._stopped(primary_projected.speed)
and self._stopped(max(v_ego, 0.0))
and primary_projected.distance <= DESIRED_STOP_DISTANCE + 1.0
)
terminal_feasible = False
if terminal and selected_projected is not None and self._stopped(selected_projected.speed):
available_decel = min(max(-a_target, 0.0), max(-a_ego, 0.0))
usable_distance = max(selected_projected.distance - DESIRED_STOP_DISTANCE, 0.0)
stopping_distance = projected_v_ego ** 2 / (2.0 * max(available_decel, 0.1))
terminal_feasible = available_decel > NEUTRAL_ACCEL and usable_distance >= stopping_distance
stock_projected = projected[stock_mpc_lead] if 0 <= stock_mpc_lead < len(projected) else None
terminal_source_covered = (terminal_feasible and selected_projected is not None and stock_projected is not None
and self._same_physical_lead(selected_projected, stock_projected))
urgent_ttc = False
danger_gap = False
current_desired_gap = DESIRED_STOP_DISTANCE + headway * current_v_ego
projected_desired_gap = DESIRED_STOP_DISTANCE + headway * projected_v_ego
for observed_lead in observed_leads:
current_closing_speed = max(current_v_ego - observed_lead.observation.speed, 0.0)
projected_closing_speed = max(-observed_lead.relative_speed, 0.0)
current_ttc = observed_lead.observation.distance / current_closing_speed if current_closing_speed > 1e-3 else math.inf
projected_ttc = observed_lead.distance / projected_closing_speed if projected_closing_speed > 1e-3 else math.inf
lead_covered = terminal_source_covered and selected_projected is not None and self._same_physical_lead(selected_projected, observed_lead)
urgent_ttc |= min(current_ttc, projected_ttc) < 4.0 and not lead_covered
danger_gap |= (observed_lead.observation.distance < 0.75 * current_desired_gap
or observed_lead.distance < 0.75 * projected_desired_gap)
stock_more_urgent = not terminal_source_covered and stock_mpc_accel <= -1.2 and stock_mpc_accel < a_target - 0.3
stock_source_confirmed = (primary is None and stock_mpc_lead == -1) or (primary is not None and stock_mpc_lead == 0)
stale_stock_stop = bool(stock_should_stop and launch and stock_source_confirmed and not fcw and not danger_gap)
stock_safety_required = bool(
fcw or danger_gap or urgent_ttc or stock_more_urgent or stock_should_stop and not stale_stock_stop
)
self.selected_lead = -1 if selected_projected is None else selected_projected.observation.index
if should_stop:
self.state = AccelControllerState.stopHold
elif launch or a_target > (previous_plan_accel if math.isfinite(previous_plan_accel) else 0.0) + 1e-6:
self.state = AccelControllerState.release
elif a_target < -NEUTRAL_ACCEL:
self.state = AccelControllerState.restrict
elif primary_projected is not None:
self.state = AccelControllerState.hold
else:
self.state = AccelControllerState.free
decision = AccelDecision(a_target, should_stop, stock_safety_required)
if primary is not None:
self._last_primary = primary
self._last_secondary = secondary
return decision
@@ -0,0 +1,43 @@
from openpilot.cereal import custom, log
AccelProfile = custom.LongitudinalPlanSP.AccelController.Profile
SPEED_BP = (0.0, 3.0, 10.0, 20.0, 30.0, 40.0)
ACCEL_V = {
AccelProfile.eco: (2.00, 1.25, 0.90, 0.70, 0.55, 0.44),
AccelProfile.normal: (2.00, 1.40, 1.08, 0.84, 0.67, 0.52),
AccelProfile.sport: (2.00, 1.48, 1.20, 0.93, 0.73, 0.60),
}
DECEL_V = (1.0, 1.2, 2.3, 2.5, 2.5, 2.5)
FOLLOW_HEADWAY = {
log.LongitudinalPersonality.relaxed: 1.8,
log.LongitudinalPersonality.standard: 1.6,
log.LongitudinalPersonality.aggressive: 1.4,
}
PACE_BUFFER_TIME = 10.0
PACE_MAX_CLOSING_SPEED = 1.2
PACE_MAX_OPENING_SPEED = 1.0
PACE_GAIN = 0.65
PACE_STABILITY_MARGIN = 2.2
SPEED_RESPONSE_TIME = 3.0
ROUTINE_DECEL = DECEL_V[0]
BRAKE_ONSET_JERK = 1.0
BRAKE_BUILD_JERK = 0.45
URGENT_BRAKE_JERK = 2.2
RELEASE_JERK = 0.8
LAUNCH_ACCEL = 2.0
DESIRED_STOP_DISTANCE = 6.0
TERMINAL_TIME_CONSTANT = 3.0
TERMINAL_PREVIEW_TIME = 12.0
TERMINAL_MAX_DECEL = DECEL_V[-1]
STOP_MARGIN_BP = (0.0, 5.0, 15.0)
STOP_MARGIN_V = (1.0, 1.0, 1.6)
GAP_DEADBAND_METERS = 2.0
GAP_DEADBAND_SECONDS = 0.15
NEUTRAL_ACCEL = 0.08
@@ -0,0 +1,529 @@
import math
from dataclasses import FrozenInstanceError
from types import SimpleNamespace
import numpy as np
from openpilot.cereal import log
from openpilot.common.realtime import DT_MDL
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.accel_controller import AccelController, AccelDecision
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.constants import (
ACCEL_V, BRAKE_ONSET_JERK, DECEL_V, LAUNCH_ACCEL, RELEASE_JERK, SPEED_BP, AccelProfile,
)
def lead(*, present=False, distance=0.0, speed=0.0, accel=0.0, tau=1.5, track_id=-1, probability=1.0):
return SimpleNamespace(
present=present,
dRel=distance,
vRel=0.0,
vLead=speed,
vLeadK=speed,
aLeadK=accel,
aLeadTau=tau,
radarTrackId=track_id,
modelProb=probability,
radar=True,
)
def radar(lead_one=None, lead_two=None):
return SimpleNamespace(leadOne=lead_one or lead(), leadTwo=lead_two or lead())
def controller(*, delay=0.10, dt=DT_MDL):
CP = SimpleNamespace(longitudinalActuatorDelay=delay, openpilotLongitudinalControl=True)
return AccelController(CP, dt=dt)
def update(instance, radar_state=None, **overrides):
arguments = {
"v_ego": 10.0,
"a_ego": 0.0,
"v_cruise": 25.0,
"follow_personality": log.LongitudinalPersonality.standard,
"radar_valid": True,
"stock_cruise_accel": 1.0,
"stock_mpc_accel": 1.0,
"stock_should_stop": False,
"stock_mpc_lead": -1,
"fcw": False,
"standstill": False,
"profile": AccelProfile.normal,
}
arguments.update(overrides)
return instance.update(radar() if radar_state is None else radar_state, **arguments)
def settle(instance, radar_state=None, frames=80, **overrides):
decision = AccelDecision()
for _ in range(frames):
decision = update(instance, radar_state, **overrides)
return decision
class TestAccelControllerContract(OpenpilotTestCase):
def test_decision_is_a_small_immutable_contract(self):
decision = AccelDecision(a_target=-0.4, should_stop=True, stock_safety_required=True)
self.assertEqual(
(decision.a_target, decision.should_stop, decision.stock_safety_required),
(-0.4, True, True),
)
with self.assertRaises(FrozenInstanceError):
decision.should_stop = False
def test_invalid_dt_is_rejected(self):
for dt in (0.0, -0.1, math.nan, math.inf):
with self.subTest(dt=dt), self.assertRaises(ValueError):
controller(dt=dt)
def test_inactive_context_resets_without_actuating(self):
cases = (
{"radar_valid": False},
{"v_ego": math.nan},
{"a_ego": math.inf},
{"a_ego": -100.0},
{"v_ego": 1e308},
{"v_cruise": -1.0},
{"stock_cruise_accel": math.nan},
{"stock_mpc_accel": math.nan},
)
for case in cases:
with self.subTest(case=case):
instance = controller()
self.assertEqual(update(instance, **case), AccelDecision())
self.assertFalse(instance.is_active)
def test_acceleration_limit_never_exceeds_stock(self):
decision = settle(controller(), v_ego=5.0, stock_cruise_accel=1.1, stock_mpc_accel=1.1)
self.assertIsNotNone(decision.a_target)
self.assertLessEqual(decision.a_target, min(1.1, np.interp(5.0, SPEED_BP, ACCEL_V[AccelProfile.normal])))
def test_selected_profile_changes_positive_acceleration_only(self):
outputs = {}
for profile in ACCEL_V:
instance = controller()
previous = 0.0
for _ in range(80):
decision = update(instance, v_ego=5.0, stock_cruise_accel=2.0, stock_mpc_accel=2.0, previous_plan_accel=previous, profile=profile)
previous = decision.a_target
outputs[profile] = decision.a_target
self.assertLess(outputs[AccelProfile.eco], outputs[AccelProfile.normal])
self.assertLess(outputs[AccelProfile.normal], outputs[AccelProfile.sport])
slower_lead = radar(lead(present=True, distance=55.0, speed=14.0, track_id=7))
braking = {
profile: settle(controller(), slower_lead, v_ego=20.0, v_cruise=30.0, stock_cruise_accel=0.7, stock_mpc_accel=0.7, profile=profile).a_target
for profile in ACCEL_V
}
self.assertEqual(len({round(value, 9) for value in braking.values()}), 1)
def test_acceleration_profiles_are_ordered_and_linearly_interpolated(self):
self.assertEqual(tuple(ACCEL_V), (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport))
self.assertEqual(set(ACCEL_V), {AccelProfile.eco, AccelProfile.normal, AccelProfile.sport})
for values in ACCEL_V.values():
self.assertEqual(len(values), len(SPEED_BP))
self.assertEqual(values[0], LAUNCH_ACCEL)
self.assertTrue(all(after <= before for before, after in zip(values[:-1], values[1:], strict=True)))
for speed, expected in zip(SPEED_BP, values, strict=True):
self.assertEqual(np.interp(speed, SPEED_BP, values), expected)
for index in range(len(SPEED_BP) - 1):
midpoint = (SPEED_BP[index] + SPEED_BP[index + 1]) / 2.0
expected = (values[index] + values[index + 1]) / 2.0
self.assertAlmostEqual(np.interp(midpoint, SPEED_BP, values), expected)
for speed in SPEED_BP[1:]:
self.assertLess(np.interp(speed, SPEED_BP, ACCEL_V[AccelProfile.eco]), np.interp(speed, SPEED_BP, ACCEL_V[AccelProfile.normal]))
self.assertLess(np.interp(speed, SPEED_BP, ACCEL_V[AccelProfile.normal]), np.interp(speed, SPEED_BP, ACCEL_V[AccelProfile.sport]))
def test_deceleration_curve_is_ascending_and_linearly_interpolated(self):
self.assertEqual(len(DECEL_V), len(SPEED_BP))
self.assertTrue(all(after >= before for before, after in zip(DECEL_V[:-1], DECEL_V[1:], strict=True)))
for speed, expected in zip(SPEED_BP, DECEL_V, strict=True):
self.assertEqual(np.interp(speed, SPEED_BP, DECEL_V), expected)
for index in range(len(SPEED_BP) - 1):
midpoint = (SPEED_BP[index] + SPEED_BP[index + 1]) / 2.0
expected = (DECEL_V[index] + DECEL_V[index + 1]) / 2.0
self.assertAlmostEqual(np.interp(midpoint, SPEED_BP, DECEL_V), expected)
class TestElasticPace(OpenpilotTestCase):
def test_slower_lead_requests_early_decel_before_ttc_is_urgent(self):
instance = controller()
slower_lead = lead(present=True, distance=55.0, speed=14.0, track_id=7)
decision = settle(instance, radar(slower_lead), v_ego=20.0, v_cruise=30.0, stock_cruise_accel=0.7, stock_mpc_accel=0.7)
self.assertGreater(55.0 / (20.0 - 14.0), 5.0)
self.assertIsNotNone(decision.a_target)
self.assertLess(decision.a_target, 0.0)
self.assertTrue(instance.is_active)
self.assertFalse(decision.stock_safety_required)
def test_closing_gap_produces_one_monotonic_braking_decision(self):
instance = controller()
outputs = []
for distance in (65.0, 60.0, 55.0, 50.0, 45.0, 40.0, 35.0):
decision = settle(
instance,
radar(lead(present=True, distance=distance, speed=14.0, track_id=11)),
frames=4,
v_ego=20.0,
v_cruise=30.0,
stock_cruise_accel=0.7,
stock_mpc_accel=0.7,
)
outputs.append(decision.a_target)
self.assertTrue(all(value is not None and math.isfinite(value) for value in outputs))
self.assertTrue(all(after <= before + 1e-9 for before, after in zip(outputs[:-1], outputs[1:], strict=True)))
def test_actuator_delay_makes_the_same_closing_lead_no_less_restrictive(self):
radar_state = radar(lead(present=True, distance=45.0, speed=14.0, track_id=12))
short_delay = settle(controller(delay=0.05), radar_state, v_ego=20.0, v_cruise=30.0, stock_cruise_accel=0.7, stock_mpc_accel=0.7)
long_delay = settle(controller(delay=0.50), radar_state, v_ego=20.0, v_cruise=30.0, stock_cruise_accel=0.7, stock_mpc_accel=0.7)
self.assertLessEqual(long_delay.a_target, short_delay.a_target + 1e-9)
def test_routine_brake_onset_is_jerk_limited(self):
instance = controller()
settle(instance, frames=30, v_ego=20.0, v_cruise=30.0, stock_cruise_accel=0.7, stock_mpc_accel=0.7)
restrictive = radar(lead(present=True, distance=45.0, speed=12.0, track_id=13))
outputs = [
update(instance, restrictive, v_ego=20.0, v_cruise=30.0, stock_cruise_accel=0.7, stock_mpc_accel=0.7).a_target
for _ in range(20)
]
self.assertTrue(all(value is not None for value in outputs))
drops = [before - after for before, after in zip(outputs[:-1], outputs[1:], strict=True)]
self.assertLessEqual(max(drops), BRAKE_ONSET_JERK * DT_MDL + 1e-9)
self.assertLess(outputs[-1], outputs[0])
def test_duplicate_radar_falls_back_to_stock(self):
instance = controller()
tracked = radar(lead(present=True, distance=50.0, speed=14.0, track_id=14))
settle(instance, tracked, frames=20, v_ego=20.0, v_cruise=30.0, stock_cruise_accel=0.7, stock_mpc_accel=0.7)
duplicate_with_different_data = radar(lead(present=True, distance=15.0, speed=2.0, track_id=14))
fallback = update(
instance,
duplicate_with_different_data,
v_ego=20.0,
v_cruise=30.0,
stock_cruise_accel=0.7,
stock_mpc_accel=0.7,
radar_valid=False,
)
self.assertEqual(fallback, AccelDecision())
def test_equal_speed_lead_does_not_chase_a_small_gap_error(self):
instance = controller()
decision = settle(instance, radar(lead(present=True, distance=24.0, speed=10.0, track_id=15)), v_ego=10.0, v_cruise=25.0)
self.assertAlmostEqual(decision.a_target, 0.0)
def test_fresh_lead_dropout_does_not_immediately_reaccelerate(self):
instance = controller()
tracked = radar(lead(present=True, distance=35.0, speed=6.0, track_id=16))
settle(instance, tracked, v_ego=12.0, v_cruise=25.0)
for _ in range(2):
self.assertLessEqual(update(instance, radar(), v_ego=12.0, v_cruise=25.0).a_target, 0.0)
def test_second_lead_requires_persistence_and_can_only_add_braking(self):
instance = controller()
primary = lead(present=True, distance=80.0, speed=20.0, track_id=21, probability=0.95)
second = lead(present=True, distance=55.0, speed=14.0, track_id=22, probability=0.95)
decisions = [
update(instance, radar(primary, second), v_ego=20.0, v_cruise=30.0, stock_cruise_accel=0.7, stock_mpc_accel=0.7)
for _ in range(3)
]
self.assertGreaterEqual(decisions[1].a_target, decisions[0].a_target)
self.assertLess(decisions[2].a_target, decisions[1].a_target)
self.assertLess(decisions[2].a_target, 0.7)
class TestStopAndLaunch(OpenpilotTestCase):
def test_distant_stationary_lead_does_not_start_a_long_brake_event(self):
decision = settle(controller(), radar(lead(present=True, distance=300.0, speed=0.0, track_id=29)), v_ego=20.0, v_cruise=25.0,
stock_cruise_accel=0.8, stock_mpc_accel=0.8)
self.assertGreaterEqual(decision.a_target, 0.0)
def test_stopped_lead_converges_to_stop_hold_without_positive_rebound(self):
instance = controller()
outputs = []
should_stop = []
samples = ((2.0, 11.0), (1.2, 8.5), (0.6, 7.0), (0.25, 6.3), (0.05, 6.05), (0.0, 6.0))
for speed, distance in samples:
decision = settle(
instance,
radar(lead(present=True, distance=distance, speed=0.0, track_id=30)),
frames=10,
v_ego=speed,
v_cruise=8.0,
stock_cruise_accel=0.8,
stock_mpc_accel=-0.5,
stock_should_stop=speed < 0.3,
standstill=speed < 0.01,
)
outputs.append(decision.a_target)
should_stop.append(decision.should_stop)
self.assertTrue(all(value is not None and value <= 0.0 for value in outputs))
self.assertFalse(any(should_stop[:3]))
self.assertTrue(any(should_stop[3:]))
self.assertTrue(should_stop[-1])
def test_same_track_departure_has_no_controller_added_dwell(self):
instance = controller()
stopped = radar(lead(present=True, distance=6.0, speed=0.0, track_id=31))
held = settle(
instance,
stopped,
frames=8,
v_ego=0.0,
v_cruise=8.0,
stock_cruise_accel=0.8,
stock_mpc_accel=-0.5,
stock_should_stop=True,
stock_mpc_lead=0,
standstill=True,
)
self.assertTrue(held.should_stop)
departing = radar(lead(present=True, distance=6.1, speed=1.0, accel=1.0, track_id=31))
released = update(
instance,
departing,
v_ego=0.0,
v_cruise=8.0,
stock_cruise_accel=0.8,
stock_mpc_accel=0.8,
stock_should_stop=False,
standstill=True,
)
self.assertFalse(released.should_stop)
self.assertEqual(released.a_target, LAUNCH_ACCEL)
limited = controller()
settle(limited, stopped, frames=8, v_ego=0.0, v_cruise=0.5, stock_cruise_accel=0.1, stock_mpc_accel=-0.5,
stock_should_stop=True, stock_mpc_lead=0, standstill=True)
released = update(limited, departing, v_ego=0.0, v_cruise=0.5, stock_cruise_accel=0.1, stock_mpc_accel=0.1, standstill=True)
self.assertEqual(released.a_target, 0.5)
def test_radar_fault_requires_clear_road_confirmation_before_launch(self):
instance = controller()
stopped = radar(lead(present=True, distance=6.0, speed=0.0, track_id=31))
settle(instance, stopped, frames=8, v_ego=0.0, v_cruise=8.0, stock_cruise_accel=0.8, stock_mpc_accel=-0.5,
stock_should_stop=True, stock_mpc_lead=0, standstill=True)
self.assertEqual(update(instance, stopped, v_ego=0.0, v_cruise=8.0, radar_valid=False, standstill=True), AccelDecision())
clear_road = radar()
for _ in range(2):
held = update(instance, clear_road, v_ego=0.0, v_cruise=8.0, stock_cruise_accel=0.8, stock_mpc_accel=-0.5,
stock_should_stop=True, stock_mpc_lead=-1, standstill=True)
self.assertTrue(held.should_stop)
self.assertLessEqual(held.a_target, 0.0)
released = update(instance, clear_road, v_ego=0.0, v_cruise=8.0, stock_cruise_accel=0.8, stock_mpc_accel=-0.5,
stock_should_stop=True, stock_mpc_lead=-1, standstill=True)
self.assertFalse(released.should_stop)
self.assertGreater(released.a_target, 0.0)
def test_new_track_departure_requires_one_confirmation_frame(self):
instance = controller()
stopped = radar(lead(present=True, distance=6.0, speed=0.0, track_id=31))
settle(
instance,
stopped,
frames=8,
v_ego=0.0,
v_cruise=8.0,
stock_cruise_accel=0.8,
stock_mpc_accel=-0.5,
stock_should_stop=True,
stock_mpc_lead=0,
standstill=True,
)
changed_track = radar(lead(present=True, distance=6.1, speed=1.0, accel=1.0, track_id=99))
unconfirmed = update(
instance,
changed_track,
v_ego=0.0,
v_cruise=8.0,
stock_cruise_accel=0.8,
stock_mpc_accel=-0.5,
stock_should_stop=True,
stock_mpc_lead=0,
standstill=True,
)
self.assertTrue(unconfirmed.should_stop)
self.assertLessEqual(unconfirmed.a_target, 0.0)
confirmed_track = radar(lead(present=True, distance=6.2, speed=1.0, accel=1.0, track_id=99))
confirmed = update(
instance,
confirmed_track,
v_ego=0.0,
v_cruise=8.0,
stock_cruise_accel=0.8,
stock_mpc_accel=-0.5,
stock_should_stop=True,
standstill=True,
)
self.assertFalse(confirmed.should_stop)
self.assertGreater(confirmed.a_target, 0.0)
def test_secondary_lead_can_veto_but_not_authorize_launch(self):
def stopped_instance():
instance = controller()
stopped = radar(lead(present=True, distance=6.0, speed=0.0, track_id=60))
settle(instance, stopped, frames=8, v_ego=0.0, v_cruise=8.0, stock_cruise_accel=0.8, stock_mpc_accel=-0.5,
stock_should_stop=True, stock_mpc_lead=0, standstill=True)
return instance
departing = lead(present=True, distance=6.1, speed=1.0, accel=1.0, track_id=60)
blocked = update(stopped_instance(), radar(departing, lead(present=True, distance=4.0, speed=0.0, track_id=61)),
v_ego=0.0, v_cruise=8.0, stock_cruise_accel=0.8, stock_mpc_accel=-0.5, stock_should_stop=True,
stock_mpc_lead=0, standstill=True)
self.assertTrue(blocked.should_stop)
self.assertLessEqual(blocked.a_target, 0.0)
buffered = update(stopped_instance(), radar(departing, lead(present=True, distance=6.1, speed=0.0, track_id=61)),
v_ego=0.0, v_cruise=8.0, stock_cruise_accel=0.8, stock_mpc_accel=-0.5, stock_should_stop=True,
stock_mpc_lead=0, standstill=True)
self.assertTrue(buffered.should_stop)
self.assertLessEqual(buffered.a_target, 0.0)
released = update(stopped_instance(), radar(departing, lead(present=True, distance=20.0, speed=2.0, track_id=62)),
v_ego=0.0, v_cruise=8.0, stock_cruise_accel=0.8, stock_mpc_accel=-0.5, stock_should_stop=True,
stock_mpc_lead=0, standstill=True)
self.assertFalse(released.should_stop)
self.assertGreater(released.a_target, 0.0)
def test_secondary_only_at_standstill_falls_back_to_stock(self):
decision = update(controller(), radar(lead_two=lead(present=True, distance=20.0, speed=2.0, track_id=63)),
v_ego=0.0, v_cruise=8.0, stock_cruise_accel=0.8, stock_mpc_accel=0.8, stock_mpc_lead=1, standstill=True)
self.assertEqual(decision, AccelDecision())
class TestSafetyAndFallback(OpenpilotTestCase):
def test_nonurgent_stock_braking_does_not_own_normal_driving(self):
decision = settle(
controller(),
v_ego=15.0,
v_cruise=25.0,
stock_cruise_accel=1.0,
stock_mpc_accel=-0.6,
stock_should_stop=False,
)
self.assertFalse(decision.stock_safety_required)
self.assertGreater(decision.a_target, -0.6)
def test_stock_cruise_candidate_is_always_an_upper_bound(self):
decision = settle(controller(), v_ego=15.0, v_cruise=25.0, stock_cruise_accel=-0.3, stock_mpc_accel=0.8)
self.assertLessEqual(decision.a_target, -0.3)
def test_fcw_bypasses_comfort_ramp_and_preserves_stock_braking(self):
instance = controller()
settle(instance, v_ego=20.0, v_cruise=30.0, stock_cruise_accel=0.7, stock_mpc_accel=0.7)
decision = update(
instance,
radar(lead(present=True, distance=15.0, speed=5.0, track_id=40)),
v_ego=20.0,
v_cruise=30.0,
stock_cruise_accel=0.7,
stock_mpc_accel=-2.5,
fcw=True,
)
self.assertTrue(decision.stock_safety_required)
self.assertTrue(math.isfinite(decision.a_target))
def test_urgent_ttc_uses_stock_floor_without_fcw(self):
decision = update(
controller(),
radar(lead(present=True, distance=20.0, speed=10.0, track_id=41)),
v_ego=20.0,
v_cruise=30.0,
stock_cruise_accel=0.7,
stock_mpc_accel=-1.8,
)
self.assertTrue(decision.stock_safety_required)
self.assertTrue(math.isfinite(decision.a_target))
def test_current_ttc_stays_authoritative_during_measured_deceleration(self):
decision = update(
controller(delay=0.95),
radar(lead(present=True, distance=70.0, speed=0.0, track_id=42)),
v_ego=20.0,
a_ego=-10.0,
v_cruise=30.0,
stock_cruise_accel=0.7,
stock_mpc_accel=-1.0,
)
self.assertTrue(decision.stock_safety_required)
def test_feasible_terminal_braking_does_not_escalate_to_late_stock_braking(self):
instance = controller(delay=0.20)
stationary = radar(lead(present=True, distance=60.0, speed=0.0, track_id=43))
decision = settle(
instance,
stationary,
frames=60,
v_ego=15.0,
a_ego=-2.5,
v_cruise=30.0,
stock_cruise_accel=0.7,
stock_mpc_accel=-3.3,
stock_mpc_lead=0,
)
self.assertFalse(decision.stock_safety_required)
self.assertGreaterEqual(decision.a_target, -2.5)
unsafe = update(instance, radar(lead(present=True, distance=45.0, speed=0.0, track_id=43)), v_ego=15.0, a_ego=-2.5,
v_cruise=30.0, stock_cruise_accel=0.7, stock_mpc_accel=-3.3, stock_mpc_lead=0)
self.assertTrue(unsafe.stock_safety_required)
def test_terminal_feasibility_only_covers_the_selected_stock_source(self):
instance = controller()
primary = lead(present=True, distance=180.0, speed=0.0, track_id=44)
settle(instance, radar(primary), frames=60, v_ego=20.0, a_ego=-2.0, v_cruise=30.0,
stock_cruise_accel=0.7, stock_mpc_accel=-2.0, stock_mpc_lead=0)
secondary = lead(present=True, distance=70.0, speed=0.0, track_id=45)
decision = update(instance, radar(primary, secondary), v_ego=20.0, a_ego=-2.0, v_cruise=30.0,
stock_cruise_accel=0.7, stock_mpc_accel=-3.0, stock_mpc_lead=1)
self.assertTrue(decision.stock_safety_required)
def test_duplicate_radar_leads_share_terminal_feasibility(self):
instance = controller(delay=0.20)
primary = lead(present=True, distance=60.0, speed=0.0, track_id=-1)
settle(instance, radar(primary), frames=60, v_ego=15.0, a_ego=-2.5, v_cruise=30.0,
stock_cruise_accel=0.7, stock_mpc_accel=-3.3, stock_mpc_lead=0)
duplicate = lead(present=True, distance=60.0, speed=0.0, track_id=-1)
decision = update(instance, radar(primary, duplicate), v_ego=15.0, a_ego=-2.5, v_cruise=30.0,
stock_cruise_accel=0.7, stock_mpc_accel=-3.3, stock_mpc_lead=1)
self.assertFalse(decision.stock_safety_required)
def test_stock_emergency_releases_through_the_jerk_governor(self):
instance = controller()
settle(instance, stock_cruise_accel=0.8, stock_mpc_accel=0.8)
update(instance, stock_cruise_accel=0.8, stock_mpc_accel=-2.5, fcw=True)
released = update(instance, stock_cruise_accel=0.8, stock_mpc_accel=0.8, previous_plan_accel=-2.5)
self.assertLessEqual(released.a_target - (-2.5), RELEASE_JERK * DT_MDL + 1e-9)
def test_unhealthy_radar_falls_back_to_stock(self):
decision = update(
controller(),
radar_valid=False,
stock_cruise_accel=0.7,
stock_mpc_accel=-0.4,
stock_should_stop=True,
)
self.assertEqual(decision, AccelDecision())
def test_malformed_lead_never_produces_nonfinite_output(self):
malformed = radar(lead(present=True, distance=math.nan, speed=math.inf, accel=math.nan, track_id=50))
decision = update(controller(), malformed, stock_cruise_accel=0.7, stock_mpc_accel=-0.3)
self.assertEqual(decision, AccelDecision())
@@ -0,0 +1,361 @@
from types import SimpleNamespace
from typing import Any
from openpilot.cereal import custom, log
from openpilot.common.params import Params
from openpilot.common.test import OpenpilotTestCase
from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalPlanSource as MpcSource
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.accel_controller import AccelController, AccelControllerState
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.constants import AccelProfile
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlannerSP, LongitudinalPlanSource
def lead(*, present=False, distance=0.0, speed=0.0, accel=0.0, track_id=-1, probability=1.0):
return SimpleNamespace(
present=present,
dRel=distance,
vRel=0.0,
vLead=speed,
vLeadK=speed,
aLeadK=accel,
aLeadTau=1.5,
radarTrackId=track_id,
modelProb=probability,
radar=True,
)
def radar(lead_one=None, lead_two=None):
return SimpleNamespace(leadOne=lead_one or lead(), leadTwo=lead_two or lead())
class PlannerSM(dict):
def __init__(self, *, experimental=False, force_decel=False, radar_state=None, radar_time=100, standstill=False, frame=0):
super().__init__(
radarState=radar_state or radar(),
carState=SimpleNamespace(vEgo=10.0, aEgo=0.0, vCruise=72.0, standstill=standstill),
selfdriveState=SimpleNamespace(
enabled=True,
experimentalMode=experimental,
personality=log.LongitudinalPersonality.standard,
),
controlsState=SimpleNamespace(forceDecel=force_decel, longControlState=LongCtrlState.pid),
)
self.valid = {"radarState": True}
self.alive = {"radarState": True}
self.logMonoTime = {"radarState": radar_time}
self.frame = frame
def all_checks(self, service_list=None):
return True
def accel_controller():
CP = SimpleNamespace(longitudinalActuatorDelay=0.1, openpilotLongitudinalControl=True)
return AccelController(CP)
def planner_for_hook(*, available=True, enabled=True, profile=AccelProfile.normal):
planner = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
dynamic_planner: Any = planner
dynamic_planner.accel_controller = accel_controller()
dynamic_planner.accel_controller_available = available
dynamic_planner.accel_controller_enabled = enabled
dynamic_planner.accel_controller_profile = profile
dynamic_planner.dec = SimpleNamespace(active=lambda: False)
dynamic_planner.model_accel_transition = SimpleNamespace(active=False)
dynamic_planner.output_v_target = 30.0
dynamic_planner.previous_plan_accel = 0.0
dynamic_planner.a_cruise = 0.0
dynamic_planner.fcw = False
dynamic_planner._radar_fresh_this_cycle = True
dynamic_planner._radar_healthy_this_cycle = True
return planner
class TestPlannerOwnership(OpenpilotTestCase):
def test_active_controller_owns_routine_acc_instead_of_stock_mpc(self):
planner = planner_for_hook()
candidates = [(-0.6, MpcSource.lead0, False), (0.7, MpcSource.cruise, False)]
result = planner.update_accel_controller(PlannerSM(), candidates)
self.assertEqual(len(result), 1)
self.assertEqual(result[0][1:], (MpcSource.cruise, False))
self.assertGreater(result[0][0], -0.6)
self.assertLessEqual(result[0][0], 0.7)
self.assertTrue(planner.accel_controller.is_active)
def test_controller_candidate_uses_the_selected_primary_lead_source(self):
planner = planner_for_hook()
planner.output_v_target = 30.0
sm = PlannerSM(radar_state=radar(lead(present=True, distance=55.0, speed=14.0, track_id=10)))
sm["carState"].vEgo = 20.0
candidates = [(-0.6, MpcSource.lead0, False), (0.7, MpcSource.cruise, False)]
result = planner.update_accel_controller(sm, candidates)
self.assertEqual(len(result), 1)
self.assertEqual(result[0][1], MpcSource.lead0)
self.assertLess(result[0][0], 0.0)
def test_only_second_lead_is_published_as_lead_one_source(self):
planner = planner_for_hook()
sm = PlannerSM(radar_state=radar(lead_two=lead(present=True, distance=55.0, speed=14.0, track_id=20)))
sm["carState"].vEgo = 20.0
candidates = [(-0.6, MpcSource.lead0, False), (0.7, MpcSource.cruise, False)]
for _ in range(3):
result = planner.update_accel_controller(sm, candidates)
self.assertEqual(len(result), 1)
self.assertEqual(result[0][1], MpcSource.lead1)
class TestPlannerSafetyArbitration(OpenpilotTestCase):
def test_fake_mpc_lead_does_not_block_free_road_launch(self):
planner = planner_for_hook()
sm = PlannerSM(standstill=True)
sm["carState"].vEgo = 0.0
candidates = [(0.0, MpcSource.lead1, True), (0.8, MpcSource.cruise, False)]
result = planner.update_accel_controller(sm, candidates)
self.assertEqual(len(result), 1)
self.assertGreater(result[0][0], 0.0)
self.assertFalse(result[0][2])
def test_fcw_keeps_stock_emergency_candidate_in_parallel(self):
planner = planner_for_hook()
planner.fcw = True
candidates = [(-2.5, MpcSource.lead0, False), (0.7, MpcSource.cruise, False)]
result = planner.update_accel_controller(PlannerSM(), candidates)
self.assertEqual(len(result), 2)
self.assertTrue(planner.accel_controller.is_active)
self.assertIn((-2.5, MpcSource.lead0, False), result)
self.assertEqual(min(result, key=lambda candidate: candidate[0]), (-2.5, MpcSource.lead0, False))
def test_urgent_ttc_keeps_stock_stop_and_braking_authoritative(self):
planner = planner_for_hook()
sm = PlannerSM(radar_state=radar(lead(present=True, distance=20.0, speed=10.0, track_id=30)))
sm["carState"].vEgo = 20.0
candidates = [(-1.8, MpcSource.lead0, True), (0.7, MpcSource.cruise, False)]
result = planner.update_accel_controller(sm, candidates)
self.assertEqual(len(result), 2)
self.assertIn((-1.8, MpcSource.lead0, True), result)
self.assertTrue(any(stop for _, _, stop in result))
self.assertEqual(min(result, key=lambda candidate: candidate[0]), (-1.8, MpcSource.lead0, True))
def test_confirmed_departure_suppresses_stale_stock_stop_in_same_frame(self):
planner = planner_for_hook()
stopped_sm = PlannerSM(
radar_state=radar(lead(present=True, distance=6.0, speed=0.0, track_id=31)),
standstill=True,
)
stopped_sm["carState"].vEgo = 0.0
stopped_candidates = [(-0.5, MpcSource.lead0, True), (0.8, MpcSource.cruise, False)]
for _ in range(2):
held = planner.update_accel_controller(stopped_sm, stopped_candidates)
self.assertTrue(any(stop for _, _, stop in held))
departure_sm = PlannerSM(
radar_state=radar(lead(present=True, distance=6.1, speed=1.0, accel=1.0, track_id=31)),
standstill=True,
)
departure_sm["carState"].vEgo = 0.0
released = planner.update_accel_controller(departure_sm, stopped_candidates)
self.assertEqual(len(released), 1)
self.assertGreater(released[0][0], 0.0)
self.assertFalse(released[0][2])
class TestPlannerFallbacks(OpenpilotTestCase):
def test_disabled_e2e_force_decel_and_unhealthy_radar_preserve_exact_candidates(self):
candidates = (
(-0.4, MpcSource.lead0, True),
(0.6, MpcSource.cruise, False),
(0.2, MpcSource.e2e, False),
)
disabled = planner_for_hook(enabled=False)
blended = planner_for_hook()
forced = planner_for_hook()
unhealthy = planner_for_hook()
unhealthy._radar_healthy_this_cycle = False
cases = (
(disabled, PlannerSM()),
(blended, PlannerSM(experimental=True)),
(forced, PlannerSM(force_decel=True)),
(unhealthy, PlannerSM()),
)
for planner, sm in cases:
with self.subTest(planner=planner, sm=sm):
result = planner.update_accel_controller(sm, candidates)
self.assertIs(result, candidates)
def test_missing_cruise_or_mpc_candidate_is_exact_noop(self):
planner = planner_for_hook()
candidates_without_cruise = ((-0.4, MpcSource.lead0, True), (0.2, MpcSource.e2e, False))
candidates_without_mpc = ((0.6, MpcSource.cruise, False), (0.2, MpcSource.e2e, False))
self.assertIs(planner.update_accel_controller(PlannerSM(), candidates_without_cruise), candidates_without_cruise)
self.assertIs(planner.update_accel_controller(PlannerSM(), candidates_without_mpc), candidates_without_mpc)
def test_malformed_radar_is_exact_stock_fallback(self):
planner = planner_for_hook()
candidates = ((-0.4, MpcSource.lead0, True), (0.6, MpcSource.cruise, False))
malformed_sm = PlannerSM(radar_state=SimpleNamespace())
self.assertIs(planner.update_accel_controller(malformed_sm, candidates), candidates)
def test_disengaged_acc_cannot_publish_stale_positive_acceleration(self):
planner = planner_for_hook()
sm = PlannerSM()
sm["controlsState"].longControlState = LongCtrlState.off
candidates = [(-0.4, MpcSource.lead0, False), (0.6, MpcSource.cruise, False)]
result = planner.update_accel_controller(sm, candidates)
self.assertEqual(result, [candidates[0], (0.0, MpcSource.cruise, False)])
self.assertEqual(planner.a_cruise, 0.0)
self.assertEqual(planner.accel_controller.state, AccelControllerState.inactive)
def test_radar_freshness_requires_a_healthy_advanced_message(self):
planner = planner_for_hook()
planner._radar_log_mono_time = None
sm = PlannerSM(radar_time=100)
self.assertTrue(planner._update_radar_freshness(sm))
self.assertFalse(planner._update_radar_freshness(sm))
sm.logMonoTime["radarState"] = 101
self.assertTrue(planner._update_radar_freshness(sm))
sm.valid["radarState"] = False
sm.logMonoTime["radarState"] = 102
self.assertFalse(planner._update_radar_freshness(sm))
self.assertFalse(planner._radar_healthy_this_cycle)
class TestParamsSchemaAndTelemetry(OpenpilotTestCase):
def test_planner_reads_and_sanitizes_controller_params(self):
planner = planner_for_hook(enabled=False)
planner.params = Params()
planner.params.put_bool("AccelPersonalityEnabled", False, block=True)
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport):
planner.params.put("AccelPersonality", profile, block=True)
planner.read_accel_controller_params()
self.assertFalse(planner.accel_controller_enabled)
self.assertEqual(planner.accel_controller_profile, profile)
planner.params.put_bool("AccelPersonalityEnabled", True, block=True)
for value, expected in ((-99, AccelProfile.eco), (99, AccelProfile.sport)):
planner.params.put("AccelPersonality", value, block=True)
planner.read_accel_controller_params()
self.assertTrue(planner.accel_controller_enabled)
self.assertEqual(planner.accel_controller_profile, expected)
self.assertEqual(planner.params.get("AccelPersonality"), expected)
def test_planner_refreshes_controller_params_every_five_frames(self):
planner = planner_for_hook(enabled=False)
planner._radar_log_mono_time = None
planner.events_sp = SimpleNamespace(clear=lambda: None)
planner.dec.update = lambda sm: None
planner.e2e_alerts_helper = SimpleNamespace(update=lambda sm, events: None)
calls = []
sm = PlannerSM()
planner.read_accel_controller_params = lambda: calls.append(sm.frame)
for frame in range(10):
sm.frame = frame
sm.logMonoTime["radarState"] = frame
planner.update(sm)
self.assertEqual(calls, [0, 5])
def test_schema_contract_and_round_trip(self):
fields = custom.LongitudinalPlanSP.AccelController.schema.fields
self.assertEqual(
{name: field.proto.ordinal.explicit for name, field in fields.items()},
{"enabled": 0, "active": 1, "shadowOnlyDEPRECATED": 2, "profile": 3, "state": 4},
)
self.assertEqual(
custom.LongitudinalPlanSP.AccelController.Profile.schema.enumerants,
{"eco": 0, "normal": 1, "sport": 2},
)
message = custom.LongitudinalPlanSP.new_message()
message.accelController.enabled = True
message.accelController.active = True
message.accelController.profile = custom.LongitudinalPlanSP.AccelController.Profile.sport
message.accelController.state = AccelControllerState.stopHold
with custom.LongitudinalPlanSP.from_bytes(message.to_bytes()) as reader:
self.assertTrue(reader.accelController.enabled)
self.assertTrue(reader.accelController.active)
self.assertEqual(reader.accelController.profile, custom.LongitudinalPlanSP.AccelController.Profile.sport)
self.assertEqual(reader.accelController.state, AccelControllerState.stopHold)
def test_controller_telemetry_is_published(self):
planner = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
dynamic_planner: Any = planner
dynamic_planner.source = LongitudinalPlanSource.cruise
dynamic_planner.output_v_target = 20.0
dynamic_planner.output_a_target = -0.1
dynamic_planner.events_sp = SimpleNamespace(to_msg=list)
dynamic_planner.dec = SimpleNamespace(mode=lambda: "acc", enabled=lambda: False, active=lambda: False)
dynamic_planner.accel_controller = accel_controller()
dynamic_planner.accel_controller_available = True
dynamic_planner.accel_controller_enabled = True
dynamic_planner.accel_controller_profile = AccelProfile.sport
dynamic_planner.accel_controller.state = AccelControllerState.restrict
dynamic_planner.scc = SimpleNamespace(
vision=SimpleNamespace(
state=0,
output_v_target=20.0,
output_a_target=0.0,
current_lat_acc=0.0,
max_pred_lat_acc=0.0,
is_enabled=False,
is_active=False,
),
map=SimpleNamespace(state=0, output_v_target=20.0, output_a_target=0.0, is_enabled=False, is_active=False),
)
dynamic_planner.resolver = SimpleNamespace(
speed_limit=0.0,
speed_limit_last=0.0,
speed_limit_final=0.0,
speed_limit_final_last=0.0,
speed_limit_valid=False,
speed_limit_last_valid=False,
speed_limit_offset=0.0,
distance=0.0,
source=custom.LongitudinalPlanSP.SpeedLimit.Source.none,
)
dynamic_planner.sla = SimpleNamespace(
state=custom.LongitudinalPlanSP.SpeedLimit.AssistState.disabled,
is_enabled=False,
is_active=False,
output_v_target=20.0,
output_a_target=0.0,
)
dynamic_planner.e2e_alerts_helper = SimpleNamespace(green_light_alert=False, lead_depart_alert=False)
sent = {}
dynamic_planner.publish_longitudinal_plan_sp(
PlannerSM(),
SimpleNamespace(send=lambda service, message: sent.update({service: message})),
)
telemetry = sent["longitudinalPlanSP"].longitudinalPlanSP.accelController
self.assertTrue(telemetry.enabled)
self.assertTrue(telemetry.active)
self.assertEqual(telemetry.profile, AccelProfile.sport)
self.assertEqual(telemetry.state, AccelControllerState.restrict)
self.assertEqual(
set(custom.LongitudinalPlanSP.AccelController.schema.fields),
{"enabled", "active", "shadowOnlyDEPRECATED", "profile", "state"},
)
@@ -1,4 +1,6 @@
class WMACConstants:
MODEL_ACCEL_TRANSITION_RATE = 3.0
# Lead detection parameters
LEAD_WINDOW_SIZE = 6 # Stable detection window
LEAD_PROB = 0.45 # Balanced threshold for lead detection
@@ -6,13 +6,15 @@ See the LICENSE.md file in the root directory for more details.
"""
# Version = 2025-6-30
import math
from typing import Literal
from openpilot.cereal import messaging
from opendbc.car import structs
from numpy import interp
from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
from openpilot.sunnypilot.selfdrive.controls.lib.dec.constants import WMACConstants
from typing import Literal
# d-e2e, from modeldata.h
TRAJECTORY_SIZE = 33
@@ -130,6 +132,51 @@ class ModeTransitionManager:
return self.current_mode
class ModelAccelTransition:
"""Smooths acceleration while DEC changes modes."""
def __init__(self, dt: float = DT_MDL):
self._max_step = WMACConstants.MODEL_ACCEL_TRANSITION_RATE * dt
self._accel = 0.0
self._active = False
self._blended = False
def reset(self) -> None:
self._active = False
self._blended = False
@property
def active(self) -> bool:
return self._active
def update(self, mpc_accel: float, model_accel: float, previous_accel: float, *, blended: bool,
urgent: bool = False, reset: bool = False) -> float:
selected_accel = min(mpc_accel, model_accel) if blended else mpc_accel
if reset or not all(math.isfinite(accel) for accel in (mpc_accel, model_accel, previous_accel)):
self.reset()
return selected_accel
if blended != self._blended:
self._accel = previous_accel
self._active = True
self._blended = blended
if urgent and selected_accel <= self._accel:
self._accel = selected_accel
self._active = True
return selected_accel
if not self._active:
return selected_accel
transition_target = model_accel if blended else mpc_accel
preview_accel = max(self._accel - self._max_step, min(self._accel + self._max_step, transition_target))
output_accel = min(mpc_accel, preview_accel)
self._accel = output_accel
if mpc_accel >= transition_target and math.isclose(preview_accel, transition_target, abs_tol=1e-9):
self._active = False
return output_accel
class DynamicExperimentalController:
def __init__(self, CP: structs.CarParams, mpc, params=None):
self._CP = CP
@@ -33,11 +33,11 @@ class MockDec:
def enabled(self) -> bool:
return True
class MockSubMaster(dict):
def __init__(self, services: dict):
super().__init__(services)
self.valid = dict.fromkeys(services, True)
self.alive = dict.fromkeys(services, True)
self.logMonoTime = dict.fromkeys(services, 0)
self.updated = dict.fromkeys(services, True)
self.recv_frame = dict.fromkeys(services, 1)
@@ -1,5 +1,7 @@
from openpilot.common.realtime import DT_MDL
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController
from openpilot.sunnypilot.selfdrive.controls.lib.dec.constants import WMACConstants
from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController, ModelAccelTransition
class MockLeadOne:
def __init__(self, present=0.0):
@@ -89,3 +91,56 @@ class TestDynamicExperimentalController(OpenpilotTestCase):
controller.update(default_sm)
assert controller.mode() == "blended"
class TestModelAccelTransition(OpenpilotTestCase):
def test_blended_entry_is_rate_bounded(self):
transition = ModelAccelTransition()
previous = 0.30
outputs = []
for _ in range(10):
previous = transition.update(0.50, -0.88, previous, blended=True)
outputs.append(previous)
max_step = WMACConstants.MODEL_ACCEL_TRANSITION_RATE * DT_MDL
self.assertAlmostEqual(outputs[0], 0.15)
assert min(b - a for a, b in zip([0.30, *outputs[:-1]], outputs, strict=True)) >= -max_step - 1e-9
self.assertAlmostEqual(outputs[-1], -0.88)
def test_harder_mpc_braking_is_immediate(self):
transition = ModelAccelTransition()
self.assertAlmostEqual(transition.update(-2.0, -0.88, 0.30, blended=True), -2.0)
def test_urgent_model_braking_is_immediate(self):
transition = ModelAccelTransition()
self.assertAlmostEqual(transition.update(0.0, -2.0, 0.30, blended=True, urgent=True), -2.0)
self.assertAlmostEqual(transition.update(0.0, 0.0, -2.0, blended=True), -1.85)
def test_harder_mpc_release_is_rate_bounded(self):
transition = ModelAccelTransition()
self.assertAlmostEqual(transition.update(-2.0, -0.5, 0.30, blended=True), -2.0)
self.assertAlmostEqual(transition.update(0.0, -0.5, -2.0, blended=True), -1.85)
def test_entry_and_exit_are_rate_bounded(self):
transition = ModelAccelTransition()
output = 0.30
for _ in range(20):
output = transition.update(0.0, -0.88, output, blended=True)
if output <= -0.88:
break
else:
self.fail("transition did not converge")
output = transition.update(0.0, -1.50, output, blended=True)
self.assertAlmostEqual(output, -1.50)
self.assertAlmostEqual(transition.update(0.0, -0.20, output, blended=False), -1.35)
def test_harder_mpc_braking_bypasses_exit_ramp(self):
transition = ModelAccelTransition()
self.assertAlmostEqual(transition.update(2.0, 0.76, 0.76, blended=True), 0.76)
self.assertAlmostEqual(transition.update(-2.0, 0.76, 0.76, blended=False), -2.0)
def test_urgent_release_remains_rate_bounded(self):
transition = ModelAccelTransition()
self.assertAlmostEqual(transition.update(-1.0, -1.0, -1.0, blended=True), -1.0)
self.assertAlmostEqual(transition.update(1.0, -1.0, -1.0, blended=False, urgent=True), -0.85)
@@ -0,0 +1,101 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import math
from typing import cast
from opendbc.car import DT_CTRL
STOPPING_DISTANCE = 0.75
STOPPING_TIME = 2.5
STOPPING_ACCEL_TOLERANCE = 0.1
STOPPING_SPEED_TOLERANCE = 0.05
STOPPING_SETTLE_FRAMES = 30
STOPPING_HOLD_ACCEL = -1.2
STOPPING_HOLD_MARGIN = 0.6
STOPPING_HOLD_SPEED_TOLERANCE = 0.01
class LongControlSP:
def __init__(self):
self._stopping_settle_frames: int | None = None
self._stopping_hold_accel: float | None = None
def _hold_supported(self) -> bool:
return self.CP.openpilotLongitudinalControl and not self.CP.notCar and self.CP.stopAccel < 0.0
def update_state(self, stopping: bool, active: bool, CS) -> None:
if not active:
self._stopping_settle_frames = None
self._stopping_hold_accel = None
return
invalid_speed = not all(math.isfinite(speed) for speed in (CS.vEgo, CS.vEgoRaw))
moving = max(abs(CS.vEgo), abs(CS.vEgoRaw)) > STOPPING_SPEED_TOLERANCE
if invalid_speed or (not stopping and moving):
self._stopping_hold_accel = None
elif (self._hold_supported() and math.isfinite(self.last_output_accel)
and self.last_output_accel <= self.CP.stopAccel):
previous_hold = self._stopping_hold_accel if self._stopping_hold_accel is not None else self.last_output_accel
self._stopping_hold_accel = min(self.last_output_accel, previous_hold)
if not stopping:
self._stopping_settle_frames = None
if self._stopping_hold_accel is not None and math.isfinite(self.last_output_accel):
self._stopping_hold_accel = min(self.last_output_accel, self._stopping_hold_accel)
def stopping_accel(self, output_accel: float, CS) -> float:
if self._stopping_hold_accel is not None and math.isfinite(CS.vEgo) and abs(CS.vEgo) <= STOPPING_SPEED_TOLERANCE:
return min(output_accel, self._stopping_hold_accel)
return output_accel
def stopping_decel_rate(self, CS, a_target: float, output_accel: float) -> float:
if not all(math.isfinite(value) for value in (output_accel, a_target, CS.vEgo, CS.vEgoRaw, CS.aEgo)):
return 1.0
hold_supported = self._hold_supported()
preserving_hold = self._stopping_hold_accel is not None
can_hold = output_accel <= 0.0 and a_target >= output_accel
terminal_speed = (0.0 <= CS.vEgo <= STOPPING_SPEED_TOLERANCE
or CS.standstill and abs(CS.vEgo) <= STOPPING_SPEED_TOLERANCE)
positive_stop_entry = self.last_output_accel > 0.0 and output_accel == 0.0
if output_accel > 0.0 or positive_stop_entry or CS.vEgo < 0.0 and not terminal_speed:
return 1.0
if terminal_speed and self._stopping_settle_frames is None:
if not preserving_hold and (not can_hold or output_accel > -STOPPING_ACCEL_TOLERANCE or CS.aEgo >= -STOPPING_ACCEL_TOLERANCE):
return 1.0
self._stopping_settle_frames = 0
time_decel = 0.0 if self._stopping_settle_frames is not None else CS.vEgo / STOPPING_TIME
required_decel = max(time_decel, CS.vEgo ** 2 / (2.0 * STOPPING_DISTANCE), 1e-3)
adequacy = min(max(-CS.aEgo / required_decel, 0.0), 1.0)
planner_need = min(max((output_accel - a_target) / max(required_decel, STOPPING_ACCEL_TOLERANCE), 0.0), 1.0)
if not terminal_speed and self._stopping_settle_frames is None and can_hold and adequacy >= 1.0:
self._stopping_settle_frames = 0
if hold_supported:
self._stopping_hold_accel = output_accel
motion_need = 1.0 - adequacy ** 2
terminal_need = 0.0
if terminal_speed or self._stopping_settle_frames not in (None, 0):
settle_frames = cast(int, self._stopping_settle_frames)
self._stopping_settle_frames = min(settle_frames + 1, STOPPING_SETTLE_FRAMES)
terminal_need = (self._stopping_settle_frames / STOPPING_SETTLE_FRAMES) ** 2
if preserving_hold and self._stopping_hold_accel is not None:
self._stopping_hold_accel = min(output_accel, self._stopping_hold_accel)
if terminal_speed:
minimum_hold = min(STOPPING_HOLD_ACCEL, self.CP.stopAccel + STOPPING_HOLD_MARGIN)
hold_target = max(self.CP.stopAccel, min(minimum_hold, self._stopping_hold_accel))
if CS.aEgo > STOPPING_ACCEL_TOLERANCE or abs(CS.vEgoRaw) > STOPPING_HOLD_SPEED_TOLERANCE:
return 1.0
hold_rate = max(planner_need, terminal_need)
if CS.vEgoRaw == 0.0 and abs(CS.vEgo) <= STOPPING_HOLD_SPEED_TOLERANCE:
if output_accel <= hold_target:
return planner_need
hold_rate = max(planner_need, min(hold_rate, (output_accel - hold_target) / DT_CTRL))
return hold_rate
return max(motion_need, planner_need, terminal_need)
@@ -8,8 +8,14 @@ See the LICENSE.md file in the root directory for more details.
from openpilot.cereal import messaging, custom
from opendbc.car import structs
from openpilot.common.constants import CV
from openpilot.selfdrive.car.cruise import V_CRUISE_MAX
from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController
from openpilot.common.params import Params
from openpilot.selfdrive.car.cruise import V_CRUISE_MAX, V_CRUISE_UNSET
from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalPlanSource as MpcSource
from openpilot.sunnypilot import get_sanitize_int_param
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.accel_controller import AccelController
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.constants import AccelProfile
from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController, ModelAccelTransition
from openpilot.sunnypilot.selfdrive.controls.lib.e2e_alerts_helper import E2EAlertsHelper
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.smart_cruise_control import SmartCruiseControl
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_assist import SpeedLimitAssist
@@ -23,18 +29,32 @@ LongitudinalPlanSource = custom.LongitudinalPlanSP.LongitudinalPlanSource
class LongitudinalPlannerSP:
def __init__(self, CP: structs.CarParams, CP_SP: structs.CarParamsSP, mpc):
self.accel_controller = AccelController(CP, dt=mpc.dt)
self.accel_controller_available = bool(CP.openpilotLongitudinalControl)
self.accel_controller_enabled = False
self.accel_controller_profile = AccelProfile.normal
self.params = Params()
self.read_accel_controller_params()
self.events_sp = EventsSP()
self.resolver = SpeedLimitResolver()
self.dec = DynamicExperimentalController(CP, mpc)
self.model_accel_transition = ModelAccelTransition(mpc.dt)
self.scc = SmartCruiseControl()
self.resolver = SpeedLimitResolver()
self.sla = SpeedLimitAssist(CP, CP_SP)
self.generation = int(model_bundle.generation) if (model_bundle := get_active_bundle()) else None
self.source = LongitudinalPlanSource.cruise
self.e2e_alerts_helper = E2EAlertsHelper()
self._radar_log_mono_time = None
self._radar_fresh_this_cycle = True
self._radar_healthy_this_cycle = True
self.output_v_target = 0.
self.output_a_target = 0.
self.previous_plan_accel = 0.
def read_accel_controller_params(self) -> None:
self.accel_controller_enabled = self.params.get_bool("AccelPersonalityEnabled")
self.accel_controller_profile = get_sanitize_int_param("AccelPersonality", AccelProfile.eco, AccelProfile.sport, self.params)
def is_e2e(self, sm: messaging.SubMaster) -> bool:
experimental_mode = sm['selfdriveState'].experimentalMode
@@ -43,6 +63,73 @@ class LongitudinalPlannerSP:
return experimental_mode and self.dec.mode() == "blended"
def select_model_accel(self, mpc_accel: float, model_accel: float, *, blended: bool,
should_stop: bool, fcw: bool, reset: bool) -> float:
return self.model_accel_transition.update(
mpc_accel, model_accel, self.previous_plan_accel, blended=blended, urgent=should_stop or fcw, reset=reset or not self.dec.active(),
)
def update_accel_controller(self, sm: messaging.SubMaster, candidates):
if not candidates:
return candidates
cruise_index = next((i for i, candidate in enumerate(candidates[1:], start=1) if candidate[1] == MpcSource.cruise), -1)
if cruise_index < 0:
return candidates
CS = sm['carState']
long_control_off = sm['controlsState'].longControlState == LongCtrlState.off
reset_state = ((long_control_off if self.accel_controller_available else not sm['selfdriveState'].enabled)
or CS.vCruise == V_CRUISE_UNSET)
cruise_accel = candidates[cruise_index][0]
mpc_accel = candidates[0][0]
mpc_source = candidates[0][1]
radar_state = sm['radarState']
lead_one_present = bool(getattr(getattr(radar_state, 'leadOne', None), 'present', False))
lead_two_present = bool(getattr(getattr(radar_state, 'leadTwo', None), 'present', False))
stock_mpc_lead = 0 if mpc_source == MpcSource.lead0 and lead_one_present else 1 if mpc_source == MpcSource.lead1 and lead_two_present else -1
stock_should_stop = any(candidate[2] for candidate in candidates)
transitioning_from_e2e = self.model_accel_transition.active
acc_selected = not self.is_e2e(sm) and not transitioning_from_e2e
active = not reset_state and acc_selected and not sm['controlsState'].forceDecel
radar_valid = self._radar_fresh_this_cycle and self._radar_healthy_this_cycle
if not self.accel_controller_available or not self.accel_controller_enabled or not active:
self.accel_controller.reset()
if self.accel_controller_available and self.accel_controller_enabled and acc_selected and reset_state:
self.a_cruise = 0.0
if cruise_accel > 0.0:
candidates = list(candidates)
_, source, stop = candidates[cruise_index]
candidates[cruise_index] = (0.0, source, stop)
return candidates
decision = self.accel_controller.update(
radar_state, v_ego=CS.vEgo, a_ego=CS.aEgo, v_cruise=self.output_v_target, follow_personality=sm['selfdriveState'].personality,
radar_valid=radar_valid, stock_cruise_accel=cruise_accel, stock_mpc_accel=mpc_accel, stock_should_stop=stock_should_stop,
fcw=self.fcw, standstill=CS.standstill, stock_mpc_lead=stock_mpc_lead, previous_plan_accel=self.previous_plan_accel,
profile=self.accel_controller_profile,
)
if decision.a_target is None:
return candidates
controller_source = (MpcSource.lead1 if self.accel_controller.selected_lead == 1 else
MpcSource.lead0 if self.accel_controller.selected_lead == 0 else MpcSource.cruise)
controller_candidate = (decision.a_target, controller_source, decision.should_stop)
if not decision.stock_safety_required:
return [controller_candidate]
stock_candidate = min(candidates, key=lambda candidate: candidate[0])
stock_safety_candidate = (stock_candidate[0], stock_candidate[1], stock_should_stop)
return [controller_candidate, stock_safety_candidate]
def _update_radar_freshness(self, sm: messaging.SubMaster) -> bool:
radar_log_mono_time = sm.logMonoTime['radarState']
radar_healthy = sm.valid['radarState'] and sm.alive['radarState']
self._radar_healthy_this_cycle = radar_healthy
radar_advanced = self._radar_log_mono_time is None or radar_log_mono_time > self._radar_log_mono_time
if radar_advanced:
self._radar_log_mono_time = radar_log_mono_time
return radar_healthy and radar_advanced
def update_targets(self, sm: messaging.SubMaster, v_ego: float, a_ego: float, v_cruise: float) -> tuple[float, float]:
CS = sm['carState']
v_cruise_cluster_kph = min(CS.vCruiseCluster, V_CRUISE_MAX)
@@ -74,6 +161,9 @@ class LongitudinalPlannerSP:
return self.output_v_target, self.output_a_target
def update(self, sm: messaging.SubMaster) -> None:
self._radar_fresh_this_cycle = self._update_radar_freshness(sm)
if sm.frame % 5 == 0:
self.read_accel_controller_params()
self.events_sp.clear()
self.dec.update(sm)
self.e2e_alerts_helper.update(sm, self.events_sp)
@@ -95,6 +185,12 @@ class LongitudinalPlannerSP:
dec.enabled = self.dec.enabled()
dec.active = self.dec.active()
accel_controller = longitudinalPlanSP.accelController
accel_controller.enabled = self.accel_controller_available and self.accel_controller_enabled
accel_controller.active = self.accel_controller.is_active
accel_controller.profile = self.accel_controller_profile
accel_controller.state = self.accel_controller.state
# Smart Cruise Control
smartCruiseControl = longitudinalPlanSP.smartCruiseControl
# Vision Control
@@ -4,6 +4,8 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from types import SimpleNamespace
from typing import Any
import numpy as np
@@ -15,8 +17,23 @@ from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET
from openpilot.selfdrive.modeld.constants import ModelConstants
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlannerSP, LongitudinalPlanSource
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control import MIN_V
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.vision_controller import SmartCruiseControlVision, _ENTERING_PRED_LAT_ACC_TH
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.vision_controller import (
_A_LAT_REG_MAX,
_BELOW_EGO_TARGET_RELEASE_RATE,
_ENTERING_PRED_LAT_ACC_TH,
_MIN_ACTIVATION_SPEED,
_RELIEF_CONFIRMATION_FRAMES,
_TARGET_RELEASE_CONFIRMATION_FRAMES,
_TARGET_RELEASE_RATE,
_TARGET_TIGHTEN_CONFIRMATION_FRAMES,
_TARGET_TIGHTEN_RATE,
_TURNING_LAT_ACC_TH,
_URGENT_PRED_LAT_ACC_TH,
SmartCruiseControlVision,
)
from openpilot.common.test import OpenpilotTestCase
VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.VisionState
@@ -107,7 +124,6 @@ def generate_controlsState():
class TestSmartCruiseControlVision(OpenpilotTestCase):
def setup_method(self):
self.params = Params()
self.reset_params()
@@ -121,36 +137,377 @@ class TestSmartCruiseControlVision(OpenpilotTestCase):
def reset_params(self):
self.params.put_bool("SmartCruiseControlVision", True, block=True)
def assert_approx(self, actual, expected):
self.assertAlmostEqual(actual, expected, delta=max(1e-12, abs(expected) * 1e-6))
def set_lat_accels(self, current: float, predicted: float, v_ego: float = 20.0, model_speed: float = 20.0) -> None:
self.sm['controlsState'].curvature = current / v_ego**2
self.sm['modelV2'].velocity.x = [model_speed] * len(ModelConstants.T_IDXS)
self.sm['modelV2'].orientationRate.z = [predicted / model_speed] * len(ModelConstants.T_IDXS)
def update_lat_accels(
self, current: float, predicted: float, cruise: float = 30.0, a_ego: float = 0.0, v_ego: float = 20.0, model_speed: float = 20.0
) -> None:
self.set_lat_accels(current, predicted, v_ego, model_speed)
self.scc_v.update(self.sm, True, False, v_ego, a_ego, cruise)
def enter_curve(self, predicted: float = 2.2) -> None:
self.update_lat_accels(0.5, predicted)
self.update_lat_accels(0.5, predicted)
assert self.scc_v.state == VisionState.entering
def test_initial_state(self):
assert self.scc_v.state == VisionState.disabled
assert not self.scc_v.is_active
assert self.scc_v.output_v_target == V_CRUISE_UNSET
assert self.scc_v.output_a_target == 0.
assert self.scc_v.output_a_target == 0.0
def test_system_disabled(self):
self.params.put_bool("SmartCruiseControlVision", False, block=True)
self.scc_v.enabled = self.params.get_bool("SmartCruiseControlVision")
for _ in range(int(10. / DT_MDL)):
self.scc_v.update(self.sm, True, False, 0., 0., 0.)
for _ in range(int(10.0 / DT_MDL)):
self.scc_v.update(self.sm, True, False, 0.0, 0.0, 0.0)
assert self.scc_v.state == VisionState.disabled
assert not self.scc_v.is_active
def test_disabled(self):
for _ in range(int(10. / DT_MDL)):
self.scc_v.update(self.sm, False, False, 0., 0., 0.)
for _ in range(int(10.0 / DT_MDL)):
self.scc_v.update(self.sm, False, False, 0.0, 0.0, 0.0)
assert self.scc_v.state == VisionState.disabled
def test_transition_disabled_to_enabled(self):
for _ in range(int(10. / DT_MDL)):
self.scc_v.update(self.sm, True, False, 0., 0., 0.)
for _ in range(int(10.0 / DT_MDL)):
self.scc_v.update(self.sm, True, False, 0.0, 0.0, 0.0)
assert self.scc_v.state == VisionState.enabled
@parameterized.expand([
def test_unconfirmed_release_holds_but_urgent_reentry_tightens(self):
self.enter_curve()
targets = [self.scc_v.output_v_target]
self.update_lat_accels(2.0, 2.2, a_ego=-0.8)
assert self.scc_v.state == VisionState.turning
assert self.scc_v.output_a_target == -0.8
turning_demand = self.scc_v._v_demand()
targets.append(self.scc_v.output_v_target)
self.update_lat_accels(1.2, 1.2, a_ego=0.3)
assert self.scc_v.state == VisionState.leaving
assert self.scc_v.output_a_target == 0.3
targets.append(self.scc_v.output_v_target)
self.update_lat_accels(1.0, 3.0, a_ego=-1.2)
assert self.scc_v.state == VisionState.entering
assert self.scc_v.output_a_target == -1.2
reentry_demand = self.scc_v._v_demand()
targets.append(self.scc_v.output_v_target)
entering, turning, leaving, reentering = targets
assert turning < entering
self.assert_approx(turning, turning_demand)
self.assert_approx(leaving, turning)
assert reentering < leaving
self.assert_approx(reentering, reentry_demand)
def test_new_curve_interrupts_confirmed_release_immediately(self):
self.enter_curve()
for _ in range(_RELIEF_CONFIRMATION_FRAMES + 1):
self.update_lat_accels(0.8, 0.8)
releasing_v_target = self.scc_v.output_v_target
assert self.scc_v.state == VisionState.leaving
self.update_lat_accels(0.8, 3.0, a_ego=-0.7)
assert self.scc_v.state == VisionState.entering
assert self.scc_v.output_v_target < releasing_v_target
assert self.scc_v.output_a_target == -0.7
@parameterized.expand([(-2.0,), (-0.5,), (0.0,), (0.8,)])
def test_planner_acceleration_passes_through_exactly(self, planner_accel):
self.enter_curve()
self.update_lat_accels(0.5, 2.2, a_ego=planner_accel)
assert self.scc_v.output_a_target == planner_accel
def test_planner_acceleration_passes_through_all_states(self):
cases = (
(False, False, 0.5, 2.2, -0.2, VisionState.disabled),
(True, False, 0.5, 0.8, 0.1, VisionState.enabled),
(True, False, 0.5, 2.2, -0.4, VisionState.entering),
(True, False, 2.0, 2.2, -0.8, VisionState.turning),
(True, False, 1.2, 1.2, 0.3, VisionState.leaving),
(True, True, 1.2, 1.2, 0.6, VisionState.overriding),
)
for long_enabled, override, current, predicted, planner_accel, state in cases:
self.set_lat_accels(current, predicted)
self.scc_v.update(self.sm, long_enabled, override, 20.0, planner_accel, 30.0)
assert self.scc_v.state == state
assert self.scc_v.output_a_target == planner_accel
def test_jitter_requires_confirmed_relief_then_releases_smoothly(self):
self.enter_curve()
previous_v_target = self.scc_v.output_v_target
for frame in range(_RELIEF_CONFIRMATION_FRAMES * 2):
self.update_lat_accels(1.0, 1.05 if frame % 2 == 0 else 1.15)
assert self.scc_v.state == VisionState.entering
assert self.scc_v.output_v_target >= previous_v_target
assert self.scc_v.output_v_target - previous_v_target <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9
previous_v_target = self.scc_v.output_v_target
for _ in range(_RELIEF_CONFIRMATION_FRAMES):
self.update_lat_accels(1.15, 0.8)
assert self.scc_v.state == VisionState.entering
assert 0.0 <= self.scc_v.output_v_target - previous_v_target <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9
previous_v_target = self.scc_v.output_v_target
release_cruise = 30.0
for _ in range(_RELIEF_CONFIRMATION_FRAMES - 1):
self.update_lat_accels(0.8, 0.8, release_cruise)
assert self.scc_v.state == VisionState.entering
assert 0.0 <= self.scc_v.output_v_target - previous_v_target <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9
previous_v_target = self.scc_v.output_v_target
active_v_targets = [previous_v_target]
for _ in range(int((release_cruise - previous_v_target) / (_TARGET_RELEASE_RATE * DT_MDL)) + 10):
self.update_lat_accels(0.8, 0.8, release_cruise)
if not self.scc_v.is_active:
break
assert self.scc_v.state == VisionState.leaving
assert self.scc_v.output_v_target != V_CRUISE_UNSET
active_v_targets.append(self.scc_v.output_v_target)
assert self.scc_v.state == VisionState.enabled
assert self.scc_v.output_v_target == V_CRUISE_UNSET
self.assert_approx(active_v_targets[-1], release_cruise)
assert np.all((np.diff(active_v_targets) >= 0.0) & (np.diff(active_v_targets) <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9))
def test_target_release_waits_for_relief_above_ego_speed(self):
self.enter_curve()
held_v_target = self.scc_v.output_v_target
self.assert_approx(held_v_target, self.scc_v.v_ego)
for _ in range(_RELIEF_CONFIRMATION_FRAMES + _TARGET_RELEASE_CONFIRMATION_FRAMES - 2):
self.update_lat_accels(0.8, 0.8)
self.assert_approx(self.scc_v.output_v_target, held_v_target)
self.update_lat_accels(0.8, 0.8)
rise = self.scc_v.output_v_target - held_v_target
assert 0.0 < rise <= _TARGET_RELEASE_RATE * DT_MDL + 1e-9
def test_curve_target_is_independent_of_ego_speed(self):
model_speed = 24.0
predicted_yaw_rate = 0.12
predicted_lat_accel = model_speed * predicted_yaw_rate
expected_v_target = (_A_LAT_REG_MAX / (predicted_yaw_rate / model_speed)) ** 0.5
targets = []
for v_ego in (18.0, 28.0):
controller = SmartCruiseControlVision()
self.set_lat_accels(0.5, predicted_lat_accel, v_ego, model_speed)
controller.update(self.sm, True, False, v_ego, 0.0, 30.0)
controller.update(self.sm, True, False, v_ego, 0.0, 30.0)
assert controller.state == VisionState.entering
targets.append(controller.v_target)
self.assert_approx(targets[0], expected_v_target)
self.assert_approx(targets[1], expected_v_target)
def test_curve_target_respects_minimum_speed_floor(self):
model_speed = 10.0
predicted_yaw_rate = 2.0
self.set_lat_accels(0.5, model_speed * predicted_yaw_rate, model_speed=model_speed)
self.scc_v.update(self.sm, True, False, 20.0, 0.0, 30.0)
self.scc_v.update(self.sm, True, False, 20.0, 0.0, 30.0)
assert self.scc_v.state == VisionState.entering
assert self.scc_v.v_target < MIN_V
self.assert_approx(self.scc_v.output_v_target, MIN_V)
@parameterized.expand(
[([], []), ([np.nan] * len(ModelConstants.T_IDXS), [np.nan] * len(ModelConstants.T_IDXS)), ([20.0] * 5, [0.1] * 3)],
names=["velocities", "yaw_rates"],
)
def test_model_vector_edges_remain_finite(self, velocities, yaw_rates):
self.sm['modelV2'].velocity.x = velocities
self.sm['modelV2'].orientationRate.z = yaw_rates
self.scc_v.update(self.sm, True, False, 20.0, 0.0, 30.0)
self.scc_v.update(self.sm, True, False, 20.0, 0.0, 30.0)
assert all(
np.isfinite(value)
for value in (
self.scc_v.current_lat_acc,
self.scc_v.max_pred_lat_acc,
self.scc_v.v_target,
self.scc_v.output_v_target,
self.scc_v.output_a_target,
)
)
@parameterized.expand([(5.75,), (9.9,), (_MIN_ACTIVATION_SPEED,)])
def test_vision_control_does_not_steal_launch(self, launch_speed):
self.set_lat_accels(0.5, 3.0, launch_speed)
self.scc_v.update(self.sm, True, False, launch_speed, 0.0, 30.0)
self.scc_v.update(self.sm, True, False, launch_speed, 0.0, 30.0)
assert launch_speed <= _MIN_ACTIVATION_SPEED
assert self.scc_v.state == VisionState.enabled
assert not self.scc_v.is_active
assert self.scc_v.output_v_target == V_CRUISE_UNSET
def test_vision_control_can_activate_above_launch_range(self):
speed = _MIN_ACTIVATION_SPEED + 0.01
self.set_lat_accels(0.5, 3.0, speed)
self.scc_v.update(self.sm, True, False, speed, 0.0, 30.0)
self.scc_v.update(self.sm, True, False, speed, 0.0, 30.0)
assert self.scc_v.state == VisionState.entering
assert self.scc_v.is_active
def test_nonurgent_activation_has_no_target_cliff(self):
v_ego = _MIN_ACTIVATION_SPEED + 0.01
model_speed = 8.0
self.update_lat_accels(0.5, 2.0, v_ego=v_ego, model_speed=model_speed)
self.update_lat_accels(0.5, 2.0, v_ego=v_ego, model_speed=model_speed)
self.assert_approx(self.scc_v.v_target, 8.0)
self.assert_approx(self.scc_v.output_v_target, v_ego)
def test_nonurgent_tightening_is_confirmed_and_rate_limited(self):
self.enter_curve()
initial_v_target = self.scc_v.output_v_target
for _ in range(_TARGET_TIGHTEN_CONFIRMATION_FRAMES - 1):
self.update_lat_accels(0.5, 2.8)
self.assert_approx(self.scc_v.output_v_target, initial_v_target)
self.update_lat_accels(0.5, 2.8)
drop = initial_v_target - self.scc_v.output_v_target
assert 0.0 < drop <= _TARGET_TIGHTEN_RATE * DT_MDL + 1e-9
def test_one_frame_curve_prediction_does_not_pulse_target(self):
self.enter_curve()
for _ in range(10):
self.update_lat_accels(0.5, 2.2)
stable_v_target = self.scc_v.output_v_target
self.update_lat_accels(0.5, 2.8)
self.assert_approx(self.scc_v.output_v_target, stable_v_target)
self.update_lat_accels(0.5, 2.2)
self.assert_approx(self.scc_v.output_v_target, stable_v_target)
def test_one_frame_release_does_not_reverse_target(self):
self.enter_curve(_URGENT_PRED_LAT_ACC_TH)
stable_v_target = self.scc_v.output_v_target
self.update_lat_accels(0.5, 2.2)
self.assert_approx(self.scc_v.output_v_target, stable_v_target)
self.update_lat_accels(0.5, _URGENT_PRED_LAT_ACC_TH)
self.assert_approx(self.scc_v.output_v_target, stable_v_target)
def test_urgent_predicted_curve_is_not_delayed(self):
self.enter_curve()
self.update_lat_accels(0.5, _URGENT_PRED_LAT_ACC_TH)
self.assert_approx(self.scc_v.output_v_target, self.scc_v._v_demand())
def test_current_curve_is_not_delayed(self):
self.enter_curve()
self.update_lat_accels(_TURNING_LAT_ACC_TH, 2.8)
self.assert_approx(self.scc_v.output_v_target, self.scc_v._v_demand())
def test_sequential_curve_confirms_release_and_tightens_urgently(self):
self.enter_curve(3.0)
for _ in range(20):
self.update_lat_accels(0.5, 3.0)
restrictive_v_target = self.scc_v.output_v_target
self.update_lat_accels(0.5, 1.4, a_ego=0.4)
assert self.scc_v.state == VisionState.entering
self.assert_approx(self.scc_v.output_v_target, restrictive_v_target)
assert self.scc_v.output_a_target == 0.4
for _ in range(_TARGET_RELEASE_CONFIRMATION_FRAMES - 2):
self.update_lat_accels(0.5, 1.4)
self.assert_approx(self.scc_v.output_v_target, restrictive_v_target)
self.update_lat_accels(0.5, 1.4)
released_v_target = self.scc_v.output_v_target
assert 0.0 < released_v_target - restrictive_v_target <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9
self.update_lat_accels(0.5, 3.0, a_ego=-0.6)
assert self.scc_v.state == VisionState.entering
self.assert_approx(self.scc_v.output_v_target, restrictive_v_target)
assert self.scc_v.output_a_target == -0.6
for _ in range(4):
self.update_lat_accels(0.5, 1.4)
self.assert_approx(self.scc_v.output_v_target, restrictive_v_target)
self.update_lat_accels(0.5, 3.0)
self.assert_approx(self.scc_v.output_v_target, restrictive_v_target)
def test_acceleration_is_continuous_through_planner_arbitration(self):
car_control = messaging.new_message('carControl')
car_control.carControl.enabled = True
car_control.carControl.cruiseControl.override = False
self.sm['carControl'] = car_control.carControl
self.sm['carState'].vCruiseCluster = 108.0
planner: Any = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
planner.scc = SimpleNamespace(
vision=self.scc_v,
map=SimpleNamespace(output_v_target=V_CRUISE_UNSET, output_a_target=0.0),
update=lambda sm, enabled, override, v_ego, a_ego, v_cruise: self.scc_v.update(sm, enabled, override, v_ego, a_ego, v_cruise),
)
planner.resolver = SimpleNamespace(
speed_limit_valid=False,
speed_limit_last_valid=False,
speed_limit=0.0,
speed_limit_final_last=0.0,
distance=0.0,
update=lambda _v_ego, _sm: None,
)
planner.sla = SimpleNamespace(
output_v_target=V_CRUISE_UNSET,
output_a_target=0.0,
update=lambda *_args: None,
)
planner.events_sp = SimpleNamespace()
self.set_lat_accels(0.5, 2.2)
planner.update_targets(self.sm, 20.0, -0.8, 30.0)
planner.update_targets(self.sm, 20.0, -0.8, 30.0)
assert planner.source == LongitudinalPlanSource.sccVision
assert planner.output_a_target == -0.8
for planner_accel in (-2.0, 0.5, -0.2):
planner.update_targets(self.sm, 20.0, planner_accel, 30.0)
assert planner.source == LongitudinalPlanSource.sccVision
assert planner.output_a_target == planner_accel
self.set_lat_accels(0.8, 0.8)
for _ in range(int(30.0 / (_TARGET_RELEASE_RATE * DT_MDL)) + 10):
planner.update_targets(self.sm, 20.0, 0.4, 30.0)
assert planner.output_a_target == 0.4
if planner.source == LongitudinalPlanSource.cruise:
break
else:
self.fail("SCC Vision did not release to cruise")
planner.update_targets(self.sm, 20.0, 0.4, 30.0)
assert self.scc_v.state == VisionState.enabled
assert planner.source == LongitudinalPlanSource.cruise
@parameterized.expand(
[
("p97_just_above_threshold", True),
("single_spike_filtered", False),
("persistent_high_values", True),
], names=["case", "should_enter"])
],
names=["case", "should_enter"],
)
def test_max_pred_lat_acc_uses_p97_and_threshold(self, case, should_enter):
n = len(ModelConstants.T_IDXS)
th = float(_ENTERING_PRED_LAT_ACC_TH)
@@ -0,0 +1,110 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import gc
from contextlib import ExitStack
from unittest import mock
import numpy as np
from openpilot.common.test import OpenpilotTestCase
from openpilot.selfdrive.test.longitudinal_maneuvers.plant import Plant
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanSource
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.vision_controller import _A_LAT_REG_MAX
def _run_constant_curve(*, scc_enabled: bool, cruise: float, duration: float = 70.0) -> dict[str, np.ndarray]:
gc.collect()
curvature = 0.005
plant = Plant(lead_relevancy=False, speed=30.0)
planner = plant.planner
planner.dec._enabled = False
planner.scc.map.enabled = False
planner.scc.vision.enabled = scc_enabled
solver_failures = 0
with ExitStack() as patches:
patches.enter_context(mock.patch.object(planner.dec, "_read_params", return_value=None))
patches.enter_context(mock.patch.object(planner.scc.map, "update_params", return_value=None))
patches.enter_context(mock.patch.object(planner.scc.vision, "_update_params", return_value=None))
original_mpc_reset = planner.mpc.reset
def record_mpc_reset(*args, **kwargs):
nonlocal solver_failures
solver_failures += int(planner.mpc.solution_status != 0)
return original_mpc_reset(*args, **kwargs)
patches.enter_context(mock.patch.object(planner.mpc, "reset", side_effect=record_mpc_reset))
if scc_enabled:
original_update_calculations = planner.scc.vision._update_calculations
def inject_constant_curvature(sm):
velocities = np.asarray(sm['modelV2'].velocity.x, dtype=float)
sm['modelV2'].orientationRate.z = (curvature * velocities).tolist()
sm['controlsState'].curvature = curvature
original_update_calculations(sm)
patches.enter_context(mock.patch.object(planner.scc.vision, "_update_calculations", side_effect=inject_constant_curvature))
original_update = planner.update
def enable_longitudinal(sm):
sm['carControl'].enabled = True
sm['carControl'].longActive = True
original_update(sm)
patches.enter_context(mock.patch.object(planner, "update", side_effect=enable_longitudinal))
rows = []
while plant.current_time < duration:
output = plant.step(v_cruise=cruise)
rows.append(
(
plant.current_time,
output['speed'],
output['should_stop'],
planner.scc.vision.is_active,
planner.source == LongitudinalPlanSource.sccVision,
planner.scc.vision.output_v_target,
)
)
data = np.asarray(rows, dtype=float)
gc.collect()
return {
'time': data[:, 0],
'speed': data[:, 1],
'should_stop': data[:, 2],
'active': data[:, 3],
'scc_source': data[:, 4],
'target': data[:, 5],
'solver_failures': np.asarray(solver_failures),
}
class TestVisionControllerClosedLoop(OpenpilotTestCase):
def test_constant_curve_recovers_like_stock_speed_cap(self):
target = (_A_LAT_REG_MAX / 0.005) ** 0.5
scc = _run_constant_curve(scc_enabled=True, cruise=30.0)
stock = _run_constant_curve(scc_enabled=False, cruise=target)
scc_final = scc['speed'][scc['time'] >= 60.0]
stock_final = stock['speed'][stock['time'] >= 60.0]
# The generated solver can report platform-specific failures for the
# synthetic no-lead plant. The feature must not make that stock baseline
# worse; requiring an absolute zero would hide a harness difference as a
# controller regression.
assert scc['solver_failures'] <= stock['solver_failures']
assert not scc['should_stop'].any()
assert np.all(scc['active'][scc['time'] >= 60.0])
assert np.all(scc['scc_source'][scc['time'] >= 60.0])
assert np.allclose(scc['target'][scc['time'] >= 60.0], target)
assert scc_final.min() >= target - 1.0
assert abs(scc_final.mean() - stock_final.mean()) < 0.5
assert abs(scc_final.min() - stock_final.min()) < 1.0
assert abs(scc_final.max() - stock_final.max()) < 1.0
@@ -23,25 +23,21 @@ _ENTERING_PRED_LAT_ACC_TH = 1.3 # Predicted Lat Acc threshold to trigger enteri
_ABORT_ENTERING_PRED_LAT_ACC_TH = 1.1 # Predicted Lat Acc threshold to abort entering state if speed drops.
_TURNING_LAT_ACC_TH = 1.6 # Lat Acc threshold to trigger turning state.
_URGENT_PRED_LAT_ACC_TH = 3. # Predicted Lat Acc threshold that requires an immediate speed reduction.
_LEAVING_LAT_ACC_TH = 1.3 # Lat Acc threshold to trigger leaving turn state.
_FINISH_LAT_ACC_TH = 1.1 # Lat Acc threshold to trigger the end of the turn cycle.
_A_LAT_REG_MAX = 2. # Maximum lateral acceleration
_NO_OVERSHOOT_TIME_HORIZON = 4. # s. Time to use for velocity desired based on a_target when not overshooting.
# Lookup table for the minimum smooth deceleration during the ENTERING state
# depending on the actual maximum absolute lateral acceleration predicted on the turn ahead.
_ENTERING_SMOOTH_DECEL_V = [-0.2, -1.] # min decel value allowed on ENTERING state
_ENTERING_SMOOTH_DECEL_BP = [1.3, 3.] # absolute value of lat acc ahead
# Lookup table for the acceleration for the TURNING state
# depending on the current lateral acceleration of the vehicle.
_TURNING_ACC_V = [0.5, 0., -0.4] # acc value
_TURNING_ACC_BP = [1.5, 2.3, 3.] # absolute value of current lat acc
_LEAVING_ACC = 0.5 # Conformable acceleration to regain speed while leaving a turn.
_RELIEF_CONFIRMATION_FRAMES = max(1, int(round(0.5 / DT_MDL)))
_TARGET_TIGHTEN_CONFIRMATION_FRAMES = max(1, int(round(0.1 / DT_MDL)))
_TARGET_RELEASE_CONFIRMATION_FRAMES = max(1, int(round(0.15 / DT_MDL)))
_TARGET_TIGHTEN_RATE = 5. # m/s^2
_TARGET_RELEASE_RATE = 1. # m/s^2
_BELOW_EGO_TARGET_RELEASE_RATE = 3. # m/s^2
_MIN_PRED_SPEED = 1. # m/s
_MIN_ACTIVATION_SPEED = 10. # m/s
class SmartCruiseControlVision:
@@ -65,14 +61,62 @@ class SmartCruiseControlVision:
self.state = VisionState.disabled
self.current_lat_acc = 0.
self.max_pred_lat_acc = 0.
self.relief_frames = 0
self.tighten_frames = 0
self.release_frames = 0
def _v_demand(self) -> float:
return max(MIN_V, min(self.v_target, self.v_cruise_setpoint))
def _curve_is_urgent(self) -> bool:
return self.current_lat_acc >= _TURNING_LAT_ACC_TH or self.max_pred_lat_acc >= _URGENT_PRED_LAT_ACC_TH
def _filtered_v_target(self) -> float:
demand = self._v_demand()
if self.output_v_target == V_CRUISE_UNSET:
self.tighten_frames = 0
self.release_frames = 0
if self._curve_is_urgent():
return demand
return max(demand, min(self.v_ego, self.v_cruise_setpoint))
if demand < self.output_v_target:
self.release_frames = 0
if self._curve_is_urgent():
self.tighten_frames = 0
return demand
self.tighten_frames += 1
if self.tighten_frames < _TARGET_TIGHTEN_CONFIRMATION_FRAMES:
return self.output_v_target
return max(demand, self.output_v_target - _TARGET_TIGHTEN_RATE * DT_MDL)
self.tighten_frames = 0
releasing_brake = self.output_v_target < min(self.v_ego, demand)
if not releasing_brake and self.relief_frames < _RELIEF_CONFIRMATION_FRAMES:
self.release_frames = 0
return self.output_v_target
if demand > self.output_v_target:
self.release_frames += 1
if self.release_frames < _TARGET_RELEASE_CONFIRMATION_FRAMES:
return self.output_v_target
else:
self.release_frames = 0
release_rate = _BELOW_EGO_TARGET_RELEASE_RATE if releasing_brake else _TARGET_RELEASE_RATE
return min(demand, self.output_v_target + release_rate * DT_MDL)
def get_a_target_from_control(self) -> float:
return self.a_target
return self.a_ego
def get_v_target_from_control(self) -> float:
if self.is_active:
return max(self.v_target, MIN_V) + self.a_target * _NO_OVERSHOOT_TIME_HORIZON
return self._filtered_v_target()
self.tighten_frames = 0
self.release_frames = 0
return V_CRUISE_UNSET
def _update_params(self) -> None:
@@ -82,25 +126,27 @@ class SmartCruiseControlVision:
def _update_calculations(self, sm: messaging.SubMaster) -> None:
if not self.long_enabled:
return
else:
rate_plan = np.array(np.abs(sm['modelV2'].orientationRate.z))
vel_plan = np.array(sm['modelV2'].velocity.x)
self.current_lat_acc = self.v_ego ** 2 * abs(sm['controlsState'].curvature)
rate_plan = np.asarray(np.abs(sm['modelV2'].orientationRate.z), dtype=float)
vel_plan = np.asarray(sm['modelV2'].velocity.x, dtype=float)
size = min(len(rate_plan), len(vel_plan))
rate_plan, vel_plan = rate_plan[:size], vel_plan[:size]
valid = np.isfinite(rate_plan) & np.isfinite(vel_plan) & (vel_plan >= _MIN_PRED_SPEED)
# get the maximum lat accel from the model
predicted_lat_accels = rate_plan * vel_plan
self.max_pred_lat_acc = np.percentile(predicted_lat_accels, 97)
# get the maximum curve based on the current velocity
v_ego = max(self.v_ego, 0.1) # ensure a value greater than 0 for calculations
max_curve = self.max_pred_lat_acc / (v_ego**2)
# Get the target velocity for the maximum curve
self.v_target = (_A_LAT_REG_MAX / max_curve) ** 0.5
self.current_lat_acc = self.v_ego ** 2 * abs(sm['controlsState'].curvature)
self.max_pred_lat_acc = 0.
self.v_target = V_CRUISE_UNSET
if np.any(valid):
self.max_pred_lat_acc = float(np.percentile(rate_plan[valid] * vel_plan[valid], 97))
max_pred_curvature = float(np.percentile(rate_plan[valid] / vel_plan[valid], 97))
if max_pred_curvature > 0.:
self.v_target = min(float((_A_LAT_REG_MAX / max_pred_curvature) ** 0.5), V_CRUISE_UNSET)
def _update_state_machine(self) -> tuple[bool, bool]:
# ENABLED, ENTERING, TURNING, LEAVING, OVERRIDING
relief = self.current_lat_acc < _FINISH_LAT_ACC_TH and self.max_pred_lat_acc < _ABORT_ENTERING_PRED_LAT_ACC_TH
self.relief_frames = self.relief_frames + 1 if self.state in ACTIVE_STATES and relief else 0
if self.state != VisionState.disabled:
# longitudinal and feature disable always have priority in a non-disabled state
if not self.long_enabled or not self.enabled:
@@ -112,7 +158,7 @@ class SmartCruiseControlVision:
# ENABLED
if self.state == VisionState.enabled:
# Do not enter a turn control cycle if the speed is low.
if self.v_ego <= MIN_V:
if self.v_ego <= _MIN_ACTIVATION_SPEED:
pass
# If significant lateral acceleration is predicted ahead, then move to Entering turn state.
elif self.max_pred_lat_acc >= _ENTERING_PRED_LAT_ACC_TH:
@@ -128,23 +174,26 @@ class SmartCruiseControlVision:
# Transition to Turning if current lateral acceleration is over the threshold.
if self.current_lat_acc >= _TURNING_LAT_ACC_TH:
self.state = VisionState.turning
# Abort if the predicted lateral acceleration drops
elif self.max_pred_lat_acc < _ABORT_ENTERING_PRED_LAT_ACC_TH:
self.state = VisionState.enabled
# Begin releasing only after both current and predicted lateral acceleration stay clear.
elif self.relief_frames >= _RELIEF_CONFIRMATION_FRAMES:
self.state = VisionState.leaving
# TURNING
elif self.state == VisionState.turning:
# Transition to Leaving if current lateral acceleration drops below a threshold.
# Transition out of Turning if current lateral acceleration drops below a threshold.
if self.current_lat_acc <= _LEAVING_LAT_ACC_TH:
self.state = VisionState.leaving
self.state = VisionState.entering if self.max_pred_lat_acc >= _ENTERING_PRED_LAT_ACC_TH else VisionState.leaving
# LEAVING
elif self.state == VisionState.leaving:
# Transition back to Turning if current lateral acceleration goes back over the threshold.
if self.current_lat_acc >= _TURNING_LAT_ACC_TH:
self.state = VisionState.turning
# Finish if current lateral acceleration goes below a threshold.
elif self.current_lat_acc < _FINISH_LAT_ACC_TH:
# Start a new turn cycle immediately if another curve is predicted.
elif self.max_pred_lat_acc >= _ENTERING_PRED_LAT_ACC_TH:
self.state = VisionState.entering
# Finish after confirmed relief and a gradual release to the cruise setpoint.
elif self.relief_frames >= _RELIEF_CONFIRMATION_FRAMES and self.output_v_target >= self.v_cruise_setpoint:
self.state = VisionState.enabled
# DISABLED
@@ -157,32 +206,11 @@ class SmartCruiseControlVision:
enabled = self.state in ENABLED_STATES
active = self.state in ACTIVE_STATES
if not active:
self.relief_frames = 0
return enabled, active
def _update_solution(self) -> float:
# DISABLED, ENABLED, OVERRIDING
if self.state not in ACTIVE_STATES:
# when not overshooting, calculate v_turn as the speed at the prediction horizon when following
# the smooth deceleration.
a_target = self.a_ego
# ENTERING
elif self.state == VisionState.entering:
# when not overshooting, target a smooth deceleration in preparation for a sharp turn to come.
a_target = np.interp(self.max_pred_lat_acc, _ENTERING_SMOOTH_DECEL_BP, _ENTERING_SMOOTH_DECEL_V)
# TURNING
elif self.state == VisionState.turning:
# When turning, we provide a target acceleration that is comfortable for the lateral acceleration felt.
a_target = np.interp(self.current_lat_acc, _TURNING_ACC_BP, _TURNING_ACC_V)
# LEAVING
elif self.state == VisionState.leaving:
# When leaving, we provide a comfortable acceleration to regain speed.
a_target = _LEAVING_ACC
else:
raise NotImplementedError(f"SCC-V state not supported: {self.state}")
return a_target
def update(self, sm: messaging.SubMaster, long_enabled: bool, long_override: bool, v_ego: float, a_ego: float,
v_cruise_setpoint: float) -> None:
self.long_enabled = long_enabled
@@ -195,7 +223,7 @@ class SmartCruiseControlVision:
self._update_calculations(sm)
self.is_enabled, self.is_active = self._update_state_machine()
self.a_target = self._update_solution()
self.a_target = self.a_ego
self.output_v_target = self.get_v_target_from_control()
self.output_a_target = self.get_a_target_from_control()
@@ -0,0 +1,396 @@
from contextlib import contextmanager
import inspect
from typing import Any
from unittest import mock
import numpy as np
from opendbc.car.interfaces import ACCEL_MAX, ACCEL_MIN
from openpilot.common.test import OpenpilotTestCase
from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import N, LongitudinalMpc, LongitudinalPlanSource
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.constants import ACCEL_V, LAUNCH_ACCEL, NEUTRAL_ACCEL, AccelProfile
from openpilot.sunnypilot.selfdrive.test.longitudinal_maneuvers.plant import PRIUS_TSS2_ROUTE_MODEL, PlantSP as Plant
def configure(plant, *, enabled=True, profile=AccelProfile.normal):
planner: Any = plant.planner
planner.accel_controller_enabled = enabled
planner.accel_controller_profile = profile
planner.read_accel_controller_params = lambda: None
dec: Any = plant.planner.dec
dec._enabled = False
dec._read_params = lambda: None
def record_candidates(plant):
snapshots = []
planner: Any = plant.planner
original = planner.update_accel_controller
def wrapper(sm, candidates):
before = tuple(candidates)
after = original(sm, candidates)
snapshots.append((before, tuple(after)))
return after
planner.update_accel_controller = wrapper
return snapshots
@contextmanager
def scripted_stock_candidates(plant, *, mpc_accel, cruise_accel, source=LongitudinalPlanSource.cruise):
values = {"mpc": float(mpc_accel), "cruise": float(cruise_accel)}
def update_mpc(_radar_state, personality):
plant.planner.mpc.source = source
plant.planner.mpc.crash_cnt = 0
with (
mock.patch.object(plant.planner.mpc, "update", side_effect=update_mpc),
mock.patch(
"openpilot.selfdrive.controls.lib.longitudinal_planner.get_accel_from_plan",
side_effect=lambda *_args, **_kwargs: values["mpc"],
),
mock.patch(
"openpilot.selfdrive.controls.lib.longitudinal_planner.get_cruise_accel",
side_effect=lambda *_args, **_kwargs: values["cruise"],
),
):
yield values
class TestAccelControllerPlannerIntegration(OpenpilotTestCase):
def test_one_stock_mpc_solve_with_unmodified_bounds_and_api(self):
plant = Plant(enabled=True, lead_relevancy=True, speed=20.0, distance_lead=70.0)
configure(plant)
mpc: Any = plant.planner.mpc
original_run = mpc.run
run_calls = []
params_at_solve = []
def count_run():
run_calls.append(None)
params_at_solve.append(mpc.params.copy())
return original_run()
mpc.run = count_run
result = plant.step(v_lead=14.0, v_cruise=25.0)
self.assertEqual(len(run_calls), 1)
self.assertTrue(np.isfinite(result["a_target"]))
self.assertEqual(LongitudinalMpc.__bases__, (object,))
self.assertEqual(tuple(inspect.signature(LongitudinalMpc.update).parameters), ("self", "radarstate", "personality"))
self.assertFalse(hasattr(mpc, "set_jerk_cost_multiplier"))
self.assertFalse(hasattr(mpc, "cruise_accel_max"))
self.assertEqual(params_at_solve[0].shape, (N + 1, 6))
np.testing.assert_array_equal(params_at_solve[0][:, 0], ACCEL_MIN)
np.testing.assert_array_equal(params_at_solve[0][:, 1], ACCEL_MAX)
def test_stock_lead_mpc_braking_remains_authoritative(self):
plant = Plant(enabled=True, lead_relevancy=True, speed=20.0, distance_lead=30.0)
configure(plant)
snapshots = record_candidates(plant)
stock_mpc_won = False
for _ in range(20):
result = plant.step(v_lead=5.0, v_cruise=30.0)
stock, augmented = snapshots[-1]
mpc_candidate = stock[0]
selected = min(augmented, key=lambda candidate: candidate[0])
self.assertAlmostEqual(result["a_target"], selected[0])
if mpc_candidate[0] < 0.0 and selected == mpc_candidate:
stock_mpc_won = True
assert mpc_candidate[1] in (LongitudinalPlanSource.lead0, LongitudinalPlanSource.lead1)
break
self.assertTrue(stock_mpc_won, "the hook must never mask stock lead braking")
def test_stock_should_stop_survives_controller_candidates(self):
plant = Plant(enabled=True, lead_relevancy=True, speed=0.2, distance_lead=3.0)
configure(plant)
snapshots = record_candidates(plant)
result = plant.step(v_lead=0.0, v_cruise=8.0)
stock, augmented = snapshots[-1]
self.assertTrue(any(candidate[2] for candidate in stock))
self.assertTrue(any(candidate[2] for candidate in augmented))
self.assertTrue(result["should_stop"])
self.assertAlmostEqual(result["a_target"], min(augmented, key=lambda candidate: candidate[0])[0])
def test_dec_blended_entry_limits_the_first_model_brake_step(self):
class DecStub:
mode_name = "acc"
def update(self, _sm):
pass
def active(self):
return True
def mode(self):
return self.mode_name
plant = Plant(
enabled=True, e2e=True, speed=22.0,
model_action_fn=lambda _current_time, _v_ego, _a_ego: (-2.0, False),
actuator_delay=0.15, actuator_lag=0.20,
)
configure(plant)
dec = DecStub()
planner: Any = plant.planner
planner.dec = dec
outputs = []
for frame in range(20):
if frame == 10:
dec.mode_name = "blended"
result = plant.step(v_lead=0.0, v_cruise=22.0)
outputs.append(result["a_target"])
self.assertGreaterEqual(outputs[10] - outputs[9], -0.15 - 1e-9)
self.assertFalse(result["fcw"])
def test_model_endpoint_urgency_does_not_bypass_entry_ramp(self):
plant = Plant(enabled=True, e2e=True, speed=21.0)
planner: Any = plant.planner
planner.dec._active = True
planner.dec._has_slow_down = True
planner.dec._urgency = 1.0
planner.previous_plan_accel = -0.136
first = planner.select_model_accel(0.0, -1.140, blended=True, should_stop=False, fcw=False, reset=False)
second = planner.select_model_accel(0.0, -1.140, blended=True, should_stop=False, fcw=False, reset=False)
self.assertAlmostEqual(first, -0.286)
self.assertAlmostEqual(second, -0.436)
def test_dec_exit_limits_positive_acceleration_step(self):
plant = Plant(enabled=True, e2e=True, speed=6.0)
planner: Any = plant.planner
planner.dec._active = True
planner.previous_plan_accel = 0.76
self.assertAlmostEqual(planner.select_model_accel(1.94, 0.76, blended=True, should_stop=False, fcw=False, reset=False), 0.76)
first = planner.select_model_accel(1.94, 0.76, blended=False, should_stop=False, fcw=False, reset=False)
second = planner.select_model_accel(1.94, 0.76, blended=False, should_stop=False, fcw=False, reset=False)
self.assertAlmostEqual(first, 0.91)
self.assertAlmostEqual(second, 1.06)
def test_dec_exit_transition_reaches_the_final_candidate_list(self):
class DecStub:
mode_name = "blended"
def update(self, _sm):
pass
def active(self):
return True
def mode(self):
return self.mode_name
plant = Plant(
enabled=True, e2e=True, speed=22.0,
model_action_fn=lambda _current_time, _v_ego, _a_ego: (0.20, False),
)
configure(plant, enabled=False)
dec = DecStub()
planner: Any = plant.planner
planner.dec = dec
def update_mpc(_radar_state, personality):
plant.planner.mpc.source = LongitudinalPlanSource.cruise
plant.planner.mpc.crash_cnt = 0
with (
mock.patch.object(plant.planner.mpc, "update", side_effect=update_mpc),
mock.patch("openpilot.selfdrive.controls.lib.longitudinal_planner.get_accel_from_plan", return_value=1.94),
mock.patch("openpilot.selfdrive.controls.lib.longitudinal_planner.get_cruise_accel", return_value=1.94),
):
outputs = [plant.step(v_lead=0.0, v_cruise=30.0)["a_target"] for _ in range(4)]
self.assertAlmostEqual(outputs[-1], 0.20)
dec.mode_name = "acc"
first = plant.step(v_lead=0.0, v_cruise=30.0)["a_target"]
second = plant.step(v_lead=0.0, v_cruise=30.0)["a_target"]
self.assertLessEqual(first - outputs[-1], 0.15 + 1e-9)
self.assertLessEqual(second - first, 0.15 + 1e-9)
self.assertEqual(plant.planner.mpc.source, LongitudinalPlanSource.cruise)
def test_disengaged_cruise_state_cannot_leak_into_first_accel(self):
plant = Plant(enabled=False, speed=10.0)
configure(plant)
with (
mock.patch.object(plant.planner.mpc, "update", return_value=None),
mock.patch("openpilot.selfdrive.controls.lib.longitudinal_planner.get_accel_from_plan", return_value=1.30),
):
for _ in range(12):
self.assertAlmostEqual(plant.step(v_lead=0.0, v_cruise=30.0)["a_target"], 0.0)
self.assertAlmostEqual(plant.planner.a_cruise, 0.0)
plant.enabled = True
first = plant.step(v_lead=0.0, v_cruise=30.0)["a_target"]
self.assertGreater(first, 0.0)
self.assertLessEqual(first, 0.10)
class TestAccelControllerClosedLoopAcceptance(OpenpilotTestCase):
def test_every_profile_launches_promptly_after_confirmed_departure(self):
for profile in ACCEL_V:
plant = Plant(enabled=True, lead_relevancy=True, speed=0.0, distance_lead=6.0, actuator_model=PRIUS_TSS2_ROUTE_MODEL, run_long_control=True)
configure(plant, profile=profile)
with scripted_stock_candidates(plant, mpc_accel=-0.5, cruise_accel=1.6, source=LongitudinalPlanSource.lead0) as stock:
for _ in range(12):
held = plant.step(v_lead=0.0, v_cruise=8.0)
self.assertTrue(held["should_stop"])
self.assertLess(held["actuator_command"], 0.0)
stock["mpc"] = 1.6
unconfirmed = plant.step(v_lead=1.0, v_cruise=8.0)
confirmed = plant.step(v_lead=1.0, v_cruise=8.0)
self.assertTrue(unconfirmed["should_stop"])
self.assertFalse(confirmed["should_stop"])
self.assertEqual(confirmed["a_target"], LAUNCH_ACCEL)
self.assertGreater(confirmed["actuator_command"], 0.0)
self.assertEqual(confirmed["long_control_state"], LongCtrlState.pid)
moving = confirmed
for _ in range(12):
moving = plant.step(v_lead=1.0, v_cruise=8.0)
if moving["speed"] >= 0.01:
break
self.assertGreaterEqual(moving["speed"], 0.01, profile)
def test_slower_lead_causes_routine_decel_while_ttc_is_still_long(self):
initial_gap = 55.0
initial_ego_speed = 20.0
lead_speed = 14.0
plant = Plant(enabled=True, lead_relevancy=True, speed=initial_ego_speed, distance_lead=initial_gap)
configure(plant)
with scripted_stock_candidates(plant, mpc_accel=0.7, cruise_accel=0.7):
result = plant.step(v_lead=lead_speed, v_cruise=30.0)
self.assertGreater(initial_gap / (initial_ego_speed - lead_speed), 8.0)
self.assertTrue(result["controller_active"])
self.assertLess(result["a_target"], 0.0)
def test_monotonically_slowing_lead_does_not_cause_brake_gas_brake(self):
plant = Plant(
enabled=True,
lead_relevancy=True,
speed=18.0,
distance_lead=65.0,
actuator_delay=0.15,
actuator_lag=0.20,
)
configure(plant)
targets = []
with scripted_stock_candidates(plant, mpc_accel=0.8, cruise_accel=0.8):
for frame in range(80):
lead_speed = max(8.0, 20.0 - 0.15 * frame)
targets.append(plant.step(v_lead=lead_speed, v_cruise=30.0)["a_target"])
phases = []
for target in targets:
phase = 1 if target > NEUTRAL_ACCEL else -1 if target < -NEUTRAL_ACCEL else 0
if phase and (not phases or phase != phases[-1]):
phases.append(phase)
self.assertIn(-1, phases)
first_brake = phases.index(-1)
self.assertNotIn(1, phases[first_brake + 1:])
def test_terminal_stop_is_bounded_and_has_no_positive_rebound(self):
plant = Plant(
enabled=True,
lead_relevancy=True,
speed=5.0,
distance_lead=24.0,
actuator_model=PRIUS_TSS2_ROUTE_MODEL,
run_long_control=True,
)
configure(plant)
samples = []
with scripted_stock_candidates(plant, mpc_accel=0.8, cruise_accel=0.8):
for _ in range(400):
result = plant.step(v_lead=0.0, v_cruise=8.0)
samples.append(result)
if result["speed"] == 0.0:
break
self.assertEqual(samples[-1]["speed"], 0.0)
self.assertTrue(samples[-1]["should_stop"])
self.assertGreaterEqual(plant.distance_lead - plant.distance, 5.8)
self.assertLessEqual(plant.distance_lead - plant.distance, 7.1)
moving_samples = samples[:-1]
terminal_samples = [sample for sample in moving_samples if sample["speed"] <= 0.3]
self.assertTrue(terminal_samples)
self.assertLessEqual(max(sample["a_target"] for sample in terminal_samples), 1e-9)
self.assertLessEqual(max(sample["realized_acceleration"] for sample in terminal_samples), 0.02)
self.assertGreaterEqual(min(sample["realized_acceleration"] for sample in moving_samples), -1.2)
realized_jerk = [
abs(current["realized_acceleration"] - previous["realized_acceleration"]) / plant.ts
for previous, current in zip(moving_samples[:-1], moving_samples[1:], strict=True)
]
self.assertLessEqual(max(realized_jerk), 1.2)
def test_high_speed_stop_uses_planned_decel_before_stock_emergency(self):
plant = Plant(enabled=True, lead_relevancy=True, speed=20.0, distance_lead=120.0, actuator_model=PRIUS_TSS2_ROUTE_MODEL, run_long_control=True)
configure(plant)
samples = []
with scripted_stock_candidates(plant, mpc_accel=0.8, cruise_accel=0.8):
for _ in range(800):
result = plant.step(v_lead=0.0, v_cruise=25.0)
samples.append(result)
if result["speed"] == 0.0 and result["should_stop"]:
break
self.assertEqual(samples[-1]["speed"], 0.0)
self.assertTrue(samples[-1]["should_stop"])
self.assertGreaterEqual(plant.distance_lead - plant.distance, 5.8)
self.assertGreaterEqual(min(sample["a_target"] for sample in samples), -2.5)
def test_terminal_decel_ceiling_does_not_fade_before_highway_stop(self):
plant = Plant(enabled=True, lead_relevancy=True, speed=25.0, distance_lead=150.0, actuator_model=PRIUS_TSS2_ROUTE_MODEL, run_long_control=True)
configure(plant)
samples = []
with scripted_stock_candidates(plant, mpc_accel=0.8, cruise_accel=0.8):
for _ in range(1000):
result = plant.step(v_lead=0.0, v_cruise=30.0)
samples.append(result)
if result["speed"] == 0.0 and result["should_stop"]:
break
self.assertEqual(samples[-1]["speed"], 0.0)
self.assertTrue(samples[-1]["should_stop"])
self.assertGreaterEqual(plant.distance_lead - plant.distance, 5.8)
self.assertGreaterEqual(min(sample["a_target"] for sample in samples), -2.5)
def test_feasible_highway_stop_does_not_fall_back_to_late_stock_braking(self):
plant = Plant(enabled=True, lead_relevancy=True, speed=25.0, distance_lead=150.0, actuator_model=PRIUS_TSS2_ROUTE_MODEL, run_long_control=True)
configure(plant)
samples = []
for _ in range(500):
result = plant.step(v_lead=0.0, v_cruise=30.0)
samples.append(result)
if result["speed"] == 0.0 and result["should_stop"]:
break
self.assertEqual(samples[-1]["speed"], 0.0)
self.assertTrue(samples[-1]["should_stop"])
self.assertGreaterEqual(plant.distance_lead - plant.distance, 5.8)
self.assertGreaterEqual(min(sample["a_target"] for sample in samples), -2.55)
@@ -0,0 +1,642 @@
import numpy as np
from unittest import mock
from opendbc.car import DT_CTRL, gen_empty_fingerprint, structs
from openpilot.common.parameterized import parameterized
from openpilot.common.test import OpenpilotTestCase
from opendbc.car.body.values import CAR as BODY
from opendbc.car.car_helpers import interfaces
from opendbc.car.ford.values import CAR as FORD
from opendbc.car.gm.values import CAR as GM
from opendbc.car.honda.values import CAR as HONDA
from opendbc.car.hyundai.values import CAR as HYUNDAI
from opendbc.car.rivian.values import CAR as RIVIAN
from opendbc.car.subaru.values import CAR as SUBARU
from opendbc.car.tesla.values import CAR as TESLA
from opendbc.car.toyota.values import CAR as TOYOTA
from opendbc.car.volkswagen.values import CAR as VOLKSWAGEN
from openpilot.selfdrive.controls.lib.drive_helpers import should_stop
from openpilot.selfdrive.controls.lib.longcontrol import LongControl, LongCtrlState
from openpilot.sunnypilot.selfdrive.controls.lib.longcontrol import (
STOPPING_HOLD_ACCEL, STOPPING_HOLD_MARGIN, STOPPING_SETTLE_FRAMES, STOPPING_SPEED_TOLERANCE,
)
from openpilot.sunnypilot.selfdrive.test.longitudinal_maneuvers.plant import PRIUS_TSS2_ROUTE_MODEL, PlantSP
PRESERVED_HOLD_VEHICLES = (
FORD.FORD_ESCAPE_MK4,
GM.CHEVROLET_VOLT,
GM.CHEVROLET_BOLT_EUV,
HONDA.HONDA_CIVIC_2022,
HYUNDAI.HYUNDAI_SONATA,
SUBARU.SUBARU_ASCENT,
TESLA.TESLA_MODEL_3,
TOYOTA.TOYOTA_RAV4_TSS2,
VOLKSWAGEN.VOLKSWAGEN_ARTEON_MK1,
)
STOP_ACCEL_VEHICLES = (*PRESERVED_HOLD_VEHICLES, RIVIAN.RIVIAN_R1)
SETTLE_VEHICLES = (TOYOTA.TOYOTA_RAV4_TSS2, HONDA.HONDA_CIVIC_2022, VOLKSWAGEN.VOLKSWAGEN_ARTEON_MK1)
UNSUPPORTED_HOLD_VEHICLES = (
(BODY.COMMA_BODY, True),
(SUBARU.SUBARU_OUTBACK, True),
(HYUNDAI.HYUNDAI_SONATA, False),
(RIVIAN.RIVIAN_R1, True),
)
ROUTE_STOP_ONSETS = (
(0.280, -0.290, -0.220, -0.220),
(0.290, -0.497, -0.270, -0.302),
(0.464, -0.223, -0.264, -0.292),
(0.467, -0.582, -0.316, -0.359),
(0.530, -0.311, -0.309, -0.333),
(0.581, -0.467, -0.312, -0.352),
(0.398, -0.557, -0.311, -0.348),
(0.517, -0.290, -0.301, -0.327),
(0.312, -0.420, -0.271, -0.304),
(0.474, -0.509, -0.303, -0.347),
(0.241, -0.554, -0.573, -0.617),
(0.292, -0.154, -0.302, -0.326),
)
GRADE_HOLD_CASES = (
(-0.49, -1.40),
(0.00, -1.40),
(0.49, -1.40),
(0.75, -1.40),
(0.98, -1.65),
(1.25, -2.00),
(1.47, -2.00),
)
def get_car_params(candidate, experimental_long=True):
fingerprint = gen_empty_fingerprint()
interface = interfaces[candidate]
CP = interface.get_params(candidate, fingerprint, [], experimental_long, False, False)
return CP, interface.get_params_sp(CP, candidate, fingerprint, [], experimental_long, False, False)
def make_car_state(v_ego=0.2, a_ego=0.0, standstill=False, v_ego_raw=None) -> structs.CarState:
raw_speed = v_ego if v_ego_raw is None else v_ego_raw
state = structs.CarState(vEgo=float(v_ego), vEgoRaw=float(raw_speed), aEgo=float(a_ego), standstill=standstill)
state.cruiseState.standstill = standstill
return state
def make_control(candidate, initial_accel=-0.33, experimental_long=True):
CP, CP_SP = get_car_params(candidate, experimental_long)
control = LongControl(CP, CP_SP)
control.long_control_state = LongCtrlState.pid
control.last_output_accel = initial_accel
return CP, control
def stock_stopping_output(output_accel, stop_accel):
return min(output_accel, 0.0) - DT_CTRL if output_accel > stop_accel else output_accel
def expected_hold_accel(CP, initial_accel=-0.33):
minimum_hold = min(STOPPING_HOLD_ACCEL, CP.stopAccel + STOPPING_HOLD_MARGIN)
return min(initial_accel, max(CP.stopAccel, minimum_hold))
def settle_preserved_hold(control):
control.update(True, make_car_state(0.28, -0.29), -0.22, True, (-3.5, 2.0))
CS = make_car_state(0.0, 0.0, standstill=True)
for _ in range(round(4.0 / DT_CTRL)):
control.update(True, CS, -0.1, True, (-3.5, 2.0))
class TestLongControlSP(OpenpilotTestCase):
def test_stop_threshold_matches_the_shared_helper(self):
assert should_stop(0.29, 0.0)
assert not should_stop(0.3, 0.0)
assert not should_stop(0.29, 0.1)
def test_hold_scope_matches_every_car_interface(self):
for candidate in interfaces:
for experimental_long in (False, True):
with self.subTest(candidate=candidate, experimental_long=experimental_long):
CP, control = make_control(candidate, experimental_long=experimental_long)
output = control.update(True, make_car_state(0.28, -0.29), -0.22, True, (-3.5, 2.0))
supported = CP.openpilotLongitudinalControl and not CP.notCar and CP.stopAccel < 0.0
self.assertAlmostEqual(output, -0.33)
assert (control._stopping_hold_accel is not None) == supported
@parameterized.expand(UNSUPPORTED_HOLD_VEHICLES, names=("candidate", "experimental_long"))
def test_unsupported_hold_semantics_keep_the_cache_disabled(self, candidate, experimental_long):
_, control = make_control(candidate, experimental_long=experimental_long)
control.update(True, make_car_state(0.28, -0.29), -0.22, True, (-3.5, 2.0))
assert control._stopping_hold_accel is None
@parameterized.expand(ROUTE_STOP_ONSETS, names=("v_ego", "a_ego", "a_target", "initial_accel"))
def test_logged_stop_onsets_hold_the_existing_brake(self, v_ego, a_ego, a_target, initial_accel):
_, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2, initial_accel)
output = control.update(True, make_car_state(v_ego, a_ego), a_target, True, (-3.5, 2.0))
assert control.long_control_state == LongCtrlState.stopping
self.assertAlmostEqual(output, initial_accel)
@parameterized.expand(ROUTE_STOP_ONSETS, names=("v_ego", "a_ego", "a_target", "initial_accel"))
def test_logged_stop_onsets_preserve_a_settled_hold(self, v_ego, a_ego, a_target, initial_accel):
CP, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2, initial_accel)
control.update(True, make_car_state(v_ego, a_ego), a_target, True, (-3.5, 2.0))
CS = make_car_state(0.0, 0.0, standstill=True)
outputs = [control.update(True, CS, a_target, True, (-3.5, 2.0)) for _ in range(round(10.0 / DT_CTRL))]
hold_floor = expected_hold_accel(CP, initial_accel)
self.assertAlmostEqual(outputs[-1], hold_floor)
np.testing.assert_allclose(outputs[-100:], outputs[-1], rtol=0.0, atol=1e-12)
assert all(current <= previous for previous, current in zip(outputs[:-1], outputs[1:], strict=True))
@parameterized.expand(PRESERVED_HOLD_VEHICLES, names=("candidate",))
def test_preserved_hold_does_not_change_the_moving_approach(self, candidate):
CP, control = make_control(candidate)
control.update(True, make_car_state(0.28, -0.29), -0.22, True, (-3.5, 2.0))
moving = [control.update(True, make_car_state(0.25, -0.25), -0.22, True, (-3.5, 2.0)) for _ in range(20)]
CS = make_car_state(0.0, 0.0, standstill=True)
terminal = [control.update(True, CS, -0.1, True, (-3.5, 2.0)) for _ in range(round(4.0 / DT_CTRL))]
np.testing.assert_allclose(moving, -0.33, rtol=0.0, atol=1e-12)
self.assertAlmostEqual(terminal[-1], expected_hold_accel(CP))
def test_glide_hold_survives_a_soft_deceleration_sample(self):
_, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2, -0.166)
samples = ((0.388, -0.201, -0.164), (0.330, -0.120, -0.140), (0.283, -0.0675, -0.120))
outputs = [control.update(True, make_car_state(v_ego, a_ego), a_target, True, (-3.5, 2.0)) for v_ego, a_ego, a_target in samples]
np.testing.assert_allclose(outputs, [-0.166] * len(samples), rtol=1e-6, atol=1e-12)
def test_glide_response_reaches_the_stock_rate_when_deceleration_stops(self):
_, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2, -0.166)
control.update(True, make_car_state(0.388, -0.201), -0.164, True, (-3.5, 2.0))
output = control.update(True, make_car_state(0.330, -0.01), -0.140, True, (-3.5, 2.0))
assert -0.176 < output < -0.175
def test_glide_response_increases_with_stopping_distance_error(self):
_, nominal = make_control(TOYOTA.TOYOTA_RAV4_TSS2, -0.166)
_, distance_error = make_control(TOYOTA.TOYOTA_RAV4_TSS2, -0.166)
for control in (nominal, distance_error):
control.update(True, make_car_state(0.388, -0.201), -0.164, True, (-3.5, 2.0))
nominal_output = nominal.update(True, make_car_state(0.330, -0.050), -0.140, True, (-3.5, 2.0))
distance_error_output = distance_error.update(True, make_car_state(0.400, -0.050), -0.140, True, (-3.5, 2.0))
assert -0.176 < distance_error_output < nominal_output
@parameterized.expand(((1.0, 0.0), (0.75, 0.4375), (0.5, 0.75), (0.0, 1.0)), names=("decel_fraction", "expected_rate"))
def test_stopping_rate_scales_with_realized_deceleration(self, decel_fraction, expected_rate):
_, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
output = control.update(True, make_car_state(0.3, -0.12 * decel_fraction), 0.0, True, (-3.5, 2.0))
self.assertAlmostEqual((-0.33 - output) / DT_CTRL, expected_rate, delta=1e-6)
def test_stopping_rate_scales_with_planner_demand(self):
_, gentle = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
_, urgent = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
gentle_output = gentle.update(True, make_car_state(0.3, -0.12), -0.34, True, (-3.5, 2.0))
urgent_output = urgent.update(True, make_car_state(0.3, -0.12), -1.0, True, (-3.5, 2.0))
assert -0.331 < gentle_output < -0.33
self.assertAlmostEqual(urgent_output, -0.34)
def test_glide_hold_yields_to_stronger_planner_braking(self):
CP, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2, -0.166)
control.update(True, make_car_state(0.388, -0.201), -0.164, True, (-3.5, 2.0))
output = control.update(True, make_car_state(0.330, -0.120), -1.0, True, (-3.5, 2.0))
self.assertAlmostEqual(output, stock_stopping_output(-0.166, CP.stopAccel))
@parameterized.expand(STOP_ACCEL_VEHICLES, names=("candidate",))
def test_urgent_braking_matches_the_stock_ramp(self, candidate):
CP, control = make_control(candidate)
CS = make_car_state(0.8, -0.1)
output = control.last_output_accel
for _ in range(round(1.0 / DT_CTRL)):
output = control.update(True, CS, -3.0, True, (-3.5, 2.0))
expected = -0.33
for _ in range(round(1.0 / DT_CTRL)):
expected = stock_stopping_output(expected, CP.stopAccel)
self.assertAlmostEqual(output, expected)
@parameterized.expand(STOP_ACCEL_VEHICLES, names=("candidate",))
def test_stronger_planner_brake_matches_the_stock_ramp(self, candidate):
CP, control = make_control(candidate)
outputs = [control.update(True, make_car_state(0.3, -0.3), -1.0, True, (-3.5, 2.0)) for _ in range(10)]
expected = []
output = -0.33
for _ in range(10):
output = stock_stopping_output(output, CP.stopAccel)
expected.append(output)
np.testing.assert_allclose(outputs, expected, rtol=1e-6, atol=1e-12)
@parameterized.expand(STOP_ACCEL_VEHICLES, names=("candidate",))
def test_insufficient_deceleration_uses_most_of_the_stock_ramp(self, candidate):
CP, control = make_control(candidate)
output = control.update(True, make_car_state(0.6, -0.1), -0.1, True, (-3.5, 2.0))
if -0.33 > CP.stopAccel:
assert -0.34 < output < -0.338
else:
self.assertAlmostEqual(output, -0.33)
def test_deceleration_noise_cannot_release_the_brake(self):
_, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
outputs = [control.update(True, make_car_state(0.3, -0.3 if frame % 2 else 0.0), -0.1, True, (-3.5, 2.0)) for frame in range(40)]
assert all(current <= previous for previous, current in zip(outputs[:-1], outputs[1:], strict=True))
def test_planner_noise_cannot_release_the_brake(self):
_, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
outputs = [control.update(True, make_car_state(0.3, -0.3), -1.0 if frame % 2 else -0.1, True, (-3.5, 2.0)) for frame in range(40)]
assert all(current <= previous for previous, current in zip(outputs[:-1], outputs[1:], strict=True))
@parameterized.expand(
(
(float("nan"), -0.3, -0.1),
(0.3, float("nan"), -0.1),
(0.3, -0.3, float("nan")),
(float("inf"), -0.3, -0.1),
(0.3, -float("inf"), -0.1),
(0.3, -0.3, float("inf")),
),
names=("v_ego", "a_ego", "a_target"),
)
def test_invalid_state_uses_the_stock_ramp(self, v_ego, a_ego, a_target):
CP, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
output = control.update(True, make_car_state(v_ego, a_ego), a_target, True, (-3.5, 2.0))
self.assertAlmostEqual(output, stock_stopping_output(-0.33, CP.stopAccel))
@parameterized.expand(
(
(0.24, 0.0, -0.49, 0.15, 0.0),
(0.53, -0.31, -0.49, 0.35, 0.1),
(0.24, 0.0, 0.0, 0.15, 0.0),
(0.464, -0.223, 0.0, 0.25, 0.05),
(0.53, -0.31, 0.0, 0.35, 0.1),
(0.24, 0.0, 0.49, 0.15, 0.0),
(0.53, -0.31, 0.49, 0.25, 0.05),
(0.6, -0.3, 0.49, 0.35, 0.1),
(0.6, -0.3, 0.49, 0.5, 0.1),
),
names=("speed", "initial_accel", "grade_accel", "actuator_lag", "actuator_delay"),
)
def test_smooth_stop_distance_is_bounded(self, speed, initial_accel, grade_accel, actuator_lag, actuator_delay):
_, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2, initial_accel)
applied_accel = initial_accel
delay = [initial_accel] * round(actuator_delay / DT_CTRL)
distance = 0.0
outputs = []
for _ in range(round(4.0 / DT_CTRL)):
command = control.update(True, make_car_state(speed, applied_accel), -0.1, True, (-3.5, 2.0))
outputs.append(command)
delayed_command = command
if delay:
delay.append(command)
delayed_command = delay.pop(0)
applied_accel += DT_CTRL / actuator_lag * (delayed_command + grade_accel - applied_accel)
speed = max(0.0, speed + applied_accel * DT_CTRL)
distance += speed * DT_CTRL
if speed == 0.0:
break
assert speed == 0.0
assert distance < 1.0
assert all(current <= previous for previous, current in zip(outputs[:-1], outputs[1:], strict=True))
@parameterized.expand(STOP_ACCEL_VEHICLES, names=("candidate",))
def test_standstill_uses_the_stock_ramp(self, candidate):
CP, control = make_control(candidate)
control.long_control_state = LongCtrlState.off
CS = make_car_state(0.0, 0.0, standstill=True)
outputs = [control.update(True, CS, 0.0, False, (-3.5, 2.0)) for _ in range(round(2.0 / DT_CTRL))]
expected = -0.33
for _ in range(round(2.0 / DT_CTRL)):
expected = stock_stopping_output(expected, CP.stopAccel)
self.assertAlmostEqual(outputs[0], stock_stopping_output(-0.33, CP.stopAccel))
self.assertAlmostEqual(outputs[-1], expected)
@parameterized.expand(PRESERVED_HOLD_VEHICLES, names=("candidate",))
def test_preserved_hold_yields_to_stronger_planner_braking(self, candidate):
CP, control = make_control(candidate)
settle_preserved_hold(control)
previous = control.last_output_accel
output = control.update(True, make_car_state(0.0, 0.0, standstill=True), CP.stopAccel, True, (-3.5, 2.0))
self.assertAlmostEqual(output, stock_stopping_output(previous, CP.stopAccel))
def test_false_departure_restores_a_stronger_preserved_hold(self):
CP, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
settle_preserved_hold(control)
stronger_hold = control.update(True, make_car_state(0.0, 0.0, standstill=True), CP.stopAccel, True, (-3.5, 2.0))
departure_state = make_car_state(0.0, 0.0, standstill=True)
departure_state.cruiseState.standstill = False
control.update(True, departure_state, 0.6, False, (-3.5, 2.0))
restored = control.update(True, make_car_state(0.0, 0.0, standstill=True), -0.1, True, (-3.5, 2.0))
self.assertAlmostEqual(restored, stronger_hold)
@parameterized.expand(SETTLE_VEHICLES, names=("candidate",))
def test_false_departure_restores_a_command_at_the_stop_limit(self, candidate):
CP, control = make_control(candidate)
strong_hold = max(CP.stopAccel - 0.2, -3.5)
control.last_output_accel = strong_hold
held = control.update(True, make_car_state(0.0, 0.0, standstill=True), -0.1, True, (-3.5, 2.0))
departure_state = make_car_state(0.0, 0.0, standstill=True)
departure_state.cruiseState.standstill = False
control.update(True, departure_state, 0.6, False, (-3.5, 2.0))
restored = control.update(True, make_car_state(0.0, 0.0, standstill=True), -0.1, True, (-3.5, 2.0))
self.assertAlmostEqual(restored, held)
@parameterized.expand(SETTLE_VEHICLES, names=("candidate",))
def test_false_departure_after_reaching_the_stop_limit_restores_braking(self, candidate):
CP, control = make_control(candidate)
CS = make_car_state(0.0, 0.0, standstill=True)
while control.last_output_accel > CP.stopAccel:
reached = control.update(True, CS, -0.1, True, (-3.5, 2.0))
departure_state = make_car_state(0.0, 0.0, standstill=True)
departure_state.cruiseState.standstill = False
control.update(True, departure_state, 0.6, False, (-3.5, 2.0))
restored = control.update(True, CS, -0.1, True, (-3.5, 2.0))
self.assertAlmostEqual(restored, reached)
@parameterized.expand(PRESERVED_HOLD_VEHICLES, names=("candidate",))
def test_inadequate_preserved_hold_uses_the_stock_ramp(self, candidate):
for v_ego, a_ego, standstill in ((0.0, 0.2, True), (-0.1, 0.0, False)):
with self.subTest(v_ego=v_ego, a_ego=a_ego, standstill=standstill):
CP, control = make_control(candidate)
settle_preserved_hold(control)
output = control.last_output_accel
expected = output
for _ in range(round(4.0 / DT_CTRL)):
output = control.update(True, make_car_state(v_ego, a_ego, standstill), -0.1, True, (-3.5, 2.0))
expected = max(stock_stopping_output(expected, CP.stopAccel), -3.5)
self.assertAlmostEqual(output, expected)
@parameterized.expand(PRESERVED_HOLD_VEHICLES, names=("candidate",))
def test_false_departure_restores_the_preserved_hold(self, candidate):
_, control = make_control(candidate)
settle_preserved_hold(control)
hold_accel = control.last_output_accel
departure_state = make_car_state(0.0, 0.0, standstill=True)
departure_state.cruiseState.standstill = False
departure = control.update(True, departure_state, 0.6, False, (-3.5, 2.0))
restored = control.update(True, make_car_state(0.0, 0.0, standstill=True), -0.1, True, (-3.5, 2.0))
assert departure > 0.0
self.assertAlmostEqual(restored, hold_accel)
@parameterized.expand(
((True, 0.0, 0.0, True), (False, 0.06, 0.06, False), (False, 0.0, 0.06, False)),
names=("inactive", "v_ego", "v_ego_raw", "standstill"),
)
def test_preserved_hold_clears_after_inactive_or_real_motion(self, inactive, v_ego, v_ego_raw, standstill):
CP, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
settle_preserved_hold(control)
control.update(not inactive, make_car_state(v_ego, 0.0, standstill=standstill, v_ego_raw=v_ego_raw), 0.6, False, (-3.5, 2.0))
output = control.update(True, make_car_state(0.0, 0.0, standstill=True), -0.1, True, (-3.5, 2.0))
assert control._stopping_hold_accel is None
self.assertAlmostEqual(output, stock_stopping_output(0.0, CP.stopAccel))
@parameterized.expand(((float("nan"), 0.0), (0.0, float("nan"))), names=("v_ego", "v_ego_raw"))
def test_invalid_speed_clears_the_preserved_hold(self, v_ego, v_ego_raw):
_, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
settle_preserved_hold(control)
previous = control.last_output_accel
output = control.update(True, make_car_state(v_ego, 0.0, standstill=True, v_ego_raw=v_ego_raw), -0.1, True, (-3.5, 2.0))
assert control._stopping_hold_accel is None
self.assertAlmostEqual(output, previous - DT_CTRL)
@parameterized.expand(((0.005, True), (-0.005, True), (0.02, True), (-0.02, False)), names=("v_ego_raw", "standstill"))
def test_raw_wheel_motion_keeps_building_brake(self, v_ego_raw, standstill):
_, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
settle_preserved_hold(control)
previous = control.last_output_accel
CS = make_car_state(0.0, 0.0, standstill=standstill, v_ego_raw=v_ego_raw)
output = control.update(True, CS, -0.1, True, (-3.5, 2.0))
self.assertAlmostEqual(output, previous - DT_CTRL)
def test_preserved_hold_removes_launch_brake_backlog(self):
CP, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
control.update(True, make_car_state(0.28, -0.29), -0.22, True, (-3.5, 2.0))
CS = make_car_state(0.0, 0.0, standstill=True)
for _ in range(round(3.0 / DT_CTRL)):
control.update(True, CS, -0.1, True, (-3.5, 2.0))
preserved_hold = control.last_output_accel
CS.cruiseState.standstill = False
requested_accels = [control.update(True, CS, min(0.15 + frame * DT_CTRL, 1.2), False, (-3.5, 2.0)) for frame in range(round(1.0 / DT_CTRL))]
def release_time(initial_accel):
applied_accel = initial_accel
for frame, requested_accel in enumerate(requested_accels):
accel_step = PRIUS_TSS2_ROUTE_MODEL.command_rate_limit * DT_CTRL
applied_accel += np.clip(requested_accel - applied_accel, -accel_step, accel_step)
if applied_accel >= 0.0:
return (frame + 1) * DT_CTRL
raise AssertionError("brake command did not release")
stock_release = release_time(CP.stopAccel)
preserved_release = release_time(preserved_hold)
self.assertAlmostEqual(preserved_hold, expected_hold_accel(CP))
assert stock_release >= 0.45
assert preserved_release <= 0.37
assert stock_release - preserved_release >= 0.14
@parameterized.expand(GRADE_HOLD_CASES, names=("grade_accel", "expected_hold"))
def test_preserved_hold_adapts_to_grade_without_creep(self, grade_accel, expected_hold):
_, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2, -0.3)
speed = 0.6
actuator_accel = -0.3
physical_accel = actuator_accel + grade_accel
stopped_frames = 0
max_post_stop_speed = 0.0
for _ in range(round(16.0 / DT_CTRL)):
standstill = bool(speed <= 1e-6)
measured_accel = max(physical_accel, 0.0) if standstill else physical_accel
output = control.update(True, make_car_state(speed, measured_accel, standstill), -0.1, True, (-3.5, 2.0))
actuator_accel += DT_CTRL / 0.25 * (output - actuator_accel)
physical_accel = actuator_accel + grade_accel
speed = max(0.0, speed + physical_accel * DT_CTRL) if speed > 0.0 or physical_accel > 0.0 else 0.0
if stopped_frames:
max_post_stop_speed = max(max_post_stop_speed, speed)
stopped_frames += 1
elif speed == 0.0:
stopped_frames = 1
if stopped_frames >= round(8.0 / DT_CTRL):
break
assert stopped_frames >= round(8.0 / DT_CTRL)
assert max_post_stop_speed == 0.0
self.assertAlmostEqual(output, expected_hold, delta=0.03)
@parameterized.expand(SETTLE_VEHICLES, names=("candidate",))
def test_final_stop_builds_brake_smoothly_while_vehicle_settles(self, candidate):
_, control = make_control(candidate)
control.update(True, make_car_state(0.28, -0.29), -0.22, True, (-3.5, 2.0))
outputs = [control.update(True, make_car_state(0.0006, a_ego, standstill=True), -0.032, True, (-3.5, 2.0)) for a_ego in (-1.098, -0.950, -0.609, -0.286)]
changes = -np.diff([-0.33, *outputs])
assert np.all(changes > 0.0)
assert np.all(np.diff(changes) > 0.0)
assert changes[-1] < 0.001
@parameterized.expand((-0.09, 0.0, 0.1), names=("a_ego",))
def test_settled_vehicle_uses_the_stock_hold_ramp(self, a_ego):
CP, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
output = control.update(True, make_car_state(0.0, a_ego, standstill=True), -0.1, True, (-3.5, 2.0))
self.assertAlmostEqual(output, stock_stopping_output(-0.33, CP.stopAccel))
@parameterized.expand(SETTLE_VEHICLES, names=("candidate",))
def test_direct_terminal_entry_builds_brake_smoothly(self, candidate):
_, control = make_control(candidate)
CS = make_car_state(0.0006, -0.3, standstill=True)
outputs = [control.update(True, CS, -0.1, True, (-3.5, 2.0)) for _ in range(4)]
rates = -np.diff([-0.33, *outputs]) / DT_CTRL
np.testing.assert_allclose(rates, [(frame / STOPPING_SETTLE_FRAMES) ** 2 for frame in range(1, 5)], rtol=1e-6, atol=1e-12)
def test_direct_terminal_entry_keeps_urgent_stock_braking(self):
CP, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
output = control.update(True, make_car_state(0.0006, -0.3, standstill=True), -1.0, True, (-3.5, 2.0))
self.assertAlmostEqual(output, stock_stopping_output(-0.33, CP.stopAccel))
@parameterized.expand((0.0, -0.05), names=("initial_accel",))
def test_direct_terminal_entry_first_builds_meaningful_brake(self, initial_accel):
CP, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2, initial_accel)
output = control.update(True, make_car_state(0.0006, -0.3, standstill=True), 0.0, True, (-3.5, 2.0))
self.assertAlmostEqual(output, stock_stopping_output(initial_accel, CP.stopAccel))
@parameterized.expand(SETTLE_VEHICLES, names=("candidate",))
def test_final_settling_ramp_is_bounded(self, candidate):
_, control = make_control(candidate)
control.update(True, make_car_state(0.28, -0.29), -0.22, True, (-3.5, 2.0))
CS = make_car_state(0.0, -0.3, standstill=True)
outputs = [control.update(True, CS, -0.1, True, (-3.5, 2.0)) for _ in range(STOPPING_SETTLE_FRAMES + 1)]
rates = -np.diff([-0.33, *outputs]) / DT_CTRL
expected = [(frame / STOPPING_SETTLE_FRAMES) ** 2 for frame in range(1, STOPPING_SETTLE_FRAMES + 1)] + [1.0]
np.testing.assert_allclose(rates, expected, rtol=1e-6, atol=1e-12)
@parameterized.expand(((0.6, -0.1, False), (0.0, 0.0, True)), names=("v_ego", "a_ego", "standstill"))
def test_stopping_never_releases_a_stronger_command(self, v_ego, a_ego, standstill):
_, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2, -3.0)
output = control.update(True, make_car_state(v_ego, a_ego, standstill), 0.0, True, (-3.5, 2.0))
self.assertAlmostEqual(output, -3.0)
def test_reported_standstill_while_moving_can_hold_the_brake(self):
_, control = make_control(GM.CHEVROLET_BOLT_EUV)
control.long_control_state = LongCtrlState.off
output = control.update(True, make_car_state(0.3, -0.3, standstill=True), -0.1, False, (-3.5, 2.0))
self.assertAlmostEqual(output, -0.33)
@parameterized.expand((-0.1, 0.09), names=("a_target",))
def test_stopping_removes_positive_acceleration_immediately(self, a_target):
_, control = make_control(HYUNDAI.HYUNDAI_SONATA, 0.2)
output = control.update(True, make_car_state(0.2, -0.2), a_target, True, (-3.5, 2.0))
self.assertAlmostEqual(output, -DT_CTRL)
def test_rollback_uses_the_stock_ramp(self):
CP, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
output = control.update(True, make_car_state(-0.1, 0.1), -0.1, True, (-3.5, 2.0))
self.assertAlmostEqual(output, stock_stopping_output(-0.33, CP.stopAccel))
def test_rollback_after_settling_arms_uses_the_stock_ramp(self):
_, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
control.update(True, make_car_state(0.28, -0.29), -0.22, True, (-3.5, 2.0))
control.update(True, make_car_state(0.01, -0.3), -0.1, True, (-3.5, 2.0))
previous = control.last_output_accel
output = control.update(True, make_car_state(-0.04, -0.3), -0.1, True, (-3.5, 2.0))
self.assertAlmostEqual(output, previous - DT_CTRL)
def test_small_velocity_noise_does_not_trigger_the_stock_rate(self):
_, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
control.update(True, make_car_state(0.28, -0.29), -0.22, True, (-3.5, 2.0))
output = control.update(True, make_car_state(-0.04, -0.3, standstill=True, v_ego_raw=0.0), -0.1, True, (-3.5, 2.0))
assert -0.331 < output < -0.33
def test_terminal_speed_chatter_cannot_extend_settling_ramp(self):
_, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
control.update(True, make_car_state(0.28, -0.29), -0.22, True, (-3.5, 2.0))
outputs = [
control.update(True, make_car_state(0.049 if frame % 2 == 0 else 0.051, -0.3, v_ego_raw=0.0), -0.1, True, (-3.5, 2.0))
for frame in range(STOPPING_SETTLE_FRAMES + 2)
]
rates = -np.diff([-0.33, *outputs]) / DT_CTRL
np.testing.assert_allclose(
rates[:STOPPING_SETTLE_FRAMES], [(frame / STOPPING_SETTLE_FRAMES) ** 2 for frame in range(1, STOPPING_SETTLE_FRAMES + 1)], rtol=1e-6, atol=1e-12
)
np.testing.assert_allclose(rates[-2:], [1.0, 1.0], rtol=1e-6, atol=1e-12)
def test_terminal_speed_plateau_cannot_extend_settling_ramp(self):
_, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
control.update(True, make_car_state(0.28, -0.29), -0.22, True, (-3.5, 2.0))
CS = make_car_state(0.03, -0.3, v_ego_raw=0.0)
outputs = [control.update(True, CS, -0.1, True, (-3.5, 2.0)) for _ in range(STOPPING_SETTLE_FRAMES + 1)]
rates = -np.diff([-0.33, *outputs]) / DT_CTRL
np.testing.assert_allclose(rates[-2:], [1.0, 1.0], rtol=1e-6, atol=1e-12)
def test_interrupted_stop_cannot_reuse_settling_hold(self):
CP, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
control.update(True, make_car_state(0.28, -0.29), -0.22, True, (-3.5, 2.0))
control.update(False, make_car_state(0.0, 0.0, standstill=True), 0.0, False, (-3.5, 2.0))
output = control.update(True, make_car_state(0.0, -0.3, standstill=True), -0.1, True, (-3.5, 2.0))
self.assertAlmostEqual(output, stock_stopping_output(0.0, CP.stopAccel))
def test_departure_uses_the_stock_pid_path(self):
_, control = make_control(TOYOTA.TOYOTA_RAV4_TSS2)
control.long_control_state = LongCtrlState.stopping
output = control.update(True, make_car_state(0.0), 0.6, False, (-3.5, 2.0))
assert control.long_control_state == LongCtrlState.pid
assert output > 0.0
def test_planner_mpc_and_longcontrol_complete_a_smooth_stop(self):
plant = PlantSP(
lead_relevancy=True,
speed=0.6,
distance_lead=3.6,
run_long_control=True,
actuator_model=PRIUS_TSS2_ROUTE_MODEL,
)
plant.planner.accel_controller_enabled = True
plant.planner.dec._enabled = False
commands = []
speeds = []
states = []
solver_statuses = []
with (
mock.patch.object(plant.planner, "read_accel_controller_params", return_value=None),
mock.patch.object(plant.planner.dec, "_read_params", return_value=None),
):
while plant.current_time < 5.0:
result = plant.step(v_lead=0.0, v_cruise=8.0)
commands.append(result["actuator_command"])
speeds.append(result["speed"])
states.append(result["long_control_state"])
solver_statuses.append(plant.planner.mpc.solution_status)
stopping = states.index(LongCtrlState.stopping)
moving_stop_commands = [
command for command, state, speed in zip(commands, states, speeds, strict=True) if state == LongCtrlState.stopping and speed > STOPPING_SPEED_TOLERANCE
]
assert all(current <= previous + 1e-9 for previous, current in zip(commands[stopping:-1], commands[stopping + 1 :], strict=True))
assert len(moving_stop_commands) > 1 and max(moving_stop_commands) - min(moving_stop_commands) < 1e-9
assert plant.speed == 0.0 and plant.distance < 1.0
assert plant.distance_lead - plant.distance > 3.0
assert all(status == 0 for status in solver_statuses)
@@ -0,0 +1,395 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass
import math
import time
from typing import Any
import numpy as np
from openpilot.cereal import log, messaging
from opendbc.car.interfaces import ACCEL_MAX, ACCEL_MIN
from openpilot.common.realtime import DT_CTRL, DT_MDL, Ratekeeper
from openpilot.selfdrive.modeld.constants import ModelConstants
from openpilot.selfdrive.controls.lib.longcontrol import LongControl, LongCtrlState
from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner
from openpilot.selfdrive.controls.radard import _LEAD_ACCEL_TAU
from openpilot.selfdrive.test.longitudinal_maneuvers.plant import Plant, PlannerSM
LeadObservation = dict[str, Any]
LeadObservationFn = Callable[[float, str, LeadObservation], LeadObservation | None]
ModelActionFn = Callable[[float, float, float], tuple[float, bool]]
EgoObservationFn = Callable[[float, float, float], tuple[float, float]]
@dataclass(frozen=True)
class ActuatorModel:
planner_delay: float
transport_delay: float
actuator_lag: float
command_rate_limit: float
stopping_acceleration: float
standstill_breakaway_acceleration: float
standstill_breakaway_time: float
def __post_init__(self):
nonnegative_fields = {
"planner_delay": self.planner_delay,
"transport_delay": self.transport_delay,
"actuator_lag": self.actuator_lag,
"standstill_breakaway_acceleration": self.standstill_breakaway_acceleration,
"standstill_breakaway_time": self.standstill_breakaway_time,
}
if any(not math.isfinite(value) or value < 0.0 for value in nonnegative_fields.values()):
raise ValueError(f"ActuatorModel fields must be finite and non-negative: {nonnegative_fields}")
if not math.isfinite(self.command_rate_limit) or self.command_rate_limit <= 0.0:
raise ValueError("command_rate_limit must be finite and positive")
if not math.isfinite(self.stopping_acceleration) or self.stopping_acceleration > 0.0:
raise ValueError("stopping_acceleration must be finite and non-positive")
# Conservative Prius TSS2 actuator model.
PRIUS_TSS2_ROUTE_MODEL = ActuatorModel(
planner_delay=0.05,
transport_delay=0.0,
actuator_lag=0.20,
command_rate_limit=4.0,
stopping_acceleration=-2.0,
standstill_breakaway_acceleration=1.0,
standstill_breakaway_time=0.05,
)
class PlantSP(Plant):
"""Closed-loop plant with configurable observations and actuator response."""
def __init__(
self,
lead_relevancy=False,
speed=0.0,
distance_lead=2.0,
enabled=True,
only_lead2=False,
only_radar=False,
e2e=False,
personality=0,
force_decel=False,
lead_observation_fn: LeadObservationFn | None = None,
model_action_fn: ModelActionFn | None = None,
ego_observation_fn: EgoObservationFn | None = None,
actuator_delay: float | None = None,
actuator_lag: float = 0.0,
actuator_model: ActuatorModel | None = None,
run_long_control: bool = False,
):
if actuator_delay is not None and (not math.isfinite(actuator_delay) or actuator_delay < 0.0):
raise ValueError("actuator_delay must be finite and non-negative")
if not math.isfinite(actuator_lag) or actuator_lag < 0.0:
raise ValueError("actuator_lag must be finite and non-negative")
self.rate = 1.0 / DT_MDL
if not Plant.messaging_initialized:
Plant.radar = messaging.pub_sock('radarState')
Plant.controls_state = messaging.pub_sock('controlsState')
Plant.selfdrive_state = messaging.pub_sock('selfdriveState')
Plant.car_state = messaging.pub_sock('carState')
Plant.plan = messaging.sub_sock('longitudinalPlan')
Plant.messaging_initialized = True
self.v_lead_prev = 0.0
self.distance = 0.0
self.speed = speed
self.should_stop = False
self.acceleration = 0.0
self.a_target = 0.0
self.actuator_command = 0.0
self.applied_actuator_command = 0.0
self.breakaway_confirmed = False
self._breakaway_timer = 0.0
# lead car
self.lead_relevancy = lead_relevancy
self.distance_lead = distance_lead
self.enabled = enabled
self.only_lead2 = only_lead2
self.only_radar = only_radar
self.e2e = e2e
self.personality = personality
self.force_decel = force_decel
self.lead_observation_fn = lead_observation_fn
self.model_action_fn = model_action_fn
self.ego_observation_fn = ego_observation_fn
self.actuator_model = actuator_model
self.actuator_delay = actuator_model.planner_delay if actuator_model is not None else actuator_delay
self.transport_delay = actuator_model.transport_delay if actuator_model is not None else actuator_delay
self.actuator_lag = actuator_model.actuator_lag if actuator_model is not None else actuator_lag
self.publish_realized_a_ego = any((lead_observation_fn is not None, model_action_fn is not None, ego_observation_fn is not None,
actuator_delay is not None, actuator_lag > 0.0, actuator_model is not None, run_long_control))
self.rk = Ratekeeper(self.rate, print_delay_threshold=100.0)
self.ts = 1.0 / self.rate
time.sleep(0.1)
self.sm = messaging.SubMaster(['longitudinalPlan'])
from opendbc.car.honda.values import CAR
from opendbc.car.honda.interface import CarInterface
CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC)
if self.actuator_delay is not None:
CP.longitudinalActuatorDelay = self.actuator_delay
CP_SP = CarInterface.get_non_essential_params_sp(CP, CAR.HONDA_CIVIC)
self.planner = LongitudinalPlanner(CP, CP_SP, init_v=self.speed)
self.long_control = LongControl(CP, CP_SP) if run_long_control else None
if self.actuator_model is not None and self.speed >= 0.01:
self.breakaway_confirmed = True
self.integration_dt = DT_CTRL if run_long_control else self.ts
delay_steps = 0 if self.transport_delay is None else round(self.transport_delay / self.integration_dt)
self._actuator_delay_queue = deque([self.acceleration] * delay_steps)
@staticmethod
def _lead_message(observation: LeadObservation):
lead = log.RadarState.LeadData.new_message()
for field, value in observation.items():
setattr(lead, field, value)
return lead
def _observe_lead(self, lead_name: str, truth: LeadObservation, present_by_default: bool) -> LeadObservation | None:
if self.lead_observation_fn is None:
return dict(truth) if present_by_default else None
observed = self.lead_observation_fn(self.current_time, lead_name, dict(truth))
if observed is None:
return None
complete_observation = dict(truth)
complete_observation.update(observed)
return complete_observation
def _update_actuator(self, command: float) -> tuple[float, float]:
if self._actuator_delay_queue:
self._actuator_delay_queue.append(command)
delayed_command = self._actuator_delay_queue.popleft()
else:
delayed_command = command
if self.actuator_model is not None:
max_command_delta = self.actuator_model.command_rate_limit * self.integration_dt
self.applied_actuator_command = float(np.clip(delayed_command,
self.applied_actuator_command - max_command_delta,
self.applied_actuator_command + max_command_delta))
if self.speed < 0.01:
if self.applied_actuator_command <= 0.0:
self.breakaway_confirmed = False
self._breakaway_timer = 0.0
elif not self.breakaway_confirmed:
breakaway_ready = self.applied_actuator_command + 1e-9 >= self.actuator_model.standstill_breakaway_acceleration
if breakaway_ready:
self._breakaway_timer += self.integration_dt
else:
self._breakaway_timer = 0.0
self.breakaway_confirmed = breakaway_ready and self._breakaway_timer + 1e-9 >= self.actuator_model.standstill_breakaway_time
if not self.breakaway_confirmed:
self.acceleration = 0.0
return delayed_command, self.acceleration
else:
self.breakaway_confirmed = True
response_command = self.applied_actuator_command
else:
self.applied_actuator_command = delayed_command
response_command = delayed_command
if self.actuator_lag > 0.0:
alpha = 1.0 - math.exp(-self.integration_dt / self.actuator_lag)
self.acceleration += alpha * (response_command - self.acceleration)
else:
self.acceleration = response_command
return delayed_command, self.acceleration
def _integrate_ego(self, dt: float, stop_at_standstill: bool = False) -> None:
self.speed += self.acceleration * dt
if self.speed <= 0.0 or stop_at_standstill and self.speed < 0.01 and self.actuator_command <= 0.0:
self.speed = self.acceleration = 0.0
self.distance += self.speed * dt
def step(self, v_lead=0.0, prob_lead=1.0, v_cruise=50.0, pitch=0.0, prob_throttle=1.0):
# ******** publish a fake model going straight and fake calibration ********
# note that this is worst case for MPC, since model will delay long mpc by one time step
radar = messaging.new_message('radarState')
control = messaging.new_message('controlsState')
ss = messaging.new_message('selfdriveState')
car_state = messaging.new_message('carState')
vehicle_parameters = messaging.new_message('vehicleParameters')
car_control = messaging.new_message('carControl')
model = messaging.new_message('modelV2')
car_state_sp = messaging.new_message('carStateSP')
live_map_data_sp = messaging.new_message('liveMapDataSP')
gps_data = messaging.new_message('gpsLocation')
a_lead = (v_lead - self.v_lead_prev) / self.ts
self.v_lead_prev = v_lead
if self.lead_relevancy:
d_rel = np.maximum(0.0, self.distance_lead - self.distance)
v_rel = v_lead - self.speed
if self.only_radar:
status = True
elif prob_lead > 0.5:
status = True
else:
status = False
else:
d_rel = 200.0
v_rel = 0.0
prob_lead = 0.0
status = False
truth_lead: LeadObservation = {
"dRel": float(d_rel),
"yRel": 0.0,
"vRel": float(v_rel),
"vLead": float(v_lead),
"vLeadK": float(v_lead),
"aLeadK": float(a_lead),
"present": bool(status),
# TODO use real radard logic for this
"aLeadTau": float(_LEAD_ACCEL_TAU),
"modelProb": float(prob_lead),
"radar": bool(self.only_radar),
"radarTrackId": -1,
}
lead_one_observation = self._observe_lead("leadOne", truth_lead, not self.only_lead2)
lead_two_observation = self._observe_lead("leadTwo", truth_lead, True)
if lead_one_observation is not None:
radar.radarState.leadOne = self._lead_message(lead_one_observation)
if lead_two_observation is not None:
radar.radarState.leadTwo = self._lead_message(lead_two_observation)
# Simulate model predicting slightly faster speed
# this is to ensure lead policy is effective when model
# does not predict slowdown in e2e mode
position = log.XYZTData.new_message()
position.x = [float(x) for x in (self.speed + 0.5) * np.array(ModelConstants.T_IDXS)]
model.modelV2.position = position
if self.model_action_fn is None:
model_acceleration, model_should_stop = self.acceleration + 0.5, False
else:
model_acceleration, model_should_stop = self.model_action_fn(self.current_time, self.speed, self.acceleration)
model.modelV2.action.desiredAcceleration = float(model_acceleration)
model.modelV2.action.shouldStop = bool(model_should_stop)
velocity = log.XYZTData.new_message()
velocity.x = [float(x) for x in (self.speed + 0.5) * np.ones_like(ModelConstants.T_IDXS)]
velocity.x[0] = float(self.speed) # always start at current speed
model.modelV2.velocity = velocity
acceleration = log.XYZTData.new_message()
acceleration.x = [float(x) for x in np.zeros_like(ModelConstants.T_IDXS)]
model.modelV2.acceleration = acceleration
model.modelV2.meta.disengagePredictions.gasPressProbs = [float(prob_throttle) for _ in range(6)]
control.controlsState.longControlState = self.long_control.long_control_state if self.long_control is not None else (
LongCtrlState.pid if self.enabled else LongCtrlState.off)
ss.selfdriveState.experimentalMode = self.e2e
ss.selfdriveState.personality = self.personality
control.controlsState.forceDecel = self.force_decel
true_v_ego = self.speed
true_a_ego = self.acceleration
published_v_ego = true_v_ego
published_a_ego = true_a_ego if self.publish_realized_a_ego else 0.0
if self.ego_observation_fn is not None:
published_v_ego, published_a_ego = self.ego_observation_fn(self.current_time, true_v_ego, true_a_ego)
car_state.carState.vEgo = float(published_v_ego)
car_state.carState.aEgo = float(published_a_ego)
car_state.carState.standstill = bool(self.speed < 0.01)
car_state.carState.vCruise = float(v_cruise * 3.6)
car_control.carControl.orientationNED = [0.0, float(pitch), 0.0]
# ******** get controlsState messages for plotting ***
sm = PlannerSM(self.rk.frame, {
'radarState': radar.radarState,
'carState': car_state.carState,
'carControl': car_control.carControl,
'controlsState': control.controlsState,
'selfdriveState': ss.selfdriveState,
'vehicleParameters': vehicle_parameters.vehicleParameters,
'modelV2': model.modelV2,
'carStateSP': car_state_sp.carStateSP,
'liveMapDataSP': live_map_data_sp.liveMapDataSP,
'gpsLocation': gps_data.gpsLocation,
})
self.planner.update(sm)
self.a_target = self.planner.output_a_target
if self.long_control is None:
self.actuator_command = self.a_target
if self.planner.output_should_stop:
stopping_acceleration = -0.5 if self.actuator_model is None else self.actuator_model.stopping_acceleration
self.actuator_command = min(stopping_acceleration, self.actuator_command)
self._update_actuator(self.actuator_command)
self._integrate_ego(self.ts)
else:
for _ in range(round(self.ts / DT_CTRL)):
car_state.carState.vEgo = self.speed
car_state.carState.aEgo = self.acceleration
car_state.carState.standstill = self.speed < 0.01
self.actuator_command = self.long_control.update(
self.enabled, car_state.carState, self.a_target, self.planner.output_should_stop, (ACCEL_MIN, ACCEL_MAX),
)
self._update_actuator(self.actuator_command)
self._integrate_ego(DT_CTRL, stop_at_standstill=True)
self.should_stop = self.planner.output_should_stop
fcw = self.planner.fcw
self.distance_lead = self.distance_lead + v_lead * self.ts
# *** radar model ***
if self.lead_relevancy:
d_rel = np.maximum(0.0, self.distance_lead - self.distance)
v_rel = v_lead - self.speed
else:
d_rel = 200.0
v_rel = 0.0
# print at 5hz
# if (self.rk.frame % (self.rate // 5)) == 0:
# print("%2.2f sec %6.2f m %6.2f m/s %6.2f m/s2 lead_rel: %6.2f m %6.2f m/s"
# % (self.current_time, self.distance, self.speed, self.acceleration, d_rel, v_rel))
# ******** update prevs ********
self.rk.monitor_time()
accel_controller = self.planner.accel_controller
return {
"distance": self.distance,
"speed": self.speed,
"acceleration": self.acceleration,
"realized_acceleration": self.acceleration,
"a_target": self.a_target,
"actuator_command": self.actuator_command,
"published_a_ego": published_a_ego,
"published_v_ego": published_v_ego,
"should_stop": self.should_stop,
"long_control_state": (int(self.long_control.long_control_state) if self.long_control is not None
else control.controlsState.longControlState.raw),
"distance_lead": self.distance_lead,
"fcw": fcw,
"mpc_source": self.planner.mpc.source,
"dec_mode": self.planner.dec.mode(),
"controller_active": accel_controller.is_active,
"model_action": {
"desiredAcceleration": float(model_acceleration),
"shouldStop": bool(model_should_stop),
},
"truth_lead": dict(truth_lead),
"lead_one_observation": None if lead_one_observation is None else dict(lead_one_observation),
"lead_two_observation": None if lead_two_observation is None else dict(lead_two_observation),
}
@@ -0,0 +1,164 @@
from collections.abc import Callable
import math
from typing import cast
from openpilot.common.parameterized import parameterized
from openpilot.common.realtime import DT_MDL
from openpilot.common.test import OpenpilotTestCase
from openpilot.selfdrive.test.longitudinal_maneuvers.plant import Plant
from openpilot.sunnypilot.selfdrive.test.longitudinal_maneuvers.plant import PlantSP
STOCK_STEP_KEYS = ("distance", "speed", "acceleration", "should_stop", "distance_lead", "fcw")
def departing_lead(current_time: float) -> float:
return 0.0 if current_time < 1.0 else min(2.0, 2.0 * (current_time - 1.0))
def stopped_lead(_current_time: float) -> float:
return 0.0
PARITY_SCENARIOS = {
"approach_stopped_lead": {"lead_relevancy": True, "speed": 15.0, "distance_lead": 60.0, "v_cruise": 20.0, "v_lead": stopped_lead, "steps": 80},
"stop_then_depart": {"lead_relevancy": True, "speed": 0.0, "distance_lead": 6.0, "v_cruise": 8.0, "v_lead": departing_lead, "steps": 120},
}
def _drive(cls, *, v_cruise: float, v_lead: Callable[[float], float], steps: int, **kwargs):
plant = cls(**kwargs)
plant.v_lead_prev = v_lead(0.0)
solver_failures = 0
original_reset = plant.planner.mpc.reset
def counting_reset(*args, **kw):
nonlocal solver_failures
if plant.planner.mpc.solution_status != 0:
solver_failures += 1
return original_reset(*args, **kw)
plant.planner.mpc.reset = counting_reset
results = []
for _ in range(steps):
lead_speed = v_lead(plant.current_time)
result = plant.step(v_lead=lead_speed, v_cruise=v_cruise)
results.append((result, plant.planner.mpc.source, plant.planner.output_a_target))
return results, solver_failures
class TestPlantSP(OpenpilotTestCase):
@parameterized.expand(PARITY_SCENARIOS, names=("scenario",), ids=lambda scenario: scenario)
def test_plant_sp_matches_stock_plant_on_shared_kwargs(self, scenario: str):
kwargs = dict(PARITY_SCENARIOS[scenario])
v_cruise = cast(float, kwargs.pop("v_cruise"))
v_lead = cast(Callable[[float], float], kwargs.pop("v_lead"))
steps = cast(int, kwargs.pop("steps"))
stock_results, stock_failures = _drive(Plant, v_cruise=v_cruise, v_lead=v_lead, steps=steps, **kwargs)
sp_results, sp_failures = _drive(PlantSP, v_cruise=v_cruise, v_lead=v_lead, steps=steps, **kwargs)
assert stock_failures == 0, f"stock Plant solver failed {stock_failures} times in {scenario!r}"
assert sp_failures == 0, f"PlantSP solver failed {sp_failures} times in {scenario!r}"
for frame, ((stock_result, stock_source, stock_a_target), (sp_result, sp_source, sp_a_target)) in enumerate(
zip(stock_results, sp_results, strict=True),
):
for key in STOCK_STEP_KEYS:
if isinstance(stock_result[key], float):
self.assertAlmostEqual(sp_result[key], stock_result[key], msg=f"{scenario} frame {frame} key {key}")
else:
assert sp_result[key] == stock_result[key], f"{scenario} frame {frame} key {key}"
assert sp_source == stock_source, f"{scenario} frame {frame} mpc.source"
self.assertAlmostEqual(sp_a_target, stock_a_target, msg=f"{scenario} frame {frame} output_a_target")
if scenario == "stop_then_depart":
departure_frame = round(1.0 / DT_MDL)
for results in (stock_results, sp_results):
assert all(result["speed"] < 0.01 for result, _, _ in results[:departure_frame])
assert results[departure_frame - 1][0]["should_stop"]
assert any(not result["should_stop"] for result, _, _ in results[departure_frame:])
assert any(result["speed"] > 0.05 for result, _, _ in results[departure_frame:])
stock_release = next(frame for frame, (result, _, _) in enumerate(stock_results)
if frame >= departure_frame and not result["should_stop"])
sp_release = next(frame for frame, (result, _, _) in enumerate(sp_results)
if frame >= departure_frame and not result["should_stop"])
stock_motion = next(frame for frame, (result, _, _) in enumerate(stock_results)
if frame >= departure_frame and result["speed"] > 0.05)
sp_motion = next(frame for frame, (result, _, _) in enumerate(sp_results)
if frame >= departure_frame and result["speed"] > 0.05)
assert sp_release == stock_release
assert sp_motion == stock_motion
def test_full_lead_observation_is_independent_from_truth(self):
callback_inputs = []
def observe_lead(current_time, lead_name, truth):
callback_inputs.append((current_time, lead_name, truth))
if lead_name == "leadOne":
return {
"dRel": 12.5,
"vRel": -4.0,
"vLead": 6.0,
"vLeadK": 5.5,
"aLeadK": -1.25,
"aLeadTau": 0.7,
"present": True,
"modelProb": 0.9,
"radarTrackId": 42,
}
return None
plant = PlantSP(lead_relevancy=True, speed=10.0, distance_lead=50.0, lead_observation_fn=observe_lead)
result = plant.step(v_lead=8.0)
assert [entry[1] for entry in callback_inputs] == ["leadOne", "leadTwo"]
self.assertAlmostEqual(callback_inputs[0][2]["dRel"], 50.0)
self.assertAlmostEqual(result["truth_lead"]["dRel"], 50.0)
self.assertAlmostEqual(result["lead_one_observation"]["dRel"], 12.5)
assert result["lead_one_observation"]["radarTrackId"] == 42
assert result["lead_two_observation"] is None
self.assertAlmostEqual(result["distance_lead"], 50.0 + 8.0 * DT_MDL)
def test_model_action_realized_acceleration_and_source_logging(self):
def model_action(current_time, v_ego, a_ego):
return -1.25, True
plant = PlantSP(speed=10.0, e2e=True, force_decel=True, model_action_fn=model_action, actuator_lag=0.5)
first = plant.step()
second = plant.step()
assert first["model_action"] == {"desiredAcceleration": -1.25, "shouldStop": True}
self.assertAlmostEqual(first["published_a_ego"], 0.0)
self.assertAlmostEqual(second["published_a_ego"], first["realized_acceleration"])
assert first["acceleration"] == first["realized_acceleration"]
assert abs(first["realized_acceleration"]) < abs(first["actuator_command"])
assert first["mpc_source"] is not None
assert first["dec_mode"] in ("acc", "blended")
assert "controller_active" in first
assert first["lead_one_observation"] is not None
assert first["truth_lead"] == first["lead_one_observation"]
def test_default_model_action_matches_stock_plant(self):
result = PlantSP(speed=10.0).step()
self.assertAlmostEqual(result["model_action"]["desiredAcceleration"], 0.5)
assert not result["model_action"]["shouldStop"]
def test_configurable_transport_delay_and_first_order_lag(self):
plant = PlantSP(speed=10.0, actuator_delay=2 * DT_MDL, actuator_lag=0.2)
self.assertAlmostEqual(plant.planner.CP.longitudinalActuatorDelay, 2 * DT_MDL)
delayed_commands = [plant._update_actuator(-1.0) for _ in range(3)]
assert [command for command, _ in delayed_commands[:2]] == [0.0, 0.0]
expected_acceleration = -(1.0 - math.exp(-DT_MDL / 0.2))
assert delayed_commands[2][0] == -1.0
self.assertAlmostEqual(delayed_commands[2][1], expected_acceleration)
@parameterized.expand(
[(-0.1, 0.0), (float("nan"), 0.0), (float("inf"), 0.0), (None, -0.1), (None, float("nan")), (None, float("inf"))],
names=("delay", "lag"),
)
def test_invalid_actuator_dynamics(self, delay, lag):
with self.assertRaises(ValueError):
PlantSP(actuator_delay=delay, actuator_lag=lag)
@@ -11,6 +11,7 @@ from opendbc.car.structs import car
from opendbc.car.hyundai.values import CAR as HYUNDAI_CAR, UNSUPPORTED_LONGITUDINAL_CAR
from opendbc.car.subaru.values import CAR as SUBARU_CAR, SubaruFlags
from opendbc.sunnypilot.car.tesla.values import TeslaFlagsSP
from opendbc.sunnypilot.car.toyota.values import ToyotaFlagsSP, VIRTUAL_CRUISE_SPEED_CAR
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.common.hardware import HARDWARE
@@ -19,6 +20,7 @@ from openpilot.common.hardware import HARDWARE
# Wire-protocol version for the capabilities payload. Bump on breaking changes
# only; additive fields are backward-compatible and do not require a bump.
PROTOCOL_VERSION = 1
TOYOTA_VIRTUAL_CRUISE_SPEED_PLATFORMS = {str(platform) for platform in VIRTUAL_CRUISE_SPEED_CAR}
# All capability fields that rules may reference.
# Non-boolean fields must have defaults in CAPABILITY_DEFAULTS.
@@ -42,6 +44,7 @@ CAPABILITY_FIELDS = (
"device_type",
"subaru_has_sng",
"hyundai_alpha_long_available",
"toyota_virtual_cruise_speed_available",
)
CAPABILITY_LABELS: dict[str, str] = {
@@ -64,6 +67,7 @@ CAPABILITY_LABELS: dict[str, str] = {
"device_type": "Device type",
"subaru_has_sng": "Subaru Stop-and-Go available",
"hyundai_alpha_long_available": "Hyundai Alpha Longitudinal available",
"toyota_virtual_cruise_speed_available": "Toyota Virtual Cruise Speed available",
}
# Explicit defaults for non-boolean capability fields
@@ -110,6 +114,12 @@ def _resolve_brand_capabilities(caps: dict, bundle_platform: str, CP) -> None:
caps["subaru_has_sng"] = not bool(CP.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID))
caps["has_stop_and_go"] = caps["subaru_has_sng"]
elif brand == "toyota":
if bundle_platform:
caps["toyota_virtual_cruise_speed_available"] = bundle_platform in TOYOTA_VIRTUAL_CRUISE_SPEED_PLATFORMS
elif CP is not None:
caps["toyota_virtual_cruise_speed_available"] = str(CP.carFingerprint) in TOYOTA_VIRTUAL_CRUISE_SPEED_PLATFORMS
def generate_capabilities(params: Params | None = None) -> dict:
"""Generate a SettingsCapabilities dict from CarParams + boolean params.
@@ -174,6 +184,8 @@ def generate_capabilities(params: Params | None = None) -> dict:
caps["icbm_available"] = bool(CP_SP.intelligentCruiseButtonManagementAvailable)
caps["has_icbm"] = bool(CP_SP.intelligentCruiseButtonManagementAvailable) and params.get_bool("IntelligentCruiseButtonManagement")
caps["tesla_has_vehicle_bus"] = bool(CP_SP.flags & TeslaFlagsSP.HAS_VEHICLE_BUS)
if caps["brand"] == "toyota":
caps["toyota_virtual_cruise_speed_available"] = bool(CP_SP.flags & ToyotaFlagsSP.VIRTUAL_CRUISE_SPEED_AVAILABLE)
except Exception:
cloudlog.exception("capabilities: failed to deserialize CarParamsSPPersistent")
@@ -652,6 +652,53 @@
}
]
},
{
"key": "AccelPersonalityEnabled",
"widget": "toggle",
"title": "Enable Accel Controller",
"description": "Use the Accel Controller for smooth, early lead following and stop-and-go. Stock emergency braking remains available as a safety backstop.",
"visibility": [
{
"type": "capability",
"field": "has_longitudinal_control",
"equals": true
}
],
"enablement": [
{
"type": "capability",
"field": "has_longitudinal_control",
"equals": true
}
]
},
{
"key": "AccelPersonality",
"widget": "multiple_button",
"title": "Acceleration Profile",
"description": "Select the vehicle acceleration response. Chauffeur braking and stopping behavior remain the same across profiles.",
"options": [
{
"value": 0,
"label": "Eco"
},
{
"value": 1,
"label": "Normal"
},
{
"value": 2,
"label": "Sport"
}
],
"enablement": [
{
"type": "capability",
"field": "has_longitudinal_control",
"equals": true
}
]
},
{
"key": "IntelligentCruiseButtonManagement",
"widget": "toggle",
@@ -712,6 +759,21 @@
"type": "capability",
"field": "has_icbm",
"equals": true
},
{
"type": "all",
"conditions": [
{
"type": "capability",
"field": "toyota_virtual_cruise_speed_available",
"equals": true
},
{
"type": "param",
"key": "ToyotaVirtualCruiseSpeed",
"equals": true
}
]
}
]
}
@@ -750,6 +812,21 @@
"type": "capability",
"field": "has_icbm",
"equals": true
},
{
"type": "all",
"conditions": [
{
"type": "capability",
"field": "toyota_virtual_cruise_speed_available",
"equals": true
},
{
"type": "param",
"key": "ToyotaVirtualCruiseSpeed",
"equals": true
}
]
}
]
}
@@ -2094,6 +2171,22 @@
"equals": true
}
]
},
{
"key": "PlanplusControl",
"widget": "option",
"title": "Plan Plus Controls",
"description": "Adjust planplus model recentering strength. The higher this number the more aggressively the model will recover to lane center; too high and it will ping-pong.",
"min": 0.0,
"max": 2.0,
"step": 0.1,
"enablement": [
{
"type": "param",
"key": "ShowAdvancedControls",
"equals": true
}
]
}
]
},
@@ -2302,6 +2395,50 @@
"title": "Toyota / Lexus Settings",
"description": "",
"items": [
{
"key": "ToyotaAutoHold",
"widget": "toggle",
"needs_onroad_cycle": true,
"title": "Toyota: Auto Brake Hold FOR TSS2 HYBRID CARS",
"enablement": [
{
"type": "not_engaged"
}
]
},
{
"key": "ToyotaEnhancedBsm",
"widget": "toggle",
"needs_onroad_cycle": true,
"title": "Toyota: Prius TSS2 BSM and some tssp",
"enablement": [
{
"type": "not_engaged"
}
]
},
{
"key": "ToyotaTSS2Long",
"widget": "toggle",
"needs_onroad_cycle": true,
"title": "Toyota: custom longitudinal for TSS2",
"enablement": [
{
"type": "not_engaged"
}
]
},
{
"key": "ToyotaDriveMode",
"widget": "toggle",
"needs_onroad_cycle": true,
"title": "Enable drive mode btn link",
"enablement": [
{
"type": "not_engaged"
}
]
},
{
"key": "ToyotaEnforceStockLongitudinal",
"widget": "toggle",
@@ -2311,6 +2448,40 @@
"enablement": [
{
"type": "not_engaged"
},
{
"type": "param",
"key": "ToyotaVirtualCruiseSpeed",
"equals": false
}
]
},
{
"key": "ToyotaVirtualCruiseSpeed",
"widget": "toggle",
"needs_onroad_cycle": true,
"title": "Toyota: Virtual Cruise Speed (Alpha)",
"description": "Uses a sunnypilot-owned cruise target with the Toyota RES/SET buttons and unlocks Custom ACC Speed Intervals. Set the short interval to 5 for next-5-unit tap behavior. The Toyota cluster continues to show the factory target and may differ from sunnypilot. The direct button signals are route-validated on Corolla Cross and Prius TSS2, but held-button timing differs by platform. Validate acceleration above the factory target in a controlled setting.",
"visibility": [
{
"type": "capability",
"field": "toyota_virtual_cruise_speed_available",
"equals": true
}
],
"enablement": [
{
"type": "not_engaged"
},
{
"type": "capability",
"field": "has_longitudinal_control",
"equals": true
},
{
"type": "param",
"key": "ToyotaEnforceStockLongitudinal",
"equals": false
}
]
},
@@ -43,6 +43,29 @@ sections:
label: Relaxed
enablement:
- $ref: '#/macros/longitudinal'
- key: AccelPersonalityEnabled
widget: toggle
title: Enable Accel Controller
description: Use the Accel Controller for smooth, early lead following and stop-and-go. Stock emergency braking
remains available as a safety backstop.
visibility:
- $ref: '#/macros/longitudinal'
enablement:
- $ref: '#/macros/longitudinal'
- key: AccelPersonality
widget: multiple_button
title: Acceleration Profile
description: Select the vehicle acceleration response. Chauffeur braking and stopping behavior remain the same across
profiles.
options:
- value: 0
label: Eco
- value: 1
label: Normal
- value: 2
label: Sport
enablement:
- $ref: '#/macros/longitudinal'
- key: IntelligentCruiseButtonManagement
widget: toggle
title: Intelligent Cruise Button Management (ICBM) (Alpha)
@@ -77,6 +100,14 @@ sections:
- type: capability
field: has_icbm
equals: true
- type: all
conditions:
- type: capability
field: toyota_virtual_cruise_speed_available
equals: true
- type: param
key: ToyotaVirtualCruiseSpeed
equals: true
items:
- key: CustomAccIncrementsEnabled
widget: toggle
@@ -98,6 +129,14 @@ sections:
- type: capability
field: has_icbm
equals: true
- type: all
conditions:
- type: capability
field: toyota_virtual_cruise_speed_available
equals: true
- type: param
key: ToyotaVirtualCruiseSpeed
equals: true
sub_panels:
- id: custom_acc_intervals
label: Custom ACC Speed Intervals Settings
@@ -51,6 +51,16 @@ sections:
key: LagdToggle
equals: true
- $ref: '#/macros/advanced_only'
- key: PlanplusControl
widget: option
title: Plan Plus Controls
description: Adjust planplus model recentering strength. The higher this number the more aggressively the model will recover
to lane center; too high and it will ping-pong.
min: 0.0
max: 2.0
step: 0.1
enablement:
- $ref: '#/macros/advanced_only'
- id: lateral_control
title: Lateral Control
description: Neural network lateral control for supported models
@@ -82,6 +82,30 @@ sections:
title: Toyota / Lexus Settings
description: ''
items:
- key: ToyotaAutoHold
widget: toggle
needs_onroad_cycle: true
title: 'Toyota: Auto Brake Hold FOR TSS2 HYBRID CARS'
enablement:
- $ref: '#/macros/not_engaged'
- key: ToyotaEnhancedBsm
widget: toggle
needs_onroad_cycle: true
title: 'Toyota: Prius TSS2 BSM and some tssp'
enablement:
- $ref: '#/macros/not_engaged'
- key: ToyotaTSS2Long
widget: toggle
needs_onroad_cycle: true
title: 'Toyota: custom longitudinal for TSS2'
enablement:
- $ref: '#/macros/not_engaged'
- key: ToyotaDriveMode
widget: toggle
needs_onroad_cycle: true
title: Enable drive mode btn link
enablement:
- $ref: '#/macros/not_engaged'
- key: ToyotaEnforceStockLongitudinal
widget: toggle
needs_onroad_cycle: true
@@ -89,6 +113,28 @@ sections:
description: sunnypilot will not take over control of gas and brakes. Factory Toyota longitudinal control will be used.
enablement:
- $ref: '#/macros/not_engaged'
- type: param
key: ToyotaVirtualCruiseSpeed
equals: false
- key: ToyotaVirtualCruiseSpeed
widget: toggle
needs_onroad_cycle: true
title: 'Toyota: Virtual Cruise Speed (Alpha)'
description: Uses a sunnypilot-owned cruise target with the Toyota RES/SET buttons and unlocks Custom ACC Speed
Intervals. Set the short interval to 5 for next-5-unit tap behavior. The Toyota cluster continues to show the
factory target and may differ from sunnypilot. The direct button signals are route-validated on Corolla Cross
and Prius TSS2, but held-button timing differs by platform. Validate acceleration above the factory target in a
controlled setting.
visibility:
- type: capability
field: toyota_virtual_cruise_speed_available
equals: true
enablement:
- $ref: '#/macros/not_engaged'
- $ref: '#/macros/longitudinal'
- type: param
key: ToyotaEnforceStockLongitudinal
equals: false
- key: ToyotaStopAndGoHack
widget: toggle
needs_onroad_cycle: true
@@ -10,9 +10,18 @@ change and must be intentional. KNOWN_PROTOCOL_VERSIONS pins the set we
explicitly support when the constant is bumped, this list must be edited in
the same commit so the bump shows up in code review.
"""
from __future__ import annotations
from typing import cast
from openpilot.cereal import custom
from openpilot.common.params import Params
from openpilot.common.parameterized import parameterized
from openpilot.common.test import OpenpilotTestCase
from opendbc.car.structs import car
from opendbc.car.toyota.values import CAR as TOYOTA_CAR
from opendbc.sunnypilot.car.toyota.values import ToyotaFlagsSP
from openpilot.sunnypilot.sunnylink.capabilities import (
CAPABILITY_DEFAULTS,
CAPABILITY_FIELDS,
@@ -20,13 +29,41 @@ from openpilot.sunnypilot.sunnylink.capabilities import (
PROTOCOL_VERSION,
generate_capabilities,
)
from openpilot.common.test import OpenpilotTestCase
KNOWN_PROTOCOL_VERSIONS = (1,)
LATEST_KNOWN = max(KNOWN_PROTOCOL_VERSIONS)
class FakeParams:
def __init__(self, values=None):
self.values = values or {}
def get(self, key, *args, **kwargs):
return self.values.get(key)
def get_bool(self, key):
return bool(self.values.get(key, False))
def build_persistent_toyota_params(platform, *, sp_flags=0) -> Params:
CP = car.CarParams.new_message()
CP.brand = "toyota"
CP.carFingerprint = str(platform)
CP.pcmCruise = True
CP.openpilotLongitudinalControl = True
CP_SP = custom.CarParamsSP.new_message()
CP_SP.flags = int(sp_flags)
return cast(Params, FakeParams(
{
"CarParamsPersistent": CP.to_bytes(),
"CarParamsSPPersistent": CP_SP.to_bytes(),
}
))
def caps():
return generate_capabilities()
@@ -52,14 +89,12 @@ class TestProtocolVersion(OpenpilotTestCase):
def test_protocol_version_is_known(self):
"""Sentinel against accidental bumps. Edit KNOWN_PROTOCOL_VERSIONS if intentional."""
assert PROTOCOL_VERSION in KNOWN_PROTOCOL_VERSIONS, (
f"PROTOCOL_VERSION={PROTOCOL_VERSION} is not in KNOWN_PROTOCOL_VERSIONS={KNOWN_PROTOCOL_VERSIONS}. " +
"If this bump is intentional, add it to KNOWN_PROTOCOL_VERSIONS."
f"PROTOCOL_VERSION={PROTOCOL_VERSION} is not in KNOWN_PROTOCOL_VERSIONS={KNOWN_PROTOCOL_VERSIONS}. "
+ "If this bump is intentional, add it to KNOWN_PROTOCOL_VERSIONS."
)
def test_protocol_version_matches_latest_known(self):
assert PROTOCOL_VERSION == LATEST_KNOWN, (
"Test invariant: PROTOCOL_VERSION must equal max(KNOWN_PROTOCOL_VERSIONS)."
)
assert PROTOCOL_VERSION == LATEST_KNOWN, "Test invariant: PROTOCOL_VERSION must equal max(KNOWN_PROTOCOL_VERSIONS)."
class TestOpaquePerBrandFlags(OpenpilotTestCase):
@@ -76,6 +111,66 @@ class TestOpaquePerBrandFlags(OpenpilotTestCase):
assert caps["hyundai_alpha_long_available"] is False
class TestToyotaVirtualCruiseSpeedCapability(OpenpilotTestCase):
def test_field_present_and_labeled(self):
assert "toyota_virtual_cruise_speed_available" in CAPABILITY_FIELDS
assert "toyota_virtual_cruise_speed_available" in CAPABILITY_LABELS
def test_default_false(self):
caps = generate_capabilities(cast(Params, FakeParams()))
assert caps["toyota_virtual_cruise_speed_available"] is False
@parameterized.expand(
(
(TOYOTA_CAR.TOYOTA_COROLLA_TSS2, True),
(TOYOTA_CAR.TOYOTA_PRIUS_TSS2, True),
(TOYOTA_CAR.TOYOTA_RAV4_TSS2, False),
)
)
def test_bundle_platform_gating(self, platform, expected):
params = FakeParams(
{
"CarPlatformBundle": {
"brand": "toyota",
"platform": str(platform),
},
}
)
caps = generate_capabilities(cast(Params, params))
assert caps["toyota_virtual_cruise_speed_available"] is expected
@parameterized.expand(
(
(TOYOTA_CAR.TOYOTA_COROLLA_TSS2, ToyotaFlagsSP.VIRTUAL_CRUISE_SPEED_AVAILABLE, True),
(TOYOTA_CAR.TOYOTA_COROLLA_TSS2, 0, True),
(TOYOTA_CAR.TOYOTA_PRIUS_TSS2, ToyotaFlagsSP.VIRTUAL_CRUISE_SPEED_AVAILABLE, True),
(TOYOTA_CAR.TOYOTA_PRIUS_TSS2, 0, True),
(TOYOTA_CAR.TOYOTA_RAV4_TSS2, ToyotaFlagsSP.VIRTUAL_CRUISE_SPEED_AVAILABLE, False),
)
)
def test_persistent_car_params_platform_gating(self, platform, sp_flags, expected):
caps = generate_capabilities(build_persistent_toyota_params(platform, sp_flags=sp_flags))
assert caps["toyota_virtual_cruise_speed_available"] is expected
@parameterized.expand(
(
(TOYOTA_CAR.TOYOTA_COROLLA_TSS2, TOYOTA_CAR.TOYOTA_RAV4_TSS2, True),
(TOYOTA_CAR.TOYOTA_PRIUS_TSS2, TOYOTA_CAR.TOYOTA_RAV4_TSS2, True),
(TOYOTA_CAR.TOYOTA_RAV4_TSS2, TOYOTA_CAR.TOYOTA_COROLLA_TSS2, False),
(TOYOTA_CAR.TOYOTA_RAV4_TSS2, TOYOTA_CAR.TOYOTA_PRIUS_TSS2, False),
)
)
def test_bundle_platform_takes_precedence_over_stale_persistent_params(self, bundle_platform, persistent_platform, expected):
params = build_persistent_toyota_params(persistent_platform, sp_flags=ToyotaFlagsSP.VIRTUAL_CRUISE_SPEED_AVAILABLE)
params.values["CarPlatformBundle"] = {
"brand": "toyota",
"platform": str(bundle_platform),
}
caps = generate_capabilities(params)
assert caps["toyota_virtual_cruise_speed_available"] is expected
class TestCapabilitiesShape(OpenpilotTestCase):
def test_all_fields_present(self, caps):
for field in CAPABILITY_FIELDS:
@@ -9,6 +9,7 @@ isolates one of the gating bugs that the design-overhaul branch fixes so a
future regression is loud and obvious. These tests are intentionally narrow
and additive they do not replace the broader test_settings_schema.py.
"""
from __future__ import annotations
import json
@@ -24,14 +25,13 @@ from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import (
_load_torque_versions,
generate_schema,
)
from openpilot.sunnypilot.sunnylink.tools.validate_settings_ui import validate as validate_settings_ui
from openpilot.common.test import OpenpilotTestCase
SCHEMA_VALIDATOR_PATH = os.path.join(os.path.dirname(DEFINITION_PATH), "settings_ui.schema.json")
def _walk_items(schema: dict[str, Any]):
"""Yield every item dict from the schema."""
def _yield(item: dict[str, Any]):
yield item
for sub in item.get("sub_items", []):
@@ -106,6 +106,26 @@ def _references_capability_field(rules: list[dict[str, Any]] | None, field: str)
return found
def _has_toyota_virtual_cruise_gate(rules: list[dict[str, Any]] | None) -> bool:
def _walk(rule: dict[str, Any]) -> bool:
if rule.get("type") == "all":
conditions = rule.get("conditions", [])
has_capability = any(
c.get("type") == "capability" and c.get("field") == "toyota_virtual_cruise_speed_available" and c.get("equals") is True for c in conditions
)
has_param = any(c.get("type") == "param" and c.get("key") == "ToyotaVirtualCruiseSpeed" and c.get("equals") is True for c in conditions)
if has_capability and has_param:
return True
if rule.get("type") == "not" and "condition" in rule:
return _walk(rule["condition"])
if rule.get("type") in ("any", "all"):
return any(_walk(c) for c in rule.get("conditions", []))
return False
return any(_walk(rule) for rule in rules or [])
def schema():
return generate_schema()
@@ -149,22 +169,13 @@ class TestTestManeuversSection(OpenpilotTestCase):
assert "is_sp_release" in vis_refs
enablement = section.get("enablement") or []
enable_refs = json.dumps(enablement)
assert "ShowAdvancedControls" in enable_refs, \
"test_maneuvers must gate ShowAdvancedControls via enablement"
assert "ShowAdvancedControls" in enable_refs, "test_maneuvers must gate ShowAdvancedControls via enablement"
class TestValidator(OpenpilotTestCase):
def test_validator_accepts_real_json(self):
"""settings_ui.json validates against settings_ui.schema.json."""
try:
import jsonschema
except ImportError:
self.skipTest("jsonschema not installed")
with open(DEFINITION_PATH) as f:
data = json.load(f)
with open(SCHEMA_VALIDATOR_PATH) as f:
validator = json.load(f)
jsonschema.validate(instance=data, schema=validator)
"""settings_ui.json passes the repository's production schema validator."""
self.assertTrue(validate_settings_ui(DEFINITION_PATH))
class TestTorqueOptionGeneration(OpenpilotTestCase):
@@ -177,16 +188,17 @@ class TestTorqueOptionGeneration(OpenpilotTestCase):
assert item.get("options") == expected
def test_torque_versions_path_resolves(self):
assert os.path.exists(TORQUE_VERSIONS_PATH), (
f"latcontrol_torque_versions.json not found at {TORQUE_VERSIONS_PATH}"
)
assert os.path.exists(TORQUE_VERSIONS_PATH), f"latcontrol_torque_versions.json not found at {TORQUE_VERSIONS_PATH}"
class TestReleaseBranchGates(OpenpilotTestCase):
@parameterized.expand([
"EnableGithubRunner",
"QuickBootToggle",
], names=["key"])
@parameterized.expand(
[
"EnableGithubRunner",
"QuickBootToggle",
],
names=["key"],
)
def test_sp_dev_items_gate_on_is_sp_release(self, schema, key):
"""sunnypilot dev items must hide on sunnypilot release branches (is_sp_release gate)."""
item = _find_item(schema, key)
@@ -208,11 +220,14 @@ class TestSpuriousOffroadGatesDropped(OpenpilotTestCase):
class TestNotEngagedReplacement(OpenpilotTestCase):
@parameterized.expand([
"AlphaLongitudinalEnabled",
"ToyotaEnforceStockLongitudinal",
"ToyotaStopAndGoHack",
], names=["key"])
@parameterized.expand(
[
"AlphaLongitudinalEnabled",
"ToyotaEnforceStockLongitudinal",
"ToyotaStopAndGoHack",
],
names=["key"],
)
def test_offroad_only_replaced_with_not_engaged(self, schema, key):
"""These items should use not_engaged, not offroad_only."""
item = _find_item(schema, key)
@@ -220,3 +235,25 @@ class TestNotEngagedReplacement(OpenpilotTestCase):
rule_types = _flatten_rule_types(item.get("enablement"))
assert "offroad_only" not in rule_types, f"{key} still uses offroad_only"
assert "not_engaged" in rule_types, f"{key} missing not_engaged"
class TestToyotaVirtualCruiseSpeed(OpenpilotTestCase):
def test_vehicle_toggle_contract(self, schema):
toyota = schema["vehicle_settings"]["toyota"]
item = next((item for item in toyota["items"] if item.get("key") == "ToyotaVirtualCruiseSpeed"), None)
assert item is not None
assert item["widget"] == "toggle"
assert item.get("needs_onroad_cycle") is True
assert _references_capability_field(item.get("visibility"), "toyota_virtual_cruise_speed_available")
assert _references_capability_field(item.get("enablement"), "has_longitudinal_control")
assert "not_engaged" in _flatten_rule_types(item.get("enablement"))
def test_custom_acc_section_links_virtual_cruise_opt_in(self, schema):
section = _find_section(schema, "cruise", "custom_acc_increments")
assert section is not None
assert _has_toyota_virtual_cruise_gate(section.get("enablement"))
item = _find_item(schema, "CustomAccIncrementsEnabled")
assert item is not None
assert _has_toyota_virtual_cruise_gate(item.get("enablement"))
@@ -276,16 +276,40 @@ class TestKnownPanels(OpenpilotTestCase):
enhanced_enable_keys = {r.get("key") for r in enhanced.get("enablement", []) if r.get("type") == "param"}
assert "NeuralNetworkLateralControl" in enhanced_enable_keys
def test_accel_controller_profile_mapping_and_enablement(self, schema):
cruise = next(p for p in schema["panels"] if p["id"] == "cruise")
items = {item["key"]: item for item in _iter_panel_items(cruise)}
assert items["AccelPersonalityEnabled"]["widget"] == "toggle"
assert items["AccelPersonality"]["options"] == [
{"value": 0, "label": "Eco"},
{"value": 1, "label": "Normal"},
{"value": 2, "label": "Sport"},
]
assert {
"type": "capability",
"field": "has_longitudinal_control",
"equals": True,
} in items["AccelPersonalityEnabled"]["enablement"]
assert {
"type": "capability",
"field": "has_longitudinal_control",
"equals": True,
} in items["AccelPersonality"]["enablement"]
profile_enable_keys = {rule.get("key") for rule in items["AccelPersonality"]["enablement"] if rule.get("type") == "param"}
assert "AccelPersonalityEnabled" not in profile_enable_keys
class TestKnownVehicleSettings(OpenpilotTestCase):
def test_hyundai_has_longitudinal_tuning(self, schema):
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("hyundai"))}
assert "HyundaiLongitudinalTuning" in keys
def test_toyota_has_enforce_stock_and_stop_go(self, schema):
def test_toyota_has_enforce_stock_stop_go_and_virtual_cruise(self, schema):
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("toyota"))}
assert "ToyotaEnforceStockLongitudinal" in keys
assert "ToyotaStopAndGoHack" in keys
assert "ToyotaVirtualCruiseSpeed" in keys
def test_tesla_has_coop_steering(self, schema):
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("tesla"))}
+18 -1
View File
@@ -45,8 +45,9 @@ class ScrollState(Enum):
class GuiScrollPanel2:
def __init__(self, horizontal: bool = True) -> None:
def __init__(self, horizontal: bool = True, handle_out_of_bounds: bool = True) -> None:
self._horizontal = horizontal
self._handle_out_of_bounds = handle_out_of_bounds
self._state = ScrollState.STEADY
self._offset: rl.Vector2 = rl.Vector2(0, 0)
self._initial_click_event: MouseEvent | None = None
@@ -85,6 +86,20 @@ class GuiScrollPanel2:
"""Returns (max_offset, min_offset) for the given bounds and content size."""
return 0.0, min(0.0, bounds_size - content_size)
def _clamp_offset(self, bounds_size: float, content_size: float) -> None:
if self._handle_out_of_bounds:
return
max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size)
offset = self.get_offset()
clamped_offset = max(min_offset, min(max_offset, offset))
if clamped_offset == offset:
return
self.set_offset(clamped_offset)
if (clamped_offset == max_offset and self._velocity > 0) or (clamped_offset == min_offset and self._velocity < 0):
self._velocity = 0.0
def _update_state(self, bounds_size: float, content_size: float, snap_target: float | None) -> None:
"""Runs per render frame, independent of mouse events. Updates auto-scrolling state and velocity."""
max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size)
@@ -138,6 +153,8 @@ class GuiScrollPanel2:
factor = 1.0 - math.exp(-SNAP_RATE * dt)
self.set_offset(self.get_offset() + dist * factor)
self._clamp_offset(bounds_size, content_size)
def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, bounds_size: float,
content_size: float) -> None:
max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size)
+10 -3
View File
@@ -75,7 +75,6 @@ class _Scroller(Widget):
self._items: list[Widget] = []
self._horizontal = horizontal
self._snap_items = snap_items
assert not self._snap_items or self._horizontal, "Snapping is only supported for horizontal scrolling"
self._spacing = spacing
self._pad = pad
@@ -191,12 +190,20 @@ class _Scroller(Widget):
snap_target: float | None = None
if self._snap_items and visible_items and self._scrolling_to[0] is None:
# TODO: this doesn't handle two small buttons at the edges well
center_pos = self._rect.x + self._rect.width / 2
closest_delta_pos = min((((item.rect.x + item.rect.width / 2) - center_pos) for item in visible_items), key=abs)
center_pos = (self._rect.x + self._rect.width / 2) if self._horizontal else (self._rect.y + self._rect.height / 2)
closest_delta_pos = min(
(self._item_center_pos(item) - center_pos for item in visible_items),
key=abs,
)
snap_target = self.scroll_panel.get_offset() - closest_delta_pos
return self.scroll_panel.update(self._rect, content_size, snap_target=snap_target)
def _item_center_pos(self, item: Widget) -> float:
if self._horizontal:
return item.rect.x + item.rect.width / 2
return item.rect.y + item.rect.height / 2
@property
def moving_items(self) -> bool:
return len(self._move_animations) > 0 or len(self._move_lift) > 0