Files
StarPilot/openpilot/starpilot/speed_limits/speed_domain.py
T
firestarsdog 6221a89f4b SLC Cleanup
2026-10-27 01:39:37 -04:00

238 lines
11 KiB
Python

"""Pure offset selection and qualified raw/cluster coordinate conversion for SLC."""
import math
import struct
from dataclasses import dataclass
from enum import Enum
from openpilot.selfdrive.car.cruise import V_CRUISE_MIN, V_CRUISE_MAX, slc_target_kph
from openpilot.starpilot.speed_limits import acceptance as acc
class DomainStatus(Enum):
VALID = "valid"
ABSENT = "absent"
UNKNOWN = "unknown"
STALE = "stale"
UNAVAILABLE = "unavailable"
INVALID = "invalid"
@dataclass(frozen=True)
class SpeedPair:
status: DomainStatus
raw_mps: float | None = None
cluster_mps: float | None = None
session_id: str | None = None
timestamp_ns: int | None = None
@dataclass(frozen=True)
class OffsetBand:
lower_mps: float
upper_mps: float | None
offset_mps: float
@dataclass(frozen=True)
class OffsetSchedule:
bands: tuple[OffsetBand, ...]
@dataclass(frozen=True)
class DomainContext:
session_id: str
timestamp_ns: int
accepted_raw_mps: float
offset_mps: float
effective_cluster_mps: float
selected_raw_mps: float
selected_delta_mps: float
ego_raw_mps: float
ego_delta_mps: float
@dataclass(frozen=True)
class DomainResolution:
status: DomainStatus
context: DomainContext | None = None
reason: str = ""
@dataclass(frozen=True)
class PlannerCoordinate:
cap_mps: float | None
basis: str
cluster_target_mps: float | None = None
ego_delta_mps: float | None = None
def _number(value: object, *, positive: bool = False) -> bool:
if type(value) not in (int, float):
return False
assert isinstance(value, (int, float))
try:
return math.isfinite(value) and (value > 0 if positive else value >= 0)
except OverflowError:
return False
def _signed_number(value: object) -> bool:
if type(value) not in (int, float):
return False
assert isinstance(value, (int, float))
try:
return math.isfinite(value)
except OverflowError:
return False
def _pair_valid(pair: object, *, positive_raw: bool) -> bool:
if not isinstance(pair, SpeedPair) or not isinstance(pair.status, DomainStatus):
return False
if pair.status is not DomainStatus.VALID:
return pair.raw_mps is None and pair.cluster_mps is None
return (_number(pair.raw_mps, positive=positive_raw) and _number(pair.cluster_mps) and
pair.cluster_mps is not None and pair.raw_mps is not None and
type(pair.session_id) is str and bool(pair.session_id) and
type(pair.timestamp_ns) is int and pair.timestamp_ns >= 0)
def selected_pair_from_kph(raw_mps: float, cluster_kph: float, *, session_id: str, timestamp_ns: int) -> SpeedPair:
"""Convert a qualified dashboard-selected cluster value from km/h to m/s."""
if not _number(raw_mps, positive=True) or not _number(cluster_kph):
return SpeedPair(DomainStatus.INVALID)
return SpeedPair(DomainStatus.VALID, raw_mps, cluster_kph / 3.6, session_id, timestamp_ns)
def context_is_valid(context: object) -> bool:
if not isinstance(context, DomainContext):
return False
return (type(context.session_id) is str and bool(context.session_id) and type(context.timestamp_ns) is int and
context.timestamp_ns >= 0 and _number(context.accepted_raw_mps, positive=True) and
_signed_number(context.offset_mps) and _number(context.effective_cluster_mps, positive=True) and
math.isclose(context.effective_cluster_mps, context.accepted_raw_mps + context.offset_mps, rel_tol=0, abs_tol=1e-9) and
_number(context.selected_raw_mps, positive=True) and _number(context.selected_delta_mps) and
_number(context.ego_raw_mps) and _number(context.ego_delta_mps))
def _schedule_valid(schedule: object) -> bool:
if not isinstance(schedule, OffsetSchedule) or not isinstance(schedule.bands, tuple) or not schedule.bands:
return False
previous_upper = None
for i, band in enumerate(schedule.bands):
if not isinstance(band, OffsetBand) or not _number(band.lower_mps) or not _signed_number(band.offset_mps):
return False
if band.upper_mps is not None and (not _number(band.upper_mps, positive=True) or band.upper_mps <= band.lower_mps):
return False
if i and (previous_upper is None or band.lower_mps < previous_upper):
return False
previous_upper = band.upper_mps
return True
def offset_for_limit(speed_mps: float, schedule: OffsetSchedule) -> float | None:
if not _number(speed_mps, positive=True) or not _schedule_valid(schedule):
return None
for band in schedule.bands:
if band.lower_mps <= speed_mps and (band.upper_mps is None or speed_mps < band.upper_mps):
return band.offset_mps
return None
def adjusted_limit(speed_mps: float, schedule: OffsetSchedule, *, minimum_target_mps: float = 0.0) -> float | None:
"""Apply the offset within the ordinary cruise-speed bounds."""
offset = offset_for_limit(speed_mps, schedule)
if offset is None or not _number(minimum_target_mps):
return None
value = speed_mps + offset
if offset < 0 and value <= minimum_target_mps:
value = speed_mps
return min(max(value, V_CRUISE_MIN / 3.6), V_CRUISE_MAX / 3.6) if _number(value, positive=True) else None
def set_speed_matches(target_mps: float, raw_kph: float, cluster_kph: float | None, is_metric: bool | None) -> bool:
"""Compare the SET values Card would actually write, including display-unit rounding."""
if type(is_metric) is not bool or not all(_number(v, positive=True) for v in (target_mps, raw_kph, cluster_kph)):
return False
if not V_CRUISE_MIN / 3.6 <= target_mps <= V_CRUISE_MAX / 3.6:
return False
assert cluster_kph is not None
# Card receives targetMps as Float32; transport can move a half-unit rounding boundary.
target_mps = struct.unpack('f', struct.pack('f', target_mps))[0]
target_kph = slc_target_kph(target_mps, is_metric)
return (math.isclose(target_kph, raw_kph, rel_tol=0, abs_tol=0.001) and
math.isclose(target_kph, cluster_kph, rel_tol=0, abs_tol=0.001))
def resolve(decision: acc.Decision, schedule: OffsetSchedule, selected: SpeedPair, ego: SpeedPair) -> DomainResolution:
"""Bind an explicit offset and contemporaneous speed pairs to an acceptance output."""
if (not isinstance(decision, acc.Decision) or not isinstance(decision.state, acc.State) or decision.errors or
not _schedule_valid(schedule) or not _pair_valid(selected, positive_raw=True) or
not _pair_valid(ego, positive_raw=False) or
type(decision.state.session_id) is not str or not decision.state.session_id or
type(decision.state.last_timestamp_ns) is not int or decision.state.last_timestamp_ns < 0 or
not isinstance(decision.authority, acc.Authority) or
not acc.authority_is_valid(decision.authority) or not isinstance(decision.policy, acc.Policy) or
any(type(value) is not bool for value in (decision.policy.confirm_lower, decision.policy.confirm_higher,
decision.policy.fallback_previous, decision.policy.display_only)) or
not acc.observation_is_valid(decision.state.observation, session_id=decision.state.session_id)):
return DomainResolution(DomainStatus.INVALID, reason="invalid acceptance, schedule or speed pair")
accepted = decision.state.accepted
if accepted is not None and (not isinstance(accepted, acc.AcceptedLimit) or
type(accepted.session_id) is not str or not accepted.session_id or
type(accepted.accepted_at_ns) is not int or accepted.accepted_at_ns < 0 or
not acc.observation_is_valid(acc.Observation(acc.ObservationKind.VALID, accepted.candidate),
session_id=decision.state.session_id)):
return DomainResolution(DomainStatus.INVALID, reason="invalid accepted limit")
if selected.status is DomainStatus.INVALID or ego.status is DomainStatus.INVALID:
return DomainResolution(DomainStatus.INVALID, reason="invalid speed coordinate")
if ((selected.status is DomainStatus.VALID and (selected.session_id != decision.state.session_id or
selected.timestamp_ns != decision.state.last_timestamp_ns)) or
(ego.status is DomainStatus.VALID and (ego.session_id != decision.state.session_id or
ego.timestamp_ns != decision.state.last_timestamp_ns))):
return DomainResolution(DomainStatus.INVALID, reason="speed pair session or timestamp mismatch")
if decision.control_target_mps is None or selected.status is not DomainStatus.VALID or ego.status is not DomainStatus.VALID:
return DomainResolution(DomainStatus.UNAVAILABLE, reason="target or coordinate evidence unavailable")
target = decision.control_target_mps
if (not _number(target, positive=True) or accepted is None or
accepted.candidate.speed_mps != target or not decision.authority.can_control or
decision.policy.display_only or
(decision.state.observation.kind is not acc.ObservationKind.VALID and not acc.previous_limit_is_qualified(decision))):
return DomainResolution(DomainStatus.INVALID, reason="accepted target mismatch")
assert selected.raw_mps is not None and selected.cluster_mps is not None
assert ego.raw_mps is not None and ego.cluster_mps is not None
offset = offset_for_limit(target, schedule)
if offset is None:
return DomainResolution(DomainStatus.UNAVAILABLE, reason="no configured offset band")
ego_delta = max(0.0, ego.cluster_mps - ego.raw_mps)
effective = adjusted_limit(target, schedule, minimum_target_mps=ego_delta)
if effective is None:
return DomainResolution(DomainStatus.INVALID, reason="nonpositive effective target")
offset = effective - target
return DomainResolution(DomainStatus.VALID, DomainContext(
decision.state.session_id, decision.state.last_timestamp_ns, target, offset, effective,
selected.raw_mps, max(0.0, selected.cluster_mps - selected.raw_mps),
ego.raw_mps, ego_delta))
def to_planner_coordinate(context: DomainContext, contribution_raw_mps: float | None, basis: str) -> PlannerCoordinate:
"""Apply a raw override in cluster space, then remove the ego delta exactly once."""
if (not context_is_valid(context) or basis not in ("none", "adoption_clear", "pedal", "persistent") or
(basis in ("none", "adoption_clear")) != (contribution_raw_mps is None)):
return PlannerCoordinate(None, "invalid")
if contribution_raw_mps is not None and not _number(contribution_raw_mps, positive=True):
return PlannerCoordinate(None, "invalid")
cluster_target = context.effective_cluster_mps
if basis == "pedal":
if contribution_raw_mps != context.ego_raw_mps:
return PlannerCoordinate(None, "invalid")
cluster_target = max(cluster_target, context.ego_raw_mps + context.ego_delta_mps)
elif basis == "persistent":
assert contribution_raw_mps is not None
cluster_target = max(cluster_target, contribution_raw_mps + context.selected_delta_mps)
cap = cluster_target - context.ego_delta_mps
if not _number(cap, positive=True):
return PlannerCoordinate(None, "nonpositive", cluster_target, context.ego_delta_mps)
return PlannerCoordinate(cap, basis, cluster_target, context.ego_delta_mps)