Compare commits

...

6 Commits

Author SHA1 Message Date
rav4kumar d075ce9996 Make SCC Vision curve pacing smooth and bounded
Use model curvature to lower cruise speed smoothly while bounding slowdown, preserving launch response, and avoiding acceleration and braking oscillation.
2026-07-28 21:47:12 -07:00
rav4kumar fff8f6a0a6 DEC radar and model braking 2026-07-28 21:46:50 -07:00
rav4kumar cb56f4b0fa ref toyota abh 2026-07-28 21:46:38 -07:00
rav4kumar 95c99889b6 ref toyota bsm 2026-07-28 21:46:27 -07:00
rav4kumar 729dfade91 ref tss2 tune 2026-07-28 21:46:15 -07:00
rav4kumar 4aabb8866d feat: accel controller 2026-07-28 21:46:02 -07:00
34 changed files with 5027 additions and 196 deletions
+30
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,35 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 {
greenLightAlert @0 :Bool;
leadDepartAlert @1 :Bool;
}
struct AccelController {
enabled @0 :Bool;
active @1 :Bool;
shadowOnlyDEPRECATED @2 :Bool;
profile @3 :Profile;
state @4 :State;
enum Profile {
eco @0;
normal @1;
sport @2;
}
enum State {
inactive @0;
free @1;
restrict @2;
hold @3;
release @4;
stopHold @5;
}
}
enum AccelerationPersonality {
eco @0;
normal @1;
sport @2;
}
}
struct OnroadEventSP @0xda96579883444c35 {
+4
View File
@@ -235,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
@@ -9,6 +9,7 @@ from openpilot.common.swaglog import cloudlog
# WARNING: imports outside of constants will not trigger a rebuild
from openpilot.selfdrive.modeld.constants import index_function
from openpilot.selfdrive.controls.radard import _LEAD_ACCEL_TAU
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalMpcSP
if __name__ == '__main__': # generating code
from acados.acados_template import AcadosModel, AcadosOcp, AcadosOcpSolver
@@ -213,8 +214,9 @@ def gen_long_ocp():
return ocp
class LongitudinalMpc:
class LongitudinalMpc(LongitudinalMpcSP):
def __init__(self, dt=DT_MDL):
LongitudinalMpcSP.__init__(self)
self.dt = dt
self.solver = AcadosOcpSolverCython(MODEL_NAME, ACADOS_SOLVER_TYPE, N)
self.reset()
@@ -270,7 +272,8 @@ class LongitudinalMpc:
def set_weights(self, prev_accel_constraint=True, personality=log.LongitudinalPersonality.standard):
jerk_factor = get_jerk_factor(personality)
a_change_cost = A_CHANGE_COST if prev_accel_constraint else 0
cost_weights = [X_EGO_OBSTACLE_COST, X_EGO_COST, V_EGO_COST, A_EGO_COST, jerk_factor * a_change_cost, jerk_factor * J_EGO_COST]
cost_weights = [X_EGO_OBSTACLE_COST, X_EGO_COST, V_EGO_COST, A_EGO_COST, jerk_factor * a_change_cost,
LongitudinalMpcSP.scale_jerk_cost(self, jerk_factor * J_EGO_COST)]
constraint_cost_weights = [LIMIT_COST, LIMIT_COST, LIMIT_COST, DANGER_ZONE_COST]
self.set_cost_weights(cost_weights, constraint_cost_weights)
@@ -345,6 +348,7 @@ class LongitudinalMpc:
self.params[:,0] = ACCEL_MIN
self.params[:,1] = ACCEL_MAX
LongitudinalMpcSP.apply_accel_limits(self)
self.params[:,2] = np.min(x_obstacles, axis=1)
self.params[:,3] = np.copy(self.a_prev)
self.params[:,4] = t_follow
@@ -364,6 +368,7 @@ class LongitudinalMpc:
self.solver.constraints_set(0, "ubx", self.x0)
self.solution_status = self.solver.solve()
LongitudinalMpcSP.save_solution_status(self)
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])
+8 -10
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
@@ -129,16 +129,12 @@ 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)
# Get new v_cruise and a_desired from Smart Cruise Control and Speed Limit Assist
v_cruise, self.a_desired = LongitudinalPlannerSP.update_targets(self, sm, self.v_desired_filter.x, self.a_desired, 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_mpc(self, sm, v_cruise, prev_accel_constraint, accel_clip[1], reset_state)
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 +150,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:
@@ -168,6 +165,7 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
else:
output_a_target = output_a_target_mpc
self.output_should_stop = output_should_stop_mpc
self.output_should_stop = LongitudinalPlannerSP.update_should_stop(self, self.output_should_stop)
for idx in range(2):
accel_clip[idx] = np.clip(accel_clip[idx], self.prev_accel_clip[idx] - 0.05, self.prev_accel_clip[idx] + 0.05)
+301 -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,113 @@ 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]]
class PlannerSM(dict):
def __init__(self, radar_frame: int, services: dict):
super().__init__(services)
self.logMonoTime = {"radarState": radar_frame}
self.valid = {"radarState": True}
self.alive = {"radarState": True}
@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 +135,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 +154,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 +173,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 +265,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 +314,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 +333,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 = PlannerSM(self.rk.frame, {
'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 +379,65 @@ 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 = getattr(self.planner, "accel_controller", None)
envelope = getattr(accel_controller, "_held_envelope", None)
pace_state = getattr(accel_controller, "pace_state", 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, "output_v_target", None),
"base_target": self.planner.output_v_target,
"raw_energy_cap": getattr(envelope, "cap", math.inf),
"live_filtered_cap": getattr(pace_state, "filtered_cap", None),
"accel_controller_selected_lead": getattr(accel_controller, "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,82 @@
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 "base_target" in first
assert "raw_energy_cap" in first
assert "live_filtered_cap" in first
assert "shadow_filtered_cap" not 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)
@@ -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):
+1
View File
@@ -129,6 +129,7 @@ def initialize_params(params) -> list[dict[str, Any]]:
keys.extend([
"ToyotaEnforceStockLongitudinal",
"ToyotaStopAndGoHack",
"ToyotaEnhancedBsm",
])
return [{k: params.get(k, return_default=True)} for k in keys]
@@ -0,0 +1,513 @@
import math
from statistics import median
import numpy as np
from cereal import custom, log
from opendbc.car.interfaces import ACCEL_MIN, ACCEL_MAX
from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalPlanSource, T_IDXS
from openpilot.sunnypilot import get_sanitize_int_param
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.constants import (
ACCEL_LIMIT_HORIZON_JERK, ACCEL_PROFILE_MAX_BP, ACCEL_PROFILE_MAX_V, ACCEL_PROFILES, BRAKING_ACCEL_LIMIT_THRESHOLD, CAP_FILTER_FRAMES,
COMFORT_DECEL, LAUNCH_END_SPEED, LAUNCH_TARGET_HEADROOM, LAUNCH_TARGET_SLEW, LEAD_LOSS_HOLD_TIME, LEAD_MATCH_ACCEL_SLEW,
LEAD_MATCH_GAP_GAIN, LEAD_MATCH_SPEED_HEADROOM, MATCHED_PACE_DECEL_RATE, MPC_DECEL_JERK_COST_MULTIPLIER,
MPC_DECEL_JERK_MAX_REQUIRED_DECEL, MPC_DECEL_JERK_MAX_REQUIRED_DECEL_RATE, MPC_DECEL_JERK_MAX_TARGET_REDUCTION,
MPC_DECEL_TREND_FRAMES, PACE_RELIEF_DEADBAND, PACE_RESTRICT_DEADBAND, PACE_TARGET_ARM_MARGIN, PACE_TARGET_RESERVE, RADAR_STALE_TIMEOUT,
STOP_HOLD_CREEP_DISTANCE, STOP_HOLD_CREEP_SPEED, STOP_HOLD_EGO_SPEED, STOP_HOLD_EXIT_FRAMES, STOP_HOLD_EXIT_SPEED,
STOP_HOLD_FAST_DEPARTURE_DISTANCE, STOP_HOLD_MAX_LEAD_DISTANCE, VEGO_NOISE_TOLERANCE, AccelProfile,
)
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.lead_envelope import EnergyEnvelope, calculate_lead_envelope
AccelControllerState = custom.LongitudinalPlanSP.AccelController.State
class PaceState:
def __init__(self) -> None:
self.cap_samples = [math.inf] * CAP_FILTER_FRAMES
self.lead_speed_samples = [math.inf] * CAP_FILTER_FRAMES
self.lead_accel_samples = [0.0] * CAP_FILTER_FRAMES
self.departure_samples: list[list[float]] = [[], []]
self.departure_motion_samples: list[float] = []
self.departure_references: list[float | None] = [None, None]
self.departure_track_ids = [-1, -1]
self.pace: float | None = None
self.state = AccelControllerState.inactive
self.departure_frames = self.active_frames = self.lead_loss_frames = 0
self.lead_switch_guard_frames = self.stale_frames = 0
self.selected_lead = self.selected_lead_track_id = -1
self.launching = self.departure_launch = self.matched_lead = False
self.lead_braking = self.e2e_braking_handoff = self.pace_reserve_armed = False
self.matched_accel_limit: float | None = None
@property
def filtered_cap(self) -> float:
return sorted(self.cap_samples)[CAP_FILTER_FRAMES // 2]
@property
def filtered_lead_speed(self) -> float:
return sorted(self.lead_speed_samples)[CAP_FILTER_FRAMES // 2]
@property
def filtered_lead_accel(self) -> float:
return sorted(self.lead_accel_samples)[CAP_FILTER_FRAMES // 2]
def robust_departure_separation(self, lead_index: int) -> float:
samples = self.departure_samples[lead_index]
return float(median(samples)) if samples else -math.inf
class AccelController:
def __init__(self, CP, dt: float = DT_MDL):
if not math.isfinite(dt) or dt <= 0.0:
raise ValueError("dt must be finite and positive")
self.dt = dt
self.delay = float(CP.longitudinalActuatorDelay) + DT_MDL
self.lead_loss_hold_frames = max(CAP_FILTER_FRAMES, math.ceil(LEAD_LOSS_HOLD_TIME / dt))
self.radar_stale_frames = max(1, math.ceil(RADAR_STALE_TIMEOUT / dt))
self.params = Params()
self.available = bool(CP.openpilotLongitudinalControl)
self.enabled = False
self.profile = AccelProfile.normal
self._param_read_frames = max(1, int(round(0.25 / dt)))
self._param_frame = 0
self._jerk_smoothing_blocked = False
self._required_decel_samples: list[float] = []
self._required_decel_lead = -1
self.pace_state = PaceState()
self._held_envelope: EnergyEnvelope | None = None
self.is_active = self.launching = self.departure_launching = False
self.output_v_target = 0.0
self.mpc_accel_max: tuple[float, ...] | None = None
self.state = AccelControllerState.inactive
self.selected_lead = -1
self.required_decel = 0.0
@property
def is_enabled(self) -> bool:
return self.available and self.enabled
def update_params(self) -> None:
if self._param_frame % self._param_read_frames == 0:
self.enabled = self.params.get_bool("AccelPersonalityEnabled")
self.profile = get_sanitize_int_param("AccelPersonality", AccelProfile.eco, AccelProfile.sport, self.params)
self._param_frame += 1
@staticmethod
def _profile(profile: int) -> int:
return profile if profile in ACCEL_PROFILES else AccelProfile.normal
@classmethod
def get_profile_accel_max(cls, profile: int, 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 calculate_energy_envelope(self, radar_state, v_ego: float, a_ego: float, profile: int,
follow_personality=log.LongitudinalPersonality.standard) -> EnergyEnvelope:
return calculate_lead_envelope(radar_state, v_ego, a_ego, self.delay, profile, follow_personality)
@staticmethod
def _lead_source(source) -> bool:
return source in (LongitudinalPlanSource.lead0, LongitudinalPlanSource.lead1)
def _update_samples(self, envelope: EnergyEnvelope) -> bool:
state = self.pace_state
had_filtered_lead = math.isfinite(state.filtered_cap)
has_lead = envelope.selected_lead >= 0
state.cap_samples.append(envelope.cap if has_lead else math.inf)
state.lead_speed_samples.append(envelope.selected_lead_speed if has_lead else math.inf)
state.lead_accel_samples.append(envelope.selected_lead_accel if has_lead else 0.0)
state.cap_samples.pop(0)
state.lead_speed_samples.pop(0)
state.lead_accel_samples.pop(0)
state.lead_loss_frames = 0 if has_lead else state.lead_loss_frames + 1
for lead_index, distance in enumerate(envelope.departure_lead_distances):
if not math.isfinite(distance):
continue
samples = state.departure_samples[lead_index]
track_id = envelope.departure_lead_track_ids[lead_index]
identity_changed = (bool(samples) and track_id != state.departure_track_ids[lead_index]
and (track_id >= 0 or state.departure_track_ids[lead_index] >= 0))
max_distance_step = max(STOP_HOLD_CREEP_DISTANCE / 2.0, 3.0 * envelope.departure_lead_speeds[lead_index] * self.dt)
geometry_jump = bool(samples) and abs(distance - samples[-1]) > max_distance_step
if identity_changed or geometry_jump:
samples.clear()
state.departure_references[lead_index] = distance
samples.append(distance)
if len(samples) > CAP_FILTER_FRAMES:
samples.pop(0)
state.departure_track_ids[lead_index] = track_id
lead_index = envelope.departure_lead_index
if lead_index >= 0:
distance = envelope.departure_lead_distances[lead_index]
samples = state.departure_motion_samples
max_distance_step = max(STOP_HOLD_CREEP_DISTANCE / 2.0, 3.0 * envelope.departure_lead_speed * self.dt)
if samples and abs(distance - samples[-1]) > max_distance_step:
samples.clear()
samples.append(distance)
if len(samples) > CAP_FILTER_FRAMES:
samples.pop(0)
return not had_filtered_lead and math.isfinite(state.filtered_cap)
def _seed_departure_tracking(self, envelope: EnergyEnvelope) -> None:
state = self.pace_state
state.departure_samples = [[], []]
state.departure_motion_samples = []
state.departure_references = [None, None]
state.departure_track_ids = list(envelope.departure_lead_track_ids)
for lead_index, distance in enumerate(envelope.departure_lead_distances):
if math.isfinite(distance):
state.departure_samples[lead_index].append(distance)
state.departure_references[lead_index] = distance
if envelope.departure_lead_index >= 0:
state.departure_motion_samples.append(envelope.departure_lead_distances[envelope.departure_lead_index])
def _departure_progress(self, envelope: EnergyEnvelope, minimum_distance: float) -> bool:
lead_index = envelope.departure_lead_index
if lead_index < 0 or envelope.departure_lead_speed <= STOP_HOLD_CREEP_SPEED:
return False
reference = self.pace_state.departure_references[lead_index]
distance = self.pace_state.robust_departure_separation(lead_index)
return reference is not None and distance - reference >= minimum_distance
def _recent_departure_motion(self) -> bool:
samples = self.pace_state.departure_motion_samples[-STOP_HOLD_EXIT_FRAMES:]
if len(samples) < STOP_HOLD_EXIT_FRAMES:
return False
deltas = np.diff(samples)
return bool(samples[-1] - samples[0] >= STOP_HOLD_FAST_DEPARTURE_DISTANCE and np.count_nonzero(deltas > 0.005) >= 2)
def _enter_stop_hold(self, envelope: EnergyEnvelope) -> None:
state = self.pace_state
self._seed_departure_tracking(envelope)
state.pace = 0.0
state.state = AccelControllerState.stopHold
state.departure_frames = 0
state.launching = state.departure_launch = False
state.matched_lead = state.pace_reserve_armed = False
state.matched_accel_limit = None
def _update_pace(self, envelope: EnergyEnvelope, base_speed: float, v_ego: float, profile: int, profile_accel_max: float,
previous_should_stop: bool, previous_mpc_source, planner_speed: float, planner_accel: float) -> float:
state = self.pace_state
lead_filter_ready = self._update_samples(envelope)
state.active_frames += 1
has_lead = envelope.selected_lead >= 0
filtered_cap = state.filtered_cap
slot_changed = has_lead and state.selected_lead >= 0 and envelope.selected_lead != state.selected_lead
track_changed = (has_lead and state.selected_lead >= 0 and envelope.selected_lead == state.selected_lead
and envelope.selected_lead_track_id != state.selected_lead_track_id
and (state.selected_lead_track_id >= 0 or envelope.selected_lead_track_id >= 0))
false_relief = has_lead and math.isfinite(filtered_cap) and envelope.cap >= filtered_cap + PACE_RELIEF_DEADBAND
if (slot_changed or track_changed) and false_relief and state.lead_switch_guard_frames == 0 and planner_accel <= BRAKING_ACCEL_LIMIT_THRESHOLD:
state.lead_switch_guard_frames = self.lead_loss_hold_frames
elif state.lead_switch_guard_frames > 0:
state.lead_switch_guard_frames -= 1
if slot_changed or track_changed:
state.matched_lead = False
state.matched_accel_limit = None
if has_lead:
state.selected_lead = envelope.selected_lead
state.selected_lead_track_id = envelope.selected_lead_track_id
elif state.lead_loss_frames >= self.lead_loss_hold_frames:
state.lead_switch_guard_frames = 0
state.selected_lead = state.selected_lead_track_id = -1
departure_separation = envelope.departure_lead_separations[envelope.departure_lead_index] if envelope.departure_lead_index >= 0 else math.inf
stopped_lead_hold = (has_lead and envelope.has_nearly_stopped_lead
and (envelope.departure_cap < 0.50
or (state.lead_braking and departure_separation <= STOP_HOLD_MAX_LEAD_DISTANCE)))
invalid_lead = envelope.lead_status and not has_lead
prior_lead_context = self._lead_source(previous_mpc_source) or math.isfinite(filtered_cap) or state.lead_braking
previous_stop = previous_should_stop and prior_lead_context and (not has_lead or envelope.departure_lead_speed < STOP_HOLD_EXIT_SPEED)
stop_evidence = stopped_lead_hold or envelope.cap < 0.50 or filtered_cap < 0.50 or (previous_stop and not state.launching) or invalid_lead
departure_motion_confirmed = (
state.launching and state.departure_launch and has_lead
and (self._departure_progress(envelope, STOP_HOLD_FAST_DEPARTURE_DISTANCE) or self._recent_departure_motion())
)
if state.active_frames >= self.lead_loss_hold_frames and math.isfinite(filtered_cap) and has_lead and planner_accel <= BRAKING_ACCEL_LIMIT_THRESHOLD:
state.lead_braking = True
elif not has_lead and state.lead_loss_frames >= self.lead_loss_hold_frames:
state.lead_braking = False
if state.pace is None:
e2e_handoff = previous_mpc_source == LongitudinalPlanSource.e2e
seed_from_ego = has_lead and planner_accel > BRAKING_ACCEL_LIMIT_THRESHOLD and not e2e_handoff
state.pace = min(base_speed, v_ego) if seed_from_ego else base_speed
state.e2e_braking_handoff = e2e_handoff and planner_accel < 0.0
state.state = AccelControllerState.free
if v_ego < STOP_HOLD_EGO_SPEED and not stop_evidence:
state.pace = min(base_speed, v_ego + LAUNCH_TARGET_HEADROOM)
state.state = AccelControllerState.release
state.launching = True
state.departure_launch = False
elif state.e2e_braking_handoff and planner_accel >= 0.0:
state.e2e_braking_handoff = False
state.pace = min(state.pace, base_speed)
if v_ego < STOP_HOLD_EGO_SPEED and stop_evidence and not departure_motion_confirmed and state.state != AccelControllerState.stopHold:
self._enter_stop_hold(envelope)
return state.pace
if state.state == AccelControllerState.stopHold:
for lead_index in range(len(state.departure_references)):
separation = state.robust_departure_separation(lead_index)
if math.isfinite(separation) and state.departure_references[lead_index] is None:
state.departure_references[lead_index] = separation
fast_departure = (has_lead and min(envelope.selected_lead_speed, envelope.departure_lead_speed) > STOP_HOLD_EXIT_SPEED
and envelope.departure_cap > STOP_HOLD_EXIT_SPEED)
raw_departure = fast_departure or not envelope.lead_status and state.lead_loss_frames >= self.lead_loss_hold_frames
departed = self._departure_progress(envelope, STOP_HOLD_CREEP_DISTANCE) or raw_departure
if fast_departure and state.departure_frames == 0 and state.departure_motion_samples:
state.departure_motion_samples = state.departure_motion_samples[-1:]
state.departure_frames = state.departure_frames + 1 if departed else 0
state.pace = 0.0
fast_departure_confirmed = fast_departure and self._recent_departure_motion()
if state.departure_frames < STOP_HOLD_EXIT_FRAMES or fast_departure and not fast_departure_confirmed:
return state.pace
state.pace = base_speed
state.state = AccelControllerState.release
state.departure_frames = 0
state.launching = True
state.departure_launch = has_lead
return state.pace
if state.launching:
renewed_stop = (has_lead and not departure_motion_confirmed
and (envelope.cap < STOP_HOLD_EXIT_SPEED
or (envelope.has_nearly_stopped_lead and envelope.departure_cap < STOP_HOLD_EXIT_SPEED)))
guarded_departure_loss = state.departure_launch and not envelope.lead_status and state.lead_loss_frames < self.lead_loss_hold_frames
if invalid_lead:
state.launching = state.departure_launch = False
if v_ego < STOP_HOLD_EGO_SPEED:
self._enter_stop_hold(envelope)
return state.pace
state.state = AccelControllerState.hold
return state.pace
if guarded_departure_loss:
state.state = AccelControllerState.hold
return state.pace
if state.departure_launch and not has_lead:
state.departure_launch = False
if renewed_stop:
state.launching = state.departure_launch = False
if v_ego < STOP_HOLD_EGO_SPEED:
self._enter_stop_hold(envelope)
return state.pace
if state.departure_launch:
state.pace = base_speed
else:
launch_target = min(base_speed, v_ego + LAUNCH_TARGET_HEADROOM)
state.pace = min(base_speed, max(state.pace, launch_target) + LAUNCH_TARGET_SLEW * self.dt)
if v_ego >= LAUNCH_END_SPEED:
state.launching = state.departure_launch = False
comfort_decel = COMFORT_DECEL[profile]
if (has_lead and not state.launching and state.state == AccelControllerState.restrict
and envelope.closing_speed <= 0.0
and v_ego >= state.filtered_lead_speed - VEGO_NOISE_TOLERANCE):
state.matched_lead = True
elif not has_lead and state.lead_loss_frames >= self.lead_loss_hold_frames:
state.matched_lead = False
lost_lead_source = self._lead_source(previous_mpc_source) and not has_lead and planner_speed < state.pace
if not has_lead and (state.matched_lead or lost_lead_source):
if lost_lead_source:
state.pace = max(planner_speed, state.pace - MATCHED_PACE_DECEL_RATE * self.dt)
state.state = AccelControllerState.hold
return state.pace
if state.matched_lead:
if math.isfinite(state.filtered_lead_speed):
recovery_speed = min(base_speed, state.filtered_lead_speed + min(LEAD_MATCH_SPEED_HEADROOM, LEAD_MATCH_GAP_GAIN * envelope.usable_gap))
desired_accel_limit = min(profile_accel_max, max(recovery_speed - v_ego, 0.0))
else:
desired_accel_limit = 0.0
if state.filtered_lead_accel < BRAKING_ACCEL_LIMIT_THRESHOLD:
desired_accel_limit = profile_accel_max
if state.matched_accel_limit is None:
state.matched_accel_limit = profile_accel_max
if state.lead_switch_guard_frames > 0:
desired_accel_limit = min(desired_accel_limit, state.matched_accel_limit)
state.matched_accel_limit = min(profile_accel_max, float(np.clip(
desired_accel_limit, state.matched_accel_limit - LEAD_MATCH_ACCEL_SLEW * self.dt,
state.matched_accel_limit + LEAD_MATCH_ACCEL_SLEW * self.dt,
)))
matched_ceiling = min(base_speed, filtered_cap)
if matched_ceiling <= state.pace - PACE_RESTRICT_DEADBAND:
state.pace = max(matched_ceiling, state.pace - MATCHED_PACE_DECEL_RATE * self.dt)
state.state = AccelControllerState.restrict
elif state.lead_switch_guard_frames == 0 and matched_ceiling >= state.pace + PACE_RELIEF_DEADBAND:
state.pace = min(matched_ceiling, state.pace + profile_accel_max * self.dt)
state.state = AccelControllerState.free if state.pace >= base_speed - PACE_RESTRICT_DEADBAND else AccelControllerState.release
else:
state.state = AccelControllerState.free if state.pace >= base_speed - PACE_RESTRICT_DEADBAND else AccelControllerState.hold
return state.pace
state.matched_accel_limit = None
ceiling = min(base_speed, filtered_cap)
if lead_filter_ready and state.active_frames == CAP_FILTER_FRAMES // 2 + 1 and not state.launching and planner_speed < state.pace:
state.pace = max(planner_speed, state.pace - comfort_decel * self.dt)
if ceiling <= state.pace - PACE_RESTRICT_DEADBAND or (state.state == AccelControllerState.restrict and ceiling < state.pace):
state.pace = max(ceiling, state.pace - comfort_decel * self.dt)
state.state = AccelControllerState.restrict
return state.pace
filter_warmup = has_lead and not math.isfinite(filtered_cap)
guarded_lead_loss = not has_lead and state.lead_loss_frames < self.lead_loss_hold_frames
if (filter_warmup or guarded_lead_loss) and state.pace < base_speed - PACE_RESTRICT_DEADBAND:
state.state = AccelControllerState.hold
return state.pace
confirmed_clear_road = not math.isfinite(filtered_cap) and not guarded_lead_loss
relief = not has_lead or envelope.closing_speed <= 0.0
if relief and (ceiling >= state.pace + PACE_RELIEF_DEADBAND or (confirmed_clear_road and ceiling > state.pace)):
if state.lead_switch_guard_frames == 0:
state.pace = ceiling
state.state = AccelControllerState.free if state.pace >= base_speed - PACE_RESTRICT_DEADBAND else AccelControllerState.release
else:
state.state = AccelControllerState.free if state.pace >= base_speed - PACE_RESTRICT_DEADBAND else AccelControllerState.hold
return state.pace
@staticmethod
def _valid_context(base_speed: float, v_ego: float, a_ego: float, planner_speed: float, planner_accel: float, stock_accel_max: float,
delay: float, engaged: bool, cruise_initialized: bool) -> bool:
values = (base_speed, v_ego, a_ego, planner_speed, planner_accel, stock_accel_max, delay)
return (engaged and cruise_initialized and base_speed >= 0.0 and v_ego >= -VEGO_NOISE_TOLERANCE
and planner_speed >= 0.0 and stock_accel_max >= 0.0 and delay >= 0.0 and all(math.isfinite(value) for value in values))
def _update_freshness(self, radar_fresh: bool) -> None:
self.pace_state.stale_frames = 0 if radar_fresh else self.pace_state.stale_frames + 1
if self.pace_state.stale_frames >= self.radar_stale_frames:
self.pace_state = PaceState()
@staticmethod
def _build_accel_ceiling(limit: float, planner_accel: float) -> tuple[float, ...] | None:
if limit >= ACCEL_MAX - 1e-9:
return None
a0 = float(np.clip(planner_accel, ACCEL_MIN, ACCEL_MAX))
ceiling = np.clip(np.maximum(limit, a0 - ACCEL_LIMIT_HORIZON_JERK * T_IDXS), 0.0, ACCEL_MAX)
return tuple(float(value) for value in ceiling)
def reset(self) -> None:
self.pace_state = PaceState()
self._held_envelope = None
self.is_active = self.launching = self.departure_launching = False
self.output_v_target = 0.0
self.mpc_accel_max = None
self.state = AccelControllerState.inactive
self.selected_lead = -1
self.required_decel = 0.0
def update(self, radar_state, *, base_speed: float, v_ego: float, a_ego: float, follow_personality, acc_selected: bool,
engaged: bool, cruise_initialized: bool, stock_accel_max: float, previous_should_stop: bool, radar_fresh: bool = True,
previous_mpc_source=None, planner_speed: float | None = None, planner_accel: float = 0.0) -> None:
self.profile = self._profile(self.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(self.profile, sanitized_v_ego)
stock_accel_max = float(stock_accel_max)
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)
planner_speed = sanitized_v_ego if planner_speed is None else planner_speed
valid_context = self._valid_context(base_speed, sanitized_v_ego, a_ego, planner_speed, planner_accel, stock_accel_max, self.delay,
engaged, cruise_initialized)
enabled_context = valid_context and self.is_enabled and bool(acc_selected)
if enabled_context and radar_fresh:
envelope = self.calculate_energy_envelope(radar_state, sanitized_v_ego, a_ego, self.profile, follow_personality)
self._held_envelope = envelope
elif enabled_context and self._held_envelope is not None:
envelope = self._held_envelope
else:
envelope = EnergyEnvelope(lead_status=self._radar_has_lead(radar_state))
self._held_envelope = None
if enabled_context:
self._update_freshness(radar_fresh)
active = enabled_context and (radar_fresh or self.pace_state.pace is not None)
if active and radar_fresh:
pace_target = self._update_pace(
envelope, base_speed, sanitized_v_ego, self.profile, profile_accel_max, previous_should_stop,
previous_mpc_source, planner_speed, planner_accel,
)
elif active:
pace_target = self.pace_state.pace
else:
self.pace_state = PaceState()
pace_target = base_speed
if not radar_fresh and not active:
self._held_envelope = None
envelope = EnergyEnvelope(lead_status=self._radar_has_lead(radar_state))
state = self.pace_state
stop_hold_active = active and state.state == AccelControllerState.stopHold
matched_limit_active = active and state.matched_lead and state.matched_accel_limit is not None and not state.e2e_braking_handoff
lead_accel_request = active and envelope.selected_lead >= 0 and envelope.closing_speed <= 0.0 and planner_accel >= 0.0
profile_limit_active = active and not stop_hold_active and (state.launching or not envelope.lead_status or lead_accel_request)
if matched_limit_active:
effective_accel_max = min(positive_accel_max, state.matched_accel_limit)
elif profile_limit_active:
effective_accel_max = positive_accel_max
else:
effective_accel_max = math.inf
mpc_accel_max = self._build_accel_ceiling(effective_accel_max, planner_accel) if matched_limit_active or profile_limit_active else None
guarded_lead_loss = not envelope.lead_status and state.selected_lead >= 0 and state.lead_loss_frames < self.lead_loss_hold_frames
lead_context = envelope.lead_status or math.isfinite(state.filtered_cap) or guarded_lead_loss
reserve_eligible = (active and lead_context and not stop_hold_active and state.lead_switch_guard_frames == 0
and not state.launching and not state.e2e_braking_handoff)
if not lead_context:
state.pace_reserve_armed = False
elif reserve_eligible and not state.pace_reserve_armed and math.isfinite(state.filtered_cap) and state.filtered_cap <= pace_target + PACE_TARGET_ARM_MARGIN:
state.pace_reserve_armed = True
target_speed = 0.0 if stop_hold_active else pace_target
if reserve_eligible and state.pace_reserve_armed:
target_speed = max(0.0, target_speed - PACE_TARGET_RESERVE)
self.is_active = active
self.launching = active and state.launching
self.departure_launching = self.launching and state.departure_launch
self.output_v_target = target_speed
self.mpc_accel_max = mpc_accel_max
self.state = state.state
self.selected_lead = envelope.selected_lead
self.required_decel = envelope.required_decel
def get_jerk_cost_multiplier(self, actuating: bool, prev_accel_constraint: bool, target_reduction: float, previous_mpc_failed: bool) -> float:
lead_restriction = (actuating and prev_accel_constraint and self.state == AccelControllerState.restrict and self.selected_lead >= 0
and not self.launching and target_reduction > 1e-6)
if not lead_restriction or self.selected_lead != self._required_decel_lead or not math.isfinite(self.required_decel):
self._required_decel_samples.clear()
if lead_restriction and math.isfinite(self.required_decel):
self._required_decel_samples.append(self.required_decel)
if len(self._required_decel_samples) > MPC_DECEL_TREND_FRAMES:
self._required_decel_samples.pop(0)
self._required_decel_lead = self.selected_lead if lead_restriction else -1
history = self._required_decel_samples
tightening_lead = (len(history) == MPC_DECEL_TREND_FRAMES
and (history[-1] - history[0]) / (self.dt * (len(history) - 1)) > MPC_DECEL_JERK_MAX_REQUIRED_DECEL_RATE
and sum(after > before for before, after in zip(history[:-1], history[1:], strict=True)) >= 2)
smoothing_eligible = (lead_restriction and target_reduction < MPC_DECEL_JERK_MAX_TARGET_REDUCTION
and 0.0 < self.required_decel < MPC_DECEL_JERK_MAX_REQUIRED_DECEL and not tightening_lead)
if previous_mpc_failed or (lead_restriction and not self._jerk_smoothing_blocked and not smoothing_eligible):
self._jerk_smoothing_blocked = True
elif not lead_restriction:
self._jerk_smoothing_blocked = False
return MPC_DECEL_JERK_COST_MULTIPLIER if smoothing_eligible and not self._jerk_smoothing_blocked else 1.0
def update_should_stop(self, should_stop: bool) -> bool:
if not self.is_active:
return should_stop
if self.departure_launching:
return False
return should_stop or self.state == AccelControllerState.stopHold
@staticmethod
def _radar_has_lead(radar_state) -> bool:
return bool(radar_state.leadOne.status or radar_state.leadTwo.status)
@@ -0,0 +1,57 @@
from cereal import custom
AccelProfile = custom.LongitudinalPlanSP.AccelController.Profile
ACCEL_PROFILES = tuple(AccelProfile.schema.enumerants.values())
COMFORT_DECEL = {
AccelProfile.eco: 0.25,
AccelProfile.normal: 0.30,
AccelProfile.sport: 0.35,
}
ACCEL_PROFILE_MAX_BP = [0.0, 3.0, 10.0, 25.0, 40.0]
ACCEL_PROFILE_MAX_V = {
AccelProfile.eco: [1.65, 1.30, 0.72, 0.32, 0.16],
AccelProfile.normal: [1.80, 1.50, 0.97, 0.48, 0.30],
AccelProfile.sport: [2.00, 1.90, 1.15, 0.68, 0.42],
}
CAP_FILTER_FRAMES = 5
LEAD_LOSS_HOLD_TIME = 0.50
PACE_RESTRICT_DEADBAND = 0.15
PACE_RELIEF_DEADBAND = 0.35
PACE_TARGET_ARM_MARGIN = 1.0
PACE_TARGET_RESERVE = 0.10
LAUNCH_TARGET_HEADROOM = 3.0
LAUNCH_TARGET_SLEW = 8.75
LAUNCH_END_SPEED = 3.0
ACCEL_LIMIT_HORIZON_JERK = 1.0
LEAD_MATCH_GAP_GAIN = 0.04
LEAD_MATCH_SPEED_HEADROOM = 1.25
LEAD_MATCH_ACCEL_SLEW = 0.25
MATCHED_PACE_DECEL_RATE = 0.50
BRAKING_ACCEL_LIMIT_THRESHOLD = -0.11
MPC_DECEL_JERK_COST_MULTIPLIER = 1.05
MPC_DECEL_JERK_MAX_REQUIRED_DECEL = 0.80
MPC_DECEL_JERK_MAX_REQUIRED_DECEL_RATE = 0.35
MPC_DECEL_JERK_MAX_TARGET_REDUCTION = 9.0
MPC_DECEL_TREND_FRAMES = 4
STOP_HOLD_EGO_SPEED = 0.30
STOPPED_LEAD_SPEED = 0.30
STOP_HOLD_EXIT_SPEED = 0.80
STOP_HOLD_EXIT_FRAMES = 4
STOP_HOLD_CREEP_SPEED = 0.15
STOP_HOLD_CREEP_DISTANCE = 0.30
STOP_HOLD_FAST_DEPARTURE_DISTANCE = 0.03
STOP_HOLD_MAX_LEAD_DISTANCE = 30.0
STOP_GAP_RESERVE = 0.75
STOP_GAP_RESERVE_LEAD_SPEED = 2.0
STOP_GAP_RESERVE_DECEL_BP = (0.30, 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,139 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import math
from typing import NamedTuple
import numpy as np
from cereal import log
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_controller.constants import (
ACCEL_PROFILES, COMFORT_DECEL, MAX_LEAD_ACCEL_TAU, MIN_LEAD_SPEED, STOP_GAP_RESERVE, STOP_GAP_RESERVE_DECEL_BP,
STOP_GAP_RESERVE_LEAD_SPEED, STOPPED_LEAD_SPEED, AccelProfile,
)
class EnergyEnvelope(NamedTuple):
cap: float = math.inf
selected_lead: int = -1
selected_lead_track_id: int = -1
selected_lead_speed: float = math.inf
selected_lead_accel: 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_distances: tuple[float, float] = (-math.inf, -math.inf)
departure_lead_track_ids: tuple[int, int] = (-1, -1)
departure_lead_separations: tuple[float, float] = (-math.inf, -math.inf)
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
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)
def _lead_values(lead) -> tuple[float, float, float, float] | None:
if not lead.status:
return None
d_rel, v_lead = float(lead.dRel), float(lead.vLeadK)
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
a_lead = float(lead.aLeadK)
if not math.isfinite(a_lead):
a_lead = 0.0
a_lead_tau = float(lead.aLeadTau)
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_lead_envelope(radar_state, v_ego: float, a_ego: float, delay: float, profile: int,
follow_personality=log.LongitudinalPersonality.standard) -> EnergyEnvelope:
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()
leads = (radar_state.leadOne, radar_state.leadTwo)
lead_status = any(lead.status for lead in leads)
t_follow = get_T_FOLLOW(follow_personality)
if not math.isfinite(t_follow) or t_follow < 0.0:
return EnergyEnvelope(lead_status=lead_status)
profile = profile if profile in ACCEL_PROFILES else AccelProfile.normal
x_ego, v_ego_delay = _project_ego(v_ego, a_ego, delay)
comfort_decel = COMFORT_DECEL[profile]
candidates: list[EnergyEnvelope] = []
departure_candidates: list[tuple[float, int]] = []
departure_speeds = [math.inf, math.inf]
departure_distances = [-math.inf, -math.inf]
departure_track_ids = [-1, -1]
departure_separations = [-math.inf, -math.inf]
departure_caps = [math.inf, math.inf]
for lead_index, lead in enumerate(leads):
values = _lead_values(lead)
if values is None:
continue
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_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_gap == 0.0 else closing_speed**2 / (2.0 * safety_gap)
reserve = 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_gap - reserve * 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_gap)
separation = x_lead - x_ego
departure_distance = x_lead + float(get_stopped_equivalence_factor(v_lead_delay))
finite_values = (x_lead, v_lead_delay, safety_gap, 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 or not math.isfinite(separation)):
continue
track_id = max(int(lead.radarTrackId), -1) if math.isfinite(lead.radarTrackId) else -1
candidates.append(EnergyEnvelope(
cap=cap, selected_lead=lead_index, selected_lead_track_id=track_id, selected_lead_speed=v_lead_delay, selected_lead_accel=a_lead,
usable_gap=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_distances[lead_index] = d_rel
departure_track_ids[lead_index] = track_id
departure_separations[lead_index] = 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 selected._replace(
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_distances=tuple(departure_distances), departure_lead_track_ids=tuple(departure_track_ids),
departure_lead_separations=tuple(departure_separations), has_nearly_stopped_lead=departure_lead_speed < STOPPED_LEAD_SPEED,
)
@@ -0,0 +1,836 @@
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, LongitudinalPlanSource, get_T_FOLLOW,
)
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.accel_controller import AccelController, AccelControllerState
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.constants import (
ACCEL_LIMIT_HORIZON_JERK, ACCEL_PROFILE_MAX_BP, ACCEL_PROFILE_MAX_V, ACCEL_PROFILES, CAP_FILTER_FRAMES, LAUNCH_END_SPEED,
COMFORT_DECEL, LAUNCH_TARGET_HEADROOM, LAUNCH_TARGET_SLEW, LEAD_MATCH_ACCEL_SLEW, MATCHED_PACE_DECEL_RATE, PACE_TARGET_RESERVE,
RADAR_STALE_TIMEOUT, STOP_GAP_RESERVE, STOP_HOLD_EXIT_FRAMES, AccelProfile,
)
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.lead_envelope import _project_ego
def make_lead(*, status=False, d_rel=0.0, v_lead_k=0.0, a_lead_k=0.0, a_lead_tau=1.5, radar_track_id=-1):
return SimpleNamespace(status=status, dRel=d_rel, vLeadK=v_lead_k, aLeadK=a_lead_k, aLeadTau=a_lead_tau,
radarTrackId=radar_track_id)
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, openpilotLongitudinalControl=True))
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,
"stock_accel_max": ACCEL_MAX,
"previous_should_stop": False,
}
args.update(overrides)
controller.profile = args.pop("profile")
controller.enabled = args.pop("enabled")
controller.update(radar_state or make_radar(), **args)
return SimpleNamespace(
target_speed=controller.output_v_target, active=controller.is_active, launching=controller.launching,
departure_launching=controller.departure_launching, mpc_accel_max=controller.mpc_accel_max, state=controller.state,
selected_lead=controller.selected_lead, required_decel=controller.required_decel,
)
def effective_accel_max(result):
return math.inf if result.mpc_accel_max is None else min(result.mpc_accel_max)
def restrictive_radar():
return make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=8.0, a_lead_k=-0.5))
def enter_stop_hold(controller, *, base_speed=8.0, v_ego=0.1):
stopped = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.0))
return update(controller, stopped, base_speed=base_speed, v_ego=v_ego, previous_should_stop=True)
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.65, 1.30, 0.72, 0.32, 0.16],
AccelProfile.normal: [1.80, 1.50, 0.97, 0.48, 0.30],
AccelProfile.sport: [2.00, 1.90, 1.15, 0.68, 0.42],
}
@pytest.mark.parametrize("profile", ACCEL_PROFILES)
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)
assert np.all(np.diff(limits) <= 0.0)
@pytest.mark.parametrize("speed", ACCEL_PROFILE_MAX_BP)
def test_profile_order_is_distinct(self, speed):
eco, normal, sport = [AccelController.get_profile_accel_max(profile, speed) for profile in ACCEL_PROFILES]
assert eco < normal < sport
def test_invalid_profile_defaults_to_normal(self):
assert AccelController._profile(999) == AccelProfile.normal
def test_stock_limit_intersects_profile_before_mpc(self):
controller = make_controller()
results = [update(controller, v_ego=10.0, profile=AccelProfile.sport, stock_accel_max=0.30)
for _ in range(controller.lead_loss_hold_frames)]
result = results[-1]
assert AccelController.get_profile_accel_max(AccelProfile.sport, 10.0) == pytest.approx(1.15)
assert effective_accel_max(result) == pytest.approx(0.30)
assert all(sample.mpc_accel_max is not None for sample in results)
assert all(max(sample.mpc_accel_max) <= 0.30 + 1e-9 for sample in results)
def test_runtime_profile_switch_applies_the_lookup_value_directly(self):
controller = make_controller()
sport = [update(controller, v_ego=10.0, profile=AccelProfile.sport, stock_accel_max=1.20)
for _ in range(controller.lead_loss_hold_frames)][-1]
eco = update(controller, v_ego=10.0, profile=AccelProfile.eco, stock_accel_max=1.20)
assert effective_accel_max(sport) == pytest.approx(1.15)
assert effective_accel_max(eco) == pytest.approx(0.72)
def test_matched_lead_waits_until_ego_catches_the_lead(self):
radar = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=8.0))
slow_controller, caught_controller = make_controller(), make_controller()
for controller in (slow_controller, caught_controller):
for _ in range(CAP_FILTER_FRAMES + 10):
update(controller, radar, v_ego=10.0, planner_accel=-0.2)
update(slow_controller, radar, v_ego=3.0, planner_accel=-0.2)
update(caught_controller, radar, v_ego=8.0, planner_accel=-0.2)
assert not slow_controller.pace_state.matched_lead
assert caught_controller.pace_state.matched_lead
def test_stock_limit_reduction_applies_immediately(self):
controller = make_controller()
for _ in range(controller.lead_loss_hold_frames):
update(controller, v_ego=10.0, profile=AccelProfile.sport, stock_accel_max=1.20)
reduced = update(controller, v_ego=10.0, profile=AccelProfile.sport, stock_accel_max=0.30)
assert effective_accel_max(reduced) == pytest.approx(0.30)
assert reduced.mpc_accel_max is not None
assert max(reduced.mpc_accel_max) <= 0.30 + 1e-9
def test_one_frame_stock_zero_does_not_poison_profile_recovery(self):
clean_controller, glitch_controller = make_controller(), make_controller()
for _ in range(clean_controller.lead_loss_hold_frames + 10):
clean = update(clean_controller, v_ego=10.0, stock_accel_max=1.5)
recovered = update(glitch_controller, v_ego=10.0, stock_accel_max=1.5)
limited = update(glitch_controller, v_ego=10.0, stock_accel_max=0.0)
clean = update(clean_controller, v_ego=10.0, stock_accel_max=1.5)
recovered = update(glitch_controller, v_ego=10.0, stock_accel_max=1.5)
assert effective_accel_max(limited) == 0.0
assert effective_accel_max(recovered) == pytest.approx(effective_accel_max(clean))
@pytest.mark.parametrize("radar_fresh", (True, False), ids=("dropout", "stale"))
def test_matched_lead_ceiling_obeys_current_stock_limit(self, radar_fresh):
controller = make_controller()
radar = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=8.0))
for _ in range(CAP_FILTER_FRAMES + 10):
update(controller, radar, v_ego=10.0, planner_accel=-0.2)
for _ in range(20):
update(controller, radar, v_ego=8.0, planner_accel=-0.2)
assert controller.pace_state.matched_lead
limited = update(controller, stock_accel_max=0.0, radar_fresh=radar_fresh)
assert effective_accel_max(limited) == 0.0
assert limited.mpc_accel_max is not None
assert max(limited.mpc_accel_max) == 0.0
def test_exact_global_max_uses_stock_ceiling(self):
result = update(make_controller(), base_speed=8.0, v_ego=0.0, profile=AccelProfile.sport)
assert AccelController.get_profile_accel_max(AccelProfile.sport, 0.0) == ACCEL_MAX
assert result.mpc_accel_max is None
class TestMpcCeiling:
@pytest.mark.parametrize("planner_accel", (-1.0, 0.0, 1.2, ACCEL_MAX))
def test_ceiling_is_finite_feasible_and_jerk_bounded(self, planner_accel):
limit = 0.50
ceiling = np.asarray(AccelController._build_accel_ceiling(limit, planner_accel))
a0 = float(np.clip(planner_accel, ACCEL_MIN, ACCEL_MAX))
assert ceiling.shape == T_IDXS.shape
assert np.all(np.isfinite(ceiling))
assert np.all((0.0 <= ceiling) & (ceiling <= ACCEL_MAX))
assert ceiling[0] + 1e-9 >= a0
assert np.all(ceiling + 1e-9 >= limit)
assert np.all(np.diff(ceiling) <= 1e-9)
assert np.all(-np.diff(ceiling) <= ACCEL_LIMIT_HORIZON_JERK * np.diff(T_IDXS) + 1e-9)
def test_zero_limit_remains_feasible_for_positive_x0(self):
ceiling = np.asarray(AccelController._build_accel_ceiling(0.0, 0.8))
assert ceiling[0] == pytest.approx(0.8)
assert ceiling[-1] == pytest.approx(0.0)
assert np.all(ceiling >= 0.0)
def test_inactive_controller_has_no_custom_ceiling(self):
controller = make_controller()
result = update(controller, enabled=False)
assert not result.active
assert result.mpc_accel_max is None
assert math.isinf(effective_accel_max(result))
assert controller.pace_state.pace is None
def test_profile_ceiling_does_not_interfere_while_planner_is_braking(self):
controller = make_controller()
radar = restrictive_radar()
warmup = [update(controller, radar, planner_accel=-0.2) for _ in range(controller.lead_loss_hold_frames)]
assert all(sample.mpc_accel_max is None for sample in warmup)
assert controller.pace_state.lead_braking
bypassed = update(controller, radar, planner_accel=-0.2, acc_selected=False)
assert not bypassed.active and bypassed.mpc_accel_max is None
assert not controller.pace_state.lead_braking
def test_profile_ceiling_stays_continuous_while_a_lead_begins_pulling_away(self):
controller = make_controller()
for _ in range(CAP_FILTER_FRAMES + 5):
update(controller, restrictive_radar(), v_ego=10.0, planner_accel=-0.2)
pulling_away = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=12.0))
result = update(controller, pulling_away, v_ego=10.0, planner_accel=0.2)
assert result.state == AccelControllerState.restrict
assert effective_accel_max(result) == pytest.approx(AccelController.get_profile_accel_max(AccelProfile.normal, 10.0))
assert result.mpc_accel_max is not None
def test_matched_lead_terminal_taper_changes_smoothly(self):
controller = make_controller()
radar = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=8.0))
for _ in range(CAP_FILTER_FRAMES + 5):
update(controller, radar, v_ego=10.0, planner_accel=-0.2)
braking = update(controller, radar, v_ego=8.0, planner_accel=-0.2)
braking_limit = controller.pace_state.matched_accel_limit
accelerating = update(controller, radar, v_ego=8.0, planner_accel=0.2)
assert controller.pace_state.matched_lead
assert braking.mpc_accel_max is not None and accelerating.mpc_accel_max is not None
assert braking_limit is not None
assert abs(controller.pace_state.matched_accel_limit - braking_limit) <= LEAD_MATCH_ACCEL_SLEW * DT_MDL + 1e-9
profile_accel_max = AccelController.get_profile_accel_max(AccelProfile.normal, 8.0)
assert effective_accel_max(braking) <= profile_accel_max
assert effective_accel_max(accelerating) <= profile_accel_max
def test_matched_lead_ignores_two_frame_speed_jump(self):
clean_controller, noisy_controller = make_controller(), make_controller()
radar = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=8.0))
for controller in (clean_controller, noisy_controller):
for _ in range(CAP_FILTER_FRAMES + 10):
update(controller, radar, v_ego=10.0, planner_accel=-0.2)
for _ in range(20):
update(controller, radar, v_ego=8.0, planner_accel=-0.2)
speed_jump = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=16.0))
for _ in range(2):
clean = update(clean_controller, radar, v_ego=8.0)
noisy = update(noisy_controller, speed_jump, v_ego=8.0)
assert effective_accel_max(noisy) == pytest.approx(effective_accel_max(clean))
assert noisy.target_speed == pytest.approx(clean.target_speed)
def test_matched_lead_ignores_two_frame_acceleration_jump(self):
clean_controller, noisy_controller = make_controller(), make_controller()
steady = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=8.0))
for controller in (clean_controller, noisy_controller):
for _ in range(CAP_FILTER_FRAMES + 10):
update(controller, steady, v_ego=10.0, planner_accel=-0.2)
for _ in range(20):
update(controller, steady, v_ego=8.0, planner_accel=-0.2)
braking_jump = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=8.0, a_lead_k=-1.0))
for _ in range(2):
clean = update(clean_controller, steady, v_ego=8.0)
noisy = update(noisy_controller, braking_jump, v_ego=8.0)
assert effective_accel_max(noisy) == pytest.approx(effective_accel_max(clean))
assert noisy.target_speed == pytest.approx(clean.target_speed)
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, _ = _project_ego(10.0, 0.0, delay)
safety_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 * COMFORT_DECEL[AccelProfile.normal] * safety_gap)
assert envelope.cap == pytest.approx(expected)
assert envelope.cap != pytest.approx(math.sqrt(v_lead**2 + 2.0 * COMFORT_DECEL[AccelProfile.normal] * safety_gap))
def test_profile_order_controls_approach_timing(self):
radar = make_radar(make_lead(status=True, d_rel=50.0, v_lead_k=8.0))
caps = [make_controller().calculate_energy_envelope(radar, 10.0, 0.0, profile).cap for profile in ACCEL_PROFILES]
assert caps[0] < caps[1] < caps[2]
def test_stopped_lead_reserve_only_reduces_comfort_gap(self):
envelope = make_controller().calculate_energy_envelope(
make_radar(make_lead(status=True, d_rel=60.0, v_lead_k=0.0)), 5.0, 0.0, AccelProfile.normal,
)
comfort_decel = COMFORT_DECEL[AccelProfile.normal]
safety_gap = (envelope.departure_cap - envelope.departure_lead_speed) ** 2 / (2.0 * comfort_decel)
assert envelope.required_decel < 0.30
assert safety_gap - envelope.usable_gap == pytest.approx(STOP_GAP_RESERVE)
assert envelope.departure_cap > envelope.cap
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))
assert make_controller().calculate_energy_envelope(radar, 10.0, 0.0, AccelProfile.normal).selected_lead == 1
@pytest.mark.parametrize("field,value", [
("aLeadK", math.nan), ("aLeadK", math.inf), ("aLeadTau", math.nan), ("aLeadTau", -1.0), ("radarTrackId", math.nan),
])
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
assert math.isinf(envelope.cap)
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 TestPaceAndLifecycle:
def test_five_frame_median_needs_three_restrictive_samples(self):
controller = make_controller()
filtered_caps = []
for _ in range(CAP_FILTER_FRAMES):
update(controller, restrictive_radar())
filtered_caps.append(controller.pace_state.filtered_cap)
assert math.isinf(filtered_caps[1])
assert math.isfinite(filtered_caps[2])
def test_restriction_uses_comfort_rate_with_one_bounded_reserve_step(self):
controller = make_controller()
results = [update(controller, restrictive_radar()) for _ in range(CAP_FILTER_FRAMES + 10)]
targets = np.asarray([result.target_speed for result in results])
max_step = COMFORT_DECEL[AccelProfile.normal] * DT_MDL
target_steps = -np.diff(targets)
assert np.count_nonzero(target_steps > max_step + 1e-9) == 1
assert np.max(target_steps) <= PACE_TARGET_RESERVE + max_step + 1e-9
assert results[-1].state == AccelControllerState.restrict
assert results[-1].target_speed < results[0].target_speed
@pytest.mark.parametrize("clear_frames", (1, 2, CAP_FILTER_FRAMES + 1))
def test_lead_acquired_after_clear_road_cannot_step_pace_to_planner(self, clear_frames):
controller = make_controller()
for _ in range(clear_frames):
update(controller, base_speed=25.0, v_ego=20.0, planner_speed=25.0)
results = [update(controller, restrictive_radar(), base_speed=25.0, v_ego=20.0, planner_speed=20.0)
for _ in range(CAP_FILTER_FRAMES)]
targets = np.asarray([25.0, *(result.target_speed for result in results)])
max_step = COMFORT_DECEL[AccelProfile.normal] * DT_MDL
target_steps = -np.diff(targets)
assert np.count_nonzero(target_steps > max_step + 1e-9) == 1
assert np.max(target_steps) <= PACE_TARGET_RESERVE + max_step + 1e-9
def test_lead_slot_is_forgotten_before_reacquisition(self):
controller = make_controller()
lead_one = restrictive_radar()
for _ in range(CAP_FILTER_FRAMES + 10):
update(controller, lead_one, base_speed=25.0, v_ego=20.0, planner_speed=20.0, planner_accel=-0.2)
for _ in range(controller.lead_loss_hold_frames):
before = update(controller, base_speed=25.0, v_ego=20.0, planner_speed=20.0, planner_accel=-0.2)
assert controller.pace_state.selected_lead == -1
lead_two = make_radar(lead_two=make_lead(status=True, d_rel=20.0, v_lead_k=8.0, a_lead_k=-0.5))
results = [update(controller, lead_two, base_speed=25.0, v_ego=20.0, planner_speed=5.0, planner_accel=-0.2)
for _ in range(CAP_FILTER_FRAMES)]
targets = np.asarray([before.target_speed, *(result.target_speed for result in results)])
max_step = COMFORT_DECEL[AccelProfile.normal] * DT_MDL
target_steps = -np.diff(targets)
assert np.count_nonzero(target_steps > max_step + 1e-9) == 1
assert np.max(target_steps) <= PACE_TARGET_RESERVE + max_step + 1e-9
@pytest.mark.parametrize("replacement_track_id", (200, -1), ids=("radar-track", "vision-track"))
def test_false_relief_track_replacement_freezes_bounded_pace_release(self, replacement_track_id):
controller = make_controller()
original = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=8.0, radar_track_id=100))
for _ in range(CAP_FILTER_FRAMES + 10):
update(controller, original, base_speed=25.0, v_ego=10.0, planner_speed=10.0, planner_accel=-0.2)
for _ in range(20):
before = update(controller, original, base_speed=25.0, v_ego=8.0, planner_speed=8.0, planner_accel=-0.2)
assert controller.pace_state.matched_lead
replacement = make_radar(make_lead(status=True, d_rel=40.0, v_lead_k=12.0, radar_track_id=replacement_track_id))
switched = update(controller, replacement, base_speed=25.0, v_ego=8.0, planner_speed=5.0, planner_accel=-0.2)
target_drop = before.target_speed - switched.target_speed
assert -PACE_TARGET_RESERVE - 1e-9 <= target_drop <= MATCHED_PACE_DECEL_RATE * DT_MDL + 1e-9
assert effective_accel_max(switched) <= AccelController.get_profile_accel_max(AccelProfile.normal, 8.0) + 1e-9
assert switched.target_speed < 25.0
assert controller.pace_state.lead_switch_guard_frames == controller.lead_loss_hold_frames
def test_track_id_churn_without_false_relief_does_not_arm_guard(self):
controller = make_controller()
original = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=8.0, radar_track_id=100))
for _ in range(CAP_FILTER_FRAMES + 10):
update(controller, original, base_speed=25.0, v_ego=10.0, planner_speed=10.0, planner_accel=-0.2)
replacement = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=8.0, radar_track_id=200))
update(controller, replacement, base_speed=25.0, v_ego=10.0, planner_speed=10.0, planner_accel=-0.2)
assert controller.pace_state.lead_switch_guard_frames == 0
def test_short_dropout_holds_then_releases_without_a_second_accel_cap(self):
controller = make_controller()
for _ in range(CAP_FILTER_FRAMES + 20):
restricted = update(controller, restrictive_radar())
held = [update(controller) for _ in range(controller.lead_loss_hold_frames - 1)]
assert all(result.target_speed <= restricted.target_speed + 1e-9 for result in held)
released = update(controller)
assert released.target_speed == 25.0
def test_previous_lead_source_synchronizes_down_to_planner(self):
controller = make_controller()
for _ in range(CAP_FILTER_FRAMES + 10):
restricted = update(controller, restrictive_radar())
planner_speed = restricted.target_speed - 2.0
synchronized = update(controller, previous_mpc_source=LongitudinalPlanSource.lead0, planner_speed=planner_speed)
assert restricted.target_speed - synchronized.target_speed == pytest.approx(MATCHED_PACE_DECEL_RATE * DT_MDL)
assert synchronized.state == AccelControllerState.hold
def test_matched_lead_dropout_synchronizes_down_to_planner(self):
controller = make_controller()
radar = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=8.0))
for _ in range(CAP_FILTER_FRAMES + 10):
update(controller, radar, v_ego=10.0, planner_accel=-0.2)
for _ in range(20):
matched = update(controller, radar, v_ego=8.0, planner_accel=-0.2)
assert controller.pace_state.matched_lead
planner_speed = matched.target_speed - 2.0
synchronized = update(controller, previous_mpc_source=LongitudinalPlanSource.lead0, planner_speed=planner_speed)
assert matched.target_speed - synchronized.target_speed == pytest.approx(MATCHED_PACE_DECEL_RATE * DT_MDL)
assert synchronized.state == AccelControllerState.hold
def test_reused_radar_holds_matched_lead_until_a_fresh_dropout(self):
controller = make_controller()
radar = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=8.0))
for _ in range(CAP_FILTER_FRAMES + 10):
update(controller, radar, v_ego=10.0, planner_accel=-0.2)
for _ in range(20):
matched = update(controller, radar, v_ego=8.0, planner_accel=-0.2)
assert controller.pace_state.matched_lead
planner_speed = matched.target_speed - 2.0
held = update(controller, radar, previous_mpc_source=LongitudinalPlanSource.lead0,
planner_speed=planner_speed, radar_fresh=False)
synchronized = update(controller, previous_mpc_source=LongitudinalPlanSource.lead0, planner_speed=planner_speed)
assert held.target_speed == pytest.approx(matched.target_speed)
assert held.state == matched.state
assert held.target_speed - synchronized.target_speed == pytest.approx(MATCHED_PACE_DECEL_RATE * DT_MDL)
assert synchronized.state == AccelControllerState.hold
def test_clear_road_launch_has_immediate_headroom_and_bounded_target_slew(self):
controller = make_controller()
initial = update(controller, base_speed=12.0, v_ego=0.0, profile=AccelProfile.normal)
rolling = update(controller, base_speed=12.0, v_ego=0.31, profile=AccelProfile.normal)
assert initial.active and initial.launching
assert LAUNCH_TARGET_HEADROOM <= initial.target_speed <= LAUNCH_TARGET_HEADROOM + LAUNCH_TARGET_SLEW * DT_MDL
assert rolling.launching
assert rolling.target_speed >= 0.31 + LAUNCH_TARGET_HEADROOM
assert rolling.target_speed - max(initial.target_speed, 0.31 + LAUNCH_TARGET_HEADROOM) <= LAUNCH_TARGET_SLEW * DT_MDL + 1e-9
finished = update(controller, base_speed=12.0, v_ego=LAUNCH_END_SPEED, profile=AccelProfile.normal)
assert not finished.launching
def test_far_stopped_lead_does_not_create_stop_hold(self):
controller = make_controller()
far_stopped = make_radar(make_lead(status=True, d_rel=60.0, v_lead_k=0.0))
results = [update(controller, far_stopped, base_speed=12.0, v_ego=0.0) for _ in range(4)]
assert all(result.state != AccelControllerState.stopHold for result in results)
def test_far_stopped_lead_does_not_use_sticky_braking_history_as_stop_evidence(self):
controller = make_controller()
far_stopped = make_radar(make_lead(status=True, d_rel=60.0, v_lead_k=0.0))
for _ in range(controller.lead_loss_hold_frames):
update(controller, far_stopped, base_speed=12.0, v_ego=10.0, planner_accel=-0.2)
assert controller.pace_state.lead_braking
result = update(controller, far_stopped, base_speed=12.0, v_ego=0.2, planner_accel=-0.2)
assert result.state != AccelControllerState.stopHold
assert result.target_speed > 0.0
def test_near_stopped_lead_uses_braking_history_to_hold_completed_stop(self):
controller = make_controller()
stopped = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=0.0))
for _ in range(controller.lead_loss_hold_frames):
update(controller, stopped, base_speed=12.0, v_ego=10.0, planner_accel=-0.2)
assert controller.pace_state.lead_braking
result = update(controller, stopped, base_speed=12.0, v_ego=0.2, planner_accel=-0.2)
assert result.state == AccelControllerState.stopHold
assert controller.pace_state.pace == 0.0
assert result.target_speed == 0.0
assert math.isinf(effective_accel_max(result))
assert result.mpc_accel_max is None
stock_limited = update(controller, stopped, base_speed=12.0, v_ego=0.2, stock_accel_max=0.0)
assert math.isinf(effective_accel_max(stock_limited))
assert stock_limited.mpc_accel_max is None
def test_stop_hold_needs_four_confirmed_departure_frames(self):
controller = make_controller()
held = enter_stop_hold(controller)
assert controller.pace_state.pace == 0.0
results = [update(controller, make_radar(make_lead(status=True, d_rel=6.0 + (frame + 1) * 0.1, v_lead_k=2.0)),
base_speed=8.0, v_ego=0.1) for frame in range(CAP_FILTER_FRAMES + STOP_HOLD_EXIT_FRAMES)]
launch_index = next(index for index, result in enumerate(results) if result.launching)
assert held.state == AccelControllerState.stopHold
assert held.target_speed == 0.0 and math.isinf(effective_accel_max(held))
assert held.mpc_accel_max is None
assert all(result.state == AccelControllerState.stopHold and not result.launching for result in results[:launch_index])
assert launch_index == STOP_HOLD_EXIT_FRAMES - 1
assert results[launch_index].target_speed >= 0.1 + LAUNCH_TARGET_HEADROOM
assert results[launch_index].departure_launching
assert effective_accel_max(results[launch_index]) == pytest.approx(
AccelController.get_profile_accel_max(AccelProfile.normal, 0.1),
)
def test_stopped_governing_lead_rejects_route_51d_radar_speed_pulse_without_delaying_departure(self):
controller = make_controller()
enter_stop_hold(controller, v_ego=0.0)
speed_pulse = (0.1361, 0.1731, 0.2146, 0.2253, 0.2137, 0.1877)
distances = (6.0, 6.0, 6.0, 5.96, 6.04, 6.04)
for distance, speed in zip(distances, speed_pulse, strict=True):
radar = make_radar(make_lead(status=True, d_rel=distance, v_lead_k=speed, radar_track_id=4887),
make_lead(status=True, d_rel=6.08, v_lead_k=0.0, radar_track_id=4905))
held = update(controller, radar, base_speed=8.0, v_ego=0.0)
assert held.state == AccelControllerState.stopHold
assert held.target_speed == 0.0 and not held.launching
results = [
update(controller, make_radar(make_lead(status=True, d_rel=6.04 + (frame + 1) * 0.1, v_lead_k=2.0, radar_track_id=4887),
make_lead(status=True, d_rel=6.12 + (frame + 1) * 0.1, v_lead_k=2.0, radar_track_id=4905)),
base_speed=8.0, v_ego=0.0)
for frame in range(STOP_HOLD_EXIT_FRAMES)
]
assert all(result.state == AccelControllerState.stopHold for result in results[:-1])
assert results[-1].launching and results[-1].departure_launching
def test_route_520_slow_lead_pulse_cannot_release_stop_hold_but_real_departure_can(self):
controller = make_controller()
enter_stop_hold(controller, v_ego=0.0)
speeds = (0.01, 0.03, 0.07, 0.10, 0.14, 0.20, 0.26, 0.32, 0.34, 0.33, 0.31, 0.28, 0.24, 0.20, 0.15, 0.09, 0.05, 0.01)
offsets = (0.00, 0.00, 0.00, 0.01, 0.01, 0.02, 0.03, 0.04, 0.06, 0.07, 0.09, 0.11, 0.12, 0.13, 0.14, 0.15, 0.15, 0.16)
for offset, speed in zip(offsets, speeds, strict=True):
pulse = make_radar(make_lead(status=True, d_rel=6.0 + offset, v_lead_k=speed, radar_track_id=2133))
held = update(controller, pulse, base_speed=8.0, v_ego=0.0)
assert held.state == AccelControllerState.stopHold
assert held.target_speed == 0.0 and not held.launching
stopped = make_radar(make_lead(status=True, d_rel=6.2, v_lead_k=0.0, radar_track_id=2133))
assert update(controller, stopped, base_speed=8.0, v_ego=0.0).state == AccelControllerState.stopHold
results = [update(controller, make_radar(make_lead(status=True, d_rel=6.2 + (frame + 1) * 0.1, v_lead_k=2.0, radar_track_id=2133)),
base_speed=8.0, v_ego=0.0) for frame in range(STOP_HOLD_EXIT_FRAMES)]
assert all(result.state == AccelControllerState.stopHold for result in results[:-1])
assert results[-1].launching and results[-1].departure_launching
def test_fast_speed_signal_that_slows_without_separating_never_releases_stop_hold(self):
controller = make_controller()
enter_stop_hold(controller, v_ego=0.0)
departing = make_radar(make_lead(status=True, d_rel=5.9, v_lead_k=2.0))
results = [update(controller, departing, base_speed=8.0, v_ego=0.0) for _ in range(STOP_HOLD_EXIT_FRAMES)]
slowed = update(controller, make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.2)), base_speed=8.0, v_ego=0.0)
assert all(result.state == AccelControllerState.stopHold and not result.launching for result in results)
assert slowed.state == AccelControllerState.stopHold
assert slowed.target_speed == 0.0 and not slowed.launching
def test_stop_hold_reseeds_departure_distance_when_radar_track_is_replaced(self):
controller = make_controller()
original = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.0, radar_track_id=100))
update(controller, original, base_speed=8.0, v_ego=0.0, previous_should_stop=True)
replacement = make_radar(make_lead(status=True, d_rel=6.4, v_lead_k=0.2, radar_track_id=200))
results = [update(controller, replacement, base_speed=8.0, v_ego=0.0) for _ in range(CAP_FILTER_FRAMES + STOP_HOLD_EXIT_FRAMES)]
assert all(result.state == AccelControllerState.stopHold for result in results)
assert all(result.target_speed == 0.0 and not result.launching for result in results)
def test_stop_hold_rejects_persistent_same_track_distance_step(self):
controller = make_controller()
original = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.0, radar_track_id=100))
update(controller, original, base_speed=8.0, v_ego=0.0, previous_should_stop=True)
stepped = make_radar(make_lead(status=True, d_rel=6.4, v_lead_k=0.2, radar_track_id=100))
results = [update(controller, stepped, base_speed=8.0, v_ego=0.0) for _ in range(CAP_FILTER_FRAMES + STOP_HOLD_EXIT_FRAMES)]
assert all(result.state == AccelControllerState.stopHold for result in results)
assert all(result.target_speed == 0.0 and not result.launching for result in results)
def test_stop_hold_reseeds_non_selected_departure_lead_when_its_track_is_replaced(self):
controller = make_controller()
original = make_radar(make_lead(status=True, d_rel=3.0, v_lead_k=0.2, radar_track_id=100),
make_lead(status=True, d_rel=6.0, v_lead_k=0.1, radar_track_id=200))
update(controller, original, base_speed=8.0, v_ego=0.0, previous_should_stop=True)
replacement = make_radar(make_lead(status=True, d_rel=3.4, v_lead_k=0.2, radar_track_id=101),
make_lead(status=True, d_rel=6.0, v_lead_k=0.1, radar_track_id=200))
envelope = controller.calculate_energy_envelope(replacement, 0.0, 0.0, AccelProfile.normal)
results = [update(controller, replacement, base_speed=8.0, v_ego=0.0) for _ in range(CAP_FILTER_FRAMES + STOP_HOLD_EXIT_FRAMES)]
assert envelope.selected_lead == 1 and envelope.departure_lead_index == 0
assert all(result.state == AccelControllerState.stopHold for result in results)
assert all(result.target_speed == 0.0 and not result.launching for result in results)
def test_genuine_departure_survives_lead_slot_and_track_flicker(self):
controller = make_controller()
enter_stop_hold(controller, v_ego=0.0)
results = []
for frame in range(STOP_HOLD_EXIT_FRAMES):
moving = make_lead(status=True, d_rel=6.0 + (frame + 1) * 0.1, v_lead_k=2.0, radar_track_id=100)
secondary = make_lead(status=True, d_rel=7.0, v_lead_k=2.0, radar_track_id=200)
results.append(update(controller, make_radar(moving, secondary) if frame % 2 == 0 else make_radar(secondary, moving),
base_speed=8.0, v_ego=0.0))
assert all(result.state == AccelControllerState.stopHold for result in results[:-1])
assert results[-1].launching and results[-1].departure_launching
def test_fast_speed_glitch_without_distance_progress_stays_in_stop_hold(self):
controller = make_controller()
stopped = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.0, radar_track_id=100))
update(controller, stopped, base_speed=8.0, v_ego=0.0, previous_should_stop=True)
glitch = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.9, radar_track_id=100))
results = [update(controller, glitch, base_speed=8.0, v_ego=0.0) for _ in range(STOP_HOLD_EXIT_FRAMES)]
results.append(update(controller, stopped, base_speed=8.0, v_ego=0.0))
assert all(result.state == AccelControllerState.stopHold for result in results)
assert all(result.target_speed == 0.0 and not result.launching for result in results)
def test_moving_departure_does_not_reenter_stop_hold_when_speed_crosses_exit_threshold(self):
controller = make_controller()
stopped = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.0, radar_track_id=100))
update(controller, stopped, base_speed=8.0, v_ego=0.0, previous_should_stop=True)
distance = 6.0
results = []
for speed in (0.81, 0.82, 0.83, 0.84, 0.79, 0.76, 0.74, 0.72):
distance += speed * DT_MDL
radar = make_radar(make_lead(status=True, d_rel=distance, v_lead_k=speed, radar_track_id=100))
results.append(update(controller, radar, base_speed=8.0, v_ego=0.0))
launch_index = next(index for index, result in enumerate(results) if result.launching)
assert all(result.state != AccelControllerState.stopHold for result in results[launch_index:])
assert all(result.target_speed > 0.0 and result.departure_launching for result in results[launch_index:])
def test_reused_radar_does_not_pulse_stop_hold_or_departure_target(self):
controller = make_controller()
enter_stop_hold(controller)
for frame in range(STOP_HOLD_EXIT_FRAMES):
departing = make_radar(make_lead(status=True, d_rel=6.0 + (frame + 1) * 0.1, v_lead_k=2.0))
fresh = update(controller, departing, base_speed=8.0, v_ego=0.1)
held = update(controller, departing, base_speed=8.0, v_ego=0.1, radar_fresh=False,
previous_mpc_source=LongitudinalPlanSource.lead0, planner_speed=0.01)
assert held.target_speed == pytest.approx(fresh.target_speed)
assert held.state == fresh.state
assert held.selected_lead == fresh.selected_lead == 0
assert effective_accel_max(held) == pytest.approx(effective_accel_max(fresh))
if frame < STOP_HOLD_EXIT_FRAMES - 1:
assert fresh.state == AccelControllerState.stopHold
assert math.isinf(effective_accel_max(fresh))
assert fresh.mpc_accel_max is None
assert fresh.launching and held.launching
assert fresh.departure_launching and held.departure_launching
assert fresh.target_speed == held.target_speed == 8.0
assert effective_accel_max(fresh) == pytest.approx(AccelController.get_profile_accel_max(AccelProfile.normal, 0.1))
def test_single_frame_departure_stays_at_zero_target_without_an_accel_ceiling(self):
controller = make_controller()
enter_stop_hold(controller)
departing = make_radar(make_lead(status=True, d_rel=8.0, v_lead_k=2.0))
stopped = make_radar(make_lead(status=True, d_rel=8.0, v_lead_k=0.0))
warm = update(controller, departing, base_speed=8.0, v_ego=0.0)
held = update(controller, stopped, base_speed=8.0, v_ego=0.0)
assert warm.state == held.state == AccelControllerState.stopHold
assert not warm.launching and not held.launching
assert math.isinf(effective_accel_max(warm)) and warm.mpc_accel_max is None
assert math.isinf(effective_accel_max(held)) and held.mpc_accel_max is None
assert held.target_speed == 0.0
def test_previous_stop_without_a_lead_does_not_latch_stop_hold(self):
result = update(
make_controller(), base_speed=8.0, v_ego=0.0, previous_should_stop=True,
previous_mpc_source=LongitudinalPlanSource.cruise,
)
assert result.state != AccelControllerState.stopHold
assert result.target_speed >= LAUNCH_TARGET_HEADROOM
def test_previous_lead_stop_survives_a_fresh_full_field_dropout(self):
result = update(
make_controller(), base_speed=8.0, v_ego=0.0, previous_should_stop=True,
previous_mpc_source=LongitudinalPlanSource.lead0,
)
assert result.state == AccelControllerState.stopHold
assert result.target_speed == 0.0
assert math.isinf(effective_accel_max(result))
assert result.mpc_accel_max is None
def test_stop_hold_without_usable_lead_stays_pinned_to_zero(self):
controller = make_controller()
enter_stop_hold(controller)
missing = update(controller, base_speed=8.0, v_ego=0.1)
assert missing.state == AccelControllerState.stopHold
assert missing.target_speed == 0.0
assert math.isinf(effective_accel_max(missing))
assert missing.mpc_accel_max is None
def test_confirmed_creep_departure_does_not_reenter_stop_hold(self):
controller = make_controller()
enter_stop_hold(controller, v_ego=0.0)
results = []
for frame in range(60):
creeping = make_radar(make_lead(status=True, d_rel=6.0 + frame * 0.01, v_lead_k=0.2))
results.append(update(controller, creeping, base_speed=8.0, v_ego=0.0))
launch_index = next(index for index, result in enumerate(results) if result.launching)
assert launch_index * DT_MDL <= 2.0
assert all(result.state != AccelControllerState.stopHold for result in results[launch_index:])
assert all(result.target_speed > 0.0 for result in results[launch_index:])
def test_departure_dropout_holds_without_resurrecting_stop_hold(self):
controller = make_controller()
enter_stop_hold(controller)
results = [update(controller, make_radar(make_lead(status=True, d_rel=6.0 + (frame + 1) * 0.1, v_lead_k=2.0)),
base_speed=8.0, v_ego=0.1) for frame in range(CAP_FILTER_FRAMES + STOP_HOLD_EXIT_FRAMES)]
launched = next(result for result in results if result.launching)
before_dropout = results[-1]
dropout = [update(controller, base_speed=8.0, v_ego=0.1) for _ in range(controller.lead_loss_hold_frames + 1)]
assert launched.launching
assert all(result.state != AccelControllerState.stopHold for result in dropout)
assert all(result.target_speed <= before_dropout.target_speed + 1e-9 for result in dropout[:controller.lead_loss_hold_frames - 1])
assert dropout[controller.lead_loss_hold_frames - 1].target_speed > before_dropout.target_speed
assert dropout[-1].launching
def test_invalid_departure_geometry_returns_to_stop_hold(self):
controller = make_controller()
enter_stop_hold(controller)
for frame in range(CAP_FILTER_FRAMES + STOP_HOLD_EXIT_FRAMES):
departing = make_radar(make_lead(status=True, d_rel=6.0 + (frame + 1) * 0.1, v_lead_k=2.0))
launched = update(controller, departing, base_speed=8.0, v_ego=0.1)
invalid = make_radar(make_lead(status=True, d_rel=math.nan, v_lead_k=2.0))
guarded = update(controller, invalid, base_speed=8.0, v_ego=0.1)
assert launched.launching
assert guarded.state == AccelControllerState.stopHold
assert guarded.target_speed == 0.0
def test_stale_timeout_fully_resets_live_state(self):
controller = make_controller()
for _ in range(CAP_FILTER_FRAMES + 10):
restricted = update(controller, restrictive_radar())
stale_frames = math.ceil(RADAR_STALE_TIMEOUT / DT_MDL)
held = [update(controller, radar_fresh=False) for _ in range(stale_frames - 1)]
timed_out = update(controller, radar_fresh=False)
assert all(result.active and result.target_speed == pytest.approx(restricted.target_speed) for result in held)
assert not timed_out.active
assert timed_out.target_speed == 25.0
assert timed_out.mpc_accel_max is None
assert timed_out.selected_lead == -1 and controller._held_envelope is None
assert controller.pace_state.pace is None
@pytest.mark.parametrize("override", [{"enabled": False}, {"acc_selected": False}, {"engaged": False}, {"cruise_initialized": False}, {"a_ego": math.inf}])
def test_bypass_or_invalid_context_resets_live_state(self, override):
controller = make_controller()
for _ in range(CAP_FILTER_FRAMES + 10):
update(controller, restrictive_radar())
result = update(controller, restrictive_radar(), **override)
assert not result.active
assert result.target_speed == 25.0
assert result.mpc_accel_max is None
assert controller.pace_state.pace is None
def test_acc_bypass_does_not_retain_state_for_live_actuation(self):
controller = make_controller()
for _ in range(CAP_FILTER_FRAMES + 20):
bypassed = update(controller, restrictive_radar(), acc_selected=False)
assert not bypassed.active
assert controller.pace_state.pace is None
assert controller._held_envelope is None
live = update(controller)
assert live.active and live.target_speed == 25.0
assert math.isinf(controller.pace_state.filtered_cap)
def test_explicit_reset_clears_pace_state(self):
controller = make_controller()
for _ in range(CAP_FILTER_FRAMES + 10):
update(controller, restrictive_radar())
controller.reset()
assert controller._held_envelope is None
pace_state = controller.pace_state
assert pace_state.pace is None and pace_state.matched_accel_limit is None
assert pace_state.state == AccelControllerState.inactive
assert pace_state.departure_frames == pace_state.active_frames == pace_state.lead_loss_frames == pace_state.stale_frames == 0
assert not pace_state.departure_motion_samples
assert not pace_state.launching and not pace_state.departure_launch and not pace_state.matched_lead
assert not pace_state.lead_braking and not pace_state.e2e_braking_handoff and not pace_state.pace_reserve_armed
assert math.isinf(pace_state.filtered_cap) and math.isinf(pace_state.filtered_lead_speed) and pace_state.filtered_lead_accel == 0.0
@@ -0,0 +1,512 @@
import inspect
import math
from types import SimpleNamespace
import numpy as np
import pytest
from cereal import custom, log, messaging
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 N, LongitudinalMpc
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalPlanSource as MpcLongitudinalPlanSource
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.accel_controller import AccelController, AccelControllerState
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.constants import (
MPC_DECEL_JERK_COST_MULTIPLIER, MPC_DECEL_JERK_MAX_REQUIRED_DECEL, MPC_DECEL_JERK_MAX_TARGET_REDUCTION, AccelProfile,
)
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalMpcSP
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlannerSP, LongitudinalPlanSource
def radar_state():
return messaging.new_message("radarState").radarState
class PlannerSM(dict):
def __init__(self, radar_log_mono_time: int):
super().__init__(
radarState=radar_state(),
carState=SimpleNamespace(vEgo=10.0, aEgo=0.0, vCruise=20.0),
selfdriveState=SimpleNamespace(personality=0),
controlsState=SimpleNamespace(forceDecel=False),
)
self.valid = {"radarState": True}
self.alive = {"radarState": True}
self.logMonoTime = {"radarState": radar_log_mono_time}
class ControllerStub:
def __init__(self, *, target_speed=15.0, active=True, mpc_accel_max=None, state=AccelControllerState.free, selected_lead=-1,
launching=False, departure_launching=False, required_decel=0.0):
self.available = self.enabled = True
self.profile = AccelProfile.normal
self.output_v_target = target_speed
self.is_active = active
self.mpc_accel_max = mpc_accel_max
self.state = state
self.selected_lead = selected_lead
self.launching = launching
self.departure_launching = departure_launching
self.required_decel = required_decel
self.dt = DT_MDL
self._jerk_smoothing_blocked = False
self._required_decel_samples = []
self._required_decel_lead = -1
self.update_kwargs = None
self.reset_calls = 0
def update(self, _radar_state, **kwargs):
self.update_kwargs = kwargs
@property
def is_enabled(self):
return self.available and self.enabled
def update_params(self):
pass
def reset(self):
self.reset_calls += 1
def get_jerk_cost_multiplier(self, *args):
return AccelController.get_jerk_cost_multiplier(self, *args)
def update_should_stop(self, should_stop):
return AccelController.update_should_stop(self, should_stop)
def planner_for_mpc_test(*, target_speed=15.0, active=True, is_e2e=False, mpc_accel_max=None,
state=AccelControllerState.free, selected_lead=-1, launching=False,
departure_launching=False, required_decel=0.0,
mpc_source=MpcLongitudinalPlanSource.lead0):
planner = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
is_e2e_calls = []
planner.is_e2e = lambda _sm: is_e2e_calls.append(True) or is_e2e
planner.output_v_target = 20.0
planner.output_should_stop = False
planner.allow_throttle = True
planner.a_desired = 0.0
planner.v_desired_filter = SimpleNamespace(x=10.0)
planner._radar_fresh_this_cycle = True
planner.mpc = SimpleNamespace(source=mpc_source, last_solution_status=0)
planner.accel_controller = ControllerStub(
target_speed=target_speed, active=active, state=state, selected_lead=selected_lead, launching=launching,
departure_launching=departure_launching, required_decel=required_decel, mpc_accel_max=mpc_accel_max,
)
return planner, is_e2e_calls
def run_controller_mpc(planner, *, mpc_v_cruise=20.0, force_decel=False):
calls = []
planner._run_mpc = lambda _sm, *args, **kwargs: calls.append((({}, *args), kwargs))
sm = {
"radarState": radar_state(),
"controlsState": SimpleNamespace(forceDecel=force_decel),
"carState": SimpleNamespace(vCruise=20.0, vEgo=10.0, aEgo=0.0),
"selfdriveState": SimpleNamespace(personality=0),
}
is_e2e = planner.update_mpc(sm, mpc_v_cruise, True, ACCEL_MAX, False)
return is_e2e, calls
def test_accel_controller_schema_contract():
expected = {"eco": 0, "normal": 1, "sport": 2}
state = {"inactive": 0, "free": 1, "restrict": 2, "hold": 3, "release": 4, "stopHold": 5}
accel_controller = custom.LongitudinalPlanSP.schema.fields["accelController"]
fields = custom.LongitudinalPlanSP.AccelController.schema.fields
assert accel_controller.proto.ordinal.explicit == 8
assert {name: field.proto.ordinal.explicit for name, field in fields.items()} == {
"enabled": 0, "active": 1, "shadowOnlyDEPRECATED": 2, "profile": 3, "state": 4,
}
assert fields["shadowOnlyDEPRECATED"].proto.slot.type.which() == "bool"
assert custom.LongitudinalPlanSP.AccelerationPersonality.schema.enumerants == expected
assert custom.LongitudinalPlanSP.AccelController.Profile.schema.enumerants == expected
assert custom.LongitudinalPlanSP.AccelController.State.schema.enumerants == state
def test_accel_controller_schema_round_trip_and_toyota_compatibility():
message = custom.LongitudinalPlanSP.new_message()
message.accelController.enabled = True
message.accelController.active = True
message.accelController.profile = custom.LongitudinalPlanSP.AccelController.Profile.sport
message.accelController.state = custom.LongitudinalPlanSP.AccelController.State.release
with custom.LongitudinalPlanSP.from_bytes(message.to_bytes()) as reader:
assert reader.accelController.enabled and reader.accelController.active
assert reader.accelController.profile == custom.LongitudinalPlanSP.AccelController.Profile.sport
assert reader.accelController.state == custom.LongitudinalPlanSP.AccelController.State.release
from opendbc.car.toyota.carstate import AccelPersonality, CarState
assert AccelPersonality.schema.enumerants == {"eco": 0, "normal": 1, "sport": 2}
assert CarState.__module__ == "opendbc.car.toyota.carstate"
def test_longitudinal_planner_sp_owns_accel_controller_integration():
assert "update_mpc" in LongitudinalPlannerSP.__dict__
assert "update_should_stop" in LongitudinalPlannerSP.__dict__
def test_mpc_inherits_accel_controller_extension_without_changing_stock_signature_or_bounds():
assert LongitudinalMpc.__bases__ == (LongitudinalMpcSP,)
assert tuple(inspect.signature(LongitudinalMpc.update).parameters) == ("self", "radarstate", "v_cruise", "personality")
mpc = LongitudinalMpc()
radar = radar_state()
mpc.run = lambda: None
mpc.set_cur_state(10.0, 0.8)
mpc.update(radar, 30.0)
np.testing.assert_array_equal(mpc.params[:, 0], ACCEL_MIN)
np.testing.assert_array_equal(mpc.params[:, 1], ACCEL_MAX)
requested_ceiling = tuple(np.full(N + 1, 0.4))
mpc.set_accel_controller_params(requested_ceiling, 1.0)
mpc.update(radar, 30.0)
np.testing.assert_array_equal(mpc.params[:, 0], ACCEL_MIN)
assert mpc.params[0, 1] == pytest.approx(0.8)
np.testing.assert_array_equal(mpc.params[1:, 1], requested_ceiling[1:])
for malformed_ceiling in ("bad", [0.4] * N, np.full(N + 1, math.nan), [10**10000] * (N + 1)):
mpc.set_accel_controller_params(malformed_ceiling, 1.0)
mpc.update(radar, 30.0)
np.testing.assert_array_equal(mpc.params[:, 0], ACCEL_MIN)
np.testing.assert_array_equal(mpc.params[:, 1], ACCEL_MAX)
mpc.set_accel_controller_params(None, 1.0)
mpc.update(radar, 30.0)
np.testing.assert_array_equal(mpc.params[:, 1], ACCEL_MAX)
def test_mpc_jerk_cost_multiplier_is_backward_compatible_and_does_not_change_other_costs():
mpc = LongitudinalMpc.__new__(LongitudinalMpc)
LongitudinalMpcSP.__init__(mpc)
captured = []
mpc.set_cost_weights = lambda costs, constraints: captured.append((np.asarray(costs), np.asarray(constraints)))
mpc.set_weights(True, personality=log.LongitudinalPersonality.standard)
default_costs, default_constraints = captured[-1]
mpc.set_accel_controller_params(None, 1.0)
mpc.set_weights(True, personality=log.LongitudinalPersonality.standard)
explicit_costs, explicit_constraints = captured[-1]
mpc.set_accel_controller_params(None, 1.2)
mpc.set_weights(True, personality=log.LongitudinalPersonality.standard)
smoothed_costs, smoothed_constraints = captured[-1]
np.testing.assert_array_equal(explicit_costs, default_costs)
np.testing.assert_array_equal(explicit_constraints, default_constraints)
np.testing.assert_array_equal(smoothed_costs[:-1], default_costs[:-1])
assert smoothed_costs[-1] == pytest.approx(default_costs[-1] * 1.2)
np.testing.assert_array_equal(smoothed_constraints, default_constraints)
mpc.set_weights(False, personality=log.LongitudinalPersonality.standard)
assert captured[-1][0][-2] == 0.0
assert captured[-1][0][-1] == pytest.approx(default_costs[-1] * 1.2)
def test_inherited_planner_uses_real_state_raw_radar_and_one_mpc_solve():
radar = radar_state()
planner = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
planner.a_desired = -0.2
planner.v_desired_filter = SimpleNamespace(x=12.0)
calls = []
def update_mpc(radar_arg, target, *, personality):
calls.append(("update", radar_arg, target, personality))
planner.mpc = SimpleNamespace(
set_accel_controller_params=lambda accel_max, multiplier: calls.append(("configure", accel_max, multiplier)),
set_weights=lambda constraint, personality: calls.append(("weights", constraint, personality)),
set_cur_state=lambda speed, accel: calls.append(("state", speed, accel)),
update=update_mpc,
)
ceiling = tuple(np.linspace(0.8, 0.4, N + 1))
sm = {"radarState": radar, "selfdriveState": SimpleNamespace(personality=2)}
planner._run_mpc(sm, 17.5, True, ceiling, jerk_cost_multiplier=1.2)
assert calls == [
("configure", ceiling, 1.2),
("weights", True, 2),
("state", 12.0, -0.2),
("update", radar, 17.5, 2),
]
assert calls[-1][1] is radar
def test_active_acc_uses_target_and_ceiling_in_exactly_one_solve():
ceiling = tuple(np.linspace(0.8, 0.4, N + 1))
planner, mode_calls = planner_for_mpc_test(mpc_accel_max=ceiling)
is_e2e, calls = run_controller_mpc(planner)
assert not is_e2e
assert len(mode_calls) == 1
assert calls == [(({}, 15.0, True, ceiling), {"jerk_cost_multiplier": 1.0})]
def test_valid_lead_stop_hold_preplans_from_raw_target_without_an_accel_ceiling():
planner, _ = planner_for_mpc_test(
target_speed=0.0, mpc_accel_max=None, state=AccelControllerState.stopHold, selected_lead=0,
)
_, calls = run_controller_mpc(planner)
assert calls == [(({}, 20.0, True, None), {"jerk_cost_multiplier": 1.0})]
def test_missing_lead_stop_hold_keeps_zero_mpc_target_without_an_accel_ceiling():
planner, _ = planner_for_mpc_test(
target_speed=0.0, mpc_accel_max=None, state=AccelControllerState.stopHold, selected_lead=-1,
)
_, calls = run_controller_mpc(planner)
assert calls == [(({}, 0.0, True, None), {"jerk_cost_multiplier": 1.0})]
@pytest.mark.parametrize(
("active", "departure_launching", "expected"),
[
(True, True, False),
(True, False, True),
(False, True, True),
],
)
def test_only_confirmed_live_acc_departure_clears_should_stop(active, departure_launching, expected):
planner = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
planner.accel_controller = ControllerStub(active=active, departure_launching=departure_launching, state=AccelControllerState.stopHold)
assert planner.update_should_stop(True) is expected
assert planner.update_should_stop(False) is (active and not departure_launching)
@pytest.mark.parametrize(("active", "is_e2e"), [(False, False), (True, True)])
def test_disabled_or_e2e_is_an_exact_mpc_bypass(active, is_e2e):
ceiling = tuple(np.linspace(0.8, 0.4, N + 1))
planner, mode_calls = planner_for_mpc_test(active=active, is_e2e=is_e2e, mpc_accel_max=ceiling)
returned_e2e, calls = run_controller_mpc(planner)
assert returned_e2e is is_e2e
assert len(mode_calls) == 1
assert calls == [(({}, 20.0, True, None), {"jerk_cost_multiplier": 1.0})]
def test_force_decel_target_remains_authoritative_and_disables_ceiling():
ceiling = tuple(np.linspace(0.8, 0.4, N + 1))
planner, mode_calls = planner_for_mpc_test(mpc_accel_max=ceiling)
_, calls = run_controller_mpc(planner, mpc_v_cruise=0.0, force_decel=True)
assert len(mode_calls) == 1
assert calls == [(({}, 0.0, True, None), {"jerk_cost_multiplier": 1.0})]
def test_previous_mpc_failure_gets_one_stock_recovery_cycle():
ceiling = tuple(np.linspace(0.8, 0.4, N + 1))
planner, mode_calls = planner_for_mpc_test(mpc_accel_max=ceiling)
controller = planner.accel_controller
planner.mpc.last_solution_status = 4
_, failed_recovery_calls = run_controller_mpc(planner)
assert controller.reset_calls == 1
assert len(mode_calls) == 1
assert failed_recovery_calls == [(({}, 20.0, True, None), {"jerk_cost_multiplier": 1.0})]
planner.mpc.last_solution_status = 0
_, recovered_calls = run_controller_mpc(planner)
assert controller.reset_calls == 1
assert len(mode_calls) == 2
assert recovered_calls == [(({}, 15.0, True, ceiling), {"jerk_cost_multiplier": 1.0})]
@pytest.mark.parametrize(
"mpc_source",
(MpcLongitudinalPlanSource.cruise, MpcLongitudinalPlanSource.lead0, MpcLongitudinalPlanSource.lead1),
)
def test_routine_governor_restriction_forwards_the_jerk_cost_multiplier(mpc_source):
planner, _ = planner_for_mpc_test(
state=AccelControllerState.restrict, selected_lead=0, required_decel=0.30,
mpc_source=mpc_source,
)
_, calls = run_controller_mpc(planner)
assert calls == [(({}, 15.0, True, None), {"jerk_cost_multiplier": MPC_DECEL_JERK_COST_MULTIPLIER})]
def test_ineligible_required_decel_blocks_smoothing_only_until_the_restriction_episode_ends():
planner, _ = planner_for_mpc_test(
state=AccelControllerState.restrict, selected_lead=0, required_decel=0.30,
)
_, initial_calls = run_controller_mpc(planner)
controller = planner.accel_controller
assert initial_calls[0][1] == {"jerk_cost_multiplier": MPC_DECEL_JERK_COST_MULTIPLIER}
controller.required_decel = MPC_DECEL_JERK_MAX_REQUIRED_DECEL
_, ineligible_calls = run_controller_mpc(planner)
assert ineligible_calls[0][1] == {"jerk_cost_multiplier": 1.0}
controller.required_decel = 0.30
_, flicker_calls = run_controller_mpc(planner)
assert flicker_calls[0][1] == {"jerk_cost_multiplier": 1.0}
controller.state = AccelControllerState.free
controller.output_v_target = 20.0
run_controller_mpc(planner)
controller.state = AccelControllerState.restrict
controller.output_v_target = 15.0
_, rearmed_calls = run_controller_mpc(planner)
assert rearmed_calls[0][1] == {"jerk_cost_multiplier": MPC_DECEL_JERK_COST_MULTIPLIER}
def test_consistently_tightening_lead_releases_smoothing_until_the_restriction_ends():
planner, _ = planner_for_mpc_test(
state=AccelControllerState.restrict, selected_lead=0, required_decel=0.18,
)
_, calls = run_controller_mpc(planner)
controller = planner.accel_controller
multipliers = [calls[0][1]["jerk_cost_multiplier"]]
for required_decel in (0.20, 0.23, 0.25):
controller.required_decel = required_decel
_, calls = run_controller_mpc(planner)
multipliers.append(calls[0][1]["jerk_cost_multiplier"])
assert multipliers == [MPC_DECEL_JERK_COST_MULTIPLIER] * 3 + [1.0]
controller.required_decel = 0.20
_, calls = run_controller_mpc(planner)
assert calls[0][1] == {"jerk_cost_multiplier": 1.0}
controller.state = AccelControllerState.free
controller.output_v_target = 20.0
run_controller_mpc(planner)
controller.state = AccelControllerState.restrict
controller.output_v_target = 15.0
controller.required_decel = 0.18
_, calls = run_controller_mpc(planner)
assert calls[0][1] == {"jerk_cost_multiplier": MPC_DECEL_JERK_COST_MULTIPLIER}
def test_one_frame_required_decel_noise_does_not_disable_routine_smoothing():
planner, _ = planner_for_mpc_test(
state=AccelControllerState.restrict, selected_lead=0, required_decel=0.18,
)
_, calls = run_controller_mpc(planner)
controller = planner.accel_controller
multipliers = [calls[0][1]["jerk_cost_multiplier"]]
for required_decel in (0.24, 0.19, 0.22):
controller.required_decel = required_decel
_, calls = run_controller_mpc(planner)
multipliers.append(calls[0][1]["jerk_cost_multiplier"])
assert multipliers == [MPC_DECEL_JERK_COST_MULTIPLIER] * 4
@pytest.mark.parametrize(
("state", "selected_lead", "launching", "required_decel", "target_speed", "mpc_source"),
[
(AccelControllerState.free, 0, False, 0.30, 15.0, MpcLongitudinalPlanSource.cruise),
(AccelControllerState.hold, 0, False, 0.30, 15.0, MpcLongitudinalPlanSource.cruise),
(AccelControllerState.stopHold, 0, False, 0.30, 15.0, MpcLongitudinalPlanSource.cruise),
(AccelControllerState.restrict, -1, False, 0.30, 15.0, MpcLongitudinalPlanSource.cruise),
(AccelControllerState.restrict, 0, True, 0.30, 15.0, MpcLongitudinalPlanSource.cruise),
(AccelControllerState.restrict, 0, False, MPC_DECEL_JERK_MAX_REQUIRED_DECEL, 15.0, MpcLongitudinalPlanSource.cruise),
(AccelControllerState.restrict, 0, False, math.inf, 15.0, MpcLongitudinalPlanSource.cruise),
(AccelControllerState.restrict, 0, False, math.nan, 15.0, MpcLongitudinalPlanSource.cruise),
(AccelControllerState.restrict, 0, False, 0.0, 15.0, MpcLongitudinalPlanSource.cruise),
(AccelControllerState.restrict, 0, False, -0.01, 15.0, MpcLongitudinalPlanSource.cruise),
(AccelControllerState.restrict, 0, False, 0.30, 20.0 - MPC_DECEL_JERK_MAX_TARGET_REDUCTION, MpcLongitudinalPlanSource.cruise),
(AccelControllerState.restrict, 0, False, 0.30, 20.0, MpcLongitudinalPlanSource.cruise),
(AccelControllerState.restrict, 0, False, 0.30, 25.0, MpcLongitudinalPlanSource.cruise),
],
)
def test_non_routine_or_stock_lead_states_keep_stock_jerk_cost(
state, selected_lead, launching, required_decel, target_speed, mpc_source,
):
planner, _ = planner_for_mpc_test(
state=state, selected_lead=selected_lead, launching=launching,
required_decel=required_decel, target_speed=target_speed, mpc_source=mpc_source,
)
_, calls = run_controller_mpc(planner)
assert calls[0][1] == {"jerk_cost_multiplier": 1.0}
def test_controller_receives_previous_mpc_state_and_cached_radar_freshness():
planner, _ = planner_for_mpc_test(mpc_source=log.LongitudinalPlan.LongitudinalPlanSource.lead0)
planner._radar_fresh_this_cycle = True
planner.a_desired = -0.4
planner.v_desired_filter = SimpleNamespace(x=9.5)
run_controller_mpc(planner)
received = planner.accel_controller.update_kwargs
assert received["previous_mpc_source"] == log.LongitudinalPlan.LongitudinalPlanSource.lead0
assert received["planner_speed"] == 9.5
assert received["planner_accel"] == -0.4
assert received["radar_fresh"] is True
def test_controller_is_disabled_when_openpilot_longitudinal_control_is_unavailable():
controller = AccelController(SimpleNamespace(longitudinalActuatorDelay=0.1, openpilotLongitudinalControl=False))
controller.enabled = True
assert not controller.is_enabled
def test_radar_freshness_is_computed_once_and_shared_with_dec_and_controller():
planner = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
planner._radar_log_mono_time = None
planner._radar_fresh_this_cycle = True
planner.events_sp = SimpleNamespace(clear=lambda: None)
dec_freshness = []
planner.dec = SimpleNamespace(update=lambda _sm, *, radar_fresh, planner_accel: dec_freshness.append(radar_fresh))
planner.e2e_alerts_helper = SimpleNamespace(update=lambda *_args: None)
planner.output_a_target = 0.0
planner.output_v_target = 20.0
planner.output_should_stop = False
planner.allow_throttle = True
planner.a_desired = 0.0
planner.v_desired_filter = SimpleNamespace(x=10.0)
planner.mpc = SimpleNamespace(source=log.LongitudinalPlan.LongitudinalPlanSource.cruise, last_solution_status=0)
planner.is_e2e = lambda _sm: False
planner._run_mpc = lambda *_args, **_kwargs: None
planner.accel_controller = ControllerStub(target_speed=20.0, active=False)
sm = PlannerSM(100)
for expected in (True, False):
planner.update(sm)
planner.update_mpc(sm, 20.0, True, ACCEL_MAX, False)
assert dec_freshness[-1] is expected and planner.accel_controller.update_kwargs["radar_fresh"] is expected
sm.logMonoTime["radarState"] = 101
planner.update(sm)
planner.update_mpc(sm, 20.0, True, ACCEL_MAX, False)
assert dec_freshness[-1] is True and planner.accel_controller.update_kwargs["radar_fresh"] is True
def test_accel_controller_status_publishes_minimal_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 = ControllerStub(active=False, state=AccelControllerState.restrict)
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.enabled and not telemetry.active
assert telemetry.profile == int(AccelProfile.normal)
assert telemetry.state == int(AccelControllerState.restrict)
assert set(custom.LongitudinalPlanSP.AccelController.schema.fields) == {"enabled", "active", "shadowOnlyDEPRECATED", "profile", "state"}
@@ -0,0 +1,83 @@
import pytest
from opendbc.car import DT_CTRL, gen_empty_fingerprint, structs
from opendbc.car.car_helpers import interfaces
from opendbc.car.ford.values import CAR as FORD
from opendbc.car.gm.values import CAR as GM
from opendbc.car.honda.values import CAR as HONDA
from opendbc.car.hyundai.values import CAR as HYUNDAI
from opendbc.car.toyota.values import CAR as TOYOTA
from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan
from openpilot.selfdrive.controls.lib.longcontrol import LongControl, LongCtrlState, long_control_state_trans
VEHICLES = [
pytest.param(TOYOTA.TOYOTA_RAV4_TSS2, (True, False, 0.0, -2.0, 0.25, 0.25, 0.3), id="toyota-rav4-tss2"),
pytest.param(HONDA.HONDA_ACCORD, (True, False, 0.0, -2.0, 0.5, 0.5, 0.8), id="honda-accord"),
pytest.param(GM.CHEVROLET_BOLT_EUV, (True, False, 0.0, -2.0, 0.25, 0.25, 2.0), id="gm-bolt-euv"),
pytest.param(HYUNDAI.HYUNDAI_SONATA, (True, True, 1.0, -2.0, 0.1, 0.5, 0.8), id="hyundai-sonata"),
pytest.param(FORD.FORD_ESCAPE_MK4, (True, False, 0.0, -2.0, 0.5, 0.5, 0.8), id="ford-escape"),
]
def get_car_params(candidate):
fingerprint = gen_empty_fingerprint()
interface = interfaces[candidate]
CP = interface.get_params(candidate, fingerprint, [], True, False, False)
CP_SP = interface.get_params_sp(CP, candidate, fingerprint, [], True, False, False)
return CP, CP_SP
@pytest.mark.parametrize(("candidate", "expected"), VEHICLES)
def test_real_vehicle_longcontrol_stop_and_start(candidate, expected):
CP, CP_SP = get_car_params(candidate)
expected_long, expected_starting, *expected_tuning = expected
assert CP.openpilotLongitudinalControl is expected_long
assert CP.startingState is expected_starting
assert (CP.startAccel, CP.stopAccel, CP.vEgoStarting, CP.vEgoStopping, CP.stoppingDecelRate) == pytest.approx(expected_tuning)
stop_speeds = [CP.vEgoStopping - 0.01] * 2
drive_speeds = [CP.vEgoStopping + 0.01] * 2
_, should_stop = get_accel_from_plan(stop_speeds, [0.0, 0.0], [0.0, 1.0], vEgoStopping=CP.vEgoStopping)
_, should_drive = get_accel_from_plan(drive_speeds, [0.0, 0.0], [0.0, 1.0], vEgoStopping=CP.vEgoStopping)
assert should_stop
assert not should_drive
departure_state = long_control_state_trans(
CP,
CP_SP,
True,
LongCtrlState.stopping,
CP.vEgoStarting - 0.01,
should_drive,
brake_pressed=False,
cruise_standstill=False,
)
assert departure_state == (LongCtrlState.starting if CP.startingState else LongCtrlState.pid)
assert (
long_control_state_trans(
CP,
CP_SP,
True,
departure_state,
CP.vEgoStarting + 0.01,
should_drive,
brake_pressed=False,
cruise_standstill=False,
)
== LongCtrlState.pid
)
CS = structs.CarState()
CS.vEgo = 0.0
CS.aEgo = 0.0
control = LongControl(CP, CP_SP)
stopping_accel = control.update(True, CS, 0.0, should_stop, (-3.0, 2.0))
assert control.long_control_state == LongCtrlState.stopping
assert stopping_accel == pytest.approx(-CP.stoppingDecelRate * DT_CTRL)
departure_accel = control.update(True, CS, 0.0, should_drive, (-3.0, 2.0))
assert control.long_control_state == departure_state
assert departure_accel == pytest.approx(CP.startAccel)
@@ -15,13 +15,9 @@ class WMACConstants:
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
RADAR_LEAD_ACC_MAX_DREL = 80.0
RADAR_LEAD_ACC_MAX_TTC = 6.0
RADAR_LEAD_ACC_MIN_CLOSING_SPEED = -0.5
RADAR_LEAD_CONTINUITY_FRAMES = max(1, int(round(1.0 / DT_MDL)))
RADAR_LEAD_DROPOUT_FRAMES = max(1, int(round(0.2 / DT_MDL)))
RADAR_STALE_FRAMES = max(1, int(round(0.5 / DT_MDL)))
SLOW_DOWN_PROB = 0.5
SLOW_DOWN_EXIT_PROB = 0.4
@@ -33,6 +29,12 @@ class WMACConstants:
MODEL_DECEL_START = -0.5
MODEL_DECEL_RANGE = 2.0
MODEL_DECEL_TREND_FRAMES = 4
MODEL_DECEL_TREND_ACCEL = -0.075
MODEL_DECEL_TREND_RATE = 0.35
MODEL_DECEL_TREND_MAX_MPC_ACCEL = 0.075
MODEL_DECEL_TREND_MAX_COMMAND_STEP = 0.15
MODEL_DECEL_TREND_RELEASE_ACCEL = -0.02
ENDPOINT_URGENCY_GAIN = 1.3
CRITICAL_ENDPOINT_FACTOR = 0.3
CRITICAL_URGENCY_GAIN = 1.5
+117 -29
View File
@@ -6,12 +6,15 @@ See the LICENSE.md file in the root directory for more details.
"""
# Version = 2025-6-30
from collections import deque
import math
from typing import Literal
from cereal import messaging
from numpy import interp
from opendbc.car import structs
from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
from openpilot.sunnypilot.selfdrive.controls.lib.dec.constants import WMACConstants
ModeType = Literal['acc', 'blended']
@@ -69,7 +72,7 @@ class ModeTransitionManager:
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)
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)
@@ -135,12 +138,6 @@ class DynamicExperimentalController:
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,
@@ -155,7 +152,12 @@ class DynamicExperimentalController:
)
self._has_lead_filtered = False
self._has_any_lead = False
self._has_current_radar_acc_lead = False
self._has_radar_acc_lead = False
self._radar_acc_lead_frames = 0
self._radar_fresh = True
self._radar_stale_frames = 0
self._has_slow_down = False
self._has_slowness = False
self._has_mpc_fcw = False
@@ -169,6 +171,10 @@ class DynamicExperimentalController:
self._expected_distance = 0.0
self._trajectory_valid = False
self._raw_urgency = 0.0
self._model_accel_samples = deque(maxlen=WMACConstants.MODEL_DECEL_TREND_FRAMES)
self._model_decel_trending = False
self._model_decel_latched = False
self._planner_accel = math.nan
def _read_params(self) -> None:
if self._frame % WMACConstants.PARAM_READ_FRAMES == 0:
@@ -186,9 +192,11 @@ class DynamicExperimentalController:
def set_mpc_fcw_crash_cnt(self) -> None:
self._mpc_fcw_crash_cnt = self._mpc.crash_cnt
def _update_calculations(self, sm: messaging.SubMaster) -> None:
def _update_calculations(self, sm: messaging.SubMaster, radar_fresh: bool) -> 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
@@ -200,8 +208,24 @@ class DynamicExperimentalController:
else:
self._standstill_count = max(0, self._standstill_count - 1)
self._has_lead_filtered = self._lead_tracker.update(float(lead_one.status))
self._has_radar_acc_lead = self._radar_acc_lead_tracker.update(self._radar_acc_lead_score(lead_one))
self._radar_fresh = bool(radar_fresh)
if self._radar_fresh:
self._radar_stale_frames = 0
self._has_lead_filtered = self._lead_tracker.update(float(lead_one.status))
self._has_any_lead = bool(lead_one.status or lead_two.status)
self._has_current_radar_acc_lead = bool(max(self._radar_acc_lead_score(lead_one), self._radar_acc_lead_score(lead_two)))
self._update_radar_acc_lead()
else:
self._radar_stale_frames += 1
self._has_current_radar_acc_lead = False
if self._radar_stale_frames < WMACConstants.RADAR_STALE_FRAMES:
self._update_radar_acc_lead()
else:
self._lead_tracker.reset()
self._has_lead_filtered = False
self._has_any_lead = False
self._has_radar_acc_lead = False
self._radar_acc_lead_frames = 0
self._has_mpc_fcw = self._mpc_fcw_crash_cnt > 0
self._calculate_slow_down(md)
@@ -217,6 +241,7 @@ class DynamicExperimentalController:
self._expected_distance = 0.0
self._trajectory_valid = False
self._update_model_decel_trend(md)
urgency = self._model_action_urgency(md)
position_valid = len(md.position.x) == WMACConstants.TRAJECTORY_SIZE
@@ -230,17 +255,46 @@ class DynamicExperimentalController:
self._has_slow_down = self._slow_down_tracker.update(self._raw_urgency)
self._urgency = self._slow_down_tracker.value
def _radar_acc_lead_score(self, lead_one) -> float:
if not lead_one.status:
return 0.0
def _update_model_decel_trend(self, md) -> None:
try:
desired_accel = float(md.action.desiredAcceleration)
except (AttributeError, OverflowError, TypeError, ValueError):
desired_accel = math.nan
if not math.isfinite(desired_accel):
self._reset_model_decel_trend()
else:
self._model_accel_samples.append(desired_accel)
history = tuple(self._model_accel_samples)
self._model_decel_trending = (len(history) == self._model_accel_samples.maxlen
and history[-1] <= WMACConstants.MODEL_DECEL_TREND_ACCEL
and (history[0] - history[-1]) / (DT_MDL * (len(history) - 1)) > WMACConstants.MODEL_DECEL_TREND_RATE
and all(after <= before for before, after in zip(history[:-1], history[1:], strict=True))
and sum(after < before for before, after in zip(history[:-1], history[1:], strict=True)) >= 2)
if len(history) == self._model_accel_samples.maxlen and all(
accel >= WMACConstants.MODEL_DECEL_TREND_RELEASE_ACCEL for accel in history
):
self._model_decel_latched = False
d_rel = float(getattr(lead_one, 'dRel', float('inf')))
v_rel = float(getattr(lead_one, 'vRel', 0.0))
if d_rel <= WMACConstants.RADAR_LEAD_ACC_MAX_DREL:
return 1.0
if v_rel <= WMACConstants.RADAR_LEAD_ACC_MIN_CLOSING_SPEED and d_rel / max(-v_rel, 0.1) <= WMACConstants.RADAR_LEAD_ACC_MAX_TTC:
return 1.0
return 0.0
def _reset_model_decel_trend(self) -> None:
self._model_accel_samples.clear()
self._model_decel_trending = False
self._model_decel_latched = False
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))
def _update_radar_acc_lead(self) -> None:
if self._has_current_radar_acc_lead:
self._radar_acc_lead_frames = WMACConstants.RADAR_LEAD_CONTINUITY_FRAMES
self._has_radar_acc_lead = True
return
if not self._has_any_lead:
self._radar_acc_lead_frames = min(self._radar_acc_lead_frames, WMACConstants.RADAR_LEAD_DROPOUT_FRAMES)
self._has_radar_acc_lead = self._radar_acc_lead_frames > 0
self._radar_acc_lead_frames = max(0, self._radar_acc_lead_frames - 1)
def _model_action_urgency(self, md) -> float:
action = getattr(md, 'action', None)
@@ -269,16 +323,41 @@ class DynamicExperimentalController:
return urgency
def _model_decel_handoff_ready(self) -> bool:
try:
mpc_accel = float(self._mpc.a_solution[1])
return (math.isfinite(mpc_accel) and mpc_accel <= WMACConstants.MODEL_DECEL_TREND_MAX_MPC_ACCEL
and math.isfinite(self._planner_accel) and self._planner_accel <= WMACConstants.MODEL_DECEL_TREND_MAX_MPC_ACCEL
and self._planner_accel - self._model_accel_samples[-1] <= WMACConstants.MODEL_DECEL_TREND_MAX_COMMAND_STEP)
except (AttributeError, IndexError, OverflowError, TypeError, ValueError):
return False
def _desired_mode(self) -> tuple[ModeType, bool]:
standstill = self._standstill_count > WMACConstants.STANDSTILL_FRAMES
urgent_slow_down = self._has_slow_down and self._raw_urgency > WMACConstants.URGENT_SLOW_DOWN_PROB
if not self._CP.radarUnavailable and self._has_current_radar_acc_lead:
self._reset_model_decel_trend()
return 'acc', True
radar_stale = not self._radar_fresh if self._has_mpc_fcw else self._radar_stale_frames > 1
if (radar_stale or not self._has_any_lead) and (self._has_mpc_fcw or urgent_slow_down):
self._radar_acc_lead_frames = 0
self._has_radar_acc_lead = False
return 'blended', True
if not self._CP.radarUnavailable and self._has_radar_acc_lead:
return 'acc', False
self._reset_model_decel_trend()
return 'acc', True
entering_model_slowdown = self._model_decel_trending and self._model_decel_handoff_ready() and not self._model_decel_latched
self._model_decel_latched |= entering_model_slowdown
if self._model_decel_latched:
return 'blended', entering_model_slowdown
if self._has_mpc_fcw:
return 'blended', True
standstill = self._standstill_count > WMACConstants.STANDSTILL_FRAMES
urgent_slow_down = self._has_slow_down and self._raw_urgency > WMACConstants.URGENT_SLOW_DOWN_PROB
if self._CP.radarUnavailable:
if standstill or self._has_slow_down:
return 'blended', urgent_slow_down
@@ -289,15 +368,24 @@ class DynamicExperimentalController:
return 'acc', False
def update(self, sm: messaging.SubMaster) -> None:
def update(self, sm: messaging.SubMaster, *, radar_fresh: bool = True, planner_accel: float | None = None) -> None:
self._read_params()
self.set_mpc_fcw_crash_cnt()
self._update_calculations(sm)
try:
self._planner_accel = float(planner_accel)
except (OverflowError, TypeError, ValueError):
self._planner_accel = math.nan
self._update_calculations(sm, radar_fresh)
self._active = sm['selfdriveState'].experimentalMode and self._enabled
if not self._active:
model_decel_latched = self._model_decel_latched
self._reset_model_decel_trend()
if model_decel_latched:
self._mode_manager.request_mode('acc', immediate=True)
mode, immediate = self._desired_mode()
self._mode_manager.request_mode(mode, immediate=immediate, hold_frames=WMACConstants.EMERGENCY_HOLD_FRAMES,
cancel_hold=self._has_radar_acc_lead)
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,18 +1,22 @@
import pytest
from openpilot.sunnypilot.selfdrive.controls.lib.dec.constants import WMACConstants
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):
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):
self.leadOne = MockLeadOne(status=status, dRel=dRel, vRel=vRel)
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:
@@ -55,7 +59,7 @@ class MockParams:
def default_sm():
sm = {
'carState': MockCarState(vEgo=10.0, vCruise=20.0),
'radarState': MockRadarState(status=1.0),
'radarState': MockRadarState(status=1.0, radar=True, radarTrackId=7),
'modelV2': MockModelData(valid=True),
'selfdriveState': MockSelfDriveState(experimentalMode=True),
}
@@ -73,6 +77,7 @@ def mock_cp():
def mock_mpc():
class MPC:
crash_cnt = 0
a_solution = [0.0, 0.0]
return MPC()
@@ -155,9 +160,162 @@ def test_model_should_stop_triggers_blended_without_valid_trajectory(mock_cp, mo
assert controller.mode() == "blended"
def test_confirmed_model_decel_trend_enters_blended_before_a_large_command(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=0.0)
for desired_acceleration in (-0.02, -0.05, -0.08):
default_sm['modelV2'] = MockModelData(valid=False, desired_acceleration=desired_acceleration)
controller.update(default_sm, planner_accel=0.0)
assert controller.mode() == "acc"
default_sm['modelV2'] = MockModelData(valid=False, desired_acceleration=-0.12)
controller.update(default_sm, planner_accel=0.0)
assert controller._model_decel_trending
assert not controller._has_slow_down
assert controller.mode() == "blended"
def test_confirmed_model_decel_handoff_stays_latched_through_a_plateau(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=0.0)
for desired_acceleration in (-0.02, -0.05, -0.08, -0.12):
default_sm['modelV2'] = MockModelData(valid=False, desired_acceleration=desired_acceleration)
controller.update(default_sm, planner_accel=0.0)
for _ in range(WMACConstants.EMERGENCY_HOLD_FRAMES + WMACConstants.EXIT_BLENDED_FRAMES + 1):
default_sm['modelV2'] = MockModelData(valid=False, desired_acceleration=-0.12)
controller.update(default_sm, planner_accel=0.0)
assert not controller._model_decel_trending
assert controller._model_decel_latched
assert controller.mode() == "blended"
for _ in range(WMACConstants.MODEL_DECEL_TREND_FRAMES + WMACConstants.EXIT_BLENDED_FRAMES):
default_sm['modelV2'] = MockModelData(valid=False, desired_acceleration=0.0)
controller.update(default_sm, planner_accel=0.0)
assert not controller._model_decel_latched
assert controller.mode() == "acc"
def test_model_decel_trend_never_overrides_a_radar_lead(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
for desired_acceleration in (-0.02, -0.05, -0.08, -0.12):
default_sm['modelV2'] = MockModelData(valid=False, desired_acceleration=desired_acceleration)
controller.update(default_sm)
assert not controller._model_accel_samples
assert not controller._model_decel_latched
assert controller._has_radar_acc_lead
assert controller.mode() == "acc"
def test_radar_acquisition_clears_a_latched_model_decel_handoff(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=0.0)
for desired_acceleration in (-0.02, -0.05, -0.08, -0.12):
default_sm['modelV2'] = MockModelData(valid=False, desired_acceleration=desired_acceleration)
controller.update(default_sm, planner_accel=0.0)
assert controller._model_decel_latched
default_sm['radarState'] = MockRadarState(status=1.0, radar=True, radarTrackId=7)
controller.update(default_sm, planner_accel=0.0)
assert not controller._model_accel_samples
assert not controller._model_decel_latched
assert controller.mode() == "acc"
def test_model_decel_trend_does_not_accumulate_while_dec_is_inactive(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=0.0)
default_sm['selfdriveState'].experimentalMode = False
for desired_acceleration in (-0.02, -0.05, -0.08, -0.12):
default_sm['modelV2'] = MockModelData(valid=False, desired_acceleration=desired_acceleration)
controller.update(default_sm, planner_accel=0.0)
assert not controller._model_accel_samples
assert not controller._model_decel_latched
default_sm['selfdriveState'].experimentalMode = True
controller.update(default_sm, planner_accel=0.0)
assert not controller._model_decel_trending
assert controller.mode() == "acc"
def test_disabling_dec_clears_a_latched_model_decel_mode(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=0.0)
for desired_acceleration in (-0.02, -0.05, -0.08, -0.12):
default_sm['modelV2'] = MockModelData(valid=False, desired_acceleration=desired_acceleration)
controller.update(default_sm, planner_accel=0.0)
assert controller._model_decel_latched
assert controller.mode() == "blended"
default_sm['selfdriveState'].experimentalMode = False
default_sm['modelV2'] = MockModelData(valid=False, desired_acceleration=0.0)
controller.update(default_sm, planner_accel=0.0)
assert not controller._model_decel_latched
assert controller.mode() == "acc"
def test_model_decel_trend_waits_while_mpc_is_accelerating(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=0.0)
mock_mpc.a_solution[1] = 0.5
for desired_acceleration in (-0.02, -0.05, -0.08, -0.12):
default_sm['modelV2'] = MockModelData(valid=False, desired_acceleration=desired_acceleration)
controller.update(default_sm, planner_accel=0.0)
assert controller._model_decel_trending
assert controller.mode() == "acc"
def test_steep_model_decel_trend_defers_to_the_existing_urgent_path(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=0.0)
for desired_acceleration in (0.0, -0.2, -0.4, -0.6):
default_sm['modelV2'] = MockModelData(valid=False, desired_acceleration=desired_acceleration)
controller.update(default_sm, planner_accel=0.05)
assert controller._model_decel_trending
assert controller.mode() == "acc"
def test_model_decel_trend_waits_while_the_planner_is_accelerating(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=0.0)
for desired_acceleration in (-0.02, -0.05, -0.08, -0.12):
default_sm['modelV2'] = MockModelData(valid=False, desired_acceleration=desired_acceleration)
controller.update(default_sm, planner_accel=0.2)
assert controller._model_decel_trending
assert controller.mode() == "acc"
def test_alternating_model_accel_noise_does_not_trigger_an_early_handoff(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['radarState'] = MockRadarState(status=0.0)
for desired_acceleration in (0.0, -0.2, 0.0, -0.2):
default_sm['modelV2'] = MockModelData(valid=False, desired_acceleration=desired_acceleration)
controller.update(default_sm)
assert not controller._model_decel_trending
assert controller.mode() == "acc"
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)
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):
@@ -168,36 +326,94 @@ def test_radar_lead_keeps_acc_over_model_slowdown(mock_cp, mock_mpc, default_sm)
assert controller.mode() == "acc"
def test_far_radar_lead_allows_blended_until_acc_relevant(mock_cp, mock_mpc, default_sm):
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)
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 not controller._has_radar_acc_lead
assert controller.mode() == "blended"
assert controller._has_radar_acc_lead
assert controller.mode() == "acc"
def test_relevant_radar_lead_smoothly_returns_to_acc(mock_cp, mock_mpc, default_sm):
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=1.0, dRel=120.0, vRel=0.0)
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=45.0, vRel=0.0)
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)
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):
@@ -209,7 +425,7 @@ def test_closing_far_radar_lead_returns_to_acc(mock_cp, mock_mpc, default_sm):
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)
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
@@ -224,12 +440,194 @@ def test_radar_lead_keeps_acc_over_fcw_and_standstill(mock_cp, mock_mpc, default
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)
controller.update(default_sm)
default_sm['radarState'] = MockRadarState(status=1.0, radar=True, radarTrackId=7)
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=50.0)
for _ in range(2):
controller.update(default_sm)
assert controller._has_slow_down
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_continuity_with_vision_fallback_expires_into_confirmed_transition(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=50.0)
for _ in range(2):
controller.update(default_sm)
assert controller._has_slow_down
default_sm['radarState'] = MockRadarState(status=1.0)
for _ in range(WMACConstants.RADAR_LEAD_CONTINUITY_FRAMES):
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() == "acc"
for _ in range(WMACConstants.ENTER_BLENDED_FRAMES - 1):
controller.update(default_sm)
assert controller.mode() == "blended"
def test_radar_lead_short_dropout_guard_expires_without_any_lead(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)
for _ in range(WMACConstants.RADAR_LEAD_DROPOUT_FRAMES):
controller.update(default_sm)
assert controller._has_radar_acc_lead
controller.update(default_sm)
assert not controller._has_radar_acc_lead
def test_one_stale_radar_frame_does_not_drop_acc_authority(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
controller.update(default_sm)
controller.update(default_sm, radar_fresh=False)
assert not controller._has_current_radar_acc_lead
assert controller._has_radar_acc_lead
assert controller._radar_acc_lead_frames == WMACConstants.RADAR_LEAD_CONTINUITY_FRAMES - 1
assert controller._radar_stale_frames == 1
assert controller.mode() == "acc"
def test_one_stale_radar_frame_does_not_override_retained_lead_for_model_urgency(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
controller.update(default_sm)
default_sm['modelV2'] = MockModelData(valid=False, should_stop=True)
controller.update(default_sm, radar_fresh=False)
assert controller.mode() == "acc"
controller.update(default_sm, radar_fresh=False)
assert controller.mode() == "blended"
def test_one_stale_radar_frame_does_not_delay_fcw(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
controller.update(default_sm)
mock_mpc.crash_cnt = 1
controller.update(default_sm, radar_fresh=False)
assert controller.mode() == "blended"
def test_frozen_radar_marker_cannot_rearm_acc_authority(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
controller.update(default_sm)
for _ in range(WMACConstants.RADAR_STALE_FRAMES - 1):
controller.update(default_sm, radar_fresh=False)
assert controller._has_radar_acc_lead
controller.update(default_sm, radar_fresh=False)
assert not controller._has_current_radar_acc_lead
assert not controller._has_radar_acc_lead
assert not controller._has_any_lead
assert not controller._has_lead_filtered
def test_fresh_radar_reacquisition_after_stale_timeout_is_immediate(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
controller.update(default_sm)
for _ in range(WMACConstants.RADAR_STALE_FRAMES):
controller.update(default_sm, radar_fresh=False)
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
controller.update(default_sm, radar_fresh=False)
assert controller.mode() == "blended"
lead_two = MockLeadOne(status=1.0, radar=True, radarTrackId=8)
default_sm['radarState'] = MockRadarState(status=0.0, leadTwo=lead_two)
controller.update(default_sm, radar_fresh=True)
assert controller._radar_stale_frames == 0
assert controller._has_current_radar_acc_lead
assert controller.mode() == "acc"
@pytest.mark.parametrize("urgent_source", ["fcw", "should_stop"])
def test_no_lead_urgent_slowdown_bypasses_radar_dropout_guard(mock_cp, mock_mpc, default_sm, urgent_source):
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)
if urgent_source == "fcw":
mock_mpc.crash_cnt = 1
else:
default_sm['modelV2'] = MockModelData(valid=False, should_stop=True)
controller.update(default_sm)
assert not controller._has_radar_acc_lead
assert controller.mode() == "blended"
mock_mpc.crash_cnt = 0
default_sm['modelV2'] = MockModelData(valid=True)
controller.update(default_sm)
assert controller.mode() == "blended"
def test_lead_two_radar_authority_continues_with_vision_lead_one(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
lead_two = MockLeadOne(status=1.0, radar=True, radarTrackId=8)
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 controller._has_current_radar_acc_lead
assert controller.mode() == "acc"
default_sm['radarState'] = MockRadarState(status=1.0)
for _ in range(WMACConstants.RADAR_LEAD_CONTINUITY_FRAMES):
controller.update(default_sm)
assert controller._has_radar_acc_lead
assert controller.mode() == "acc"
def test_alternating_radar_slots_keep_acc_authority(mock_cp, mock_mpc, default_sm):
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
for frame in range(WMACConstants.RADAR_LEAD_CONTINUITY_FRAMES * 2):
if frame % 2 == 0:
default_sm['radarState'] = MockRadarState(status=1.0, radar=True, radarTrackId=7, leadTwo=MockLeadOne(status=1.0))
else:
default_sm['radarState'] = MockRadarState(status=1.0, leadTwo=MockLeadOne(status=1.0, radar=True, radarTrackId=8))
controller.update(default_sm)
assert controller._has_current_radar_acc_lead
assert controller.mode() == "acc"
def test_radar_reacquisition_immediately_restores_acc_after_continuity_expiry(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)
controller.update(default_sm)
default_sm['radarState'] = MockRadarState(status=1.0)
for _ in range(WMACConstants.RADAR_LEAD_CONTINUITY_FRAMES + 1):
controller.update(default_sm)
assert not controller._has_radar_acc_lead
assert controller.mode() == "blended"
lead_two = MockLeadOne(status=1.0, radar=True, radarTrackId=8)
default_sm['radarState'] = MockRadarState(status=1.0, leadTwo=lead_two)
controller.update(default_sm)
assert controller._has_current_radar_acc_lead
assert controller.mode() == "acc"
@@ -0,0 +1,38 @@
"""
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 numpy as np
from opendbc.car.interfaces import ACCEL_MIN, ACCEL_MAX
class LongitudinalMpcSP:
def __init__(self) -> None:
self._accel_max_trajectory: tuple[float, ...] | None = None
self._jerk_cost_multiplier = 1.0
self.last_solution_status = 0
def set_accel_controller_params(self, accel_max: tuple[float, ...] | None, jerk_cost_multiplier: float) -> None:
self._accel_max_trajectory = accel_max
self._jerk_cost_multiplier = jerk_cost_multiplier
def scale_jerk_cost(self, jerk_cost: float) -> float:
return jerk_cost * self._jerk_cost_multiplier
def apply_accel_limits(self) -> None:
if self._accel_max_trajectory is None:
return
accel_max = np.asarray(self._accel_max_trajectory)
if accel_max.shape != self.params[:, 1].shape or accel_max.dtype.kind not in "iuf" or not np.all(np.isfinite(accel_max)):
return
self.params[:, 1] = np.clip(accel_max, 0.0, ACCEL_MAX)
self.params[0, 1] = max(self.params[0, 1], float(np.clip(self.x0[2], ACCEL_MIN, ACCEL_MAX)))
def save_solution_status(self) -> None:
self.last_solution_status = self.solution_status
@@ -5,10 +5,12 @@ 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 cereal import messaging, custom
from cereal import custom, messaging
from opendbc.car import structs
from openpilot.common.constants import CV
from openpilot.selfdrive.car.cruise import V_CRUISE_MAX
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.car.cruise import V_CRUISE_MAX, V_CRUISE_UNSET
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.accel_controller import AccelController, AccelControllerState
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,9 +24,10 @@ 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.mpc = mpc
self.accel_controller = AccelController(CP, dt=dt)
self.events_sp = EventsSP()
self.resolver = SpeedLimitResolver()
self.dec = DynamicExperimentalController(CP, mpc)
self.scc = SmartCruiseControl()
self.resolver = SpeedLimitResolver()
@@ -32,6 +35,8 @@ 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._radar_log_mono_time = None
self._radar_fresh_this_cycle = True
self.output_v_target = 0.
self.output_a_target = 0.
@@ -43,6 +48,41 @@ class LongitudinalPlannerSP:
return experimental_mode and self.dec.mode() == "blended"
def _run_mpc(self, sm: messaging.SubMaster, v_cruise: float, prev_accel_constraint: bool, accel_max=None, *, jerk_cost_multiplier: float = 1.0) -> None:
self.mpc.set_accel_controller_params(accel_max, jerk_cost_multiplier)
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)
def update_mpc(self, sm: messaging.SubMaster, v_cruise: float, prev_accel_constraint: bool, stock_accel_max: float, reset_state: bool) -> bool:
is_e2e = self.is_e2e(sm)
force_decel = sm['controlsState'].forceDecel
previous_mpc_failed = self.mpc.last_solution_status != 0
if previous_mpc_failed:
self.accel_controller.reset()
self.accel_controller.update(
sm['radarState'], base_speed=self.output_v_target, v_ego=sm['carState'].vEgo, a_ego=sm['carState'].aEgo,
follow_personality=sm['selfdriveState'].personality, acc_selected=not is_e2e and not previous_mpc_failed,
engaged=not reset_state and not force_decel, cruise_initialized=sm['carState'].vCruise != V_CRUISE_UNSET,
stock_accel_max=stock_accel_max if self.allow_throttle else 0.0, previous_should_stop=self.output_should_stop,
radar_fresh=self._radar_fresh_this_cycle, previous_mpc_source=self.mpc.source, planner_speed=self.v_desired_filter.x,
planner_accel=self.a_desired,
)
controller = self.accel_controller
actuating = controller.is_active and not is_e2e and not force_decel and not previous_mpc_failed
valid_lead_stop_hold = actuating and controller.state == AccelControllerState.stopHold and controller.selected_lead >= 0
controller_v_cruise = v_cruise if valid_lead_stop_hold else min(v_cruise, controller.output_v_target) if actuating else v_cruise
accel_max = controller.mpc_accel_max if actuating else None
jerk_cost_multiplier = controller.get_jerk_cost_multiplier(
actuating, prev_accel_constraint, v_cruise - controller_v_cruise, previous_mpc_failed,
)
self._run_mpc(sm, controller_v_cruise, prev_accel_constraint, accel_max, jerk_cost_multiplier=jerk_cost_multiplier)
return is_e2e
def update_should_stop(self, should_stop: bool) -> bool:
return self.accel_controller.update_should_stop(should_stop)
def update_targets(self, sm: messaging.SubMaster, v_ego: float, a_ego: float, v_cruise: float) -> tuple[float, float]:
CS = sm['carState']
v_cruise_cluster_kph = min(CS.vCruiseCluster, V_CRUISE_MAX)
@@ -73,9 +113,19 @@ class LongitudinalPlannerSP:
self.output_v_target, self.output_a_target = targets[self.source]
return self.output_v_target, self.output_a_target
def _update_radar_freshness(self, sm: messaging.SubMaster) -> bool:
radar_log_mono_time = sm.logMonoTime['radarState']
radar_healthy = sm.valid['radarState'] and sm.alive['radarState']
radar_advanced = self._radar_log_mono_time is None or radar_log_mono_time > self._radar_log_mono_time
if radar_advanced:
self._radar_log_mono_time = radar_log_mono_time
return radar_healthy and radar_advanced
def update(self, sm: messaging.SubMaster) -> None:
self._radar_fresh_this_cycle = self._update_radar_freshness(sm)
self.accel_controller.update_params()
self.events_sp.clear()
self.dec.update(sm)
self.dec.update(sm, radar_fresh=self._radar_fresh_this_cycle, planner_accel=self.output_a_target)
self.e2e_alerts_helper.update(sm, self.events_sp)
def publish_longitudinal_plan_sp(self, sm: messaging.SubMaster, pm: messaging.PubMaster) -> None:
@@ -95,6 +145,12 @@ class LongitudinalPlannerSP:
dec.enabled = self.dec.enabled()
dec.active = self.dec.active()
accelController = longitudinalPlanSP.accelController
accelController.enabled = self.accel_controller.is_enabled
accelController.active = self.accel_controller.is_active
accelController.profile = self.accel_controller.profile
accelController.state = self.accel_controller.state
# Smart Cruise Control
smartCruiseControl = longitudinalPlanSP.smartCruiseControl
# Vision Control
@@ -4,6 +4,8 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from types import SimpleNamespace
import numpy as np
import pytest
@@ -13,8 +15,12 @@ from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET
from openpilot.selfdrive.modeld.constants import ModelConstants
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlannerSP, LongitudinalPlanSource
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control import MIN_V
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.vision_controller import SmartCruiseControlVision, _ENTERING_PRED_LAT_ACC_TH
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.vision_controller import (
_A_LAT_REG_MAX, _BELOW_EGO_TARGET_RELEASE_RATE, _ENTERING_PRED_LAT_ACC_TH, _MIN_ACTIVATION_SPEED,
_RELIEF_CONFIRMATION_FRAMES, _TARGET_RELEASE_RATE, SmartCruiseControlVision,
)
VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.VisionState
@@ -118,6 +124,21 @@ class TestSmartCruiseControlVision:
def reset_params(self):
self.params.put_bool("SmartCruiseControlVision", True, block=True)
def set_lat_accels(self, current: float, predicted: float, v_ego: float = 20., model_speed: float = 20.) -> None:
self.sm['controlsState'].curvature = current / v_ego**2
self.sm['modelV2'].velocity.x = [model_speed] * len(ModelConstants.T_IDXS)
self.sm['modelV2'].orientationRate.z = [predicted / model_speed] * len(ModelConstants.T_IDXS)
def update_lat_accels(self, current: float, predicted: float, cruise: float = 30., a_ego: float = 0.,
v_ego: float = 20., model_speed: float = 20.) -> None:
self.set_lat_accels(current, predicted, v_ego, model_speed)
self.scc_v.update(self.sm, True, False, v_ego, a_ego, cruise)
def enter_curve(self, predicted: float = 2.2) -> None:
self.update_lat_accels(0.5, predicted)
self.update_lat_accels(0.5, predicted)
assert self.scc_v.state == VisionState.entering
def test_initial_state(self):
assert self.scc_v.state == VisionState.disabled
assert not self.scc_v.is_active
@@ -143,6 +164,253 @@ class TestSmartCruiseControlVision:
self.scc_v.update(self.sm, True, False, 0., 0., 0.)
assert self.scc_v.state == VisionState.enabled
def test_unconfirmed_leaving_and_reentry_only_shape_speed(self):
self.enter_curve()
targets = [self.scc_v.output_v_target]
self.update_lat_accels(2., 2.2, a_ego=-0.8)
assert self.scc_v.state == VisionState.turning
assert self.scc_v.output_a_target == -0.8
targets.append(self.scc_v.output_v_target)
self.update_lat_accels(1.2, 1.2, a_ego=0.3)
assert self.scc_v.state == VisionState.leaving
assert self.scc_v.output_a_target == 0.3
targets.append(self.scc_v.output_v_target)
self.update_lat_accels(1., 3., a_ego=-1.2)
assert self.scc_v.state == VisionState.entering
assert self.scc_v.output_a_target == -1.2
targets.append(self.scc_v.output_v_target)
entering, turning, leaving, reentering = targets
assert turning == pytest.approx(entering)
assert 0. < leaving - turning <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9
assert reentering < leaving
def test_new_curve_interrupts_confirmed_release_immediately(self):
self.enter_curve()
for _ in range(_RELIEF_CONFIRMATION_FRAMES + 1):
self.update_lat_accels(0.8, 0.8)
releasing_v_target = self.scc_v.output_v_target
assert self.scc_v.state == VisionState.leaving
self.update_lat_accels(0.8, 3., a_ego=-0.7)
assert self.scc_v.state == VisionState.entering
assert self.scc_v.output_v_target < releasing_v_target
assert self.scc_v.output_a_target == -0.7
@pytest.mark.parametrize("planner_accel", (-2., -0.5, 0., 0.8))
def test_planner_acceleration_passes_through_exactly(self, planner_accel):
self.enter_curve()
self.update_lat_accels(0.5, 2.2, a_ego=planner_accel)
assert self.scc_v.output_a_target == planner_accel
def test_planner_acceleration_passes_through_all_states(self):
cases = (
(False, False, 0.5, 2.2, -0.2, VisionState.disabled),
(True, False, 0.5, 0.8, 0.1, VisionState.enabled),
(True, False, 0.5, 2.2, -0.4, VisionState.entering),
(True, False, 2., 2.2, -0.8, VisionState.turning),
(True, False, 1.2, 1.2, 0.3, VisionState.leaving),
(True, True, 1.2, 1.2, 0.6, VisionState.overriding),
)
for long_enabled, override, current, predicted, planner_accel, state in cases:
self.set_lat_accels(current, predicted)
self.scc_v.update(self.sm, long_enabled, override, 20., planner_accel, 30.)
assert self.scc_v.state == state
assert self.scc_v.output_a_target == planner_accel
def test_jitter_requires_confirmed_relief_then_releases_smoothly(self):
self.enter_curve()
previous_v_target = self.scc_v.output_v_target
for frame in range(_RELIEF_CONFIRMATION_FRAMES * 2):
self.update_lat_accels(1., 1.05 if frame % 2 == 0 else 1.15)
assert self.scc_v.state == VisionState.entering
assert self.scc_v.output_v_target >= previous_v_target
assert self.scc_v.output_v_target - previous_v_target <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9
previous_v_target = self.scc_v.output_v_target
for _ in range(_RELIEF_CONFIRMATION_FRAMES):
self.update_lat_accels(1.15, 0.8)
assert self.scc_v.state == VisionState.entering
assert 0. <= self.scc_v.output_v_target - previous_v_target <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9
previous_v_target = self.scc_v.output_v_target
release_cruise = 30.
for _ in range(_RELIEF_CONFIRMATION_FRAMES - 1):
self.update_lat_accels(0.8, 0.8, release_cruise)
assert self.scc_v.state == VisionState.entering
assert 0. <= self.scc_v.output_v_target - previous_v_target <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9
previous_v_target = self.scc_v.output_v_target
active_v_targets = [previous_v_target]
for _ in range(int((release_cruise - previous_v_target) / (_TARGET_RELEASE_RATE * DT_MDL)) + 10):
self.update_lat_accels(0.8, 0.8, release_cruise)
if not self.scc_v.is_active:
break
assert self.scc_v.state == VisionState.leaving
assert self.scc_v.output_v_target != V_CRUISE_UNSET
active_v_targets.append(self.scc_v.output_v_target)
assert self.scc_v.state == VisionState.enabled
assert self.scc_v.output_v_target == V_CRUISE_UNSET
assert active_v_targets[-1] == pytest.approx(release_cruise)
assert np.all((np.diff(active_v_targets) >= 0.) &
(np.diff(active_v_targets) <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9))
def test_target_release_slows_after_reaching_ego_speed(self):
self.enter_curve()
for _ in range(100):
previous_v_target = self.scc_v.output_v_target
self.update_lat_accels(0.8, 0.8)
if previous_v_target >= self.scc_v.v_ego:
rise = self.scc_v.output_v_target - previous_v_target
assert 0. < rise <= _TARGET_RELEASE_RATE * DT_MDL + 1e-9
break
else:
pytest.fail("curve target did not release to ego speed")
def test_curve_target_is_independent_of_ego_speed(self):
model_speed = 24.
predicted_yaw_rate = 0.12
predicted_lat_accel = model_speed * predicted_yaw_rate
expected_v_target = (_A_LAT_REG_MAX / (predicted_yaw_rate / model_speed)) ** 0.5
targets = []
for v_ego in (18., 28.):
controller = SmartCruiseControlVision()
self.set_lat_accels(0.5, predicted_lat_accel, v_ego, model_speed)
controller.update(self.sm, True, False, v_ego, 0., 30.)
controller.update(self.sm, True, False, v_ego, 0., 30.)
assert controller.state == VisionState.entering
targets.append(controller.v_target)
assert targets[0] == pytest.approx(expected_v_target)
assert targets[1] == pytest.approx(expected_v_target)
def test_curve_target_respects_minimum_speed_floor(self):
model_speed = 10.
predicted_yaw_rate = 2.
self.set_lat_accels(0.5, model_speed * predicted_yaw_rate, model_speed=model_speed)
self.scc_v.update(self.sm, True, False, 20., 0., 30.)
self.scc_v.update(self.sm, True, False, 20., 0., 30.)
assert self.scc_v.state == VisionState.entering
assert self.scc_v.v_target < MIN_V
assert self.scc_v.output_v_target == pytest.approx(MIN_V)
@pytest.mark.parametrize(
("velocities", "yaw_rates"),
[([], []), ([np.nan] * len(ModelConstants.T_IDXS), [np.nan] * len(ModelConstants.T_IDXS)), ([20.] * 5, [0.1] * 3)],
ids=("empty", "nonfinite", "mismatched"),
)
def test_model_vector_edges_remain_finite(self, velocities, yaw_rates):
self.sm['modelV2'].velocity.x = velocities
self.sm['modelV2'].orientationRate.z = yaw_rates
self.scc_v.update(self.sm, True, False, 20., 0., 30.)
self.scc_v.update(self.sm, True, False, 20., 0., 30.)
assert all(np.isfinite(value) for value in (
self.scc_v.current_lat_acc, self.scc_v.max_pred_lat_acc, self.scc_v.v_target,
self.scc_v.output_v_target, self.scc_v.output_a_target,
))
@pytest.mark.parametrize("launch_speed", (5.75, 9.9, _MIN_ACTIVATION_SPEED))
def test_vision_control_does_not_steal_launch(self, launch_speed):
self.set_lat_accels(0.5, 3., launch_speed)
self.scc_v.update(self.sm, True, False, launch_speed, 0., 30.)
self.scc_v.update(self.sm, True, False, launch_speed, 0., 30.)
assert launch_speed <= _MIN_ACTIVATION_SPEED
assert self.scc_v.state == VisionState.enabled
assert not self.scc_v.is_active
assert self.scc_v.output_v_target == V_CRUISE_UNSET
def test_vision_control_can_activate_above_launch_range(self):
speed = _MIN_ACTIVATION_SPEED + 0.01
self.set_lat_accels(0.5, 3., speed)
self.scc_v.update(self.sm, True, False, speed, 0., 30.)
self.scc_v.update(self.sm, True, False, speed, 0., 30.)
assert self.scc_v.state == VisionState.entering
assert self.scc_v.is_active
def test_sequential_curve_tightens_immediately_and_releases_bounded(self):
self.enter_curve(3.)
for _ in range(20):
self.update_lat_accels(0.5, 3.)
restrictive_v_target = self.scc_v.output_v_target
self.update_lat_accels(0.5, 1.4, a_ego=0.4)
first_relief_v_target = self.scc_v.output_v_target
assert self.scc_v.state == VisionState.entering
assert 0. < first_relief_v_target - restrictive_v_target <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9
assert self.scc_v.output_a_target == 0.4
self.update_lat_accels(0.5, 1.4)
assert 0. <= self.scc_v.output_v_target - first_relief_v_target <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9
self.update_lat_accels(0.5, 3., a_ego=-0.6)
assert self.scc_v.state == VisionState.entering
assert self.scc_v.output_v_target == pytest.approx(restrictive_v_target)
assert self.scc_v.output_a_target == -0.6
for _ in range(4):
self.update_lat_accels(0.5, 1.4)
assert 0. < self.scc_v.output_v_target - restrictive_v_target <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9
self.update_lat_accels(0.5, 3.)
assert self.scc_v.output_v_target == pytest.approx(restrictive_v_target)
def test_acceleration_is_continuous_through_planner_arbitration(self):
car_control = messaging.new_message('carControl')
car_control.carControl.enabled = True
car_control.carControl.cruiseControl.override = False
self.sm['carControl'] = car_control.carControl
self.sm['carState'].vCruiseCluster = 108.
planner = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
planner.scc = SimpleNamespace(
vision=self.scc_v,
map=SimpleNamespace(output_v_target=V_CRUISE_UNSET, output_a_target=0.),
update=lambda sm, enabled, override, v_ego, a_ego, v_cruise: self.scc_v.update(
sm, enabled, override, v_ego, a_ego, v_cruise),
)
planner.resolver = SimpleNamespace(
speed_limit_valid=False, speed_limit_last_valid=False, speed_limit=0., speed_limit_final_last=0., distance=0.,
update=lambda _v_ego, _sm: None,
)
planner.sla = SimpleNamespace(
output_v_target=V_CRUISE_UNSET, output_a_target=0., update=lambda *_args: None,
)
planner.events_sp = SimpleNamespace()
self.set_lat_accels(0.5, 2.2)
planner.update_targets(self.sm, 20., -0.8, 30.)
planner.update_targets(self.sm, 20., -0.8, 30.)
assert planner.source == LongitudinalPlanSource.sccVision
assert planner.output_a_target == -0.8
for planner_accel in (-2., 0.5, -0.2):
planner.update_targets(self.sm, 20., planner_accel, 30.)
assert planner.source == LongitudinalPlanSource.sccVision
assert planner.output_a_target == planner_accel
self.set_lat_accels(0.8, 0.8)
for _ in range(int(30. / (_TARGET_RELEASE_RATE * DT_MDL)) + 10):
planner.update_targets(self.sm, 20., 0.4, 30.)
assert planner.output_a_target == 0.4
if planner.source == LongitudinalPlanSource.cruise:
break
else:
pytest.fail("SCC Vision did not release to cruise")
planner.update_targets(self.sm, 20., 0.4, 30.)
assert self.scc_v.state == VisionState.enabled
assert planner.source == LongitudinalPlanSource.cruise
@pytest.mark.parametrize(
"case, should_enter",
[
@@ -0,0 +1,82 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import gc
import numpy as np
from openpilot.selfdrive.test.longitudinal_maneuvers.plant import Plant
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanSource
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.vision_controller import _A_LAT_REG_MAX
def _run_constant_curve(*, scc_enabled: bool, cruise: float, duration: float = 70.) -> dict[str, np.ndarray]:
gc.collect()
curvature = 0.005
plant = Plant(lead_relevancy=False, speed=30., actuator_delay=0.15, actuator_lag=0.20)
planner = plant.planner
planner.accel_controller.enabled = False
planner.accel_controller.update_params = lambda: None
planner.dec._enabled = False
planner.dec._read_params = lambda: None
planner.scc.map.enabled = False
planner.scc.map.update_params = lambda: None
planner.scc.vision.enabled = scc_enabled
planner.scc.vision._update_params = lambda: None
if scc_enabled:
original_update_calculations = planner.scc.vision._update_calculations
def inject_constant_curvature(sm):
velocities = np.asarray(sm['modelV2'].velocity.x, dtype=float)
sm['modelV2'].orientationRate.z = (curvature * velocities).tolist()
sm['controlsState'].curvature = curvature
original_update_calculations(sm)
planner.scc.vision._update_calculations = inject_constant_curvature
original_update = planner.update
def enable_longitudinal(sm):
sm['carControl'].enabled = True
sm['carControl'].longActive = True
original_update(sm)
planner.update = enable_longitudinal
rows = []
while plant.current_time < duration:
output = plant.step(v_cruise=cruise)
rows.append((
plant.current_time, output['speed'], planner.mpc.last_solution_status, output['should_stop'],
planner.scc.vision.is_active, planner.source == LongitudinalPlanSource.sccVision,
planner.scc.vision.output_v_target,
))
data = np.asarray(rows, dtype=float)
gc.collect()
return {
'time': data[:, 0], 'speed': data[:, 1], 'solver_status': data[:, 2], 'should_stop': data[:, 3],
'active': data[:, 4], 'scc_source': data[:, 5], 'target': data[:, 6],
}
def test_constant_curve_recovers_like_stock_speed_cap():
target = (_A_LAT_REG_MAX / 0.005) ** 0.5
scc = _run_constant_curve(scc_enabled=True, cruise=30.)
stock = _run_constant_curve(scc_enabled=False, cruise=target)
scc_final = scc['speed'][scc['time'] >= 60.]
stock_final = stock['speed'][stock['time'] >= 60.]
assert not scc['solver_status'].any()
assert not stock['solver_status'].any()
assert not scc['should_stop'].any()
assert np.all(scc['active'][scc['time'] >= 60.])
assert np.all(scc['scc_source'][scc['time'] >= 60.])
assert np.allclose(scc['target'][scc['time'] >= 60.], target)
assert scc_final.min() >= target - 1.
assert abs(scc_final.mean() - stock_final.mean()) < 0.5
assert abs(scc_final.min() - stock_final.min()) < 1.
assert abs(scc_final.max() - stock_final.max()) < 1.
@@ -29,19 +29,11 @@ _FINISH_LAT_ACC_TH = 1.1 # Lat Acc threshold to trigger the end of the turn cyc
_A_LAT_REG_MAX = 2. # Maximum lateral acceleration
_NO_OVERSHOOT_TIME_HORIZON = 4. # s. Time to use for velocity desired based on a_target when not overshooting.
# Lookup table for the minimum smooth deceleration during the ENTERING state
# depending on the actual maximum absolute lateral acceleration predicted on the turn ahead.
_ENTERING_SMOOTH_DECEL_V = [-0.2, -1.] # min decel value allowed on ENTERING state
_ENTERING_SMOOTH_DECEL_BP = [1.3, 3.] # absolute value of lat acc ahead
# Lookup table for the acceleration for the TURNING state
# depending on the current lateral acceleration of the vehicle.
_TURNING_ACC_V = [0.5, 0., -0.4] # acc value
_TURNING_ACC_BP = [1.5, 2.3, 3.] # absolute value of current lat acc
_LEAVING_ACC = 0.5 # Conformable acceleration to regain speed while leaving a turn.
_RELIEF_CONFIRMATION_FRAMES = max(1, int(round(0.5 / DT_MDL)))
_TARGET_RELEASE_RATE = 1. # m/s^2
_BELOW_EGO_TARGET_RELEASE_RATE = 3. # m/s^2
_MIN_PRED_SPEED = 1. # m/s
_MIN_ACTIVATION_SPEED = 10. # m/s
class SmartCruiseControlVision:
@@ -65,13 +57,26 @@ class SmartCruiseControlVision:
self.state = VisionState.disabled
self.current_lat_acc = 0.
self.max_pred_lat_acc = 0.
self.relief_frames = 0
def _v_demand(self) -> float:
return max(MIN_V, min(self.v_target, self.v_cruise_setpoint))
def _released_v_target(self) -> float:
demand = self._v_demand()
if demand < self.output_v_target:
return demand
release_rate = _BELOW_EGO_TARGET_RELEASE_RATE if self.output_v_target < min(self.v_ego, demand) else _TARGET_RELEASE_RATE
return min(demand, self.output_v_target + release_rate * DT_MDL)
def get_a_target_from_control(self) -> float:
return self.a_target
return self.a_ego
def get_v_target_from_control(self) -> float:
if self.is_active:
return max(self.v_target, MIN_V) + self.a_target * _NO_OVERSHOOT_TIME_HORIZON
if self.output_v_target == V_CRUISE_UNSET:
return self._v_demand()
return self._released_v_target()
return V_CRUISE_UNSET
@@ -82,25 +87,27 @@ class SmartCruiseControlVision:
def _update_calculations(self, sm: messaging.SubMaster) -> None:
if not self.long_enabled:
return
else:
rate_plan = np.array(np.abs(sm['modelV2'].orientationRate.z))
vel_plan = np.array(sm['modelV2'].velocity.x)
self.current_lat_acc = self.v_ego ** 2 * abs(sm['controlsState'].curvature)
rate_plan = np.asarray(np.abs(sm['modelV2'].orientationRate.z), dtype=float)
vel_plan = np.asarray(sm['modelV2'].velocity.x, dtype=float)
size = min(len(rate_plan), len(vel_plan))
rate_plan, vel_plan = rate_plan[:size], vel_plan[:size]
valid = np.isfinite(rate_plan) & np.isfinite(vel_plan) & (vel_plan >= _MIN_PRED_SPEED)
# get the maximum lat accel from the model
predicted_lat_accels = rate_plan * vel_plan
self.max_pred_lat_acc = np.percentile(predicted_lat_accels, 97)
# get the maximum curve based on the current velocity
v_ego = max(self.v_ego, 0.1) # ensure a value greater than 0 for calculations
max_curve = self.max_pred_lat_acc / (v_ego**2)
# Get the target velocity for the maximum curve
self.v_target = (_A_LAT_REG_MAX / max_curve) ** 0.5
self.current_lat_acc = self.v_ego ** 2 * abs(sm['controlsState'].curvature)
self.max_pred_lat_acc = 0.
self.v_target = V_CRUISE_UNSET
if np.any(valid):
self.max_pred_lat_acc = float(np.percentile(rate_plan[valid] * vel_plan[valid], 97))
max_pred_curvature = float(np.percentile(rate_plan[valid] / vel_plan[valid], 97))
if max_pred_curvature > 0.:
self.v_target = min(float((_A_LAT_REG_MAX / max_pred_curvature) ** 0.5), V_CRUISE_UNSET)
def _update_state_machine(self) -> tuple[bool, bool]:
# ENABLED, ENTERING, TURNING, LEAVING, OVERRIDING
relief = self.current_lat_acc < _FINISH_LAT_ACC_TH and self.max_pred_lat_acc < _ABORT_ENTERING_PRED_LAT_ACC_TH
self.relief_frames = self.relief_frames + 1 if self.state in ACTIVE_STATES and relief else 0
if self.state != VisionState.disabled:
# longitudinal and feature disable always have priority in a non-disabled state
if not self.long_enabled or not self.enabled:
@@ -112,7 +119,7 @@ class SmartCruiseControlVision:
# ENABLED
if self.state == VisionState.enabled:
# Do not enter a turn control cycle if the speed is low.
if self.v_ego <= MIN_V:
if self.v_ego <= _MIN_ACTIVATION_SPEED:
pass
# If significant lateral acceleration is predicted ahead, then move to Entering turn state.
elif self.max_pred_lat_acc >= _ENTERING_PRED_LAT_ACC_TH:
@@ -128,23 +135,26 @@ class SmartCruiseControlVision:
# Transition to Turning if current lateral acceleration is over the threshold.
if self.current_lat_acc >= _TURNING_LAT_ACC_TH:
self.state = VisionState.turning
# Abort if the predicted lateral acceleration drops
elif self.max_pred_lat_acc < _ABORT_ENTERING_PRED_LAT_ACC_TH:
self.state = VisionState.enabled
# Begin releasing only after both current and predicted lateral acceleration stay clear.
elif self.relief_frames >= _RELIEF_CONFIRMATION_FRAMES:
self.state = VisionState.leaving
# TURNING
elif self.state == VisionState.turning:
# Transition to Leaving if current lateral acceleration drops below a threshold.
# Transition out of Turning if current lateral acceleration drops below a threshold.
if self.current_lat_acc <= _LEAVING_LAT_ACC_TH:
self.state = VisionState.leaving
self.state = VisionState.entering if self.max_pred_lat_acc >= _ENTERING_PRED_LAT_ACC_TH else VisionState.leaving
# LEAVING
elif self.state == VisionState.leaving:
# Transition back to Turning if current lateral acceleration goes back over the threshold.
if self.current_lat_acc >= _TURNING_LAT_ACC_TH:
self.state = VisionState.turning
# Finish if current lateral acceleration goes below a threshold.
elif self.current_lat_acc < _FINISH_LAT_ACC_TH:
# Start a new turn cycle immediately if another curve is predicted.
elif self.max_pred_lat_acc >= _ENTERING_PRED_LAT_ACC_TH:
self.state = VisionState.entering
# Finish after confirmed relief and a gradual release to the cruise setpoint.
elif self.relief_frames >= _RELIEF_CONFIRMATION_FRAMES and self.output_v_target >= self.v_cruise_setpoint:
self.state = VisionState.enabled
# DISABLED
@@ -157,32 +167,11 @@ class SmartCruiseControlVision:
enabled = self.state in ENABLED_STATES
active = self.state in ACTIVE_STATES
if not active:
self.relief_frames = 0
return enabled, active
def _update_solution(self) -> float:
# DISABLED, ENABLED, OVERRIDING
if self.state not in ACTIVE_STATES:
# when not overshooting, calculate v_turn as the speed at the prediction horizon when following
# the smooth deceleration.
a_target = self.a_ego
# ENTERING
elif self.state == VisionState.entering:
# when not overshooting, target a smooth deceleration in preparation for a sharp turn to come.
a_target = np.interp(self.max_pred_lat_acc, _ENTERING_SMOOTH_DECEL_BP, _ENTERING_SMOOTH_DECEL_V)
# TURNING
elif self.state == VisionState.turning:
# When turning, we provide a target acceleration that is comfortable for the lateral acceleration felt.
a_target = np.interp(self.current_lat_acc, _TURNING_ACC_BP, _TURNING_ACC_V)
# LEAVING
elif self.state == VisionState.leaving:
# When leaving, we provide a comfortable acceleration to regain speed.
a_target = _LEAVING_ACC
else:
raise NotImplementedError(f"SCC-V state not supported: {self.state}")
return a_target
def update(self, sm: messaging.SubMaster, long_enabled: bool, long_override: bool, v_ego: float, a_ego: float,
v_cruise_setpoint: float) -> None:
self.long_enabled = long_enabled
@@ -195,7 +184,7 @@ class SmartCruiseControlVision:
self._update_calculations(sm)
self.is_enabled, self.is_active = self._update_state_machine()
self.a_target = self._update_solution()
self.a_target = self.a_ego
self.output_v_target = self.get_v_target_from_control()
self.output_a_target = self.get_a_target_from_control()
File diff suppressed because it is too large Load Diff
+22
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": ""
+65 -11
View File
@@ -519,12 +519,6 @@
}
]
},
{
"key": "RoadEdgeLaneChangeEnabled",
"widget": "toggle",
"title": "Block Lane Change: Road Edge Detection",
"description": "Blocks lane change when the model sees a road edge on the side you signal."
},
{
"key": "AutoLaneChangeBsmDelay",
"widget": "toggle",
@@ -629,8 +623,8 @@
{
"key": "AccelPersonalityEnabled",
"widget": "toggle",
"title": "Enable Acceleration Profiles",
"description": "Enables acceleration profile selection for longitudinal control.",
"title": "Enable Accel Controller",
"description": "Begin slowing early and smoothly behind lead vehicles. Stock longitudinal control retains braking and stopping authority.",
"visibility": [
{
"type": "capability",
@@ -650,10 +644,10 @@
"key": "AccelPersonality",
"widget": "multiple_button",
"title": "Acceleration Profile",
"description": "Controls how quickly sunnypilot accelerates while preserving braking and stop behavior.",
"description": "Eco slows earliest and recovers gently, Normal balances comfort and response, and Sport reacts and recovers more quickly.",
"options": [
{
"value": 2,
"value": 0,
"label": "Eco"
},
{
@@ -661,7 +655,7 @@
"label": "Normal"
},
{
"value": 0,
"value": 2,
"label": "Sport"
}
],
@@ -2059,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
}
]
}
]
},
@@ -2226,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,19 +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: Controls how quickly sunnypilot accelerates while preserving braking and stop behavior.
description: Eco slows earliest and recovers gently, Normal balances comfort and response, and Sport reacts
and recovers more quickly.
options:
- value: 2
- value: 0
label: Eco
- value: 1
label: Normal
- value: 0
- 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)
@@ -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):