Compare commits

...

31 Commits

Author SHA1 Message Date
rav4kumar ad9ac9ae6c Keep real radar leads under ACC authority 2026-07-18 13:44:34 -07:00
rav4kumar d58fe4c12b Smooth radar ACC handoffs and stop departures 2026-07-18 13:39:36 -07:00
rav4kumar 2166414e9d Temper high-speed acceleration for smoother cruising 2026-07-18 09:33:59 -07:00
rav4kumar bab628da90 Keep stock force-deceleration flow intact 2026-07-18 09:33:58 -07:00
rav4kumar 5c6d189e7e Finalize safe pre-MPC Accel Controller 2026-07-18 09:33:48 -07:00
rav4kumar a90286b4a5 Prevent premature stop-hold release 2026-07-18 00:02:57 -07:00
rav4kumar 41cfac46d7 Prevent pace handoff lurches without damping acceleration 2026-07-17 18:57:19 -07:00
rav4kumar cae47a6251 Cover low-speed mode changes without MPC faults 2026-07-17 14:37:17 -07:00
rav4kumar 828f36210c Prevent solver faults during early mode transitions 2026-07-17 14:33:17 -07:00
rav4kumar df61e0da78 Make longitudinal pacing responsive without sacrificing smoothness 2026-07-17 14:32:22 -07:00
rav4kumar 9a15cfadae Make acceleration safety logic easier to audit 2026-07-17 01:17:22 -07:00
rav4kumar 0cf8af572e Prevent launch hesitation and lead-handoff oscillation 2026-07-17 01:04:49 -07:00
rav4kumar 1aa85675d1 Smooth slow-lead braking handoffs 2026-07-16 14:34:11 -07:00
rav4kumar 1dc2ed7901 Deliver prompt takeoffs without sacrificing smooth lead approaches 2026-07-16 13:23:06 -07:00
rav4kumar 52d7dd58a7 Keep radar lead response predictable under ACC 2026-07-16 13:21:22 -07:00
rav4kumar b1039ef1c3 Make DEC reliably complete model-predicted stops 2026-07-16 13:08:12 -07:00
rav4kumar 052a3a0ebf Avoid profile jerk by shaping feasible MPC plans 2026-07-15 14:31:59 -07:00
rav4kumar 8fbd9a93cf ref 2026-07-15 14:07:17 -07:00
rav4kumar 09abbe1f28 Make accel profiles shape MPC cruise response 2026-07-15 13:39:15 -07:00
rav4kumar 7133e04e1f accel control make lead approaches earlier without replacing stock safety control 2026-07-15 12:39:26 -07:00
rav4kumar ffb7bbbbc4 ref 2026-06-09 12:41:01 -07:00
rav4kumar 83de89e253 feat(dec): rework dynamic experimental controller 2026-06-06 11:45:24 -07:00
rav4kumar 04224e8747 Add custom params to sunnylink settings 2026-06-06 10:31:17 -07:00
rav4kumar 6012ebb7c7 ref 2026-06-06 10:30:46 -07:00
rav4kumar e91dbe351e ref 2026-06-05 11:29:37 -07:00
rav4kumar e25061fd08 fix mapd scorll 2026-06-05 11:28:47 -07:00
rav4kumar baf56ae324 feat/relc 2026-06-05 11:06:06 -07:00
rav4kumar 9badd3fa40 mici-sla-ui 2026-06-05 11:05:29 -07:00
rav4kumar 2220e7fc11 point the submodule 2026-06-05 11:04:53 -07:00
rav4kumar e862935209 toyota sp link and drive mode btn support 2026-06-05 11:04:49 -07:00
rav4kumar 9bd504a5cb abh, bsm 2026-06-05 11:04:43 -07:00
49 changed files with 4531 additions and 437 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/commaai/msgq.git
+45
View File
@@ -194,6 +194,7 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 {
aTarget @5 :Float32;
events @6 :List(OnroadEventSP.Event);
e2eAlerts @7 :E2eAlerts;
accelController @8 :AccelController;
struct DynamicExperimentalControl {
state @0 :DynamicExperimentalControlState;
@@ -296,6 +297,47 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 {
greenLightAlert @0 :Bool;
leadDepartAlert @1 :Bool;
}
struct AccelController {
enabled @0 :Bool;
active @1 :Bool;
shadowOnly @2 :Bool;
profile @3 :Profile;
state @4 :State;
vTargetBase @5 :Float32;
vTargetRaw @6 :Float32;
vTargetFiltered @7 :Float32;
vTargetShadow @8 :Float32;
leadIndex @9 :Int8 = -1;
usableGap @10 :Float32;
closingSpeed @11 :Float32;
requiredDecel @12 :Float32;
aMaxProfile @13 :Float32;
aMaxEffective @14 :Float32;
enum Profile {
eco @0;
normal @1;
sport @2;
}
enum State {
inactive @0;
free @1;
restrict @2;
hold @3;
release @4;
stopHold @5;
}
}
# Compatibility type for vehicle integrations that map physical drive modes
# onto AccelPersonality. New controller telemetry uses AccelController.Profile.
enum AccelerationPersonality {
eco @0;
normal @1;
sport @2;
}
}
struct OnroadEventSP @0xda96579883444c35 {
@@ -342,6 +384,7 @@ struct OnroadEventSP @0xda96579883444c35 {
speedLimitChanged @21;
speedLimitPending @22;
e2eChime @23;
laneChangeRoadEdge @24;
}
}
@@ -448,6 +491,8 @@ struct LiveMapDataSP @0xf416ec09499d9d19 {
struct ModelDataV2SP @0xa1680744031fdb2d {
laneTurnDirection @0 :TurnDirection;
leftLaneChangeEdgeBlock @1 :Bool;
rightLaneChangeEdgeBlock @2 :Bool;
enum TurnDirection {
none @0;
+11
View File
@@ -179,12 +179,19 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"QuickBootToggle", {PERSISTENT | BACKUP, BOOL, "0"}},
{"QuietMode", {PERSISTENT | BACKUP, BOOL, "0"}},
{"RainbowMode", {PERSISTENT | BACKUP, BOOL, "0"}},
{"RoadEdgeLaneChangeEnabled", {PERSISTENT | BACKUP, BOOL, "0"}},
{"RocketFuel", {PERSISTENT | BACKUP, BOOL, "0"}},
{"ShowAdvancedControls", {PERSISTENT | BACKUP, BOOL, "0"}},
{"ShowTurnSignals", {PERSISTENT | BACKUP, BOOL, "0"}},
{"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"}},
@@ -228,6 +235,10 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"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
@@ -112,12 +112,16 @@ class TestParams:
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("LiveParameters")
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("LiveParameters") is None
assert self.params.get("LiveParameters", return_default=True) is None
+7 -1
View File
@@ -10,7 +10,7 @@ from cereal import car, log, custom
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.safety import ALTERNATIVE_EXPERIENCE
from opendbc.car import DT_CTRL, structs
from opendbc.car.can_definitions import CanData, CanRecvCallable, CanSendCallable
from opendbc.car.carlog import carlog
@@ -121,7 +121,13 @@ class Car:
self.CI, self.CP, self.CP_SP = CI, CI.CP, CI.CP_SP
self.RI = RI
# set alternative experiences from parameters
sp_toyota_auto_brake_hold = self.params.get_bool("ToyotaAutoHold")
self.CP.alternativeExperience = 0
if sp_toyota_auto_brake_hold:
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)
+3 -3
View File
@@ -56,7 +56,7 @@ class DesireHelper:
def get_lane_change_direction(CS):
return LaneChangeDirection.left if CS.leftBlinker else LaneChangeDirection.right
def update(self, carstate, lateral_active, lane_change_prob):
def update(self, carstate, lateral_active, lane_change_prob, left_edge_detected=False, right_edge_detected=False):
self.alc.update_params()
self.lane_turn_controller.update_params()
v_ego = carstate.vEgo
@@ -88,8 +88,8 @@ class DesireHelper:
((carstate.steeringTorque > 0 and self.lane_change_direction == LaneChangeDirection.left) or
(carstate.steeringTorque < 0 and self.lane_change_direction == LaneChangeDirection.right))
blindspot_detected = ((carstate.leftBlindspot and self.lane_change_direction == LaneChangeDirection.left) or
(carstate.rightBlindspot and self.lane_change_direction == LaneChangeDirection.right))
blindspot_detected = (((carstate.leftBlindspot or left_edge_detected) and self.lane_change_direction == LaneChangeDirection.left) or
((carstate.rightBlindspot or right_edge_detected) and self.lane_change_direction == LaneChangeDirection.right))
self.alc.update_lane_change(blindspot_detected, carstate.brakePressed)
@@ -217,6 +217,7 @@ class LongitudinalMpc:
def __init__(self, dt=DT_MDL):
self.dt = dt
self.solver = AcadosOcpSolverCython(MODEL_NAME, ACADOS_SOLVER_TYPE, N)
self.last_solution_status = 0
self.reset()
self.source = LongitudinalPlanSource.cruise
@@ -313,7 +314,8 @@ class LongitudinalMpc:
lead_xv = self.extrapolate_lead(x_lead, v_lead, a_lead, a_lead_tau)
return lead_xv
def update(self, radarstate, v_cruise, personality=log.LongitudinalPersonality.standard):
def update(self, radarstate, v_cruise, personality=log.LongitudinalPersonality.standard,
accel_max: float | tuple[float, ...] | np.ndarray | None = None):
t_follow = get_T_FOLLOW(personality)
v_ego = self.x0[1]
self.status = radarstate.leadOne.status or radarstate.leadTwo.status
@@ -345,6 +347,17 @@ class LongitudinalMpc:
self.params[:,0] = ACCEL_MIN
self.params[:,1] = ACCEL_MAX
if accel_max is not None:
try:
accel_max_trajectory = np.asarray(accel_max, dtype=float)
except (TypeError, ValueError):
accel_max_trajectory = np.empty(0)
if accel_max_trajectory.ndim == 0 and np.isfinite(accel_max_trajectory) and accel_max_trajectory >= 0.0:
accel_max_trajectory = np.full(N + 1, float(accel_max_trajectory))
valid_accel_max = accel_max_trajectory.shape == (N + 1,) and np.all(np.isfinite(accel_max_trajectory))
if valid_accel_max:
self.params[:,1] = np.clip(accel_max_trajectory, ACCEL_MIN, ACCEL_MAX)
self.params[0,1] = max(self.params[0,1], float(np.clip(self.x0[2], ACCEL_MIN, ACCEL_MAX)))
self.params[:,2] = np.min(x_obstacles, axis=1)
self.params[:,3] = np.copy(self.a_prev)
self.params[:,4] = t_follow
@@ -364,6 +377,7 @@ class LongitudinalMpc:
self.solver.constraints_set(0, "ubx", self.x0)
self.solution_status = self.solver.solve()
self.last_solution_status = self.solution_status
self.solve_time = float(self.solver.get_stats('time_tot')[0])
self.time_qp_solution = float(self.solver.get_stats('time_qp')[0])
self.time_linearization = float(self.solver.get_stats('time_lin')[0])
+15 -8
View File
@@ -51,7 +51,7 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
def __init__(self, CP, CP_SP, init_v=0.0, init_a=0.0, dt=DT_MDL):
self.CP = CP
self.mpc = LongitudinalMpc(dt=dt)
LongitudinalPlannerSP.__init__(self, self.CP, CP_SP, self.mpc)
LongitudinalPlannerSP.__init__(self, self.CP, CP_SP, self.mpc, dt=dt)
self.fcw = False
self.dt = dt
self.allow_throttle = True
@@ -113,6 +113,7 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
accel_clip = [ACCEL_MIN, get_max_accel(v_ego)]
steer_angle_without_offset = sm['carState'].steeringAngleDeg - sm['liveParameters'].angleOffsetDeg
accel_clip = limit_accel_in_turns(v_ego, steer_angle_without_offset, accel_clip, self.CP)
profile_accel_clip = limit_accel_in_turns(v_ego, steer_angle_without_offset, [ACCEL_MIN, ACCEL_MAX], self.CP)
if reset_state:
self.v_desired_filter.x = v_ego
@@ -129,16 +130,21 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
clipped_accel_coast = max(accel_coast, accel_clip[0])
clipped_accel_coast_interp = np.interp(v_ego, [MIN_ALLOW_THROTTLE_SPEED, MIN_ALLOW_THROTTLE_SPEED*2], [accel_clip[1], clipped_accel_coast])
accel_clip[1] = min(accel_clip[1], clipped_accel_coast_interp)
controller_accel_max = profile_accel_clip[1] if self.allow_throttle else 0.0
# Get new v_cruise and a_desired from Smart Cruise Control and Speed Limit Assist
previous_output_a_target = self.output_a_target
v_cruise, self.a_desired = LongitudinalPlannerSP.update_targets(self, sm, self.v_desired_filter.x, self.a_desired, v_cruise)
base_v_cruise = v_cruise
if force_slow_decel:
v_cruise = 0.0
self.mpc.set_weights(prev_accel_constraint, personality=sm['selfdriveState'].personality)
self.mpc.set_cur_state(self.v_desired_filter.x, self.a_desired)
self.mpc.update(sm['radarState'], v_cruise, personality=sm['selfdriveState'].personality)
is_e2e = LongitudinalPlannerSP.update_accel_controller_mpc(
self, sm, base_v_cruise, v_cruise, prev_accel_constraint, reset_state=reset_state,
cruise_initialized=v_cruise_initialized, planner_accel=self.a_desired, previous_output_accel=previous_output_a_target,
available_accel_max=controller_accel_max, previous_should_stop=self.output_should_stop, force_decel=force_slow_decel,
)
self.v_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.v_solution)
self.a_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.a_solution)
@@ -154,13 +160,14 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
self.a_desired = float(np.interp(self.dt, CONTROL_N_T_IDX, self.a_desired_trajectory))
self.v_desired_filter.x = self.v_desired_filter.x + self.dt * (self.a_desired + a_prev) / 2.0
action_t = self.CP.longitudinalActuatorDelay + DT_MDL
output_a_target_mpc, output_should_stop_mpc = get_accel_from_plan(self.v_desired_trajectory, self.a_desired_trajectory, CONTROL_N_T_IDX,
action_t=action_t, vEgoStopping=self.CP.vEgoStopping)
action_t = self.CP.longitudinalActuatorDelay + DT_MDL
output_a_target_mpc, output_should_stop_mpc = get_accel_from_plan(
self.v_desired_trajectory, self.a_desired_trajectory, CONTROL_N_T_IDX, action_t=action_t, vEgoStopping=self.CP.vEgoStopping,
)
output_a_target_e2e = sm['modelV2'].action.desiredAcceleration
output_should_stop_e2e = sm['modelV2'].action.shouldStop
if self.is_e2e(sm):
if is_e2e:
output_a_target = min(output_a_target_e2e, output_a_target_mpc)
self.output_should_stop = output_should_stop_e2e or output_should_stop_mpc
if output_a_target < output_a_target_mpc:
+8 -1
View File
@@ -321,9 +321,16 @@ class SelfdriveD(CruiseHelper):
# Handle lane change
if self.sm['modelV2'].meta.laneChangeState == LaneChangeState.preLaneChange:
direction = self.sm['modelV2'].meta.laneChangeDirection
mdv2sp = self.sm['modelDataV2SP']
if (CS.leftBlindspot and direction == LaneChangeDirection.left) or \
(CS.rightBlindspot and direction == LaneChangeDirection.right):
(CS.rightBlindspot and direction == LaneChangeDirection.right):
self.events.add(EventName.laneChangeBlocked)
elif (mdv2sp.leftLaneChangeEdgeBlock and direction == LaneChangeDirection.left) or \
(mdv2sp.rightLaneChangeEdgeBlock and direction == LaneChangeDirection.right):
self.events_sp.add(custom.OnroadEventSP.EventName.laneChangeRoadEdge)
else:
if direction == LaneChangeDirection.left:
self.events.add(EventName.preLaneChangeLeft)
+292 -47
View File
@@ -1,5 +1,11 @@
#!/usr/bin/env python3
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 cereal import log
@@ -11,12 +17,105 @@ from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPl
from openpilot.selfdrive.controls.radard import _LEAD_ACCEL_TAU
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")
# Route-derived conservative Prius TSS2 stress model for the acceleration-controller
# regression suite. The 1.0 m/s² gate represents prompt takeoffs, not a universal
# physical threshold: the supplied routes also contain low-command creep departures.
# This models vehicle response only and does not emulate Toyota's CAN controller.
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 Plant:
messaging_initialized = False
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):
self.rate = 1. / DT_MDL
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,
):
"""Closed-loop longitudinal planner plant.
``lead_observation_fn(time, lead_name, truth)`` may return a complete or partial
observed LeadData mapping, or ``None`` for an absent lead. It is called separately
for ``leadOne`` and ``leadTwo``. The supplied truth mapping is a copy, and observed
values never affect the physical lead trajectory.
``model_action_fn(time, v_ego, a_ego)`` returns
``(desired_acceleration, should_stop)``.
``ego_observation_fn(time, true_v_ego, true_a_ego)`` returns the observed
``(v_ego, a_ego)`` published in ``carState``. It can inject measurement noise
without changing the physical plant state.
Passing ``actuator_delay`` both overrides ``CP.longitudinalActuatorDelay`` and
adds the corresponding command transport delay to the plant. ``None`` keeps the
historical Honda planner delay with instantaneous plant response. ``actuator_lag``
is an optional first-order acceleration-response time constant. Both defaults keep
historical plant dynamics unchanged.
``actuator_model`` opts into a staged vehicle-response model. Its planner delay
is used by MPC, while its independent transport delay is used by the command
queue before rate limiting, standstill breakaway confirmation, and first-order
lag. Leaving it unset preserves the historical actuator path.
"""
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')
@@ -28,10 +127,15 @@ class Plant:
self.v_lead_prev = 0.0
self.distance = 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
@@ -42,9 +146,18 @@ class Plant:
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))
self.rk = Ratekeeper(self.rate, print_delay_threshold=100.0)
self.ts = 1. / self.rate
self.ts = 1.0 / self.rate
time.sleep(0.1)
self.sm = messaging.SubMaster(['longitudinalPlan'])
@@ -52,14 +165,86 @@ class Plant:
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)
if self.actuator_model is not None and self.speed >= 0.01:
self.breakaway_confirmed = True
delay_steps = 0 if self.transport_delay is None else round(self.transport_delay / self.ts)
self._actuator_delay_queue = deque([self.acceleration] * delay_steps)
@property
def current_time(self):
return float(self.rk.frame) / self.rate
def step(self, v_lead=0.0, prob_lead=1.0, v_cruise=50., pitch=0.0, prob_throttle=1.0):
@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
# Partial overrides are convenient for individual sensor glitches, while copying
# from truth ensures every field written to cereal is deterministic.
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.ts
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.ts
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:
# Preserve the historical response path exactly when no staged model is used.
self.applied_actuator_command = delayed_command
response_command = delayed_command
if self.actuator_lag > 0.0:
alpha = 1.0 - math.exp(-self.ts / self.actuator_lag)
self.acceleration += alpha * (response_command - self.acceleration)
else:
self.acceleration = response_command
return delayed_command, self.acceleration
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')
@@ -72,39 +257,48 @@ class Plant:
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
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., self.distance_lead - self.distance)
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 > .5:
elif prob_lead > 0.5:
status = True
else:
status = False
else:
d_rel = 200.
v_rel = 0.
d_rel = 200.0
v_rel = 0.0
prob_lead = 0.0
status = False
lead = log.RadarState.LeadData.new_message()
lead.dRel = float(d_rel)
lead.yRel = 0.0
lead.vRel = float(v_rel)
lead.aRel = float(a_lead - self.acceleration)
lead.vLead = float(v_lead)
lead.vLeadK = float(v_lead)
lead.aLeadK = float(a_lead)
# TODO use real radard logic for this
lead.aLeadTau = float(_LEAD_ACCEL_TAU)
lead.status = status
lead.modelProb = float(prob_lead)
if not self.only_lead2:
radar.radarState.leadOne = lead
radar.radarState.leadTwo = lead
truth_lead: LeadObservation = {
"dRel": float(d_rel),
"yRel": 0.0,
"vRel": float(v_rel),
"aRel": float(a_lead - self.acceleration),
"vLead": float(v_lead),
"dPath": 0.0,
"vLat": 0.0,
"vLeadK": float(v_lead),
"aLeadK": float(a_lead),
"fcw": False,
"status": 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
@@ -112,10 +306,15 @@ class Plant:
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
model.modelV2.action.desiredAcceleration = float(self.acceleration + 0.1)
if self.model_action_fn is None:
model_acceleration, model_should_stop = self.acceleration + 0.1, 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
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)]
@@ -126,33 +325,45 @@ class Plant:
ss.selfdriveState.experimentalMode = self.e2e
ss.selfdriveState.personality = self.personality
control.controlsState.forceDecel = self.force_decel
car_state.carState.vEgo = float(self.speed)
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., float(pitch), 0.]
car_control.carControl.orientationNED = [0.0, float(pitch), 0.0]
# ******** get controlsState messages for plotting ***
sm = {'radarState': radar.radarState,
'carState': car_state.carState,
'carControl': car_control.carControl,
'controlsState': control.controlsState,
'selfdriveState': ss.selfdriveState,
'liveParameters': lp.liveParameters,
'modelV2': model.modelV2,
'carStateSP': car_state_sp.carStateSP,
'liveMapDataSP': live_map_data_sp.liveMapDataSP,
'gpsLocation': gps_data.gpsLocation}
sm = {
'radarState': radar.radarState,
'carState': car_state.carState,
'carControl': car_control.carControl,
'controlsState': control.controlsState,
'selfdriveState': ss.selfdriveState,
'liveParameters': lp.liveParameters,
'modelV2': model.modelV2,
'carStateSP': car_state_sp.carStateSP,
'liveMapDataSP': live_map_data_sp.liveMapDataSP,
'gpsLocation': gps_data.gpsLocation,
}
self.planner.update(sm)
self.acceleration = self.planner.output_a_target
self.a_target = self.planner.output_a_target
self.actuator_command = self.a_target
if self.planner.output_should_stop:
self.acceleration = min(-0.5, self.acceleration)
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)
delayed_actuator_command, _ = self._update_actuator(self.actuator_command)
self.speed = self.speed + self.acceleration * self.ts
self.should_stop = self.planner.output_should_stop
fcw = self.planner.fcw
self.distance_lead = self.distance_lead + v_lead * self.ts
# ******** run the car ********
#print(self.distance, speed)
# print(self.distance, speed)
if self.speed <= 0:
self.speed = 0
self.acceleration = 0
@@ -160,30 +371,64 @@ class Plant:
# *** radar model ***
if self.lead_relevancy:
d_rel = np.maximum(0., self.distance_lead - self.distance)
d_rel = np.maximum(0.0, self.distance_lead - self.distance)
v_rel = v_lead - self.speed
else:
d_rel = 200.
v_rel = 0.
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_result = getattr(self.planner, "accel_controller_result", None)
return {
"distance": self.distance,
"speed": self.speed,
"acceleration": self.acceleration,
"realized_acceleration": self.acceleration,
"a_target": self.a_target,
"planner_acceleration": self.a_target,
"actuator_command": self.actuator_command,
"stop_clamped_actuator_command": self.actuator_command,
"delayed_actuator_command": delayed_actuator_command,
"applied_actuator_command": self.applied_actuator_command,
"vehicle_actuator_command": self.applied_actuator_command,
"true_v_ego": true_v_ego,
"true_a_ego": true_a_ego,
"published_a_ego": published_a_ego,
"published_v_ego": published_v_ego,
"observed_a_ego": published_a_ego,
"observed_v_ego": published_v_ego,
"planner_delay": self.actuator_delay,
"transport_delay": self.transport_delay,
"breakaway_confirmed": self.breakaway_confirmed,
"breakaway_time": self._breakaway_timer,
"should_stop": self.should_stop,
"distance_lead": self.distance_lead,
"fcw": fcw,
"mpc_source": self.planner.mpc.source,
"dec_mode": self.planner.dec.mode(),
"pace_cap": getattr(accel_controller_result, "target_speed", None),
"base_target": getattr(accel_controller_result, "base_speed", None),
"raw_energy_cap": getattr(accel_controller_result, "raw_energy_cap", None),
"live_filtered_cap": getattr(accel_controller_result, "live_filtered_cap", None),
"shadow_filtered_cap": getattr(accel_controller_result, "shadow_filtered_cap", None),
"accel_controller_selected_lead": getattr(accel_controller_result, "selected_lead", None),
"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),
}
# simple engage in standalone mode
def plant_thread():
plant = Plant()
@@ -0,0 +1,80 @@
import math
import pytest
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.test.longitudinal_maneuvers.plant import Plant
def test_full_lead_observation_is_independent_from_truth():
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,
"status": True,
"modelProb": 0.9,
"radarTrackId": 42,
}
return None
plant = Plant(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"]
assert callback_inputs[0][2]["dRel"] == pytest.approx(50.0)
assert result["truth_lead"]["dRel"] == pytest.approx(50.0)
assert result["lead_one_observation"]["dRel"] == pytest.approx(12.5)
assert result["lead_one_observation"]["radarTrackId"] == 42
assert result["lead_two_observation"] is None
assert result["distance_lead"] == pytest.approx(50.0 + 8.0 * DT_MDL)
def test_model_action_realized_acceleration_and_source_logging():
def model_action(current_time, v_ego, a_ego):
return -1.25, True
plant = Plant(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}
assert first["published_a_ego"] == pytest.approx(0.0)
assert second["published_a_ego"] == pytest.approx(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 "pace_cap" in first
assert "raw_energy_cap" in first
assert "live_filtered_cap" in first
assert first["lead_one_observation"] is not None
assert first["truth_lead"] == first["lead_one_observation"]
def test_configurable_transport_delay_and_first_order_lag():
plant = Plant(speed=10.0, actuator_delay=2 * DT_MDL, actuator_lag=0.2)
assert plant.planner.CP.longitudinalActuatorDelay == pytest.approx(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
assert delayed_commands[2][1] == pytest.approx(expected_acceleration)
@pytest.mark.parametrize(
("delay", "lag"),
[(-0.1, 0.0), (float("nan"), 0.0), (float("inf"), 0.0), (None, -0.1), (None, float("nan")), (None, float("inf"))],
)
def test_invalid_actuator_dynamics(delay, lag):
with pytest.raises(ValueError):
Plant(actuator_delay=delay, actuator_lag=lag)
+43 -1
View File
@@ -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(
"Begin slowing early and smoothly behind lead vehicles. Stock longitudinal control retains braking and stopping authority."
),
"AccelPersonality": tr_noop(
"Eco slows earliest and recovers gently, Normal balances comfort and response, and Sport reacts and recovers more quickly."
),
"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_personality_enabled = toggle_item(
lambda: tr("Enable Accel Controller"),
lambda: tr(DESCRIPTIONS["AccelPersonalityEnabled"]),
self._params.get_bool("AccelPersonalityEnabled"),
callback=self._set_accel_personality_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_personality_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_personality_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_personality_enabled.action_item.set_enabled(True)
self._accel_personality_setting.action_item.set_enabled(accel_personality_enabled)
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_personality_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,10 @@ 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_personality_enabled.action_item.set_state(accel_personality_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 +282,10 @@ 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_personality_enabled(self, state: bool):
self._params.put_bool("AccelPersonalityEnabled", state, block=True)
self._accel_personality_setting.action_item.set_enabled(state and ui_state.has_longitudinal_control)
+5 -2
View File
@@ -13,6 +13,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.onroad import OnroadViewContainerSP as AugmentedRoadView
ONROAD_DELAY = 2.5 # seconds
@@ -118,13 +119,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):
@@ -14,6 +14,8 @@ class TogglesLayoutMici(NavScroller):
super().__init__()
self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"])
self._accel_personality_enabled = BigParamControl("enable accel controller", "AccelPersonalityEnabled")
self._accel_personality_toggle = BigMultiParamToggle("acceleration profile", "AccelPersonality", ["eco", "normal", "sport"])
self._experimental_btn = BigParamControl("experimental mode", "ExperimentalMode")
is_metric_toggle = BigParamControl("use metric units", "IsMetric")
ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled")
@@ -24,6 +26,8 @@ class TogglesLayoutMici(NavScroller):
self._scroller.add_widgets([
self._personality_toggle,
self._accel_personality_enabled,
self._accel_personality_toggle,
self._experimental_btn,
is_metric_toggle,
ldw_toggle,
@@ -36,6 +40,7 @@ class TogglesLayoutMici(NavScroller):
# Toggle lists
self._refresh_toggles = (
("ExperimentalMode", self._experimental_btn),
("AccelPersonalityEnabled", self._accel_personality_enabled),
("IsMetric", is_metric_toggle),
("IsLdwEnabled", ldw_toggle),
("AlwaysOnDM", always_on_dm_toggle),
@@ -45,6 +50,9 @@ class TogglesLayoutMici(NavScroller):
)
enable_openpilot.set_enabled(lambda: not ui_state.engaged)
self._accel_personality_toggle.set_enabled(
lambda: ui_state.has_longitudinal_control and ui_state.params.get_bool("AccelPersonalityEnabled")
)
record_front.set_enabled(False if ui_state.params.get_bool("RecordFrontLock") else (lambda: not ui_state.engaged))
record_mic.set_enabled(lambda: not ui_state.engaged)
@@ -75,13 +83,18 @@ class TogglesLayoutMici(NavScroller):
if ui_state.has_longitudinal_control:
self._experimental_btn.set_visible(True)
self._personality_toggle.set_visible(True)
self._accel_personality_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_personality_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()
+6 -1
View File
@@ -382,13 +382,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):
@@ -51,11 +51,17 @@ class LaneChangeSettingsLayout(Widget):
description=lambda: tr("Toggle to enable a delay timer for seamless lane changes when blind spot monitoring " +
"(BSM) detects a obstructing vehicle, ensuring safe maneuvering."),
)
self._road_edge_block = toggle_item_sp(
param="RoadEdgeLaneChangeEnabled",
title=lambda: tr("Block Lane Change: Road Edge Detection"),
description=lambda: tr("Blocks the lane change if the model sees a road edge on your signaled side."),
)
items = [
self._lane_change_timer,
LineSeparatorSP(40),
self._bsm_delay,
self._road_edge_block,
]
return items
@@ -0,0 +1,13 @@
"""
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
class MiciMainLayoutSP(MiciMainLayout):
def _should_auto_scroll_to_onroad(self) -> bool:
return not self._onroad_layout.is_on_info_panel()
@@ -0,0 +1,63 @@
"""
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 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, callback) -> None:
self.road_view.set_click_callback(callback)
self.onroad_info_panel.set_click_callback(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,324 @@
"""
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
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
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import MousePos
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
@dataclass(frozen=True)
class OnroadInfoPanelColors:
white: rl.Color = rl.WHITE
black: rl.Color = rl.BLACK
red: rl.Color = rl.Color(255, 0, 0, 255)
green: rl.Color = rl.Color(0, 255, 0, 255)
grey: rl.Color = rl.Color(190, 195, 190, 255)
light_grey: rl.Color = rl.Color(200, 200, 200, 255)
dark_grey: rl.Color = rl.Color(100, 100, 100, 255)
bg_dark: rl.Color = rl.Color(0, 0, 0, 255)
card_bg: rl.Color = rl.Color(50, 50, 50, 200)
badge_bg: rl.Color = 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)
margin = 20
mid_y = rect.y + rect.height / 2
left_x = rect.x + 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
speed_val = str(round(display_speed))
if self.speed_limit_valid and display_speed > self.speed_limit:
speed_color = COLORS.red
else:
speed_color = COLORS.white
rl.draw_text_ex(self._font_semi_bold, unit, rl.Vector2(left_x, mid_y - 95), 38, 0, COLORS.grey)
rl.draw_text_ex(self._font_bold, speed_val, rl.Vector2(left_x, mid_y - 60), 110, 0, speed_color)
sign_width = 135
sign_height = 135 if ui_state.is_metric else 175
has_next = self.next_speed_limit > 0 and self.next_speed_limit != self.speed_limit
target_slide = 1.0 if has_next else 0.0
slide_speed = 3.0 * rl.get_frame_time()
if self._sign_slide < target_slide:
self._sign_slide = min(self._sign_slide + slide_speed, target_slide)
elif self._sign_slide > target_slide:
self._sign_slide = max(self._sign_slide - slide_speed, target_slide)
next_w = int(sign_width * 0.7)
next_h = int(sign_height * 0.7)
next_peek = int(next_w * 0.85) + 5
centered_x = rect.x + rect.width - sign_width - margin
shifted_x = rect.x + rect.width - sign_width - margin - next_peek
sign_x = centered_x + (shifted_x - centered_x) * self._sign_slide
sign_y = rect.y + (rect.height - sign_height) / 2
road_y = mid_y + 55
road_width = sign_x - left_x - margin
self._draw_road_name(left_x, road_y, road_width)
if has_next and self._sign_slide > 0.01:
next_val = str(round(self.next_speed_limit))
dist_str = self._format_distance(self.next_speed_limit_distance)
next_x = sign_x + sign_width - int(next_w * 0.15)
next_y = sign_y + (sign_height - next_h) / 2
next_speed_color = COLORS.black
if ui_state.is_metric:
self._draw_vienna_sign(next_x, next_y, next_w, next_h, next_val, next_speed_color, is_upcoming=True)
else:
self._draw_mutcd_sign(next_x, next_y, next_w, next_h, next_val, next_speed_color, is_upcoming=True)
dist_size = measure_text_cached(self._font_medium, dist_str, 24)
rl.draw_text_ex(self._font_medium, dist_str, rl.Vector2(next_x + next_w / 2 - dist_size.x / 2, next_y + next_h + 4), 24, 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_val = str(abs(round(self.speed_limit_offset)))
badge_sz = 42
badge_x = sign_x + sign_width - badge_sz * 0.85
badge_y = sign_y - badge_sz * 0.25
if ui_state.is_metric:
badge_r = badge_sz / 2
badge_cx = badge_x + badge_r
badge_cy = badge_y + badge_r
rl.draw_circle(int(badge_cx), int(badge_cy), badge_r + 2, COLORS.dark_grey)
rl.draw_circle(int(badge_cx), int(badge_cy), badge_r, COLORS.badge_bg)
self._draw_text_centered(self._font_bold, offset_val, 24, rl.Vector2(badge_cx, badge_cy), COLORS.white)
else:
mutcd_badge_x = sign_x + sign_width - badge_sz * 0.65
mutcd_badge_y = sign_y - badge_sz * 0.50
badge_rect = rl.Rectangle(mutcd_badge_x, mutcd_badge_y, badge_sz, badge_sz)
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(self._font_bold, offset_val, 24, rl.Vector2(mutcd_badge_x + badge_sz / 2, mutcd_badge_y + badge_sz / 2), COLORS.white)
# SCC
speed_size = measure_text_cached(self._font_bold, speed_val, 110)
scc_x = left_x + speed_size.x + 30
scc_y = mid_y - 50
self._draw_scc_icons(scc_x, scc_y)
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) -> None:
sm = ui_state.sm
if not sm.valid["longitudinalPlanSP"]:
return
scc = sm["longitudinalPlanSP"].smartCruiseControl
box_w, box_h = 100, 36
gap = 6
drawn = 0
for label, active in [("SCC-V", scc.vision.active), ("SCC-M", scc.map.active)]:
if not active:
continue
bx = x
by = y + drawn * (box_h + gap)
rl.draw_rectangle_rounded(rl.Rectangle(bx, by, box_w, box_h), 0.3, 10, COLORS.green)
self._draw_text_centered(self._font_bold, label, 20, rl.Vector2(bx + box_w / 2, by + box_h / 2), COLORS.black)
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:
road_display = self.road_name if self.road_name else "--"
font_size = 30
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)
text_size = measure_text_cached(self._font_bold, speed_str, int(font_size))
text_pos = rl.Vector2(center.x - text_size.x / 2, center.y - text_size.y / 2)
rl.draw_text_ex(self._font_bold, speed_str, text_pos, font_size, 0, speed_color)
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(self._font_bold, tr("AHEAD"), label_size, rl.Vector2(mid_x, y + height * 0.27), COLORS.black)
else:
self._draw_text_centered(self._font_bold, tr("SPEED"), label_size, rl.Vector2(mid_x, y + height * 0.20), COLORS.black)
self._draw_text_centered(self._font_bold, tr("LIMIT"), label_size, rl.Vector2(mid_x, y + height * 0.40), COLORS.black)
speed_font_size = int(width * 0.52) if len(speed_str) >= 3 else int(width * 0.62)
self._draw_text_centered(self._font_bold, speed_str, speed_font_size, rl.Vector2(mid_x, y + height * 0.72), speed_color)
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 _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,30 @@
"""
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.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, rect: rl.Rectangle) -> None:
super()._render(rect)
if self._show_confidence_ball:
self._real_confidence_ball.render(self.rect)
@@ -0,0 +1,34 @@
"""
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):
"""Reject orthogonal-dominant drags so nested scrollers (outer horizontal +
inner vertical) don't both engage on a slightly diagonal swipe.
Implemented as a post-super state rollback rather than reimplementing the
PRESSED state machine — keeps stock behaviour authoritative."""
def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, bounds_size: float,
content_size: float) -> None:
pre_state = self._state
super()._handle_mouse_event(mouse_event, bounds, bounds_size, content_size)
if self._state == ScrollState.MANUAL_SCROLL and pre_state == ScrollState.PRESSED and \
self._initial_click_event is not None:
diff_x = abs(mouse_event.pos.x - self._initial_click_event.pos.x)
diff_y = abs(mouse_event.pos.y - self._initial_click_event.pos.y)
along = diff_x if self._horizontal else diff_y
anti = diff_y if self._horizontal else diff_x
if anti > along:
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()
+6 -1
View File
@@ -40,6 +40,7 @@ from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelp
from openpilot.sunnypilot.livedelay.helpers import get_lat_delay
from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase
from openpilot.sunnypilot.models.helpers import get_active_bundle
from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController
PROCESS_NAME = "selfdrive.modeld.modeld_tinygrad"
@@ -329,6 +330,7 @@ def main(demo=False):
prev_action = log.ModelDataV2.Action()
DH = DesireHelper()
RELC = RoadEdgeLaneChangeController(DH)
meta_constants = load_meta_constants()
while True:
@@ -433,7 +435,10 @@ def main(demo=False):
l_lane_change_prob = desire_state[log.Desire.laneChangeLeft]
r_lane_change_prob = desire_state[log.Desire.laneChangeRight]
lane_change_prob = l_lane_change_prob + r_lane_change_prob
DH.update(sm['carState'], sm['carControl'].latActive, lane_change_prob)
RELC.update(modelv2_send.modelV2.roadEdgeStds, modelv2_send.modelV2.laneLineProbs, v_ego)
mdv2sp_send.modelDataV2SP.leftLaneChangeEdgeBlock = RELC.left_edge_detected
mdv2sp_send.modelDataV2SP.rightLaneChangeEdgeBlock = RELC.right_edge_detected
DH.update(sm['carState'], sm['carControl'].latActive, lane_change_prob, RELC.left_edge_detected, RELC.right_edge_detected)
modelv2_send.modelV2.meta.laneChangeState = DH.lane_change_state
modelv2_send.modelV2.meta.laneChangeDirection = DH.lane_change_direction
mdv2sp_send.modelDataV2SP.laneTurnDirection = DH.lane_turn_direction
@@ -0,0 +1,8 @@
from openpilot.sunnypilot.selfdrive.controls.lib.accel_personality.accel_controller import (
AccelController,
AccelControllerResult,
AccelControllerState,
)
from openpilot.sunnypilot.selfdrive.controls.lib.accel_personality.constants import AccelProfile
__all__ = ["AccelController", "AccelControllerResult", "AccelControllerState", "AccelProfile"]
@@ -0,0 +1,619 @@
#!/usr/bin/env python3
from collections import deque
from dataclasses import dataclass, field
from enum import IntEnum
import math
import numpy as np
from cereal import log
from opendbc.car.interfaces import ACCEL_MIN, ACCEL_MAX
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalMpc, STOP_DISTANCE, T_IDXS, get_T_FOLLOW, get_stopped_equivalence_factor
from openpilot.selfdrive.controls.radard import _LEAD_ACCEL_TAU
from openpilot.sunnypilot.selfdrive.controls.lib.accel_personality.constants import (
ACCEL_PROFILE_MAX_BP, ACCEL_PROFILE_MAX_V, APPROACH_CLOSING_SPEED, APPROACH_LEAD_DECEL, APPROACH_LEAD_SPEED_MARGIN, APPROACH_MIN_SPEED,
BRAKE_CAP_MARGIN, CAP_FILTER_FRAMES, CAP_RELAX_JERK, CAP_TIGHTEN_JERK, COAST_MATCH_CLOSING_SPEED, COAST_MATCH_USABLE_GAP,
DROPOUT_ACTION_ACCEL_MARGIN, HORIZON_DOWN_JERK, HORIZON_HOLD_TIME, HORIZON_SPEED_BUDGET, HORIZON_UP_JERK, MAX_LEAD_ACCEL_TAU,
MIN_LEAD_SPEED, POSITIVE_MPC_HEADROOM, PROFILE_CONFIGS, PROFILE_TRANSITION_JERK, RADAR_STALE_TIMEOUT, RELIEF_CAP_MARGIN,
RELIEF_CONFIRM_FRAMES, RELIEF_LEAD_SPEED_STEP, RELIEF_MPC_JERK, REQUIRED_DECEL_MARGIN, ROUTINE_DECEL_MAX, STOP_HOLD_EGO_SPEED,
SHALLOW_BRAKE_BOUND, SHALLOW_BRAKE_RELIEF_TIME, STOP_GAP_RESERVE, STOP_GAP_RESERVE_DECEL_BP, STOP_GAP_RESERVE_LEAD_SPEED,
STOP_HOLD_CREEP_ABORT_FRAMES, STOP_HOLD_CREEP_DISTANCE, STOP_HOLD_CREEP_SPEED,
STOP_HOLD_EXIT_FRAMES, STOP_HOLD_EXIT_SPEED,
STOPPED_LEAD_SPEED, URGENT_CLOSING_SPEED, URGENT_RELEASE_ACCEL, URGENT_REQUIRED_DECEL, URGENT_TTC, URGENT_TTC_MIN_CLOSING,
VEGO_NOISE_TOLERANCE, AccelProfile,
)
class AccelControllerState(IntEnum):
inactive = 0
free = 1
restrict = 2
hold = 3
release = 4
stopHold = 5
@dataclass(frozen=True)
class EnergyEnvelope:
cap: float = math.inf
selected_lead: int = -1
selected_lead_speed: float = math.inf
selected_lead_decel: float = 0.0
departure_lead_index: int = -1
departure_lead_speed: float = math.inf
departure_cap: float = math.inf
departure_lead_speeds: tuple[float, float] = (math.inf, math.inf)
departure_lead_separations: tuple[float, float] = (-math.inf, -math.inf)
usable_gap: float = math.inf
safety_usable_gap: float = math.inf
closing_speed: float = 0.0
required_decel: float = 0.0
has_nearly_stopped_lead: bool = False
lead_status: bool = False
@dataclass(frozen=True)
class AccelControllerResult:
target_speed: float
enabled: bool
active: bool
shadow_active: bool
launching: bool
stock_mode: bool
profile: AccelProfile
profile_accel_max: float
positive_accel_max: float
effective_accel_max: float
mpc_accel_max: tuple[float, ...] | None
state: AccelControllerState
shadow_state: AccelControllerState
base_speed: float
raw_energy_cap: float
live_filtered_cap: float
shadow_filtered_cap: float
selected_lead: int
selected_lead_speed: float
usable_gap: float
closing_speed: float
required_decel: float
@dataclass
class _ControllerPath:
cap_samples: deque[float] = field(default_factory=lambda: deque([math.inf] * CAP_FILTER_FRAMES, maxlen=CAP_FILTER_FRAMES))
required_samples: deque[float] = field(default_factory=lambda: deque(maxlen=CAP_FILTER_FRAMES))
lead_decel_samples: deque[float] = field(default_factory=lambda: deque(maxlen=CAP_FILTER_FRAMES))
departure_samples: tuple[deque[float], deque[float]] = field(
default_factory=lambda: (deque(maxlen=CAP_FILTER_FRAMES), deque(maxlen=CAP_FILTER_FRAMES)),
)
departure_references: list[float | None] = field(default_factory=lambda: [None, None])
bound: float | None = None
state: AccelControllerState = AccelControllerState.inactive
relief_frames: int = 0
bound_relief_frames: int = 0
bound_relief_required_frames: int = 0
departure_frames: int = 0
creep_abort_frames: int = 0
stale_frames: int = 0
urgent: bool = False
urgent_severe: bool = False
urgent_safe_frames: int = 0
departing_from_stop: bool = False
previous_lead_speed: float | None = None
lead_speed_relief: bool = False
@property
def filtered_cap(self) -> float:
return sorted(self.cap_samples)[CAP_FILTER_FRAMES // 2]
@property
def robust_required_decel(self) -> float:
return float(np.median(self.required_samples)) if self.required_samples else 0.0
@property
def robust_lead_decel(self) -> float:
return float(np.median(self.lead_decel_samples)) if self.lead_decel_samples else 0.0
def robust_departure_separation(self, lead_index: int) -> float:
samples = self.departure_samples[lead_index]
return float(np.median(samples)) if samples else -math.inf
def reset(self) -> None:
self.cap_samples = deque([math.inf] * CAP_FILTER_FRAMES, maxlen=CAP_FILTER_FRAMES)
self.required_samples.clear()
self.lead_decel_samples.clear()
self.departure_samples = (deque(maxlen=CAP_FILTER_FRAMES), deque(maxlen=CAP_FILTER_FRAMES))
self.departure_references = [None, None]
self.bound = None
self.state = AccelControllerState.inactive
self.relief_frames = 0
self.bound_relief_frames = 0
self.bound_relief_required_frames = 0
self.departure_frames = 0
self.creep_abort_frames = 0
self.stale_frames = 0
self.urgent = False
self.urgent_severe = False
self.urgent_safe_frames = 0
self.departing_from_stop = False
self.previous_lead_speed = None
self.lead_speed_relief = False
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.CP = CP
self.dt = dt
self.radar_stale_frames = max(1, math.ceil(RADAR_STALE_TIMEOUT / dt))
self.shallow_brake_relief_frames = max(RELIEF_CONFIRM_FRAMES, math.ceil(SHALLOW_BRAKE_RELIEF_TIME / dt))
self.live = _ControllerPath()
self.shadow = _ControllerPath()
@staticmethod
def _profile(profile: int | AccelProfile) -> AccelProfile:
try:
return AccelProfile(profile)
except (TypeError, ValueError):
return AccelProfile.normal
@classmethod
def get_profile_accel_max(cls, profile: int | AccelProfile, v_ego: float) -> float:
if not math.isfinite(v_ego):
return math.nan
selected_profile = cls._profile(profile)
return float(np.interp(max(v_ego, 0.0), ACCEL_PROFILE_MAX_BP, ACCEL_PROFILE_MAX_V[selected_profile]))
def _delay(self) -> float:
try:
return float(self.CP.longitudinalActuatorDelay) + DT_MDL
except (AttributeError, OverflowError, TypeError, ValueError):
return math.nan
@staticmethod
def _project_ego(v_ego: float, a_ego: float, delay: float) -> tuple[float, float]:
if a_ego < 0.0:
stop_time = -v_ego / a_ego if v_ego > 0.0 else 0.0
if stop_time <= delay:
distance = -v_ego**2 / (2.0 * a_ego) if v_ego > 0.0 else 0.0
return distance, 0.0
return max(v_ego * delay + 0.5 * a_ego * delay**2, 0.0), max(v_ego + a_ego * delay, 0.0)
@staticmethod
def _lead_values(lead) -> tuple[float, float, float, float] | None:
try:
if not lead.status:
return None
d_rel = float(lead.dRel)
v_lead = float(lead.vLeadK)
except (AttributeError, OverflowError, TypeError, ValueError):
return None
if not math.isfinite(d_rel) or d_rel < 0.0 or not math.isfinite(v_lead) or v_lead < MIN_LEAD_SPEED:
return None
try:
a_lead = float(lead.aLeadK)
except (AttributeError, OverflowError, TypeError, ValueError):
a_lead = 0.0
if not math.isfinite(a_lead):
a_lead = 0.0
try:
a_lead_tau = float(lead.aLeadTau)
except (AttributeError, OverflowError, TypeError, ValueError):
a_lead_tau = _LEAD_ACCEL_TAU
if not math.isfinite(a_lead_tau) or not 0.0 < a_lead_tau <= MAX_LEAD_ACCEL_TAU:
a_lead_tau = _LEAD_ACCEL_TAU
return d_rel, max(v_lead, 0.0), float(np.clip(a_lead, -10.0, 5.0)), a_lead_tau
def calculate_energy_envelope(self, radar_state, v_ego: float, a_ego: float, profile: int | AccelProfile,
follow_personality=log.LongitudinalPersonality.standard) -> EnergyEnvelope:
delay = self._delay()
if not all(math.isfinite(value) for value in (v_ego, a_ego, delay)) or v_ego < 0.0 or delay < 0.0:
return EnergyEnvelope()
try:
leads = (radar_state.leadOne, radar_state.leadTwo)
lead_status = any(bool(lead.status) for lead in leads)
except (AttributeError, TypeError, ValueError):
return EnergyEnvelope()
try:
t_follow = get_T_FOLLOW(follow_personality)
except (NotImplementedError, TypeError, ValueError):
t_follow = get_T_FOLLOW(log.LongitudinalPersonality.standard)
if not math.isfinite(t_follow) or t_follow < 0.0:
return EnergyEnvelope(lead_status=lead_status)
x_ego, v_ego_delay = self._project_ego(v_ego, a_ego, delay)
comfort_decel = PROFILE_CONFIGS[self._profile(profile)].comfort_decel
candidates: list[EnergyEnvelope] = []
departure_candidates: list[tuple[float, int]] = []
departure_speeds = [math.inf, math.inf]
departure_separations = [-math.inf, -math.inf]
departure_caps = [math.inf, math.inf]
for lead_index, lead in enumerate(leads):
values = self._lead_values(lead)
if values is None:
continue
try:
d_rel, v_lead, a_lead, a_lead_tau = values
lead_xv = LongitudinalMpc.extrapolate_lead(d_rel, v_lead, a_lead, a_lead_tau)
x_lead = float(np.interp(delay, T_IDXS, lead_xv[:, 0]))
v_lead_delay = float(np.interp(delay, T_IDXS, lead_xv[:, 1]))
safety_usable_gap = max(x_lead - x_ego - STOP_DISTANCE - t_follow * v_lead_delay, 0.0)
closing_speed = max(v_ego_delay - v_lead_delay, 0.0)
required_decel = (0.0 if closing_speed == 0.0 else math.inf if safety_usable_gap == 0.0
else closing_speed**2 / (2.0 * safety_usable_gap))
reserve_speed = float(np.interp(v_lead_delay, (0.0, STOP_GAP_RESERVE_LEAD_SPEED), (STOP_GAP_RESERVE, 0.0)))
reserve_scale = float(np.interp(required_decel, STOP_GAP_RESERVE_DECEL_BP, (1.0, 0.0)))
usable_gap = max(safety_usable_gap - reserve_speed * reserve_scale, 0.0)
cap = v_lead_delay + math.sqrt(2.0 * comfort_decel * usable_gap)
departure_cap = v_lead_delay + math.sqrt(2.0 * comfort_decel * safety_usable_gap)
projected_separation = x_lead - x_ego
departure_distance = x_lead + float(get_stopped_equivalence_factor(v_lead_delay))
except (FloatingPointError, OverflowError, TypeError, ValueError):
continue
finite_values = (x_lead, v_lead_delay, usable_gap, safety_usable_gap, closing_speed, cap, departure_cap, departure_distance)
if not all(math.isfinite(value) and value >= 0.0 for value in finite_values) or math.isnan(required_decel) or required_decel < 0.0:
continue
if not math.isfinite(projected_separation):
continue
candidates.append(EnergyEnvelope(cap=cap, selected_lead=lead_index, selected_lead_speed=v_lead_delay,
selected_lead_decel=max(-a_lead, 0.0), usable_gap=usable_gap,
safety_usable_gap=safety_usable_gap, closing_speed=closing_speed,
required_decel=required_decel, lead_status=lead_status))
departure_candidates.append((departure_distance, lead_index))
departure_speeds[lead_index] = v_lead_delay
departure_separations[lead_index] = projected_separation
departure_caps[lead_index] = departure_cap
if not candidates:
return EnergyEnvelope(lead_status=lead_status)
selected = min(candidates, key=lambda candidate: candidate.cap)
departure_lead_index = min(departure_candidates, key=lambda candidate: candidate[0])[1]
departure_lead_speed = departure_speeds[departure_lead_index]
return EnergyEnvelope(
cap=selected.cap, selected_lead=selected.selected_lead, selected_lead_speed=selected.selected_lead_speed,
selected_lead_decel=selected.selected_lead_decel, departure_lead_index=departure_lead_index,
departure_lead_speed=departure_lead_speed, departure_cap=departure_caps[departure_lead_index],
departure_lead_speeds=tuple(departure_speeds), departure_lead_separations=tuple(departure_separations),
usable_gap=selected.usable_gap, safety_usable_gap=selected.safety_usable_gap, closing_speed=selected.closing_speed,
required_decel=selected.required_decel, has_nearly_stopped_lead=departure_lead_speed < STOPPED_LEAD_SPEED,
lead_status=lead_status,
)
@staticmethod
def _move(value: float, target: float, rate: float, dt: float) -> float:
return float(np.clip(target, value - rate * dt, value + rate * dt))
@staticmethod
def _ttc(envelope: EnergyEnvelope) -> float:
return envelope.safety_usable_gap / envelope.closing_speed if envelope.closing_speed > 0.0 else math.inf
def _update_samples(self, path: _ControllerPath, envelope: EnergyEnvelope) -> None:
has_lead = envelope.selected_lead >= 0
path.lead_speed_relief = (has_lead and path.previous_lead_speed is not None
and envelope.selected_lead_speed > path.previous_lead_speed + RELIEF_LEAD_SPEED_STEP)
path.previous_lead_speed = envelope.selected_lead_speed if has_lead else None
path.cap_samples.append(envelope.cap if has_lead else math.inf)
for lead_index, separation in enumerate(envelope.departure_lead_separations):
if math.isfinite(separation):
path.departure_samples[lead_index].append(separation)
if has_lead:
if math.isfinite(envelope.required_decel):
path.required_samples.append(envelope.required_decel)
if math.isfinite(envelope.selected_lead_decel):
path.lead_decel_samples.append(envelope.selected_lead_decel)
else:
path.required_samples.append(0.0)
path.lead_decel_samples.append(0.0)
@staticmethod
def _reset_departure_tracking(path: _ControllerPath, envelope: EnergyEnvelope) -> None:
path.departure_samples = (deque(maxlen=CAP_FILTER_FRAMES), deque(maxlen=CAP_FILTER_FRAMES))
path.departure_references = [None, None]
for lead_index, separation in enumerate(envelope.departure_lead_separations):
if math.isfinite(separation):
path.departure_samples[lead_index].append(separation)
path.departure_references[lead_index] = separation
path.departure_frames = 0
path.creep_abort_frames = 0
@staticmethod
def _clear_bound_relief(path: _ControllerPath) -> None:
path.bound_relief_frames = 0
path.bound_relief_required_frames = 0
@staticmethod
def _creep_departure(path: _ControllerPath, envelope: EnergyEnvelope) -> bool:
lead_index = envelope.departure_lead_index
if lead_index < 0 or envelope.departure_lead_speed <= STOP_HOLD_CREEP_SPEED:
return False
separation = path.robust_departure_separation(lead_index)
reference = path.departure_references[lead_index]
return reference is not None and separation - reference >= STOP_HOLD_CREEP_DISTANCE
def _update_path(self, path: _ControllerPath, envelope: EnergyEnvelope, base_speed: float, v_ego: float, action_accel: float,
positive_accel_max: float, profile: AccelProfile, previous_should_stop: bool) -> bool:
self._update_samples(path, envelope)
has_lead = envelope.selected_lead >= 0
filtered_cap = path.filtered_cap
robust_required = path.robust_required_decel
robust_lead_decel = path.robust_lead_decel
ttc = self._ttc(envelope)
moving_away = (has_lead and not envelope.has_nearly_stopped_lead
and envelope.selected_lead_speed > v_ego + APPROACH_CLOSING_SPEED
and envelope.cap > v_ego + RELIEF_CAP_MARGIN)
if path.departing_from_stop:
if v_ego >= STOP_HOLD_EGO_SPEED:
path.departing_from_stop = False
path.creep_abort_frames = 0
elif envelope.lead_status and (not has_lead or envelope.departure_lead_speed <= STOP_HOLD_CREEP_SPEED):
path.creep_abort_frames += 1
if path.creep_abort_frames >= STOP_HOLD_CREEP_ABORT_FRAMES:
path.departing_from_stop = False
path.creep_abort_frames = 0
else:
path.creep_abort_frames = 0
stop_hold = (v_ego < STOP_HOLD_EGO_SPEED and not path.departing_from_stop
and (previous_should_stop or (envelope.lead_status and not has_lead)
or (has_lead and (envelope.has_nearly_stopped_lead or envelope.cap < 0.50))))
if path.state == AccelControllerState.stopHold:
self._clear_bound_relief(path)
for lead_index in range(len(path.departure_references)):
separation = path.robust_departure_separation(lead_index)
if math.isfinite(separation) and path.departure_references[lead_index] is None:
path.departure_references[lead_index] = separation
departed = (not envelope.lead_status
or (has_lead and envelope.departure_lead_speed > STOP_HOLD_EXIT_SPEED
and envelope.departure_cap > STOP_HOLD_EXIT_SPEED)
or self._creep_departure(path, envelope))
path.departure_frames = path.departure_frames + 1 if departed else 0
path.bound = 0.0
if path.departure_frames < STOP_HOLD_EXIT_FRAMES:
return False
path.state = AccelControllerState.free
path.bound = positive_accel_max
path.departure_frames = 0
path.departing_from_stop = True
return False
if stop_hold:
path.state = AccelControllerState.stopHold
path.bound = 0.0
path.relief_frames = 0
self._clear_bound_relief(path)
path.departure_frames = 0
path.urgent = False
path.urgent_severe = False
path.urgent_safe_frames = 0
path.departing_from_stop = False
self._reset_departure_tracking(path, envelope)
return False
urgent_closing = envelope.closing_speed > URGENT_TTC_MIN_CLOSING
raw_urgent = (has_lead and v_ego >= STOP_HOLD_EGO_SPEED
and (envelope.closing_speed >= URGENT_CLOSING_SPEED
or (urgent_closing and envelope.required_decel >= URGENT_REQUIRED_DECEL)
or (urgent_closing and ttc <= URGENT_TTC)))
if raw_urgent:
path.urgent = True
path.urgent_severe |= envelope.closing_speed >= URGENT_CLOSING_SPEED or envelope.required_decel >= URGENT_REQUIRED_DECEL
path.urgent_safe_frames = 0
path.bound = None
path.state = AccelControllerState.hold
path.relief_frames = 0
self._clear_bound_relief(path)
return True
if path.urgent:
matched = has_lead and envelope.closing_speed <= APPROACH_CLOSING_SPEED and robust_lead_decel <= 0.05
urgent_safe = (not has_lead or moving_away or matched) and (not path.urgent_severe or action_accel >= URGENT_RELEASE_ACCEL)
path.urgent_safe_frames = path.urgent_safe_frames + 1 if urgent_safe else 0
if path.urgent_safe_frames < RELIEF_CONFIRM_FRAMES:
path.bound = None
path.state = AccelControllerState.hold
self._clear_bound_relief(path)
return True
path.urgent = False
path.urgent_severe = False
path.urgent_safe_frames = 0
if not has_lead or moving_away:
path.state = AccelControllerState.free
path.bound = min(action_accel, 0.0)
else:
path.state = AccelControllerState.hold
path.bound = 0.0
if path.state == AccelControllerState.inactive and has_lead and not math.isfinite(filtered_cap):
path.bound = min(action_accel, 0.0)
self._clear_bound_relief(path)
return False
dropout_guard = (not has_lead and math.isfinite(filtered_cap)
and path.state in (AccelControllerState.restrict, AccelControllerState.hold) and path.bound is not None)
if dropout_guard:
path.bound = min(path.bound, action_accel + DROPOUT_ACTION_ACCEL_MARGIN)
profile_config = PROFILE_CONFIGS[profile]
lead_demand = (envelope.closing_speed > APPROACH_CLOSING_SPEED
or (robust_lead_decel > APPROACH_LEAD_DECEL
and envelope.selected_lead_speed < v_ego + APPROACH_LEAD_SPEED_MARGIN))
braking_zone = filtered_cap < v_ego + BRAKE_CAP_MARGIN
anticipation = filtered_cap < base_speed - profile_config.anticipation_margin
approach = (has_lead and (v_ego > APPROACH_MIN_SPEED or path.state == AccelControllerState.restrict)
and lead_demand and (braking_zone or anticipation))
retaining_lead = path.state in (AccelControllerState.restrict, AccelControllerState.hold) and has_lead and not moving_away
if approach or retaining_lead:
entering = path.state not in (AccelControllerState.restrict, AccelControllerState.hold)
if path.bound is None or entering:
path.bound = action_accel
matched = envelope.closing_speed <= APPROACH_CLOSING_SPEED and robust_lead_decel <= 0.05
coast_cap = envelope.selected_lead_speed + math.sqrt(2.0 * profile_config.comfort_decel * COAST_MATCH_USABLE_GAP)
coast_to_match = (robust_lead_decel <= 0.05 and envelope.closing_speed <= COAST_MATCH_CLOSING_SPEED
and filtered_cap > coast_cap)
if matched or coast_to_match:
target_decel = 0.0
elif braking_zone:
target_decel = min(max(robust_required + REQUIRED_DECEL_MARGIN, robust_lead_decel), ROUTINE_DECEL_MAX)
else:
target_decel = profile_config.glide_decel
target = -target_decel
bound_relief = has_lead and path.bound < 0.0 and target > path.bound + 1e-9
if bound_relief and path.bound_relief_frames == 0:
path.bound_relief_required_frames = (self.shallow_brake_relief_frames
if path.bound >= SHALLOW_BRAKE_BOUND else RELIEF_CONFIRM_FRAMES)
path.bound_relief_frames = path.bound_relief_frames + 1 if bound_relief else 0
if not bound_relief:
self._clear_bound_relief(path)
if bound_relief and path.bound_relief_frames < path.bound_relief_required_frames:
target = path.bound
path.bound = self._move(path.bound, target, CAP_RELAX_JERK if target > path.bound else CAP_TIGHTEN_JERK, self.dt)
path.state = AccelControllerState.hold if matched or coast_to_match else AccelControllerState.restrict
path.relief_frames = 0
return False
if path.state in (AccelControllerState.restrict, AccelControllerState.hold):
self._clear_bound_relief(path)
relief = not has_lead or moving_away
path.relief_frames = path.relief_frames + 1 if relief else 0
path.bound = min(path.bound if path.bound is not None else action_accel, 0.0)
if path.relief_frames < RELIEF_CONFIRM_FRAMES:
path.state = AccelControllerState.hold
return False
path.state = AccelControllerState.free
path.relief_frames = 0
return False
if path.bound is None:
path.bound = positive_accel_max
else:
path.bound = self._move(path.bound, positive_accel_max, PROFILE_TRANSITION_JERK, self.dt)
self._clear_bound_relief(path)
path.state = AccelControllerState.free
return False
@staticmethod
def _build_accel_ceiling(bound: float, v_ego: float, planner_accel: float, action_time: float) -> tuple[float, ...] | None:
if bound >= ACCEL_MAX - 1e-9:
return None
a0 = float(np.clip(planner_accel, ACCEL_MIN, ACCEL_MAX))
if bound > 0.0:
ceiling = np.full(len(T_IDXS), min(bound + POSITIVE_MPC_HEADROOM, ACCEL_MAX))
elif bound == 0.0:
ceiling = np.maximum(0.0, a0 - HORIZON_DOWN_JERK * T_IDXS)
else:
descent = np.maximum(bound, a0 - HORIZON_DOWN_JERK * T_IDXS)
reach_time = max((a0 - bound) / HORIZON_DOWN_JERK, 0.0)
release_time = max(action_time + HORIZON_HOLD_TIME, reach_time + HORIZON_HOLD_TIME)
recovery = np.clip(bound + HORIZON_UP_JERK * np.maximum(T_IDXS - release_time, 0.0), bound, 0.0)
ceiling = np.where(T_IDXS <= release_time, descent, np.maximum(descent, recovery))
budget = HORIZON_SPEED_BUDGET * max(v_ego, 0.0)
negative_area = float(np.trapezoid(-np.minimum(ceiling, 0.0), T_IDXS))
if negative_area > budget and negative_area > 1e-9:
ceiling = np.where(ceiling < 0.0, ceiling * budget / negative_area, ceiling)
ceiling = np.clip(ceiling, ACCEL_MIN, ACCEL_MAX)
ceiling[0] = max(ceiling[0], a0)
return tuple(float(value) for value in ceiling)
@staticmethod
def _valid_context(base_speed: float, v_ego: float, a_ego: float, planner_accel: float, action_accel: float,
positive_accel_max: float, delay: float, engaged: bool, cruise_initialized: bool, controller_fault: bool) -> bool:
values = (base_speed, v_ego, a_ego, planner_accel, action_accel, positive_accel_max, delay)
return (engaged and cruise_initialized and not controller_fault and base_speed >= 0.0 and v_ego >= -VEGO_NOISE_TOLERANCE
and delay >= 0.0 and all(math.isfinite(value) for value in values))
def _update_freshness(self, path: _ControllerPath, radar_fresh: bool) -> bool:
if radar_fresh:
path.stale_frames = 0
return True
path.stale_frames += 1
if path.stale_frames < self.radar_stale_frames and (path.bound is not None or path.urgent):
return False
path.reset()
return False
def reset(self) -> None:
self.live.reset()
self.shadow.reset()
def update(self, radar_state, *, base_speed: float, v_ego: float, a_ego: float, profile: int | AccelProfile, follow_personality,
enabled: bool, acc_selected: bool, engaged: bool, cruise_initialized: bool, planner_accel: float, action_accel: float,
stock_accel_max: float, previous_should_stop: bool, controller_fault: bool = False,
radar_fresh: bool = True) -> AccelControllerResult:
selected_profile = self._profile(profile)
sanitized_v_ego = max(v_ego, 0.0) if math.isfinite(v_ego) and v_ego >= -VEGO_NOISE_TOLERANCE else v_ego
profile_accel_max = self.get_profile_accel_max(selected_profile, sanitized_v_ego)
try:
stock_accel_max = float(stock_accel_max)
except (OverflowError, TypeError, ValueError):
stock_accel_max = math.nan
positive_accel_max = (max(0.0, min(profile_accel_max, stock_accel_max, ACCEL_MAX))
if math.isfinite(profile_accel_max) and math.isfinite(stock_accel_max) else math.nan)
valid_context = self._valid_context(base_speed, sanitized_v_ego, a_ego, planner_accel, action_accel, positive_accel_max,
self._delay(), engaged, cruise_initialized, controller_fault)
envelope = (self.calculate_energy_envelope(radar_state, sanitized_v_ego, a_ego, selected_profile, follow_personality)
if valid_context and radar_fresh else EnergyEnvelope(lead_status=self._radar_has_lead(radar_state)))
shadow_fresh = self._update_freshness(self.shadow, radar_fresh) if valid_context else False
if valid_context and radar_fresh:
self._update_path(self.shadow, envelope, base_speed, sanitized_v_ego, action_accel, positive_accel_max,
selected_profile, previous_should_stop)
shadow_active = True
elif valid_context and not shadow_fresh and (self.shadow.bound is not None or self.shadow.urgent):
shadow_active = True
else:
self.shadow.reset()
shadow_active = False
live_context = valid_context and bool(enabled) and bool(acc_selected)
live_fresh = self._update_freshness(self.live, radar_fresh) if live_context else False
if live_context and radar_fresh:
stock_mode = self._update_path(self.live, envelope, base_speed, sanitized_v_ego, action_accel,
positive_accel_max, selected_profile, previous_should_stop)
live_active = True
elif live_context and not live_fresh and (self.live.bound is not None or self.live.urgent):
stock_mode = self.live.urgent
live_active = True
else:
self.live.reset()
stock_mode = False
live_active = False
if live_active and not stock_mode and self.live.bound is not None:
effective_accel_max = float(np.clip(self.live.bound, ACCEL_MIN, ACCEL_MAX))
if self.live.bound_relief_frames and self.live.lead_speed_relief:
effective_accel_max = min(effective_accel_max, action_accel + RELIEF_MPC_JERK * self.dt)
mpc_accel_max = self._build_accel_ceiling(effective_accel_max, sanitized_v_ego, planner_accel, self._delay())
else:
effective_accel_max = math.inf
mpc_accel_max = None
return AccelControllerResult(
target_speed=0.0 if live_active and self.live.state == AccelControllerState.stopHold else base_speed,
enabled=bool(enabled), active=live_active, shadow_active=shadow_active,
launching=live_active and self.live.departing_from_stop, stock_mode=stock_mode, profile=selected_profile,
profile_accel_max=profile_accel_max if live_active else math.inf,
positive_accel_max=positive_accel_max if live_active else math.inf, effective_accel_max=effective_accel_max,
mpc_accel_max=mpc_accel_max,
state=self.live.state, shadow_state=self.shadow.state, base_speed=base_speed, raw_energy_cap=envelope.cap,
live_filtered_cap=self.live.filtered_cap if live_active else math.inf,
shadow_filtered_cap=self.shadow.filtered_cap if shadow_active else math.inf, selected_lead=envelope.selected_lead,
selected_lead_speed=envelope.selected_lead_speed, usable_gap=envelope.usable_gap,
closing_speed=envelope.closing_speed, required_decel=envelope.required_decel,
)
@staticmethod
def _radar_has_lead(radar_state) -> bool:
try:
return bool(radar_state.leadOne.status or radar_state.leadTwo.status)
except (AttributeError, TypeError, ValueError):
return True
@@ -0,0 +1,75 @@
from dataclasses import dataclass
from enum import IntEnum
class AccelProfile(IntEnum):
eco = 0
normal = 1
sport = 2
@dataclass(frozen=True)
class ProfileConfig:
comfort_decel: float
anticipation_margin: float
glide_decel: float
PROFILE_CONFIGS = {
AccelProfile.eco: ProfileConfig(comfort_decel=0.25, anticipation_margin=0.15, glide_decel=0.12),
AccelProfile.normal: ProfileConfig(comfort_decel=0.35, anticipation_margin=1.00, glide_decel=0.16),
AccelProfile.sport: ProfileConfig(comfort_decel=0.50, anticipation_margin=2.00, glide_decel=0.20),
}
ACCEL_PROFILE_MAX_BP = [0.0, 3.0, 10.0, 25.0, 40.0]
ACCEL_PROFILE_MAX_V = {
AccelProfile.eco: [1.55, 1.25, 0.85, 0.40, 0.20],
AccelProfile.normal: [1.70, 1.40, 1.05, 0.55, 0.35],
AccelProfile.sport: [2.00, 1.90, 1.70, 0.90, 0.60],
}
CAP_FILTER_FRAMES = 5
RELIEF_CONFIRM_FRAMES = 5
STOP_HOLD_EXIT_FRAMES = 4
STOP_HOLD_EGO_SPEED = 0.30
STOPPED_LEAD_SPEED = 0.30
STOP_HOLD_EXIT_SPEED = 0.80
STOP_HOLD_CREEP_SPEED = 0.15
STOP_HOLD_CREEP_DISTANCE = 0.30
STOP_HOLD_CREEP_ABORT_FRAMES = 4
STOP_GAP_RESERVE = 0.75
STOP_GAP_RESERVE_LEAD_SPEED = 2.0
STOP_GAP_RESERVE_DECEL_BP = (0.30, 0.80)
MPC_SEED_RISE_RATE = 6.0
APPROACH_MIN_SPEED = 2.0
APPROACH_CLOSING_SPEED = 0.15
BRAKE_CAP_MARGIN = 0.50
APPROACH_LEAD_DECEL = 0.10
APPROACH_LEAD_SPEED_MARGIN = 0.50
RELIEF_CAP_MARGIN = 0.35
COAST_MATCH_CLOSING_SPEED = 2.50
COAST_MATCH_USABLE_GAP = 4.0
REQUIRED_DECEL_MARGIN = 0.03
ROUTINE_DECEL_MAX = 1.0
CAP_TIGHTEN_JERK = 0.60
CAP_RELAX_JERK = 0.80
SHALLOW_BRAKE_BOUND = -0.25
SHALLOW_BRAKE_RELIEF_TIME = 1.75
RELIEF_MPC_JERK = 3.20
RELIEF_LEAD_SPEED_STEP = 0.05
DROPOUT_ACTION_ACCEL_MARGIN = 0.08
PROFILE_TRANSITION_JERK = 1.50
POSITIVE_MPC_HEADROOM = 0.02
URGENT_CLOSING_SPEED = 12.0
URGENT_REQUIRED_DECEL = 1.0
URGENT_TTC = 3.2
URGENT_TTC_MIN_CLOSING = 1.0
URGENT_RELEASE_ACCEL = -0.20
HORIZON_DOWN_JERK = 2.0
HORIZON_UP_JERK = 1.0
HORIZON_HOLD_TIME = 0.50
HORIZON_SPEED_BUDGET = 0.80
RADAR_STALE_TIMEOUT = 0.50
MAX_LEAD_ACCEL_TAU = 10.0
MIN_LEAD_SPEED = -1.0
VEGO_NOISE_TOLERANCE = 0.10
@@ -0,0 +1,445 @@
import math
from types import SimpleNamespace
import numpy as np
import pytest
from cereal import log
from opendbc.car.interfaces import ACCEL_MAX, ACCEL_MIN
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import STOP_DISTANCE, T_IDXS, LongitudinalMpc, get_T_FOLLOW
from openpilot.sunnypilot.selfdrive.controls.lib.accel_personality.accel_controller import (
ACCEL_PROFILE_MAX_BP,
ACCEL_PROFILE_MAX_V,
CAP_FILTER_FRAMES,
HORIZON_SPEED_BUDGET,
POSITIVE_MPC_HEADROOM,
PROFILE_CONFIGS,
PROFILE_TRANSITION_JERK,
RADAR_STALE_TIMEOUT,
RELIEF_CONFIRM_FRAMES,
SHALLOW_BRAKE_BOUND,
STOP_GAP_RESERVE,
STOP_HOLD_CREEP_ABORT_FRAMES,
STOP_HOLD_EXIT_FRAMES,
AccelController,
AccelControllerState,
AccelProfile,
)
def make_lead(*, status=False, d_rel=0.0, v_lead_k=0.0, a_lead_k=0.0, a_lead_tau=1.5):
return SimpleNamespace(status=status, dRel=d_rel, vLeadK=v_lead_k, aLeadK=a_lead_k, aLeadTau=a_lead_tau)
def make_radar(lead_one=None, lead_two=None):
return SimpleNamespace(leadOne=lead_one or make_lead(), leadTwo=lead_two or make_lead())
def make_controller(delay=0.10):
return AccelController(SimpleNamespace(longitudinalActuatorDelay=delay))
def update(controller, radar_state=None, **overrides):
args = {
"base_speed": 25.0,
"v_ego": 10.0,
"a_ego": 0.0,
"profile": AccelProfile.normal,
"follow_personality": log.LongitudinalPersonality.standard,
"enabled": True,
"acc_selected": True,
"engaged": True,
"cruise_initialized": True,
"planner_accel": 0.0,
"action_accel": 0.0,
"stock_accel_max": ACCEL_MAX,
"previous_should_stop": False,
}
args.update(overrides)
return controller.update(radar_state or make_radar(), **args)
def restrictive_radar():
return make_radar(make_lead(status=True, d_rel=25.0, v_lead_k=8.0, a_lead_k=-0.5))
class TestProfiles:
def test_lookup_table_is_explicit_and_tunable(self):
assert ACCEL_PROFILE_MAX_BP == [0.0, 3.0, 10.0, 25.0, 40.0]
assert ACCEL_PROFILE_MAX_V == {
AccelProfile.eco: [1.55, 1.25, 0.85, 0.40, 0.20],
AccelProfile.normal: [1.70, 1.40, 1.05, 0.55, 0.35],
AccelProfile.sport: [2.00, 1.90, 1.70, 0.90, 0.60],
}
@pytest.mark.parametrize("profile", list(AccelProfile))
def test_lookup_interpolates_and_stays_inside_global_limit(self, profile):
for speed, expected in zip(ACCEL_PROFILE_MAX_BP, ACCEL_PROFILE_MAX_V[profile], strict=True):
assert AccelController.get_profile_accel_max(profile, speed) == expected
limits = [AccelController.get_profile_accel_max(profile, speed) for speed in np.linspace(-1.0, 50.0, 201)]
assert all(0.0 <= limit <= ACCEL_MAX for limit in limits)
@pytest.mark.parametrize("speed", [0.0, 3.0, 10.0, 25.0, 40.0])
def test_profile_order_is_distinct(self, speed):
limits = [AccelController.get_profile_accel_max(profile, speed) for profile in AccelProfile]
assert limits[0] < limits[1] < limits[2]
@pytest.mark.parametrize("profile", list(AccelProfile))
def test_clear_road_applies_profile_immediately(self, profile):
result = update(make_controller(), v_ego=0.0, profile=profile)
expected = ACCEL_PROFILE_MAX_V[profile][0]
assert result.active and result.state == AccelControllerState.free
assert result.target_speed == result.base_speed == 25.0
assert result.positive_accel_max == expected
assert result.effective_accel_max == expected
if expected == ACCEL_MAX:
assert result.mpc_accel_max is None
else:
np.testing.assert_array_equal(result.mpc_accel_max, min(expected + POSITIVE_MPC_HEADROOM, ACCEL_MAX))
def test_turn_or_throttle_limit_intersects_profile(self):
result = update(make_controller(), profile=AccelProfile.sport, stock_accel_max=0.0)
assert result.positive_accel_max == 0.0
assert result.effective_accel_max == 0.0
np.testing.assert_array_equal(result.mpc_accel_max, 0.0)
def test_profile_switch_changes_ceiling_without_a_step(self):
controller = make_controller()
sport = update(controller, profile=AccelProfile.sport, v_ego=10.0)
eco = update(controller, profile=AccelProfile.eco, v_ego=10.0)
assert sport.effective_accel_max > eco.effective_accel_max > eco.positive_accel_max
assert sport.effective_accel_max - eco.effective_accel_max == pytest.approx(PROFILE_TRANSITION_JERK * DT_MDL)
def test_invalid_profile_defaults_to_normal(self):
result = update(make_controller(), profile=999)
assert result.profile == AccelProfile.normal
class TestEnergyEnvelope:
def test_relative_pace_energy_formula(self):
controller = make_controller()
lead = make_lead(status=True, d_rel=50.0, v_lead_k=8.0)
envelope = controller.calculate_energy_envelope(make_radar(lead), 10.0, 0.0, AccelProfile.normal)
delay = controller._delay()
lead_xv = LongitudinalMpc.extrapolate_lead(lead.dRel, lead.vLeadK, lead.aLeadK, lead.aLeadTau)
x_lead = float(np.interp(delay, T_IDXS, lead_xv[:, 0]))
v_lead = float(np.interp(delay, T_IDXS, lead_xv[:, 1]))
x_ego, _ = controller._project_ego(10.0, 0.0, delay)
gap = max(x_lead - x_ego - STOP_DISTANCE - get_T_FOLLOW(log.LongitudinalPersonality.standard) * v_lead, 0.0)
expected = v_lead + math.sqrt(2.0 * PROFILE_CONFIGS[AccelProfile.normal].comfort_decel * gap)
assert envelope.cap == pytest.approx(expected)
assert envelope.cap != pytest.approx(math.sqrt(v_lead**2 + 2.0 * PROFILE_CONFIGS[AccelProfile.normal].comfort_decel * gap))
def test_profile_order_controls_approach_timing(self):
controller = make_controller()
radar = make_radar(make_lead(status=True, d_rel=50.0, v_lead_k=8.0))
caps = [controller.calculate_energy_envelope(radar, 10.0, 0.0, profile).cap for profile in AccelProfile]
assert caps[0] < caps[1] < caps[2]
def test_stopped_lead_reserve_only_reduces_comfort_gap(self):
controller = make_controller()
lead = make_lead(status=True, d_rel=20.0, v_lead_k=0.0)
envelope = controller.calculate_energy_envelope(make_radar(lead), 2.0, 0.0, AccelProfile.normal)
expected = math.sqrt(2.0 * PROFILE_CONFIGS[AccelProfile.normal].comfort_decel * envelope.usable_gap)
assert envelope.required_decel < 0.30
assert envelope.safety_usable_gap - envelope.usable_gap == pytest.approx(STOP_GAP_RESERVE)
assert envelope.cap == pytest.approx(expected)
assert envelope.departure_cap > envelope.cap
def test_stop_reserve_fades_out_of_urgent_braking(self):
controller = make_controller()
lead = make_lead(status=True, d_rel=20.0, v_lead_k=0.0)
envelope = controller.calculate_energy_envelope(make_radar(lead), 20.0, 0.0, AccelProfile.normal)
assert envelope.required_decel > 0.80
assert envelope.usable_gap == envelope.safety_usable_gap
assert envelope.cap == envelope.departure_cap
assert controller._ttc(envelope) == pytest.approx(envelope.safety_usable_gap / envelope.closing_speed)
def test_more_restrictive_lead_is_selected(self):
radar = make_radar(make_lead(status=True, d_rel=70.0, v_lead_k=12.0), make_lead(status=True, d_rel=25.0, v_lead_k=8.0))
envelope = make_controller().calculate_energy_envelope(radar, 10.0, 0.0, AccelProfile.normal)
assert envelope.selected_lead == 1
@pytest.mark.parametrize("field,value", [("aLeadK", math.nan), ("aLeadK", math.inf), ("aLeadTau", math.nan), ("aLeadTau", -1.0)])
def test_nonessential_invalid_lead_fields_are_sanitized(self, field, value):
lead = make_lead(status=True, d_rel=30.0, v_lead_k=8.0)
setattr(lead, field, value)
envelope = make_controller().calculate_energy_envelope(make_radar(lead), 10.0, 0.0, AccelProfile.normal)
assert envelope.selected_lead == 0
assert math.isfinite(envelope.cap)
@pytest.mark.parametrize("field,value", [("dRel", math.nan), ("dRel", -1.0), ("vLeadK", math.nan), ("vLeadK", -2.0)])
def test_invalid_geometry_is_not_used(self, field, value):
lead = make_lead(status=True, d_rel=30.0, v_lead_k=8.0)
setattr(lead, field, value)
envelope = make_controller().calculate_energy_envelope(make_radar(lead), 10.0, 0.0, AccelProfile.normal)
assert envelope.selected_lead == -1
assert envelope.lead_status
def test_raw_radar_is_never_mutated(self):
lead = make_lead(status=True, d_rel=30.0, v_lead_k=8.0, a_lead_k=-15.0, a_lead_tau=math.nan)
before = vars(lead).copy()
make_controller().calculate_energy_envelope(make_radar(lead), 10.0, 0.0, AccelProfile.normal)
assert vars(lead) == before
class TestAccelControllerState:
def test_five_frame_median_needs_three_restrictive_samples(self):
controller = make_controller()
results = [update(controller, restrictive_radar()) for _ in range(CAP_FILTER_FRAMES)]
assert math.isinf(results[1].live_filtered_cap)
assert math.isfinite(results[2].live_filtered_cap)
def test_routine_approach_builds_safe_finite_horizon_ceiling(self):
controller = make_controller()
result = None
for _ in range(CAP_FILTER_FRAMES):
result = update(controller, restrictive_radar())
assert result is not None and result.state == AccelControllerState.restrict
ceiling = np.asarray(result.mpc_accel_max)
assert ceiling.shape == T_IDXS.shape
assert np.all(np.isfinite(ceiling))
assert np.all((ceiling >= ACCEL_MIN) & (ceiling <= ACCEL_MAX))
assert ceiling[0] >= 0.0
assert np.min(ceiling) < -0.05 and ceiling[-1] == pytest.approx(0.0)
assert np.trapezoid(-np.minimum(ceiling, 0.0), T_IDXS) <= HORIZON_SPEED_BUDGET * 10.0 + 1e-9
def test_ongoing_mpc_braking_does_not_ratchet_the_controller(self):
controller = make_controller()
for _ in range(CAP_FILTER_FRAMES):
previous = update(controller, restrictive_radar())
result = update(controller, restrictive_radar(), action_accel=-1.2, planner_accel=-1.0)
assert result.effective_accel_max >= previous.effective_accel_max - 0.60 * DT_MDL - 1e-9
def test_two_dropouts_cannot_release_restriction(self):
controller = make_controller()
for _ in range(CAP_FILTER_FRAMES):
restricted = update(controller, restrictive_radar())
results = [update(controller) for _ in range(2)]
assert all(result.active and result.effective_accel_max <= 0.0 for result in results)
assert all(result.effective_accel_max >= restricted.effective_accel_max for result in results)
def test_relief_requires_consecutive_confirmation(self):
controller = make_controller()
for _ in range(CAP_FILTER_FRAMES):
update(controller, restrictive_radar())
moving_away = make_radar(make_lead(status=True, d_rel=45.0, v_lead_k=13.0))
early = [update(controller, moving_away) for _ in range(RELIEF_CONFIRM_FRAMES - 1)]
assert all(result.state == AccelControllerState.hold and result.effective_accel_max <= 0.0 for result in early)
released = update(controller, moving_away)
assert released.state == AccelControllerState.free
assert released.effective_accel_max <= 0.0
accelerating = update(controller, moving_away)
assert released.effective_accel_max < accelerating.effective_accel_max <= accelerating.positive_accel_max
def test_shallow_brake_relief_uses_long_confirmation_without_delaying_tightening(self):
controller = make_controller()
controller.live.state = AccelControllerState.restrict
controller.live.bound = SHALLOW_BRAKE_BOUND + 0.05
matched = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=10.0))
held = [update(controller, matched) for _ in range(controller.shallow_brake_relief_frames - 1)]
assert all(result.effective_accel_max == pytest.approx(SHALLOW_BRAKE_BOUND + 0.05) for result in held)
relaxed = update(controller, matched)
assert relaxed.effective_accel_max > held[-1].effective_accel_max
for _ in range(CAP_FILTER_FRAMES):
tightened = update(controller, restrictive_radar())
assert tightened.effective_accel_max < relaxed.effective_accel_max
def test_strong_brake_relief_keeps_existing_confirmation(self):
controller = make_controller()
controller.live.state = AccelControllerState.restrict
controller.live.bound = SHALLOW_BRAKE_BOUND - 0.25
matched = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=10.0))
held = [update(controller, matched) for _ in range(RELIEF_CONFIRM_FRAMES - 1)]
assert all(result.effective_accel_max == pytest.approx(SHALLOW_BRAKE_BOUND - 0.25) for result in held)
relaxed = update(controller, matched)
assert relaxed.effective_accel_max > held[-1].effective_accel_max
continuing = [update(controller, matched) for _ in range(8)]
assert all(current.effective_accel_max > previous.effective_accel_max
for previous, current in zip([relaxed, *continuing[:-1]], continuing, strict=True))
def test_urgent_frame_uses_exact_stock_path(self):
urgent = make_radar(make_lead(status=True, d_rel=18.0, v_lead_k=0.0))
result = update(make_controller(), urgent, v_ego=20.0)
assert result.active and result.stock_mode
assert result.mpc_accel_max is None
assert math.isinf(result.effective_accel_max)
def test_urgent_relief_stays_stock_until_braking_has_recovered(self):
controller = make_controller()
urgent = make_radar(make_lead(status=True, d_rel=18.0, v_lead_k=0.0))
update(controller, urgent, v_ego=20.0)
result = update(controller, action_accel=-1.5, planner_accel=-1.2, v_ego=19.8)
assert result.stock_mode
assert result.mpc_accel_max is None
recovered = [update(controller, action_accel=0.0, planner_accel=0.0, v_ego=19.8) for _ in range(RELIEF_CONFIRM_FRAMES)]
assert all(sample.stock_mode for sample in recovered[:-1])
assert recovered[-1].state == AccelControllerState.free
def test_stop_hold_needs_four_departure_frames(self):
controller = make_controller()
stopped = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.0))
held = update(controller, stopped, base_speed=8.0, v_ego=0.1, previous_should_stop=True)
assert held.state == AccelControllerState.stopHold
np.testing.assert_array_equal(held.mpc_accel_max, 0.0)
departing = make_radar(make_lead(status=True, d_rel=8.0, v_lead_k=2.0))
confirmation = [update(controller, departing, base_speed=8.0, v_ego=0.1) for _ in range(STOP_HOLD_EXIT_FRAMES)]
assert all(result.effective_accel_max == 0.0 for result in confirmation[:-1])
launched = confirmation[-1]
assert launched.launching and launched.state == AccelControllerState.free
assert launched.effective_accel_max == launched.positive_accel_max
def test_false_creep_speed_without_range_gain_stays_held(self):
controller = make_controller()
stopped = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.0))
update(controller, stopped, base_speed=8.0, v_ego=0.1, previous_should_stop=True)
false_creep = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.30))
held = [update(controller, false_creep, base_speed=8.0, v_ego=0.1) for _ in range(20)]
assert all(result.state == AccelControllerState.stopHold and not result.launching for result in held)
def test_invalid_lead_geometry_cannot_confirm_departure(self):
controller = make_controller()
stopped = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.0))
update(controller, stopped, base_speed=8.0, v_ego=0.1, previous_should_stop=True)
invalid = make_radar(make_lead(status=True, d_rel=math.nan, v_lead_k=0.30))
held = [update(controller, invalid, base_speed=8.0, v_ego=0.1) for _ in range(2 * STOP_HOLD_EXIT_FRAMES)]
assert all(result.state == AccelControllerState.stopHold and not result.launching for result in held)
def test_short_range_drop_and_restore_cannot_fake_creep_departure(self):
controller = make_controller()
stopped = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.0))
update(controller, stopped, base_speed=8.0, v_ego=0.1, previous_should_stop=True)
low_range = make_radar(make_lead(status=True, d_rel=5.0, v_lead_k=0.0))
for _ in range(2):
update(controller, low_range, base_speed=8.0, v_ego=0.1)
restored = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.30))
results = [update(controller, restored, base_speed=8.0, v_ego=0.1) for _ in range(8)]
assert all(result.state == AccelControllerState.stopHold and not result.launching for result in results)
def test_slow_creep_with_confirmed_range_gain_releases(self):
controller = make_controller()
stopped = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.0))
update(controller, stopped, base_speed=8.0, v_ego=0.1, previous_should_stop=True)
results = []
for frame in range(20):
creep = make_radar(make_lead(status=True, d_rel=6.0 + 0.05 * frame, v_lead_k=0.30))
results.append(update(controller, creep, base_speed=8.0, v_ego=0.1))
launched = [frame for frame, result in enumerate(results) if result.launching]
assert launched and launched[0] >= STOP_HOLD_EXIT_FRAMES
assert all(result.state == AccelControllerState.stopHold for result in results[:launched[0]])
def test_slow_creep_survives_lead_slot_switching(self):
controller = make_controller()
stopped = make_radar(make_lead(status=True, d_rel=6.0), make_lead(status=True, d_rel=6.1))
update(controller, stopped, base_speed=8.0, v_ego=0.1, previous_should_stop=True)
results = []
for frame in range(24):
distance = 6.0 + 0.04 * frame
offset = 0.05 if frame % 2 else 0.0
lead_one = make_lead(status=True, d_rel=distance + offset, v_lead_k=0.30)
lead_two = make_lead(status=True, d_rel=distance + 0.05 - offset, v_lead_k=0.30)
results.append(update(controller, make_radar(lead_one, lead_two), base_speed=8.0, v_ego=0.1))
assert any(result.launching for result in results)
def test_departure_that_stops_again_returns_to_hold(self):
controller = make_controller()
stopped = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.0))
update(controller, stopped, base_speed=8.0, v_ego=0.1, previous_should_stop=True)
departing = make_radar(make_lead(status=True, d_rel=8.0, v_lead_k=2.0))
for _ in range(STOP_HOLD_EXIT_FRAMES):
launched = update(controller, departing, base_speed=8.0, v_ego=0.1)
assert launched.launching
stalled = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.11))
settling = [update(controller, stalled, base_speed=8.0, v_ego=0.1) for _ in range(STOP_HOLD_CREEP_ABORT_FRAMES)]
assert all(result.launching for result in settling[:-1])
assert settling[-1].state == AccelControllerState.stopHold and not settling[-1].launching
def test_invalid_geometry_after_departure_returns_to_hold(self):
controller = make_controller()
stopped = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.0))
update(controller, stopped, base_speed=8.0, v_ego=0.1, previous_should_stop=True)
departing = make_radar(make_lead(status=True, d_rel=8.0, v_lead_k=2.0))
for _ in range(STOP_HOLD_EXIT_FRAMES):
launched = update(controller, departing, base_speed=8.0, v_ego=0.1)
assert launched.launching
invalid = make_radar(make_lead(status=True, d_rel=math.nan, v_lead_k=0.30))
settling = [update(controller, invalid, base_speed=8.0, v_ego=0.1) for _ in range(STOP_HOLD_CREEP_ABORT_FRAMES)]
assert settling[-1].state == AccelControllerState.stopHold and not settling[-1].launching
def test_stale_radar_freezes_then_discards_live_state(self):
controller = make_controller()
for _ in range(CAP_FILTER_FRAMES):
restricted = update(controller, restrictive_radar())
hold_frames = math.ceil(RADAR_STALE_TIMEOUT / DT_MDL) - 1
frozen = [update(controller, radar_fresh=False) for _ in range(hold_frames)]
assert all(result.active and result.effective_accel_max == restricted.effective_accel_max for result in frozen)
timed_out = update(controller, radar_fresh=False)
assert not timed_out.active and timed_out.mpc_accel_max is None
def test_stale_radar_preserves_urgent_stock_passthrough_until_timeout(self):
controller = make_controller()
urgent = make_radar(make_lead(status=True, d_rel=18.0, v_lead_k=0.0))
result = update(controller, urgent, v_ego=20.0)
assert result.active and result.shadow_active and result.stock_mode
hold_frames = math.ceil(RADAR_STALE_TIMEOUT / DT_MDL) - 1
frozen = [update(controller, radar_fresh=False, v_ego=20.0) for _ in range(hold_frames)]
assert all(sample.active and sample.shadow_active and sample.stock_mode for sample in frozen)
assert all(sample.state == AccelControllerState.hold and sample.mpc_accel_max is None for sample in frozen)
timed_out = update(controller, radar_fresh=False, v_ego=20.0)
assert not timed_out.active and not timed_out.shadow_active and not timed_out.stock_mode
@pytest.mark.parametrize("override", [
{"enabled": False}, {"acc_selected": False}, {"engaged": False}, {"cruise_initialized": False}, {"controller_fault": True},
])
def test_bypass_never_actuates(self, override):
result = update(make_controller(), restrictive_radar(), **override)
assert not result.active
assert result.target_speed == result.base_speed
assert result.mpc_accel_max is None
assert math.isinf(result.effective_accel_max)
def test_shadow_history_never_enters_live_actuation(self):
controller = make_controller()
for _ in range(CAP_FILTER_FRAMES):
shadow = update(controller, restrictive_radar(), enabled=False)
assert shadow.shadow_state == AccelControllerState.restrict
live = update(controller)
assert live.state == AccelControllerState.free
assert live.effective_accel_max > 0.0
@pytest.mark.parametrize("v_ego", [0.0, 0.2, 0.5, 1.0, 2.0, 10.0, 40.0])
@pytest.mark.parametrize("bound", [-3.5, -2.0, -1.0, -0.1, 0.0, 0.8, 2.0])
def test_accel_ceiling_properties(v_ego, bound):
result = AccelController._build_accel_ceiling(bound, v_ego, planner_accel=0.3, action_time=0.25)
if bound >= ACCEL_MAX:
assert result is None
return
ceiling = np.asarray(result)
assert ceiling.shape == T_IDXS.shape
assert np.all(np.isfinite(ceiling))
assert np.all((ceiling >= ACCEL_MIN) & (ceiling <= ACCEL_MAX))
assert ceiling[0] >= 0.3 - 1e-9
if bound > 0.0:
np.testing.assert_array_equal(ceiling, min(bound + POSITIVE_MPC_HEADROOM, ACCEL_MAX))
if bound < 0.0:
assert np.trapezoid(-np.minimum(ceiling, 0.0), T_IDXS) <= HORIZON_SPEED_BUDGET * v_ego + 1e-9
@@ -0,0 +1,246 @@
import math
from types import SimpleNamespace
import numpy as np
import pytest
from cereal import custom, messaging
from opendbc.car.interfaces import ACCEL_MAX, ACCEL_MIN
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import N, LongitudinalMpc
from openpilot.sunnypilot.selfdrive.controls.lib.accel_personality import AccelControllerState, AccelProfile
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlannerSP, LongitudinalPlanSource
def radar_state():
return messaging.new_message('radarState').radarState
def test_legacy_profile_enum_keeps_toyota_importable():
expected = {"eco": 0, "normal": 1, "sport": 2}
assert custom.LongitudinalPlanSP.AccelerationPersonality.schema.enumerants == expected
assert custom.LongitudinalPlanSP.AccelController.Profile.schema.enumerants == expected
from opendbc.car.toyota.carstate import AccelPersonality, CarState
assert AccelPersonality.schema.enumerants == expected
assert CarState.__module__ == "opendbc.car.toyota.carstate"
def test_stock_mpc_parameters_are_unchanged():
mpc = LongitudinalMpc()
mpc.set_cur_state(10.0, 0.0)
mpc.run = lambda: None
mpc.update(radar_state(), 30.0)
np.testing.assert_array_equal(mpc.params[:, 0], ACCEL_MIN)
np.testing.assert_array_equal(mpc.params[:, 1], ACCEL_MAX)
def test_positive_scalar_changes_only_acceleration_ceiling():
radar = radar_state()
mpc = LongitudinalMpc()
mpc.set_cur_state(10.0, 0.0)
mpc.run = lambda: None
mpc.update(radar, 30.0)
stock = mpc.params.copy()
stock_source = mpc.source
mpc.update(radar, 30.0, accel_max=0.8)
np.testing.assert_array_equal(mpc.params[:, 0], stock[:, 0])
np.testing.assert_array_equal(mpc.params[:, 1], 0.8)
np.testing.assert_array_equal(mpc.params[:, 2:], stock[:, 2:])
assert mpc.source == stock_source
def test_negative_finite_horizon_ceiling_is_applied_exactly():
ceiling = np.linspace(0.2, -0.8, N + 1)
mpc = LongitudinalMpc()
mpc.set_cur_state(10.0, 0.2)
mpc.run = lambda: None
mpc.update(radar_state(), 30.0, accel_max=ceiling)
np.testing.assert_allclose(mpc.params[:, 1], ceiling)
np.testing.assert_array_equal(mpc.params[:, 0], ACCEL_MIN)
@pytest.mark.parametrize("accel_max", [np.inf, np.nan, -0.4, ACCEL_MIN, np.ones(N), "invalid"])
def test_invalid_or_negative_scalar_limit_is_exact_stock(accel_max):
radar = radar_state()
mpc = LongitudinalMpc()
mpc.set_cur_state(10.0, 0.0)
mpc.run = lambda: None
mpc.update(radar, 30.0)
stock = mpc.params.copy()
mpc.update(radar, 30.0, accel_max=accel_max)
np.testing.assert_array_equal(mpc.params, stock)
def test_custom_ceiling_keeps_raw_lead_obstacle_and_source_authoritative():
radar = radar_state()
radar.leadOne.status = True
radar.leadOne.dRel = 30.0
radar.leadOne.vLead = 5.0
radar.leadOne.aLeadK = 0.0
radar.leadOne.aLeadTau = 1.0
before = (radar.leadOne.dRel, radar.leadOne.vLead, radar.leadOne.aLeadK)
mpc = LongitudinalMpc()
mpc.set_cur_state(20.0, 0.0)
mpc.run = lambda: None
mpc.update(radar, 30.0)
stock = mpc.params.copy()
stock_source = mpc.source
mpc.update(radar, 30.0, accel_max=np.linspace(0.0, -0.5, N + 1))
np.testing.assert_array_equal(mpc.params[:, 0], stock[:, 0])
np.testing.assert_array_equal(mpc.params[:, 2:], stock[:, 2:])
assert mpc.source == stock_source
assert (radar.leadOne.dRel, radar.leadOne.vLead, radar.leadOne.aLeadK) == before
def test_retry_seed_is_bounded_and_nonnegative_in_speed():
planner = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
planner.mpc = LongitudinalMpc()
states = []
planner.mpc.solver = SimpleNamespace(set=lambda _stage, field, value: states.append(np.asarray(value)) if field == 'x' else None)
planner.mpc.set_cur_state(0.0, ACCEL_MIN)
planner._seed_mpc_current_state()
states = np.asarray(states)
assert len(states) == N + 1
assert np.all(np.diff(states[:, 0]) >= 0.0)
assert np.all(states[:, 1] >= 0.0)
assert np.all((states[:, 2] >= ACCEL_MIN) & (states[:, 2] <= ACCEL_MAX))
def test_last_solve_failure_survives_internal_reset():
mpc = LongitudinalMpc()
mpc.last_solution_status = 3
mpc.reset()
assert mpc.solution_status == 0
assert mpc.last_solution_status == 3
@pytest.mark.parametrize(("controller_active", "expected_accel"), [(True, -1.2), (False, None)])
def test_e2e_to_acc_handoff_preserves_braking_only_when_controller_is_active(controller_active, expected_accel):
planner = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
planner._previous_is_e2e = True
planner.accel_personality_enabled = controller_active
planner.accel_controller_fault_latched = False
planner.is_e2e = lambda _sm: False
planner.accel_controller = SimpleNamespace(reset=lambda: None)
planner.mpc = SimpleNamespace(
a_prev=np.zeros(N + 1), crash_cnt=0, v_solution=np.zeros(N + 1), a_solution=np.zeros(N + 1),
j_solution=np.zeros(N), last_solution_status=0,
)
planner.update_accel_controller = lambda *_args, **_kwargs: setattr(
planner, "accel_controller_result",
SimpleNamespace(enabled=controller_active, active=controller_active, shadow_active=controller_active,
stock_mode=not controller_active, target_speed=20.0, mpc_accel_max=None,
state=AccelControllerState.free, effective_accel_max=0.8 if controller_active else np.inf),
)
calls = []
planner._run_mpc = lambda *_args, **kwargs: calls.append(kwargs)
is_e2e = planner.update_accel_controller_mpc(
{}, 20.0, 20.0, True, reset_state=False, cruise_initialized=True, planner_accel=0.1,
previous_output_accel=-1.2, available_accel_max=ACCEL_MAX, previous_should_stop=False, force_decel=False,
)
assert not is_e2e
assert calls[0]["current_accel"] == expected_accel
assert calls[0]["seed_target"] is None
def test_failed_e2e_to_acc_handoff_retries_without_custom_state():
planner = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
planner._previous_is_e2e = True
planner.accel_personality_enabled = True
planner.accel_controller_fault_latched = False
planner.is_e2e = lambda _sm: False
reset_calls = []
planner.accel_controller = SimpleNamespace(reset=lambda: reset_calls.append(True))
planner.mpc = SimpleNamespace(
a_prev=np.zeros(N + 1), crash_cnt=0, v_solution=np.zeros(N + 1), a_solution=np.zeros(N + 1),
j_solution=np.zeros(N), last_solution_status=0,
)
planner.update_accel_controller = lambda *_args, **_kwargs: setattr(
planner, "accel_controller_result",
SimpleNamespace(enabled=True, active=True, shadow_active=True, stock_mode=True, target_speed=15.0, mpc_accel_max=None,
state=AccelControllerState.hold, effective_accel_max=np.inf),
)
calls = []
positional_calls = []
def run_mpc(*args, **kwargs):
positional_calls.append(args)
calls.append(kwargs)
planner.mpc.last_solution_status = 1 if len(calls) == 1 else 0
planner._run_mpc = run_mpc
planner.update_accel_controller_mpc(
{}, 20.0, 20.0, True, reset_state=False, cruise_initialized=True, planner_accel=0.1,
previous_output_accel=-1.2, available_accel_max=ACCEL_MAX, previous_should_stop=False, force_decel=False,
)
assert [call.get("current_accel") for call in calls] == [-1.2, None]
assert [args[1] for args in positional_calls] == [15.0, 20.0]
assert len(positional_calls[1]) == 3
assert calls[0]["seed_target"] is None
assert calls[1]["seed"]
assert "current_accel" not in calls[1]
assert "retry_state" in calls[1]
assert reset_calls == [True]
assert planner.accel_controller_fault_latched
def test_e2e_to_acc_handoff_never_turns_braking_into_acceleration():
planner = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
planner._previous_is_e2e = True
planner.accel_personality_enabled = True
planner.accel_controller_fault_latched = False
planner.is_e2e = lambda _sm: False
planner.accel_controller = SimpleNamespace(reset=lambda: None)
planner.mpc = SimpleNamespace(
a_prev=np.zeros(N + 1), crash_cnt=0, v_solution=np.zeros(N + 1), a_solution=np.zeros(N + 1),
j_solution=np.zeros(N), last_solution_status=0,
)
planner.update_accel_controller = lambda *_args, **_kwargs: setattr(
planner, "accel_controller_result",
SimpleNamespace(enabled=True, active=True, shadow_active=True, stock_mode=True, target_speed=20.0, mpc_accel_max=None,
state=AccelControllerState.free, effective_accel_max=np.inf),
)
calls = []
planner._run_mpc = lambda *_args, **kwargs: calls.append(kwargs)
planner.update_accel_controller_mpc(
{}, 20.0, 20.0, True, reset_state=False, cruise_initialized=True, planner_accel=-0.7,
previous_output_accel=0.3, available_accel_max=ACCEL_MAX, previous_should_stop=False, force_decel=False,
)
assert calls[0]["current_accel"] == -0.7
def test_shadow_telemetry_publishes_controller_fields():
planner = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
planner.source = LongitudinalPlanSource.cruise
planner.output_v_target = 20.0
planner.output_a_target = 0.0
planner.events_sp = SimpleNamespace(to_msg=list)
planner.dec = SimpleNamespace(mode=lambda: "acc", enabled=lambda: False, active=lambda: False)
planner.accel_controller_result = SimpleNamespace(
enabled=True, active=False, shadow_active=True, profile=AccelProfile.normal, state=AccelControllerState.inactive,
shadow_state=AccelControllerState.restrict, base_speed=20.0, raw_energy_cap=15.0, live_filtered_cap=np.inf,
shadow_filtered_cap=12.5, selected_lead=1, usable_gap=30.0, closing_speed=5.0, required_decel=0.4,
profile_accel_max=np.inf, effective_accel_max=np.inf,
)
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),
)
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)
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)
planner.e2e_alerts_helper = SimpleNamespace(green_light_alert=False, lead_depart_alert=False)
sent = {}
planner.publish_longitudinal_plan_sp(SimpleNamespace(all_checks=lambda service_list: True),
SimpleNamespace(send=lambda service, message: sent.update({service: message})))
telemetry = sent["longitudinalPlanSP"].longitudinalPlanSP.accelController
assert telemetry.vTargetShadow == pytest.approx(12.5)
assert telemetry.aMaxProfile == math.inf
assert telemetry.aMaxEffective == math.inf
@@ -1,17 +1,43 @@
from openpilot.common.realtime import DT_MDL
class WMACConstants:
# Lead detection parameters
LEAD_WINDOW_SIZE = 6 # Stable detection window
LEAD_PROB = 0.45 # Balanced threshold for lead detection
TRAJECTORY_SIZE = 33
PARAM_READ_FRAMES = max(1, int(round(1.0 / DT_MDL)))
# Slow down detection parameters
SLOW_DOWN_WINDOW_SIZE = 5 # Responsive but stable
SLOW_DOWN_PROB = 0.3 # Balanced threshold for slow down scenarios
EMERGENCY_HOLD_FRAMES = max(1, int(round(0.75 / DT_MDL)))
MIN_MODE_DURATION = {'acc': max(1, int(round(0.6 / DT_MDL))), 'blended': max(1, int(round(0.5 / DT_MDL)))}
ENTER_BLENDED_FRAMES = max(1, int(round(0.4 / DT_MDL)))
EXIT_BLENDED_FRAMES = max(1, int(round(0.35 / DT_MDL)))
STANDSTILL_FRAMES = max(1, int(round(0.2 / DT_MDL)))
# Optimized slow down distance curve - smooth and progressive
LEAD_PROB = 0.45
LEAD_EXIT_PROB = 0.25
LEAD_RISE_RATE = 1.0
LEAD_FALL_RATE = 0.35
RADAR_LEAD_ACC_PROB = 0.5
RADAR_LEAD_ACC_EXIT_PROB = 0.4
RADAR_LEAD_ACC_RISE_RATE = 1.0
RADAR_LEAD_ACC_FALL_RATE = 0.25
SLOW_DOWN_PROB = 0.5
SLOW_DOWN_EXIT_PROB = 0.4
SLOW_DOWN_RISE_RATE = 0.65
SLOW_DOWN_FALL_RATE = 0.15
SLOW_DOWN_BP = [0., 10., 20., 30., 40., 50., 55., 60.]
SLOW_DOWN_DIST = [32., 46., 64., 86., 108., 130., 145., 165.]
URGENT_SLOW_DOWN_PROB = 0.85
# Slowness detection parameters
SLOWNESS_WINDOW_SIZE = 10 # Stable slowness detection
SLOWNESS_PROB = 0.55 # Clear threshold for slowness
SLOWNESS_CRUISE_OFFSET = 1.025 # Conservative cruise speed offset
MODEL_DECEL_START = -0.5
MODEL_DECEL_RANGE = 2.0
ENDPOINT_URGENCY_GAIN = 1.3
CRITICAL_ENDPOINT_FACTOR = 0.3
CRITICAL_URGENCY_GAIN = 1.5
SPEED_URGENCY_MIN = 25.0
SPEED_URGENCY_RANGE = 80.0
SLOWNESS_PROB = 0.55
SLOWNESS_EXIT_PROB = 0.45
SLOWNESS_RISE_RATE = 0.35
SLOWNESS_FALL_RATE = 0.5
SLOWNESS_CRUISE_OFFSET = 1.025
+168 -258
View File
@@ -6,129 +6,116 @@ See the LICENSE.md file in the root directory for more details.
"""
# Version = 2025-6-30
from 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
SET_MODE_TIMEOUT = 15
from cereal import messaging
from numpy import interp
from opendbc.car import structs
from openpilot.common.params import Params
from openpilot.sunnypilot.selfdrive.controls.lib.dec.constants import WMACConstants
# Define the valid mode types
ModeType = Literal['acc', 'blended']
class SmoothKalmanFilter:
"""Enhanced Kalman filter with smoothing for stable decision making."""
def clip01(value: float) -> float:
return max(0.0, min(1.0, float(value)))
def __init__(self, initial_value=0, measurement_noise=0.1, process_noise=0.01,
alpha=1.0, smoothing_factor=0.85):
self.x = initial_value
self.P = 1.0
self.R = measurement_noise
self.Q = process_noise
self.alpha = alpha
self.smoothing_factor = smoothing_factor
self.initialized = False
self.history = []
self.max_history = 10
self.confidence = 0.0
def add_data(self, measurement):
if len(self.history) >= self.max_history:
self.history.pop(0)
self.history.append(measurement)
class SmoothedSignal:
def __init__(self, rise_rate: float, fall_rate: float, initial_value: float = 0.0):
self.rise_rate = clip01(rise_rate)
self.fall_rate = clip01(fall_rate)
self.value = clip01(initial_value)
if not self.initialized:
self.x = measurement
self.initialized = True
self.confidence = 0.1
return
def update(self, measurement: float) -> float:
measurement = clip01(measurement)
rate = self.rise_rate if measurement > self.value else self.fall_rate
self.value += (measurement - self.value) * rate
return self.value
self.P = self.alpha * self.P + self.Q
def reset(self, value: float = 0.0) -> None:
self.value = clip01(value)
K = self.P / (self.P + self.R)
effective_K = K * (1.0 - self.smoothing_factor) + self.smoothing_factor * 0.1
innovation = measurement - self.x
self.x = self.x + effective_K * innovation
self.P = (1 - effective_K) * self.P
class HysteresisSignal:
def __init__(self, enter_threshold: float, exit_threshold: float, rise_rate: float, fall_rate: float):
self.enter_threshold = clip01(enter_threshold)
self.exit_threshold = clip01(exit_threshold)
self.filter = SmoothedSignal(rise_rate, fall_rate)
self.active = False
if abs(innovation) < 0.1:
self.confidence = min(1.0, self.confidence + 0.05)
else:
self.confidence = max(0.1, self.confidence - 0.02)
def update(self, measurement: float) -> bool:
value = self.filter.update(measurement)
threshold = self.exit_threshold if self.active else self.enter_threshold
self.active = value > threshold
return self.active
def get_value(self):
return self.x if self.initialized else None
def reset(self) -> None:
self.filter.reset()
self.active = False
def get_confidence(self):
return self.confidence
def reset_data(self):
self.initialized = False
self.history = []
self.confidence = 0.0
@property
def value(self) -> float:
return self.filter.value
class ModeTransitionManager:
"""Manages smooth transitions between driving modes with hysteresis."""
def __init__(self):
self.current_mode: ModeType = 'acc'
self.mode_confidence = {'acc': 1.0, 'blended': 0.0}
self.transition_timeout = 0
self.min_mode_duration = 10
self.mode_duration = 0
self.emergency_override = False
self._pending_mode: ModeType = 'acc'
self._pending_count = 0
self._blended_hold_frames = 0
def request_mode(self, mode: ModeType, confidence: float = 1.0, emergency: bool = False):
# Emergency override for critical situations (stops, collisions)
if emergency:
self.emergency_override = True
self.current_mode = mode
self.transition_timeout = SET_MODE_TIMEOUT
self.mode_duration = 0
def request_mode(self, mode: ModeType, immediate: bool = False, hold_frames: int = 0, cancel_hold: bool = False) -> None:
if immediate:
self._blended_hold_frames = max(self._blended_hold_frames, hold_frames) if mode == 'blended' else 0
self._pending_mode = mode
self._pending_count = 0
self._switch_mode(mode)
return
self.mode_confidence[mode] = min(1.0, self.mode_confidence[mode] + 0.1 * confidence)
for m in self.mode_confidence:
if m != mode:
self.mode_confidence[m] = max(0.0, self.mode_confidence[m] - 0.05)
if cancel_hold and mode == 'acc':
self._blended_hold_frames = 0
# Require minimum duration in current mode (unless emergency)
if self.mode_duration < self.min_mode_duration and not self.emergency_override:
if self._blended_hold_frames > 0:
mode = 'blended'
if mode == self.current_mode:
self._pending_mode = mode
self._pending_count = 0
return
# Hysteresis: higher threshold for mode changes
confidence_threshold = 0.6 if mode != self.current_mode else 0.3 # Lower threshold for faster response
if mode != self._pending_mode:
self._pending_mode = mode
self._pending_count = 1
else:
self._pending_count += 1
if self.mode_confidence[mode] > confidence_threshold:
if mode != self.current_mode and self.transition_timeout == 0:
self.transition_timeout = SET_MODE_TIMEOUT
self.current_mode = mode
self.mode_duration = 0
if self.mode_duration < WMACConstants.MIN_MODE_DURATION[self.current_mode]:
return
def update(self):
if self.transition_timeout > 0:
self.transition_timeout -= 1
required_count = WMACConstants.ENTER_BLENDED_FRAMES if mode == 'blended' else WMACConstants.EXIT_BLENDED_FRAMES
if self._pending_count >= required_count:
self._switch_mode(mode)
def update(self) -> None:
if self._blended_hold_frames > 0:
self._blended_hold_frames -= 1
self.mode_duration += 1
# Reset emergency override after some time
if self.emergency_override and self.mode_duration > 20:
self.emergency_override = False
# Gradual confidence decay
for mode in self.mode_confidence:
self.mode_confidence[mode] *= 0.98
def get_mode(self) -> ModeType:
return self.current_mode
def _switch_mode(self, mode: ModeType) -> None:
if mode == self.current_mode:
return
self.current_mode = mode
self.mode_duration = 0
self._pending_mode = mode
self._pending_count = 0
class DynamicExperimentalController:
def __init__(self, CP: structs.CarParams, mpc, params=None):
@@ -142,35 +129,33 @@ class DynamicExperimentalController:
self._mode_manager = ModeTransitionManager()
# Smooth filters for stable decision making with faster response for critical scenarios
self._lead_filter = SmoothKalmanFilter(
measurement_noise=0.15,
process_noise=0.05,
alpha=1.02,
smoothing_factor=0.8
self._lead_tracker = HysteresisSignal(
enter_threshold=WMACConstants.LEAD_PROB,
exit_threshold=WMACConstants.LEAD_EXIT_PROB,
rise_rate=WMACConstants.LEAD_RISE_RATE,
fall_rate=WMACConstants.LEAD_FALL_RATE,
)
self._radar_acc_lead_tracker = HysteresisSignal(
enter_threshold=WMACConstants.RADAR_LEAD_ACC_PROB,
exit_threshold=WMACConstants.RADAR_LEAD_ACC_EXIT_PROB,
rise_rate=WMACConstants.RADAR_LEAD_ACC_RISE_RATE,
fall_rate=WMACConstants.RADAR_LEAD_ACC_FALL_RATE,
)
self._slow_down_tracker = HysteresisSignal(
enter_threshold=WMACConstants.SLOW_DOWN_PROB,
exit_threshold=WMACConstants.SLOW_DOWN_EXIT_PROB,
rise_rate=WMACConstants.SLOW_DOWN_RISE_RATE,
fall_rate=WMACConstants.SLOW_DOWN_FALL_RATE,
)
self._slowness_tracker = HysteresisSignal(
enter_threshold=WMACConstants.SLOWNESS_PROB,
exit_threshold=WMACConstants.SLOWNESS_EXIT_PROB,
rise_rate=WMACConstants.SLOWNESS_RISE_RATE,
fall_rate=WMACConstants.SLOWNESS_FALL_RATE,
)
self._slow_down_filter = SmoothKalmanFilter(
measurement_noise=0.1,
process_noise=0.1,
alpha=1.05,
smoothing_factor=0.7
)
self._slowness_filter = SmoothKalmanFilter(
measurement_noise=0.1,
process_noise=0.06,
alpha=1.015,
smoothing_factor=0.92
)
self._mpc_fcw_filter = SmoothKalmanFilter(
measurement_noise=0.2,
process_noise=0.1,
alpha=1.1,
smoothing_factor=0.5
)
self._has_lead_filtered = False
self._has_radar_acc_lead = False
self._has_slow_down = False
self._has_slowness = False
self._has_mpc_fcw = False
@@ -179,13 +164,14 @@ class DynamicExperimentalController:
self._has_standstill = False
self._mpc_fcw_crash_cnt = 0
self._standstill_count = 0
# debug
self._endpoint_x = float('inf')
self._expected_distance = 0.0
self._trajectory_valid = False
self._raw_urgency = 0.0
def _read_params(self) -> None:
if self._frame % int(1. / DT_MDL) == 0:
if self._frame % WMACConstants.PARAM_READ_FRAMES == 0:
self._enabled = self._params.get_bool("DynamicExperimentalControl")
def mode(self) -> str:
@@ -198,191 +184,115 @@ class DynamicExperimentalController:
return self._active
def set_mpc_fcw_crash_cnt(self) -> None:
"""Set MPC FCW crash count"""
self._mpc_fcw_crash_cnt = self._mpc.crash_cnt
def _update_calculations(self, sm: messaging.SubMaster) -> None:
car_state = sm['carState']
lead_one = sm['radarState'].leadOne
radar_state = sm['radarState']
lead_one = radar_state.leadOne
lead_two = radar_state.leadTwo
md = sm['modelV2']
self._v_ego_kph = car_state.vEgo * 3.6
self._v_cruise_kph = car_state.vCruise
self._has_standstill = car_state.standstill
# standstill detection
if self._has_standstill:
self._standstill_count = min(20, self._standstill_count + 1)
self._standstill_count = min(WMACConstants.STANDSTILL_FRAMES * 3, self._standstill_count + 1)
else:
self._standstill_count = max(0, self._standstill_count - 1)
# Lead detection
self._lead_filter.add_data(float(lead_one.status))
lead_value = self._lead_filter.get_value() or 0.0
self._has_lead_filtered = lead_value > WMACConstants.LEAD_PROB
# MPC FCW detection
fcw_filtered_value = self._mpc_fcw_filter.get_value() or 0.0
self._mpc_fcw_filter.add_data(float(self._mpc_fcw_crash_cnt > 0))
self._has_mpc_fcw = fcw_filtered_value > 0.5
# Slow down detection
self._has_lead_filtered = self._lead_tracker.update(float(lead_one.status))
radar_acc_lead_score = max(self._radar_acc_lead_score(lead_one), self._radar_acc_lead_score(lead_two))
self._has_radar_acc_lead = self._radar_acc_lead_tracker.update(radar_acc_lead_score)
self._has_mpc_fcw = self._mpc_fcw_crash_cnt > 0
self._calculate_slow_down(md)
# Slowness detection
if not (self._standstill_count > 5) and not self._has_slow_down:
if self._standstill_count > WMACConstants.STANDSTILL_FRAMES or self._has_slow_down:
self._slowness_tracker.reset()
self._has_slowness = False
else:
current_slowness = float(self._v_ego_kph <= (self._v_cruise_kph * WMACConstants.SLOWNESS_CRUISE_OFFSET))
self._slowness_filter.add_data(current_slowness)
slowness_value = self._slowness_filter.get_value() or 0.0
self._has_slowness = self._slowness_tracker.update(current_slowness)
# Hysteresis for slowness
threshold = WMACConstants.SLOWNESS_PROB * (0.8 if self._has_slowness else 1.1)
self._has_slowness = slowness_value > threshold
def _calculate_slow_down(self, md):
"""Calculate urgency based on trajectory endpoint vs expected distance."""
# Reset to safe defaults
urgency = 0.0
def _calculate_slow_down(self, md) -> None:
self._endpoint_x = float('inf')
self._expected_distance = 0.0
self._trajectory_valid = False
#Require exact trajectory size
position_valid = len(md.position.x) == TRAJECTORY_SIZE
orientation_valid = len(md.orientation.x) == TRAJECTORY_SIZE
urgency = self._model_action_urgency(md)
position_valid = len(md.position.x) == WMACConstants.TRAJECTORY_SIZE
if not (position_valid and orientation_valid):
# Invalid trajectory - this itself might indicate a stop scenario
# Apply moderate urgency for incomplete trajectories at speed
if self._v_ego_kph > 20.0:
urgency = 0.3
if position_valid:
self._trajectory_valid = True
self._endpoint_x = md.position.x[WMACConstants.TRAJECTORY_SIZE - 1]
self._expected_distance = interp(self._v_ego_kph, WMACConstants.SLOW_DOWN_BP, WMACConstants.SLOW_DOWN_DIST)
urgency = max(urgency, self._endpoint_urgency(self._endpoint_x, self._expected_distance))
self._slow_down_filter.add_data(urgency)
urgency_filtered = self._slow_down_filter.get_value() or 0.0
self._has_slow_down = urgency_filtered > WMACConstants.SLOW_DOWN_PROB
self._urgency = urgency_filtered
return
self._raw_urgency = clip01(urgency)
self._has_slow_down = self._slow_down_tracker.update(self._raw_urgency)
self._urgency = self._slow_down_tracker.value
# We have a valid full trajectory
self._trajectory_valid = True
def _radar_acc_lead_score(self, lead_one) -> float:
radar_track_id = int(getattr(lead_one, 'radarTrackId', -1))
return float(lead_one.status and (bool(getattr(lead_one, 'radar', False)) or radar_track_id >= 0))
# Use the exact endpoint (33rd point, index 32)
endpoint_x = md.position.x[TRAJECTORY_SIZE - 1]
self._endpoint_x = endpoint_x
def _model_action_urgency(self, md) -> float:
action = getattr(md, 'action', None)
if action is None:
return 0.0
# Get expected distance based on current speed using tuned constants
expected_distance = interp(self._v_ego_kph,
WMACConstants.SLOW_DOWN_BP,
WMACConstants.SLOW_DOWN_DIST)
self._expected_distance = expected_distance
urgency = 1.0 if getattr(action, 'shouldStop', False) else 0.0
desired_accel = getattr(action, 'desiredAcceleration', 0.0)
if desired_accel < WMACConstants.MODEL_DECEL_START:
urgency = max(urgency, min(1.0, (WMACConstants.MODEL_DECEL_START - desired_accel) / WMACConstants.MODEL_DECEL_RANGE))
return urgency
# Calculate urgency based on trajectory shortage
if endpoint_x < expected_distance:
shortage = expected_distance - endpoint_x
shortage_ratio = shortage / expected_distance
def _endpoint_urgency(self, endpoint_x: float, expected_distance: float) -> float:
if endpoint_x >= expected_distance:
return 0.0
# Base urgency on shortage ratio
urgency = min(1.0, shortage_ratio * 2.0)
shortage_ratio = (expected_distance - endpoint_x) / expected_distance
urgency = min(1.0, shortage_ratio * WMACConstants.ENDPOINT_URGENCY_GAIN)
# Increase urgency for very short trajectories (imminent stops)
critical_distance = expected_distance * 0.3
if endpoint_x < critical_distance:
urgency = min(1.0, urgency * 2.0)
if endpoint_x < expected_distance * WMACConstants.CRITICAL_ENDPOINT_FACTOR:
urgency = min(1.0, urgency * WMACConstants.CRITICAL_URGENCY_GAIN)
# Speed-based urgency adjustment
if self._v_ego_kph > 25.0:
speed_factor = 1.0 + (self._v_ego_kph - 25.0) / 80.0
urgency = min(1.0, urgency * speed_factor)
if self._v_ego_kph > WMACConstants.SPEED_URGENCY_MIN:
speed_factor = 1.0 + (self._v_ego_kph - WMACConstants.SPEED_URGENCY_MIN) / WMACConstants.SPEED_URGENCY_RANGE
urgency = min(1.0, urgency * speed_factor)
# Apply filtering but with less smoothing for stops
self._slow_down_filter.add_data(urgency)
urgency_filtered = self._slow_down_filter.get_value() or 0.0
return urgency
# Update state with lower threshold for better stop detection
self._has_slow_down = urgency_filtered > (WMACConstants.SLOW_DOWN_PROB * 0.8)
self._urgency = urgency_filtered
def _desired_mode(self) -> tuple[ModeType, bool]:
if not self._CP.radarUnavailable and self._has_radar_acc_lead:
return 'acc', True
def _radarless_mode(self) -> None:
"""Radarless mode decision logic with emergency handling."""
# EMERGENCY: MPC FCW - immediate blended mode
if self._has_mpc_fcw:
self._mode_manager.request_mode('blended', confidence=1.0, emergency=True)
return
return 'blended', True
# Standstill: use blended
if self._standstill_count > 3:
self._mode_manager.request_mode('blended', confidence=0.9)
return
standstill = self._standstill_count > WMACConstants.STANDSTILL_FRAMES
urgent_slow_down = self._has_slow_down and self._raw_urgency > WMACConstants.URGENT_SLOW_DOWN_PROB
# Slow down scenarios: emergency for high urgency, normal for lower urgency
if self._has_slow_down:
if self._urgency > 0.7:
# Emergency: immediate blended mode for high urgency stops
self._mode_manager.request_mode('blended', confidence=1.0, emergency=True)
else:
# Normal: blended with urgency-based confidence
confidence = min(1.0, self._urgency * 1.5)
self._mode_manager.request_mode('blended', confidence=confidence)
return
if self._CP.radarUnavailable:
if standstill or self._has_slow_down:
return 'blended', urgent_slow_down
return 'acc', False
# Driving slow: use ACC (but not if actively slowing down)
if self._has_slowness and not self._has_slow_down:
self._mode_manager.request_mode('acc', confidence=0.8)
return
if standstill or self._has_slow_down:
return 'blended', urgent_slow_down
# Default: ACC
self._mode_manager.request_mode('acc', confidence=0.7)
def _radar_mode(self) -> None:
"""Radar mode with emergency handling."""
# EMERGENCY: MPC FCW - immediate blended mode
if self._has_mpc_fcw:
self._mode_manager.request_mode('blended', confidence=1.0, emergency=True)
return
# If lead detected and not in standstill: always use ACC
if self._has_lead_filtered and not (self._standstill_count > 3):
self._mode_manager.request_mode('acc', confidence=1.0)
return
# Slow down scenarios: emergency for high urgency, normal for lower urgency
if self._has_slow_down:
if self._urgency > 0.7:
# Emergency: immediate blended mode for high urgency stops
self._mode_manager.request_mode('blended', confidence=1.0, emergency=True)
else:
# Normal: blended with urgency-based confidence
confidence = min(1.0, self._urgency * 1.3)
self._mode_manager.request_mode('blended', confidence=confidence)
return
# Standstill: use blended
if self._standstill_count > 3:
self._mode_manager.request_mode('blended', confidence=0.9)
return
# Driving slow: use ACC (but not if actively slowing down)
if self._has_slowness and not self._has_slow_down:
self._mode_manager.request_mode('acc', confidence=0.8)
return
# Default: ACC
self._mode_manager.request_mode('acc', confidence=0.7)
return 'acc', False
def update(self, sm: messaging.SubMaster) -> None:
self._read_params()
self.set_mpc_fcw_crash_cnt()
self._update_calculations(sm)
if self._CP.radarUnavailable:
self._radarless_mode()
else:
self._radar_mode()
mode, immediate = self._desired_mode()
self._mode_manager.request_mode(mode, immediate=immediate, hold_frames=WMACConstants.EMERGENCY_HOLD_FRAMES,
cancel_hold=not self._CP.radarUnavailable and self._has_radar_acc_lead)
self._mode_manager.update()
self._active = sm['selfdriveState'].experimentalMode and self._enabled
self._frame += 1
@@ -1,94 +0,0 @@
import pytest
from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController
class MockLeadOne:
def __init__(self, status=0.0):
self.status = status
class MockRadarState:
def __init__(self, status=0.0):
self.leadOne = MockLeadOne(status=status)
class MockCarState:
def __init__(self, vEgo=0.0, vCruise=0.0, standstill=False):
self.vEgo = vEgo
self.vCruise = vCruise
self.standstill = standstill
class MockModelData:
def __init__(self, valid=True):
size = 33 if valid else 10 # incomplete if invalid
self.position = type("Pos", (), {"x": [0.0] * size})()
self.orientation = type("Ori", (), {"x": [0.0] * size})()
class MockSelfDriveState:
def __init__(self, experimentalMode=False):
self.experimentalMode = experimentalMode
class MockParams:
def get_bool(self, name):
return True
@pytest.fixture
def default_sm():
sm = {
'carState': MockCarState(vEgo=10.0, vCruise=20.0),
'radarState': MockRadarState(status=1.0),
'modelV2': MockModelData(valid=True),
'selfdriveState': MockSelfDriveState(experimentalMode=True),
}
return sm
@pytest.fixture
def mock_cp():
class CP:
radarUnavailable = False
return CP()
@pytest.fixture
def mock_mpc():
class MPC:
crash_cnt = 0
return MPC()
# Fake Kalman Filter that always returns a given value
class FakeKalman:
def __init__(self, value=1.0):
self.value = value
def add_data(self, v): pass
def get_value(self): return self.value
def get_confidence(self): return 1.0
def reset_data(self): pass
def test_initial_mode_is_acc(mock_cp, mock_mpc):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
assert controller.mode() == "acc"
def test_standstill_triggers_blended(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['carState'].standstill = True
for _ in range(10):
controller.update(default_sm)
assert controller.mode() == "blended"
def test_emergency_blended_on_fcw(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
mock_mpc.crash_cnt = 1 # simulate FCW
for _ in range(2):
controller.update(default_sm)
assert controller.mode() == "blended"
def test_radarless_slowdown_triggers_blended(mock_cp, mock_mpc, default_sm):
mock_cp.radarUnavailable = True
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
# Force conditions to simulate slowdown
controller._slow_down_filter = FakeKalman(value=1.0) # Ensure urgency triggers slowdown
controller._v_ego_kph = 35.0
default_sm['modelV2'] = MockModelData(valid=False) # Incomplete trajectory
for _ in range(3):
controller.update(default_sm)
assert controller.mode() == "blended"
@@ -0,0 +1,313 @@
import pytest
from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController, HysteresisSignal
class MockLeadOne:
def __init__(self, status=0.0, dRel=30.0, vRel=0.0, radar=False, radarTrackId=-1):
self.status = status
self.dRel = dRel
self.vRel = vRel
self.radar = radar
self.radarTrackId = radarTrackId
class MockRadarState:
def __init__(self, status=0.0, dRel=30.0, vRel=0.0, radar=False, radarTrackId=-1, leadTwo=None):
self.leadOne = MockLeadOne(status=status, dRel=dRel, vRel=vRel, radar=radar, radarTrackId=radarTrackId)
self.leadTwo = leadTwo if leadTwo is not None else MockLeadOne()
class MockCarState:
def __init__(self, vEgo=0.0, vCruise=0.0, standstill=False):
self.vEgo = vEgo
self.vCruise = vCruise
self.standstill = standstill
class MockAction:
def __init__(self, desiredAcceleration=0.0, shouldStop=False):
self.desiredAcceleration = desiredAcceleration
self.shouldStop = shouldStop
class MockModelData:
def __init__(self, valid=True, endpoint_x=200.0, orientation_valid=None, desired_acceleration=0.0, should_stop=False):
position_size = 33 if valid else 10
orientation_size = position_size if orientation_valid is None else (33 if orientation_valid else 10)
position_x = [0.0] * position_size
if position_x:
position_x[-1] = endpoint_x
self.position = type("Pos", (), {"x": position_x})()
self.orientation = type("Ori", (), {"x": [0.0] * orientation_size})()
self.acceleration = type("Accel", (), {"x": [0.0] * position_size})()
self.action = MockAction(desired_acceleration, should_stop)
class MockSelfDriveState:
def __init__(self, experimentalMode=False):
self.experimentalMode = experimentalMode
class MockParams:
def get_bool(self, name):
return True
@pytest.fixture
def default_sm():
sm = {
'carState': MockCarState(vEgo=10.0, vCruise=20.0),
'radarState': MockRadarState(status=1.0, radar=True, radarTrackId=7),
'modelV2': MockModelData(valid=True),
'selfdriveState': MockSelfDriveState(experimentalMode=True),
}
return sm
@pytest.fixture
def mock_cp():
class CP:
radarUnavailable = False
return CP()
@pytest.fixture
def mock_mpc():
class MPC:
crash_cnt = 0
return MPC()
def test_initial_mode_is_acc(mock_cp, mock_mpc):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
assert controller.mode() == "acc"
def test_standstill_triggers_blended(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=0.0)
default_sm['carState'].standstill = True
for _ in range(20):
controller.update(default_sm)
assert controller.mode() == "blended"
def test_emergency_blended_on_fcw(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=0.0)
mock_mpc.crash_cnt = 1
controller.update(default_sm)
assert controller.mode() == "blended"
def test_radarless_slowdown_triggers_blended(mock_cp, mock_mpc, default_sm):
mock_cp.radarUnavailable = True
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=0.0)
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
controller.update(default_sm)
assert controller.mode() == "blended"
def test_valid_position_with_missing_orientation_can_trigger_slowdown(mock_cp, mock_mpc, default_sm):
mock_cp.radarUnavailable = True
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=0.0)
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0, orientation_valid=False)
controller.update(default_sm)
assert controller._trajectory_valid
assert controller.mode() == "blended"
def test_incomplete_position_does_not_trigger_slowdown(mock_cp, mock_mpc, default_sm):
mock_cp.radarUnavailable = True
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=0.0)
default_sm['modelV2'] = MockModelData(valid=False, endpoint_x=0.0)
for _ in range(3):
controller.update(default_sm)
assert not controller._trajectory_valid
assert not controller._has_slow_down
assert controller.mode() == "acc"
def test_slowdown_hysteresis_prevents_threshold_chatter():
signal = HysteresisSignal(enter_threshold=0.5, exit_threshold=0.4, rise_rate=1.0, fall_rate=1.0)
assert signal.update(0.55)
assert signal.update(0.45)
assert not signal.update(0.35)
def test_model_should_stop_triggers_blended_without_valid_trajectory(mock_cp, mock_mpc, default_sm):
mock_cp.radarUnavailable = True
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=0.0)
default_sm['modelV2'] = MockModelData(valid=False, should_stop=True)
controller.update(default_sm)
assert not controller._trajectory_valid
assert controller.mode() == "blended"
def test_radar_lead_keeps_acc_over_model_slowdown(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=1.0, radar=True, radarTrackId=7)
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
for _ in range(3):
controller.update(default_sm)
assert controller._has_slow_down
assert controller._has_radar_acc_lead
assert controller.mode() == "acc"
def test_far_radar_lead_always_uses_acc(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=1.0, dRel=120.0, vRel=0.0, radar=True)
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
controller.update(default_sm)
assert controller._has_lead_filtered
assert controller._has_radar_acc_lead
assert controller.mode() == "acc"
def test_radar_acquisition_immediately_returns_blended_to_acc(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=0.0)
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
controller.update(default_sm)
assert controller.mode() == "blended"
default_sm['radarState'] = MockRadarState(status=1.0, dRel=120.0, radar=True, radarTrackId=7)
controller.update(default_sm)
assert controller._has_radar_acc_lead
assert controller.mode() == "acc"
default_sm['radarState'] = MockRadarState(status=0.0)
default_sm['modelV2'] = MockModelData(valid=True)
for _ in range(20):
controller.update(default_sm)
assert controller.mode() == "acc"
def test_close_vision_only_lead_can_use_blended(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=1.0, dRel=30.0, vRel=-5.0)
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
controller.update(default_sm)
assert not controller._has_radar_acc_lead
assert controller.mode() == "blended"
def test_second_radar_lead_forces_acc(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
lead_two = MockLeadOne(status=1.0, dRel=120.0, radar=True, radarTrackId=8)
default_sm['radarState'] = MockRadarState(status=1.0, dRel=30.0, vRel=-5.0, leadTwo=lead_two)
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
controller.update(default_sm)
assert controller._has_radar_acc_lead
assert controller.mode() == "acc"
def test_second_vision_only_lead_does_not_force_acc(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
lead_two = MockLeadOne(status=1.0, dRel=20.0, vRel=-10.0)
default_sm['radarState'] = MockRadarState(status=0.0, leadTwo=lead_two)
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
controller.update(default_sm)
assert not controller._has_radar_acc_lead
assert controller.mode() == "blended"
def test_inactive_lead_with_radar_marker_does_not_force_acc(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=0.0, radar=True, radarTrackId=7)
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
controller.update(default_sm)
assert not controller._has_radar_acc_lead
assert controller.mode() == "blended"
def test_radarless_car_ignores_marked_radar_track(mock_cp, mock_mpc, default_sm):
mock_cp.radarUnavailable = True
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=1.0, radar=True, radarTrackId=7)
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
controller.update(default_sm)
assert controller._has_radar_acc_lead
assert controller.mode() == "blended"
def test_closing_far_radar_lead_returns_to_acc(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=1.0, dRel=120.0, vRel=-25.0, radarTrackId=7)
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
for _ in range(20):
controller.update(default_sm)
assert controller._has_radar_acc_lead
assert controller.mode() == "acc"
def test_radar_lead_keeps_acc_over_fcw_and_standstill(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=1.0, radar=True, radarTrackId=7)
default_sm['carState'].standstill = True
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0, should_stop=True)
mock_mpc.crash_cnt = 1
for _ in range(10):
controller.update(default_sm)
assert controller._has_lead_filtered
assert controller._has_mpc_fcw
assert controller.mode() == "acc"
def test_lead_flicker_hold_prevents_one_frame_mode_flip(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=1.0, radar=True, radarTrackId=7)
controller.update(default_sm)
default_sm['radarState'] = MockRadarState(status=0.0)
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
controller.update(default_sm)
assert controller._has_lead_filtered
assert controller.mode() == "acc"
def test_radar_lead_dropout_guard_expires(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=1.0, radar=True, radarTrackId=7)
controller.update(default_sm)
default_sm['radarState'] = MockRadarState(status=0.0)
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
for _ in range(3):
controller.update(default_sm)
assert controller._has_radar_acc_lead
assert controller.mode() == "acc"
controller.update(default_sm)
assert not controller._has_radar_acc_lead
assert controller.mode() == "blended"
@@ -5,10 +5,19 @@ 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 numpy as np
from cereal import messaging, custom
from opendbc.car import structs
from opendbc.car.interfaces import ACCEL_MIN, ACCEL_MAX
from openpilot.common.constants import CV
from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.car.cruise import V_CRUISE_MAX
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import N, T_IDXS
from openpilot.sunnypilot import get_sanitize_int_param
from openpilot.sunnypilot.selfdrive.controls.lib.accel_personality import AccelController, AccelControllerState, AccelProfile
from openpilot.sunnypilot.selfdrive.controls.lib.accel_personality.constants import MPC_SEED_RISE_RATE
from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController
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
@@ -22,7 +31,8 @@ LongitudinalPlanSource = custom.LongitudinalPlanSP.LongitudinalPlanSource
class LongitudinalPlannerSP:
def __init__(self, CP: structs.CarParams, CP_SP: structs.CarParamsSP, mpc):
def __init__(self, CP: structs.CarParams, CP_SP: structs.CarParamsSP, mpc, dt: float = DT_MDL):
self.params = Params()
self.events_sp = EventsSP()
self.resolver = SpeedLimitResolver()
self.dec = DynamicExperimentalController(CP, mpc)
@@ -32,10 +42,28 @@ class LongitudinalPlannerSP:
self.generation = int(model_bundle.generation) if (model_bundle := get_active_bundle()) else None
self.source = LongitudinalPlanSource.cruise
self.e2e_alerts_helper = E2EAlertsHelper()
self.accel_controller = AccelController(CP, dt=dt)
self.accel_controller_result = None
self.accel_controller_fault_latched = False
self._previous_is_e2e = False
self._param_read_frames = max(1, int(round(0.25 / dt)))
self._param_frame = 0
self.accel_personality_enabled = False
self.accel_personality = int(AccelProfile.normal)
self.output_v_target = 0.
self.output_a_target = 0.
def _read_accel_controller_params(self) -> None:
if self._param_frame % self._param_read_frames == 0:
self.accel_personality_enabled = self.params.get_bool("AccelPersonalityEnabled")
self.accel_personality = get_sanitize_int_param(
"AccelPersonality", int(AccelProfile.eco), int(AccelProfile.sport), self.params,
)
self._param_frame += 1
def is_e2e(self, sm: messaging.SubMaster) -> bool:
experimental_mode = sm['selfdriveState'].experimentalMode
if not self.dec.active():
@@ -73,7 +101,108 @@ class LongitudinalPlannerSP:
self.output_v_target, self.output_a_target = targets[self.source]
return self.output_v_target, self.output_a_target
@staticmethod
def _radar_fresh(sm: messaging.SubMaster) -> bool:
try:
return bool(sm.updated['radarState'] and sm.valid['radarState'] and sm.alive['radarState'])
except (AttributeError, KeyError, TypeError):
return True
def update_accel_controller(self, sm: messaging.SubMaster, base_speed: float, engaged: bool, cruise_initialized: bool,
acc_selected: bool, planner_accel: float, action_accel: float, stock_accel_max: float,
previous_should_stop: bool, controller_fault: bool = False) -> float:
self.accel_controller_result = self.accel_controller.update(
sm['radarState'], base_speed=base_speed, v_ego=sm['carState'].vEgo, a_ego=sm['carState'].aEgo,
profile=self.accel_personality, follow_personality=sm['selfdriveState'].personality,
enabled=self.accel_personality_enabled, acc_selected=acc_selected, engaged=engaged, cruise_initialized=cruise_initialized,
planner_accel=planner_accel, action_accel=action_accel, stock_accel_max=stock_accel_max,
previous_should_stop=previous_should_stop, controller_fault=controller_fault, radar_fresh=self._radar_fresh(sm),
)
return self.accel_controller_result.target_speed
def _run_mpc(self, sm: messaging.SubMaster, v_cruise: float, prev_accel_constraint: bool, accel_max=None, *, seed=False,
seed_target=None, seed_rise_rate=MPC_SEED_RISE_RATE, retry_state=None, current_accel=None) -> None:
if retry_state is not None:
self.mpc.a_prev = retry_state[0].copy()
self.mpc.crash_cnt = retry_state[1]
self.mpc.set_weights(prev_accel_constraint, personality=sm['selfdriveState'].personality)
mpc_accel = self.a_desired if current_accel is None else float(np.clip(current_accel, ACCEL_MIN, ACCEL_MAX))
self.mpc.set_cur_state(self.v_desired_filter.x, mpc_accel)
if seed or seed_target is not None:
self._seed_mpc_current_state(seed_target, seed_rise_rate)
self.mpc.update(sm['radarState'], v_cruise, personality=sm['selfdriveState'].personality, accel_max=accel_max)
def _seed_mpc_current_state(self, accel_target=None, rise_rate=MPC_SEED_RISE_RATE) -> None:
target = float(np.clip(self.mpc.x0[2] if accel_target is None else accel_target, ACCEL_MIN, ACCEL_MAX))
desired_accel = target * np.ones(N + 1) if accel_target is None else np.minimum(self.mpc.x0[2] + rise_rate * T_IDXS, target)
acceleration = np.zeros(N + 1)
velocity = np.zeros(N + 1)
position = np.zeros(N + 1)
jerk = np.zeros(N)
acceleration[0] = self.mpc.x0[2]
velocity[0] = max(self.mpc.x0[1], 0.0)
position[0] = self.mpc.x0[0]
for idx in range(1, N + 1):
dt = T_IDXS[idx] - T_IDXS[idx - 1]
min_accel = 0.0 if velocity[idx - 1] <= 1e-3 and acceleration[idx - 1] < 0.0 else -2.0 * velocity[idx - 1] / dt - acceleration[idx - 1]
acceleration[idx] = np.clip(max(desired_accel[idx], min_accel), ACCEL_MIN, ACCEL_MAX)
jerk[idx - 1] = (acceleration[idx] - acceleration[idx - 1]) / dt
position[idx] = max(position[idx - 1], position[idx - 1] + velocity[idx - 1] * dt + 0.5 * acceleration[idx - 1] * dt**2
+ jerk[idx - 1] * dt**3 / 6.0)
velocity[idx] = max(0.0, velocity[idx - 1] + 0.5 * (acceleration[idx - 1] + acceleration[idx]) * dt)
for idx in range(N + 1):
self.mpc.solver.set(idx, 'x', np.array([position[idx], velocity[idx], acceleration[idx]]))
for idx in range(N):
self.mpc.solver.set(idx, 'u', np.array([jerk[idx]]))
def update_accel_controller_mpc(self, sm: messaging.SubMaster, base_v_cruise: float, mpc_v_cruise: float,
prev_accel_constraint: bool, *, reset_state: bool, cruise_initialized: bool,
planner_accel: float, previous_output_accel: float, available_accel_max: float,
previous_should_stop: bool, force_decel: bool):
is_e2e = self.is_e2e(sm)
was_e2e = self._previous_is_e2e
if reset_state or not self.accel_personality_enabled:
self.accel_controller_fault_latched = False
self.update_accel_controller(
sm, base_v_cruise, engaged=not reset_state and not force_decel, cruise_initialized=cruise_initialized,
acc_selected=not is_e2e, planner_accel=planner_accel, action_accel=previous_output_accel,
stock_accel_max=available_accel_max, previous_should_stop=previous_should_stop,
controller_fault=self.accel_controller_fault_latched,
)
result = self.accel_controller_result
handoff_context = result.enabled and result.shadow_active and not force_decel and not self.accel_controller_fault_latched
transition_from_e2e = handoff_context and was_e2e and not is_e2e and result.active
handoff_accel = (min(planner_accel, previous_output_accel)
if transition_from_e2e and result.active and np.isfinite(previous_output_accel) else None)
self._previous_is_e2e = is_e2e and handoff_context
controller_actuating = result.active and not result.stock_mode and not force_decel
accel_max = result.mpc_accel_max if controller_actuating else None
free_profile_limit = controller_actuating and result.state == AccelControllerState.free and result.effective_accel_max > 0.0
seed_target = result.effective_accel_max if free_profile_limit and handoff_accel is None else None
custom_mpc = handoff_accel is not None or (controller_actuating and (accel_max is not None or seed_target is not None))
retry_state = (self.mpc.a_prev.copy(), self.mpc.crash_cnt)
controller_v_cruise = min(mpc_v_cruise, result.target_speed)
self._run_mpc(sm, controller_v_cruise, prev_accel_constraint, accel_max, seed_target=seed_target, current_accel=handoff_accel)
finite_solution = all(np.all(np.isfinite(solution)) for solution in (self.mpc.v_solution, self.mpc.a_solution, self.mpc.j_solution))
custom_failed = custom_mpc and (self.mpc.last_solution_status != 0 or not finite_solution)
if custom_failed:
self.accel_controller_fault_latched = True
self.accel_controller.reset()
self._run_mpc(sm, mpc_v_cruise, prev_accel_constraint, seed=True, retry_state=retry_state)
self.update_accel_controller(
sm, base_v_cruise, engaged=not reset_state and not force_decel, cruise_initialized=cruise_initialized,
acc_selected=not is_e2e, planner_accel=planner_accel, action_accel=previous_output_accel,
stock_accel_max=available_accel_max, previous_should_stop=previous_should_stop, controller_fault=True,
)
if custom_failed and self.mpc.last_solution_status != 0:
self.mpc.a_prev, self.mpc.crash_cnt = retry_state
return is_e2e
def update(self, sm: messaging.SubMaster) -> None:
self._read_accel_controller_params()
self.events_sp.clear()
self.dec.update(sm)
self.e2e_alerts_helper.update(sm, self.events_sp)
@@ -95,6 +224,25 @@ class LongitudinalPlannerSP:
dec.enabled = self.dec.enabled()
dec.active = self.dec.active()
if self.accel_controller_result is not None:
result = self.accel_controller_result
accel_controller = longitudinalPlanSP.accelController
accel_controller.enabled = result.enabled
accel_controller.active = result.active
accel_controller.shadowOnly = result.shadow_active and not result.active
accel_controller.profile = int(result.profile)
accel_controller.state = int(result.state if result.active else result.shadow_state)
accel_controller.vTargetBase = float(result.base_speed)
accel_controller.vTargetRaw = float(result.raw_energy_cap)
accel_controller.vTargetFiltered = float(result.live_filtered_cap)
accel_controller.vTargetShadow = float(result.shadow_filtered_cap)
accel_controller.leadIndex = result.selected_lead
accel_controller.usableGap = float(result.usable_gap)
accel_controller.closingSpeed = float(result.closing_speed)
accel_controller.requiredDecel = float(result.required_decel)
accel_controller.aMaxProfile = float(result.profile_accel_max)
accel_controller.aMaxEffective = float(result.effective_accel_max)
# Smart Cruise Control
smartCruiseControl = longitudinalPlanSP.smartCruiseControl
# Vision Control
+84
View File
@@ -0,0 +1,84 @@
"""
Copyright (c) 2021-, rav4kumar, 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 numpy as np
from openpilot.common.constants import CV
from openpilot.common.realtime import DT_MDL
from openpilot.common.params import Params
NEARSIDE_PROB = 0.2
EDGE_PROB = 0.35
EDGE_REACTION_TIME = 1.0
EDGE_CLEAR_TIME = 0.3
MIN_SPEED = 20 * CV.MPH_TO_MS
class RoadEdgeLaneChangeController:
def __init__(self, desire_helper):
self.DH = desire_helper
self.params = Params()
self.enabled = self.params.get_bool("RoadEdgeLaneChangeEnabled")
self.param_read_counter = 0
self.left_edge_detected = False
self.right_edge_detected = False
self.left_edge_timer = 0.0
self.right_edge_timer = 0.0
self.left_clear_timer = 0.0
self.right_clear_timer = 0.0
def read_params(self) -> None:
self.enabled = self.params.get_bool("RoadEdgeLaneChangeEnabled")
def update_params(self) -> None:
if self.param_read_counter % 50 == 0:
self.read_params()
self.param_read_counter += 1
def reset(self) -> None:
self.left_edge_detected = False
self.right_edge_detected = False
self.left_edge_timer = 0.0
self.right_edge_timer = 0.0
self.left_clear_timer = 0.0
self.right_clear_timer = 0.0
def update(self, road_edge_stds, lane_line_probs, v_ego: float) -> None:
self.update_params()
if not self.enabled or v_ego < MIN_SPEED:
self.reset()
return
left_edge_prob = np.clip(1.0 - road_edge_stds[0], 0.0, 1.0)
right_edge_prob = np.clip(1.0 - road_edge_stds[1], 0.0, 1.0)
left_lane_prob = lane_line_probs[0]
right_lane_prob = lane_line_probs[3]
left_cond = left_edge_prob > EDGE_PROB and left_lane_prob < NEARSIDE_PROB and right_lane_prob >= left_lane_prob
right_cond = right_edge_prob > EDGE_PROB and right_lane_prob < NEARSIDE_PROB and left_lane_prob >= right_lane_prob
if left_cond:
self.left_edge_timer = min(self.left_edge_timer + DT_MDL, EDGE_REACTION_TIME + EDGE_CLEAR_TIME)
self.left_clear_timer = 0.0
if self.left_edge_timer > EDGE_REACTION_TIME:
self.left_edge_detected = True
else:
self.left_clear_timer += DT_MDL
if self.left_clear_timer > EDGE_CLEAR_TIME:
self.left_edge_timer = 0.0
self.left_edge_detected = False
if right_cond:
self.right_edge_timer = min(self.right_edge_timer + DT_MDL, EDGE_REACTION_TIME + EDGE_CLEAR_TIME)
self.right_clear_timer = 0.0
if self.right_edge_timer > EDGE_REACTION_TIME:
self.right_edge_detected = True
else:
self.right_clear_timer += DT_MDL
if self.right_clear_timer > EDGE_CLEAR_TIME:
self.right_edge_timer = 0.0
self.right_edge_detected = False
@@ -0,0 +1,990 @@
from collections.abc import Callable
from dataclasses import dataclass
import gc
import numpy as np
import pytest
from opendbc.car.interfaces import ACCEL_MAX, ACCEL_MIN
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import STOP_DISTANCE, get_T_FOLLOW
from openpilot.selfdrive.controls.lib.longitudinal_planner import get_max_accel
from openpilot.selfdrive.test.longitudinal_maneuvers.plant import PRIUS_TSS2_ROUTE_MODEL, LeadObservation, Plant
from openpilot.sunnypilot.selfdrive.controls.lib.accel_personality import AccelControllerState, AccelProfile
ROUTINE_GAP_TOLERANCE = 0.10
@dataclass
class ClosedLoopTrace:
time: np.ndarray
speed: np.ndarray
distance: np.ndarray
distance_lead: np.ndarray
a_target: np.ndarray
acceleration: np.ndarray
should_stop: np.ndarray
fcw: np.ndarray
source: list
active: np.ndarray
shadow_active: np.ndarray
launching: np.ndarray
target_speed: np.ndarray
stock_mode: np.ndarray
raw_cap: np.ndarray
filtered_cap: np.ndarray
selected_lead: np.ndarray
profile_accel_max: np.ndarray
effective_accel_max: np.ndarray
state: np.ndarray
required_decel: np.ndarray
controller_fault: np.ndarray
controller_fault_latched: np.ndarray
mpc_accel_max: np.ndarray
actuator_command: np.ndarray
solver_status: np.ndarray
solver_failures: int
solver_failure_times: list[float]
def _configure_plant(plant: Plant, *, enabled: bool, profile: int = 1, dec_enabled: bool = False) -> None:
plant.planner.accel_personality_enabled = enabled
plant.planner.accel_personality = profile
plant.planner._read_accel_controller_params = lambda: None
plant.planner.dec._enabled = dec_enabled
plant.planner.dec._read_params = lambda: None
def _run(
*,
duration: float,
controller_enabled: bool,
profile: int = 1,
v_lead: float | Callable[[float], float] = 0.0,
v_cruise: float = 30.0,
dec_enabled: bool = False,
**plant_kwargs,
) -> ClosedLoopTrace:
gc.collect()
plant = Plant(**plant_kwargs)
_configure_plant(plant, enabled=controller_enabled, profile=profile, dec_enabled=dec_enabled)
plant.v_lead_prev = float(v_lead) if isinstance(v_lead, (int, float)) else float(v_lead(0.0))
solver_failures = 0
solver_failure_times = []
original_mpc_reset = plant.planner.mpc.reset
def count_failed_solve() -> None:
nonlocal solver_failures
if plant.planner.mpc.solution_status != 0:
solver_failures += 1
solver_failure_times.append(plant.current_time)
original_mpc_reset()
plant.planner.mpc.reset = count_failed_solve
rows = []
sources = []
while plant.current_time < duration:
lead_speed = float(v_lead) if isinstance(v_lead, (int, float)) else v_lead(plant.current_time)
controller_fault = plant.planner.mpc.last_solution_status != 0
result = plant.step(v_lead=lead_speed, v_cruise=v_cruise)
controller = plant.planner.accel_controller_result
rows.append(
(
plant.current_time,
result["speed"],
result["distance"],
result["distance_lead"],
result["a_target"],
result["realized_acceleration"],
result["should_stop"],
result["fcw"],
controller.active,
controller.shadow_active,
controller.launching,
controller.target_speed,
controller.stock_mode,
controller.raw_energy_cap,
controller.live_filtered_cap,
controller.selected_lead,
controller.profile_accel_max,
controller.effective_accel_max,
controller.state,
controller.required_decel,
controller_fault,
plant.planner.accel_controller_fault_latched,
min(controller.mpc_accel_max) if controller.mpc_accel_max is not None else np.nan,
result["actuator_command"],
plant.planner.mpc.last_solution_status,
)
)
sources.append(result["mpc_source"])
data = np.asarray(rows, dtype=float)
trace = ClosedLoopTrace(
time=data[:, 0],
speed=data[:, 1],
distance=data[:, 2],
distance_lead=data[:, 3],
a_target=data[:, 4],
acceleration=data[:, 5],
should_stop=data[:, 6].astype(bool),
fcw=data[:, 7].astype(bool),
source=sources,
active=data[:, 8].astype(bool),
shadow_active=data[:, 9].astype(bool),
launching=data[:, 10].astype(bool),
target_speed=data[:, 11],
stock_mode=data[:, 12].astype(bool),
raw_cap=data[:, 13],
filtered_cap=data[:, 14],
selected_lead=data[:, 15].astype(int),
profile_accel_max=data[:, 16],
effective_accel_max=data[:, 17],
state=data[:, 18].astype(int),
required_decel=data[:, 19],
controller_fault=data[:, 20].astype(bool),
controller_fault_latched=data[:, 21].astype(bool),
mpc_accel_max=data[:, 22],
actuator_command=data[:, 23],
solver_status=data[:, 24].astype(int),
solver_failures=solver_failures,
solver_failure_times=solver_failure_times,
)
plant.planner.mpc.reset = original_mpc_reset
gc.collect()
return trace
def _first_time_below(trace: ClosedLoopTrace, threshold: float) -> float:
indices = np.flatnonzero(trace.a_target <= threshold)
assert len(indices), f"never reached {threshold} m/s²"
return float(trace.time[indices[0]])
def _sustained_time_below(trace: ClosedLoopTrace, threshold: float, *, after: float = 0.5, duration: float = 0.5) -> float:
required_frames = round(duration / DT_MDL)
below = (trace.time >= after) & (trace.a_target <= threshold)
sustained = np.convolve(below.astype(int), np.ones(required_frames, dtype=int), mode="valid") == required_frames
indices = np.flatnonzero(sustained)
assert len(indices), f"never sustained {threshold} m/s² for {duration} s"
return float(trace.time[indices[0]])
def _command_jerk(trace: ClosedLoopTrace, after: float = 0.0) -> np.ndarray:
indices = np.flatnonzero(trace.time >= after)
assert len(indices) >= 2
return np.diff(trace.a_target[indices]) / DT_MDL
def _filtered_realized_jerk(trace: ClosedLoopTrace, after: float = 1.0, min_speed: float = 0.0) -> np.ndarray:
filtered_acceleration = np.convolve(trace.acceleration, np.ones(3) / 3.0, mode="valid")
samples = (trace.time[2:-1] >= after) & (trace.speed[2:-1] >= min_speed)
return (np.diff(filtered_acceleration) / DT_MDL)[samples]
def _has_brake_coast_brake(values: np.ndarray, brake: float = -0.8, coast: float = -0.35, frames: int = 2) -> bool:
phase = 0
for index in range(len(values) - frames + 1):
window = values[index : index + frames]
if np.all(window <= brake):
if phase == 2:
return True
phase = 1
elif phase == 1 and np.all(window >= coast):
phase = 2
return False
def _has_propulsion_after_braking(values: np.ndarray, propulsion: float = 0.2, brake: float = -0.2, frames: int = 2) -> bool:
braking = False
for index in range(len(values) - frames + 1):
window = values[index : index + frames]
if np.all(window <= brake):
braking = True
elif braking and np.all(window >= propulsion):
return True
return False
def _has_propulsion_brake_cycle(values: np.ndarray, propulsion: float = 0.2, brake: float = -0.2, frames: int = 2) -> bool:
phases = []
for index in range(len(values) - frames + 1):
window = values[index : index + frames]
phase = 1 if np.all(window >= propulsion) else -1 if np.all(window <= brake) else 0
if phase and (not phases or phase != phases[-1]):
phases.append(phase)
if len(phases) >= 3 and phases[-1] == phases[-3]:
return True
return False
@pytest.mark.parametrize(
("plant_kwargs", "expect_shadow"),
[
({"enabled": False, "lead_relevancy": True, "speed": 20.0, "distance_lead": 70.0}, False),
({"e2e": True, "lead_relevancy": False, "speed": 20.0}, True),
],
ids=("disengaged", "e2e-shadow"),
)
def test_non_actuating_modes_match_clean_base(plant_kwargs, expect_shadow):
common = dict(duration=2.0, v_lead=14.0, **plant_kwargs)
baseline = _run(controller_enabled=False, **common)
trace = _run(controller_enabled=True, **common)
np.testing.assert_allclose(trace.a_target, baseline.a_target, atol=1e-6, rtol=0.0)
np.testing.assert_array_equal(trace.should_stop, baseline.should_stop)
np.testing.assert_array_equal(trace.fcw, baseline.fcw)
assert trace.source == baseline.source
assert not trace.active.any()
np.testing.assert_array_equal(trace.shadow_active, np.full_like(trace.active, expect_shadow))
def test_disabled_profiles_match_clean_base():
common = dict(duration=2.0, controller_enabled=False, lead_relevancy=True, speed=20.0, distance_lead=70.0, v_lead=14.0)
traces = [_run(profile=profile, **common) for profile in range(3)]
for trace in traces[1:]:
np.testing.assert_allclose(trace.a_target, traces[0].a_target, atol=1e-6, rtol=0.0)
np.testing.assert_array_equal(trace.should_stop, traces[0].should_stop)
assert trace.source == traces[0].source
assert all(np.isinf(trace.effective_accel_max).all() for trace in traces)
@pytest.mark.parametrize("lead_relevancy", (False, True), ids=("clear-road", "lead"))
def test_force_decel_matches_controller_off(lead_relevancy):
common = dict(duration=2.0, force_decel=True, lead_relevancy=lead_relevancy, speed=20.0,
distance_lead=70.0, v_lead=14.0, profile=0)
baseline = _run(controller_enabled=False, **common)
trace = _run(controller_enabled=True, **common)
np.testing.assert_allclose(trace.a_target, baseline.a_target, atol=1e-6, rtol=0.0)
np.testing.assert_array_equal(trace.should_stop, baseline.should_stop)
np.testing.assert_array_equal(trace.fcw, baseline.fcw)
assert trace.source == baseline.source
def test_e2e_to_radar_acc_handoff_keeps_braking_continuous():
plant = Plant(
lead_relevancy=True, speed=10.0, distance_lead=30.0, actuator_delay=0.15, actuator_lag=0.20,
model_action_fn=lambda current_time, _v_ego, _a_ego: (-1.0 if current_time < 2.0 else 0.0, False),
)
_configure_plant(plant, enabled=True)
rows = []
while plant.current_time < 2.4:
plant.e2e = plant.current_time < 2.0
result = plant.step(v_lead=8.0, v_cruise=20.0)
rows.append((plant.current_time, result["a_target"], plant.planner.mpc.last_solution_status,
plant.planner.accel_controller_result.active))
time_values, acceleration, solver_status, active = np.asarray(rows, dtype=float).T
transition = np.flatnonzero(time_values > 2.0)[0]
assert acceleration[transition] - acceleration[transition - 1] < 0.15
assert np.max(np.diff(acceleration[transition - 1:]) / DT_MDL) < 3.0
assert not solver_status[transition:].any()
assert active[transition]
def test_active_controller_is_pre_mpc_and_preserves_stock_lead_authority():
plant = Plant(lead_relevancy=False, speed=0.0, actuator_delay=0.15, actuator_lag=0.20)
_configure_plant(plant, enabled=True, profile=0)
result = plant.step(v_cruise=15.0)
controller = plant.planner.accel_controller_result
assert controller.mpc_accel_max is not None
np.testing.assert_allclose(plant.planner.mpc.params[:, 1], controller.mpc_accel_max)
assert np.all((plant.planner.mpc.params[:, 1] >= 0.0) & (plant.planner.mpc.params[:, 1] <= ACCEL_MAX))
assert ACCEL_MIN <= result["a_target"] <= get_max_accel(plant.speed)
for _ in range(100):
result = plant.step(v_cruise=15.0)
if plant.speed >= 0.30:
break
assert plant.speed >= 0.30
controller = plant.planner.accel_controller_result
assert controller.mpc_accel_max is not None
np.testing.assert_allclose(plant.planner.mpc.params[:, 1], controller.mpc_accel_max)
assert np.all((plant.planner.mpc.params[:, 1] >= 0.0) & (plant.planner.mpc.params[:, 1] <= ACCEL_MAX))
lead_plant = Plant(lead_relevancy=True, speed=0.0, distance_lead=6.0, actuator_delay=0.15, actuator_lag=0.20)
_configure_plant(lead_plant, enabled=True, profile=0)
lead_plant.step(v_lead=0.0, v_cruise=15.0)
controller = lead_plant.planner.accel_controller_result
assert controller.target_speed == 0.0
np.testing.assert_array_equal(controller.mpc_accel_max, 0.0)
np.testing.assert_array_equal(lead_plant.planner.mpc.params[:, 1], 0.0)
def test_clear_road_launch_is_immediate_and_profiles_separate():
common = dict(
duration=6.0,
controller_enabled=True,
lead_relevancy=False,
speed=0.0,
v_cruise=15.0,
actuator_delay=0.15,
actuator_lag=0.20,
)
traces = [_run(profile=profile, **common) for profile in range(3)]
for trace in traces:
positive = np.flatnonzero(trace.a_target > 0.05)
moving = np.flatnonzero(trace.speed > 0.01)
assert len(positive) and trace.time[positive[0]] <= 4 * DT_MDL
assert len(moving) and trace.time[moving[0]] <= 1.0
assert np.all(trace.effective_accel_max[trace.active] > 0.0)
assert not np.any(trace.a_target < -0.05)
assert trace.solver_failures == 0
onset_times = [float(trace.time[np.flatnonzero(trace.a_target > 0.05)[0]]) for trace in traces]
assert max(onset_times) - min(onset_times) <= DT_MDL
def test_profiles_have_distinct_moving_speed_preshape():
traces = [
_run(
duration=18.0,
controller_enabled=True,
profile=profile,
lead_relevancy=False,
speed=0.0,
v_cruise=30.0,
actuator_delay=0.15,
actuator_lag=0.20,
)
for profile in range(3)
]
samples = [np.flatnonzero(trace.speed >= 10.0)[0] for trace in traces]
configured = [float(trace.profile_accel_max[index]) for trace, index in zip(traces, samples, strict=True)]
effective = [float(trace.effective_accel_max[index]) for trace, index in zip(traces, samples, strict=True)]
assert configured[0] < configured[1] < configured[2]
assert effective[0] < effective[1] < effective[2]
speed_grid = np.linspace(5.0, 16.0, 45)
moving_acceleration = [np.interp(speed_grid, trace.speed, trace.a_target) for trace in traces]
assert np.all(moving_acceleration[1] - moving_acceleration[0] > 0.10)
assert np.all(moving_acceleration[2] - moving_acceleration[1] > 0.05)
assert all(trace.solver_failures == 0 for trace in traces)
def test_runtime_profile_switch_is_distinct_and_smooth():
plant = Plant(lead_relevancy=False, speed=0.0, actuator_delay=0.15, actuator_lag=0.20)
_configure_plant(plant, enabled=True, profile=AccelProfile.sport)
while plant.speed < 10.0 and plant.current_time < 15.0:
plant.step(v_cruise=30.0)
assert plant.speed >= 10.0
switch_start = plant.current_time
rows = []
while plant.current_time < switch_start + 5.0:
elapsed = plant.current_time - switch_start
profile = AccelProfile.sport if elapsed < 1.0 or elapsed >= 3.0 else AccelProfile.eco
plant.planner.accel_personality = profile
result = plant.step(v_cruise=30.0)
controller = plant.planner.accel_controller_result
rows.append((plant.current_time - switch_start, profile, result["a_target"], controller.effective_accel_max,
plant.planner.mpc.last_solution_status, plant.planner.accel_controller_fault_latched))
data = np.asarray(rows, dtype=float)
time_values, profiles, acceleration, effective_max, solver_status, fault_latched = data.T
settled_eco = (profiles == AccelProfile.eco) & (time_values >= 2.0) & (time_values < 3.0)
settled_sport = (profiles == AccelProfile.sport) & (time_values >= 4.0)
assert np.max(effective_max[settled_eco]) < np.min(effective_max[settled_sport])
assert np.mean(acceleration[settled_eco]) + 0.15 < np.mean(acceleration[settled_sport])
switch_window = ((time_values[1:] >= 0.5) & (time_values[1:] <= 1.5)) | ((time_values[1:] >= 2.5) & (time_values[1:] <= 3.5))
assert np.max(np.abs(np.diff(acceleration)[switch_window] / DT_MDL)) < 3.0
assert np.min(acceleration) >= -0.05
assert not solver_status.any()
assert not fault_latched.any()
def test_clear_road_acceleration_crosses_lut_without_solver_failure():
trace = _run(
duration=12.0,
controller_enabled=True,
profile=1,
lead_relevancy=False,
speed=0.0,
v_cruise=22.352,
actuator_delay=0.15,
actuator_lag=0.20,
)
assert np.max(trace.speed) > 10.0
assert trace.solver_failures == 0
assert np.all(trace.effective_accel_max[trace.active] > 0.0)
def test_prius_route_model_launches_without_a_dead_pedal():
trace = _run(
duration=3.0,
controller_enabled=True,
profile=1,
lead_relevancy=False,
speed=0.0,
v_cruise=22.352,
actuator_model=PRIUS_TSS2_ROUTE_MODEL,
)
positive = np.flatnonzero(trace.a_target > 0.05)
moving = np.flatnonzero(trace.speed > 0.05)
assert len(positive) and trace.time[positive[0]] <= 4 * DT_MDL
assert len(moving) and trace.time[moving[0]] <= 1.0
assert trace.solver_failures == 0
@pytest.mark.parametrize(
("actuator_delay", "actuator_lag"),
[(0.10, 0.20), (0.15, 0.25), (0.20, 0.20), (0.25, 0.30), (0.30, 0.35)],
ids=("toyota", "honda", "gm", "hyundai", "ford"),
)
def test_stopped_lead_requires_four_departure_frames_and_launches_promptly(actuator_delay, actuator_lag):
departure_time = 1.0
def lead_speed(current_time: float) -> float:
return 0.0 if current_time < departure_time else 2.0
trace = _run(
duration=2.5,
controller_enabled=True,
lead_relevancy=True,
speed=0.0,
distance_lead=6.0,
v_lead=lead_speed,
v_cruise=8.0,
actuator_delay=actuator_delay,
actuator_lag=actuator_lag,
)
first_three = (trace.time > departure_time) & (trace.time <= departure_time + 3 * DT_MDL + 1e-9)
assert np.max(trace.speed[first_three]) < 1e-3
assert not trace.launching[first_three].any()
departure_release = np.flatnonzero((trace.time >= departure_time) & trace.launching)
assert len(departure_release) and trace.time[departure_release[0]] >= departure_time + 3 * DT_MDL
moving = np.flatnonzero((trace.time >= departure_time) & (trace.speed > 0.05))
assert len(moving) and trace.time[moving[0]] <= departure_time + 4 * DT_MDL + 1.0
assert np.min(trace.effective_accel_max[departure_release[0] : moving[0] + 1]) > 1.5
assert not _has_brake_coast_brake(trace.a_target[trace.time >= departure_time])
assert trace.solver_failures == 0
def test_creeping_lead_departure_is_prompt_and_does_not_lurch():
departure_time = 1.0
def lead_speed(current_time: float) -> float:
if current_time < departure_time:
return 0.0
if current_time < departure_time + 0.5:
return 1.6 * (current_time - departure_time)
return min(2.5, 0.8 + 0.7 * (current_time - departure_time - 0.5))
def observe(_current_time: float, lead_name: str, truth: LeadObservation) -> LeadObservation | None:
return None if lead_name == "leadTwo" else truth | {"aLeadK": 0.0, "radarTrackId": 2133, "radar": True}
common = dict(
duration=6.0,
profile=0,
lead_relevancy=True,
speed=0.0,
distance_lead=3.6,
v_lead=lead_speed,
v_cruise=22.352,
lead_observation_fn=observe,
actuator_delay=0.15,
actuator_lag=0.20,
)
baseline = _run(controller_enabled=False, **common)
trace = _run(controller_enabled=True, **common)
after_departure = trace.time >= departure_time
lead_speeds = np.array([lead_speed(max(0.0, current_time - DT_MDL)) for current_time in trace.time])
baseline_moving = np.flatnonzero((baseline.time >= departure_time) & (baseline.speed > 0.05))
moving = np.flatnonzero(after_departure & (trace.speed > 0.05))
assert len(baseline_moving) and len(moving)
assert trace.time[moving[0]] <= baseline.time[baseline_moving[0]]
assert np.all(trace.speed[after_departure] <= lead_speeds[after_departure] + 0.20)
assert not _has_brake_coast_brake(trace.a_target[after_departure])
assert np.min(trace.distance_lead - trace.distance) >= np.min(baseline.distance_lead - baseline.distance) - 1e-3
assert trace.solver_failures == 0
def test_stop_hold_ignores_two_frame_total_lead_dropout():
def observe(current_time: float, _lead_name: str, truth: LeadObservation) -> LeadObservation | None:
return None if 1.0 <= current_time < 1.1 else truth
trace = _run(
duration=2.0,
controller_enabled=True,
lead_relevancy=True,
speed=0.0,
distance_lead=6.0,
v_lead=0.0,
v_cruise=8.0,
lead_observation_fn=observe,
actuator_delay=0.15,
actuator_lag=0.20,
)
assert np.max(trace.speed) < 1e-3
assert np.max(trace.effective_accel_max[np.isfinite(trace.effective_accel_max)]) == 0.0
assert trace.solver_failures == 0
def test_low_speed_stopped_lead_never_accelerates_during_stop_hold():
def lead_speed(current_time: float) -> float:
return max(0.0, 1.9 - 1.16 * current_time)
def observe(current_time: float, lead_name: str, truth: LeadObservation) -> LeadObservation | None:
if lead_name == "leadTwo":
return None
moving = lead_speed(current_time) > 0.0
return truth | {"vLeadK": truth["vLeadK"] if moving else -0.01, "aLeadK": -1.16 if moving else 0.0, "radarTrackId": 7, "radar": True}
common = dict(
duration=6.0,
profile=0,
lead_relevancy=True,
speed=4.5,
distance_lead=18.0,
v_lead=lead_speed,
v_cruise=23.056,
lead_observation_fn=observe,
actuator_delay=0.15,
actuator_lag=0.20,
)
baseline = _run(controller_enabled=False, **common)
trace = _run(controller_enabled=True, **common)
urgent_demand = (trace.required_decel >= 0.45) & (trace.speed >= 0.30) & ~trace.should_stop
stop_hold = trace.state == int(AccelControllerState.stopHold)
assert urgent_demand.any() and stop_hold.any()
assert np.max(trace.a_target[urgent_demand]) < 0.0
hold_indices = np.flatnonzero(stop_hold)
assert np.max(trace.acceleration[stop_hold]) < 0.25
assert np.max(trace.speed[stop_hold]) < 0.30
assert trace.distance[hold_indices[-1]] - trace.distance[hold_indices[0]] < 0.05
assert not _has_brake_coast_brake(trace.a_target[trace.time >= 1.0])
assert np.min(trace.a_target) >= np.min(baseline.a_target) - ROUTINE_GAP_TOLERANCE
assert np.min(trace.distance_lead - trace.distance) >= np.min(baseline.distance_lead - baseline.distance) - ROUTINE_GAP_TOLERANCE
assert not trace.fcw.any()
assert trace.solver_failures == 0
def test_moving_lead_dropout_and_false_relief_do_not_release_pace():
def observe(current_time: float, _lead_name: str, truth: LeadObservation) -> LeadObservation | None:
if 2.0 <= current_time < 2.1:
return None
if 3.0 <= current_time < 3.1:
return {"dRel": truth["dRel"] + 5.0}
return truth
common = dict(
duration=5.0,
lead_relevancy=True,
speed=22.0,
distance_lead=85.0,
v_lead=14.0,
lead_observation_fn=observe,
actuator_delay=0.20,
actuator_lag=0.25,
)
trace = _run(controller_enabled=True, **common)
for start in (2.0, 3.0):
before = trace.effective_accel_max[np.flatnonzero(trace.time < start)[-1]]
guard = (trace.time >= start) & (trace.time < start + 0.2) & trace.active
assert np.all(trace.effective_accel_max[guard] <= before + 0.02)
assert not _has_brake_coast_brake(trace.a_target[trace.time >= 1.0])
assert not _has_propulsion_after_braking(trace.a_target[trace.time >= 1.0])
assert np.max(np.abs(_command_jerk(trace, after=1.0))) < 3.0
assert trace.solver_failures == 0
@pytest.mark.parametrize(
("actuator_delay", "actuator_lag"),
[(0.10, 0.20), (0.15, 0.25), (0.20, 0.20), (0.25, 0.30), (0.30, 0.35)],
ids=("toyota", "honda", "gm", "hyundai", "ford"),
)
def test_confirmed_finite_relief_transitions_smoothly(actuator_delay, actuator_lag):
def lead_speed(current_time: float) -> float:
return 8.0 if current_time < 5.0 else min(15.0, 8.0 + 3.5 * (current_time - 5.0))
common = dict(
duration=9.0,
profile=1,
lead_relevancy=True,
speed=12.0,
distance_lead=50.0,
v_lead=lead_speed,
v_cruise=20.0,
actuator_delay=actuator_delay,
actuator_lag=actuator_lag,
)
trace = _run(controller_enabled=True, **common)
released = np.flatnonzero((trace.time >= 5.0) & (trace.state == int(AccelControllerState.free)))
assert len(released)
reached_profile = np.flatnonzero((np.arange(len(trace.time)) >= released[0]) &
(trace.effective_accel_max >= trace.profile_accel_max - 1e-6))
assert len(reached_profile)
rising = (np.arange(len(trace.time)) >= released[0]) & (np.arange(len(trace.time)) <= reached_profile[0])
assert rising.any()
assert np.all(np.diff(trace.effective_accel_max[rising]) >= -1e-9)
assert not _has_brake_coast_brake(trace.a_target[trace.time >= 5.0])
assert not _has_propulsion_brake_cycle(trace.a_target[trace.time >= 5.0])
assert np.max(np.abs(_command_jerk(trace, after=5.0))) < 3.0
assert trace.solver_failures == 0
def test_low_speed_far_lead_acquisition_does_not_fault_or_lurch():
acquisition_time = 5.0
def observe(current_time: float, _lead_name: str, truth: LeadObservation) -> LeadObservation | None:
return None if current_time < acquisition_time else truth
common = dict(
duration=8.0,
profile=0,
lead_relevancy=True,
speed=0.0,
distance_lead=180.0,
v_lead=3.0,
v_cruise=30.0,
lead_observation_fn=observe,
actuator_delay=0.15,
actuator_lag=0.20,
)
trace = _run(controller_enabled=True, **common)
acquired = (trace.time >= acquisition_time) & (trace.selected_lead >= 0)
response = trace.time >= acquisition_time
jerk_response = trace.time[1:] >= acquisition_time
assert acquired.any()
assert not trace.controller_fault[response].any()
assert not trace.solver_status.any()
assert not trace.controller_fault_latched.any()
assert trace.solver_failures == 0
assert np.max(np.abs(np.diff(trace.a_target)[jerk_response] / DT_MDL)) < 3.0
assert not _has_brake_coast_brake(trace.a_target[response])
assert not _has_propulsion_after_braking(trace.a_target[response])
def test_alternating_range_glitch_has_bounded_jerk_and_no_reversal():
glitch_start = 5.0
glitch_end = 5.5
def observe(current_time: float, _lead_name: str, truth: LeadObservation) -> LeadObservation:
if glitch_start <= current_time < glitch_end:
frame = round(current_time / DT_MDL)
return truth | {"dRel": truth["dRel"] + (5.0 if frame % 2 else 0.0)}
return truth
common = dict(
duration=10.0,
lead_relevancy=True,
speed=8.0,
distance_lead=20.0,
v_lead=1.5,
actuator_delay=0.20,
actuator_lag=0.25,
)
control = _run(controller_enabled=True, **common)
baseline = _run(controller_enabled=False, lead_observation_fn=observe, **common)
trace = _run(controller_enabled=True, lead_observation_fn=observe, **common)
window = (trace.time[1:] >= glitch_start) & (trace.time[1:] < glitch_end)
assert np.max(np.abs(np.diff(trace.a_target)[window] / DT_MDL)) < 3.0
response = (trace.time >= glitch_start) & (trace.time < glitch_end + 1.0)
assert np.max(np.abs((trace.a_target - control.a_target)[response])) < 0.07
np.testing.assert_array_equal(trace.should_stop[response], baseline.should_stop[response])
np.testing.assert_array_equal(trace.fcw[response], baseline.fcw[response])
assert not _has_brake_coast_brake(trace.a_target[response])
assert not _has_propulsion_after_braking(trace.a_target[response])
assert np.min(trace.distance_lead - trace.distance) >= np.min(baseline.distance_lead - baseline.distance) - ROUTINE_GAP_TOLERANCE
assert trace.solver_failures == 0
@pytest.mark.parametrize(
("actuator_delay", "actuator_lag"),
[(0.10, 0.20), (0.15, 0.25), (0.20, 0.20), (0.25, 0.30), (0.30, 0.35)],
ids=("toyota", "honda", "gm", "hyundai", "ford"),
)
def test_slow_lead_approach_is_smooth_across_actuator_dynamics(actuator_delay, actuator_lag):
lead_speed = 10.0
trace = _run(
duration=70.0,
controller_enabled=True,
profile=1,
lead_relevancy=True,
speed=20.0,
distance_lead=100.0,
v_lead=lead_speed,
v_cruise=30.0,
actuator_delay=actuator_delay,
actuator_lag=actuator_lag,
)
desired_gap = STOP_DISTANCE + get_T_FOLLOW() * lead_speed
gap = trace.distance_lead - trace.distance
closing_speed = trace.speed - lead_speed
closing = closing_speed > 0.1
meaningful_closing = closing_speed > 0.3
settled = trace.time >= trace.time[-1] - 3.0
moving = (trace.time[1:] >= 0.5) & (trace.speed[1:] >= 2.0) & ~trace.should_stop[1:] & ~trace.should_stop[:-1]
assert np.max(np.abs(np.diff(trace.a_target)[moving] / DT_MDL)) < 3.0
assert not _has_brake_coast_brake(trace.a_target[trace.time >= 1.0])
assert not _has_propulsion_brake_cycle(trace.a_target[trace.time >= 1.0])
assert np.max(trace.a_target[meaningful_closing]) <= 0.2
assert np.percentile(np.abs(_filtered_realized_jerk(trace)), 95) < 0.35
assert np.min(trace.a_target) >= -1.1
assert np.min(trace.acceleration) >= -1.1
assert np.min(gap) >= desired_gap - 1.6
assert np.min(gap[closing] / closing_speed[closing]) >= 2.0
assert abs(np.median(trace.speed[settled]) - lead_speed) <= 0.5
assert desired_gap - 1.6 <= np.median(gap[settled]) <= desired_gap + 6.0
assert not trace.fcw.any()
assert not trace.should_stop.any()
assert not trace.solver_status.any()
assert not trace.controller_fault_latched.any()
assert trace.solver_failures == 0
def test_decelerating_moving_lead_stays_smooth_and_safe():
def lead_speed(current_time: float) -> float:
if current_time < 2.0:
return 15.0
progress = min((current_time - 2.0) / 6.0, 1.0)
return 15.0 - 5.0 * (3.0 * progress**2 - 2.0 * progress**3)
common = dict(
duration=14.0,
profile=1,
lead_relevancy=True,
speed=20.0,
distance_lead=110.0,
v_lead=lead_speed,
v_cruise=30.0,
actuator_delay=0.20,
actuator_lag=0.25,
)
baseline = _run(controller_enabled=False, **common)
trace = _run(controller_enabled=True, **common)
lead_decelerating = (trace.time >= 2.0) & (trace.time <= 8.0) & trace.active
settled = trace.time >= 8.0
assert np.any(trace.effective_accel_max[lead_decelerating] < 0.0)
assert not trace.should_stop.any()
assert np.max(np.abs(_command_jerk(trace, after=1.0))) < 4.25
baseline_p95 = np.percentile(np.abs(_filtered_realized_jerk(baseline)), 95)
trace_p95 = np.percentile(np.abs(_filtered_realized_jerk(trace)), 95)
assert trace_p95 <= max(0.20, baseline_p95 + 0.02)
assert not _has_brake_coast_brake(trace.a_target[trace.time >= 1.0])
assert not _has_propulsion_after_braking(trace.a_target[trace.time >= 1.0])
assert np.max(trace.a_target[settled]) <= 0.2
assert np.min(trace.distance_lead - trace.distance) >= np.min(baseline.distance_lead - baseline.distance) - ROUTINE_GAP_TOLERANCE
assert not trace.fcw.any()
assert trace.solver_failures == 0
def test_severe_closing_never_delays_stock_braking_or_reduces_clearance():
common = dict(
duration=12.0,
lead_relevancy=True,
speed=20.0,
distance_lead=160.0,
v_lead=3.5,
actuator_delay=0.20,
actuator_lag=0.20,
)
baseline = _run(controller_enabled=False, **common)
trace = _run(controller_enabled=True, **common)
for threshold in (-1.0, -2.0):
assert _first_time_below(trace, threshold) <= _first_time_below(baseline, threshold) + 1e-9
baseline_gap = baseline.distance_lead - baseline.distance
controlled_gap = trace.distance_lead - trace.distance
assert np.min(controlled_gap) >= np.min(baseline_gap) - 0.02
baseline_closing = baseline.speed - 3.5
controlled_closing = trace.speed - 3.5
baseline_ttc = np.min(baseline_gap[baseline_closing > 0.1] / baseline_closing[baseline_closing > 0.1])
controlled_ttc = np.min(controlled_gap[controlled_closing > 0.1] / controlled_closing[controlled_closing > 0.1])
assert controlled_ttc >= baseline_ttc - 0.02
assert np.min(controlled_gap) > 0.0
onset = (trace.time[1:] > 0.5) & (trace.time[1:] < 3.0)
assert np.max(np.abs(np.diff(trace.a_target)[onset] / DT_MDL)) < 4.0
assert trace.solver_failures == 0
@pytest.mark.parametrize(
("actuator_delay", "actuator_lag"),
[(0.10, 0.20), (0.15, 0.25), (0.20, 0.20), (0.25, 0.30), (0.30, 0.35)],
ids=("toyota", "honda", "gm", "hyundai", "ford"),
)
@pytest.mark.parametrize("profile", range(3), ids=("eco", "normal", "sport"))
def test_far_lead_deceleration_starts_early_and_stays_smooth(profile, actuator_delay, actuator_lag):
common = dict(
duration=11.0,
lead_relevancy=True,
speed=25.0,
distance_lead=200.0,
v_lead=15.0,
actuator_delay=actuator_delay,
actuator_lag=actuator_lag,
)
baseline = _run(controller_enabled=False, **common)
trace = _run(controller_enabled=True, profile=profile, **common)
baseline_onset = _sustained_time_below(baseline, -0.10)
trace_onset = _sustained_time_below(trace, -0.10)
negative_bound = np.isfinite(trace.mpc_accel_max) & (trace.mpc_accel_max < -0.05)
assert negative_bound.any()
assert trace.time[np.flatnonzero(negative_bound)[0]] <= baseline_onset - 0.5
assert trace_onset <= baseline_onset - 0.5
assert trace.acceleration.min() >= baseline.acceleration.min() - 0.1
trace_p95 = float(np.percentile(np.abs(_filtered_realized_jerk(trace)), 95))
assert trace_p95 < 0.45
assert np.max(np.abs(_command_jerk(trace, after=0.5))) < 3.0
assert not _has_brake_coast_brake(trace.a_target[trace.time >= 1.0])
assert not _has_propulsion_brake_cycle(trace.a_target[trace.time >= 1.0])
assert not trace.fcw.any()
assert not trace.solver_status.any()
assert not trace.controller_fault_latched.any()
assert trace.solver_failures == 0
def test_far_lead_profile_order_is_monotonic():
traces = [
_run(
duration=6.0,
controller_enabled=True,
profile=profile,
lead_relevancy=True,
speed=25.0,
distance_lead=200.0,
v_lead=15.0,
actuator_delay=0.10,
actuator_lag=0.20,
)
for profile in range(3)
]
bound_onsets = [
float(trace.time[np.flatnonzero(np.isfinite(trace.mpc_accel_max) & (trace.mpc_accel_max < -0.05))[0]])
for trace in traces
]
decel_onsets = [_sustained_time_below(trace, -0.10) for trace in traces]
assert bound_onsets[0] <= bound_onsets[1] <= bound_onsets[2]
assert decel_onsets[0] <= decel_onsets[1] <= decel_onsets[2]
assert traces[0].raw_cap[0] < traces[1].raw_cap[0] < traces[2].raw_cap[0]
def test_prior_stock_solver_status_does_not_disable_clear_road_controller():
plant = Plant(speed=0.0, actuator_delay=0.15, actuator_lag=0.20)
_configure_plant(plant, enabled=True, profile=1)
plant.step(v_cruise=15.0)
assert plant.planner.accel_controller_result.active
assert plant.planner.mpc.last_solution_status == 0
plant.planner.mpc.last_solution_status = 3
plant.step(v_cruise=15.0)
assert plant.planner.mpc.last_solution_status == 0
recovered = plant.planner.accel_controller_result
assert recovered.active
assert not plant.planner.accel_controller_fault_latched
assert np.isfinite(recovered.effective_accel_max)
def test_prior_stock_solver_status_does_not_disable_lead_controller():
plant = Plant(lead_relevancy=True, speed=25.0, distance_lead=200.0, actuator_delay=0.15, actuator_lag=0.20)
_configure_plant(plant, enabled=True, profile=1)
plant.v_lead_prev = 15.0
for _ in range(30):
plant.step(v_lead=15.0, v_cruise=30.0)
assert plant.planner.accel_controller_result.effective_accel_max < 0.0
assert plant.planner.mpc.last_solution_status == 0
plant.planner.mpc.last_solution_status = 3
result = plant.step(v_lead=15.0, v_cruise=30.0)
assert plant.planner.mpc.last_solution_status == 0
assert plant.planner.accel_controller_result.active
assert not plant.planner.accel_controller_fault_latched
assert result["a_target"] <= 0.2
@pytest.mark.parametrize(("profile", "speed", "expects_ceiling"), ((0, 10.0, True), (2, 0.0, False)), ids=("ceiling", "seed-only"))
def test_failed_custom_solve_restores_stock_state_and_counts_fcw_once(profile, speed, expects_ceiling):
plant = Plant(lead_relevancy=False, speed=speed, actuator_delay=0.15, actuator_lag=0.20)
_configure_plant(plant, enabled=True, profile=profile)
saved_a_prev = np.full_like(plant.planner.mpc.a_prev, -0.25)
accepted_a_prev = np.full_like(saved_a_prev, 0.15)
plant.planner.mpc.a_prev = saved_a_prev.copy()
plant.planner.mpc.crash_cnt = 2.0
if not expects_ceiling:
plant.planner.accel_controller._build_accel_ceiling = lambda *_args: None
calls = []
def update_mpc(_radar_state, _v_cruise, personality, accel_max=None):
calls.append((personality, accel_max))
if len(calls) == 1:
plant.planner.mpc.last_solution_status = plant.planner.mpc.solution_status = 4
plant.planner.mpc.a_prev = np.zeros_like(saved_a_prev)
plant.planner.mpc.crash_cnt = 0.0
else:
np.testing.assert_array_equal(plant.planner.mpc.a_prev, saved_a_prev)
assert plant.planner.mpc.crash_cnt == 2.0
plant.planner.mpc.last_solution_status = plant.planner.mpc.solution_status = 0
plant.planner.mpc.a_prev = accepted_a_prev.copy()
plant.planner.mpc.crash_cnt += 1.0
plant.planner.mpc.update = update_mpc
result = plant.step(v_cruise=30.0)
assert len(calls) == 2
assert (calls[0][1] is not None) == expects_ceiling and calls[1][1] is None
assert plant.planner.accel_controller_fault_latched
assert not plant.planner.accel_controller_result.active
assert plant.planner.mpc.crash_cnt == 3.0
np.testing.assert_array_equal(plant.planner.mpc.a_prev, accepted_a_prev)
assert result["fcw"] == (speed > 0.0)
@pytest.mark.parametrize("mode", ("disabled", "e2e"))
def test_stock_solver_recovery_is_not_warm_seeded_when_controller_cannot_actuate(mode):
plant = Plant(lead_relevancy=False, speed=10.0, actuator_delay=0.15, actuator_lag=0.20, e2e=mode == "e2e")
_configure_plant(plant, enabled=mode != "disabled", profile=0)
plant.planner.mpc.last_solution_status = 3
seeds = []
plant.planner._seed_mpc_current_state = lambda _target=None: seeds.append(True)
plant.step(v_cruise=30.0)
assert not seeds
@pytest.mark.parametrize("pre_frames", (1, 2))
@pytest.mark.parametrize("mode", ("disabled", "e2e"))
def test_early_launch_transition_returns_to_stock_without_solver_fault(pre_frames, mode):
plant = Plant(speed=0.0, actuator_delay=0.15, actuator_lag=0.20)
_configure_plant(plant, enabled=True, profile=1)
for _ in range(pre_frames):
plant.step(v_cruise=15.0)
if mode == "disabled":
plant.planner.accel_personality_enabled = False
plant.planner._read_accel_controller_params = lambda: None
else:
plant.e2e = True
for _ in range(4):
plant.step(v_cruise=15.0)
controller = plant.planner.accel_controller_result
assert not controller.active
assert controller.mpc_accel_max is None
assert plant.planner.mpc.last_solution_status == 0
np.testing.assert_array_equal(plant.planner.mpc.params[:, 1], ACCEL_MAX)
@pytest.mark.parametrize("profile", range(3), ids=("eco", "normal", "sport"))
@pytest.mark.parametrize("mode", ("disabled", "e2e"))
def test_launch_transition_after_crossing_standstill_threshold(profile, mode):
plant = Plant(speed=0.29, actuator_delay=0.15, actuator_lag=0.20)
_configure_plant(plant, enabled=True, profile=profile)
plant.acceleration = 0.5
plant.planner.a_desired = 0.5
plant.step(v_cruise=15.0)
assert plant.speed > 0.30
if mode == "disabled":
plant.planner.accel_personality_enabled = False
plant.planner._read_accel_controller_params = lambda: None
else:
plant.e2e = True
for _ in range(4):
plant.step(v_cruise=15.0)
controller = plant.planner.accel_controller_result
assert not controller.active
assert controller.mpc_accel_max is None
assert plant.planner.mpc.last_solution_status == 0
np.testing.assert_array_equal(plant.planner.mpc.params[:, 1], ACCEL_MAX)
@@ -5,6 +5,8 @@ from openpilot.common.params import Params
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper
from openpilot.sunnypilot.selfdrive.controls.lib.lane_turn_desire import LaneTurnController, LANE_CHANGE_SPEED_MIN
from openpilot.sunnypilot.selfdrive.controls.lib.auto_lane_change import AutoLaneChangeMode
from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController
TurnDirection = custom.ModelDataV2SP.TurnDirection
@@ -107,7 +109,11 @@ def set_lane_turn_params():
])
def test_desire_helper_integration(carstate, lateral_active, lane_change_prob, expected_desire, set_lane_turn_params):
dh = DesireHelper()
relc = RoadEdgeLaneChangeController(dh)
relc.enabled = True
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE
for _ in range(10):
dh.update(carstate, lateral_active, lane_change_prob)
dh.update(carstate, lateral_active, lane_change_prob,
left_edge_detected=relc.left_edge_detected, right_edge_detected=relc.right_edge_detected)
assert dh.desire == expected_desire # The first four tests were unit tests to test the controller, where this tests the integration in desire helpers
@@ -0,0 +1,99 @@
"""
Copyright (c) 2021-, rav4kumar, 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 pytest
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper
from openpilot.sunnypilot.selfdrive.controls.lib.relc import (
RoadEdgeLaneChangeController, EDGE_REACTION_TIME, EDGE_CLEAR_TIME, MIN_SPEED,
)
V_HIGH = MIN_SPEED + 2.0
V_LOW = MIN_SPEED - 1.0
@pytest.fixture
def relc(mocker):
mock_params = mocker.patch("openpilot.sunnypilot.selfdrive.controls.lib.relc.Params")
mock_params.return_value.get_bool.return_value = True
controller = RoadEdgeLaneChangeController(DesireHelper())
controller.enabled = True
return controller
def drive(controller, road_edge_stds, lane_line_probs, seconds, v_ego=V_HIGH):
for _ in range(int(seconds / DT_MDL) + 1):
controller.update(road_edge_stds, lane_line_probs, v_ego)
@pytest.mark.parametrize("road_edge_stds,lane_line_probs,attr", [
([0.0, 0.9], [0.0, 0.8, 0.8, 0.8], "left_edge_detected"),
([0.9, 0.0], [0.8, 0.8, 0.8, 0.0], "right_edge_detected"),
])
def test_edge_detection(relc, road_edge_stds, lane_line_probs, attr):
drive(relc, road_edge_stds, lane_line_probs, EDGE_REACTION_TIME + 0.1)
assert getattr(relc, attr)
def test_edge_detection_requires_time(relc):
drive(relc, [0.0, 0.9], [0.0, 0.8, 0.8, 0.8], EDGE_REACTION_TIME - 0.05)
assert not relc.left_edge_detected
def test_both_edges_detected(relc):
drive(relc, [0.0, 0.0], [0.0, 0.8, 0.8, 0.0], EDGE_REACTION_TIME + 0.1)
assert relc.left_edge_detected
assert relc.right_edge_detected
def test_noise_doesnt_clear(relc):
edge = ([0.0, 0.9], [0.0, 0.8, 0.8, 0.8])
clear = ([0.9, 0.9], [0.8, 0.8, 0.8, 0.8])
drive(relc, *edge, EDGE_REACTION_TIME + 0.1)
assert relc.left_edge_detected
relc.update(*clear, V_HIGH)
relc.update(*edge, V_HIGH)
assert relc.left_edge_detected
def test_clears_after_window(relc):
edge = ([0.0, 0.9], [0.0, 0.8, 0.8, 0.8])
clear = ([0.9, 0.9], [0.8, 0.8, 0.8, 0.8])
drive(relc, *edge, EDGE_REACTION_TIME + 0.1)
assert relc.left_edge_detected
drive(relc, *clear, EDGE_CLEAR_TIME + 0.05)
assert not relc.left_edge_detected
assert relc.left_edge_timer == 0.0
def test_low_speed_skips(relc):
drive(relc, [0.0, 0.9], [0.0, 0.8, 0.8, 0.8], EDGE_REACTION_TIME + 0.1, v_ego=V_LOW)
assert not relc.left_edge_detected
assert relc.left_edge_timer == 0.0
def test_speed_drop_resets(relc):
drive(relc, [0.0, 0.9], [0.0, 0.8, 0.8, 0.8], EDGE_REACTION_TIME + 0.1)
assert relc.left_edge_detected
relc.update([0.0, 0.9], [0.0, 0.8, 0.8, 0.8], V_LOW)
assert not relc.left_edge_detected
def test_param_off_resets(relc):
drive(relc, [0.0, 0.9], [0.0, 0.8, 0.8, 0.8], EDGE_REACTION_TIME + 0.1)
assert relc.left_edge_detected
relc.params.get_bool.return_value = False
relc.read_params()
relc.update([0.0, 0.9], [0.0, 0.8, 0.8, 0.8], V_HIGH)
assert not relc.left_edge_detected
assert not relc.right_edge_detected
@@ -243,4 +243,12 @@ EVENTS_SP: dict[int, dict[str, Alert | AlertCallbackType]] = {
AlertStatus.normal, AlertSize.none,
Priority.MID, VisualAlert.none, AudibleAlert.prompt, 3.),
},
EventNameSP.laneChangeRoadEdge: {
ET.WARNING: Alert(
"Lane Change Unavailable: Road Edge",
"",
AlertStatus.userPrompt, AlertSize.small,
Priority.LOW, VisualAlert.none, AudibleAlert.prompt, 0.1),
},
}
+42
View File
@@ -1,4 +1,26 @@
{
"AccelPersonality": {
"title": "Acceleration Profile",
"description": "Eco slows earliest and recovers gently, Normal balances comfort and response, and Sport reacts and recovers more quickly.",
"options": [
{
"value": 0,
"label": "Eco"
},
{
"value": 1,
"label": "Normal"
},
{
"value": 2,
"label": "Sport"
}
]
},
"AccelPersonalityEnabled": {
"title": "Enable Accel Controller",
"description": "Begin slowing early and smoothly behind lead vehicles. Stock longitudinal control retains braking and stopping authority."
},
"AccessToken": {
"title": "AccessTokenIsNice",
"description": ""
@@ -1118,6 +1140,10 @@
"title": "Record Front Lock",
"description": ""
},
"RoadEdgeLaneChangeEnabled": {
"title": "Block Lane Change: Road Edge Detection",
"description": ""
},
"RoadName": {
"title": "Road Name",
"description": ""
@@ -1323,6 +1349,22 @@
"max": 5.0,
"step": 0.1,
"unit": "m/s\u00b2"
},
"ToyotaAutoHold": {
"title": "Toyota: Auto Brake Hold FOR TSS2 HYBRID CARS",
"description": ""
},
"ToyotaEnhancedBsm": {
"title": "Toyota: Prius TSS2 BSM and some tssp",
"description": ""
},
"ToyotaTSS2Long": {
"title": "Toyota: custom longitudinal for TSS2",
"description": ""
},
"ToyotaDriveMode": {
"title": "Enable drive mode btn link",
"description": ""
},
"ToyotaEnforceStockLongitudinal": {
"title": "Toyota: Enforce Factory Longitudinal Control",
+112
View File
@@ -620,6 +620,58 @@
}
]
},
{
"key": "AccelPersonalityEnabled",
"widget": "toggle",
"title": "Enable Accel Controller",
"description": "Begin slowing early and smoothly behind lead vehicles. Stock longitudinal control retains braking and stopping authority.",
"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": "Eco slows earliest and recovers gently, Normal balances comfort and response, and Sport reacts and recovers more quickly.",
"options": [
{
"value": 0,
"label": "Eco"
},
{
"value": 1,
"label": "Normal"
},
{
"value": 2,
"label": "Sport"
}
],
"enablement": [
{
"type": "capability",
"field": "has_longitudinal_control",
"equals": true
},
{
"type": "param",
"key": "AccelPersonalityEnabled",
"equals": true
}
]
},
{
"key": "IntelligentCruiseButtonManagement",
"widget": "toggle",
@@ -2001,6 +2053,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
}
]
}
]
},
@@ -2168,6 +2236,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",
@@ -43,6 +43,32 @@ sections:
label: Relaxed
enablement:
- $ref: '#/macros/longitudinal'
- key: AccelPersonalityEnabled
widget: toggle
title: Enable Accel Controller
description: Begin slowing early and smoothly behind lead vehicles. Stock longitudinal control retains braking
and stopping authority.
visibility:
- $ref: '#/macros/longitudinal'
enablement:
- $ref: '#/macros/longitudinal'
- key: AccelPersonality
widget: multiple_button
title: Acceleration Profile
description: Eco slows earliest and recovers gently, Normal balances comfort and response, and Sport reacts
and recovers more quickly.
options:
- value: 0
label: Eco
- value: 1
label: Normal
- value: 2
label: Sport
enablement:
- $ref: '#/macros/longitudinal'
- type: param
key: AccelPersonalityEnabled
equals: true
- key: IntelligentCruiseButtonManagement
widget: toggle
title: Intelligent Cruise Button Management (ICBM) (Alpha)
@@ -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
@@ -60,6 +60,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
@@ -272,6 +272,22 @@ class TestKnownPanels:
nnlc_enable_keys = {r.get("key") for r in nnlc.get("enablement", []) if r.get("type") == "param"}
assert "EnforceTorqueControl" in nnlc_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": "param",
"key": "AccelPersonalityEnabled",
"equals": True,
} in items["AccelPersonality"]["enablement"]
class TestKnownVehicleSettings:
def test_hyundai_has_longitudinal_tuning(self, schema):
+3 -2
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
@@ -98,7 +99,7 @@ class GuiScrollPanel2:
# simple exponential return if out of bounds
# out of bounds is handled by snapping, so skip if set
out_of_bounds = self.get_offset() > max_offset or self.get_offset() < min_offset
if out_of_bounds and snap_target is None:
if out_of_bounds and snap_target is None and self._handle_out_of_bounds:
target = max_offset if self.get_offset() > max_offset else min_offset
dt = rl.get_frame_time() or 1e-6
+6 -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,8 +190,12 @@ 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)
if self._horizontal:
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)
else:
center_pos = self._rect.y + self._rect.height / 2
closest_delta_pos = min((((item.rect.y + item.rect.height / 2) - 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)