Prevent launch hesitation and lead-handoff oscillation

This commit is contained in:
rav4kumar
2026-07-17 01:04:49 -07:00
parent 1aa85675d1
commit 0cf8af572e
4 changed files with 696 additions and 85 deletions
@@ -145,6 +145,11 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
if force_slow_decel:
v_cruise = 0.0
if self.accel_controller_result.reset_mpc:
# An optimizer warm-start reset must not erase stock FCW evidence.
crash_cnt = self.mpc.crash_cnt
self.mpc.reset()
self.mpc.crash_cnt = crash_cnt
self.mpc.set_weights(prev_accel_constraint, personality=sm['selfdriveState'].personality)
self.mpc.set_cur_state(self.v_desired_filter.x, self.a_desired)
self.mpc.update(
@@ -42,20 +42,21 @@ class ProfileConfig:
PROFILE_CONFIGS = {
AccelProfile.eco: ProfileConfig(comfort_decel=0.25, release_rate=0.65, release_confirm=0.50),
AccelProfile.normal: ProfileConfig(comfort_decel=0.335, release_rate=0.85, release_confirm=0.35),
AccelProfile.sport: ProfileConfig(comfort_decel=0.50, release_rate=1.10, release_confirm=0.20),
AccelProfile.eco: ProfileConfig(comfort_decel=0.25, release_rate=0.90, release_confirm=0.50),
AccelProfile.normal: ProfileConfig(comfort_decel=0.335, release_rate=1.15, release_confirm=0.35),
AccelProfile.sport: ProfileConfig(comfort_decel=0.50, release_rate=1.45, release_confirm=0.20),
}
ACCEL_PROFILE_MAX_BP = [0.0, 10.0, 25.0, 40.0]
# These are pre-MPC profile requests. Values remain inside global ACCEL_MAX;
# the planner's stock speed/turn/coast limit remains the final output authority.
ACCEL_PROFILE_MAX_V = {
AccelProfile.eco: [1.55, 0.30, 0.20, 0.10],
AccelProfile.normal: [1.70, 0.90, 0.40, 0.20],
AccelProfile.eco: [1.55, 0.85, 0.45, 0.25],
AccelProfile.normal: [1.65, 1.10, 0.70, 0.45],
AccelProfile.sport: [2.00, 1.70, 1.20, 0.90],
}
LAUNCH_DELTA_V = 3.0
LAUNCH_TARGET_HANDOFF_SPEED = 1.0
CAP_FILTER_FRAMES = 5
RESTRICT_DEADBAND = 0.15
@@ -69,13 +70,20 @@ LAUNCH_PROFILE_HANDOFF_SPEED = 0.05
VEGO_NOISE_TOLERANCE = 0.10
ACCEL_LIMIT_JERK = 1.0
DECEL_LIMIT_JERK = 1.10
LAUNCH_ACCEL_RATE = 4.0
LAUNCH_ACCEL_RATE = 5.0
CLEAR_LAUNCH_ACCEL_RATE = 3.0
INITIAL_LAUNCH_ACCEL_MAX = 0.95
BREAKAWAY_ACCEL_MAX = 1.15
HOLD_ACCEL_MAX = 0.10
MATCHED_LEAD_CLOSING_SPEED = 0.02
MATCHED_LEAD_PROFILE_ACCEL = -0.90
MATCHED_LEAD_ACCEL_TAPER_SPEED = 1.25
MATCHED_LEAD_ACCEL_GAIN = 0.80
NO_PROPULSION_CLOSING_SPEED = 0.02
MATERIAL_CLOSING_DECEL_ENTER = 0.08
MATERIAL_CLOSING_DECEL_EXIT = 0.03
MATERIAL_CLOSING_SPEED_ENTER = 0.25
MATERIAL_CLOSING_SPEED_EXIT = 0.10
MATERIAL_CLOSING_EXIT_FRAMES = 3
LAUNCH_PACE_RATE = 5.0
LEAD_ACQUISITION_INITIAL_AUTHORITY = 0.20
LEAD_ACQUISITION_TIME = 0.30
@@ -85,6 +93,7 @@ LEAD_ACQUISITION_MIN_TTC = 10.0
LEAD_ACQUISITION_MAX_DECEL = 0.25
LEAD_ACQUISITION_MIN_LEAD_ACCEL = -0.50
LEAD_ACQUISITION_MIN_PLANNER_ACCEL = -0.10
LEAD_DEPARTURE_INITIAL_AUTHORITY = 0.0
LEAD_DEPARTURE_HANDOFF_TIME = 0.50
RELATIVE_PACE_PREVIEW_TIME = 3.0
URGENT_BYPASS_REQUIRED_DECEL = 0.45
@@ -119,6 +128,7 @@ class AccelControllerResult:
effective_accel_max: float
mpc_accel_max: tuple[float, ...] | None
mpc_shape_cruise: bool
reset_mpc: bool
lead_obstacle_weights: tuple[float, float]
state: AccelControllerState
shadow_state: AccelControllerState
@@ -142,6 +152,7 @@ class _PacePath:
relief_time: float = 0.0
departure_frames: int = 0
departing_from_stop: bool = False
launch_target_active: bool = False
departure_handoff_active: bool = False
stop_departure_confirmed: bool = False
stopped_lead_hold: bool = False
@@ -150,6 +161,8 @@ class _PacePath:
urgent_bypass_active: bool = False
urgent_recovery_active: bool = False
urgent_dropout_frames: int = 0
material_closing: bool = False
material_relief_frames: int = 0
lead_seen: list[bool] = field(default_factory=lambda: [False, False])
lead_track_ids: list[int] = field(default_factory=lambda: [-1, -1])
lead_obstacle_weights: list[float] = field(default_factory=lambda: [1.0, 1.0])
@@ -161,6 +174,7 @@ class _PacePath:
self.relief_time = 0.0
self.departure_frames = 0
self.departing_from_stop = False
self.launch_target_active = False
self.departure_handoff_active = False
self.stop_departure_confirmed = False
self.stopped_lead_hold = False
@@ -169,6 +183,8 @@ class _PacePath:
self.urgent_bypass_active = False
self.urgent_recovery_active = False
self.urgent_dropout_frames = 0
self.material_closing = False
self.material_relief_frames = 0
self.lead_seen = [False, False]
self.lead_track_ids = [-1, -1]
self.lead_obstacle_weights = [1.0, 1.0]
@@ -377,7 +393,7 @@ class AccelController:
) -> tuple[float, float]:
"""Ramp benign new obstacles into MPC while making every urgent lead immediate."""
authority_step = (1.0 - LEAD_ACQUISITION_INITIAL_AUTHORITY) * self.dt / LEAD_ACQUISITION_TIME
departure_step = self.dt / LEAD_DEPARTURE_HANDOFF_TIME
departure_step = (1.0 - LEAD_DEPARTURE_INITIAL_AUTHORITY) * self.dt / LEAD_DEPARTURE_HANDOFF_TIME
for lead_index, lead in enumerate((radar_state.leadOne, radar_state.leadTwo)):
if not self._valid_lead(lead):
path.lead_seen[lead_index] = False
@@ -396,11 +412,20 @@ class AccelController:
and v_ego <= float(lead.vLeadK)
)
if safe_departure_handoff:
if path.departing_from_stop:
path.lead_obstacle_weights[lead_index] = 0.0
if path.departure_handoff_active:
if safe_departure_handoff:
# Keep enough cruise incentive to clear standstill actuator deadband,
# then restore full raw-lead authority over the configured handoff. The
# previous zero-until-motion handoff produced a large launch pulse.
previous_weight = path.lead_obstacle_weights[lead_index]
if previous_weight <= LEAD_DEPARTURE_INITIAL_AUTHORITY and v_ego < LAUNCH_PROFILE_HANDOFF_SPEED:
path.lead_obstacle_weights[lead_index] = LEAD_DEPARTURE_INITIAL_AUTHORITY
else:
path.lead_obstacle_weights[lead_index] = min(1.0, max(previous_weight, LEAD_DEPARTURE_INITIAL_AUTHORITY) + departure_step)
else:
path.lead_obstacle_weights[lead_index] = min(1.0, path.lead_obstacle_weights[lead_index] + departure_step)
# Any renewed closing or stopped-lead evidence restores stock
# obstacle authority immediately.
path.lead_obstacle_weights[lead_index] = 1.0
elif new_lead:
path.lead_obstacle_weights[lead_index] = LEAD_ACQUISITION_INITIAL_AUTHORITY if allow_blend and benign else 1.0
elif path.lead_obstacle_weights[lead_index] < 1.0:
@@ -426,7 +451,7 @@ class AccelController:
# not require enough braking for a newly detected close stopped lead.
hold_weight = 0.0 if v_ego < v_ego_stopping else 1.0
path.lead_obstacle_weights = [hold_weight, hold_weight]
elif path.departure_handoff_active and all(
elif path.departure_handoff_active and not path.departing_from_stop and all(
not seen or weight >= 1.0 for seen, weight in zip(path.lead_seen, path.lead_obstacle_weights, strict=True)
):
path.departure_handoff_active = False
@@ -453,18 +478,50 @@ class AccelController:
previous_should_stop: bool,
has_nearly_stopped_lead: bool,
departure_lead_speed: float,
selected_lead_speed: float,
closing_speed: float,
required_decel: float,
launch_delta_v: float,
) -> float:
filtered_cap = path.update_filter(raw_cap)
# Treat only meaningful relative-energy demand as a closing event. Radar
# track switches routinely move raw relative speed a few cm/s across zero;
# using that sign directly made the full-horizon acceleration ceiling flip
# between braking and gas. Hysteresis keeps material approaches prompt while
# letting matched traffic coast through harmless lead noise.
if math.isfinite(raw_cap):
restrictive_evidence = (
required_decel >= MATERIAL_CLOSING_DECEL_ENTER
or closing_speed >= MATERIAL_CLOSING_SPEED_ENTER
)
relief_evidence = (
required_decel <= MATERIAL_CLOSING_DECEL_EXIT
and closing_speed <= MATERIAL_CLOSING_SPEED_EXIT
)
if restrictive_evidence:
path.material_closing = True
path.material_relief_frames = 0
elif path.material_closing and relief_evidence:
path.material_relief_frames += 1
if path.material_relief_frames >= MATERIAL_CLOSING_EXIT_FRAMES:
path.material_closing = False
path.material_relief_frames = 0
else:
path.material_relief_frames = 0
elif not math.isfinite(filtered_cap):
path.material_closing = False
path.material_relief_frames = 0
just_initialized = path.pace is None
if just_initialized:
# A clear road has no prior restriction to release from, so expose base
# cruise immediately. With any lead present, seed at ego instead: the
# first radar frame can contain a large aLeadK spike, and using that
# transient energy cap as pace caused several seconds of acceleration
# before a late brake.
path.pace = base_speed if not math.isfinite(raw_cap) else min(base_speed, v_ego)
# cruise immediately. Any lead starts from ego pace; raw lead motion is
# not trusted to raise the target on its first observation.
if not math.isfinite(raw_cap):
path.pace = base_speed
else:
path.pace = min(base_speed, v_ego)
path.state = AccelControllerState.free
# A clear-road standstill engagement should request motion immediately. A
@@ -474,6 +531,7 @@ class AccelController:
path.state = AccelControllerState.release
path.relief_time = 0.0
path.departing_from_stop = True
path.launch_target_active = True
return filtered_cap
# A lower non-controller target is authoritative, and is also the correct seed if it later clears.
@@ -491,6 +549,21 @@ class AccelController:
if v_ego >= STOP_HOLD_EGO_SPEED:
path.stop_departure_confirmed = False
if path.launch_target_active:
if v_ego >= LAUNCH_TARGET_HANDOFF_SPEED:
# Handoff from the base-cruise launch target without replacing it with
# a stale pace only a few tenths above ego in one frame. Keep the same
# bounded launch preview used at departure; profile acceleration and
# the ordinary release ramp retain authority from here.
path.pace = min(base_speed, max(path.pace, v_ego + launch_delta_v))
path.launch_target_active = False
elif (
has_nearly_stopped_lead
or closing_speed > MATERIAL_CLOSING_SPEED_EXIT
or required_decel >= MATERIAL_CLOSING_DECEL_ENTER
):
path.launch_target_active = False
renewed_stop_evidence = filtered_cap < STOP_HOLD_CAP or has_nearly_stopped_lead
stale_plan_stop = previous_should_stop and not path.departing_from_stop and not path.stop_departure_confirmed
enter_stop_hold = v_ego < STOP_HOLD_EGO_SPEED and (renewed_stop_evidence or stale_plan_stop)
@@ -500,6 +573,7 @@ class AccelController:
path.relief_time = 0.0
path.departure_frames = 0
path.departing_from_stop = False
path.launch_target_active = False
path.departure_handoff_active = False
path.stop_departure_confirmed = False
return filtered_cap
@@ -524,6 +598,7 @@ class AccelController:
path.relief_time = config.release_confirm
path.departure_frames = 0
path.departing_from_stop = True
path.launch_target_active = True
path.departure_handoff_active = True
path.stop_departure_confirmed = True
path.stopped_lead_hold = False
@@ -531,7 +606,20 @@ class AccelController:
return filtered_cap
ceiling = min(base_speed, filtered_cap)
if math.isfinite(raw_cap) and closing_speed > 0.0:
matched_moving_lead = (
math.isfinite(selected_lead_speed)
and closing_speed <= MATERIAL_CLOSING_SPEED_EXIT
and v_ego <= selected_lead_speed + MATERIAL_CLOSING_SPEED_EXIT
)
if matched_moving_lead:
# Once ego is no longer closing, surplus gap is not a reason to target a
# speed above the lead and manufacture another braking event. Upward
# changes still use the profile release ramp; only an already-high pace
# is synchronized down to the moving lead.
matched_ceiling = min(base_speed, max(selected_lead_speed, 0.0))
path.pace = min(path.pace, matched_ceiling)
ceiling = min(ceiling, matched_ceiling)
if math.isfinite(raw_cap) and path.material_closing:
# Never spend stored gap by accelerating toward a slower lead. Hold the
# current pace until relative speed is matched; the energy envelope may
# still lower it at the configured comfort rate.
@@ -541,12 +629,15 @@ class AccelController:
path.state = AccelControllerState.restrict
path.relief_time = 0.0
path.departing_from_stop = False
path.launch_target_active = False
path.departure_handoff_active = False
return filtered_cap
relief = ceiling - path.pace
confirmed_clear = not math.isfinite(raw_cap) and not math.isfinite(filtered_cap)
relief_deadband = RESTRICT_DEADBAND if confirmed_clear else RELIEF_DEADBAND
release_allowed = path.state == AccelControllerState.release and relief > RESTRICT_DEADBAND
if relief >= RELIEF_DEADBAND and not release_allowed:
if relief >= relief_deadband and not release_allowed:
path.relief_time += self.dt
path.state = AccelControllerState.hold
release_allowed = path.relief_time >= config.release_confirm
@@ -555,9 +646,15 @@ class AccelController:
pace_rate = LAUNCH_PACE_RATE if path.departing_from_stop else config.release_rate
path.pace = min(ceiling, path.pace + pace_rate * self.dt)
path.state = AccelControllerState.release
elif relief <= RELIEF_DEADBAND:
elif relief <= relief_deadband:
path.relief_time = 0.0
path.state = AccelControllerState.free if path.pace >= base_speed else AccelControllerState.hold
if confirmed_clear:
# Close the final sub-deadband clear-road gap instead of leaving the
# controller indefinitely in HOLD_ACCEL_MAX with no lead present.
path.pace = ceiling
path.state = AccelControllerState.free
else:
path.state = AccelControllerState.free if path.pace >= base_speed else AccelControllerState.hold
return filtered_cap
@@ -568,8 +665,11 @@ class AccelController:
planner_accel: float,
profile_accel_max: float,
config: ProfileConfig,
v_ego: float,
selected_lead_speed: float,
closing_speed: float,
lead_present: bool,
material_closing: bool,
) -> tuple[float, float]:
"""Return telemetry effective max and the controller's pre-MPC upper bound."""
profile_limit = float(np.clip(profile_accel_max, 0.0, ACCEL_MAX))
@@ -597,8 +697,9 @@ class AccelController:
path.accel_limit = min(launch_target, previous_limit + LAUNCH_ACCEL_RATE * self.dt)
else:
# Start below the solver's standstill cold-start edge, then reach the
# common breakaway floor over two controller frames. The lookup table
# takes over after the first few centimeters.
# common breakaway floor over two controller frames. The launch horizon
# exposes each profile's higher future ceiling without stepping the
# near-time constraint that keeps the one-iteration solver stable.
if path.accel_limit is None:
path.accel_limit = min(INITIAL_LAUNCH_ACCEL_MAX, BREAKAWAY_ACCEL_MAX)
else:
@@ -621,7 +722,7 @@ class AccelController:
path.decel_limit_active = path.state == AccelControllerState.restrict
return min(stock_accel_max, path.accel_limit), path.accel_limit
if path.state == AccelControllerState.restrict and (not lead_present or closing_speed > MATCHED_LEAD_CLOSING_SPEED):
if path.state == AccelControllerState.restrict and (not lead_present or material_closing):
# A negative full-horizon maximum is useful only while ego is still
# closing. Keeping it after matching relative speed forces continued
# braking below a slower moving lead. Once matched, retain the lowering
@@ -637,11 +738,25 @@ class AccelController:
if path.accel_limit is None:
path.accel_limit = max(0.0, planner_accel)
path.decel_limit_active = True
elif lead_present and closing_speed > MATCHED_LEAD_CLOSING_SPEED:
elif lead_present and material_closing:
# Do not spend the remaining gap by accelerating toward a slower lead,
# regardless of whether the scalar pace state is currently releasing.
requested_limit = HOLD_ACCEL_MAX
path.decel_limit_active = False
elif lead_present and closing_speed > NO_PROPULSION_CLOSING_SPEED:
# Small closing rates do not justify a forced negative comfort ceiling,
# but they still must not receive positive propulsion. Keeping this
# separate from material-closing deceleration prevents both gas/brake
# cycling and centimeter-per-second radar-noise chatter.
requested_limit = HOLD_ACCEL_MAX
path.decel_limit_active = False
elif lead_present and math.isfinite(selected_lead_speed) and selected_lead_speed - v_ego <= MATCHED_LEAD_ACCEL_TAPER_SPEED:
# Taper propulsion before reaching a moving lead, accounting for the
# acceleration already in the planner/actuator path. Waiting until ego
# is faster leaves too much stored acceleration and creates overshoot.
speed_error = max(selected_lead_speed - v_ego, 0.0)
requested_limit = min(profile_limit, max(HOLD_ACCEL_MAX, MATCHED_LEAD_ACCEL_GAIN * speed_error))
path.decel_limit_active = False
elif path.state in (AccelControllerState.restrict, AccelControllerState.hold):
# No gas while waiting for relief confirmation. This is the main
# anti-rubber-band rule for a still-closing lead.
@@ -664,13 +779,9 @@ class AccelController:
effective_limit = min(stock_accel_max, path.accel_limit)
return effective_limit, path.accel_limit
def _build_mpc_accel_max(
self,
path: _PacePath,
accel_limit: float,
) -> tuple[float, ...] | None:
def _build_mpc_accel_max(self, accel_limit: float | None) -> tuple[float, ...] | None:
"""Build the controller's pre-MPC acceleration upper-bound trajectory."""
if not math.isfinite(accel_limit):
if accel_limit is None or not math.isfinite(accel_limit):
return None
bounded_limit = float(np.clip(accel_limit, ACCEL_MIN, ACCEL_MAX))
@@ -743,6 +854,9 @@ class AccelController:
)
envelope = self.calculate_energy_envelope(radar_state, sanitized_v_ego, a_ego, profile, follow_personality) if valid_context else EnergyEnvelope()
selected_lead_speed = math.inf
if envelope.selected_lead in (0, 1):
selected_lead_speed = float(getattr((radar_state.leadOne, radar_state.leadTwo)[envelope.selected_lead], "vLeadK", math.inf))
if valid_context:
shadow_filtered_cap = self._update_path(
@@ -756,7 +870,9 @@ class AccelController:
previous_should_stop,
envelope.has_nearly_stopped_lead,
envelope.departure_lead_speed,
selected_lead_speed,
envelope.closing_speed,
envelope.required_decel,
launch_delta_v,
)
self._update_accel_limit(
@@ -765,8 +881,11 @@ class AccelController:
planner_accel,
profile_accel_max,
config,
sanitized_v_ego,
selected_lead_speed,
envelope.closing_speed,
envelope.selected_lead >= 0,
self.shadow.material_closing,
)
shadow_active = True
else:
@@ -774,6 +893,7 @@ class AccelController:
shadow_filtered_cap = math.inf
shadow_active = False
reset_mpc = False
live_active = valid_context and bool(enabled) and bool(acc_selected)
if live_active:
live_was_initialized = self.live.pace is not None
@@ -795,7 +915,9 @@ class AccelController:
previous_should_stop,
envelope.has_nearly_stopped_lead,
envelope.departure_lead_speed,
selected_lead_speed,
envelope.closing_speed,
envelope.required_decel,
launch_delta_v,
)
lead_obstacle_weights = self._update_lead_obstacle_weights(
@@ -808,18 +930,18 @@ class AccelController:
allow_blend=live_was_initialized,
)
urgent_was_active = self.live.urgent_bypass_active
pre_urgent_accel_limit = self.live.accel_limit
selected_lead_speed = math.inf
if envelope.selected_lead in (0, 1):
selected_lead_speed = float(getattr((radar_state.leadOne, radar_state.leadTwo)[envelope.selected_lead], "vLeadK", math.inf))
selected_has_full_authority = (
envelope.selected_lead in (0, 1) and lead_obstacle_weights[envelope.selected_lead] >= 1.0
)
urgent_required_decel = envelope.required_decel
if not live_was_initialized or not established_selected_lead:
urgent_required_decel = max(urgent_required_decel, envelope.conservative_required_decel)
low_speed_urgent_lead = (
selected_lead_speed < URGENT_LOW_SPEED_LEAD_BYPASS
and self.live.state != AccelControllerState.stopHold
)
urgent_trigger = (
sanitized_v_ego >= URGENT_BYPASS_MIN_SPEED
(sanitized_v_ego >= URGENT_BYPASS_MIN_SPEED or low_speed_urgent_lead)
and urgent_required_decel >= URGENT_BYPASS_REQUIRED_DECEL
and (not live_was_initialized or established_selected_lead or selected_has_full_authority)
)
@@ -878,43 +1000,55 @@ class AccelController:
self.live.decel_limit_active = self.live.accel_limit < 0.0
self.live.urgent_recovery_active = False
effective_accel_max = min(stock_accel_max, self.live.accel_limit)
mpc_accel_max = self._build_mpc_accel_max(self.live, self.live.accel_limit)
mpc_accel_max = self._build_mpc_accel_max(self.live.accel_limit)
mpc_shape_cruise = True
lead_obstacle_weights = (1.0, 1.0)
target_speed = min(base_speed, self.live.pace if self.live.pace is not None else sanitized_v_ego)
elif urgent_bypass:
# Comfort shaping must never compete with urgent braking. Hand the raw
# leads, base cruise target, and stock acceleration bounds directly to
# MPC. On the rising edge only, retain a prior nonnegative upper bound
# for one solve so introducing the raw obstacle does not simultaneously
# remove an optimizer constraint. A nonnegative maximum cannot weaken
# braking, and the following urgent frame returns to stock bounds.
urgent_entry_limit = None
if not urgent_was_active and pre_urgent_accel_limit is not None and math.isfinite(pre_urgent_accel_limit):
urgent_entry_limit = float(np.clip(max(pre_urgent_accel_limit, 0.0), 0.0, ACCEL_MAX))
# Hand raw leads, base cruise, and stock bounds directly to MPC. Clear
# the old comfort-control warm start once on urgent entry so its prior
# positive-acceleration solution cannot destabilize the abrupt raw-lead
# solve. The reset occurs before set_cur_state/update in the planner.
reset_mpc = reset_mpc or not urgent_was_active
self.live.launch_target_active = False
self.live.accel_limit = None
self.live.decel_limit_active = False
self.live.urgent_recovery_active = False
if self.live.pace is not None:
self.live.pace = min(self.live.pace, planner_speed, sanitized_v_ego)
effective_accel_max = min(stock_accel_max, urgent_entry_limit) if urgent_entry_limit is not None else stock_accel_max
mpc_accel_max = self._build_mpc_accel_max(self.live, urgent_entry_limit) if urgent_entry_limit is not None else None
effective_accel_max = stock_accel_max
mpc_accel_max = None
mpc_shape_cruise = False
lead_obstacle_weights = (1.0, 1.0)
target_speed = base_speed
else:
recovery_was_active = self.live.urgent_recovery_active
effective_accel_max, controller_accel_max = self._update_accel_limit(
self.live,
stock_accel_max,
planner_accel,
profile_accel_max,
config,
sanitized_v_ego,
selected_lead_speed,
envelope.closing_speed,
envelope.selected_lead >= 0,
self.live.material_closing,
)
if (
recovery_was_active
and not self.live.urgent_recovery_active
and envelope.closing_speed <= 0.10
and math.isfinite(selected_lead_speed)
and self.live.pace is not None
):
# Once relative speed is matched, never carry a recovery pace below
# the moving lead. This avoids the old choice between a base-cruise
# gas pulse and prolonged braking below lead speed.
self.live.pace = max(self.live.pace, min(base_speed, selected_lead_speed))
# Feed only the controller-owned ceiling into MPC. Stock's speed, turn,
# coast, and no-throttle limits remain in their original output clip.
mpc_accel_max = self._build_mpc_accel_max(self.live, controller_accel_max)
mpc_accel_max = self._build_mpc_accel_max(controller_accel_max)
mpc_shape_cruise = mpc_accel_max is not None
if mpc_accel_max is None:
effective_accel_max = stock_accel_max
@@ -924,16 +1058,34 @@ class AccelController:
# 0.30 m/s hold threshold; keeping base cruise there can otherwise
# permit a slow coast while lead authority is intentionally muted.
target_speed = 0.0
elif self.live.departing_from_stop:
# Give all profiles the same prompt stock breakaway. The raw lead still
# owns obstacle braking, and profile separation begins after motion.
elif self.live.departing_from_stop or self.live.launch_target_active:
# Keep stock's normal cruise incentive through the first meter per
# second of a confirmed launch. The lookup ceiling shapes takeoff
# while raw-lead authority restores; renewed closing evidence
# cancels this path immediately.
target_speed = base_speed
elif (
envelope.selected_lead >= 0
and math.isfinite(selected_lead_speed)
and selected_lead_speed > STOPPED_LEAD_SPEED
and envelope.closing_speed <= MATERIAL_CLOSING_SPEED_EXIT
and envelope.required_decel <= MATERIAL_CLOSING_DECEL_EXIT
and planner_accel < -0.20
and not self.live.material_closing
):
# A moving lead that has already matched ego no longer needs the
# cruise obstacle to reinforce residual braking. Let raw lead
# geometry hold the gap while base cruise helps MPC unwind smoothly;
# any renewed relative-energy demand exits through material_closing.
# Keep mpc_accel_max as a hard bound, but do not also use that small
# comfort ceiling to clip the cruise speed trajectory below the lead.
target_speed = base_speed
mpc_shape_cruise = False
lead_obstacle_weights = (1.0, 1.0)
elif self.live.urgent_recovery_active:
# Keep stock's cruise incentive while the positive recovery ceiling
# gently unwinds the hard lead-braking plan. Switching immediately
# to the synchronized pace can make MPC keep braking well below a
# moving lead even though the ceiling itself is nonnegative.
target_speed = base_speed
# Keep full raw-lead authority while the pre-MPC ceiling rejoins, but
# retain the synchronized pace so relief cannot become a gas pulse.
target_speed = min(base_speed, self.live.pace if self.live.pace is not None else base_speed)
lead_obstacle_weights = (1.0, 1.0)
else:
target_speed = min(base_speed, self.live.pace if self.live.pace is not None else base_speed)
@@ -958,6 +1110,7 @@ class AccelController:
effective_accel_max=effective_accel_max,
mpc_accel_max=mpc_accel_max,
mpc_shape_cruise=mpc_shape_cruise,
reset_mpc=reset_mpc,
lead_obstacle_weights=lead_obstacle_weights,
state=self.live.state,
shadow_state=self.shadow.state,
@@ -83,8 +83,8 @@ class TestAccelProfileLimits:
def test_profile_table_matches_tuned_values(self):
assert ACCEL_PROFILE_MAX_BP == [0.0, 10.0, 25.0, 40.0]
assert ACCEL_PROFILE_MAX_V == {
AccelProfile.eco: [1.55, 0.30, 0.20, 0.10],
AccelProfile.normal: [1.70, 0.90, 0.40, 0.20],
AccelProfile.eco: [1.55, 0.85, 0.45, 0.25],
AccelProfile.normal: [1.65, 1.10, 0.70, 0.45],
AccelProfile.sport: [2.00, 1.70, 1.20, 0.90],
}
@@ -139,7 +139,7 @@ class TestAccelProfileLimits:
result = update(governor, profile=AccelProfile.eco, v_ego=17.5, planner_speed=17.5)
assert result.profile_accel_max == pytest.approx(0.25)
assert result.profile_accel_max == pytest.approx(0.65)
def test_clear_road_profile_is_a_separate_pre_mpc_trajectory(self):
governor = make_governor()
@@ -149,7 +149,7 @@ class TestAccelProfileLimits:
assert result.mpc_accel_max is not None
assert result.mpc_shape_cruise
assert len(result.mpc_accel_max) == N + 1
assert_profile_trajectory(result, 0.90)
assert_profile_trajectory(result, 1.10)
def test_ordinary_closing_lead_uses_no_gas_pre_mpc_bound(self):
governor = make_governor()
@@ -210,9 +210,9 @@ class TestAccelProfileLimits:
result = update(governor, profile=AccelProfile.normal, v_ego=10.0, planner_speed=10.0, stock_accel_max=1.40)
assert result.profile_accel_max == 0.90
assert result.effective_accel_max == 0.90
assert_profile_trajectory(result, 0.90)
assert result.profile_accel_max == 1.10
assert result.effective_accel_max == 1.10
assert_profile_trajectory(result, 1.10)
def test_first_enable_seeds_from_positive_planner_accel_within_stock(self):
governor = make_governor()
@@ -255,9 +255,9 @@ class TestAccelProfileLimits:
released = update(governor, profile=AccelProfile.normal, v_ego=10.0, planner_speed=10.0, stock_accel_max=1.40)
assert tightened.effective_accel_max == 0.40
assert_profile_trajectory(tightened, 0.90)
assert released.effective_accel_max == 0.90
assert_profile_trajectory(released, 0.90)
assert_profile_trajectory(tightened, 1.10)
assert released.effective_accel_max == 1.10
assert_profile_trajectory(released, 1.10)
def test_negative_stock_max_remains_authoritative_outside_the_mpc_profile_bound(self):
governor = make_governor()
@@ -265,7 +265,7 @@ class TestAccelProfileLimits:
result = update(governor, v_ego=10.0, planner_speed=10.0, stock_accel_max=-0.20, planner_accel=1.0)
assert result.effective_accel_max == -0.20
assert_profile_trajectory(result, 1.0)
assert_profile_trajectory(result, 1.10)
def test_profile_tightening_can_converge_below_positive_planner_accel(self):
governor = make_governor()
@@ -337,6 +337,7 @@ class TestLeadObstacleAcquisition:
assert result.required_decel > 1.0
assert result.lead_obstacle_weights == (1.0, 1.0)
assert result.reset_mpc
@pytest.mark.parametrize(
"lead",
@@ -526,6 +527,66 @@ class TestAccelControllerState:
assert result.mpc_accel_max is None
assert result.lead_obstacle_weights == (1.0, 1.0)
@pytest.mark.parametrize("profile", list(AccelProfile))
def test_e5_low_speed_urgency_hands_back_to_stock_immediately(self, profile):
governor = make_governor(delay=0.15)
established_lead = make_radar(make_lead(
status=True,
d_rel=18.68,
v_lead_k=2.876,
a_lead_k=-0.743,
radar_track_id=7,
))
established_args = {
"profile": profile,
"base_speed": 23.056,
"v_ego": 5.015,
"a_ego": -0.949,
"planner_speed": 5.015,
"planner_accel": -1.021,
"stock_accel_max": 1.40,
}
for _ in range(5):
established = update(governor, established_lead, **established_args)
assert established.required_decel < URGENT_BYPASS_REQUIRED_DECEL
assert not governor.live.urgent_bypass_active
assert established.target_speed < established.base_speed
assert established.mpc_shape_cruise
urgent_lead = make_radar(make_lead(
status=True,
d_rel=16.32,
v_lead_k=1.89,
a_lead_k=-1.16,
radar_track_id=7,
))
urgent_args = established_args | {
"v_ego": 4.499,
"a_ego": -0.75,
"planner_speed": 4.499,
"planner_accel": -0.95,
}
entering = update(governor, urgent_lead, **urgent_args)
assert entering.required_decel > URGENT_BYPASS_REQUIRED_DECEL
assert governor.live.urgent_bypass_active
assert entering.target_speed == entering.base_speed
assert entering.lead_obstacle_weights == (1.0, 1.0)
assert not entering.mpc_shape_cruise
assert entering.mpc_accel_max is None
assert entering.effective_accel_max == urgent_args["stock_accel_max"]
stock_owned = update(governor, urgent_lead, **urgent_args)
assert governor.live.urgent_bypass_active
assert stock_owned.target_speed == stock_owned.base_speed
assert stock_owned.lead_obstacle_weights == (1.0, 1.0)
assert not stock_owned.mpc_shape_cruise
assert stock_owned.mpc_accel_max is None
assert stock_owned.effective_accel_max == urgent_args["stock_accel_max"]
def test_positive_lead_accel_spike_cannot_delay_first_urgent_bypass(self):
governor = make_governor()
radar_state = make_radar(make_lead(status=True, d_rel=90.0, v_lead_k=14.0, a_lead_k=5.0))
@@ -538,7 +599,7 @@ class TestAccelControllerState:
assert result.mpc_accel_max is None
assert result.lead_obstacle_weights == (1.0, 1.0)
def test_urgent_entry_bridges_existing_nonnegative_ceiling_for_one_frame(self):
def test_urgent_entry_removes_existing_ceiling_on_first_frame(self):
governor = make_governor()
clear = update(governor, base_speed=40.0, v_ego=34.8, planner_speed=34.8)
urgent_radar = make_radar(make_lead(status=True, d_rel=94.0, v_lead_k=23.4, radar_track_id=22))
@@ -548,10 +609,13 @@ class TestAccelControllerState:
assert clear.mpc_accel_max is not None
assert entering.required_decel > URGENT_BYPASS_REQUIRED_DECEL
assert entering.mpc_accel_max == clear.mpc_accel_max
assert entering.mpc_accel_max is None
assert entering.reset_mpc
assert not entering.mpc_shape_cruise
assert entering.lead_obstacle_weights == (1.0, 1.0)
assert established.mpc_accel_max is None
assert not established.reset_mpc
assert not established.mpc_shape_cruise
def test_urgent_dropout_holds_pace_and_nonpositive_ceiling_without_lead_geometry(self):
governor = make_governor()
@@ -574,7 +638,8 @@ class TestAccelControllerState:
assert not governor.live.urgent_recovery_active
assert expired.mpc_accel_max is not None
assert expired.target_speed == expired.live_pace
assert expired.target_speed < expired.base_speed
assert expired.target_speed == expired.base_speed
assert expired.state == AccelControllerState.free
def test_urgent_exit_slews_rejoin_ceiling_then_releases_after_speed_match(self):
governor = make_governor()
@@ -626,8 +691,7 @@ class TestAccelControllerState:
assert renewed.required_decel > URGENT_BYPASS_REQUIRED_DECEL
assert governor.live.urgent_bypass_active
assert not governor.live.urgent_recovery_active
assert renewed.mpc_accel_max is not None
assert min(renewed.mpc_accel_max) >= 0.0
assert renewed.mpc_accel_max is None
assert not renewed.mpc_shape_cruise
assert renewed.lead_obstacle_weights == (1.0, 1.0)
@@ -658,6 +722,21 @@ class TestAccelControllerState:
expected_rate = PROFILE_CONFIGS[AccelProfile.normal].release_rate
assert result.live_pace == pytest.approx(held_pace + expected_rate * DT_MDL)
def test_confirmed_clear_closes_sub_deadband_hold_to_base(self):
governor = make_governor()
base_speed = 20.0
governor.live.pace = base_speed - 0.10
governor.live.state = AccelControllerState.hold
governor.live.accel_limit = HOLD_ACCEL_MAX
result = update(governor, base_speed=base_speed)
assert math.isinf(result.raw_energy_cap)
assert math.isinf(result.live_filtered_cap)
assert result.state == AccelControllerState.free
assert result.live_pace == base_speed
assert result.target_speed == base_speed
def test_live_state_never_adopts_shadow_history(self):
governor = make_governor()
radar_state = make_radar(self.restrictive_lead)
@@ -717,7 +796,40 @@ class TestAccelControllerState:
assert departed.target_speed == stop_args["base_speed"]
assert departed.effective_accel_max == pytest.approx(LAUNCH_ACCEL_RATE * DT_MDL)
assert_profile_trajectory(departed, departed.effective_accel_max)
assert departed.lead_obstacle_weights == (0.0, 1.0)
assert 0.0 < departed.lead_obstacle_weights[0] < 1.0
assert departed.lead_obstacle_weights[1] == 1.0
def test_creeping_lead_departure_confirms_before_bounded_launch(self):
governor = make_governor()
stopped = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.0, radar_track_id=7))
creeping = make_radar(make_lead(status=True, d_rel=8.0, v_lead_k=0.6, radar_track_id=7))
stop_args = {
"base_speed": 5.0,
"v_ego": 0.1,
"planner_speed": 0.1,
"previous_mpc_source": LongitudinalPlanSource.lead0,
"previous_should_stop": True,
}
for _ in range(3):
update(governor, stopped, **stop_args)
departure = [update(governor, creeping, **stop_args) for _ in range(4)]
for held in departure[:3]:
assert held.state == AccelControllerState.stopHold
assert not held.launching
assert held.live_pace == 0.0
assert held.target_speed == 0.0
launched = departure[3]
assert launched.state == AccelControllerState.release
assert launched.launching
assert launched.target_speed == launched.base_speed
assert launched.live_pace == pytest.approx(launched.live_filtered_cap)
assert launched.live_pace >= 0.6
assert launched.live_pace < launched.base_speed
assert 0.0 < launched.lead_obstacle_weights[0] < 1.0
def test_far_irrelevant_stopped_lead_does_not_block_departure(self):
governor = make_governor()
@@ -895,15 +1007,17 @@ class TestAccelControllerState:
governor = make_governor()
noisy_moving_lead = make_radar(make_lead(status=True, d_rel=10.0, v_lead_k=1.5))
first = update(governor, noisy_moving_lead, base_speed=5.0, v_ego=0.0, planner_speed=0.0)
second = update(governor, noisy_moving_lead, base_speed=5.0, v_ego=0.0, planner_speed=0.0)
results = [update(governor, noisy_moving_lead, base_speed=5.0, v_ego=0.0, planner_speed=0.0) for _ in range(8)]
first, second = results[:2]
assert first.selected_lead == 0
assert first.live_pace == 0.0
assert first.target_speed == first.live_pace
assert not governor.live.stopped_lead_hold
assert second.target_speed == second.live_pace
assert second.target_speed < second.base_speed
assert second.target_speed >= first.target_speed
assert results[-1].target_speed > 0.0
assert results[-1].target_speed <= noisy_moving_lead.leadOne.vLeadK
def test_real_stopped_evidence_latches_hold_after_noisy_first_frame(self):
governor = make_governor()
@@ -934,7 +1048,7 @@ class TestAccelControllerState:
assert settled.selected_lead == 0
assert not governor.live.stopped_lead_hold
assert settled.target_speed == settled.live_pace
assert observations[0].target_speed == observations[0].base_speed
assert observations[0].target_speed == moving_lead.leadOne.vLeadK
assert settled.target_speed < settled.base_speed
def test_stop_hold_dropout_pins_target_without_losing_hold_state(self):
@@ -972,7 +1086,7 @@ class TestAccelControllerState:
assert_profile_trajectory(first, INITIAL_LAUNCH_ACCEL_MAX)
assert_profile_trajectory(third, BREAKAWAY_ACCEL_MAX)
def test_confirmed_departure_has_no_later_pace_jump(self):
def test_confirmed_departure_holds_base_target_then_hands_off_to_launch_preview(self):
governor = make_governor()
stopped = make_radar(make_lead(status=True, d_rel=6.0, v_lead_k=0.0))
moving = make_radar(make_lead(status=True, d_rel=20.0, v_lead_k=5.0))
@@ -996,10 +1110,16 @@ class TestAccelControllerState:
expected_step = PROFILE_CONFIGS[AccelProfile.normal].release_rate * DT_MDL
assert handed_back.live_pace == pytest.approx(departing.live_pace + expected_step)
assert handed_back.live_pace < min(handed_back.base_speed, handed_back.live_filtered_cap)
assert handed_back.target_speed == handed_back.live_pace
assert handed_back.target_speed == handed_back.base_speed
assert handed_back.mpc_accel_max is not None
assert handed_back.mpc_shape_cruise
handoff = update(governor, moving, **(lead_args | {"v_ego": 1.0, "planner_speed": 1.0}))
assert not governor.live.launch_target_active
assert min(handoff.base_speed, 1.0 + LAUNCH_DELTA_V) <= handoff.live_pace <= handoff.base_speed
assert handoff.target_speed == handoff.live_pace
@pytest.mark.parametrize(
"bypass",
[
@@ -8,6 +8,7 @@ from opendbc.car.interfaces import ACCEL_MIN
from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
from openpilot.selfdrive.controls.lib.longitudinal_planner import get_max_accel
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalPlanSource
from openpilot.selfdrive.test.longitudinal_maneuvers.plant import PRIUS_TSS2_ROUTE_MODEL, LeadObservation, Plant
from openpilot.sunnypilot.selfdrive.controls.lib.accel_personality import AccelControllerState
@@ -40,6 +41,8 @@ class ClosedLoopTrace:
observed_acceleration: np.ndarray
lead_obstacle_weight_0: np.ndarray
lead_obstacle_weight_1: np.ndarray
state: np.ndarray
required_decel: np.ndarray
solver_failures: int
@@ -106,6 +109,8 @@ def _run(
result["observed_a_ego"],
controller.lead_obstacle_weights[0],
controller.lead_obstacle_weights[1],
controller.state,
controller.required_decel,
)
)
sources.append(result["mpc_source"])
@@ -138,6 +143,8 @@ def _run(
observed_acceleration=data[:, 22],
lead_obstacle_weight_0=data[:, 23],
lead_obstacle_weight_1=data[:, 24],
state=data[:, 25].astype(int),
required_decel=data[:, 26],
solver_failures=solver_failures,
)
@@ -180,6 +187,28 @@ def _has_propulsion_brake_reversal(trace: ClosedLoopTrace, after: float) -> bool
return False
def _has_stable_brake_gas_brake(values: np.ndarray, threshold: float, frames: int = 5) -> bool:
"""Return whether brake, gas, then brake each persist for ``frames`` samples."""
phase = 0
brake_seen = False
gas_after_brake_seen = False
for index in range(len(values) - frames + 1):
window = values[index:index + frames]
candidate = -1 if np.all(window <= -threshold) else 1 if np.all(window >= threshold) else 0
if candidate == 0 or candidate == phase:
continue
phase = candidate
if candidate < 0:
if gas_after_brake_seen:
return True
brake_seen = True
elif brake_seen:
gas_after_brake_seen = True
return False
@pytest.fixture(autouse=True)
def _restore_controller_defaults():
yield
@@ -278,7 +307,7 @@ def test_two_frame_dropout_and_false_relief_do_not_release_pace(record_property)
assert not _has_propulsion_brake_reversal(trace, after=1.0)
record_property("clean_base_solver_failures", baseline.solver_failures)
record_property("accel_controller_solver_failures", trace.solver_failures)
assert trace.solver_failures <= baseline.solver_failures
assert trace.solver_failures == 0
if trace.solver_failures:
pytest.xfail("opt-in validation: absolute zero-solver-failure gate is unmet with raw two-frame all-lead dropout")
@@ -324,11 +353,12 @@ def test_benign_far_lead_acquisition_ramps_optimizer_authority_without_jerk():
trace = _run(
duration=3.0,
controller_enabled=True,
profile=0,
lead_relevancy=True,
speed=20.0,
distance_lead=126.0,
v_lead=17.0,
v_cruise=30.0,
v_cruise=20.0,
lead_observation_fn=observe,
actuator_delay=0.15,
actuator_lag=0.20,
@@ -372,12 +402,12 @@ def test_route_shaped_urgent_lead_acquisition_is_immediate_and_does_not_delay_br
acquired = np.flatnonzero((trace.time >= acquisition_time) & (trace.selected_lead == 0))
assert len(acquired)
assert trace.lead_obstacle_weight_0[acquired[0]] == 1.0
for threshold in (-1.0, -2.0):
assert _first_time_below(trace, threshold) <= _first_time_below(baseline, threshold) + 1e-9
assert trace.solver_failures == 0
record_property("clean_base_solver_failures", baseline.solver_failures)
if baseline.solver_failures:
pytest.xfail("provisional route gate: clean-base MPC loses the abrupt 34.8-to-23.4 m/s lead-acquisition solve")
for threshold in (-1.0, -2.0):
assert _first_time_below(trace, threshold) <= _first_time_below(baseline, threshold) + 1e-9
baseline_gap = baseline.distance_lead - baseline.distance
controlled_gap = trace.distance_lead - trace.distance
@@ -389,6 +419,119 @@ def test_route_shaped_urgent_lead_acquisition_is_immediate_and_does_not_delay_br
assert np.min(controlled_ttc) >= np.min(baseline_ttc) - 1e-3
def test_moderate_urgent_lead_acquisition_does_not_delay_stock_braking():
acquisition_time = 1.0
def observe(current_time: float, lead_name: str, truth: LeadObservation) -> LeadObservation | None:
if current_time < acquisition_time or lead_name == "leadTwo":
return None
return truth | {"radarTrackId": 23}
common = dict(
duration=8.0,
lead_relevancy=True,
speed=20.0,
distance_lead=55.0,
v_lead=12.0,
v_cruise=20.0,
lead_observation_fn=observe,
actuator_delay=0.15,
actuator_lag=0.20,
)
baseline = _run(controller_enabled=False, **common)
trace = _run(controller_enabled=True, **common)
assert trace.solver_failures <= baseline.solver_failures
for threshold in (-1.0, -2.0):
assert _first_time_below(trace, threshold) <= _first_time_below(baseline, threshold) + 1e-9
if trace.solver_failures:
pytest.xfail("opt-in validation: clean base and controller both lose the moderate abrupt-acquisition solve on this platform")
def test_urgent_warm_start_reset_preserves_fcw_history_until_mpc_update():
lead_visible = False
def observe(_current_time: float, lead_name: str, truth: LeadObservation) -> LeadObservation | None:
if not lead_visible or lead_name == "leadTwo":
return None
return truth | {"radarTrackId": 24}
_set_accel_controller_params(enabled=True)
plant = Plant(
lead_relevancy=True,
speed=34.8,
distance_lead=105.0,
lead_observation_fn=observe,
actuator_delay=0.15,
actuator_lag=0.20,
)
while plant.current_time < 1.0:
plant.step(v_lead=23.4, v_cruise=40.0)
lead_visible = True
plant.planner.mpc.crash_cnt = 2.0
crash_count_at_update = []
original_update = plant.planner.mpc.update
def capture_crash_count(*args, **kwargs):
crash_count_at_update.append(plant.planner.mpc.crash_cnt)
return original_update(*args, **kwargs)
plant.planner.mpc.update = capture_crash_count
plant.step(v_lead=23.4, v_cruise=40.0)
assert plant.planner.accel_controller_result.reset_mpc
assert crash_count_at_update == [2.0]
def test_route_e5_low_speed_urgent_closing_stays_with_stock_braking():
# E5 reached its first urgent relative-energy sample below 5 m/s: ego was
# about 4.5 m/s and the decelerating lead was about 1.9 m/s at 16-18 m.
# The controller must hand that case to raw stock MPC immediately even
# though the old speed-only urgent gate was not met.
def lead_speed(current_time: float) -> float:
return max(0.0, 1.9 - 1.16 * current_time)
def observe(current_time: float, lead_name: str, truth: LeadObservation) -> LeadObservation | None:
if lead_name == "leadTwo":
return None
return truth | {
"aLeadK": -1.16 if lead_speed(current_time) > 0.0 else 0.0,
"radarTrackId": 7,
"radar": True,
}
common = dict(
duration=6.0,
profile=0,
lead_relevancy=True,
speed=4.5,
distance_lead=18.0,
v_lead=lead_speed,
v_cruise=23.056,
lead_observation_fn=observe,
actuator_delay=0.15,
actuator_lag=0.20,
)
baseline = _run(controller_enabled=False, **common)
trace = _run(controller_enabled=True, **common)
urgent_demand = (trace.required_decel >= 0.45) & (trace.speed >= 0.30) & ~trace.should_stop
urgent_indices = np.flatnonzero(urgent_demand)
assert len(urgent_indices)
first_urgent = urgent_indices[0]
assert trace.speed[first_urgent] < 5.0
assert trace.urgent_bypass[first_urgent]
assert trace.urgent_bypass[urgent_demand].all()
assert np.max(trace.a_target[urgent_demand]) < 0.0
assert _first_time_below(trace, -1.0) <= _first_time_below(baseline, -1.0) + 1e-9
baseline_gap = baseline.distance_lead - baseline.distance
controlled_gap = trace.distance_lead - trace.distance
assert np.min(controlled_gap) >= np.min(baseline_gap) - 1e-3
assert trace.solver_failures == 0
def test_alternating_full_lead_range_glitch_has_bounded_jerk_and_no_reversal():
glitch_start = 5.0
glitch_end = 5.5
@@ -426,6 +569,62 @@ def test_alternating_full_lead_range_glitch_has_bounded_jerk_and_no_reversal():
assert not np.any(disturbance[positive[0] + 1:] < -0.2)
def test_route_like_tiny_closing_track_noise_does_not_chatter_accel_authority():
noise_start = 3.0
phase_time = 2.5
def observe(current_time: float, lead_name: str, truth: LeadObservation) -> LeadObservation | None:
if lead_name == "leadTwo":
return None
closing_sample = current_time < noise_start or int((current_time - noise_start) // phase_time) % 2 == 0
v_ego = truth["vLead"] - truth["vRel"]
v_lead_observed = v_ego - 0.60 if closing_sample else v_ego + 0.10
a_lead_observed = -0.15 if closing_sample else 0.12
return truth | {
"vRel": v_lead_observed - v_ego,
"aRel": a_lead_observed + truth["aRel"],
"vLead": v_lead_observed,
"vLeadK": v_lead_observed,
"aLeadK": a_lead_observed,
"radarTrackId": 655 if closing_sample else 798,
"radar": True,
}
trace = _run(
duration=16.0,
controller_enabled=True,
profile=0,
lead_relevancy=True,
speed=30.0,
# E8 repeatedly switched radar tracks while following at roughly 55-79 m.
# Use the middle of that band so the fixture isolates tiny relative-speed
# noise rather than entering the desired-gap singularity as ego settles.
distance_lead=70.0,
v_lead=30.0,
v_cruise=30.0,
lead_observation_fn=observe,
actuator_delay=0.10,
actuator_lag=0.20,
)
noise = trace.time >= noise_start
first_noise = np.flatnonzero(noise)[0]
filtered_acceleration = np.convolve(trace.acceleration[noise], np.ones(5) / 5.0, mode="valid")
assert np.max(trace.required_decel[noise]) < 0.03
assert not trace.urgent_bypass[noise].any()
assert np.all(trace.selected_lead[noise] == 0)
assert np.all(trace.state[noise] == AccelControllerState.free)
assert all(source == LongitudinalPlanSource.cruise for source in trace.source[first_noise:])
assert not _has_stable_brake_gas_brake(trace.a_target[noise], threshold=0.08)
assert not _has_stable_brake_gas_brake(trace.effective_accel_max[noise], threshold=0.05)
assert not _has_stable_brake_gas_brake(filtered_acceleration, threshold=0.15)
assert np.max(np.diff(trace.pace[noise])) <= 1e-9
assert np.min(trace.distance_lead - trace.distance) > 55.0
assert trace.solver_failures == 0
def test_repeated_slow_lead_stop_go_has_no_post_settle_reversal():
def lead_speed(current_time: float) -> float:
return float(0.1 * (1.0 - np.cos(np.pi * current_time)))
@@ -449,6 +648,52 @@ def test_repeated_slow_lead_stop_go_has_no_post_settle_reversal():
assert not _has_propulsion_brake_reversal(trace, after=4.0)
def test_route_e7_creeping_lead_departure_has_no_stable_brake_gas_brake():
departure_time = 1.0
def lead_speed(current_time: float) -> float:
if current_time < departure_time:
return 0.0
if current_time < departure_time + 0.5:
return 1.6 * (current_time - departure_time)
if current_time < departure_time + 1.5:
return 0.8
return min(2.5, 0.8 + 1.13 * (current_time - departure_time - 1.5))
def observe(_current_time: float, lead_name: str, truth: LeadObservation) -> LeadObservation | None:
if lead_name == "leadTwo":
return None
return truth | {"radarTrackId": 2133, "radar": True}
trace = _run(
duration=7.0,
controller_enabled=True,
profile=0,
lead_relevancy=True,
speed=0.0,
# E7's bookmarked queue oscillation had a 3.6-3.9 m radar gap.
distance_lead=3.6,
v_lead=lead_speed,
v_cruise=22.352,
lead_observation_fn=observe,
actuator_delay=0.15,
actuator_lag=0.20,
)
after_departure = trace.time >= departure_time
lead_speeds = np.array([lead_speed(max(0.0, current_time - DT_MDL)) for current_time in trace.time])
filtered_acceleration = np.convolve(trace.acceleration[after_departure], np.ones(5) / 5.0, mode="valid")
moving = np.flatnonzero(after_departure & (trace.speed > 0.05))
assert len(moving)
assert trace.time[moving[0]] <= departure_time + 3.5
assert np.all(trace.speed[after_departure] <= lead_speeds[after_departure] + 0.20)
assert not _has_stable_brake_gas_brake(trace.a_target[after_departure], threshold=0.20)
assert not _has_stable_brake_gas_brake(filtered_acceleration, threshold=0.20)
assert np.min(trace.distance_lead - trace.distance) >= 3.5
assert trace.solver_failures == 0
def test_severe_closing_never_delays_braking_or_reduces_clearance():
common = dict(
duration=12.0,
@@ -570,6 +815,42 @@ def test_slow_lead_rejoin_is_smooth_across_profiles_and_actuator_dynamics(profil
assert np.max(trace.a_target[trace.speed > lead_speed + 0.2]) <= 0.2
@pytest.mark.parametrize("profile", range(3), ids=("eco", "normal", "sport"))
def test_decelerating_moving_lead_unwinds_brake_without_false_stop(profile):
def lead_speed(current_time: float) -> float:
if current_time < 2.0:
return 15.0
if current_time >= 8.0:
return 5.0
progress = (current_time - 2.0) / 6.0
return 15.0 - 10.0 * (3.0 * progress * progress - 2.0 * progress * progress * progress)
trace = _run(
duration=18.0,
controller_enabled=True,
profile=profile,
lead_relevancy=True,
speed=20.0,
distance_lead=110.0,
v_lead=lead_speed,
v_cruise=30.0,
actuator_delay=0.20,
actuator_lag=0.25,
)
after_lead_settles = trace.time >= 8.0
command_jerk = _command_jerk(trace, after=1.0)
gap = trace.distance_lead - trace.distance
assert trace.urgent_bypass.any()
assert trace.solver_failures == 0
assert not trace.should_stop[after_lead_settles].any()
assert np.max(np.abs(command_jerk)) < 3.5
assert np.min(trace.speed[after_lead_settles]) >= 2.0
assert np.min(gap) > 20.0
assert not _has_propulsion_brake_reversal(trace, after=1.0)
@pytest.mark.parametrize(
("actuator_delay", "actuator_lag"),
[
@@ -767,6 +1048,58 @@ def test_clear_road_launch_is_immediate_bounded_and_profiles_feel_distinct():
assert final_speeds[2] - final_speeds[1] > 0.4
def test_accelerating_lead_departure_is_prompt_smooth_and_profiles_feel_distinct():
departure_time = 1.0
def lead_speed(current_time: float) -> float:
return 0.0 if current_time < departure_time else min(15.0, 2.0 * (current_time - departure_time))
traces = [
_run(
duration=10.0,
controller_enabled=True,
profile=profile,
lead_relevancy=True,
speed=0.0,
distance_lead=6.0,
v_lead=lead_speed,
v_cruise=22.352,
actuator_model=PRIUS_TSS2_ROUTE_MODEL,
)
for profile in range(3)
]
first_credible_lead_time = departure_time + 0.20
movement_times = []
for trace in traces:
before_confirmation = trace.time < first_credible_lead_time + 3 * DT_MDL
assert np.max(trace.speed[before_confirmation]) < 1e-3
moving = np.flatnonzero((trace.time >= first_credible_lead_time) & (trace.speed > 0.05))
assert len(moving)
movement_times.append(float(trace.time[moving[0]]))
assert movement_times[-1] - first_credible_lead_time <= 1.0
lead_speeds = np.array([lead_speed(max(0.0, t - DT_MDL)) for t in trace.time])
assert np.all(trace.speed <= lead_speeds + 0.20)
assert np.min(trace.distance_lead - trace.distance) >= 5.99
assert not trace.fcw.any()
assert trace.solver_failures == 0
assert not _has_propulsion_brake_reversal(trace, after=departure_time)
departure_jerk = np.diff(trace.a_target)[trace.time[1:] >= departure_time] / DT_MDL
assert np.max(np.abs(departure_jerk)) < 4.0
assert max(movement_times) - min(movement_times) <= DT_MDL
steady = (traces[0].time >= 8.0) & (traces[0].time <= 10.0)
mean_speeds = [float(np.mean(trace.speed[steady])) for trace in traces]
assert mean_speeds[0] < mean_speeds[1] < mean_speeds[2]
assert mean_speeds[1] - mean_speeds[0] >= 0.60
assert mean_speeds[2] - mean_speeds[1] >= 0.20
terminal_distances = [float(trace.distance[-1]) for trace in traces]
assert terminal_distances[1] - terminal_distances[0] >= 2.0
assert terminal_distances[2] - terminal_distances[1] >= 0.50
def test_profile_trajectory_is_pre_mpc_and_not_a_custom_output_clamp():
_set_accel_controller_params(enabled=True, profile=0)
plant = Plant(speed=10.0, actuator_delay=0.15, actuator_lag=0.20)