Files

347 lines
15 KiB
Python

"""Frozen CEM stop-light model-length detector over explicit owner evidence.
The current model action.shouldStop can veto release, but cannot acquire a light.
No Params, IPC, sign recognition, or longitudinal control is owned here.
"""
from __future__ import annotations
from dataclasses import dataclass
import math
import numpy as np
from opendbc.car.ford.values import CAR as FORD_CAR
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.realtime import DT_MDL
from openpilot.starpilot.conditional_mode.turn_scene import committed_turn_scene
FORD_STOP_NAMES = frozenset(str(car) for car in FORD_CAR)
THRESHOLD = 1.0 - 1.0 / math.e
MAX_SOURCE_AGE_S = 0.25
MAX_FRAME_GAP_S = MAX_SOURCE_AGE_S
MPH_TO_MPS = 0.44704
MPS_TO_MPH = 1.0 / MPH_TO_MPS
FROZEN_RAW_STOP_DISTANCE_M = 50.0 # CRUISING_SPEED * PLANNER_TIME.
STOP_MODEL_RELEASE_MARGIN_M = 4.0 # Existing model-stop off margin.
@dataclass(frozen=True)
class StopLead:
present: bool
distance_m: float | None = None
speed_mps: float | None = None
radar: bool | None = None
model_probability: float | None = None
tracked: bool | None = None
@dataclass(frozen=True)
class StopFrame:
observed_mono_s: float
now_mono_s: float
speed_mps: float | None
model_horizon_m: float | None
model_stop_time_s: float | None
traffic_mode: bool | None
stop_sign_confirmed: bool | None
forcing_stop: bool | None
lead: StopLead | None
standstill: bool | None
left_blinker: bool | None
right_blinker: bool | None
steering_angle_deg: float | None
driving_in_curve: bool | None
car_fingerprint: str | None
dashboard_stop_sign: bool | None = None
pedal_override: bool | None = None
model_tick_mono_s: float | None = None
model_should_stop: bool | None = None
stationary_launch_clear: bool = False
standstill_path_extent_m: float | None = None
longitudinal_stop_active: bool = False
@dataclass(frozen=True)
class StopObservation:
light_detected: bool | None
standstill_hold: bool | None
standstill_reason: str | None
model_stopping: bool | None
def _finite(value: object, low: float, high: float) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
try:
number = float(value)
except (OverflowError, ValueError):
return None
return number if math.isfinite(number) and low <= number <= high else None
def _boolean(value: object) -> bool | None:
return value if type(value) is bool else None
def _interp(speed_mph: float, low: float, high: float) -> float:
return float(np.interp(speed_mph, [0.0, 35.0, 45.0], [low, low, high]))
class StopLightDetector:
"""One frozen model-tick filter; missing decisive owner input resets it."""
def __init__(self):
self.reset()
def reset(self) -> None:
self.light_filter = FirstOrderFilter(0.0, 0.8, DT_MDL)
self.lead_clear_filter = FirstOrderFilter(0.0, 0.6, DT_MDL)
self.light_detected = False
self.model_detected = False
self.light_hold_until_s = 0.0
self.approach_hold_until_s = 0.0
self.standstill_model_stopped = False
self.standstill_reason: str | None = None
self.committed = False
self.standstill_committed = False
self.commit_distance_m = 0.0
self.commit_clear_since_s: float | None = None
self.last_observed_mono_s: float | None = None
self.last_model_tick_mono_s: float | None = None
def invalidate(self) -> None:
committed, distance, stopped = self.committed, self.commit_distance_m, self.standstill_committed
self.reset()
# Missing evidence cannot acquire a stop or prove that a committed one cleared.
self.committed, self.commit_distance_m, self.standstill_committed = committed, distance, stopped
def _reset_light(self) -> None:
# Retain filter alpha across resets; clear its state and latches.
self.light_filter.x = 0.0
self.lead_clear_filter.x = 0.0
self.light_detected = False
self.model_detected = False
self.light_hold_until_s = 0.0
self.approach_hold_until_s = 0.0
def _commit(self, frame: StopFrame, horizon: float | None, model_stopping: bool | None, acquire: bool, dt: float) -> None:
if frame.pedal_override is True:
self.committed = False
self.standstill_committed = False
self.commit_clear_since_s = None
return
if acquire and not self.committed and horizon is not None:
self.committed = True
self.commit_distance_m = horizon
if self.committed:
assert frame.speed_mps is not None
self.commit_distance_m = max(0.0, self.commit_distance_m - frame.speed_mps * dt)
launch_clear = (self.standstill_committed and frame.standstill is True and frame.speed_mps <= 0.01 and
frame.forcing_stop is False and frame.stationary_launch_clear is True)
clear_distance = max(0.0 if launch_clear else FROZEN_RAW_STOP_DISTANCE_M,
self.commit_distance_m) + STOP_MODEL_RELEASE_MARGIN_M
clear = (horizon is not None and model_stopping is False and frame.model_should_stop is False and
horizon >= clear_distance)
if clear:
if self.commit_clear_since_s is None:
self.commit_clear_since_s = frame.now_mono_s
elif frame.now_mono_s - self.commit_clear_since_s >= 0.5:
self.committed = False
self.standstill_committed = False
self.commit_clear_since_s = None
self._reset_light()
else:
self.commit_clear_since_s = None
def _commit_ford_standstill(self, frame: StopFrame, extent: float) -> None:
# Commit only an already established, geometry-qualified stopped hold.
# The model action corroborates that hold; it cannot acquire a light alone.
if (frame.car_fingerprint in FORD_STOP_NAMES and frame.standstill is True and
frame.pedal_override is False and frame.model_should_stop is True and
frame.longitudinal_stop_active is True and
self.standstill_reason == 'light' and self.standstill_model_stopped and
extent < FROZEN_RAW_STOP_DISTANCE_M):
self._commit(frame, extent, True, True, 0.0)
def _standstill(self, frame: StopFrame, model_stopped: bool) -> tuple[bool | None, str | None]:
if not frame.standstill:
self.standstill_reason = None
self.standstill_model_stopped = False
return False, None
if _boolean(frame.pedal_override) is None:
self.standstill_reason = None
self.standstill_model_stopped = False
return None, None
if frame.pedal_override:
self.standstill_reason = None
self.standstill_model_stopped = False
return False, None
if not frame.stop_sign_confirmed and _boolean(frame.dashboard_stop_sign) is None:
self.standstill_reason = None
self.standstill_model_stopped = False
return None, None
if self.committed:
self.standstill_committed = True
if frame.stop_sign_confirmed or frame.dashboard_stop_sign:
self.standstill_reason = 'sign'
elif self.light_detected or frame.forcing_stop or model_stopped:
if self.standstill_reason is None:
self.standstill_reason = 'light'
elif self.standstill_reason == 'light':
self.standstill_reason = None
if self.standstill_reason == 'sign':
return True, 'sign'
return bool(self.light_detected or frame.forcing_stop or model_stopped), self.standstill_reason
def step(self, frame: StopFrame) -> StopObservation:
unknown = StopObservation(None, None, None, None)
observed = _finite(frame.observed_mono_s, 0.0, 1e12)
model_tick = _finite(frame.model_tick_mono_s, 0.0, 1e12) if frame.model_tick_mono_s is not None else observed
now = _finite(frame.now_mono_s, 0.0, 1e12)
speed = _finite(frame.speed_mps, 0.0, 80.0)
horizon = _finite(frame.model_horizon_m, 0.0, 500.0)
model_time = _finite(frame.model_stop_time_s, 0.0, 10.0)
forcing_stop = _boolean(frame.forcing_stop)
lead = frame.lead
if (
observed is None
or model_tick is None
or now is None
or not 0.0 <= now - observed <= MAX_SOURCE_AGE_S
or not observed <= model_tick <= now
or speed is None
or model_time is None
or _boolean(frame.traffic_mode) is None
or _boolean(frame.stop_sign_confirmed) is None
or forcing_stop is None
or _boolean(frame.standstill) is None
or _boolean(frame.left_blinker) is None
or _boolean(frame.right_blinker) is None
or type(frame.car_fingerprint) is not str
or not frame.car_fingerprint
or lead is None
or _boolean(lead.present) is None
):
self.invalidate()
return unknown
if self.last_model_tick_mono_s is not None:
elapsed_ns = round(model_tick * 1e9) - round(self.last_model_tick_mono_s * 1e9)
if elapsed_ns <= 0:
return unknown # Re-reading one model event cannot renew a stop scene.
if elapsed_ns > round(MAX_FRAME_GAP_S * 1e9):
self.invalidate()
return unknown
if lead.present:
distance = _finite(lead.distance_m, 0.0, 500.0)
lead_speed = _finite(lead.speed_mps, -30.0, 100.0)
lead_prob = _finite(lead.model_probability, 0.0, 1.0)
if None in (distance, lead_speed, lead_prob) or _boolean(lead.radar) is None or (_boolean(lead.tracked) is None and not self.committed):
self.invalidate()
return unknown
else:
distance, lead_speed, lead_prob = None, None, None
assert lead is not None
if frame.pedal_override is True:
self.reset()
return StopObservation(False, False, None, False)
dt = model_tick - self.last_model_tick_mono_s if self.last_model_tick_mono_s is not None else DT_MDL
self.last_observed_mono_s = observed
self.last_model_tick_mono_s = model_tick
if horizon is None or (self.committed and lead.present and lead.tracked is None):
self._commit(frame, None, None, False, dt)
if self.committed:
self.light_detected = True
hold, reason = self._standstill(frame, True)
return StopObservation(True, hold, reason, None)
extent = _finite(frame.standstill_path_extent_m, 0.0, 500.0)
if horizon is None and frame.standstill is True and frame.pedal_override is False and extent is not None:
self._commit_ford_standstill(frame, extent)
release_distance = FROZEN_RAW_STOP_DISTANCE_M + (STOP_MODEL_RELEASE_MARGIN_M if self.standstill_model_stopped else 0.0)
self.standstill_model_stopped = extent < release_distance
hold, reason = self._standstill(frame, self.standstill_model_stopped or forcing_stop)
return StopObservation(None, hold, reason, None)
self.invalidate()
return unknown
# At standstill the speed-scaled detector has a zero threshold. Its raw
# 50 m fallback therefore needs the same spatial release band as an approach;
# otherwise a 49.9/50.1 m horizon alternates the actual CEM hold each tick.
if frame.stationary_launch_clear is True and frame.standstill and frame.pedal_override is False:
# The qualified launch proof clears the coarse stationary <50m fallback;
# the acquired stop still holds until _commit's continuous clear dwell.
self.standstill_model_stopped = False
elif frame.standstill and frame.pedal_override is False:
release_distance = FROZEN_RAW_STOP_DISTANCE_M + (STOP_MODEL_RELEASE_MARGIN_M if self.standstill_model_stopped else 0.0)
self.standstill_model_stopped = horizon < release_distance
else:
self.standstill_model_stopped = False
self._commit_ford_standstill(frame, horizon)
model_stopped = (self.standstill_model_stopped if frame.standstill else horizon < FROZEN_RAW_STOP_DISTANCE_M) or forcing_stop
if frame.stop_sign_confirmed:
self.light_filter.x = 1.0
self.light_detected = True
hold, reason = self._standstill(frame, model_stopped)
return StopObservation(True, hold, reason, None)
turn_scene = committed_turn_scene(
speed_mps=speed, standstill=frame.standstill, left_blinker=frame.left_blinker,
right_blinker=frame.right_blinker, steering_angle_deg=frame.steering_angle_deg,
driving_in_curve=frame.driving_in_curve,
)
if turn_scene is None:
self.invalidate()
return unknown
if turn_scene or frame.traffic_mode or speed * MPS_TO_MPH > 75.0:
self._reset_light()
self._commit(frame, horizon, horizon < FROZEN_RAW_STOP_DISTANCE_M, False, dt)
self.light_detected = self.committed
hold, reason = self._standstill(frame, model_stopped)
return StopObservation(self.light_detected, hold, reason, False)
speed_mph = speed * MPS_TO_MPH
self.light_filter.update_alpha(
min(_interp(speed_mph, 0.35, 0.8), 0.25) if frame.car_fingerprint == 'HYUNDAI_ELANTRA_2021' else _interp(speed_mph, 0.35, 0.8)
)
self.lead_clear_filter.update_alpha(_interp(speed_mph, 0.6, 0.35))
boost = _interp(speed_mph, 1.0, 1.2)
cap_factor = _interp(speed_mph, 0.0, 1.0)
adjusted_time = model_time * boost
if cap_factor > 0.0:
adjusted_time = min(adjusted_time, 9.0 * cap_factor + model_time * (1.0 - cap_factor))
threshold_m = max(speed * adjusted_time, 0.0)
if self.model_detected:
model_stopping = horizon < threshold_m + 4.0
else:
model_stopping = horizon < max(threshold_m - 2.5, 0.0)
self.model_detected = model_stopping
relevant = bool(lead.present and distance is not None and distance < threshold_m + 15.0)
if lead.present:
assert distance is not None and lead_speed is not None and lead_prob is not None
trackable_approach = bool(relevant and not lead.radar and lead_prob >= 0.9 and lead_speed < 4.5 and not lead.tracked)
handoff_speed = lead_speed < 2.0
else:
trackable_approach = False
handoff_speed = False
if (self.light_detected or self.model_detected or now < self.approach_hold_until_s) and trackable_approach:
self.approach_hold_until_s = now + 1.0
approach_latched = now < self.approach_hold_until_s and trackable_approach
handoff = relevant and not lead.tracked and ((self.light_detected and handoff_speed) or approach_latched)
if handoff:
lead_cleared = True
else:
self.lead_clear_filter.update(not relevant)
lead_cleared = self.lead_clear_filter.x >= THRESHOLD
self.light_filter.update(model_stopping and lead_cleared)
filtered = bool(self.light_filter.x >= THRESHOLD**2 and lead_cleared)
active = bool(filtered or handoff or approach_latched)
strong = horizon < max(threshold_m - 10.0, 0.0)
if filtered and (model_stopped or strong):
self.light_hold_until_s = now + 4.0
hold_context = not relevant or trackable_approach
self._commit(frame, horizon, model_stopping, filtered or handoff or approach_latched, dt)
self.light_detected = bool(self.committed or active or (hold_context and now < self.light_hold_until_s))
hold, reason = self._standstill(frame, model_stopped)
return StopObservation(self.light_detected, hold, reason, model_stopping)