Ford: add C2-free shared path experiment

This commit is contained in:
Isaac Barham
2026-09-04 15:22:32 -04:00
parent 727c26ce8c
commit daeb966d05
7 changed files with 287 additions and 745 deletions
+55 -70
View File
@@ -13,88 +13,73 @@ Turning it off restores the prior selection, including the older observer if
that option was already enabled. No mid-drive controller switching is added.
Neither the CAN frequency (100 Hz for LMC2), driver/fault enablement, nor the
existing downstream curvature and Panda checks are changed by this experiment.
No new per-vehicle tuning table or online learner is added.
existing Panda checks are changed by this experiment. No per-vehicle tuning
table, online learner, host-side slew, or coefficient handoff is added.
## One request, then allocation
## C2-free request
The controller keeps three decisions separate:
The controller samples one model pose at a firmware-derived temporal horizon:
1. **Hold request.** Use a single 7 m remaining-model preview for offset and
heading. Advance the reference by the existing 0.1 s nominal prediction
interval. Keep a bounded gentle C2 contribution, and add only the model pose
beyond the existing 0.006 /m gentle envelope. That excess grows linearly for
a circular-path fixture; it is not the old blend share multiplied by pose.
2. **Correction.** Compare model pose at the prediction interval with a
constant-curvature projection from measured steering-derived curvature.
Express offset/heading error in the predicted vehicle frame. Apply its
normalized contribution **after** hold-request saturation so a large raw
preview cannot swallow an unwind correction. At the modeled arc, this
correction is zero while the holding request remains. No noisy measured
curvature derivative or integral accumulation is used. If the model path
straightens while measured curvature is still large, recovery keeps the
opposing pose correction active until actual motion returns to the gentle
envelope; the reference alone cannot switch that correction off.
3. **Allocation.** Independently supply that total with reachable C0/C1/C2
states. Channel preference cannot change the requested total. C2 is preferred
for settled gentle driving, reduced across the existing 0.0060.012 /m band,
and requested zero for large maneuvers or a still-large measured turn.
Unreachable fast demand is reported,
not used as permission to refill C2. C3 remains zero.
```text
H = sqrt(0.30078125 / 0.25) = 1.096870548 seconds
C0 = model lateral displacement at H
C1 = wrap(model heading change at H - measured curvature * model arc to H)
C2 = 0
C3 = 0
```
The allocator scores candidate packets against every nominal 4 ms tick in the
next 100 Hz period, not just its endpoint. It considers neighboring wire
quantizations. First minimize total-contribution error beyond half-LSB encoding
uncertainty, then favor the C2 endpoint and coordinated C0/C1 preference. Avoid
unnecessary latent coefficient accumulation beyond nominal contribution caps.
The model endpoint is translated and rotated from the model's first pose before
encoding. `position.y[0]` is not used because the rolling model begins at ego.
C0 carries the ordinary arc and centering request. C1 carries only the heading
that the current measured curvature is not predicted to cover: it adds when the
vehicle is behind, approaches zero on an aligned arc, and reverses when measured
curvature is ahead of the model.
## Explicit assumptions and limitations
The horizon comes from the decoded ML3V-14D003-BD normalized contributions:
The contribution/slew model comes from decoded **ML3V-14D003-BD**, not verified
Lightning RL38-14D003-AA or logged Raptor BC firmware. Factoring out its common
speed gain leaves:
```text
q0 = clip(0.5*C0_state, ±0.5)
q2 = clip(0.30078125*v²*C2_state, ±0.5)
```
- `q0 = clip(0.5*C0_state, ±0.5)`
- `q1 = clip(10*C1_state, ±0.349609375)`
- `q2 = clip(0.30078125*vRaw²*C2_state, ±0.5)`
For a constant-curvature path, `y(H) ≈ 0.5*curvature*(vH)²`. Equating its C0
contribution with C2 gives the horizon above. This is a field conversion from
one decoded firmware, not a fitted Lightning response gain.
These are nominal internal contributions, **not steering angle, torque, yaw, or
curvature**. They are fixed response assumptions, not a newly identified plant.
The hold request is bounded to nominal fast-channel authority before adding
bounded feedback. This sacrifices excess raw coefficient windup under the BD
hypothesis; if that hypothesis is wrong, actual maneuver authority may be weaker.
C0/C1 retain the full symmetric DBC-safe ranges (±5.11 m and ±0.5 rad). The
smaller contribution plateaus decoded from Raptor BD are not treated as proven
Lightning command limits; earlier physical testing found that doing so weakened
turn authority. The downstream CAN packer still quantizes the fields, and the
PSCM still owns any internal coefficient slew.
Primary states use the decoded 4 ms slew steps; inactive states drain at their
separate finite rates. Startup/gaps start with uncertainty intervals rather than
assumed zero. Before nominal history initializes, the prior default encoder is
used with output continuity. Missing/invalid model or motion input ramps the
requested path toward zero through existing limits rather than inventing error
correction. Packet prediction includes Float32 serialization, the existing
downstream C2 rate limiter, and sign-reversed DBC rounding. It does not have
PSCM execution acknowledgments or a verified delivery
delay model. Unmodeled firmware shaping remains unmodeled.
## Explicit limitations
The 0.1 s prediction is inherited as a short nominal horizon; it is **not a
verified learned lateral delay**. Wheel-derived curvature is not a complete
vehicle-motion measurement. This is not a claim of universal Ford stability or
servo-like tracking. Unchanged safety checks do not by themselves certify the
new control law. Offline replay holds actual motion/model replanning fixed and
cannot predict changed intervention rates or prove a physical steering cure.
The coefficient relationship has not been confirmed in Lightning RL38 firmware
or across Ford models. Model output updates at approximately 20 Hz even though
the latest command is repeated at 100 Hz. Wheel-derived curvature is not a
complete vehicle-motion measurement, and its multiplication by the model arc
can reintroduce a heading correction when model and measured motion are not
latency-aligned. Offline route analysis found no systematic high-speed early
entry and retained the large fast request at selected failed turns, but one
recorded hunting window became better and another became worse.
Missing or invalid model input sends a valid zero-coefficient path while lateral
control remains active; it never falls back to a C2-producing controller. On
inactive lateral control the path is invalid and the existing car controller
sends zero coefficients. This experiment cannot prove physical tracking from
offline replay because the PSCM's inner controller and vehicle response remain
black boxes.
## Diagnostics and validation
`Ford path controller selected` records the class at startup. When selected,
`Ford shared path experiment` records the nominal hypothesis, status, consumed
model timestamp, holding request, feedback, total, pose errors, state intervals,
predicted contribution error and shortfall at 5 Hz. Existing rlogs retain the
actual outgoing path/CAN commands at their original rate. `active` means the
experimental allocator is selected with initialized nominal history, not that
the model has been validated against the PSCM.
`Ford shared path experiment` records the hypothesis, status, consumed model
timestamp, temporal horizon, model offset/heading, predicted heading, heading
residual, model arc, and output fields at 5 Hz. Existing rlogs retain the actual
outgoing path/CAN commands at their original rate.
Unit tests cover hold-versus-correction behavior, both transfer directions,
unknown history, inactive drain, intermediate ticks, quantization, saturation,
S-shaped preferences, invalid input, timing gaps, unchanged downstream limits,
and default-off selection. Replay includes interventions; it is a command audit,
not a new simulated vehicle trajectory. Hardware validation must separately
assess authority, oscillation, tracking, overrides, and availability in a
controlled test environment before treating this as a driving improvement.
Unit tests cover temporal interpolation, coordinate transforms, constant-curve
equivalence, under/aligned/overtracking C1 behavior, direct sign reversal,
C2/C3 exclusion, invalid input, DBC bounds, logging, and default-off selection.
Hardware validation must separately assess turn authority, oscillation,
tracking, overrides, and availability in a controlled test environment.
@@ -1,340 +1,105 @@
"""Experimental shared path loop. The BD contribution map is a hypothesis, not a vehicle plant.
All contributions below are divided by the firmware's common speed gain. They
are neither curvature nor steering angle. Keep this experiment separate from
the default controller until its closed-loop response has been validated.
"""
"""Default-off Ford C2-free path experiment."""
from dataclasses import dataclass
from itertools import product
import math
import struct
from typing import Any
from opendbc.can import CANPacker
from opendbc.car.ford.values import CarControllerParams, FordFlags
from openpilot.selfdrive.controls.lib.ford_path import (
DBC_ANGLE, DBC_CURVATURE, DBC_OFFSET, FordPath, FordPathController,
_model_path, _path_pose, _predicted_pose, _relative_pose,
)
import numpy as np
_GENTLE_CURVATURE = 0.006
_FULL_POSE_CURVATURE = 0.012
_PREVIEW_DISTANCE = 7.0
_PREDICTION_TIME = 0.1
_CONTRIBUTION_LIMITS = (0.5, 0.349609375, 0.5)
_FAST_AUTHORITY = sum(_CONTRIBUTION_LIMITS[:2])
_FIRMWARE_DT = 0.004
_STATE_RATES = (1.5, 0.100006103515625, 0.0030059814453125)
_INACTIVE_RATES = (300.0, 30.0, 2.0)
_RESOLUTIONS = (0.01, 0.0005, 0.00002)
_RANGES = tuple((-min(abs(lo), abs(hi)), min(abs(lo), abs(hi))) for lo, hi in (DBC_OFFSET, DBC_ANGLE, DBC_CURVATURE))
_UNKNOWN_RANGES = (*_RANGES[:2], (-0.03024, 0.03024)) # includes an earlier C2 + 10*C3 target
from opendbc.car.ford.values import FordFlags
from openpilot.selfdrive.controls.lib.ford_path import DBC_ANGLE, DBC_OFFSET, FordPath
def _clip(value, lower, upper):
return min(max(value, lower), upper)
def contributions(values, speed):
weights = (0.5, 10.0, 0.30078125 * speed ** 2)
return tuple(_clip(w * v, -limit, limit) for w, v, limit in zip(weights, values, _CONTRIBUTION_LIMITS, strict=True))
def _values(path):
return path.path_offset, path.path_angle, path.curvature
def _advance(state, command, ticks, rates=_STATE_RATES):
return tuple(_clip(target, value - rate * ticks * _FIRMWARE_DT, value + rate * ticks * _FIRMWARE_DT)
for value, target, rate in zip(state, command, rates, strict=True))
# For a constant-curvature path, y(H) ~= 0.5 * curvature * (speed * H)^2.
# This horizon makes the recovered C0 contribution equal the recovered C2
# contribution: 0.5 * y(H) == 0.30078125 * speed^2 * curvature.
_C2_FREE_HORIZON_S = math.sqrt(0.30078125 / 0.25)
_C0_RANGE = (-min(abs(DBC_OFFSET[0]), abs(DBC_OFFSET[1])), min(abs(DBC_OFFSET[0]), abs(DBC_OFFSET[1])))
_C1_RANGE = (-min(abs(DBC_ANGLE[0]), abs(DBC_ANGLE[1])), min(abs(DBC_ANGLE[0]), abs(DBC_ANGLE[1])))
@dataclass(frozen=True)
class PathRequest:
total: float
feedforward: float
feedback: float
preferred: FordPath
offset_error: float
heading_error: float
geometric_request: tuple[float, float]
class _C2FreeRequest:
command: FordPath
model_offset: float
model_heading: float
predicted_heading: float
arc: float
def request_for_model(model, desired_curvature: float, *, current_curvature: float, v_ego: float,
response_speed: float | None = None) -> PathRequest | None:
path = _model_path(model) if model is not None else None
if path is None or not all(math.isfinite(v) for v in (desired_curvature, current_curvature, v_ego)):
def _clip(value: float, limits: tuple[float, float]) -> float:
return min(max(value, limits[0]), limits[1])
def _wrap(angle: float) -> float:
return math.atan2(math.sin(angle), math.cos(angle))
def _sample(time: list[float], values: list[float]) -> float:
return float(np.interp(_C2_FREE_HORIZON_S, time, values))
def _c2_free_request(model, current_curvature: float) -> _C2FreeRequest | None:
"""Encode one temporal model pose through C0/C1 without charging C2."""
try:
time = [float(value) for value in model.position.t]
x = [float(value) for value in model.position.x]
y = [float(value) for value in model.position.y]
heading = [float(value) for value in model.orientation.z]
current_curvature = float(current_curvature)
except (AttributeError, TypeError, ValueError):
return None
speed = max(v_ego, 0.0)
response_speed = speed if response_speed is None else max(response_speed, 0.0)
if not math.isfinite(response_speed):
if (len(time) < 2 or len(time) != len(x) or len(time) != len(y) or len(time) != len(heading) or
not math.isfinite(current_curvature) or
not all(math.isfinite(value) for values in (time, x, y, heading) for value in values) or
any(after <= before for before, after in zip(time[:-1], time[1:], strict=True)) or
not time[0] <= _C2_FREE_HORIZON_S <= time[-1]):
return None
advance = min(speed * _PREDICTION_TIME, path[0][-1])
horizon = min(_PREVIEW_DISTANCE, path[0][-1] - advance)
if horizon <= 1e-3:
return None
offset, angle = _relative_pose(advance + horizon, path, _path_pose(advance, path))
error_y, error_heading = _relative_pose(advance, path, _predicted_pose(advance, current_curvature, 0.0))
demand = max(abs(2.0 * offset / horizon ** 2), abs(angle / horizon), abs(desired_curvature))
# A soft residual beyond the existing gentle-curvature envelope, not p*pose.
# On a circular path its amplitude grows linearly with excess curvature.
excess = max(0.0, 1.0 - _GENTLE_CURVATURE / max(demand, _GENTLE_CURVATURE))
recovery = max(0.0, 1.0 - _GENTLE_CURVATURE / max(abs(current_curvature), _GENTLE_CURVATURE))
correction_share = max(excess, recovery)
offset_ff, angle_ff = excess * offset, excess * angle
base_curvature = _clip(desired_curvature, -_GENTLE_CURVATURE, _GENTLE_CURVATURE)
feedforward = _clip(sum(contributions((offset_ff, angle_ff, base_curvature), response_speed)), -_FAST_AUTHORITY, _FAST_AUTHORITY)
# Correct AFTER nominal feedforward saturation. Otherwise a large raw preview
# can swallow an unwind correction without changing predicted contribution.
feedback = _clip(correction_share * (0.5 * error_y + 10.0 * error_heading), -_FAST_AUTHORITY, _FAST_AUTHORITY)
total = _clip(feedforward + feedback, -_FAST_AUTHORITY, _FAST_AUTHORITY)
unwrapped_heading = np.unwrap(heading).tolist()
target_x = _sample(time, x)
target_y = _sample(time, y)
target_heading = _sample(time, unwrapped_heading)
distance = [0.0]
for i in range(1, len(x)):
distance.append(distance[-1] + math.hypot(x[i] - x[i - 1], y[i] - y[i - 1]))
arc = _sample(time, distance)
allocation_demand = max(demand, abs(current_curvature))
share = _clip((allocation_demand - _GENTLE_CURVATURE) / (_FULL_POSE_CURVATURE - _GENTLE_CURVATURE), 0.0, 1.0)
# Retain metres/radians before wire or nominal-contribution clipping. These
# are requested geometry, not measured path error or additional authority.
geometric_request = (offset_ff + correction_share * error_y, angle_ff + correction_share * error_heading)
preferred = FordPath(True, _clip(geometric_request[0], *_RANGES[0]),
_clip(geometric_request[1], *_RANGES[1]),
_clip(desired_curvature * (1.0 - share), *_RANGES[2]), 0.0)
return PathRequest(total, feedforward, feedback, preferred, error_y, error_heading, geometric_request)
class ContributionAllocator:
"""Allocate a separately supplied total under the nominal BD coefficient model.
Unknown history starts as an interval, not zero. An interval becoming narrow
means the NOMINAL recurrence has initialized, not that firmware equivalence
or successful PSCM execution has been established.
"""
def __init__(self, dt=0.01, *, initial_state=None):
self.dt = dt
self.lower = tuple(bounds[0] for bounds in _UNKNOWN_RANGES) if initial_state is None else tuple(initial_state)
self.upper = tuple(bounds[1] for bounds in _UNKNOWN_RANGES) if initial_state is None else tuple(initial_state)
self.command = FordPath()
self.last_path = FordPath()
self.sent_curvature = 0.0
signals = CANPacker('ford_lincoln_base_pt').dbc.name_to_msg['LateralMotionControl2'].sigs
self._wire_signals = tuple(signals[name] for name in ('LatCtlPathOffst_L_Actl', 'LatCtlPath_An_Actl', 'LatCtlCurv_No_Actl'))
self._has_command = False
self.phase = 0.0
self.predicted_total = 0.0
self.shortfall = 0.0
self.predicted_peak_error = 0.0
@property
def state(self):
return tuple((low + high) * 0.5 for low, high in zip(self.lower, self.upper, strict=True))
@property
def initialized(self):
return all(high - low <= resolution * 0.5 for low, high, resolution in zip(self.lower, self.upper, _RESOLUTIONS, strict=True))
def advance(self, elapsed):
if not math.isfinite(elapsed) or elapsed < 0.0 or elapsed > 0.1:
self.lower = tuple(bounds[0] for bounds in _UNKNOWN_RANGES)
self.upper = tuple(bounds[1] for bounds in _UNKNOWN_RANGES)
self.phase = 0.0
return
if not self._has_command:
return
self.phase += elapsed
ticks = int((self.phase + 1e-12) / _FIRMWARE_DT)
self.phase -= ticks * _FIRMWARE_DT
rates = _STATE_RATES if self.command.valid else _INACTIVE_RATES
self.lower = _advance(self.lower, _values(self.command), ticks, rates)
self.upper = _advance(self.upper, _values(self.command), ticks, rates)
def _packet_field(self, index, value, speed):
# carControlSP uses Float32; CANPacker rounds in the sign-reversed DBC
# coordinate system with floor(x + .5), NOT Python's ties-to-even round.
value = struct.unpack('f', struct.pack('f', value))[0]
if index == 2 and speed is not None:
value = CarControllerParams.CURVATURE_LIMITS.apply_limits(
value, self.sent_curvature, speed, 0.0, True, CarControllerParams.LMC2_STEP,
)
signal = self._wire_signals[index]
return -(math.floor((-value - signal.offset) / signal.factor + 0.5) * signal.factor + signal.offset), value
def _packet(self, values, speed=None):
fields = tuple(self._packet_field(i, value, speed) for i, value in enumerate(values))
return tuple(field[0] for field in fields), fields[2][1]
def set_command(self, command, speed=None):
if command.curvature_rate != 0.0:
raise ValueError("C3 is not part of this experiment")
self.last_path = command
self._has_command = True
if command.valid:
packed, self.sent_curvature = self._packet(_values(command), speed)
self.command = FordPath(True, *packed, 0.0)
else:
self.command = FordPath()
self.sent_curvature = 0.0
@staticmethod
def tolerance(speed):
# Half a wire LSB in each normalized contribution, not a tuning deadband.
return 0.5 * (0.5 * _RESOLUTIONS[0] + 10.0 * _RESOLUTIONS[1] + 0.30078125 * speed ** 2 * _RESOLUTIONS[2])
def _bounds(self, preferred, speed):
previous = _values(self.last_path)
low = [_clip(previous[0] - 4.0 * self.dt, *_RANGES[0]), _clip(previous[1] - self.dt, *_RANGES[1])]
high = [_clip(previous[0] + 4.0 * self.dt, *_RANGES[0]), _clip(previous[1] + self.dt, *_RANGES[1])]
# C2 may move only toward the preferred range. In large maneuvers that
# range is exactly zero: unreachable fast demand must NOT refill C2.
limits = CarControllerParams.CURVATURE_LIMITS
for bounds, target in ((low, min(0.0, preferred.curvature)), (high, max(0.0, preferred.curvature))):
bounds.append(limits.apply_limits(target, self.sent_curvature, speed, 0.0, True, CarControllerParams.LMC2_STEP))
return tuple(low), tuple(high)
def _continuous_candidate(self, requested, preferred, speed, low, high, ticks):
state = self.state
qlow = contributions(_advance(state, low, ticks), speed)
qhigh = contributions(_advance(state, high, ticks), speed)
total = _clip(requested, sum(qlow), sum(qhigh))
qpref = contributions(_advance(state, _values(preferred), ticks), speed)
slow = _clip(qpref[2], max(qlow[2], total - qhigh[0] - qhigh[1]), min(qhigh[2], total - qlow[0] - qlow[1]))
fast = total - slow
q0 = _clip(0.5 * (fast + qpref[0] - qpref[1]), max(qlow[0], fast - qhigh[1]), min(qhigh[0], fast - qlow[1]))
weights = (0.5, 10.0, 0.30078125 * speed ** 2)
# A minimum-state candidate under the nominal map, not a wire-authority
# limit. The full geometric preference is also considered by allocate().
return tuple(_clip(q / weight if weight else pref, lo, hi)
for q, weight, pref, lo, hi in zip((q0, fast - q0, slow), weights, _values(preferred), low, high, strict=True))
def allocate(self, requested, preferred, speed):
if not self.initialized:
raise ValueError("coefficient history is not initialized")
if not all(math.isfinite(v) for v in (requested, speed, *_values(preferred))) or speed < 0.0 or preferred.curvature_rate != 0.0:
raise ValueError("invalid allocation request")
low, high = self._bounds(preferred, speed)
ticks = max(1, int((self.phase + self.dt + 1e-12) / _FIRMWARE_DT))
preferred_values = _values(preferred)
qpref = contributions(preferred_values, speed)
weights = (0.5, 10.0, 0.30078125 * speed ** 2)
tolerance = self.tolerance(speed)
state = self.state
# Back-off relative to either the preferred path or retained state must
# drain latent coefficients, even if every next-tick candidate still sits
# on a nominal plateau. Extra outward demand must not erase raw geometry.
backing_off = any(total * (requested - total) < -tolerance * abs(total)
for total in (sum(qpref), sum(contributions(state, speed))))
# Joint outward buildup need not wait for the nominal plateaus. If either
# fast field must reverse or drain toward its preference, keep the plateau
# guard: extending the other field can disrupt a settling correction.
building = requested != 0.0 and all(requested * current >= 0.0 and requested * (value - current) >= 0.0
for current, value in zip(state[:2], preferred_values[:2], strict=True))
preserve_geometry = tuple(not backing_off and abs(weight * value) > limit
and current * value > 0.0 and (building or abs(weight * current) >= limit)
for weight, value, current, limit in
zip(weights[:2], preferred_values[:2], state[:2], _CONTRIBUTION_LIMITS[:2], strict=True))
candidates = {tuple(_clip(v, lo, hi) for v, lo, hi in zip(_values(path), low, high, strict=True))
for path in (preferred, self.last_path)}
for horizon in {1, ticks}:
candidate = self._continuous_candidate(requested, preferred, speed, low, high, horizon)
neighbors = [{_clip(round(value / resolution) * resolution + shift * resolution, lo, hi) for shift in (-1, 0, 1)}
for value, resolution, lo, hi in zip(candidate, _RESOLUTIONS, low, high, strict=True)]
# Allow a larger geometric field alongside a corrected other field,
# without pulling that correction back toward the raw preference.
for i, preserve in enumerate(preserve_geometry):
if preserve:
neighbors[i].add(_clip(preferred_values[i], low[i], high[i]))
candidates.update(product(*neighbors))
effects = []
# Candidate packets share most coefficient values. Packing, limiting and
# projecting each field once avoids repeating them for every combination.
# Keep every candidate and all intermediate ticks in the evaluation.
for i, (weight, limit, rate) in enumerate(zip(weights, _CONTRIBUTION_LIMITS, _STATE_RATES, strict=True)):
cache = {}
for value in {candidate[i] for candidate in candidates}:
packed, _ = self._packet_field(i, value, speed)
totals = tuple(_clip(weight * _clip(packed, bound[i] - rate * tick * _FIRMWARE_DT,
bound[i] + rate * tick * _FIRMWARE_DT), -limit, limit)
for bound in (self.lower, self.upper) for tick in range(1, ticks + 1))
endpoint = _clip(weight * _clip(packed, state[i] - rate * ticks * _FIRMWARE_DT,
state[i] + rate * ticks * _FIRMWARE_DT), -limit, limit)
cache[value] = (totals, endpoint, max(0.0, abs(weight * packed) - limit),
abs(packed - preferred_values[i]) / _RESOLUTIONS[i])
effects.append(cache)
best = None
for candidate in sorted(candidates):
fields = tuple(cache[value] for cache, value in zip(effects, candidate, strict=True))
totals = [sum(parts) for parts in zip(*(field[0] for field in fields), strict=True)]
endpoint = tuple(field[1] for field in fields)
worst_error = max(abs(total - requested) for total in totals)
latent = sum(field[2] for field in fields)
# Among nominally equivalent allocations, retain the requested fast
# geometry instead of treating the unverified BD plateaus as wire caps.
geometry_error = round(sum(field[3] for field, preserve in zip(fields[:2], preserve_geometry, strict=True) if preserve), 9)
score = (round(max(0.0, worst_error - tolerance), 12), abs(endpoint[2] - qpref[2]),
(endpoint[0] - qpref[0]) ** 2 + (endpoint[1] - qpref[1]) ** 2, geometry_error, latent,
sum((new - old) ** 2 for new, old in zip(candidate, _values(self.last_path), strict=True)))
if best is None or score < best[0]:
best = score, candidate, sum(endpoint), worst_error
assert best is not None
_, command, self.predicted_total, self.predicted_peak_error = best
self.shortfall = requested - self.predicted_total
result = FordPath(True, *command, 0.0)
self.set_command(result, speed)
return result
dx = target_x - x[0]
dy = target_y - y[0]
cosine = math.cos(unwrapped_heading[0])
sine = math.sin(unwrapped_heading[0])
model_offset = -sine * dx + cosine * dy
model_heading = _wrap(target_heading - unwrapped_heading[0])
predicted_heading = current_curvature * arc
heading_residual = _wrap(model_heading - predicted_heading)
command = FordPath(
valid=True,
path_offset=_clip(model_offset, _C0_RANGE),
path_angle=_clip(heading_residual, _C1_RANGE),
curvature=0.0,
curvature_rate=0.0,
)
return _C2FreeRequest(command, model_offset, model_heading, predicted_heading, arc)
class FordSharedPathController:
"""Default-off live experiment: independent demand, feedback, and allocation."""
"""Use model C0 and residual C1 directly, with C2/C3 always zero."""
def __init__(self, dt=0.01):
self.dt = dt
self.allocator = ContributionAllocator(dt)
self.fallback = FordPathController(dt)
self.last_time = None
self.diagnostics: dict[str, Any] = {"status": "initializing", "hypothesis": "ML3V-BD-normalized-v1"}
def __init__(self):
self.diagnostics: dict[str, object] = {"status": "initializing", "hypothesis": "ML3V-BD-C2-free-v1"}
def update(self, model, desired_curvature: float, *, current_curvature=0.0, v_ego=0.0,
v_ego_raw=0.0, active=True, now=None):
elapsed = self.dt if now is None or self.last_time is None else now - self.last_time
self.last_time = now
self.allocator.advance(elapsed)
# Keep fallback history current, seeded from the command actually requested
# by this controller. A missing model/history must not cause an output jump.
self.fallback._last_path = self.allocator.last_path
valid_inputs = all(math.isfinite(v) for v in (desired_curvature, current_curvature, v_ego, v_ego_raw))
baseline = self.fallback.update(model if valid_inputs else None, desired_curvature, current_curvature=current_curvature,
v_ego=v_ego, active=active)
speed = max(v_ego_raw, 0.0) if math.isfinite(v_ego_raw) else 0.0
request = request_for_model(model, desired_curvature, current_curvature=current_curvature, v_ego=v_ego,
response_speed=speed) if active and valid_inputs else None
status = "inactive" if not active else "invalid_input" if request is None else "warming_history" if not self.allocator.initialized else "active"
# The reference uses filtered travel speed, while contribution scheduling
# and the unchanged downstream curvature limiter use raw wheel speed.
if status == "active":
result = self.allocator.allocate(request.total, request.preferred, speed)
else:
curvature = CarControllerParams.CURVATURE_LIMITS.apply_limits(
baseline.curvature, self.allocator.sent_curvature, speed, 0.0, True, CarControllerParams.LMC2_STEP,
) if baseline.valid else 0.0
result = FordPath(baseline.valid, _clip(baseline.path_offset, *_RANGES[0]), _clip(baseline.path_angle, *_RANGES[1]), curvature, 0.0)
self.allocator.set_command(result, speed)
def update(self, model, desired_curvature: float, *, current_curvature: float = 0.0, v_ego: float = 0.0,
v_ego_raw: float = 0.0, active: bool = True, now: float | None = None) -> FordPath:
request = _c2_free_request(model, current_curvature) if active and model is not None else None
status = "inactive" if not active else "invalid_input" if request is None else "active"
result = request.command if request is not None else FordPath(valid=active)
self.diagnostics = {
"status": status, "hypothesis": "ML3V-BD-normalized-v1",
"requested": request.total if request else 0.0,
"feedforward": request.feedforward if request else 0.0,
"feedback": request.feedback if request else 0.0,
"offset_error": request.offset_error if request else 0.0,
"heading_error": request.heading_error if request else 0.0,
"geometric_request": request.geometric_request if request else None,
# Locally predicted packet fields, not a PSCM execution acknowledgment.
"packed_command": _values(self.allocator.command),
"state": self.allocator.state,
"state_width": tuple(hi - lo for lo, hi in zip(self.allocator.lower, self.allocator.upper, strict=True)),
"predicted_total": self.allocator.predicted_total if status == "active" else 0.0,
"predicted_peak_error": self.allocator.predicted_peak_error if status == "active" else 0.0,
# Nominal allocation error only: zero is NOT successful path tracking.
"shortfall": self.allocator.shortfall if status == "active" else 0.0,
"status": status, "hypothesis": "ML3V-BD-C2-free-v1", "horizon_s": _C2_FREE_HORIZON_S,
"model_offset": request.model_offset if request else None,
"model_heading": request.model_heading if request else None,
"predicted_heading": request.predicted_heading if request else None,
"heading_residual": _wrap(request.model_heading - request.predicted_heading) if request else None,
"arc": request.arc if request else None,
"command": (result.path_offset, result.path_angle, result.curvature, result.curvature_rate),
}
return result
@@ -52,28 +52,28 @@ class TestFordControlsLogging(unittest.TestCase):
record = self.emit_controls_event('Ford shared path experiment', controls)
self.assertEqual(record['model_mono_time'], 123456789)
self.assertEqual(record['status'], controller.diagnostics['status'])
self.assertEqual(record['state'], list(controller.diagnostics['state']))
self.assertEqual(record['requested'], controller.diagnostics['requested'])
self.assertEqual(record['command'], list(controller.diagnostics['command']))
self.assertEqual(record['horizon_s'], controller.diagnostics['horizon_s'])
def test_geometric_request_logs_and_clears_without_changing_allocation_meaning(self):
def test_temporal_model_pose_diagnostics_log_and_clear(self):
controller = FordSharedPathController()
model = SimpleNamespace(position=SimpleNamespace(x=[0.0, 20.0], y=[0.0, -10.0]),
model = SimpleNamespace(position=SimpleNamespace(t=[0.0, 2.0], x=[0.0, 20.0], y=[0.0, -10.0]),
orientation=SimpleNamespace(z=[0.0, -1.0]))
for _ in range(4):
controller.update(model, 0.0, active=False)
for _ in range(150):
controller.update(model, 0.0, current_curvature=-0.019, v_ego=2.1, v_ego_raw=2.1)
controller.update(model, 0.0, current_curvature=-0.019, v_ego=2.1, v_ego_raw=2.1)
controls = SimpleNamespace(ford_path_controller=controller, sm=SimpleNamespace(logMonoTime={'modelV2': 123456789}))
record = self.emit_controls_event('Ford shared path experiment', controls)
self.assertEqual(record['geometric_request'], list(controller.diagnostics['geometric_request']))
self.assertEqual(record['packed_command'], list(controller.diagnostics['packed_command']))
self.assertLess(record['geometric_request'][1], record['packed_command'][1])
self.assertAlmostEqual(record['shortfall'], 0.0)
self.assertEqual(record['model_offset'], controller.diagnostics['model_offset'])
self.assertEqual(record['model_heading'], controller.diagnostics['model_heading'])
self.assertEqual(record['predicted_heading'], controller.diagnostics['predicted_heading'])
self.assertEqual(record['heading_residual'], controller.diagnostics['heading_residual'])
self.assertEqual(record['arc'], controller.diagnostics['arc'])
self.assertEqual(record['command'], list(controller.diagnostics['command']))
for active in (True, False):
controller.update(None, 0.0, active=active)
record = self.emit_controls_event('Ford shared path experiment', controls)
self.assertIsNone(record['geometric_request'])
self.assertIsNone(record['model_offset'])
self.assertIsNone(record['heading_residual'])
if __name__ == '__main__':
@@ -1,291 +1,30 @@
import cProfile
import math
import random
import tempfile
import unittest
from types import SimpleNamespace
from opendbc.can import CANPacker, CANParser
from opendbc.car.ford.fordcan import CanBus, create_lat_ctl2_msg
from opendbc.car.ford.values import CarControllerParams, FordFlags
from opendbc.car.ford.values import FordFlags
from openpilot.selfdrive.controls.lib.ford_path import FordPath, FordPathController, FordPscmObserverPathController
from openpilot.selfdrive.controls.lib.ford_shared_path import (
ContributionAllocator, FordSharedPathController, contributions, request_for_model, select_shared_path_controller,
_C2_FREE_HORIZON_S, FordSharedPathController, _c2_free_request, select_shared_path_controller,
)
def circle(curvature, speed=8.0):
def circle(curvature: float, speed: float = 8.0):
distance = [i * 0.1 for i in range(401)]
heading = [curvature * s for s in distance]
return SimpleNamespace(
position=SimpleNamespace(x=[math.sin(h) / curvature if curvature else s for s, h in zip(distance, heading, strict=True)],
y=[(1 - math.cos(h)) / curvature if curvature else 0.0 for h in heading]),
position=SimpleNamespace(
t=[s / speed for s in distance],
x=[math.sin(h) / curvature if curvature else s for s, h in zip(distance, heading, strict=True)],
y=[(1.0 - math.cos(h)) / curvature if curvature else 0.0 for h in heading],
),
orientation=SimpleNamespace(z=heading),
)
class TestSharedRequest(unittest.TestCase):
def test_gentle_request_is_c2_only_and_preserves_upstream_request(self):
for desired in (-0.004, 0.0, 0.004):
request = request_for_model(circle(0.003), desired, current_curvature=0.005, v_ego=20.0)
self.assertEqual(request.preferred.path_offset, 0.0)
self.assertEqual(request.preferred.path_angle, 0.0)
self.assertEqual(request.preferred.curvature, desired)
self.assertEqual(request.feedback, 0.0)
self.assertAlmostEqual(request.total, 0.30078125 * 20.0 ** 2 * desired)
def test_saturated_turn_can_back_off_without_erasing_the_arc_at_target(self):
for sign in (-1, 1):
model = circle(sign * 0.04)
aligned = request_for_model(model, sign * 0.04, current_curvature=sign * 0.04, v_ego=8.0)
over = request_for_model(model, sign * 0.04, current_curvature=sign * 0.06, v_ego=8.0)
self.assertGreater(sign * aligned.total, 0.7)
self.assertLess(sign * over.feedback, -0.1)
self.assertLess(sign * over.total, sign * aligned.total - 0.1)
self.assertEqual(over.preferred.curvature, 0.0)
def test_remaining_model_arc_survives_a_collapsed_action(self):
request = request_for_model(circle(0.04), 0.0, current_curvature=0.04, v_ego=8.0)
self.assertGreater(request.total, 0.5)
self.assertAlmostEqual(request.feedback, 0.0, places=5)
self.assertEqual(request.preferred.curvature, 0.0)
def test_measured_turn_retains_unwind_after_model_straightens(self):
for sign in (-1, 1):
request = request_for_model(circle(0.0), sign * 0.002, current_curvature=sign * 0.03, v_ego=8.0)
self.assertLess(sign * request.feedback, -0.1)
self.assertLess(sign * request.total, 0.0)
self.assertEqual(request.preferred.curvature, 0.0)
class TestContributionAllocator(unittest.TestCase):
def test_fresh_turn_does_not_wait_for_nominal_plateaus_to_send_geometry(self):
for sign in (-1, 1):
allocator = ContributionAllocator(initial_state=(0.0, 0.0, 0.0))
preferred = FordPath(True, sign * 2.0, sign * 0.3, 0.0)
requested = sum(contributions((preferred.path_offset, preferred.path_angle, 0.0), 8.0))
for _ in range(10):
command = allocator.allocate(requested, preferred, 8.0)
self.assertEqual(command.curvature, 0.0)
allocator.advance(allocator.dt)
# Existing host slew permits 4 m/s and 1 rad/s. Allow one control tick
# for initialization, not the time needed to fill a nominal plateau.
self.assertGreaterEqual(sign * command.path_offset, 4.0 * 9 * allocator.dt - 1e-9)
self.assertGreaterEqual(sign * command.path_angle, 9 * allocator.dt - 1e-9)
def test_zero_total_does_not_treat_opposing_fields_as_joint_buildup(self):
for sign in (-1, 1):
initial = (sign * 0.6, -sign * 0.03, 0.0)
allocator = ContributionAllocator(initial_state=initial)
allocator.set_command(FordPath(True, *initial), 8.0)
command = allocator.allocate(0.0, FordPath(True, sign * 0.7, -sign * 0.3, 0.0), 8.0)
self.assertLessEqual(abs(command.path_angle), 0.035)
self.assertEqual(command.curvature, 0.0)
# Known limitation of experimental early geometry: queued commands prolong
# release in the nominal BD model. This is NOT verified physical behavior.
@unittest.expectedFailure
def test_short_turn_release_preserves_command_history_not_just_coefficient_state(self):
for sign in (-1, 1):
allocator = ContributionAllocator(initial_state=(0.0, 0.0, 0.0))
preferred = FordPath(True, sign * 3.5, sign * 0.5, 0.0)
requested = sum(contributions((preferred.path_offset, preferred.path_angle, 0.0), 8.0))
for _ in range(30):
allocator.allocate(requested, preferred, 8.0)
allocator.advance(allocator.dt)
initial_total = sign * sum(contributions(allocator.state, 8.0))
# Continue the same allocator. Recreating it with command=state hides
# queued commands that can keep building after the request disappears.
for _ in range(30):
command = allocator.allocate(0.0, FordPath(True), 8.0)
self.assertEqual(command.curvature, 0.0)
allocator.advance(allocator.dt)
self.assertLessEqual(sign * sum(contributions(allocator.state, 8.0)), initial_total + allocator.tolerance(8.0))
self.assertLessEqual(abs(sum(contributions(allocator.state, 8.0))), allocator.tolerance(8.0))
def test_nominal_plateau_does_not_erase_requested_fast_geometry(self):
allocator = ContributionAllocator(initial_state=(0.0, 0.0, 0.0))
preferred = FordPath(True, 2.0, 0.12, 0.0, 0.0)
requested = sum(contributions((preferred.path_offset, preferred.path_angle, 0.0), 5.0))
for _ in range(150):
command = allocator.allocate(requested, preferred, 5.0)
self.assertEqual(command.curvature, 0.0)
allocator.advance(0.01)
self.assertAlmostEqual(command.path_offset, preferred.path_offset)
self.assertAlmostEqual(command.path_angle, preferred.path_angle)
self.assertAlmostEqual(allocator.shortfall, 0.0)
def test_overturn_correction_releases_large_geometry_still_in_model(self):
for sign in (-1, 1):
model = circle(sign * 0.06)
hold = request_for_model(model, sign * 0.06, current_curvature=sign * 0.06, v_ego=2.0)
correction = request_for_model(model, sign * 0.06, current_curvature=sign * 0.10, v_ego=2.0)
initial = (hold.preferred.path_offset, hold.preferred.path_angle, 0.0)
allocator = ContributionAllocator(initial_state=initial)
allocator.set_command(hold.preferred, 2.0)
self.assertLess(sign * correction.total, sign * hold.total)
self.assertGreater(abs(correction.preferred.path_angle), 0.3)
for _ in range(100):
command = allocator.allocate(correction.total, correction.preferred, 2.0)
self.assertEqual(command.curvature, 0.0)
allocator.advance(0.01)
self.assertLessEqual(abs(allocator.shortfall), allocator.tolerance(2.0))
def test_extra_outward_demand_does_not_erase_preferred_fast_geometry(self):
for sign in (-1, 1):
allocator = ContributionAllocator(initial_state=(0.0, 0.0, 0.0))
preferred = FordPath(True, sign * 0.24, sign * 0.0515, 0.0)
requested = sign * 0.6644
self.assertGreater(abs(requested), abs(sum(contributions((preferred.path_offset, preferred.path_angle, 0.0), 10.0))))
for _ in range(150):
command = allocator.allocate(requested, preferred, 10.0)
self.assertEqual(command.curvature, 0.0)
allocator.advance(0.01)
self.assertAlmostEqual(command.path_angle, preferred.path_angle)
self.assertLessEqual(abs(allocator.shortfall), allocator.tolerance(10.0))
def test_straight_release_does_not_reintroduce_geometry_during_latent_unwind(self):
for sign in (-1, 1):
initial = (sign * 3.5, sign * 0.5, 0.0)
allocator = ContributionAllocator(initial_state=initial)
allocator.set_command(FordPath(True, *initial), 8.0)
# A deliberately charged nominal state takes seconds to drain. Once
# its total settles, preferring zero geometry must not create a second
# opposite contribution as the remaining C1 state leaves saturation.
for i in range(600):
command = allocator.allocate(0.0, FordPath(True), 8.0)
self.assertEqual(command.curvature, 0.0)
allocator.advance(0.01)
if i >= 310:
self.assertLessEqual(abs(sum(contributions(allocator.state, 8.0))), allocator.tolerance(8.0) + 1e-8)
def test_larger_heading_does_not_displace_corrected_offset_during_model_unwind(self):
for sign in (-1, 1):
initial = (sign * 3.5, sign * 0.5, 0.0)
allocator = ContributionAllocator(initial_state=initial)
allocator.set_command(FordPath(True, *initial), 20.0)
request = request_for_model(circle(0.0), sign * 0.002, current_curvature=sign * 0.04, v_ego=20.0)
for i in range(650):
command = allocator.allocate(request.total, request.preferred, 20.0)
self.assertEqual(command.curvature, 0.0)
allocator.advance(0.01)
if i >= 500:
self.assertLessEqual(abs(sum(contributions(allocator.state, 20.0)) - request.total), allocator.tolerance(20.0) + 1e-8)
def test_candidate_search_has_bounded_curvature_limiter_work(self):
allocator = ContributionAllocator(initial_state=(0.2, 0.01, 0.003))
allocator.set_command(FordPath(True, 0.2, 0.01, 0.003))
profile = cProfile.Profile()
profile.runcall(allocator.allocate, 0.3, FordPath(True, 0.4, 0.02, 0.002), 12.0)
limit_code = CarControllerParams.CURVATURE_LIMITS.apply_limits.__func__.__code__
calls = sum(entry.callcount for entry in profile.getstats() if entry.code is limit_code)
# Two bounds, at most eight distinct candidate C2 values, and final packet.
# This operation budget catches repeated work without flaky wall-clock limits.
self.assertGreater(calls, 0)
self.assertLessEqual(calls, 11)
def test_fixed_request_is_preserved_while_c2_unloads(self):
speed = 10.0
initial = (0.0, 0.0, 0.004)
requested = sum(contributions(initial, speed))
allocator = ContributionAllocator(initial_state=initial)
allocator.set_command(FordPath(True, *initial))
for _ in range(180):
command = allocator.allocate(requested, FordPath(True, 0.1, 0.01, 0.0), speed)
self.assertLessEqual(command.curvature, 0.004)
allocator.advance(0.01)
self.assertLessEqual(abs(sum(contributions(allocator.state, speed)) - requested), allocator.tolerance(speed) + 1e-8)
self.assertLess(abs(allocator.state[2]), 0.00004)
self.assertGreater(allocator.state[0] + allocator.state[1], 0.0)
def test_c2_reloads_only_as_fast_contribution_can_be_removed(self):
speed = 20.0
initial = (0.4, 0.01, 0.0)
requested = sum(contributions(initial, speed))
desired_c2 = requested / (0.30078125 * speed ** 2)
allocator = ContributionAllocator(initial_state=initial)
allocator.set_command(FordPath(True, *initial))
for _ in range(400):
allocator.allocate(requested, FordPath(True, 0.0, 0.0, desired_c2), speed)
self.assertLessEqual(allocator.predicted_peak_error, allocator.tolerance(speed) + 1e-8)
allocator.advance(0.01)
self.assertLess(abs(allocator.state[0]), 0.01)
self.assertLess(abs(allocator.state[1]), 0.0005)
self.assertAlmostEqual(allocator.state[2], desired_c2, delta=0.00002)
def test_inactive_state_drains_instead_of_resetting_instantly(self):
allocator = ContributionAllocator(initial_state=(1.0, 0.1, 0.01))
allocator.set_command(FordPath())
self.assertEqual(allocator.state, (1.0, 0.1, 0.01))
allocator.advance(0.004)
self.assertAlmostEqual(allocator.state[2], 0.002)
allocator.advance(0.004)
self.assertEqual(allocator.state, (0.0, 0.0, 0.0))
def test_unknown_history_and_gaps_cannot_be_used_as_exact_state(self):
allocator = ContributionAllocator()
self.assertFalse(allocator.initialized)
with self.assertRaises(ValueError):
allocator.allocate(0.1, FordPath(True), 10.0)
allocator.set_command(FordPath())
allocator.advance(0.024)
self.assertTrue(allocator.initialized)
allocator.advance(0.25)
self.assertFalse(allocator.initialized)
def test_startup_does_not_invent_an_inactive_command(self):
allocator = ContributionAllocator()
allocator.advance(0.1)
self.assertFalse(allocator.initialized)
def test_unreachable_request_is_reported_and_never_refills_c2(self):
allocator = ContributionAllocator(initial_state=(0.0, 0.0, 0.0))
command = allocator.allocate(0.84, FordPath(True, 1.0, 0.03, 0.0), 10.0)
self.assertEqual(command.curvature, 0.0)
self.assertGreater(allocator.shortfall, 0.7)
self.assertLessEqual(command.path_offset, 0.04)
self.assertLessEqual(command.path_angle, 0.01)
def test_active_state_uses_250hz_slew_and_wire_values(self):
allocator = ContributionAllocator(initial_state=(0.0, 0.0, 0.0))
allocator.set_command(FordPath(True, 0.1001, 0.0101, 0.002001))
allocator.advance(0.01)
self.assertAlmostEqual(allocator.state[0], 1.5 * 0.008)
self.assertAlmostEqual(allocator.state[1], 0.100006103515625 * 0.008)
allocator.advance(0.01)
self.assertAlmostEqual(allocator.state[0], 1.5 * 0.020)
self.assertAlmostEqual(allocator.command.path_angle, 0.0100)
def test_estimated_packet_matches_float32_carcontrol_and_can_packing(self):
from openpilot.cereal import custom
packer = CANPacker('ford_lincoln_base_pt')
parser = CANParser('ford_lincoln_base_pt', [('LateralMotionControl2', 0)], 0)
allocator = ContributionAllocator(initial_state=(0.0, 0.0, 0.0))
for c0, c1, c2 in ((0.005, 0.00025, 0.00001), (-0.005, -0.00025, -0.00001),
(0.115, 0.03475, 0.00301), (-0.115, -0.03475, -0.00301)):
command = FordPath(True, c0, c1, c2)
allocator.set_command(command)
message = custom.CarControlSP.new_message()
path = message.fordLateralPath
path.pathOffset, path.pathAngle, path.curvature = c0, c1, c2
packet = create_lat_ctl2_msg(packer, CanBus(fingerprint={0: {}}), 2, -path.pathOffset, -path.pathAngle, -path.curvature, 0.0, 0)
parser.update([0, [packet]])
decoded = parser.vl['LateralMotionControl2']
for value, field in ((allocator.command.path_offset, 'LatCtlPathOffst_L_Actl'),
(allocator.command.path_angle, 'LatCtlPath_An_Actl'), (allocator.command.curvature, 'LatCtlCurv_No_Actl')):
self.assertAlmostEqual(value, -decoded[field], places=10)
def test_s_shape_preference_keeps_opposing_fast_fields(self):
allocator = ContributionAllocator(initial_state=(0.0, 0.0, 0.0))
preferred = FordPath(True, 0.2, -0.01, 0.0)
for _ in range(100):
command = allocator.allocate(0.0, preferred, 8.0)
allocator.advance(0.01)
self.assertGreater(command.path_offset, 0.1)
self.assertLess(command.path_angle, -0.005)
self.assertLessEqual(abs(sum(contributions(allocator.state, 8.0))), allocator.tolerance(8.0))
class TestSharedController(unittest.TestCase):
def test_native_toggle_is_default_off_and_selection_is_startup_only(self):
from openpilot.common.params import Params
@@ -298,89 +37,140 @@ class TestSharedController(unittest.TestCase):
chosen = select_shared_path_controller('ford', FordFlags.CANFD, params.get_bool('FordSharedPathController'), prior)
self.assertIsInstance(chosen, FordSharedPathController)
params.put_bool('FordSharedPathController', False, block=True)
self.assertIsInstance(chosen, FordSharedPathController) # running selection is unchanged
self.assertIsInstance(chosen, FordSharedPathController)
self.assertIs(select_shared_path_controller('ford', FordFlags.CANFD, params.get_bool('FordSharedPathController'), prior), prior)
def test_inactive_initializes_history_and_large_turn_uses_live_fast_fields(self):
controller = FordSharedPathController()
model = circle(0.04)
for _ in range(4):
self.assertFalse(controller.update(model, 0.04, current_curvature=0.0, v_ego=8.0, v_ego_raw=8.0, active=False).valid)
for _ in range(100):
command = controller.update(model, 0.04, current_curvature=0.0, v_ego=8.0, v_ego_raw=8.0)
self.assertTrue(command.valid)
self.assertEqual(command.curvature, 0.0)
self.assertEqual(controller.diagnostics['status'], 'active')
self.assertGreater(command.path_offset, 0.0)
self.assertGreater(command.path_angle, 0.0)
def test_large_model_geometry_reaches_wire_limits_without_losing_raw_request(self):
controller = FordSharedPathController()
model = circle(-0.12)
for _ in range(4):
controller.update(model, 0.0, active=False)
for _ in range(150):
command = controller.update(model, 0.0, current_curvature=-0.019, v_ego=2.1, v_ego_raw=2.1)
diagnostic = controller.diagnostics
self.assertEqual(diagnostic['status'], 'active')
self.assertAlmostEqual(diagnostic['shortfall'], 0.0)
# Allocation success is only agreement with the nominal coefficient map.
# Keep the geometric request even beyond the DBC heading range, rather
# than presenting the held coefficient command as the model's full path.
offset, heading = diagnostic['geometric_request']
self.assertAlmostEqual(offset, command.path_offset)
self.assertLess(heading, -0.5)
self.assertAlmostEqual(diagnostic['packed_command'][0], offset, delta=0.005)
self.assertAlmostEqual(diagnostic['packed_command'][1], -0.5)
self.assertEqual(diagnostic['packed_command'][2], 0.0)
def test_default_off_and_unsupported_cars_retain_the_exact_previous_object(self):
for previous in (FordPathController(), FordPscmObserverPathController()):
for brand, flags, enabled in (("ford", FordFlags.CANFD, False), ("ford", 0, True), ("tesla", FordFlags.CANFD, True)):
self.assertIs(select_shared_path_controller(brand, flags, enabled, previous), previous)
self.assertIsInstance(select_shared_path_controller("ford", FordFlags.CANFD, True, previous), FordSharedPathController)
def test_invalid_model_and_timing_gap_do_not_jump_fast_fields(self):
def test_c2_and_c3_are_always_zero(self):
controller = FordSharedPathController()
model = circle(0.04)
for i in range(4):
controller.update(model, 0.04, v_ego=8.0, v_ego_raw=8.0, active=False, now=i * 0.01)
for i in range(4, 104):
previous = controller.update(model, 0.04, v_ego=8.0, v_ego_raw=8.0, now=i * 0.01)
command = controller.update(None, 0.04, v_ego=8.0, v_ego_raw=8.0, now=1.04)
self.assertEqual(controller.diagnostics['status'], 'invalid_input')
self.assertLessEqual(abs(command.path_offset - previous.path_offset), 0.04 + 1e-9)
self.assertLessEqual(abs(command.path_angle - previous.path_angle), 0.01 + 1e-9)
previous = command
command = controller.update(model, 0.04, v_ego=8.0, v_ego_raw=8.0, now=2.0)
self.assertEqual(controller.diagnostics['status'], 'warming_history')
self.assertLessEqual(abs(command.path_offset - previous.path_offset), 0.04 + 1e-9)
self.assertLessEqual(abs(command.path_angle - previous.path_angle), 0.01 + 1e-9)
def test_random_sequences_keep_existing_command_and_downstream_limits(self):
rng = random.Random(68)
controller = FordSharedPathController()
previous = FordPath()
for i in range(600):
speed = rng.uniform(1.0, 35.0)
curvature = rng.uniform(-0.12, 0.12)
active = i >= 4 and i % 37 != 0
command = controller.update(circle(curvature), rng.uniform(-0.02, 0.02),
current_curvature=rng.uniform(-0.12, 0.12), v_ego=speed, v_ego_raw=speed, active=active)
self.assertTrue(all(math.isfinite(v) for v in (command.path_offset, command.path_angle, command.curvature)))
self.assertLessEqual(abs(command.path_offset), 5.11)
self.assertLessEqual(abs(command.path_angle), 0.5)
self.assertLessEqual(abs(command.curvature), 0.02)
requests = [
controller.update(circle(curvature, speed), desired, current_curvature=actual,
v_ego=speed, v_ego_raw=speed)
for curvature, desired, actual, speed in (
(0.0, 0.0, 0.0, 20.0), (0.003, -0.01, 0.005, 20.0),
(0.12, 0.12, 0.0, 5.0), (-0.12, 0.12, -0.2, 5.0),
)
]
requests += [controller.update(None, 0.1), controller.update(circle(0.1), 0.1, active=False)]
for command in requests:
self.assertEqual(command.curvature, 0.0)
self.assertEqual(command.curvature_rate, 0.0)
if active:
self.assertLessEqual(abs(command.path_offset - previous.path_offset), 0.04 + 1e-9)
self.assertLessEqual(abs(command.path_angle - previous.path_angle), 0.01 + 1e-9)
limited = CarControllerParams.CURVATURE_LIMITS.apply_limits(command.curvature, previous.curvature, speed, 0.0, True, 1)
self.assertAlmostEqual(command.curvature, limited, places=10)
else:
self.assertEqual(command, FordPath())
previous = command
def test_constant_curvature_uses_firmware_derived_temporal_offset(self):
for sign in (-1, 1):
for speed in (5.0, 10.0, 15.0, 20.0):
curvature = sign * 0.001
request = _c2_free_request(circle(curvature, speed), curvature)
self.assertIsNotNone(request)
c2_equivalent = 0.30078125 * speed ** 2 * curvature
self.assertAlmostEqual(0.5 * request.command.path_offset, c2_equivalent, delta=abs(c2_equivalent) * 1e-3)
self.assertAlmostEqual(request.command.path_angle, 0.0, delta=1e-5)
self.assertEqual(request.command.curvature, 0.0)
def test_exact_time_sample_and_origin_transform(self):
horizon = _C2_FREE_HORIZON_S
local_x = [0.0, 2.0, 6.0]
local_y = [0.0, 0.4, 1.2]
local_heading = [0.0, 0.12, 0.3]
def transformed(rotation, tx, ty):
cosine, sine = math.cos(rotation), math.sin(rotation)
return SimpleNamespace(
position=SimpleNamespace(
t=[0.0, horizon, 2.0 * horizon],
x=[tx + cosine * x - sine * y for x, y in zip(local_x, local_y, strict=True)],
y=[ty + sine * x + cosine * y for x, y in zip(local_x, local_y, strict=True)],
),
orientation=SimpleNamespace(z=[rotation + heading for heading in local_heading]),
)
reference = _c2_free_request(transformed(0.0, 0.0, 0.0), 0.01)
moved = _c2_free_request(transformed(0.7, 40.0, -3.0), 0.01)
self.assertIsNotNone(reference)
self.assertIsNotNone(moved)
self.assertAlmostEqual(reference.model_offset, 0.4)
self.assertAlmostEqual(reference.model_heading, 0.12)
self.assertAlmostEqual(reference.arc, math.hypot(2.0, 0.4))
self.assertAlmostEqual(moved.model_offset, reference.model_offset)
self.assertAlmostEqual(moved.model_heading, reference.model_heading)
self.assertAlmostEqual(moved.arc, reference.arc)
self.assertAlmostEqual(moved.command.path_offset, reference.command.path_offset)
self.assertAlmostEqual(moved.command.path_angle, reference.command.path_angle)
def test_c1_is_only_remaining_heading_error(self):
for sign in (-1, 1):
curvature = sign * 0.02
model = circle(curvature, 8.0)
behind = _c2_free_request(model, 0.0)
aligned = _c2_free_request(model, curvature)
ahead = _c2_free_request(model, 2.0 * curvature)
self.assertGreater(sign * behind.command.path_angle, 0.0)
self.assertAlmostEqual(aligned.command.path_angle, 0.0, delta=1e-5)
self.assertLess(sign * ahead.command.path_angle, 0.0)
for request in (behind, aligned, ahead):
expected = math.atan2(math.sin(request.model_heading - request.predicted_heading),
math.cos(request.model_heading - request.predicted_heading))
self.assertAlmostEqual(request.command.path_angle, expected)
def test_action_curvature_does_not_change_model_pose_command(self):
controller = FordSharedPathController()
model = circle(0.03, 10.0)
commands = [controller.update(model, desired, current_curvature=0.01, v_ego=10.0, v_ego_raw=10.0)
for desired in (-0.1, -0.005, 0.0, 0.005, 0.1)]
self.assertTrue(all(command == commands[0] for command in commands[1:]))
def test_direct_targets_do_not_have_host_side_slew_or_history(self):
controller = FordSharedPathController()
positive = controller.update(circle(0.08, 5.0), 0.08, current_curvature=0.0)
negative = controller.update(circle(-0.08, 5.0), -0.08, current_curvature=0.0)
positive_again = controller.update(circle(0.08, 5.0), 0.08, current_curvature=0.0)
self.assertGreater(positive.path_offset, 0.0)
self.assertGreater(positive.path_angle, 0.0)
self.assertLess(negative.path_offset, 0.0)
self.assertLess(negative.path_angle, 0.0)
self.assertEqual(positive_again, positive)
def test_invalid_model_never_falls_back_to_c2(self):
controller = FordSharedPathController()
invalid_models = [
None,
SimpleNamespace(position=SimpleNamespace(t=[0.0], x=[0.0], y=[0.0]), orientation=SimpleNamespace(z=[0.0])),
SimpleNamespace(position=SimpleNamespace(t=[0.0, 2.0], x=[0.0, math.nan], y=[0.0, 0.0]),
orientation=SimpleNamespace(z=[0.0, 0.0])),
]
for model in invalid_models:
command = controller.update(model, 0.02, current_curvature=0.01)
self.assertEqual(command, FordPath(valid=True))
self.assertEqual(controller.diagnostics['status'], 'invalid_input')
self.assertEqual(controller.update(circle(0.02), 0.02, active=False), FordPath())
self.assertEqual(controller.diagnostics['status'], 'inactive')
def test_commands_use_full_symmetric_dbc_bounds(self):
horizon = _C2_FREE_HORIZON_S
packer = CANPacker('ford_lincoln_base_pt')
parser = CANParser('ford_lincoln_base_pt', [('LateralMotionControl2', 0)], 0)
for sign in (-1, 1):
model = SimpleNamespace(
position=SimpleNamespace(t=[0.0, horizon], x=[0.0, 0.0], y=[0.0, sign * 20.0]),
orientation=SimpleNamespace(z=[0.0, sign * 2.0]),
)
command = FordSharedPathController().update(model, 0.0, current_curvature=0.0)
self.assertEqual(command.path_offset, sign * 5.11)
self.assertEqual(command.path_angle, sign * 0.5)
self.assertEqual(command.curvature, 0.0)
self.assertEqual(command.curvature_rate, 0.0)
packet = create_lat_ctl2_msg(packer, CanBus(fingerprint={0: {}}), 2, -command.path_offset,
-command.path_angle, -command.curvature, -command.curvature_rate, 0)
parser.update([0, [packet]])
decoded = parser.vl['LateralMotionControl2']
self.assertAlmostEqual(decoded['LatCtlPathOffst_L_Actl'], -command.path_offset)
self.assertAlmostEqual(decoded['LatCtlPath_An_Actl'], -command.path_angle)
self.assertEqual(decoded['LatCtlCurv_No_Actl'], 0.0)
if __name__ == '__main__':
@@ -2183,8 +2183,8 @@
"widget": "toggle",
"needs_onroad_cycle": true,
"title": "Shared Path Controller (Experimental)",
"description": "Experimental live steering for Ford CAN FD vehicles, combining path feedback with a state-aware C0/C1/C2 handoff.",
"details": "Default off. Uses nominal firmware response assumptions that are unverified across Ford models; this controller is not road-validated. Enable only for controlled testing. Takes priority over PSCM Coefficient Observer while enabled. Turning it off restores the previous controller selection. Changes apply after a real offroad-to-onroad cycle, not immediately or on disengagement alone.",
"description": "Experimental live steering for Ford CAN FD vehicles, using a temporal model-path C0 offset and C1 heading residual while keeping C2/C3 zero.",
"details": "Default off. The 1.097-second model horizon is derived from decoded Raptor firmware coefficients and is unverified across Ford models; this controller is not road-validated. Enable only for controlled testing. Takes priority over PSCM Coefficient Observer while enabled. Turning it off restores the previous controller selection. Changes apply after a real offroad-to-onroad cycle, not immediately or on disengagement alone.",
"enablement": [
{
"type": "offroad_only"
@@ -14,8 +14,8 @@ sections:
widget: toggle
needs_onroad_cycle: true
title: Shared Path Controller (Experimental)
description: Experimental live steering for Ford CAN FD vehicles, combining path feedback with a state-aware C0/C1/C2 handoff.
details: Default off. Uses nominal firmware response assumptions that are unverified across Ford models; this controller is not road-validated. Enable only for controlled testing. Takes priority over PSCM Coefficient Observer while enabled. Turning it off restores the previous controller selection. Changes apply after a real offroad-to-onroad cycle, not immediately or on disengagement alone.
description: Experimental live steering for Ford CAN FD vehicles, using a temporal model-path C0 offset and C1 heading residual while keeping C2/C3 zero.
details: Default off. The 1.097-second model horizon is derived from decoded Raptor firmware coefficients and is unverified across Ford models; this controller is not road-validated. Enable only for controlled testing. Takes priority over PSCM Coefficient Observer while enabled. Turning it off restores the previous controller selection. Changes apply after a real offroad-to-onroad cycle, not immediately or on disengagement alone.
enablement:
- $ref: '#/macros/offroad'
- key: FordPscmObserver
@@ -288,6 +288,8 @@ class TestKnownVehicleSettings(OpenpilotTestCase):
# No other toggle can prevent disabling this experiment while offroad.
assert shared["enablement"] == [{"type": "offroad_only"}]
assert "Ford CAN FD" in shared["description"]
assert "C0" in shared["description"] and "C1" in shared["description"]
assert "C2/C3 zero" in shared["description"]
assert "not road-validated" in shared["details"]
assert "offroad" in shared["details"] and "onroad" in shared["details"]