Keep Ford angle AOL within native steering limits through turns and pedals

This commit is contained in:
firestar5683
2026-10-24 10:40:09 -05:00
parent d646a5dea8
commit 3f693e1735
10 changed files with 194 additions and 12 deletions
@@ -38,6 +38,8 @@ class Inputs:
lane_direction: int = 0
limit_status: int = 0
geometry: LaneGeometry | None = None
speed_min: float | None = None
speed_max: float | None = None
@dataclass(frozen=True)
@@ -85,6 +87,12 @@ class AngleController:
if not i.active or not all(math.isfinite(v) for v in values) or i.speed < 0.0:
self.reset()
return Command()
speed_min = max(i.speed, 0.1) if i.speed_min is None else i.speed_min
speed_max = max(i.speed, 0.1) if i.speed_max is None else i.speed_max
if (not all(math.isfinite(v) for v in (speed_min, speed_max)) or
not 0 < speed_min <= max(i.speed, 0.1) <= speed_max):
self.reset()
return Command()
if i.steering_pressed and not self.press_last:
self.preturned = abs(i.steering_angle) > 45.0
self.press_last = i.steering_pressed
@@ -152,7 +160,7 @@ class AngleController:
# measured-curvature interval and the modern ISO envelope. If those bands
# cannot meet the previous actuator ROC, send a neutral mode-0 frame.
effective_speed = max(i.speed, 0.1)
cap = min(bounds.CURVATURE_MAX, bounds.LATERAL_ACCEL / max(i.speed, 1.0) ** 2)
cap = min(bounds.CURVATURE_MAX, bounds.LATERAL_ACCEL / max(speed_max, 1.0) ** 2)
low_kappa, high_kappa = -cap, cap
if i.speed > 9.0:
low_kappa = max(low_kappa, i.measured_curvature - 0.0018)
@@ -161,8 +169,10 @@ class AngleController:
# gain (0.95..1.3), so the worst equivalent curvature remains bounded.
lower_gain = bounds.GAIN_MAX if low_kappa >= 0.0 else bounds.GAIN_MIN
upper_gain = bounds.GAIN_MIN if high_kappa >= 0.0 else bounds.GAIN_MAX
lower = max(-bounds.PATH_MAX, low_kappa * effective_speed * lower_gain, self.path_last - roc)
upper = min(-bounds.PATH_MIN, high_kappa * effective_speed * upper_gain, self.path_last + roc)
lower_speed = speed_max if low_kappa >= 0.0 else speed_min
upper_speed = speed_min if high_kappa >= 0.0 else speed_max
lower = max(-bounds.PATH_MAX, low_kappa * lower_speed * lower_gain, self.path_last - roc)
upper = min(-bounds.PATH_MIN, high_kappa * upper_speed * upper_gain, self.path_last + roc)
# Bound the actual-angle equivalent derivative at the worst admitted gain.
# 45ms core margin remains inside the native elapsed-time/50ms cap.
jerk_delta = bounds.LATERAL_JERK / max(i.speed - 1.0, 1.0) ** 2 * bounds.CORE_STEER_PERIOD_S * bounds.GAIN_MIN * effective_speed
@@ -88,6 +88,7 @@ class Host:
self.cp = cp
self.controller = AngleController(low_gain=low, high_gain=high)
self.inputs = None
self.tx_blocked = None
def update(self, CC, CS):
if self.inputs is not None:
@@ -102,6 +103,25 @@ class Host:
(not aol or CS.out.gearShifter in (structs.CarState.GearShifter.drive,
structs.CarState.GearShifter.sport, structs.CarState.GearShifter.low)) and
not CS.out.steerFaultTemporary and not CS.out.steerFaultPermanent and not CS.out.vehicleSensorsInvalid)
# Rejected frames do not advance Panda's accepted counter/path history.
# One real blocked-TX increment requires an accepted neutral resynchronization,
# rather than sending an indefinitely rejected chain of active counters.
counter_reader = getattr(self.inputs, "native_tx_blocked", None)
blocked = counter_reader() if ready and callable(counter_reader) else None
if not ready:
self.tx_blocked = None
elif type(blocked) is int and 0 <= blocked <= 0xFFFFFFFF:
changed = self.tx_blocked is not None and blocked != self.tx_blocked
self.tx_blocked = blocked
ready = not changed
speed_min = speed_max = None
speed_window = getattr(CS, "bluepilot_speed_window", None)
if speed_window is not None:
low, high, step = speed_window
# Allow source progress across the existing 100 ms CAN freshness window.
margin = max(0.05, 5 * step)
speed_min = max(0.1, min(low, CS.out.vEgoRaw) - margin)
speed_max = max(high, CS.out.vEgoRaw, 0.1) + margin
meta = model.meta if model is not None else None
return self.controller.step(Inputs(
active=ready, speed=CS.out.vEgoRaw, measured_curvature=-CS.out.yawRate / max(CS.out.vEgoRaw, 0.1),
@@ -111,4 +131,4 @@ class Host:
lane_change=int(getattr(meta.laneChangeState, "raw", meta.laneChangeState)) in (1, 2, 3) if meta else False,
lane_direction=int(getattr(meta.laneChangeDirection, "raw", meta.laneChangeDirection)) if meta else 0,
limit_status=getattr(CS, "lat_ctl_lim_stat", 0),
geometry=model_geometry(model, fresh=snapshot is not None)))
geometry=model_geometry(model, fresh=snapshot is not None), speed_min=speed_min, speed_max=speed_max))
+10
View File
@@ -40,6 +40,8 @@ class CarState(CarStateBase):
self.lkas_available = False
self.lkas_available_ts_nanos = 0
self.lateral_motion_control = None
self.bluepilot_speed_samples = [0] * 6 if CP.safetyConfigs and CP.safetyConfigs[-1].safetyParam & 128 else None
self.bluepilot_speed_window = None
def get_car_gps(self):
return self.car_gps_tracker.get()
@@ -63,6 +65,14 @@ class CarState(CarStateBase):
# car speed
ret.vEgoRaw = cp.vl["BrakeSysFeatures"]["Veh_V_ActlBrk"] * CV.KPH_TO_MS
if self.bluepilot_speed_samples is not None:
# Match Panda's six raw ABS speed samples, including its millimetre/second rounding.
for value in cp.vl_all["BrakeSysFeatures"]["Veh_V_ActlBrk"]:
self.bluepilot_speed_samples.append(math.floor(value * CV.KPH_TO_MS * 1000 + .5))
self.bluepilot_speed_samples = self.bluepilot_speed_samples[-6:]
samples = self.bluepilot_speed_samples
step = max(abs(b - a) for a, b in zip(samples, samples[1:], strict=False)) / 1000
self.bluepilot_speed_window = (min(samples) / 1000, max(samples) / 1000, step)
ret.vEgo, ret.aEgo = self.update_speed_kf(ret.vEgoRaw)
ret.yawRate = cp.vl["Yaw_Data_FD1"]["VehYaw_W_Actl"]
ret.standstill = cp.vl["DesiredTorqBrk"]["VehStop_D_Stat"] == 1
+2 -1
View File
@@ -1762,8 +1762,9 @@ class SelfdriveD:
# Ioniq6 also checks the physical brake before its separately published host sample.
# Retain only its already granted LAT; all proof, health and LONG withdrawal gates remain.
# The exact vehicle policies and all proof/health checks above remain required.
from openpilot.starpilot.car.ford.aol import early_native_long_withdrawal_supported as ford_early_long
return bool(gm_native_accepts_cp(self.CP, native.safetyModel, native.safetyParam) or
early_native_long_withdrawal_supported(self.CP) or
early_native_long_withdrawal_supported(self.CP) or ford_early_long(self.CP) or
toyota_aol_qualified(self.CP, marked_only=True) or
not native.pandaInventory[owners[0]].controlsAllowed and
not self.sm['pandaStates'][owners[0]].controlsAllowed)
+2 -1
View File
@@ -320,7 +320,8 @@ class TransportPauseFeedback:
from openpilot.starpilot.car.hyundai.aol import qualified_ccnc_long_aol as ev9_qualified
from opendbc.car import structs
ev9 = cp.brand == 'hyundai' and ev9_qualified(cp, marked_only=True)
early_long = gm or cp.brand == 'hyundai' and early_native_long_withdrawal_supported(cp) or ev9
from openpilot.starpilot.car.ford.aol import early_native_long_withdrawal_supported as ford_early_long
early_long = gm or cp.brand == 'hyundai' and early_native_long_withdrawal_supported(cp) or ev9 or ford_early_long(cp)
from openpilot.starpilot.car.gm.aol import f1_conventional_lateral_owner, gm_physical_pedal_withdrawal
gm_pedal = gm_physical_pedal_withdrawal(cp, cs) and not f1_conventional_lateral_owner(cp)
event_ns = int(sm.logMonoTime['onroadEvents'])
+7
View File
@@ -23,6 +23,13 @@ def native_accepts_cp(cp, model, param):
return qualified(cp, marked_only=True) and native_profile_supported(model, param) and param == aol_safety_config(cp).safetyParam
def early_native_long_withdrawal_supported(cp):
# Panda can observe a pedal before the parsed host sample. The existing
# independent-LAT proof remains mandatory; this never authorizes LONG.
return bool(qualified(cp, marked_only=True) and cp.openpilotLongitudinalControl and
aol_safety_config(cp).safetyParam & ANGLE)
def native_latch_rejected(cp, native):
return qualified(cp, marked_only=True) and native.requestedLateral and not native.lateralAllowed
@@ -123,6 +123,14 @@ class ManualTurnInputs:
except (AttributeError, KeyError, TypeError, ValueError, OverflowError):
return False
def native_tx_blocked(self):
"""Read only after native_permission qualified this same Panda snapshot."""
try:
value = self.sm["pandaStates"][-1].safetyTxBlocked
return int(value) if 0 <= value <= 0xFFFFFFFF else None
except (AttributeError, IndexError, KeyError, TypeError, ValueError, OverflowError):
return None
def assist_permission(self):
if not self.track_assist_permission:
return (), False
@@ -233,7 +233,7 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag
for original in messages:
if original.which() == 'modelV2':
original = original.as_builder()
original.modelV2.action.desiredCurvature = 0.0001
original.modelV2.action.desiredCurvature = getattr(monkeypatch, "ford_test_curvature", 0.0001)
original = original.as_reader()
snapshots.append(original)
return control_update(timestamp, snapshots)
@@ -256,6 +256,9 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag
local.setattr(sd.sm, 'update_msgs', location_update)
delayed_pedal_frames = []
delayed_pedals_seen = set()
def step(*, engaged=False, brake=False, reverse=False, warmup=False, fault=None, main_off=False, availability_transition=False):
nonlocal steps, warmup_waits, qualified_samples, active_lat_frames, active_long_frames, nonzero_after_brake, brake_seen
brake_seen |= brake
@@ -267,7 +270,8 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag
request = 0 if prior_axis is None else (int(prior_axis.aolAxisState.desiredLateral) | (int(prior_axis.aolAxisState.desiredLongitudinal) << 1))
arm_heartbeat = retained_arm(published.get('aolIntentWire'), prior_axis, now[0])
heartbeat = bool(request or prior_state is not None and prior_state.selfdriveState.enabled or arm_heartbeat)
frames = []
frames = delayed_pedal_frames[:]
delayed_pedal_frames.clear()
def receive(name, values):
fields = dict(values)
@@ -281,7 +285,14 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag
if not (fault == 'source-loss' and name == 'Yaw_Data_FD1'):
accepted = safety.safety_rx_hook(joined_packet(frame))
assert bool(accepted) == (not (fault == 'rx-quality' and name == 'BrakeSysFeatures'))
frames.append(frame)
pedal = name == 'EngVehicleSpThrottle' and fault == 'gas' or name == 'EngBrakeData' and brake
if getattr(monkeypatch, 'ford_native_first_pedal', False) and pedal and name not in delayed_pedals_seen:
# The real native RX already consumed this frame above. Deliver it
# to the host on the next update, retaining native authority now.
delayed_pedals_seen.add(name)
delayed_pedal_frames.append(frame)
else:
frames.append(frame)
with patch.object(native, 'rx', side_effect=receive):
native.pump(
@@ -290,7 +301,7 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag
gear=1 if reverse else 3,
mask=request,
heartbeat=heartbeat and fault != 'heartbeat',
speed=15.0,
speed=(3.1 + .12 * ((steps // 2) % 12) / 11 if getattr(monkeypatch, "ford_speed_window", False) else 15.0),
eps=1 if fault == 'temporary-eps' else 0,
)
assert native.tick == steps
@@ -385,7 +396,7 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag
qualified_samples += 1
parsed_before_producer = state.to_dict()
assert (main_off or state.cruiseState.available or availability_transition and not state.cruiseState.enabled) and not state.steeringPressed
assert bool(state.gasPressed) == (fault == 'gas')
assert bool(state.gasPressed) == (fault == 'gas' and not any(frame[0] == 0x204 for frame in delayed_pedal_frames))
axis, sd_state, command = drive(
steps,
car_state_message=published['carState'],
@@ -401,6 +412,9 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag
panda_state_message=panda,
safety_message=wire,
)
if delayed_pedal_frames:
assert command.latActive and host.aol_card_intent.allowed_latch, 'Native-first pedal erased granted lateral intent'
assert not command.longActive, 'Native LONG withdrawal was ignored'
assert state.to_dict() == parsed_before_producer, 'Authority harness changed actual parsed CarState'
previous_sd.update(sd.pm.messages)
control = controls.pm.messages['carControl']
@@ -493,12 +507,18 @@ def run_actual_producer(ford_joined, monkeypatch, *, scenario='reverse', reengag
assert bool(command.longActive) == joined.expected_long
for _ in range(30):
state, axis, sd_state, command = step(engaged=True, fault='gas')
if getattr(monkeypatch, 'ford_native_first_pedal', False):
assert command.latActive and host.aol_card_intent.allowed_latch, 'Native-first gas erased lateral intent'
assert not command.longActive or joined.last_permission & 2
assert command.latActive and not command.longActive and host.aol_card_intent.allowed_latch
for _ in range(30):
state, axis, sd_state, command = step(engaged=True)
assert command.latActive and bool(command.longActive) == joined.expected_long
for _ in range(30):
state, axis, sd_state, command = step(brake=True)
if getattr(monkeypatch, 'ford_native_first_pedal', False):
assert command.latActive and host.aol_card_intent.allowed_latch, 'Native-first brake erased lateral intent'
assert not command.longActive or joined.last_permission & 2
assert host.aol_card_intent.allowed_latch and command.latActive and not command.longActive
if getattr(monkeypatch, 'ford_angle', False):
for _ in range(20):
@@ -24,3 +24,108 @@ def test_angle_aol_brake_reverse_fault_and_hands_free(ford_joined,monkeypatch):
def test_mache_nonzero_angle_after_brake_with_hands_free(ford_joined,monkeypatch):
monkeypatch.ford_nonzero = True
run_actual_producer(ford_joined,monkeypatch,scenario='configuration-parity')
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18)])
@pytest.mark.parametrize('alpha', [False, True])
@pytest.mark.parametrize('hands_free', [True])
@pytest.mark.parametrize('topology', ['automatic', 'manual-multi'])
def test_tight_turn_speed_window_stays_inside_native_caps(ford_joined, monkeypatch):
# Route19's first blocked frames exceed Panda's cap by using the latest
# speed instead of its six-sample minimum. Exercise real parser/native windows.
monkeypatch.ford_nonzero = True
monkeypatch.ford_speed_window = True
monkeypatch.ford_test_curvature = .03
run_actual_producer(ford_joined, monkeypatch, scenario='configuration-parity')
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18)])
@pytest.mark.parametrize('alpha', [False, True])
@pytest.mark.parametrize('hands_free', [True])
@pytest.mark.parametrize('topology', ['automatic'])
def test_real_rejected_packet_resynchronizes_without_permission_bypass(ford_joined, monkeypatch):
from types import SimpleNamespace
from opendbc.car import structs
from opendbc.safety.tests.libsafety import libsafety_py
joined = ford_joined
native, safety = joined.native, joined.safety
native.arm()
adapter = joined.host.CI.CC.bp_lat
inputs = adapter.host.inputs
blocked = 0
monkeypatch.setattr(inputs, 'update', lambda: None)
monkeypatch.setattr(inputs, 'lateral_snapshot', lambda speed: None)
# Isolate the adapter recovery while preserving actual physical native authority.
# The full caller matrix above retains real wire/inventory/freshness qualification.
monkeypatch.setattr(inputs, 'native_permission', lambda cp, max_age_ns: bool(safety.aol_get_permission_mask() & 1))
CC = structs.CarControl(enabled=True, latActive=True)
CC.actuators.curvature = .0001
state = structs.CarState(canValid=True, vEgoRaw=15., gearShifter=structs.CarState.GearShifter.drive)
state.cruiseState.available = True
CS = SimpleNamespace(out=state, lat_ctl_lim_stat=0)
rejected = recovered = False
neutral_after_rejection = False
for frame in range(180):
native.pump(1, mask=1, speed=15.)
now = (1_000_000 + native.tick * 10_000) * 1000
from openpilot.cereal import messaging
panda = messaging.new_message('pandaStates', 1, valid=True, logMonoTime=now)
panda.pandaStates[0].safetyTxBlocked = blocked
inputs.sm.update_msgs(now / 1e9, [panda.as_reader()])
packets = []
if frame % 5 == 0:
packets += adapter.step_lateral(CC, CS, native.packer, joined.host.CI.CC.CAN, frame, now)
if frame % 3 == 0:
packets += adapter.step_lka(native.packer, joined.host.CI.CC.CAN, now)
for address, original, bus in packets:
data = bytearray(original)
active = address == 0x3D6 and (data[0] >> 4) & 7 == 1
if active and frame >= 80 and not rejected:
data[1] ^= 1 # One actual bad checksum; native must reject it.
assert not safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus, data))
blocked += 1
rejected = True
continue
accepted = safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus, data))
assert accepted, (frame, hex(address), data.hex())
if rejected and address == 0x3D6:
neutral_after_rejection |= not active
recovered |= active and neutral_after_rejection
assert rejected and neutral_after_rejection and recovered
# Host recovery never replaces native authorization.
safety.aol_set_host_request(0)
native.pump(5, mask=0, speed=15.)
now = (1_000_000 + native.tick * 10_000) * 1000
neutral = adapter.step_lateral(CC, CS, native.packer, joined.host.CI.CC.CAN, 180, now)[0]
assert (neutral[1][0] >> 4) & 7 == 0
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18), (CAR.FORD_ESCAPE_MK4, 32), (CAR.FORD_F_150_MK14, 66)])
@pytest.mark.parametrize('alpha', [True])
@pytest.mark.parametrize('hands_free', [True])
@pytest.mark.parametrize('topology', ['automatic', 'manual-multi'])
def test_native_first_pedal_preserves_only_granted_lateral(ford_joined, monkeypatch):
monkeypatch.ford_nonzero = True
monkeypatch.ford_native_first_pedal = True
run_actual_producer(ford_joined, monkeypatch, scenario='configuration-parity')
@pytest.mark.parametrize('identity', [(CAR.FORD_MUSTANG_MACH_E_MK1, 18)])
@pytest.mark.parametrize('alpha', [True])
@pytest.mark.parametrize('hands_free', [True])
@pytest.mark.parametrize('topology', ['automatic', 'manual-multi'])
def test_early_pedal_admission_requires_exact_marked_angle_long_profile(ford_joined):
from opendbc.car import structs
from openpilot.starpilot.car.ford.aol import early_native_long_withdrawal_supported
cp = ford_joined.host.CP
assert early_native_long_withdrawal_supported(cp)
for field, value in (('alternativeExperience', 0), ('openpilotLongitudinalControl', False),
('passive', True), ('dashcamOnly', True), ('brand', 'hyundai')):
changed = structs.CarParams(**cp.to_dict())
setattr(changed, field, value)
assert not early_native_long_withdrawal_supported(changed)
for mask in (128, 1, 16):
changed = structs.CarParams(**cp.to_dict())
changed.safetyConfigs[-1].safetyParam ^= mask
assert not early_native_long_withdrawal_supported(changed)
+1 -1
View File
@@ -1 +1 @@
{"protected_inventory_sha256": "28e81fb375b1a055d806280af30b92101095fcc3a11d1bb200e9a645a3d84d25", "schema_version": 1}
{"protected_inventory_sha256": "689ef29810231d81be86a79ecf7c83429f53f5bee5e38ae268e9a3ae977cfcbb", "schema_version": 1}