mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-08-21 02:43:46 +08:00
feat(long): smooth lead following with acceleration profiles
This commit is contained in:
@@ -203,6 +203,7 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 {
|
||||
aTarget @5 :Float32;
|
||||
events @6 :List(OnroadEventSP.Event);
|
||||
e2eAlerts @7 :E2eAlerts;
|
||||
accelController @8 :AccelController;
|
||||
|
||||
struct DynamicExperimentalControl {
|
||||
state @0 :DynamicExperimentalControlState;
|
||||
@@ -305,6 +306,35 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 {
|
||||
greenLightAlert @0 :Bool;
|
||||
leadDepartAlert @1 :Bool;
|
||||
}
|
||||
|
||||
struct AccelController {
|
||||
enabled @0 :Bool;
|
||||
active @1 :Bool;
|
||||
shadowOnlyDEPRECATED @2 :Bool;
|
||||
profile @3 :Profile;
|
||||
state @4 :State;
|
||||
|
||||
enum Profile {
|
||||
eco @0;
|
||||
normal @1;
|
||||
sport @2;
|
||||
}
|
||||
|
||||
enum State {
|
||||
inactive @0;
|
||||
free @1;
|
||||
restrict @2;
|
||||
hold @3;
|
||||
release @4;
|
||||
stopHold @5;
|
||||
}
|
||||
}
|
||||
|
||||
enum AccelerationPersonality {
|
||||
eco @0;
|
||||
normal @1;
|
||||
sport @2;
|
||||
}
|
||||
}
|
||||
|
||||
struct OnroadEventSP @0xda96579883444c35 {
|
||||
|
||||
@@ -232,6 +232,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"}},
|
||||
|
||||
@@ -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("LiveParametersV2")
|
||||
|
||||
assert self.params.get("LanguageSetting") is None
|
||||
assert self.params.get("LanguageSetting", return_default=False) is None
|
||||
assert isinstance(self.params.get("LanguageSetting", return_default=True), str)
|
||||
assert isinstance(self.params.get("LongitudinalPersonality", return_default=True), int)
|
||||
assert self.params.get("AccelPersonalityEnabled", return_default=True) is False
|
||||
assert self.params.get("AccelPersonality", return_default=True) == 1
|
||||
assert self.params.get("LiveParametersV2") is None
|
||||
assert self.params.get("LiveParametersV2", return_default=True) is None
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -266,7 +268,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)
|
||||
|
||||
@@ -326,7 +329,7 @@ class LongitudinalMpc:
|
||||
# when the leads are no factor.
|
||||
v_lower = v_ego + (T_IDXS * CRUISE_MIN_ACCEL * 1.05)
|
||||
# TODO does this make sense when max_a is negative?
|
||||
v_upper = v_ego + (T_IDXS * CRUISE_MAX_ACCEL * 1.05)
|
||||
v_upper = v_ego + (T_IDXS * self.cruise_accel_max(CRUISE_MAX_ACCEL) * 1.05)
|
||||
v_cruise_clipped = np.clip(v_cruise * np.ones(N+1), v_lower, v_upper)
|
||||
cruise_obstacle = np.cumsum(T_DIFFS * v_cruise_clipped) + get_safe_obstacle_distance(v_cruise_clipped, t_follow)
|
||||
|
||||
@@ -340,6 +343,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
|
||||
@@ -359,6 +363,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])
|
||||
|
||||
for i in range(N+1):
|
||||
|
||||
@@ -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
|
||||
@@ -110,15 +110,15 @@ 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
|
||||
|
||||
is_e2e, v_cruise = LongitudinalPlannerSP.update_accel_controller(self, sm, v_cruise, prev_accel_constraint, accel_clip[1], reset_state)
|
||||
|
||||
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.set_cur_state(self.v_desired_filter.x, self.mpc_accel_seed)
|
||||
self.mpc.update(sm['radarState'], v_cruise, personality=sm['selfdriveState'].personality)
|
||||
|
||||
self.v_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.v_solution)
|
||||
@@ -135,13 +135,13 @@ 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
|
||||
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)
|
||||
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:
|
||||
@@ -149,6 +149,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)
|
||||
|
||||
@@ -11,6 +11,14 @@ from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPl
|
||||
from openpilot.selfdrive.controls.radard import _LEAD_ACCEL_TAU
|
||||
|
||||
|
||||
class PlannerSM(dict):
|
||||
def __init__(self, radar_frame: int, services: dict):
|
||||
super().__init__(services)
|
||||
self.logMonoTime = {"radarState": radar_frame}
|
||||
self.valid = {"radarState": True}
|
||||
self.alive = {"radarState": True}
|
||||
|
||||
|
||||
class Plant:
|
||||
messaging_initialized = False
|
||||
|
||||
@@ -132,7 +140,7 @@ class Plant:
|
||||
car_control.carControl.orientationNED = [0., float(pitch), 0.]
|
||||
|
||||
# ******** get controlsState messages for plotting ***
|
||||
sm = {'radarState': radar.radarState,
|
||||
sm = PlannerSM(self.rk.frame, {'radarState': radar.radarState,
|
||||
'carState': car_state.carState,
|
||||
'carControl': car_control.carControl,
|
||||
'controlsState': control.controlsState,
|
||||
@@ -141,7 +149,7 @@ class Plant:
|
||||
'modelV2': model.modelV2,
|
||||
'carStateSP': car_state_sp.carStateSP,
|
||||
'liveMapDataSP': live_map_data_sp.liveMapDataSP,
|
||||
'gpsLocation': gps_data.gpsLocation}
|
||||
'gpsLocation': gps_data.gpsLocation})
|
||||
self.planner.update(sm)
|
||||
self.acceleration = self.planner.output_a_target
|
||||
if self.planner.output_should_stop:
|
||||
|
||||
@@ -27,6 +27,12 @@ DESCRIPTIONS = {
|
||||
"In relaxed mode sunnypilot will stay further away from lead cars. On supported cars, you can cycle through these personalities with " +
|
||||
"your steering wheel distance button."
|
||||
),
|
||||
"AccelPersonalityEnabled": tr_noop(
|
||||
"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()
|
||||
|
||||
@@ -383,13 +383,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):
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import math
|
||||
|
||||
from opendbc.car.interfaces import ACCEL_MAX
|
||||
from openpilot.cereal import custom
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.sunnypilot import get_sanitize_int_param
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.constants import (
|
||||
LEAD_SAMPLE_FILTER_FRAMES, COMFORT_DECEL, LEAD_DROPOUT_COAST_TIME, MPC_DECEL_JERK_COST_MULTIPLIER,
|
||||
MPC_DECEL_JERK_LONG_TREND_FRAMES, MPC_DECEL_JERK_LONG_TREND_RATE, MPC_DECEL_JERK_MAX_REQUIRED_DECEL,
|
||||
MPC_DECEL_JERK_MAX_REQUIRED_DECEL_RATE, MPC_DECEL_JERK_MAX_TARGET_REDUCTION, MPC_DECEL_TREND_FRAMES,
|
||||
PARAM_READ_INTERVAL, LEAD_RELEASE_CONFIRM_TIME, RADAR_STALE_TIMEOUT, SPEED_DEADBAND, VEGO_NOISE_TOLERANCE,
|
||||
AccelProfile, profile_accel_max, sanitize_profile,
|
||||
)
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.helpers import build_accel_ceiling, is_valid_context
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.lead import LeadPlan, calculate_lead_plan
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.lead_controller import LeadController
|
||||
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalPlanSource
|
||||
|
||||
AccelControllerState = custom.LongitudinalPlanSP.AccelController.State
|
||||
|
||||
|
||||
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_confirm_frames = max(LEAD_SAMPLE_FILTER_FRAMES, math.ceil(LEAD_RELEASE_CONFIRM_TIME / dt))
|
||||
self.dropout_frames = max(self.lead_confirm_frames, math.ceil(LEAD_DROPOUT_COAST_TIME / dt))
|
||||
self.dropout_release_frames = max(1, self.dropout_frames - self.lead_confirm_frames)
|
||||
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(PARAM_READ_INTERVAL / dt)))
|
||||
self._param_frame = 0
|
||||
self._jerk_smoothing_blocked = False
|
||||
self._required_decel_samples: list[float] = []
|
||||
self._required_decel_long_samples: list[float] = []
|
||||
self._required_decel_lead = -1
|
||||
self._required_decel_lead_track_id = -1
|
||||
self._lead_trend_warmup = False
|
||||
|
||||
self.lead_controller = LeadController()
|
||||
self._stale_frames = 0
|
||||
self._cruise_accel_limited = False
|
||||
|
||||
self.is_active = False
|
||||
self.output_v_target = 0.0
|
||||
self.mpc_accel_max: tuple[float, ...] | None = None
|
||||
self.cruise_accel_max: float | None = None
|
||||
self.state = AccelControllerState.inactive
|
||||
self.selected_lead = -1
|
||||
self.selected_lead_track_id = -1
|
||||
self.required_decel = 0.0
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
return self.available and self.enabled
|
||||
|
||||
@property
|
||||
def launching(self) -> bool:
|
||||
return self.lead_controller.launching
|
||||
|
||||
@property
|
||||
def departure_launching(self) -> bool:
|
||||
return self.lead_controller.departure_launching
|
||||
|
||||
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
|
||||
|
||||
def reset(self) -> None:
|
||||
self.lead_controller = LeadController()
|
||||
self._stale_frames = 0
|
||||
self._jerk_smoothing_blocked = False
|
||||
self._required_decel_samples.clear()
|
||||
self._required_decel_long_samples.clear()
|
||||
self._required_decel_lead = self._required_decel_lead_track_id = -1
|
||||
self._lead_trend_warmup = False
|
||||
self._cruise_accel_limited = False
|
||||
self.is_active = False
|
||||
self.output_v_target = 0.0
|
||||
self.mpc_accel_max = None
|
||||
self.cruise_accel_max = None
|
||||
self.state = AccelControllerState.inactive
|
||||
self.selected_lead = -1
|
||||
self.selected_lead_track_id = -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, radar_fresh: bool = True,
|
||||
previous_mpc_source=None, planner_speed: float | None = None, planner_accel: float = 0.0,
|
||||
previous_plan_accel: float = 0.0) -> None:
|
||||
self.profile = sanitize_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_max_accel = profile_accel_max(self.profile, sanitized_v_ego)
|
||||
stock_accel_max = float(stock_accel_max)
|
||||
positive_accel_max = (max(0.0, min(profile_max_accel, stock_accel_max, ACCEL_MAX))
|
||||
if math.isfinite(profile_max_accel) and math.isfinite(stock_accel_max) else math.nan)
|
||||
planner_speed = sanitized_v_ego if planner_speed is None else planner_speed
|
||||
valid_context = is_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)
|
||||
|
||||
lead_plan = None
|
||||
if enabled_context:
|
||||
if radar_fresh:
|
||||
self._stale_frames = 0
|
||||
lead_plan = calculate_lead_plan(radar_state, sanitized_v_ego, a_ego, self.delay, self.profile, follow_personality)
|
||||
else:
|
||||
self._stale_frames += 1
|
||||
new_acc_handoff = (self.lead_controller.target_speed is None and previous_mpc_source == LongitudinalPlanSource.e2e
|
||||
and math.isfinite(previous_plan_accel))
|
||||
if new_acc_handoff:
|
||||
lead_plan = LeadPlan()
|
||||
|
||||
if lead_plan is not None:
|
||||
self.lead_controller.update(lead_plan, base_speed, sanitized_v_ego, COMFORT_DECEL[self.profile], profile_max_accel, self.dt,
|
||||
self.lead_confirm_frames, self.dropout_frames, planner_speed, planner_accel,
|
||||
previous_mpc_source, previous_plan_accel)
|
||||
stale_limit = self.dropout_frames if self.lead_controller.launching else self.radar_stale_frames
|
||||
if not radar_fresh and self._stale_frames >= stale_limit and not self.lead_controller.stop_hold:
|
||||
self.lead_controller.reset()
|
||||
else:
|
||||
self._stale_frames = 0
|
||||
|
||||
active = enabled_context and (radar_fresh or self.lead_controller.target_speed is not None)
|
||||
self.is_active = active
|
||||
if not active:
|
||||
self.lead_controller.reset()
|
||||
self._cruise_accel_limited = False
|
||||
self.output_v_target = base_speed
|
||||
self.mpc_accel_max = None
|
||||
self.cruise_accel_max = None
|
||||
self.state = AccelControllerState.inactive
|
||||
self.selected_lead = -1
|
||||
self.selected_lead_track_id = -1
|
||||
self.required_decel = 0.0
|
||||
return
|
||||
|
||||
lead_controller = self.lead_controller
|
||||
self.output_v_target = lead_controller.target_speed if lead_controller.target_speed is not None else base_speed
|
||||
self.selected_lead = lead_controller.selected_lead
|
||||
self.selected_lead_track_id = lead_controller.selected_lead_track_id
|
||||
self.required_decel = lead_controller.required_decel
|
||||
|
||||
recovery_limit_active = lead_controller.lead_recovery and lead_controller.recovery_accel_limit is not None and not lead_controller.e2e_braking_handoff
|
||||
lead_recovery_accel = lead_controller.lead_recovery and planner_accel >= 0.0
|
||||
dropout_coast = lead_controller.should_coast_on_dropout
|
||||
profile_limit_active = (not lead_controller.stop_hold and (not lead_controller.has_lead or lead_recovery_accel
|
||||
or lead_controller.departure_launching or lead_controller.leadless_departure)
|
||||
and not lead_controller.e2e_braking_handoff)
|
||||
if recovery_limit_active:
|
||||
effective_accel_max = min(positive_accel_max, lead_controller.recovery_accel_limit)
|
||||
elif profile_limit_active:
|
||||
effective_accel_max = positive_accel_max
|
||||
else:
|
||||
effective_accel_max = math.inf
|
||||
self.mpc_accel_max = (build_accel_ceiling(effective_accel_max, planner_accel)
|
||||
if recovery_limit_active or profile_limit_active else None)
|
||||
|
||||
lead_context = lead_controller.has_lead or math.isfinite(lead_controller.lead_speed_ceiling)
|
||||
start_cruise_accel_limit = (lead_controller.target_speed is not None and not lead_controller.restricting and not lead_controller.releasing
|
||||
and lead_controller.has_lead and previous_mpc_source == LongitudinalPlanSource.cruise)
|
||||
keep_cruise_accel_limit = (self._cruise_accel_limited and lead_context and not lead_controller.restricting and not lead_controller.releasing
|
||||
and not lead_controller.e2e_braking_handoff)
|
||||
self._cruise_accel_limited = start_cruise_accel_limit or keep_cruise_accel_limit
|
||||
if dropout_coast:
|
||||
release_frame = max(0, lead_controller.lead_loss_frames - self.lead_confirm_frames)
|
||||
self.cruise_accel_max = positive_accel_max * release_frame / self.dropout_release_frames
|
||||
else:
|
||||
self.cruise_accel_max = positive_accel_max if self._cruise_accel_limited else None
|
||||
|
||||
if lead_controller.stop_hold:
|
||||
self.state = AccelControllerState.stopHold
|
||||
elif lead_controller.restricting:
|
||||
self.state = AccelControllerState.restrict
|
||||
elif lead_controller.releasing:
|
||||
self.state = AccelControllerState.release
|
||||
elif lead_controller.target_speed >= base_speed - SPEED_DEADBAND:
|
||||
self.state = AccelControllerState.free
|
||||
else:
|
||||
self.state = AccelControllerState.hold
|
||||
|
||||
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)
|
||||
same_lead = self.selected_lead == self._required_decel_lead and self.selected_lead_track_id == self._required_decel_lead_track_id
|
||||
lead_changed = lead_restriction and self._required_decel_lead >= 0 and not same_lead
|
||||
if lead_changed:
|
||||
self._lead_trend_warmup = True
|
||||
elif not lead_restriction:
|
||||
self._lead_trend_warmup = False
|
||||
if not lead_restriction or not same_lead or not math.isfinite(self.required_decel):
|
||||
self._required_decel_samples.clear()
|
||||
self._required_decel_long_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_long_samples.append(self.required_decel)
|
||||
if len(self._required_decel_long_samples) > MPC_DECEL_JERK_LONG_TREND_FRAMES:
|
||||
self._required_decel_long_samples.pop(0)
|
||||
self._required_decel_lead = self.selected_lead if lead_restriction else -1
|
||||
self._required_decel_lead_track_id = self.selected_lead_track_id if lead_restriction else -1
|
||||
|
||||
history = self._required_decel_samples
|
||||
history_ready = len(history) == MPC_DECEL_TREND_FRAMES
|
||||
tightening_lead = (history_ready
|
||||
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)
|
||||
long_history = self._required_decel_long_samples
|
||||
long_history_ready = len(long_history) == MPC_DECEL_JERK_LONG_TREND_FRAMES
|
||||
sustained_tightening = (long_history_ready
|
||||
and (long_history[-1] - long_history[0]) / (self.dt * (len(long_history) - 1)) > MPC_DECEL_JERK_LONG_TREND_RATE)
|
||||
modest_decel = (lead_restriction and target_reduction < MPC_DECEL_JERK_MAX_TARGET_REDUCTION
|
||||
and 0.0 < self.required_decel < MPC_DECEL_JERK_MAX_REQUIRED_DECEL)
|
||||
smoothing_eligible = (modest_decel and (not self._lead_trend_warmup or history_ready)
|
||||
and not tightening_lead and not sustained_tightening)
|
||||
if history_ready:
|
||||
self._lead_trend_warmup = False
|
||||
if previous_mpc_failed or (lead_restriction and not self._jerk_smoothing_blocked
|
||||
and (not modest_decel or tightening_lead or sustained_tightening)):
|
||||
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, departure_authorized: bool = True) -> bool:
|
||||
if not self.is_active:
|
||||
return should_stop
|
||||
if self.lead_controller.departure_launching and departure_authorized:
|
||||
return False
|
||||
return should_stop or self.lead_controller.stop_hold
|
||||
@@ -0,0 +1,72 @@
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.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.56, 1.30, 0.72, 0.32, 0.24],
|
||||
AccelProfile.normal: [1.58, 1.51, 0.98, 0.53, 0.35],
|
||||
AccelProfile.sport: [2.00, 1.91, 1.16, 0.73, 0.47],
|
||||
}
|
||||
|
||||
BRAKING_ACCEL_THRESHOLD = -0.11
|
||||
|
||||
LEAD_SAMPLE_FILTER_FRAMES = 5
|
||||
LEAD_RELEASE_CONFIRM_TIME = 0.50
|
||||
LEAD_DROPOUT_COAST_TIME = 1.50
|
||||
|
||||
SPEED_DEADBAND = 0.15
|
||||
|
||||
TARGET_RELEASE_SLEW = 8.75
|
||||
LAUNCH_TARGET_HEADROOM = 3.0
|
||||
LAUNCH_END_SPEED = 3.0
|
||||
|
||||
LEAD_RECOVERY_HEADROOM = 1.25
|
||||
LEAD_RECOVERY_ACCEL_SLEW = 0.25
|
||||
LEAD_RECOVERY_DECEL_RATE = 0.50
|
||||
|
||||
STOP_HOLD_EGO_SPEED = 0.30
|
||||
STOP_HOLD_SPEED_FLOOR = 0.15
|
||||
STOP_HOLD_EXIT_FRAMES = 4
|
||||
STOP_HOLD_CREEP_DISTANCE = 0.30
|
||||
STOP_HOLD_MAX_LEAD_DISTANCE = 30.0
|
||||
DISTANCE_JUMP_CONFIRM_FRAMES = 3
|
||||
|
||||
STOP_GAP_RESERVE = 0.75
|
||||
|
||||
RADAR_STALE_TIMEOUT = 0.50
|
||||
MAX_LEAD_ACCEL_TAU = 10.0
|
||||
MIN_LEAD_SPEED = -1.0
|
||||
VEGO_NOISE_TOLERANCE = 0.10
|
||||
PARAM_READ_INTERVAL = 0.25
|
||||
ACCEL_LIMIT_HORIZON_JERK = 1.0
|
||||
|
||||
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_LONG_TREND_FRAMES = 6
|
||||
MPC_DECEL_JERK_LONG_TREND_RATE = 0.02
|
||||
MPC_DECEL_JERK_MAX_TARGET_REDUCTION = 9.0
|
||||
MPC_DECEL_TREND_FRAMES = 4
|
||||
|
||||
|
||||
def sanitize_profile(profile: int) -> int:
|
||||
return profile if profile in ACCEL_PROFILES else AccelProfile.normal
|
||||
|
||||
|
||||
def profile_accel_max(profile: int, v_ego: float) -> float:
|
||||
if not math.isfinite(v_ego):
|
||||
return math.nan
|
||||
return float(np.interp(max(v_ego, 0.0), ACCEL_PROFILE_MAX_BP, ACCEL_PROFILE_MAX_V[sanitize_profile(profile)]))
|
||||
@@ -0,0 +1,22 @@
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
from opendbc.car.interfaces import ACCEL_MIN, ACCEL_MAX
|
||||
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import T_IDXS
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.constants import ACCEL_LIMIT_HORIZON_JERK, VEGO_NOISE_TOLERANCE
|
||||
|
||||
|
||||
def is_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 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)
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
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 openpilot.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 (
|
||||
COMFORT_DECEL, MAX_LEAD_ACCEL_TAU, MIN_LEAD_SPEED, STOP_GAP_RESERVE, STOP_HOLD_SPEED_FLOOR, sanitize_profile,
|
||||
)
|
||||
|
||||
|
||||
class LeadPlan(NamedTuple):
|
||||
speed_ceiling: 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: int = -1
|
||||
departure_lead_track_id: int = -1
|
||||
departure_lead_speed: float = math.inf
|
||||
departure_lead_raw_speed: float = math.inf
|
||||
departure_lead_distance: float = math.inf
|
||||
departure_lead_separation: float = math.inf
|
||||
departure_speed_ceiling: 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, float] | None:
|
||||
if not lead.present:
|
||||
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
|
||||
raw_v_lead = float(getattr(lead, "vLead", v_lead))
|
||||
if not math.isfinite(raw_v_lead):
|
||||
raw_v_lead = 0.0
|
||||
return d_rel, max(v_lead, 0.0), max(raw_v_lead, 0.0), float(np.clip(a_lead, -10.0, 5.0)), a_lead_tau
|
||||
|
||||
|
||||
def calculate_lead_plan(radar_state, v_ego: float, a_ego: float, delay: float, profile: int,
|
||||
follow_personality=log.LongitudinalPersonality.standard) -> LeadPlan:
|
||||
if not all(math.isfinite(value) for value in (v_ego, a_ego, delay)) or v_ego < 0.0 or delay < 0.0:
|
||||
return LeadPlan()
|
||||
|
||||
leads = (radar_state.leadOne, radar_state.leadTwo)
|
||||
lead_status = any(lead.present for lead in leads)
|
||||
t_follow = get_T_FOLLOW(follow_personality)
|
||||
if not math.isfinite(t_follow) or t_follow < 0.0:
|
||||
return LeadPlan(lead_status=lead_status)
|
||||
|
||||
profile = sanitize_profile(profile)
|
||||
x_ego, v_ego_delay = _project_ego(v_ego, a_ego, delay)
|
||||
comfort_decel = COMFORT_DECEL[profile]
|
||||
candidates: list[LeadPlan] = []
|
||||
departure_candidates: list[tuple[float, LeadPlan]] = []
|
||||
|
||||
for lead_index, lead in enumerate(leads):
|
||||
values = _lead_values(lead)
|
||||
if values is None:
|
||||
continue
|
||||
|
||||
d_rel, v_lead, raw_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)
|
||||
usable_gap = max(safety_gap - STOP_GAP_RESERVE, 0.0)
|
||||
speed_ceiling = v_lead_delay + math.sqrt(2.0 * comfort_decel * usable_gap)
|
||||
departure_speed_ceiling = 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, speed_ceiling, departure_speed_ceiling, 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
|
||||
candidate = LeadPlan(
|
||||
speed_ceiling=speed_ceiling, selected_lead=lead_index, selected_lead_track_id=track_id,
|
||||
selected_lead_speed=v_lead_delay, selected_lead_accel=a_lead, departure_lead=lead_index,
|
||||
departure_lead_track_id=track_id, departure_lead_speed=v_lead_delay, departure_lead_raw_speed=raw_v_lead,
|
||||
departure_lead_distance=d_rel, departure_lead_separation=separation,
|
||||
departure_speed_ceiling=departure_speed_ceiling, closing_speed=closing_speed, required_decel=required_decel,
|
||||
has_nearly_stopped_lead=v_lead_delay < STOP_HOLD_SPEED_FLOOR, lead_status=lead_status,
|
||||
)
|
||||
candidates.append(candidate)
|
||||
departure_candidates.append((departure_distance, candidate))
|
||||
|
||||
if not candidates:
|
||||
return LeadPlan(lead_status=lead_status)
|
||||
|
||||
selected = min(candidates, key=lambda candidate: candidate.speed_ceiling)
|
||||
departure = min(departure_candidates, key=lambda candidate: candidate[0])[1]
|
||||
return selected._replace(
|
||||
departure_lead=departure.selected_lead, departure_lead_track_id=departure.selected_lead_track_id,
|
||||
departure_lead_speed=departure.selected_lead_speed, departure_lead_raw_speed=departure.departure_lead_raw_speed,
|
||||
departure_lead_distance=departure.departure_lead_distance,
|
||||
departure_lead_separation=departure.departure_lead_separation,
|
||||
departure_speed_ceiling=departure.departure_speed_ceiling,
|
||||
has_nearly_stopped_lead=departure.has_nearly_stopped_lead,
|
||||
)
|
||||
@@ -0,0 +1,386 @@
|
||||
"""
|
||||
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
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalPlanSource
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.constants import (
|
||||
BRAKING_ACCEL_THRESHOLD, LEAD_SAMPLE_FILTER_FRAMES, DISTANCE_JUMP_CONFIRM_FRAMES, LAUNCH_END_SPEED, LAUNCH_TARGET_HEADROOM,
|
||||
LEAD_RECOVERY_ACCEL_SLEW, LEAD_RECOVERY_HEADROOM, LEAD_RECOVERY_DECEL_RATE, SPEED_DEADBAND, STOP_HOLD_CREEP_DISTANCE,
|
||||
STOP_HOLD_EGO_SPEED, STOP_HOLD_EXIT_FRAMES, STOP_HOLD_MAX_LEAD_DISTANCE, STOP_HOLD_SPEED_FLOOR, TARGET_RELEASE_SLEW,
|
||||
)
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.lead import LeadPlan
|
||||
|
||||
|
||||
def _median(samples: list[float]) -> float:
|
||||
return sorted(samples)[len(samples) // 2]
|
||||
|
||||
|
||||
def _slew(current: float, target: float, rate: float, dt: float) -> float:
|
||||
return float(np.clip(target, current - rate * dt, current + rate * dt))
|
||||
|
||||
|
||||
def _max_distance_step(lead_speed: float, dt: float) -> float:
|
||||
return max(STOP_HOLD_CREEP_DISTANCE / 2.0, 3.0 * max(lead_speed, 0.0) * dt)
|
||||
|
||||
|
||||
def _same_lead(first: int, first_track_id: int, second: int, second_track_id: int) -> bool:
|
||||
if first < 0 or second < 0:
|
||||
return False
|
||||
if first_track_id >= 0 or second_track_id >= 0:
|
||||
return first_track_id >= 0 and first_track_id == second_track_id
|
||||
return first == second
|
||||
|
||||
|
||||
class LeadController:
|
||||
def __init__(self) -> None:
|
||||
self.lead_speed_samples = [math.inf] * LEAD_SAMPLE_FILTER_FRAMES
|
||||
self.lead_accel_samples = [0.0] * LEAD_SAMPLE_FILTER_FRAMES
|
||||
|
||||
self.lead_speed_ceiling = math.inf
|
||||
self.release_confirm_frames = 0
|
||||
self.lead_loss_frames = 0
|
||||
self._dropout_was_restricting = False
|
||||
self._dropout_was_braking = False
|
||||
|
||||
self.target_speed: float | None = None
|
||||
self.e2e_braking_handoff = False
|
||||
self.lead_recovery = False
|
||||
self.recovery_accel_limit: float | None = None
|
||||
|
||||
self.stop_hold = False
|
||||
self.launching = False
|
||||
self.departure_launching = False
|
||||
self.leadless_departure = False
|
||||
self.held_lead = -1
|
||||
self.held_lead_track_id = -1
|
||||
self.held_lead_trusted = False
|
||||
self.departure_confirm_frames = 0
|
||||
self.no_departure_lead_frames = 0
|
||||
self.departure_distance_ref: float | None = None
|
||||
self.last_departure_distance: float | None = None
|
||||
self.pending_distance_jump: float | None = None
|
||||
self.distance_jump_frames = 0
|
||||
self.braking_for_lead = False
|
||||
self._lead_frames = 0
|
||||
|
||||
self.restricting = False
|
||||
self.releasing = False
|
||||
self.has_lead = False
|
||||
self.required_decel = 0.0
|
||||
self.selected_lead = -1
|
||||
self.selected_lead_track_id = -1
|
||||
self.raw_speed_ceiling = math.inf
|
||||
|
||||
@property
|
||||
def filtered_lead_speed(self) -> float:
|
||||
return _median(self.lead_speed_samples)
|
||||
|
||||
@property
|
||||
def filtered_lead_accel(self) -> float:
|
||||
return _median(self.lead_accel_samples)
|
||||
|
||||
@property
|
||||
def should_coast_on_dropout(self) -> bool:
|
||||
return not self.has_lead and self._dropout_was_restricting and self._dropout_was_braking and math.isfinite(self.lead_speed_ceiling)
|
||||
|
||||
def reset(self) -> None:
|
||||
self.__init__()
|
||||
|
||||
def _update_lead_bookkeeping(self, lead_plan: LeadPlan, was_restricting: bool) -> None:
|
||||
self.has_lead = lead_plan.selected_lead >= 0
|
||||
self.raw_speed_ceiling = lead_plan.speed_ceiling if self.has_lead else math.inf
|
||||
if self.has_lead:
|
||||
self.lead_loss_frames = 0
|
||||
self._dropout_was_braking = False
|
||||
else:
|
||||
if self.lead_loss_frames == 0:
|
||||
self._dropout_was_restricting = was_restricting
|
||||
self.lead_loss_frames += 1
|
||||
self.selected_lead = lead_plan.selected_lead
|
||||
self.selected_lead_track_id = lead_plan.selected_lead_track_id if self.has_lead else -1
|
||||
|
||||
def _update_speed_sample(self, lead_plan: LeadPlan) -> None:
|
||||
if not self.has_lead:
|
||||
self.lead_speed_samples.append(math.inf)
|
||||
self.lead_speed_samples.pop(0)
|
||||
self.lead_accel_samples.append(0.0)
|
||||
self.lead_accel_samples.pop(0)
|
||||
return
|
||||
if self.raw_speed_ceiling <= self.lead_speed_ceiling + 1e-9:
|
||||
self.lead_speed_samples.append(lead_plan.selected_lead_speed)
|
||||
self.lead_speed_samples.pop(0)
|
||||
self.lead_accel_samples.append(lead_plan.selected_lead_accel)
|
||||
self.lead_accel_samples.pop(0)
|
||||
|
||||
def _update_speed_ceiling(self, lead_confirm_frames: int, dropout_frames: int) -> None:
|
||||
candidate = self.raw_speed_ceiling
|
||||
if candidate <= self.lead_speed_ceiling:
|
||||
self.lead_speed_ceiling = candidate
|
||||
self.release_confirm_frames = 0
|
||||
return
|
||||
|
||||
if not self.has_lead:
|
||||
hold_frames = dropout_frames if self._dropout_was_restricting else lead_confirm_frames
|
||||
if self.lead_loss_frames <= hold_frames:
|
||||
return
|
||||
self.lead_speed_ceiling = candidate
|
||||
self.release_confirm_frames = 0
|
||||
return
|
||||
|
||||
if candidate >= self.lead_speed_ceiling + SPEED_DEADBAND:
|
||||
self.release_confirm_frames += 1
|
||||
else:
|
||||
self.release_confirm_frames = 0
|
||||
if self.release_confirm_frames > lead_confirm_frames:
|
||||
self.lead_speed_ceiling = candidate
|
||||
self.release_confirm_frames = 0
|
||||
|
||||
def _guarded_distance(self, raw: float, lead_speed: float, dt: float) -> float:
|
||||
if self.last_departure_distance is not None:
|
||||
delta = raw - self.last_departure_distance
|
||||
if abs(delta) > _max_distance_step(lead_speed, dt):
|
||||
consistent = self.pending_distance_jump is not None and delta * self.pending_distance_jump > 0.0
|
||||
self.distance_jump_frames = self.distance_jump_frames + 1 if consistent else 1
|
||||
self.pending_distance_jump = delta
|
||||
if self.distance_jump_frames >= DISTANCE_JUMP_CONFIRM_FRAMES:
|
||||
self.pending_distance_jump = None
|
||||
self.distance_jump_frames = 0
|
||||
self.departure_confirm_frames = 0
|
||||
if abs(delta) >= STOP_HOLD_CREEP_DISTANCE:
|
||||
self.held_lead_trusted = False
|
||||
else:
|
||||
raw = self.last_departure_distance
|
||||
else:
|
||||
self.pending_distance_jump = None
|
||||
self.distance_jump_frames = 0
|
||||
self.last_departure_distance = raw
|
||||
return raw
|
||||
|
||||
def _reset_distance_guard(self) -> None:
|
||||
self.last_departure_distance = self.pending_distance_jump = None
|
||||
self.distance_jump_frames = 0
|
||||
|
||||
def _reset_departure_confirmation(self) -> None:
|
||||
self.departure_confirm_frames = 0
|
||||
self.departure_distance_ref = None
|
||||
self.no_departure_lead_frames = 0
|
||||
|
||||
def _set_held_lead(self, lead_plan: LeadPlan, replacement: bool = False) -> None:
|
||||
self.held_lead = lead_plan.departure_lead
|
||||
self.held_lead_track_id = lead_plan.departure_lead_track_id
|
||||
self.held_lead_trusted = not replacement and self.held_lead_track_id >= 0
|
||||
self._reset_distance_guard()
|
||||
if self.held_lead >= 0:
|
||||
self.last_departure_distance = lead_plan.departure_lead_distance
|
||||
self._reset_departure_confirmation()
|
||||
|
||||
def _update_stop_hold(self, lead_plan: LeadPlan, v_ego: float, base_speed: float, dt: float, lead_confirm_frames: int) -> bool:
|
||||
if self.stop_hold:
|
||||
has_departure_lead = _same_lead(self.held_lead, self.held_lead_track_id,
|
||||
lead_plan.departure_lead, lead_plan.departure_lead_track_id)
|
||||
if lead_plan.departure_lead >= 0 and not has_departure_lead:
|
||||
continuous_vision_lead = (self.held_lead_track_id < 0 and lead_plan.departure_lead_track_id < 0
|
||||
and self.last_departure_distance is not None
|
||||
and abs(lead_plan.departure_lead_distance - self.last_departure_distance)
|
||||
<= _max_distance_step(lead_plan.departure_lead_speed, dt))
|
||||
if continuous_vision_lead:
|
||||
self.held_lead = lead_plan.departure_lead
|
||||
self.held_lead_trusted = False
|
||||
else:
|
||||
self._set_held_lead(lead_plan, replacement=True)
|
||||
has_departure_lead = True
|
||||
if has_departure_lead or lead_plan.lead_status:
|
||||
self.no_departure_lead_frames = 0
|
||||
else:
|
||||
self.no_departure_lead_frames += 1
|
||||
lead_speed = lead_plan.departure_lead_speed if has_departure_lead else 0.0
|
||||
raw_lead_speed = lead_plan.departure_lead_raw_speed if has_departure_lead else 0.0
|
||||
evidence = has_departure_lead and min(lead_speed, raw_lead_speed) > STOP_HOLD_SPEED_FLOOR
|
||||
distance = None
|
||||
if has_departure_lead:
|
||||
distance = self._guarded_distance(lead_plan.departure_lead_distance, lead_speed, dt)
|
||||
|
||||
if evidence:
|
||||
if self.departure_confirm_frames == 0:
|
||||
self.departure_distance_ref = distance
|
||||
self.departure_confirm_frames += 1
|
||||
else:
|
||||
self.departure_confirm_frames = 0
|
||||
self.departure_distance_ref = None
|
||||
if not has_departure_lead:
|
||||
self.held_lead_trusted = False
|
||||
self._reset_distance_guard()
|
||||
|
||||
growth = 0.0
|
||||
if self.departure_confirm_frames > 0 and self.departure_distance_ref is not None and distance is not None:
|
||||
growth = distance - self.departure_distance_ref
|
||||
|
||||
dwell_ready = self.departure_confirm_frames >= STOP_HOLD_EXIT_FRAMES
|
||||
departing_with_lead = has_departure_lead and dwell_ready and growth + 1e-9 >= STOP_HOLD_CREEP_DISTANCE
|
||||
departing_no_lead = (not lead_plan.lead_status and self.no_departure_lead_frames >= lead_confirm_frames
|
||||
and base_speed > STOP_HOLD_SPEED_FLOOR)
|
||||
|
||||
if departing_with_lead or departing_no_lead:
|
||||
trusted_departure = departing_with_lead and self.held_lead_trusted
|
||||
self.stop_hold = False
|
||||
self.launching = True
|
||||
self.departure_launching = trusted_departure
|
||||
self.leadless_departure = not trusted_departure
|
||||
self.held_lead = self.held_lead_track_id = -1
|
||||
self.held_lead_trusted = False
|
||||
self._reset_departure_confirmation()
|
||||
self.target_speed = min(v_ego, base_speed)
|
||||
else:
|
||||
self.target_speed = 0.0
|
||||
return self.stop_hold
|
||||
|
||||
departure_separation = lead_plan.departure_lead_separation if lead_plan.departure_lead >= 0 else math.inf
|
||||
stopped_lead_hold = lead_plan.has_nearly_stopped_lead and (
|
||||
lead_plan.departure_speed_ceiling < STOP_HOLD_SPEED_FLOOR
|
||||
or (self.braking_for_lead and departure_separation <= STOP_HOLD_MAX_LEAD_DISTANCE)
|
||||
)
|
||||
retained_stop_hold = not self.has_lead and math.isfinite(self.lead_speed_ceiling) and self.lead_speed_ceiling < STOP_HOLD_SPEED_FLOOR
|
||||
if not self.launching and v_ego < STOP_HOLD_EGO_SPEED and (
|
||||
stopped_lead_hold or retained_stop_hold
|
||||
):
|
||||
self.stop_hold = True
|
||||
self._set_held_lead(lead_plan)
|
||||
self.launching = self.departure_launching = self.leadless_departure = False
|
||||
self.lead_recovery = False
|
||||
self.recovery_accel_limit = None
|
||||
self.target_speed = 0.0
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _update_launch(self, lead_plan: LeadPlan, base_speed: float, v_ego: float, dt: float, lead_confirm_frames: int) -> None:
|
||||
if not self.launching:
|
||||
return
|
||||
if v_ego >= LAUNCH_END_SPEED:
|
||||
self.launching = self.departure_launching = self.leadless_departure = False
|
||||
return
|
||||
invalid_lead = lead_plan.lead_status and not self.has_lead
|
||||
renewed_stop = self.has_lead and lead_plan.has_nearly_stopped_lead
|
||||
if invalid_lead or renewed_stop:
|
||||
self.launching = self.departure_launching = self.leadless_departure = False
|
||||
if v_ego < STOP_HOLD_EGO_SPEED:
|
||||
self.stop_hold = True
|
||||
self._set_held_lead(lead_plan)
|
||||
self.target_speed = 0.0
|
||||
return
|
||||
self.releasing = True
|
||||
if self.departure_launching:
|
||||
self.target_speed = base_speed
|
||||
elif self.leadless_departure:
|
||||
self.target_speed = min(base_speed, max(self.target_speed or 0.0, v_ego) + TARGET_RELEASE_SLEW * dt)
|
||||
elif not self.has_lead and self.lead_loss_frames >= lead_confirm_frames:
|
||||
self.target_speed = min(base_speed, self.lead_speed_ceiling)
|
||||
else:
|
||||
launch_target = min(base_speed, v_ego + LAUNCH_TARGET_HEADROOM)
|
||||
self.target_speed = min(base_speed, max(self.target_speed or 0.0, launch_target) + TARGET_RELEASE_SLEW * dt)
|
||||
|
||||
def _update_recovery(self, ceiling: float, base_speed: float, v_ego: float, profile_max_accel: float, dt: float) -> None:
|
||||
if math.isfinite(self.filtered_lead_speed):
|
||||
recovery_speed = min(base_speed, self.filtered_lead_speed + LEAD_RECOVERY_HEADROOM)
|
||||
desired_accel_limit = float(np.clip(recovery_speed - v_ego, 0.0, profile_max_accel))
|
||||
else:
|
||||
desired_accel_limit = 0.0
|
||||
if self.filtered_lead_accel < BRAKING_ACCEL_THRESHOLD:
|
||||
# Avoid stacking the recovery slew on top of a braking lead.
|
||||
desired_accel_limit = profile_max_accel
|
||||
if self.recovery_accel_limit is None:
|
||||
self.recovery_accel_limit = profile_max_accel
|
||||
self.recovery_accel_limit = _slew(self.recovery_accel_limit, desired_accel_limit, LEAD_RECOVERY_ACCEL_SLEW, dt)
|
||||
|
||||
if ceiling <= self.target_speed - SPEED_DEADBAND:
|
||||
self.target_speed = max(ceiling, self.target_speed - LEAD_RECOVERY_DECEL_RATE * dt)
|
||||
self.restricting = True
|
||||
elif ceiling >= self.target_speed + SPEED_DEADBAND:
|
||||
self.target_speed = min(ceiling, self.target_speed + profile_max_accel * dt)
|
||||
self.releasing = True
|
||||
|
||||
def _update_target_law(self, lead_plan: LeadPlan, base_speed: float, v_ego: float, comfort_decel: float,
|
||||
profile_max_accel: float, dt: float, planner_speed: float,
|
||||
dropout_frames: int, was_restricting: bool) -> None:
|
||||
ceiling = min(base_speed, self.lead_speed_ceiling)
|
||||
new_recovery = self.has_lead and lead_plan.closing_speed <= 0.0
|
||||
still_within_dropout = not self.has_lead and self.lead_loss_frames <= dropout_frames
|
||||
self.lead_recovery = new_recovery or (self.lead_recovery and (self.has_lead or still_within_dropout))
|
||||
if self.lead_recovery:
|
||||
self._update_recovery(ceiling, base_speed, v_ego, profile_max_accel, dt)
|
||||
return
|
||||
|
||||
self.recovery_accel_limit = None
|
||||
synced_to_planner = ceiling < self.target_speed and planner_speed < self.target_speed
|
||||
if synced_to_planner:
|
||||
self.target_speed = max(planner_speed, self.target_speed - comfort_decel * dt)
|
||||
|
||||
if ceiling <= self.target_speed - SPEED_DEADBAND or (was_restricting and ceiling < self.target_speed):
|
||||
if not synced_to_planner:
|
||||
self.target_speed = max(ceiling, self.target_speed - comfort_decel * dt)
|
||||
self.restricting = True
|
||||
elif ceiling >= self.target_speed + SPEED_DEADBAND:
|
||||
self.target_speed = min(ceiling, self.target_speed + TARGET_RELEASE_SLEW * dt)
|
||||
self.releasing = True
|
||||
|
||||
def update(self, lead_plan: LeadPlan, base_speed: float, v_ego: float, comfort_decel: float, profile_max_accel: float,
|
||||
dt: float, lead_confirm_frames: int, dropout_frames: int, planner_speed: float,
|
||||
planner_accel: float, previous_mpc_source, previous_plan_accel: float) -> float:
|
||||
was_restricting = self.restricting
|
||||
was_braking_for_lead = self.braking_for_lead
|
||||
previous_lead = self.selected_lead
|
||||
previous_track_id = self.selected_lead_track_id
|
||||
holding_below_cruise = (not self.lead_recovery and self.target_speed is not None and math.isfinite(self.lead_speed_ceiling)
|
||||
and self.lead_speed_ceiling < base_speed - SPEED_DEADBAND
|
||||
and self.lead_speed_ceiling - v_ego < LEAD_RECOVERY_HEADROOM)
|
||||
self.restricting = self.releasing = False
|
||||
self._update_lead_bookkeeping(lead_plan, was_restricting or holding_below_cruise)
|
||||
lead_changed = not _same_lead(previous_lead, previous_track_id, self.selected_lead, self.selected_lead_track_id)
|
||||
if not self.has_lead or lead_changed:
|
||||
self._lead_frames = 0
|
||||
if self.braking_for_lead and lead_changed:
|
||||
self.braking_for_lead = False
|
||||
if not self.has_lead and self.lead_loss_frames == 1:
|
||||
self._dropout_was_braking = was_braking_for_lead and planner_accel <= BRAKING_ACCEL_THRESHOLD
|
||||
self._update_speed_ceiling(lead_confirm_frames, dropout_frames)
|
||||
self._update_speed_sample(lead_plan)
|
||||
self.required_decel = lead_plan.required_decel
|
||||
|
||||
self._lead_frames += int(self.has_lead)
|
||||
if (self._lead_frames >= lead_confirm_frames and math.isfinite(self.lead_speed_ceiling)
|
||||
and self.has_lead and planner_accel <= BRAKING_ACCEL_THRESHOLD):
|
||||
self.braking_for_lead = True
|
||||
elif not self.has_lead and self.lead_loss_frames >= lead_confirm_frames:
|
||||
self.braking_for_lead = False
|
||||
|
||||
if self.target_speed is None:
|
||||
self.target_speed = min(base_speed, v_ego)
|
||||
e2e_handoff = previous_mpc_source == LongitudinalPlanSource.e2e
|
||||
self.e2e_braking_handoff = e2e_handoff and math.isfinite(previous_plan_accel) and previous_plan_accel <= BRAKING_ACCEL_THRESHOLD
|
||||
stop_hold_reason = lead_plan.has_nearly_stopped_lead or (math.isfinite(self.lead_speed_ceiling) and self.lead_speed_ceiling < STOP_HOLD_SPEED_FLOOR)
|
||||
if v_ego < STOP_HOLD_EGO_SPEED and not stop_hold_reason:
|
||||
self.target_speed = min(base_speed, v_ego + LAUNCH_TARGET_HEADROOM)
|
||||
self.launching = True
|
||||
self.departure_launching = False
|
||||
elif self.e2e_braking_handoff and planner_accel > BRAKING_ACCEL_THRESHOLD:
|
||||
self.e2e_braking_handoff = False
|
||||
|
||||
self.target_speed = min(self.target_speed, base_speed)
|
||||
|
||||
if self._update_stop_hold(lead_plan, v_ego, base_speed, dt, lead_confirm_frames):
|
||||
return self.target_speed
|
||||
|
||||
self._update_launch(lead_plan, base_speed, v_ego, dt, lead_confirm_frames)
|
||||
if self.launching or self.stop_hold:
|
||||
return self.target_speed
|
||||
|
||||
self._update_target_law(lead_plan, base_speed, v_ego, comfort_decel, profile_max_accel, dt, planner_speed,
|
||||
dropout_frames, was_restricting)
|
||||
return self.target_speed
|
||||
+789
@@ -0,0 +1,789 @@
|
||||
import math
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from openpilot.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, LEAD_SAMPLE_FILTER_FRAMES, COMFORT_DECEL,
|
||||
LAUNCH_END_SPEED, LAUNCH_TARGET_HEADROOM, LEAD_RECOVERY_ACCEL_SLEW, LEAD_RECOVERY_HEADROOM, MPC_DECEL_JERK_COST_MULTIPLIER,
|
||||
STOP_GAP_RESERVE, STOP_HOLD_EXIT_FRAMES, TARGET_RELEASE_SLEW, AccelProfile, profile_accel_max, sanitize_profile,
|
||||
)
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.helpers import build_accel_ceiling
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.lead import _project_ego, calculate_lead_plan
|
||||
|
||||
|
||||
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(present=status, dRel=d_rel, vLead=v_lead_k, 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 get_lead_plan(controller, radar_state, v_ego: float, a_ego: float, profile: int):
|
||||
return calculate_lead_plan(radar_state, v_ego, a_ego, controller.delay, profile)
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
args.update(overrides)
|
||||
controller.profile = args.pop("profile")
|
||||
controller.enabled = args.pop("enabled")
|
||||
controller.update(radar_state or make_radar(), **args)
|
||||
lead_controller = controller.lead_controller
|
||||
return SimpleNamespace(
|
||||
target_speed=controller.output_v_target, active=controller.is_active, launching=lead_controller.launching,
|
||||
departure_launching=lead_controller.departure_launching, mpc_accel_max=controller.mpc_accel_max,
|
||||
cruise_accel_max=controller.cruise_accel_max, state=controller.state, selected_lead=controller.selected_lead,
|
||||
required_decel=controller.required_decel, stop_hold=lead_controller.stop_hold, lead_recovery=lead_controller.lead_recovery,
|
||||
recovery_accel_limit=lead_controller.recovery_accel_limit, lead_speed_ceiling=lead_controller.lead_speed_ceiling, restricting=lead_controller.restricting,
|
||||
releasing=lead_controller.releasing,
|
||||
)
|
||||
|
||||
|
||||
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.0, frames=6, track_id=-1):
|
||||
stopped = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.0, radar_track_id=track_id))
|
||||
result = None
|
||||
for _ in range(frames):
|
||||
result = update(controller, stopped, base_speed=base_speed, v_ego=v_ego)
|
||||
assert result.stop_hold
|
||||
return result
|
||||
|
||||
|
||||
class TestProfiles:
|
||||
@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 profile_accel_max(profile, speed) == expected
|
||||
|
||||
limits = [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 = (profile_accel_max(profile, speed) for profile in ACCEL_PROFILES)
|
||||
assert eco < normal < sport
|
||||
|
||||
def test_invalid_profile_defaults_to_normal(self):
|
||||
assert sanitize_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_confirm_frames)]
|
||||
result = results[-1]
|
||||
assert profile_accel_max(AccelProfile.sport, 10.0) > 0.30
|
||||
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_confirm_frames)][-1]
|
||||
eco = update(controller, v_ego=10.0, profile=AccelProfile.eco, stock_accel_max=1.20)
|
||||
|
||||
assert effective_accel_max(sport) == pytest.approx(profile_accel_max(AccelProfile.sport, 10.0))
|
||||
assert effective_accel_max(eco) == pytest.approx(profile_accel_max(AccelProfile.eco, 10.0))
|
||||
|
||||
def test_stock_limit_reduction_applies_immediately(self):
|
||||
controller = make_controller()
|
||||
for _ in range(controller.lead_confirm_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_confirm_frames + 10):
|
||||
clean = update(clean_controller, v_ego=10.0, stock_accel_max=1.5)
|
||||
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_lead_recovery_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(controller.lead_confirm_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.lead_controller.lead_recovery
|
||||
|
||||
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 profile_accel_max(AccelProfile.sport, 0.0) == ACCEL_MAX
|
||||
assert result.mpc_accel_max is None
|
||||
|
||||
|
||||
class TestBuildAccelCeiling:
|
||||
@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(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(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)
|
||||
|
||||
|
||||
class TestMpcCeilingIntegration:
|
||||
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.lead_controller.target_speed is None
|
||||
|
||||
def test_closing_on_a_lead_has_no_ceiling_regardless_of_planner_accel_sign(self):
|
||||
# Leave the lead MPC unconstrained while ego is closing.
|
||||
controller = make_controller()
|
||||
radar = restrictive_radar()
|
||||
for planner_accel in (-0.2, 0.2, -0.2):
|
||||
result = update(controller, radar, v_ego=10.0, planner_accel=planner_accel)
|
||||
assert result.mpc_accel_max is None
|
||||
assert not controller.lead_controller.lead_recovery
|
||||
|
||||
bypassed = update(controller, radar, planner_accel=-0.2, acc_selected=False)
|
||||
assert not bypassed.active and bypassed.mpc_accel_max is None
|
||||
|
||||
def test_eco_cruise_limit_remains_active_while_closing_on_a_lead(self):
|
||||
controller = make_controller()
|
||||
radar = make_radar(make_lead(status=True, d_rel=80.0, v_lead_k=19.25))
|
||||
result = update(controller, radar, base_speed=20.0, v_ego=20.0, profile=AccelProfile.eco, planner_accel=0.16,
|
||||
previous_mpc_source=LongitudinalPlanSource.cruise)
|
||||
|
||||
assert result.state == AccelControllerState.free
|
||||
assert result.mpc_accel_max is None
|
||||
assert result.cruise_accel_max == pytest.approx(profile_accel_max(AccelProfile.eco, 20.0))
|
||||
|
||||
def test_lead_cruise_limit_does_not_follow_mpc_source_or_accel_sign(self):
|
||||
controller = make_controller()
|
||||
expected = profile_accel_max(AccelProfile.eco, 20.0)
|
||||
inputs = (
|
||||
(LongitudinalPlanSource.cruise, 0.02, 19.9, 100),
|
||||
(LongitudinalPlanSource.lead0, -0.02, 20.1, -1),
|
||||
(LongitudinalPlanSource.cruise, -0.10, 19.9, 101),
|
||||
(LongitudinalPlanSource.lead0, -0.12, 20.1, -1),
|
||||
(LongitudinalPlanSource.lead1, 0.02, 20.1, -1),
|
||||
)
|
||||
|
||||
for source, planner_accel, lead_speed, track_id in inputs:
|
||||
radar = make_radar(make_lead(status=True, d_rel=150.0, v_lead_k=lead_speed, radar_track_id=track_id))
|
||||
result = update(controller, radar, base_speed=20.0, v_ego=20.0, profile=AccelProfile.eco,
|
||||
planner_accel=planner_accel, previous_mpc_source=source)
|
||||
assert result.cruise_accel_max == pytest.approx(expected)
|
||||
|
||||
def test_profile_ceiling_stays_continuous_while_a_lead_begins_pulling_away(self):
|
||||
controller = make_controller()
|
||||
for _ in range(controller.lead_confirm_frames):
|
||||
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 result.lead_recovery
|
||||
assert result.mpc_accel_max is not None
|
||||
profile_ceiling = profile_accel_max(AccelProfile.normal, 10.0)
|
||||
assert profile_ceiling - LEAD_RECOVERY_ACCEL_SLEW * DT_MDL - 1e-9 <= effective_accel_max(result) <= profile_ceiling + 1e-9
|
||||
|
||||
|
||||
class TestLead:
|
||||
def test_speed_ceiling_matches_stopping_energy_formula(self):
|
||||
controller = make_controller()
|
||||
lead = make_lead(status=True, d_rel=50.0, v_lead_k=8.0)
|
||||
result = get_lead_plan(controller, 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)
|
||||
usable_gap = max(safety_gap - STOP_GAP_RESERVE, 0.0)
|
||||
expected = v_lead + math.sqrt(2.0 * COMFORT_DECEL[AccelProfile.normal] * usable_gap)
|
||||
|
||||
assert result.speed_ceiling == pytest.approx(expected)
|
||||
|
||||
def test_profile_order_controls_approach_timing(self):
|
||||
radar = make_radar(make_lead(status=True, d_rel=50.0, v_lead_k=8.0))
|
||||
ceilings = [get_lead_plan(make_controller(), radar, 10.0, 0.0, profile).speed_ceiling for profile in ACCEL_PROFILES]
|
||||
assert ceilings[0] < ceilings[1] < ceilings[2]
|
||||
|
||||
@pytest.mark.parametrize("v_lead_k", (0.0, 8.0), ids=("stopped", "moving"))
|
||||
def test_reserve_is_flat_not_speed_or_decel_scaled(self, v_lead_k):
|
||||
lead = get_lead_plan(make_controller(), make_radar(make_lead(status=True, d_rel=60.0, v_lead_k=v_lead_k)),
|
||||
5.0, 0.0, AccelProfile.normal)
|
||||
comfort_decel = COMFORT_DECEL[AccelProfile.normal]
|
||||
safety_gap = (lead.departure_speed_ceiling - lead.departure_lead_speed) ** 2 / (2.0 * comfort_decel)
|
||||
usable_gap = (lead.speed_ceiling - lead.selected_lead_speed) ** 2 / (2.0 * comfort_decel)
|
||||
assert safety_gap - usable_gap == pytest.approx(STOP_GAP_RESERVE)
|
||||
assert lead.departure_speed_ceiling > lead.speed_ceiling
|
||||
|
||||
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 get_lead_plan(make_controller(), radar, 10.0, 0.0, AccelProfile.normal).selected_lead == 1
|
||||
|
||||
def test_departure_lead_prefers_nearer_lead_over_speed_governing_lead(self):
|
||||
radar = 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))
|
||||
result = get_lead_plan(make_controller(), radar, 0.0, 0.0, AccelProfile.normal)
|
||||
assert result.selected_lead == 1
|
||||
assert result.departure_lead == 0
|
||||
|
||||
@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)
|
||||
result = get_lead_plan(make_controller(), make_radar(lead), 10.0, 0.0, AccelProfile.normal)
|
||||
assert result.selected_lead == 0
|
||||
assert math.isfinite(result.speed_ceiling)
|
||||
|
||||
@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)
|
||||
result = get_lead_plan(make_controller(), make_radar(lead), 10.0, 0.0, AccelProfile.normal)
|
||||
assert result.selected_lead == -1
|
||||
assert result.lead_status
|
||||
assert math.isinf(result.speed_ceiling)
|
||||
|
||||
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()
|
||||
get_lead_plan(make_controller(), make_radar(lead), 10.0, 0.0, AccelProfile.normal)
|
||||
assert vars(lead) == before
|
||||
|
||||
|
||||
class TestTargetAndSpeedCeiling:
|
||||
def test_lead_recovery_accel_limit_ignores_a_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(controller.lead_confirm_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_restriction_target_speed_is_rate_limited(self):
|
||||
controller = make_controller()
|
||||
targets = [update(controller, restrictive_radar()).target_speed for _ in range(15)]
|
||||
max_step = COMFORT_DECEL[AccelProfile.normal] * DT_MDL
|
||||
|
||||
steps = -np.diff(targets[1:])
|
||||
assert np.all(steps <= max_step + 1e-9)
|
||||
assert controller.state == AccelControllerState.restrict
|
||||
assert targets[-1] < targets[1]
|
||||
|
||||
@pytest.mark.parametrize("previous_mpc_source", (None, LongitudinalPlanSource.cruise, LongitudinalPlanSource.lead0))
|
||||
def test_target_speed_syncs_down_to_planner_speed_regardless_of_previous_mpc_source(self, previous_mpc_source):
|
||||
controller = make_controller()
|
||||
for _ in range(15):
|
||||
restricted = update(controller, restrictive_radar())
|
||||
planner_speed = restricted.target_speed - 2.0
|
||||
|
||||
synced = update(controller, restrictive_radar(), previous_mpc_source=previous_mpc_source, planner_speed=planner_speed,
|
||||
planner_accel=-0.2)
|
||||
assert synced.target_speed == pytest.approx(restricted.target_speed - COMFORT_DECEL[AccelProfile.normal] * DT_MDL)
|
||||
|
||||
def test_short_dropout_holds_then_releases_at_a_bounded_rate(self):
|
||||
controller = make_controller()
|
||||
for _ in range(15):
|
||||
restricted = update(controller, restrictive_radar())
|
||||
|
||||
held = [update(controller) for _ in range(controller.dropout_frames - 1)]
|
||||
assert all(result.target_speed <= restricted.target_speed + 1e-9 for result in held)
|
||||
|
||||
released = [update(controller) for _ in range(60)]
|
||||
targets = [restricted.target_speed, *(result.target_speed for result in released)]
|
||||
assert np.max(np.diff(targets)) <= TARGET_RELEASE_SLEW * DT_MDL + 1e-9
|
||||
assert released[-1].target_speed >= 25.0 - 0.15 - 1e-9
|
||||
assert released[-1].state == AccelControllerState.free
|
||||
|
||||
def test_restricting_lead_dropout_coasts_before_release(self):
|
||||
controller = make_controller()
|
||||
for _ in range(15):
|
||||
update(controller, restrictive_radar(), planner_accel=-0.5)
|
||||
|
||||
coast = update(controller, planner_accel=-0.5)
|
||||
|
||||
assert coast.cruise_accel_max == 0.0
|
||||
assert coast.mpc_accel_max is not None
|
||||
assert effective_accel_max(coast) == pytest.approx(profile_accel_max(AccelProfile.normal, 10.0))
|
||||
|
||||
ceilings = [coast.cruise_accel_max]
|
||||
ceilings.extend(update(controller, planner_accel=-0.5).cruise_accel_max for _ in range(controller.dropout_frames - 1))
|
||||
assert ceilings == sorted(ceilings)
|
||||
assert ceilings[-1] == pytest.approx(profile_accel_max(AccelProfile.normal, 10.0))
|
||||
|
||||
def test_should_coast_on_dropout_lifecycle(self):
|
||||
controller = make_controller()
|
||||
for _ in range(15):
|
||||
update(controller, restrictive_radar(), planner_accel=-0.5)
|
||||
|
||||
update(controller, planner_accel=-0.5)
|
||||
assert controller.lead_controller.should_coast_on_dropout
|
||||
|
||||
update(controller, restrictive_radar(), planner_accel=0.2)
|
||||
assert not controller.lead_controller.should_coast_on_dropout
|
||||
update(controller, planner_accel=0.2)
|
||||
assert not controller.lead_controller.should_coast_on_dropout
|
||||
|
||||
controller.reset()
|
||||
assert not controller.lead_controller.should_coast_on_dropout
|
||||
|
||||
def test_e2e_braking_handoff_clears_when_braking_ends(self):
|
||||
controller = make_controller()
|
||||
update(controller, previous_mpc_source=LongitudinalPlanSource.e2e, previous_plan_accel=-1.0, planner_accel=-0.5)
|
||||
assert controller.lead_controller.e2e_braking_handoff
|
||||
|
||||
still_braking = update(controller, planner_accel=-0.12)
|
||||
assert controller.lead_controller.e2e_braking_handoff
|
||||
assert still_braking.mpc_accel_max is None
|
||||
|
||||
coasting = update(controller, planner_accel=-0.10)
|
||||
assert not controller.lead_controller.e2e_braking_handoff
|
||||
assert coasting.mpc_accel_max is not None
|
||||
|
||||
|
||||
class TestMatchedLead:
|
||||
def test_recovery_accel_limit_unthrottled_when_ego_well_below_lead_speed(self):
|
||||
controller = make_controller()
|
||||
radar = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=8.0))
|
||||
for _ in range(20):
|
||||
update(controller, radar, v_ego=10.0, planner_accel=-0.2)
|
||||
result = update(controller, radar, v_ego=3.0, planner_accel=-0.2)
|
||||
|
||||
assert result.lead_recovery
|
||||
assert result.recovery_accel_limit == pytest.approx(profile_accel_max(AccelProfile.normal, 3.0))
|
||||
assert effective_accel_max(result) == pytest.approx(profile_accel_max(AccelProfile.normal, 3.0))
|
||||
|
||||
def test_recovery_accel_limit_throttles_toward_recovery_headroom_when_near_lead_speed(self):
|
||||
controller = make_controller()
|
||||
slow_radar = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=3.0))
|
||||
for _ in range(20):
|
||||
update(controller, slow_radar, v_ego=5.0, planner_accel=-0.2)
|
||||
for _ in range(40):
|
||||
result = update(controller, slow_radar, v_ego=3.0, planner_accel=-0.2)
|
||||
|
||||
assert result.lead_recovery
|
||||
assert result.recovery_accel_limit == pytest.approx(LEAD_RECOVERY_HEADROOM)
|
||||
assert result.recovery_accel_limit < profile_accel_max(AccelProfile.normal, 3.0)
|
||||
|
||||
def test_recovery_accel_limit_slew_bounded_and_independent_of_planner_accel_sign(self):
|
||||
braking_controller, accelerating_controller = make_controller(), make_controller()
|
||||
radar = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=8.0))
|
||||
for controller in (braking_controller, accelerating_controller):
|
||||
for _ in range(20):
|
||||
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)
|
||||
before = braking_controller.lead_controller.recovery_accel_limit
|
||||
assert accelerating_controller.lead_controller.recovery_accel_limit == pytest.approx(before)
|
||||
|
||||
braking = update(braking_controller, radar, v_ego=8.0, planner_accel=-0.2)
|
||||
accelerating = update(accelerating_controller, radar, v_ego=8.0, planner_accel=0.2)
|
||||
|
||||
assert abs(braking.recovery_accel_limit - before) <= LEAD_RECOVERY_ACCEL_SLEW * DT_MDL + 1e-9
|
||||
assert accelerating.recovery_accel_limit == pytest.approx(braking.recovery_accel_limit)
|
||||
|
||||
|
||||
class TestLaunchAndDeparture:
|
||||
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 + TARGET_RELEASE_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) <= TARGET_RELEASE_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_departure_launch_keeps_profile_ceiling_through_lead_recovery_transition(self):
|
||||
controller = make_controller()
|
||||
enter_stop_hold(controller, base_speed=12.0, track_id=100)
|
||||
for frame in range(STOP_HOLD_EXIT_FRAMES):
|
||||
departing = make_radar(make_lead(status=True, d_rel=6.5 + 0.1 * frame, v_lead_k=5.0, radar_track_id=100))
|
||||
launching = update(controller, departing, base_speed=12.0, v_ego=0.1, profile=AccelProfile.eco, planner_accel=1.4)
|
||||
|
||||
exited = update(controller, departing, base_speed=12.0, v_ego=LAUNCH_END_SPEED, profile=AccelProfile.eco, planner_accel=1.4)
|
||||
|
||||
assert launching.departure_launching and effective_accel_max(launching) == pytest.approx(profile_accel_max(AccelProfile.eco, 0.1))
|
||||
profile_ceiling = profile_accel_max(AccelProfile.eco, LAUNCH_END_SPEED)
|
||||
assert exited.lead_recovery
|
||||
assert profile_ceiling - LEAD_RECOVERY_ACCEL_SLEW * DT_MDL <= effective_accel_max(exited) <= profile_ceiling
|
||||
|
||||
def test_e2e_braking_handoff_arms_only_on_seed_frame_from_previous_plan_accel(self):
|
||||
armed = make_controller()
|
||||
update(armed, base_speed=20.0, v_ego=15.0, previous_mpc_source=LongitudinalPlanSource.e2e,
|
||||
previous_plan_accel=-1.0, planner_accel=0.5)
|
||||
assert armed.lead_controller.e2e_braking_handoff
|
||||
|
||||
not_armed = make_controller()
|
||||
update(not_armed, base_speed=20.0, v_ego=15.0, previous_mpc_source=LongitudinalPlanSource.e2e,
|
||||
previous_plan_accel=0.5, planner_accel=-1.0)
|
||||
assert not not_armed.lead_controller.e2e_braking_handoff
|
||||
|
||||
def test_stop_hold_needs_four_confirmed_departure_frames_with_real_radar(self):
|
||||
controller = make_controller()
|
||||
held = enter_stop_hold(controller, track_id=100)
|
||||
assert held.stop_hold and held.target_speed == 0.0 and held.mpc_accel_max is None
|
||||
|
||||
results = [update(controller, make_radar(make_lead(status=True, d_rel=6.0 + (frame + 1) * 0.1, v_lead_k=2.0, radar_track_id=100)),
|
||||
base_speed=8.0, v_ego=0.1) for frame in range(STOP_HOLD_EXIT_FRAMES + 4)]
|
||||
launch_index = next(index for index, result in enumerate(results) if result.launching)
|
||||
|
||||
assert launch_index == STOP_HOLD_EXIT_FRAMES - 1
|
||||
assert all(result.stop_hold and not result.launching for result in results[:launch_index])
|
||||
assert results[launch_index].departure_launching
|
||||
assert results[launch_index].target_speed == 8.0
|
||||
|
||||
def test_renewed_stop_mid_launch_aborts_back_to_stop_hold(self):
|
||||
controller = make_controller()
|
||||
enter_stop_hold(controller, track_id=100)
|
||||
for frame in range(STOP_HOLD_EXIT_FRAMES + 2):
|
||||
moving = make_radar(make_lead(status=True, d_rel=6.0 + (frame + 1) * 0.1, v_lead_k=2.0, radar_track_id=100))
|
||||
update(controller, moving, base_speed=8.0, v_ego=0.1)
|
||||
assert controller.lead_controller.launching and controller.lead_controller.departure_launching
|
||||
|
||||
renewed_stop_lead = make_radar(make_lead(status=True, d_rel=6.5, v_lead_k=0.05, radar_track_id=100))
|
||||
result = update(controller, renewed_stop_lead, base_speed=8.0, v_ego=0.1)
|
||||
assert result.stop_hold
|
||||
assert result.target_speed == 0.0
|
||||
assert not result.launching
|
||||
|
||||
def test_invalid_lead_mid_launch_aborts_launch_without_reentering_stop_hold(self):
|
||||
controller = make_controller()
|
||||
enter_stop_hold(controller, track_id=100)
|
||||
for frame in range(STOP_HOLD_EXIT_FRAMES + 2):
|
||||
moving = make_radar(make_lead(status=True, d_rel=6.0 + (frame + 1) * 0.1, v_lead_k=2.0, radar_track_id=100))
|
||||
update(controller, moving, base_speed=8.0, v_ego=0.5)
|
||||
assert controller.lead_controller.launching
|
||||
|
||||
invalid = make_radar(make_lead(status=True, d_rel=math.nan, v_lead_k=2.0))
|
||||
result = update(controller, invalid, base_speed=8.0, v_ego=0.5)
|
||||
assert not result.stop_hold
|
||||
assert not result.launching
|
||||
assert result.active
|
||||
|
||||
def test_genuine_departure_survives_lead_slot_and_track_flicker(self):
|
||||
controller = make_controller()
|
||||
enter_stop_hold(controller, track_id=100)
|
||||
results = []
|
||||
for frame in range(STOP_HOLD_EXIT_FRAMES + 4):
|
||||
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)
|
||||
radar = make_radar(moving, secondary) if frame % 2 == 0 else make_radar(secondary, moving)
|
||||
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 launch_index == STOP_HOLD_EXIT_FRAMES - 1
|
||||
assert all(result.stop_hold for result in results[:launch_index])
|
||||
assert results[-1].launching and results[-1].departure_launching
|
||||
|
||||
def test_confirmed_creep_departure_departs_within_budget(self):
|
||||
controller = make_controller()
|
||||
enter_stop_hold(controller)
|
||||
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(not result.stop_hold for result in results[launch_index:])
|
||||
|
||||
def test_departure_dropout_holds_without_resurrecting_stop_hold(self):
|
||||
controller = make_controller()
|
||||
enter_stop_hold(controller)
|
||||
for frame in range(STOP_HOLD_EXIT_FRAMES + 2):
|
||||
moving = make_radar(make_lead(status=True, d_rel=6.0 + (frame + 1) * 0.1, v_lead_k=2.0))
|
||||
update(controller, moving, base_speed=8.0, v_ego=0.1)
|
||||
assert controller.lead_controller.launching
|
||||
|
||||
dropout = [update(controller, base_speed=8.0, v_ego=0.1) for _ in range(controller.lead_confirm_frames + 5)]
|
||||
assert all(not result.stop_hold for result in dropout)
|
||||
assert all(result.launching for result in dropout)
|
||||
|
||||
def test_departure_launch_expires_after_bounded_radar_staleness(self):
|
||||
controller = make_controller()
|
||||
enter_stop_hold(controller, track_id=100)
|
||||
for frame in range(STOP_HOLD_EXIT_FRAMES + 2):
|
||||
moving = make_radar(make_lead(status=True, d_rel=6.0 + (frame + 1) * 0.1, v_lead_k=2.0, radar_track_id=100))
|
||||
update(controller, moving, base_speed=8.0, v_ego=0.1)
|
||||
assert controller.lead_controller.departure_launching
|
||||
|
||||
held = [update(controller, moving, base_speed=8.0, v_ego=0.1, radar_fresh=False)
|
||||
for _ in range(controller.dropout_frames - 1)]
|
||||
expired = update(controller, moving, base_speed=8.0, v_ego=0.1, radar_fresh=False)
|
||||
|
||||
assert all(result.active and result.departure_launching for result in held)
|
||||
assert not expired.active
|
||||
assert not controller.lead_controller.departure_launching
|
||||
assert controller.update_should_stop(True)
|
||||
|
||||
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.stop_hold
|
||||
assert missing.target_speed == 0.0
|
||||
assert missing.mpc_accel_max is None
|
||||
|
||||
def test_leadless_departure_does_not_override_should_stop(self):
|
||||
controller = make_controller()
|
||||
enter_stop_hold(controller)
|
||||
for _ in range(controller.lead_confirm_frames):
|
||||
result = update(controller, base_speed=8.0, v_ego=0.0)
|
||||
|
||||
assert result.launching
|
||||
assert not result.departure_launching
|
||||
assert controller.update_should_stop(True)
|
||||
|
||||
def test_invalid_lead_does_not_release_stop_hold(self):
|
||||
controller = make_controller()
|
||||
enter_stop_hold(controller, track_id=100)
|
||||
invalid = make_radar(make_lead(status=True, d_rel=math.nan, v_lead_k=2.0, radar_track_id=100))
|
||||
|
||||
results = [update(controller, invalid, base_speed=8.0, v_ego=0.31) for _ in range(controller.lead_confirm_frames + 2)]
|
||||
first_absent = update(controller, base_speed=8.0, v_ego=0.31)
|
||||
|
||||
assert all(result.stop_hold and result.target_speed == 0.0 for result in results)
|
||||
assert first_absent.stop_hold and first_absent.target_speed == 0.0
|
||||
assert controller.update_should_stop(False)
|
||||
|
||||
def test_vision_lead_departure_does_not_override_should_stop(self):
|
||||
controller = make_controller()
|
||||
enter_stop_hold(controller)
|
||||
for frame in range(STOP_HOLD_EXIT_FRAMES):
|
||||
moving = make_radar(make_lead(status=True, d_rel=6.0 + 0.1 * frame, v_lead_k=2.0))
|
||||
result = update(controller, moving, base_speed=8.0, v_ego=0.0)
|
||||
|
||||
assert result.launching
|
||||
assert not result.departure_launching
|
||||
assert controller.update_should_stop(True)
|
||||
|
||||
def test_replacement_lead_cannot_claim_a_confirmed_departure(self):
|
||||
controller = make_controller()
|
||||
stopped = make_lead(status=True, d_rel=6.0, v_lead_k=0.0, radar_track_id=100)
|
||||
moving = make_lead(status=True, d_rel=20.0, v_lead_k=2.0, radar_track_id=200)
|
||||
for _ in range(6):
|
||||
held = update(controller, make_radar(stopped, moving), base_speed=8.0, v_ego=0.0, planner_accel=-0.5)
|
||||
assert held.stop_hold
|
||||
|
||||
for _ in range(STOP_HOLD_EXIT_FRAMES):
|
||||
moving.dRel += 0.1
|
||||
result = update(controller, make_radar(lead_two=moving), base_speed=8.0, v_ego=0.0, planner_accel=-0.5)
|
||||
|
||||
assert result.launching
|
||||
assert not result.departure_launching
|
||||
assert controller.update_should_stop(True)
|
||||
|
||||
def test_moving_departure_does_not_reenter_stop_hold_once_launching(self):
|
||||
controller = make_controller()
|
||||
enter_stop_hold(controller, track_id=100)
|
||||
distance = 6.0
|
||||
results = []
|
||||
for speed in (0.81, 0.82, 0.83, 0.84, 0.79, 0.76, 0.74, 0.72, 0.70):
|
||||
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(not result.stop_hold 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_far_stopped_lead_should_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(not result.stop_hold for result in results)
|
||||
|
||||
def test_fast_speed_glitch_without_distance_progress_should_stay_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)
|
||||
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 + 2)]
|
||||
|
||||
assert all(result.stop_hold and result.target_speed == 0.0 and not result.launching for result in results)
|
||||
|
||||
|
||||
class TestFreshnessAndReset:
|
||||
def test_frozen_output_during_a_single_non_fresh_radar_frame(self):
|
||||
controller = make_controller()
|
||||
radar = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=8.0, a_lead_k=-0.5))
|
||||
for _ in range(15):
|
||||
fresh = update(controller, radar, v_ego=10.0, planner_accel=-0.2)
|
||||
|
||||
held = update(controller, radar, v_ego=10.0, planner_accel=-0.2, radar_fresh=False)
|
||||
assert held.target_speed == pytest.approx(fresh.target_speed)
|
||||
assert held.state == fresh.state
|
||||
assert held.selected_lead == fresh.selected_lead
|
||||
assert effective_accel_max(held) == pytest.approx(effective_accel_max(fresh))
|
||||
|
||||
def test_stale_timeout_fully_resets_live_state(self):
|
||||
controller = make_controller()
|
||||
radar = restrictive_radar()
|
||||
for _ in range(15):
|
||||
update(controller, radar)
|
||||
held = [update(controller, radar_fresh=False) for _ in range(controller.radar_stale_frames - 1)]
|
||||
timed_out = update(controller, radar_fresh=False)
|
||||
|
||||
assert all(result.active 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
|
||||
assert controller.lead_controller.target_speed is None
|
||||
|
||||
def test_acc_bypass_starts_a_new_stale_episode(self):
|
||||
controller = make_controller()
|
||||
radar = restrictive_radar()
|
||||
update(controller, radar)
|
||||
for _ in range(controller.radar_stale_frames - 1):
|
||||
update(controller, radar, radar_fresh=False)
|
||||
|
||||
bypassed = update(controller, radar, acc_selected=False, radar_fresh=False)
|
||||
handoff = update(controller, radar, radar_fresh=False, previous_mpc_source=LongitudinalPlanSource.e2e,
|
||||
previous_plan_accel=-0.5, planner_accel=0.5)
|
||||
|
||||
assert not bypassed.active
|
||||
assert handoff.active
|
||||
assert controller.lead_controller.e2e_braking_handoff
|
||||
|
||||
@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(15):
|
||||
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.lead_controller.target_speed is None
|
||||
|
||||
def test_acc_bypass_does_not_retain_state_for_live_actuation(self):
|
||||
controller = make_controller()
|
||||
for _ in range(20):
|
||||
bypassed = update(controller, restrictive_radar(), acc_selected=False)
|
||||
assert not bypassed.active
|
||||
assert controller.lead_controller.target_speed is None
|
||||
live = update(controller)
|
||||
|
||||
assert live.active
|
||||
assert 10.0 < live.target_speed <= 10.0 + TARGET_RELEASE_SLEW * DT_MDL + 1e-9
|
||||
|
||||
def test_explicit_reset_clears_lead_state(self):
|
||||
controller = make_controller()
|
||||
for _ in range(15):
|
||||
update(controller, restrictive_radar())
|
||||
controller._jerk_smoothing_blocked = True
|
||||
controller._required_decel_samples = [0.2]
|
||||
controller._required_decel_lead = controller._required_decel_lead_track_id = 1
|
||||
controller._lead_trend_warmup = True
|
||||
controller.reset()
|
||||
|
||||
assert not controller._jerk_smoothing_blocked
|
||||
assert controller._required_decel_samples == []
|
||||
assert controller._required_decel_lead == controller._required_decel_lead_track_id == -1
|
||||
assert not controller._lead_trend_warmup
|
||||
lead_controller = controller.lead_controller
|
||||
assert lead_controller.target_speed is None and lead_controller.recovery_accel_limit is None
|
||||
assert not lead_controller.stop_hold and not lead_controller.launching and not lead_controller.lead_recovery
|
||||
assert math.isinf(lead_controller.lead_speed_ceiling) and math.isinf(lead_controller.filtered_lead_speed)
|
||||
assert lead_controller.lead_speed_samples == [math.inf] * LEAD_SAMPLE_FILTER_FRAMES
|
||||
assert controller.state == AccelControllerState.inactive
|
||||
assert controller.selected_lead == controller.selected_lead_track_id == -1
|
||||
|
||||
|
||||
class TestJerkCostMultiplier:
|
||||
@pytest.mark.parametrize("replacement_track_id", (200, -1), ids=("radar-track", "vision-track"))
|
||||
def test_track_id_change_requires_new_history_before_jerk_smoothing(self, replacement_track_id):
|
||||
controller = make_controller()
|
||||
controller.state = AccelControllerState.restrict
|
||||
controller.selected_lead = 0
|
||||
controller.selected_lead_track_id = 100
|
||||
controller.required_decel = 0.2
|
||||
original = [controller.get_jerk_cost_multiplier(True, True, 1.0, False) for _ in range(4)]
|
||||
|
||||
controller.selected_lead_track_id = replacement_track_id
|
||||
replacement = [controller.get_jerk_cost_multiplier(True, True, 1.0, False) for _ in range(4)]
|
||||
|
||||
assert original == [MPC_DECEL_JERK_COST_MULTIPLIER] * 4
|
||||
assert replacement == [1.0, 1.0, 1.0, MPC_DECEL_JERK_COST_MULTIPLIER]
|
||||
assert controller._required_decel_samples == [0.2] * 4
|
||||
+613
@@ -0,0 +1,613 @@
|
||||
import inspect
|
||||
import math
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from openpilot.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, cruise_accel_max=None,
|
||||
state=AccelControllerState.free, selected_lead=-1,
|
||||
selected_lead_track_id=-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.cruise_accel_max = cruise_accel_max
|
||||
self.state = state
|
||||
self.selected_lead = selected_lead
|
||||
self.selected_lead_track_id = selected_lead_track_id
|
||||
self.launching = launching
|
||||
self.departure_launching = departure_launching
|
||||
self.lead_controller = SimpleNamespace(departure_launching=departure_launching, stop_hold=state == AccelControllerState.stopHold)
|
||||
self.required_decel = required_decel
|
||||
self.dt = DT_MDL
|
||||
self._jerk_smoothing_blocked = False
|
||||
self._required_decel_samples = []
|
||||
self._required_decel_long_samples = []
|
||||
self._required_decel_lead = -1
|
||||
self._required_decel_lead_track_id = -1
|
||||
self._lead_trend_warmup = False
|
||||
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, departure_authorized=True):
|
||||
return AccelController.update_should_stop(self, should_stop, departure_authorized)
|
||||
|
||||
|
||||
def planner_for_mpc_test(*, target_speed=15.0, active=True, is_e2e=False, mpc_accel_max=None,
|
||||
cruise_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._long_active_last_cycle = True
|
||||
planner.previous_plan_accel = 0.0
|
||||
planner.mpc_accel_seed = 0.0
|
||||
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,
|
||||
cruise_accel_max=cruise_accel_max,
|
||||
)
|
||||
return planner, is_e2e_calls
|
||||
|
||||
|
||||
def prepare_controller_mpc(planner, *, mpc_v_cruise=20.0, force_decel=False, stock_accel_max=ACCEL_MAX):
|
||||
configs = []
|
||||
sm = {
|
||||
"radarState": radar_state(),
|
||||
"controlsState": SimpleNamespace(forceDecel=force_decel),
|
||||
"carState": SimpleNamespace(vCruise=20.0, vEgo=10.0, aEgo=0.0),
|
||||
"selfdriveState": SimpleNamespace(personality=0),
|
||||
}
|
||||
planner.mpc.set_accel_controller_params = lambda *args: configs.append(args)
|
||||
is_e2e, target = planner.update_accel_controller(sm, mpc_v_cruise, True, stock_accel_max, False)
|
||||
assert len(configs) == 1
|
||||
return is_e2e, target, configs[0]
|
||||
|
||||
|
||||
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_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)
|
||||
assert mpc.cruise_accel_max(1.6) == 1.6
|
||||
|
||||
mpc.set_accel_controller_params(None, 1.0, 0.4)
|
||||
assert mpc.cruise_accel_max(1.6) == 0.4
|
||||
mpc.set_accel_controller_params(None, 1.0, 0.0)
|
||||
assert mpc.cruise_accel_max(1.6) == 0.0
|
||||
|
||||
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_accel_controller_hook_only_configures_mpc():
|
||||
radar = radar_state()
|
||||
planner, _ = planner_for_mpc_test(active=False)
|
||||
calls = []
|
||||
planner.mpc = SimpleNamespace(
|
||||
source=MpcLongitudinalPlanSource.cruise,
|
||||
last_solution_status=0,
|
||||
set_accel_controller_params=lambda accel_max, multiplier, cruise_accel_max: calls.append(
|
||||
("configure", accel_max, multiplier, cruise_accel_max)),
|
||||
set_weights=lambda constraint, personality: calls.append(("weights", constraint, personality)),
|
||||
set_cur_state=lambda speed, accel: calls.append(("state", speed, accel)),
|
||||
update=lambda radar_arg, target, *, personality: calls.append(("update", radar_arg, target, personality)),
|
||||
)
|
||||
sm = {
|
||||
"radarState": radar,
|
||||
"controlsState": SimpleNamespace(forceDecel=False),
|
||||
"carState": SimpleNamespace(vCruise=20.0, vEgo=10.0, aEgo=0.0),
|
||||
"selfdriveState": SimpleNamespace(personality=2),
|
||||
}
|
||||
is_e2e, target = planner.update_accel_controller(sm, 17.5, True, ACCEL_MAX, False)
|
||||
|
||||
assert not is_e2e and target == 17.5
|
||||
assert calls == [("configure", None, 1.0, None)]
|
||||
|
||||
|
||||
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, target, config = prepare_controller_mpc(planner)
|
||||
|
||||
assert not is_e2e
|
||||
assert len(mode_calls) == 1
|
||||
assert target == 15.0
|
||||
assert config == (ceiling, 1.0, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cruise_accel_max", (0.0, 0.3))
|
||||
def test_cruise_accel_ceiling_is_forwarded_to_mpc(cruise_accel_max):
|
||||
planner, _ = planner_for_mpc_test(cruise_accel_max=cruise_accel_max)
|
||||
_, _, config = prepare_controller_mpc(planner)
|
||||
assert config == (None, 1.0, cruise_accel_max)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("stock_accel_max", "expected"), ((1.2, 1.2), (-0.3, 0.0)))
|
||||
def test_controller_receives_stock_allow_throttle_ceiling(stock_accel_max, expected):
|
||||
planner, _ = planner_for_mpc_test()
|
||||
planner.allow_throttle = False
|
||||
prepare_controller_mpc(planner, stock_accel_max=stock_accel_max)
|
||||
|
||||
assert planner.accel_controller.update_kwargs["stock_accel_max"] == expected
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
_, target, config = prepare_controller_mpc(planner)
|
||||
|
||||
assert target == 20.0
|
||||
assert config == (None, 1.0, None)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
_, target, config = prepare_controller_mpc(planner)
|
||||
|
||||
assert target == 0.0
|
||||
assert config == (None, 1.0, None)
|
||||
|
||||
|
||||
@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)
|
||||
planner._accel_controller_actuating = active
|
||||
planner._radar_fresh_this_cycle = True
|
||||
planner.mpc = SimpleNamespace(last_solution_status=0)
|
||||
assert planner.update_should_stop(True) is expected
|
||||
assert planner.update_should_stop(False) is (active and not departure_launching)
|
||||
|
||||
|
||||
def test_stale_radar_cannot_authorize_departure():
|
||||
planner = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
|
||||
planner.accel_controller = ControllerStub(active=True, departure_launching=True)
|
||||
planner._accel_controller_actuating = True
|
||||
planner._radar_fresh_this_cycle = False
|
||||
planner.mpc = SimpleNamespace(last_solution_status=0)
|
||||
|
||||
assert planner.update_should_stop(True)
|
||||
|
||||
|
||||
@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, target, config = prepare_controller_mpc(planner)
|
||||
|
||||
assert returned_e2e is is_e2e
|
||||
assert len(mode_calls) == 1
|
||||
assert target == 20.0
|
||||
assert config == (None, 1.0, None)
|
||||
|
||||
|
||||
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)
|
||||
_, target, config = prepare_controller_mpc(planner, mpc_v_cruise=0.0, force_decel=True)
|
||||
|
||||
assert len(mode_calls) == 1
|
||||
assert target == 0.0
|
||||
assert config == (None, 1.0, None)
|
||||
|
||||
|
||||
def test_previous_mpc_failure_gets_one_stock_recovery_cycle_without_resetting_controller_state():
|
||||
ceiling = tuple(np.linspace(0.8, 0.4, N + 1))
|
||||
planner, mode_calls = planner_for_mpc_test(mpc_accel_max=ceiling, departure_launching=True)
|
||||
controller = planner.accel_controller
|
||||
planner.mpc.last_solution_status = 4
|
||||
|
||||
_, failed_target, failed_config = prepare_controller_mpc(planner)
|
||||
assert controller.reset_calls == 0
|
||||
assert controller.update_kwargs["acc_selected"]
|
||||
assert len(mode_calls) == 1
|
||||
assert failed_target == 20.0
|
||||
assert failed_config == (None, 1.0, None)
|
||||
assert planner.update_should_stop(True)
|
||||
|
||||
planner.mpc.last_solution_status = 0
|
||||
_, recovered_target, recovered_config = prepare_controller_mpc(planner)
|
||||
assert controller.reset_calls == 0
|
||||
assert len(mode_calls) == 2
|
||||
assert recovered_target == 15.0
|
||||
assert recovered_config == (ceiling, 1.0, None)
|
||||
assert not planner.update_should_stop(True)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
_, target, config = prepare_controller_mpc(planner)
|
||||
|
||||
assert target == 15.0
|
||||
assert config == (None, MPC_DECEL_JERK_COST_MULTIPLIER, None)
|
||||
|
||||
|
||||
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_config = prepare_controller_mpc(planner)
|
||||
controller = planner.accel_controller
|
||||
assert initial_config[1] == MPC_DECEL_JERK_COST_MULTIPLIER
|
||||
|
||||
controller.required_decel = MPC_DECEL_JERK_MAX_REQUIRED_DECEL
|
||||
_, _, ineligible_config = prepare_controller_mpc(planner)
|
||||
assert ineligible_config[1] == 1.0
|
||||
|
||||
controller.required_decel = 0.30
|
||||
_, _, flicker_config = prepare_controller_mpc(planner)
|
||||
assert flicker_config[1] == 1.0
|
||||
|
||||
controller.state = AccelControllerState.free
|
||||
controller.output_v_target = 20.0
|
||||
prepare_controller_mpc(planner)
|
||||
controller.state = AccelControllerState.restrict
|
||||
controller.output_v_target = 15.0
|
||||
_, _, rearmed_config = prepare_controller_mpc(planner)
|
||||
assert rearmed_config[1] == 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,
|
||||
)
|
||||
_, _, config = prepare_controller_mpc(planner)
|
||||
controller = planner.accel_controller
|
||||
multipliers = [config[1]]
|
||||
for required_decel in (0.20, 0.23, 0.25):
|
||||
controller.required_decel = required_decel
|
||||
_, _, config = prepare_controller_mpc(planner)
|
||||
multipliers.append(config[1])
|
||||
|
||||
assert multipliers == [MPC_DECEL_JERK_COST_MULTIPLIER] * 3 + [1.0]
|
||||
|
||||
controller.required_decel = 0.20
|
||||
_, _, config = prepare_controller_mpc(planner)
|
||||
assert config[1] == 1.0
|
||||
|
||||
controller.state = AccelControllerState.free
|
||||
controller.output_v_target = 20.0
|
||||
prepare_controller_mpc(planner)
|
||||
controller.state = AccelControllerState.restrict
|
||||
controller.output_v_target = 15.0
|
||||
controller.required_decel = 0.18
|
||||
_, _, config = prepare_controller_mpc(planner)
|
||||
assert config[1] == 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,
|
||||
)
|
||||
_, _, config = prepare_controller_mpc(planner)
|
||||
controller = planner.accel_controller
|
||||
multipliers = [config[1]]
|
||||
for required_decel in (0.24, 0.19, 0.22):
|
||||
controller.required_decel = required_decel
|
||||
_, _, config = prepare_controller_mpc(planner)
|
||||
multipliers.append(config[1])
|
||||
|
||||
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,
|
||||
)
|
||||
_, _, config = prepare_controller_mpc(planner)
|
||||
|
||||
assert config[1] == 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.previous_plan_accel = -1.1
|
||||
planner.v_desired_filter = SimpleNamespace(x=9.5)
|
||||
prepare_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["previous_plan_accel"] == -1.1
|
||||
assert received["radar_fresh"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("previous_plan_accel", "a_desired", "expected"), (
|
||||
(-1.1, 0.0, -1.1), (0.057, 1.032, 0.057), (0.4, -1.2, -1.2),
|
||||
))
|
||||
def test_e2e_to_acc_handoff_uses_one_sided_mpc_seed(previous_plan_accel, a_desired, expected):
|
||||
planner, _ = planner_for_mpc_test(mpc_source=MpcLongitudinalPlanSource.e2e)
|
||||
planner.previous_plan_accel = previous_plan_accel
|
||||
planner.a_desired = a_desired
|
||||
prepare_controller_mpc(planner)
|
||||
|
||||
assert planner.mpc_accel_seed == expected
|
||||
assert planner.a_desired == a_desired
|
||||
|
||||
|
||||
def test_disabled_controller_does_not_change_e2e_to_acc_seed():
|
||||
planner, _ = planner_for_mpc_test(active=False, mpc_source=MpcLongitudinalPlanSource.e2e)
|
||||
planner.accel_controller.enabled = False
|
||||
planner.previous_plan_accel = -1.1
|
||||
planner.a_desired = 0.0
|
||||
prepare_controller_mpc(planner)
|
||||
|
||||
assert planner.mpc_accel_seed == planner.a_desired == 0.0
|
||||
|
||||
|
||||
def test_inactive_previous_cycle_does_not_restore_an_old_e2e_brake_plan():
|
||||
planner, _ = planner_for_mpc_test(mpc_source=MpcLongitudinalPlanSource.e2e)
|
||||
planner._long_active_last_cycle = False
|
||||
planner.previous_plan_accel = -1.1
|
||||
planner.a_desired = 0.0
|
||||
prepare_controller_mpc(planner)
|
||||
|
||||
assert planner.mpc_accel_seed == planner.a_desired == 0.0
|
||||
assert math.isinf(planner.accel_controller.update_kwargs["previous_plan_accel"])
|
||||
|
||||
|
||||
def test_failed_e2e_solution_does_not_seed_the_next_mpc_cycle():
|
||||
planner, _ = planner_for_mpc_test(mpc_source=MpcLongitudinalPlanSource.e2e)
|
||||
planner.mpc.last_solution_status = 1
|
||||
planner.previous_plan_accel = -1.1
|
||||
planner.a_desired = 0.0
|
||||
prepare_controller_mpc(planner)
|
||||
|
||||
assert planner.mpc_accel_seed == planner.a_desired == 0.0
|
||||
assert math.isinf(planner.accel_controller.update_kwargs["previous_plan_accel"])
|
||||
|
||||
|
||||
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._long_active_last_cycle = False
|
||||
planner.previous_plan_accel = 0.0
|
||||
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,
|
||||
set_accel_controller_params=lambda *_args: None,
|
||||
)
|
||||
planner.is_e2e = lambda _sm: False
|
||||
planner.accel_controller = ControllerStub(target_speed=20.0, active=False)
|
||||
|
||||
sm = PlannerSM(100)
|
||||
for expected in (True, False):
|
||||
planner.update(sm)
|
||||
planner.update_accel_controller(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_accel_controller(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"}
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.constants import (
|
||||
LEAD_SAMPLE_FILTER_FRAMES, COMFORT_DECEL, DISTANCE_JUMP_CONFIRM_FRAMES, LEAD_DROPOUT_COAST_TIME, LEAD_RELEASE_CONFIRM_TIME,
|
||||
STOP_HOLD_EXIT_FRAMES, TARGET_RELEASE_SLEW, AccelProfile,
|
||||
)
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.lead import LeadPlan
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.lead_controller import LeadController
|
||||
|
||||
DT = DT_MDL
|
||||
COMFORT_DECEL_NORMAL = COMFORT_DECEL[AccelProfile.normal]
|
||||
PROFILE_MAX_ACCEL = 1.5
|
||||
|
||||
|
||||
def _frames(seconds: float) -> int:
|
||||
return math.ceil(seconds / DT)
|
||||
|
||||
|
||||
LEAD_CONFIRM_FRAMES = max(LEAD_SAMPLE_FILTER_FRAMES, _frames(LEAD_RELEASE_CONFIRM_TIME))
|
||||
DROPOUT_FRAMES = max(LEAD_CONFIRM_FRAMES, _frames(LEAD_DROPOUT_COAST_TIME))
|
||||
|
||||
|
||||
def _lead_plan(speed: float, distance: float, speed_ceiling: float = 0.0, closing_speed: float = 0.0,
|
||||
required_decel: float = 0.0, track_id: int = 1) -> LeadPlan:
|
||||
return LeadPlan(
|
||||
speed_ceiling=speed_ceiling, selected_lead=0, selected_lead_track_id=track_id, selected_lead_speed=speed, selected_lead_accel=0.0,
|
||||
departure_lead=0, departure_lead_track_id=track_id, departure_lead_speed=speed, departure_lead_distance=distance,
|
||||
departure_lead_raw_speed=speed,
|
||||
departure_lead_separation=distance, departure_speed_ceiling=speed_ceiling,
|
||||
closing_speed=closing_speed, required_decel=required_decel,
|
||||
has_nearly_stopped_lead=speed < 0.15, lead_status=True,
|
||||
)
|
||||
|
||||
|
||||
def _no_lead() -> LeadPlan:
|
||||
return LeadPlan(lead_status=False)
|
||||
|
||||
|
||||
def _run(lead_controller: LeadController, lead_plan: LeadPlan, base_speed: float, v_ego: float, planner_speed: float | None = None,
|
||||
planner_accel: float = 0.0, previous_mpc_source=None, previous_plan_accel: float = 0.0) -> float:
|
||||
return lead_controller.update(lead_plan, base_speed, v_ego, COMFORT_DECEL_NORMAL, PROFILE_MAX_ACCEL, DT,
|
||||
LEAD_CONFIRM_FRAMES, DROPOUT_FRAMES, v_ego if planner_speed is None else planner_speed,
|
||||
planner_accel, previous_mpc_source, previous_plan_accel)
|
||||
|
||||
|
||||
def test_repeated_stop_reseeds_departure_distance():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(20):
|
||||
_run(lead_controller, _lead_plan(0.0, 6.0), base_speed=8.0, v_ego=0.0)
|
||||
for frame in range(20):
|
||||
_run(lead_controller, _lead_plan(2.0, 6.0 + 2.0 * (frame + 1) * DT), base_speed=8.0, v_ego=min(3.5, frame * 0.3))
|
||||
|
||||
for _ in range(10):
|
||||
_run(lead_controller, _lead_plan(0.0, 3.0), base_speed=8.0, v_ego=0.0)
|
||||
assert lead_controller.stop_hold
|
||||
|
||||
released_frame = None
|
||||
for frame in range(60):
|
||||
_run(lead_controller, _lead_plan(0.2, 3.0 + 0.2 * (frame + 1) * DT), base_speed=8.0, v_ego=0.0)
|
||||
if not lead_controller.stop_hold:
|
||||
released_frame = frame
|
||||
break
|
||||
|
||||
assert released_frame is not None
|
||||
assert released_frame * DT <= 2.0
|
||||
|
||||
|
||||
def test_departure_dropout_reseeds_distance_guard():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(20):
|
||||
_run(lead_controller, _lead_plan(0.0, 6.0), base_speed=8.0, v_ego=0.0)
|
||||
_run(lead_controller, _no_lead(), base_speed=8.0, v_ego=0.0)
|
||||
|
||||
released_frame = None
|
||||
for frame in range(60):
|
||||
_run(lead_controller, _lead_plan(0.2, 3.0 + 0.2 * (frame + 1) * DT), base_speed=8.0, v_ego=0.0)
|
||||
if not lead_controller.stop_hold:
|
||||
released_frame = frame
|
||||
break
|
||||
|
||||
assert released_frame is not None
|
||||
assert released_frame * DT <= 2.0
|
||||
|
||||
|
||||
def test_departure_dropout_revokes_track_identity_trust():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(20):
|
||||
_run(lead_controller, _lead_plan(0.0, 6.0, track_id=100), base_speed=8.0, v_ego=0.0)
|
||||
|
||||
_run(lead_controller, _no_lead(), base_speed=8.0, v_ego=0.0)
|
||||
for frame in range(4):
|
||||
_run(lead_controller, _lead_plan(2.0, 20.0 + 0.1 * frame, speed_ceiling=8.0, track_id=100), base_speed=8.0, v_ego=0.0)
|
||||
|
||||
assert lead_controller.launching
|
||||
assert lead_controller.leadless_departure
|
||||
assert not lead_controller.departure_launching
|
||||
|
||||
|
||||
def test_range_discontinuity_revokes_track_identity_trust():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(20):
|
||||
_run(lead_controller, _lead_plan(0.0, 6.0, track_id=100), base_speed=8.0, v_ego=0.0)
|
||||
|
||||
for frame in range(DISTANCE_JUMP_CONFIRM_FRAMES + STOP_HOLD_EXIT_FRAMES + 2):
|
||||
_run(lead_controller, _lead_plan(2.0, 20.0 + 0.1 * frame, speed_ceiling=8.0, track_id=100), base_speed=8.0, v_ego=0.0)
|
||||
if not lead_controller.stop_hold:
|
||||
break
|
||||
|
||||
assert lead_controller.launching
|
||||
assert lead_controller.leadless_departure
|
||||
assert not lead_controller.departure_launching
|
||||
|
||||
|
||||
def test_fast_lead_speed_requires_full_departure_distance():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(20):
|
||||
_run(lead_controller, _lead_plan(0.0, 6.0), base_speed=8.0, v_ego=0.0)
|
||||
|
||||
for distance in (6.00, 6.01, 6.02, 6.03):
|
||||
target = _run(lead_controller, _lead_plan(1.0, distance), base_speed=8.0, v_ego=0.0)
|
||||
|
||||
assert lead_controller.stop_hold
|
||||
assert target == 0.0
|
||||
|
||||
|
||||
def test_lead_disappearance_releases_stop_hold_monotonically():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(20):
|
||||
_run(lead_controller, _lead_plan(0.0, 6.0), base_speed=8.0, v_ego=0.0)
|
||||
|
||||
targets = [_run(lead_controller, _no_lead(), base_speed=8.0, v_ego=0.0) for _ in range(DROPOUT_FRAMES + 10)]
|
||||
first_release = next(index for index, target in enumerate(targets) if target > 0.0)
|
||||
release_targets = targets[first_release:]
|
||||
|
||||
steps = [after - before for before, after in zip(release_targets[:-1], release_targets[1:], strict=True)]
|
||||
assert all(0.0 <= step <= TARGET_RELEASE_SLEW * DT + 1e-9 for step in steps)
|
||||
|
||||
|
||||
def test_no_lead_departure_stays_bounded_when_lead_returns():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(20):
|
||||
_run(lead_controller, _lead_plan(0.0, 6.0), base_speed=8.0, v_ego=0.0)
|
||||
for _ in range(LEAD_CONFIRM_FRAMES):
|
||||
_run(lead_controller, _no_lead(), base_speed=8.0, v_ego=0.0)
|
||||
|
||||
before = lead_controller.target_speed
|
||||
target = _run(lead_controller, _lead_plan(2.0, 6.0, speed_ceiling=8.0), base_speed=8.0, v_ego=0.5)
|
||||
|
||||
assert target <= max(before, 0.5) + TARGET_RELEASE_SLEW * DT + 1e-9
|
||||
|
||||
|
||||
def test_leadless_stop_release_never_exceeds_current_base_speed():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(20):
|
||||
_run(lead_controller, _lead_plan(0.0, 6.0), base_speed=8.0, v_ego=0.0)
|
||||
|
||||
targets = [_run(lead_controller, _no_lead(), base_speed=0.5, v_ego=4.0) for _ in range(LEAD_CONFIRM_FRAMES)]
|
||||
|
||||
assert max(targets) <= 0.5
|
||||
|
||||
|
||||
def test_retained_speed_ceiling_does_not_bind_a_moving_replacement():
|
||||
lead_controller = LeadController()
|
||||
_run(lead_controller, _lead_plan(0.0, 5.0, speed_ceiling=0.1, track_id=100), base_speed=8.0, v_ego=0.4)
|
||||
|
||||
_run(lead_controller, _lead_plan(2.0, 20.0, speed_ceiling=8.0, track_id=200), base_speed=8.0, v_ego=0.2)
|
||||
|
||||
assert lead_controller.lead_speed_ceiling == pytest.approx(0.1)
|
||||
assert not lead_controller.stop_hold
|
||||
|
||||
|
||||
def test_braking_state_does_not_cross_confirmed_track_replacement():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(LEAD_CONFIRM_FRAMES + 2):
|
||||
_run(lead_controller, _lead_plan(8.0, 20.0, speed_ceiling=5.0, track_id=10), base_speed=15.0, v_ego=10.0, planner_accel=-0.5)
|
||||
assert lead_controller.braking_for_lead
|
||||
|
||||
_run(lead_controller, _lead_plan(0.0, 25.0, speed_ceiling=4.0, track_id=99), base_speed=8.0, v_ego=0.0, planner_accel=-0.5)
|
||||
|
||||
assert not lead_controller.stop_hold
|
||||
|
||||
|
||||
def test_braking_state_does_not_cross_vision_slot_replacement():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(LEAD_CONFIRM_FRAMES + 2):
|
||||
_run(lead_controller, _lead_plan(8.0, 20.0, speed_ceiling=5.0, track_id=-1), base_speed=15.0, v_ego=10.0, planner_accel=-0.5)
|
||||
assert lead_controller.braking_for_lead
|
||||
|
||||
replacement = _lead_plan(0.0, 25.0, speed_ceiling=4.0, track_id=-1)._replace(selected_lead=1, departure_lead=1)
|
||||
_run(lead_controller, replacement, base_speed=8.0, v_ego=0.0, planner_accel=-0.5)
|
||||
|
||||
assert not lead_controller.stop_hold
|
||||
|
||||
|
||||
def test_radar_track_keeps_braking_confirmation_across_slots():
|
||||
lead_controller = LeadController()
|
||||
for frame in range(LEAD_CONFIRM_FRAMES + 2):
|
||||
plan = _lead_plan(0.0, 7.0, speed_ceiling=0.8, track_id=100)
|
||||
if frame % 2:
|
||||
plan = plan._replace(selected_lead=1, departure_lead=1)
|
||||
_run(lead_controller, plan, base_speed=8.0, v_ego=0.0, planner_accel=-0.5)
|
||||
|
||||
assert lead_controller.braking_for_lead
|
||||
assert lead_controller.stop_hold
|
||||
|
||||
|
||||
def test_vision_slot_churn_releases_conservatively():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(20):
|
||||
_run(lead_controller, _lead_plan(0.0, 6.0, track_id=-1), base_speed=8.0, v_ego=0.0)
|
||||
|
||||
for frame in range(STOP_HOLD_EXIT_FRAMES + 2):
|
||||
plan = _lead_plan(2.0, 6.0 + 0.1 * (frame + 1), speed_ceiling=8.0, track_id=-1)
|
||||
if frame % 2:
|
||||
plan = plan._replace(selected_lead=1, departure_lead=1)
|
||||
_run(lead_controller, plan, base_speed=8.0, v_ego=0.0)
|
||||
if not lead_controller.stop_hold:
|
||||
break
|
||||
|
||||
assert lead_controller.launching
|
||||
assert lead_controller.leadless_departure
|
||||
assert not lead_controller.departure_launching
|
||||
|
||||
|
||||
def test_radar_track_dropout_keeps_braking_context():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(LEAD_CONFIRM_FRAMES + 2):
|
||||
_run(lead_controller, _lead_plan(8.0, 20.0, speed_ceiling=5.0, track_id=10), base_speed=15.0, v_ego=10.0, planner_accel=-0.5)
|
||||
assert lead_controller.braking_for_lead
|
||||
|
||||
_run(lead_controller, _no_lead(), base_speed=15.0, v_ego=10.0, planner_accel=-0.5)
|
||||
|
||||
assert lead_controller.should_coast_on_dropout
|
||||
|
||||
|
||||
def test_range_replacement_starts_a_new_departure_baseline():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(20):
|
||||
_run(lead_controller, _lead_plan(0.0, 6.0), base_speed=8.0, v_ego=0.0)
|
||||
for _ in range(2):
|
||||
_run(lead_controller, _lead_plan(0.2, 6.0), base_speed=8.0, v_ego=0.0)
|
||||
|
||||
released_frame = None
|
||||
for frame in range(60):
|
||||
distance = 3.0 + 0.2 * frame * DT
|
||||
_run(lead_controller, _lead_plan(0.2, distance), base_speed=8.0, v_ego=0.0)
|
||||
if not lead_controller.stop_hold:
|
||||
released_frame = frame
|
||||
break
|
||||
|
||||
assert released_frame is not None
|
||||
assert released_frame * DT <= 2.0
|
||||
|
||||
|
||||
def test_stop_hold_clears_the_previous_recovery_limit():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(LEAD_CONFIRM_FRAMES + 120):
|
||||
_run(lead_controller, _lead_plan(5.0, 20.0, speed_ceiling=8.0), base_speed=12.0, v_ego=10.0)
|
||||
assert lead_controller.recovery_accel_limit == pytest.approx(0.0)
|
||||
|
||||
_run(lead_controller, _lead_plan(0.0, 5.0), base_speed=8.0, v_ego=0.2, planner_accel=-0.5)
|
||||
assert lead_controller.stop_hold
|
||||
assert lead_controller.recovery_accel_limit is None
|
||||
|
||||
for frame in range(9):
|
||||
_run(lead_controller, _lead_plan(1.0, 5.0 + 0.04 * frame, speed_ceiling=8.0), base_speed=8.0, v_ego=0.0)
|
||||
assert lead_controller.departure_launching
|
||||
|
||||
_run(lead_controller, _lead_plan(5.0, 8.0, speed_ceiling=8.0), base_speed=8.0, v_ego=3.1)
|
||||
assert lead_controller.recovery_accel_limit is not None
|
||||
assert lead_controller.recovery_accel_limit > 1.4
|
||||
|
||||
|
||||
def test_speed_ceiling_tightens_immediately():
|
||||
lead_controller = LeadController()
|
||||
_run(lead_controller, _lead_plan(20.0, 100.0, speed_ceiling=25.0), base_speed=30.0, v_ego=20.0)
|
||||
assert lead_controller.lead_speed_ceiling == pytest.approx(25.0)
|
||||
_run(lead_controller, _lead_plan(15.0, 40.0, speed_ceiling=10.0), base_speed=30.0, v_ego=20.0)
|
||||
assert lead_controller.lead_speed_ceiling == pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_speed_ceiling_requires_confirmation_before_release():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(LEAD_SAMPLE_FILTER_FRAMES + 2):
|
||||
_run(lead_controller, _lead_plan(20.0, 100.0, speed_ceiling=20.0), base_speed=30.0, v_ego=20.0)
|
||||
assert lead_controller.lead_speed_ceiling == pytest.approx(20.0)
|
||||
|
||||
glitch_len = LEAD_CONFIRM_FRAMES - 2
|
||||
for _ in range(glitch_len):
|
||||
_run(lead_controller, _lead_plan(20.0, 100.0, speed_ceiling=28.0), base_speed=30.0, v_ego=20.0)
|
||||
assert lead_controller.lead_speed_ceiling == pytest.approx(20.0), "brief relief spike must not be trusted"
|
||||
|
||||
for _ in range(LEAD_SAMPLE_FILTER_FRAMES + 2):
|
||||
_run(lead_controller, _lead_plan(20.0, 100.0, speed_ceiling=20.0), base_speed=30.0, v_ego=20.0)
|
||||
assert lead_controller.lead_speed_ceiling == pytest.approx(20.0)
|
||||
|
||||
for _ in range(LEAD_CONFIRM_FRAMES + LEAD_SAMPLE_FILTER_FRAMES + 2):
|
||||
_run(lead_controller, _lead_plan(20.0, 100.0, speed_ceiling=28.0), base_speed=30.0, v_ego=20.0)
|
||||
assert lead_controller.lead_speed_ceiling == pytest.approx(28.0), "sustained relief must eventually be trusted"
|
||||
|
||||
|
||||
def test_conflicting_relief_evidence_stays_restricted():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(LEAD_SAMPLE_FILTER_FRAMES + 2):
|
||||
_run(lead_controller, _lead_plan(20.0, 100.0, speed_ceiling=20.0), base_speed=30.0, v_ego=20.0)
|
||||
|
||||
targets = []
|
||||
for frame in range(300):
|
||||
speed_ceiling = 28.0 if frame % 2 == 0 else 20.0
|
||||
targets.append(_run(lead_controller, _lead_plan(20.0, 100.0, speed_ceiling=speed_ceiling), base_speed=30.0, v_ego=20.0))
|
||||
|
||||
assert lead_controller.lead_speed_ceiling == pytest.approx(20.0)
|
||||
assert max(targets) == pytest.approx(20.0)
|
||||
|
||||
|
||||
def test_lead_dropout_holds_speed_ceiling_before_release():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(LEAD_SAMPLE_FILTER_FRAMES + 2):
|
||||
_run(lead_controller, _lead_plan(6.0, 50.0, speed_ceiling=6.0), base_speed=18.0, v_ego=10.0)
|
||||
assert lead_controller.lead_speed_ceiling == pytest.approx(6.0)
|
||||
|
||||
for _ in range(DROPOUT_FRAMES - 1):
|
||||
_run(lead_controller, _no_lead(), base_speed=18.0, v_ego=10.0)
|
||||
assert lead_controller.lead_speed_ceiling == pytest.approx(6.0), "must coast, not snap, before the dropout window elapses"
|
||||
|
||||
for _ in range(LEAD_SAMPLE_FILTER_FRAMES + 2):
|
||||
_run(lead_controller, _no_lead(), base_speed=18.0, v_ego=10.0)
|
||||
assert math.isinf(lead_controller.lead_speed_ceiling), "must release promptly once the dropout window has elapsed"
|
||||
|
||||
|
||||
def test_target_release_is_rate_limited():
|
||||
lead_controller = LeadController()
|
||||
for _ in range(LEAD_SAMPLE_FILTER_FRAMES + 2):
|
||||
_run(lead_controller, _lead_plan(20.0, 60.0, speed_ceiling=22.0), base_speed=30.0, v_ego=22.0)
|
||||
before = lead_controller.target_speed
|
||||
|
||||
target = _run(lead_controller, _lead_plan(28.0, 200.0, speed_ceiling=30.0), base_speed=30.0, v_ego=22.0)
|
||||
assert target <= before + TARGET_RELEASE_SLEW * DT + 1e-9
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
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._cruise_accel_max: 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,
|
||||
cruise_accel_max: float | None = None) -> None:
|
||||
self._accel_max_trajectory = accel_max
|
||||
self._cruise_accel_max = cruise_accel_max
|
||||
self._jerk_cost_multiplier = jerk_cost_multiplier
|
||||
|
||||
def cruise_accel_max(self, stock_accel_max: float) -> float:
|
||||
if self._cruise_accel_max is None or not np.isfinite(self._cruise_accel_max):
|
||||
return stock_accel_max
|
||||
return min(max(self._cruise_accel_max, 0.0), stock_accel_max)
|
||||
|
||||
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,15 @@ 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 openpilot.cereal import messaging, custom
|
||||
from opendbc.car import structs
|
||||
from openpilot.common.constants import CV
|
||||
from openpilot.selfdrive.car.cruise import V_CRUISE_MAX
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.selfdrive.car.cruise import V_CRUISE_MAX, V_CRUISE_UNSET
|
||||
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.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 +27,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,9 +38,15 @@ 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._long_active_last_cycle = False
|
||||
self._accel_controller_actuating = False
|
||||
|
||||
self.output_v_target = 0.
|
||||
self.output_a_target = 0.
|
||||
self.previous_plan_accel = 0.
|
||||
self.mpc_accel_seed = 0.
|
||||
|
||||
def is_e2e(self, sm: messaging.SubMaster) -> bool:
|
||||
experimental_mode = sm['selfdriveState'].experimentalMode
|
||||
@@ -43,6 +55,44 @@ class LongitudinalPlannerSP:
|
||||
|
||||
return experimental_mode and self.dec.mode() == "blended"
|
||||
|
||||
def update_accel_controller(self, sm: messaging.SubMaster, v_cruise: float, prev_accel_constraint: bool,
|
||||
stock_accel_max: float, reset_state: bool) -> tuple[bool, float]:
|
||||
is_e2e = self.is_e2e(sm)
|
||||
force_decel = sm['controlsState'].forceDecel
|
||||
previous_mpc_failed = self.mpc.last_solution_status != 0
|
||||
previous_plan_accel = self.previous_plan_accel if self._long_active_last_cycle and not previous_mpc_failed else float("inf")
|
||||
self.mpc_accel_seed = self.a_desired
|
||||
|
||||
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,
|
||||
engaged=not reset_state and not force_decel, cruise_initialized=sm['carState'].vCruise != V_CRUISE_UNSET,
|
||||
stock_accel_max=max(stock_accel_max, 0.0),
|
||||
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, previous_plan_accel=previous_plan_accel,
|
||||
)
|
||||
controller = self.accel_controller
|
||||
acc_handoff = (controller.is_active and not is_e2e and not previous_mpc_failed and self.mpc.source == MpcLongitudinalPlanSource.e2e
|
||||
and math.isfinite(previous_plan_accel))
|
||||
if acc_handoff:
|
||||
self.mpc_accel_seed = min(self.a_desired, previous_plan_accel)
|
||||
actuating = controller.is_active and not is_e2e and not force_decel and not previous_mpc_failed
|
||||
self._accel_controller_actuating = actuating
|
||||
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
|
||||
cruise_accel_max = controller.cruise_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.mpc.set_accel_controller_params(accel_max, jerk_cost_multiplier, cruise_accel_max)
|
||||
self._long_active_last_cycle = not reset_state and not force_decel
|
||||
return is_e2e, controller_v_cruise
|
||||
|
||||
def update_should_stop(self, should_stop: bool) -> bool:
|
||||
departure_authorized = self._accel_controller_actuating and self._radar_fresh_this_cycle and self.mpc.last_solution_status == 0
|
||||
return self.accel_controller.update_should_stop(should_stop, departure_authorized)
|
||||
|
||||
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,7 +123,18 @@ 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.previous_plan_accel = self.output_a_target
|
||||
self._radar_fresh_this_cycle = self._update_radar_freshness(sm)
|
||||
self.accel_controller.update_params()
|
||||
self.events_sp.clear()
|
||||
self.dec.update(sm)
|
||||
self.e2e_alerts_helper.update(sm, self.events_sp)
|
||||
@@ -95,6 +156,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
|
||||
|
||||
+1764
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.cereal import log, messaging
|
||||
from opendbc.car.interfaces import ACCEL_MAX, ACCEL_MIN
|
||||
from openpilot.common.realtime import DT_CTRL, DT_MDL, Ratekeeper
|
||||
from openpilot.selfdrive.modeld.constants import ModelConstants
|
||||
from openpilot.selfdrive.controls.lib.longcontrol import LongControl, LongCtrlState
|
||||
from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner
|
||||
from openpilot.selfdrive.controls.radard import _LEAD_ACCEL_TAU
|
||||
from openpilot.selfdrive.test.longitudinal_maneuvers.plant import Plant, PlannerSM
|
||||
|
||||
|
||||
LeadObservation = dict[str, Any]
|
||||
LeadObservationFn = Callable[[float, str, LeadObservation], LeadObservation | None]
|
||||
ModelActionFn = Callable[[float, float, float], tuple[float, bool]]
|
||||
EgoObservationFn = Callable[[float, float, float], tuple[float, float]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActuatorModel:
|
||||
planner_delay: float
|
||||
transport_delay: float
|
||||
actuator_lag: float
|
||||
command_rate_limit: float
|
||||
stopping_acceleration: float
|
||||
standstill_breakaway_acceleration: float
|
||||
standstill_breakaway_time: float
|
||||
|
||||
def __post_init__(self):
|
||||
nonnegative_fields = {
|
||||
"planner_delay": self.planner_delay,
|
||||
"transport_delay": self.transport_delay,
|
||||
"actuator_lag": self.actuator_lag,
|
||||
"standstill_breakaway_acceleration": self.standstill_breakaway_acceleration,
|
||||
"standstill_breakaway_time": self.standstill_breakaway_time,
|
||||
}
|
||||
if any(not math.isfinite(value) or value < 0.0 for value in nonnegative_fields.values()):
|
||||
raise ValueError(f"ActuatorModel fields must be finite and non-negative: {nonnegative_fields}")
|
||||
if not math.isfinite(self.command_rate_limit) or self.command_rate_limit <= 0.0:
|
||||
raise ValueError("command_rate_limit must be finite and positive")
|
||||
if not math.isfinite(self.stopping_acceleration) or self.stopping_acceleration > 0.0:
|
||||
raise ValueError("stopping_acceleration must be finite and non-positive")
|
||||
|
||||
|
||||
# Conservative Prius TSS2 actuator model.
|
||||
PRIUS_TSS2_ROUTE_MODEL = ActuatorModel(
|
||||
planner_delay=0.05,
|
||||
transport_delay=0.0,
|
||||
actuator_lag=0.20,
|
||||
command_rate_limit=4.0,
|
||||
stopping_acceleration=-2.0,
|
||||
standstill_breakaway_acceleration=1.0,
|
||||
standstill_breakaway_time=0.05,
|
||||
)
|
||||
|
||||
|
||||
class PlantSP(Plant):
|
||||
"""Closed-loop plant with configurable observations and actuator response."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
lead_relevancy=False,
|
||||
speed=0.0,
|
||||
distance_lead=2.0,
|
||||
enabled=True,
|
||||
only_lead2=False,
|
||||
only_radar=False,
|
||||
e2e=False,
|
||||
personality=0,
|
||||
force_decel=False,
|
||||
lead_observation_fn: LeadObservationFn | None = None,
|
||||
model_action_fn: ModelActionFn | None = None,
|
||||
ego_observation_fn: EgoObservationFn | None = None,
|
||||
actuator_delay: float | None = None,
|
||||
actuator_lag: float = 0.0,
|
||||
actuator_model: ActuatorModel | None = None,
|
||||
run_long_control: bool = False,
|
||||
):
|
||||
if actuator_delay is not None and (not math.isfinite(actuator_delay) or actuator_delay < 0.0):
|
||||
raise ValueError("actuator_delay must be finite and non-negative")
|
||||
if not math.isfinite(actuator_lag) or actuator_lag < 0.0:
|
||||
raise ValueError("actuator_lag must be finite and non-negative")
|
||||
|
||||
self.rate = 1.0 / DT_MDL
|
||||
|
||||
if not Plant.messaging_initialized:
|
||||
Plant.radar = messaging.pub_sock('radarState')
|
||||
Plant.controls_state = messaging.pub_sock('controlsState')
|
||||
Plant.selfdrive_state = messaging.pub_sock('selfdriveState')
|
||||
Plant.car_state = messaging.pub_sock('carState')
|
||||
Plant.plan = messaging.sub_sock('longitudinalPlan')
|
||||
Plant.messaging_initialized = True
|
||||
|
||||
self.v_lead_prev = 0.0
|
||||
|
||||
self.distance = 0.0
|
||||
self.speed = speed
|
||||
self.should_stop = False
|
||||
self.acceleration = 0.0
|
||||
self.a_target = 0.0
|
||||
self.actuator_command = 0.0
|
||||
self.applied_actuator_command = 0.0
|
||||
self.breakaway_confirmed = False
|
||||
self._breakaway_timer = 0.0
|
||||
|
||||
# lead car
|
||||
self.lead_relevancy = lead_relevancy
|
||||
self.distance_lead = distance_lead
|
||||
self.enabled = enabled
|
||||
self.only_lead2 = only_lead2
|
||||
self.only_radar = only_radar
|
||||
self.e2e = e2e
|
||||
self.personality = personality
|
||||
self.force_decel = force_decel
|
||||
self.lead_observation_fn = lead_observation_fn
|
||||
self.model_action_fn = model_action_fn
|
||||
self.ego_observation_fn = ego_observation_fn
|
||||
self.actuator_model = actuator_model
|
||||
self.actuator_delay = actuator_model.planner_delay if actuator_model is not None else actuator_delay
|
||||
self.transport_delay = actuator_model.transport_delay if actuator_model is not None else actuator_delay
|
||||
self.actuator_lag = actuator_model.actuator_lag if actuator_model is not None else actuator_lag
|
||||
self.publish_realized_a_ego = any((lead_observation_fn is not None, model_action_fn is not None, ego_observation_fn is not None,
|
||||
actuator_delay is not None, actuator_lag > 0.0, actuator_model is not None, run_long_control))
|
||||
|
||||
self.rk = Ratekeeper(self.rate, print_delay_threshold=100.0)
|
||||
self.ts = 1.0 / self.rate
|
||||
time.sleep(0.1)
|
||||
self.sm = messaging.SubMaster(['longitudinalPlan'])
|
||||
|
||||
from opendbc.car.honda.values import CAR
|
||||
from opendbc.car.honda.interface import CarInterface
|
||||
|
||||
CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC)
|
||||
if self.actuator_delay is not None:
|
||||
CP.longitudinalActuatorDelay = self.actuator_delay
|
||||
CP_SP = CarInterface.get_non_essential_params_sp(CP, CAR.HONDA_CIVIC)
|
||||
self.planner = LongitudinalPlanner(CP, CP_SP, init_v=self.speed)
|
||||
self.long_control = LongControl(CP, CP_SP) if run_long_control else None
|
||||
|
||||
if self.actuator_model is not None and self.speed >= 0.01:
|
||||
self.breakaway_confirmed = True
|
||||
self.integration_dt = DT_CTRL if run_long_control else self.ts
|
||||
delay_steps = 0 if self.transport_delay is None else round(self.transport_delay / self.integration_dt)
|
||||
self._actuator_delay_queue = deque([self.acceleration] * delay_steps)
|
||||
|
||||
@staticmethod
|
||||
def _lead_message(observation: LeadObservation):
|
||||
lead = log.RadarState.LeadData.new_message()
|
||||
for field, value in observation.items():
|
||||
setattr(lead, field, value)
|
||||
return lead
|
||||
|
||||
def _observe_lead(self, lead_name: str, truth: LeadObservation, present_by_default: bool) -> LeadObservation | None:
|
||||
if self.lead_observation_fn is None:
|
||||
return dict(truth) if present_by_default else None
|
||||
|
||||
observed = self.lead_observation_fn(self.current_time, lead_name, dict(truth))
|
||||
if observed is None:
|
||||
return None
|
||||
|
||||
complete_observation = dict(truth)
|
||||
complete_observation.update(observed)
|
||||
return complete_observation
|
||||
|
||||
def _update_actuator(self, command: float) -> tuple[float, float]:
|
||||
if self._actuator_delay_queue:
|
||||
self._actuator_delay_queue.append(command)
|
||||
delayed_command = self._actuator_delay_queue.popleft()
|
||||
else:
|
||||
delayed_command = command
|
||||
|
||||
if self.actuator_model is not None:
|
||||
max_command_delta = self.actuator_model.command_rate_limit * self.integration_dt
|
||||
self.applied_actuator_command = float(np.clip(delayed_command,
|
||||
self.applied_actuator_command - max_command_delta,
|
||||
self.applied_actuator_command + max_command_delta))
|
||||
|
||||
if self.speed < 0.01:
|
||||
if self.applied_actuator_command <= 0.0:
|
||||
self.breakaway_confirmed = False
|
||||
self._breakaway_timer = 0.0
|
||||
elif not self.breakaway_confirmed:
|
||||
breakaway_ready = self.applied_actuator_command + 1e-9 >= self.actuator_model.standstill_breakaway_acceleration
|
||||
if breakaway_ready:
|
||||
self._breakaway_timer += self.integration_dt
|
||||
else:
|
||||
self._breakaway_timer = 0.0
|
||||
|
||||
self.breakaway_confirmed = breakaway_ready and self._breakaway_timer + 1e-9 >= self.actuator_model.standstill_breakaway_time
|
||||
if not self.breakaway_confirmed:
|
||||
self.acceleration = 0.0
|
||||
return delayed_command, self.acceleration
|
||||
else:
|
||||
self.breakaway_confirmed = True
|
||||
|
||||
response_command = self.applied_actuator_command
|
||||
else:
|
||||
self.applied_actuator_command = delayed_command
|
||||
response_command = delayed_command
|
||||
|
||||
if self.actuator_lag > 0.0:
|
||||
alpha = 1.0 - math.exp(-self.integration_dt / self.actuator_lag)
|
||||
self.acceleration += alpha * (response_command - self.acceleration)
|
||||
else:
|
||||
self.acceleration = response_command
|
||||
return delayed_command, self.acceleration
|
||||
|
||||
def _integrate_ego(self, dt: float, stop_at_standstill: bool = False) -> None:
|
||||
self.speed += self.acceleration * dt
|
||||
if self.speed <= 0.0 or stop_at_standstill and self.speed < 0.01 and self.actuator_command <= 0.0:
|
||||
self.speed = self.acceleration = 0.0
|
||||
self.distance += self.speed * dt
|
||||
|
||||
def step(self, v_lead=0.0, prob_lead=1.0, v_cruise=50.0, pitch=0.0, prob_throttle=1.0):
|
||||
# ******** publish a fake model going straight and fake calibration ********
|
||||
# note that this is worst case for MPC, since model will delay long mpc by one time step
|
||||
radar = messaging.new_message('radarState')
|
||||
control = messaging.new_message('controlsState')
|
||||
ss = messaging.new_message('selfdriveState')
|
||||
car_state = messaging.new_message('carState')
|
||||
lp = messaging.new_message('liveParameters')
|
||||
car_control = messaging.new_message('carControl')
|
||||
model = messaging.new_message('modelV2')
|
||||
car_state_sp = messaging.new_message('carStateSP')
|
||||
live_map_data_sp = messaging.new_message('liveMapDataSP')
|
||||
gps_data = messaging.new_message('gpsLocation')
|
||||
a_lead = (v_lead - self.v_lead_prev) / self.ts
|
||||
self.v_lead_prev = v_lead
|
||||
|
||||
if self.lead_relevancy:
|
||||
d_rel = np.maximum(0.0, self.distance_lead - self.distance)
|
||||
v_rel = v_lead - self.speed
|
||||
if self.only_radar:
|
||||
status = True
|
||||
elif prob_lead > 0.5:
|
||||
status = True
|
||||
else:
|
||||
status = False
|
||||
else:
|
||||
d_rel = 200.0
|
||||
v_rel = 0.0
|
||||
prob_lead = 0.0
|
||||
status = False
|
||||
|
||||
truth_lead: LeadObservation = {
|
||||
"dRel": float(d_rel),
|
||||
"yRel": 0.0,
|
||||
"vRel": float(v_rel),
|
||||
"vLead": float(v_lead),
|
||||
"vLeadK": float(v_lead),
|
||||
"aLeadK": float(a_lead),
|
||||
"present": bool(status),
|
||||
# TODO use real radard logic for this
|
||||
"aLeadTau": float(_LEAD_ACCEL_TAU),
|
||||
"modelProb": float(prob_lead),
|
||||
"radar": bool(self.only_radar),
|
||||
"radarTrackId": -1,
|
||||
}
|
||||
lead_one_observation = self._observe_lead("leadOne", truth_lead, not self.only_lead2)
|
||||
lead_two_observation = self._observe_lead("leadTwo", truth_lead, True)
|
||||
if lead_one_observation is not None:
|
||||
radar.radarState.leadOne = self._lead_message(lead_one_observation)
|
||||
if lead_two_observation is not None:
|
||||
radar.radarState.leadTwo = self._lead_message(lead_two_observation)
|
||||
|
||||
# Simulate model predicting slightly faster speed
|
||||
# this is to ensure lead policy is effective when model
|
||||
# does not predict slowdown in e2e mode
|
||||
position = log.XYZTData.new_message()
|
||||
position.x = [float(x) for x in (self.speed + 0.5) * np.array(ModelConstants.T_IDXS)]
|
||||
model.modelV2.position = position
|
||||
if self.model_action_fn is None:
|
||||
model_acceleration, model_should_stop = self.acceleration + 0.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
|
||||
model.modelV2.velocity = velocity
|
||||
acceleration = log.XYZTData.new_message()
|
||||
acceleration.x = [float(x) for x in np.zeros_like(ModelConstants.T_IDXS)]
|
||||
model.modelV2.acceleration = acceleration
|
||||
model.modelV2.meta.disengagePredictions.gasPressProbs = [float(prob_throttle) for _ in range(6)]
|
||||
|
||||
control.controlsState.longControlState = self.long_control.long_control_state if self.long_control is not None else (
|
||||
LongCtrlState.pid if self.enabled else LongCtrlState.off)
|
||||
ss.selfdriveState.experimentalMode = self.e2e
|
||||
ss.selfdriveState.personality = self.personality
|
||||
control.controlsState.forceDecel = self.force_decel
|
||||
true_v_ego = self.speed
|
||||
true_a_ego = self.acceleration
|
||||
published_v_ego = true_v_ego
|
||||
published_a_ego = true_a_ego if self.publish_realized_a_ego else 0.0
|
||||
if self.ego_observation_fn is not None:
|
||||
published_v_ego, published_a_ego = self.ego_observation_fn(self.current_time, true_v_ego, true_a_ego)
|
||||
car_state.carState.vEgo = float(published_v_ego)
|
||||
car_state.carState.aEgo = float(published_a_ego)
|
||||
car_state.carState.standstill = bool(self.speed < 0.01)
|
||||
car_state.carState.vCruise = float(v_cruise * 3.6)
|
||||
car_control.carControl.orientationNED = [0.0, float(pitch), 0.0]
|
||||
|
||||
# ******** get controlsState messages for plotting ***
|
||||
sm = PlannerSM(self.rk.frame, {
|
||||
'radarState': radar.radarState,
|
||||
'carState': car_state.carState,
|
||||
'carControl': car_control.carControl,
|
||||
'controlsState': control.controlsState,
|
||||
'selfdriveState': ss.selfdriveState,
|
||||
'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.a_target = self.planner.output_a_target
|
||||
if self.long_control is None:
|
||||
self.actuator_command = self.a_target
|
||||
if self.planner.output_should_stop:
|
||||
stopping_acceleration = -0.5 if self.actuator_model is None else self.actuator_model.stopping_acceleration
|
||||
self.actuator_command = min(stopping_acceleration, self.actuator_command)
|
||||
self._update_actuator(self.actuator_command)
|
||||
self._integrate_ego(self.ts)
|
||||
else:
|
||||
for _ in range(round(self.ts / DT_CTRL)):
|
||||
car_state.carState.vEgo = self.speed
|
||||
car_state.carState.aEgo = self.acceleration
|
||||
car_state.carState.standstill = self.speed < 0.01
|
||||
self.actuator_command = self.long_control.update(
|
||||
self.enabled, car_state.carState, self.a_target, self.planner.output_should_stop, (ACCEL_MIN, ACCEL_MAX),
|
||||
)
|
||||
self._update_actuator(self.actuator_command)
|
||||
self._integrate_ego(DT_CTRL, stop_at_standstill=True)
|
||||
self.should_stop = self.planner.output_should_stop
|
||||
fcw = self.planner.fcw
|
||||
self.distance_lead = self.distance_lead + v_lead * self.ts
|
||||
|
||||
# *** radar model ***
|
||||
if self.lead_relevancy:
|
||||
d_rel = np.maximum(0.0, self.distance_lead - self.distance)
|
||||
v_rel = v_lead - self.speed
|
||||
else:
|
||||
d_rel = 200.0
|
||||
v_rel = 0.0
|
||||
|
||||
# print at 5hz
|
||||
# if (self.rk.frame % (self.rate // 5)) == 0:
|
||||
# print("%2.2f sec %6.2f m %6.2f m/s %6.2f m/s2 lead_rel: %6.2f m %6.2f m/s"
|
||||
# % (self.current_time, self.distance, self.speed, self.acceleration, d_rel, v_rel))
|
||||
|
||||
# ******** update prevs ********
|
||||
self.rk.monitor_time()
|
||||
|
||||
accel_controller = self.planner.accel_controller
|
||||
return {
|
||||
"distance": self.distance,
|
||||
"speed": self.speed,
|
||||
"acceleration": self.acceleration,
|
||||
"realized_acceleration": self.acceleration,
|
||||
"a_target": self.a_target,
|
||||
"actuator_command": self.actuator_command,
|
||||
"published_a_ego": published_a_ego,
|
||||
"published_v_ego": published_v_ego,
|
||||
"should_stop": self.should_stop,
|
||||
"long_control_state": (int(self.long_control.long_control_state) if self.long_control is not None
|
||||
else control.controlsState.longControlState.raw),
|
||||
"distance_lead": self.distance_lead,
|
||||
"fcw": fcw,
|
||||
"mpc_source": self.planner.mpc.source,
|
||||
"dec_mode": self.planner.dec.mode(),
|
||||
"controller_target": accel_controller.output_v_target,
|
||||
"model_action": {
|
||||
"desiredAcceleration": float(model_acceleration),
|
||||
"shouldStop": bool(model_should_stop),
|
||||
},
|
||||
"truth_lead": dict(truth_lead),
|
||||
"lead_one_observation": None if lead_one_observation is None else dict(lead_one_observation),
|
||||
"lead_two_observation": None if lead_two_observation is None else dict(lead_two_observation),
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
from collections.abc import Callable
|
||||
import math
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.selfdrive.test.longitudinal_maneuvers.plant import Plant
|
||||
from openpilot.sunnypilot.selfdrive.test.longitudinal_maneuvers.plant import PlantSP
|
||||
|
||||
STOCK_STEP_KEYS = ("distance", "speed", "acceleration", "should_stop", "distance_lead", "fcw")
|
||||
|
||||
|
||||
def departing_lead(current_time: float) -> float:
|
||||
return 0.0 if current_time < 1.0 else min(2.0, 2.0 * (current_time - 1.0))
|
||||
|
||||
|
||||
def stopped_lead(_current_time: float) -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
PARITY_SCENARIOS = {
|
||||
"approach_stopped_lead": {"lead_relevancy": True, "speed": 15.0, "distance_lead": 60.0, "v_cruise": 20.0, "v_lead": stopped_lead, "steps": 80},
|
||||
"stop_then_depart": {"lead_relevancy": True, "speed": 0.0, "distance_lead": 6.0, "v_cruise": 8.0, "v_lead": departing_lead, "steps": 120},
|
||||
}
|
||||
|
||||
|
||||
def _drive(cls, *, v_cruise: float, v_lead: Callable[[float], float], steps: int, **kwargs):
|
||||
plant = cls(**kwargs)
|
||||
plant.v_lead_prev = v_lead(0.0)
|
||||
solver_failures = 0
|
||||
original_reset = plant.planner.mpc.reset
|
||||
|
||||
def counting_reset(*args, **kw):
|
||||
nonlocal solver_failures
|
||||
if plant.planner.mpc.solution_status != 0:
|
||||
solver_failures += 1
|
||||
return original_reset(*args, **kw)
|
||||
|
||||
plant.planner.mpc.reset = counting_reset
|
||||
results = []
|
||||
for _ in range(steps):
|
||||
lead_speed = v_lead(plant.current_time)
|
||||
result = plant.step(v_lead=lead_speed, v_cruise=v_cruise)
|
||||
results.append((result, plant.planner.mpc.source, plant.planner.output_a_target))
|
||||
return results, solver_failures
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", PARITY_SCENARIOS, ids=list(PARITY_SCENARIOS))
|
||||
def test_plant_sp_matches_stock_plant_on_shared_kwargs(scenario: str):
|
||||
kwargs = dict(PARITY_SCENARIOS[scenario])
|
||||
v_cruise = cast(float, kwargs.pop("v_cruise"))
|
||||
v_lead = cast(Callable[[float], float], kwargs.pop("v_lead"))
|
||||
steps = cast(int, kwargs.pop("steps"))
|
||||
|
||||
stock_results, stock_failures = _drive(Plant, v_cruise=v_cruise, v_lead=v_lead, steps=steps, **kwargs)
|
||||
sp_results, sp_failures = _drive(PlantSP, v_cruise=v_cruise, v_lead=v_lead, steps=steps, **kwargs)
|
||||
|
||||
assert stock_failures == 0, f"stock Plant solver failed {stock_failures} times in {scenario!r}"
|
||||
assert sp_failures == 0, f"PlantSP solver failed {sp_failures} times in {scenario!r}"
|
||||
|
||||
for frame, ((stock_result, stock_source, stock_a_target), (sp_result, sp_source, sp_a_target)) in enumerate(
|
||||
zip(stock_results, sp_results, strict=True),
|
||||
):
|
||||
for key in STOCK_STEP_KEYS:
|
||||
if isinstance(stock_result[key], float):
|
||||
assert sp_result[key] == pytest.approx(stock_result[key]), f"{scenario} frame {frame} key {key}"
|
||||
else:
|
||||
assert sp_result[key] == stock_result[key], f"{scenario} frame {frame} key {key}"
|
||||
assert sp_source == stock_source, f"{scenario} frame {frame} mpc.source"
|
||||
assert sp_a_target == pytest.approx(stock_a_target), f"{scenario} frame {frame} output_a_target"
|
||||
|
||||
if scenario == "stop_then_depart":
|
||||
departure_frame = round(1.0 / DT_MDL)
|
||||
for results in (stock_results, sp_results):
|
||||
assert all(result["speed"] < 0.01 for result, _, _ in results[:departure_frame])
|
||||
assert results[departure_frame - 1][0]["should_stop"]
|
||||
assert any(not result["should_stop"] for result, _, _ in results[departure_frame:])
|
||||
assert any(result["speed"] > 0.05 for result, _, _ in results[departure_frame:])
|
||||
stock_release = next(frame for frame, (result, _, _) in enumerate(stock_results) if frame >= departure_frame and not result["should_stop"])
|
||||
sp_release = next(frame for frame, (result, _, _) in enumerate(sp_results) if frame >= departure_frame and not result["should_stop"])
|
||||
stock_motion = next(frame for frame, (result, _, _) in enumerate(stock_results) if frame >= departure_frame and result["speed"] > 0.05)
|
||||
sp_motion = next(frame for frame, (result, _, _) in enumerate(sp_results) if frame >= departure_frame and result["speed"] > 0.05)
|
||||
assert sp_release == stock_release
|
||||
assert sp_motion == stock_motion
|
||||
|
||||
|
||||
def test_full_lead_observation_is_independent_from_truth():
|
||||
callback_inputs = []
|
||||
|
||||
def observe_lead(current_time, lead_name, truth):
|
||||
callback_inputs.append((current_time, lead_name, truth))
|
||||
if lead_name == "leadOne":
|
||||
return {
|
||||
"dRel": 12.5,
|
||||
"vRel": -4.0,
|
||||
"vLead": 6.0,
|
||||
"vLeadK": 5.5,
|
||||
"aLeadK": -1.25,
|
||||
"aLeadTau": 0.7,
|
||||
"present": True,
|
||||
"modelProb": 0.9,
|
||||
"radarTrackId": 42,
|
||||
}
|
||||
return None
|
||||
|
||||
plant = PlantSP(lead_relevancy=True, speed=10.0, distance_lead=50.0, lead_observation_fn=observe_lead)
|
||||
result = plant.step(v_lead=8.0)
|
||||
|
||||
assert [entry[1] for entry in callback_inputs] == ["leadOne", "leadTwo"]
|
||||
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 = PlantSP(speed=10.0, e2e=True, force_decel=True, model_action_fn=model_action, actuator_lag=0.5)
|
||||
first = plant.step()
|
||||
second = plant.step()
|
||||
|
||||
assert first["model_action"] == {"desiredAcceleration": -1.25, "shouldStop": True}
|
||||
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 "controller_target" 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 = PlantSP(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):
|
||||
PlantSP(actuator_delay=delay, actuator_lag=lag)
|
||||
@@ -652,6 +652,58 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "AccelPersonalityEnabled",
|
||||
"widget": "toggle",
|
||||
"title": "Enable Accel Controller",
|
||||
"description": "Begin slowing early and smoothly behind lead vehicles. Stock longitudinal control retains braking and stopping authority.",
|
||||
"visibility": [
|
||||
{
|
||||
"type": "capability",
|
||||
"field": "has_longitudinal_control",
|
||||
"equals": true
|
||||
}
|
||||
],
|
||||
"enablement": [
|
||||
{
|
||||
"type": "capability",
|
||||
"field": "has_longitudinal_control",
|
||||
"equals": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "AccelPersonality",
|
||||
"widget": "multiple_button",
|
||||
"title": "Acceleration Profile",
|
||||
"description": "Eco slows earliest and recovers gently, Normal balances comfort and response, and Sport reacts and recovers more quickly.",
|
||||
"options": [
|
||||
{
|
||||
"value": 0,
|
||||
"label": "Eco"
|
||||
},
|
||||
{
|
||||
"value": 1,
|
||||
"label": "Normal"
|
||||
},
|
||||
{
|
||||
"value": 2,
|
||||
"label": "Sport"
|
||||
}
|
||||
],
|
||||
"enablement": [
|
||||
{
|
||||
"type": "capability",
|
||||
"field": "has_longitudinal_control",
|
||||
"equals": true
|
||||
},
|
||||
{
|
||||
"type": "param",
|
||||
"key": "AccelPersonalityEnabled",
|
||||
"equals": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "IntelligentCruiseButtonManagement",
|
||||
"widget": "toggle",
|
||||
|
||||
@@ -43,6 +43,32 @@ sections:
|
||||
label: Relaxed
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: AccelPersonalityEnabled
|
||||
widget: toggle
|
||||
title: Enable Accel Controller
|
||||
description: Begin slowing early and smoothly behind lead vehicles. Stock longitudinal control retains braking
|
||||
and stopping authority.
|
||||
visibility:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: AccelPersonality
|
||||
widget: multiple_button
|
||||
title: Acceleration Profile
|
||||
description: Eco slows earliest and recovers gently, Normal balances comfort and response, and Sport reacts
|
||||
and recovers more quickly.
|
||||
options:
|
||||
- value: 0
|
||||
label: Eco
|
||||
- value: 1
|
||||
label: Normal
|
||||
- value: 2
|
||||
label: Sport
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- type: param
|
||||
key: AccelPersonalityEnabled
|
||||
equals: true
|
||||
- key: IntelligentCruiseButtonManagement
|
||||
widget: toggle
|
||||
title: Intelligent Cruise Button Management (ICBM) (Alpha)
|
||||
|
||||
@@ -278,6 +278,22 @@ class TestKnownPanels:
|
||||
enhanced_enable_keys = {r.get("key") for r in enhanced.get("enablement", []) if r.get("type") == "param"}
|
||||
assert "NeuralNetworkLateralControl" in enhanced_enable_keys
|
||||
|
||||
def test_accel_controller_profile_mapping_and_enablement(self, schema):
|
||||
cruise = next(p for p in schema["panels"] if p["id"] == "cruise")
|
||||
items = {item["key"]: item for item in _iter_panel_items(cruise)}
|
||||
|
||||
assert items["AccelPersonalityEnabled"]["widget"] == "toggle"
|
||||
assert items["AccelPersonality"]["options"] == [
|
||||
{"value": 0, "label": "Eco"},
|
||||
{"value": 1, "label": "Normal"},
|
||||
{"value": 2, "label": "Sport"},
|
||||
]
|
||||
assert {
|
||||
"type": "param",
|
||||
"key": "AccelPersonalityEnabled",
|
||||
"equals": True,
|
||||
} in items["AccelPersonality"]["enablement"]
|
||||
|
||||
|
||||
class TestKnownVehicleSettings:
|
||||
def test_hyundai_has_longitudinal_tuning(self, schema):
|
||||
|
||||
Reference in New Issue
Block a user