Ford: remove diagnostic tests and restore stock maneuver tools

Revert the six Ford channel-test commits and discard the unfinished keyboard standstill changes. Restore the original lateral maneuver and joystick tools, removing test toggles, parameters, schema additions, reports, and control overrides. Preserve the driving controller and C0 distance toggle.

Validation: 232 Ford controller tests passed; tracked tree matches de23ac008.
This commit is contained in:
Isaac Barham
2026-09-14 19:20:46 -04:00
parent 506e9dd651
commit 16b2a3e996
21 changed files with 35 additions and 1561 deletions
-25
View File
@@ -1220,21 +1220,6 @@ struct DriverAssistance {
struct LateralManeuverPlan {
desiredCurvature @0 :Float32; # 1/m
fordChannelTest @1 :FordChannelTest;
struct FordChannelTest {
runId @0 :UInt32;
channel @1 :Channel;
phase @2 :Phase;
delta @3 :Float32; # legacy raw-pulse increment; zero for normal channel maneuvers
speed @4 :Float32; # target m/s
keyboardRequestId @5 :UInt64; # nonzero for a manually triggered normal-controller step
keyboardPhase @6 :Phase; # baseline/pulse/release inside phase=maneuver
targetAccel @7 :Float32; # signed keyboard step amplitude, m/s^2 (not a raw field increment)
enum Channel { none @0; c0 @1; c1 @2; }
enum Phase { waiting @0; baseline @1; pulse @2; release @3; complete @4; aborted @5; maneuver @6; }
}
}
struct LongitudinalPlan @0xe00b5b3eba12876c {
@@ -2134,16 +2119,6 @@ struct Joystick {
# convenient for debug and live tuning
axes @0: List(Float32);
buttons @1: List(Bool);
fordKeyboard @2 :FordKeyboard;
struct FordKeyboard {
requestId @0 :UInt64; # changes only for a new action; heartbeats repeat the same request
channel @1 :LateralManeuverPlan.FordChannelTest.Channel;
speed @2 :Float32;
accel @3 :Float32;
action @4 :Action;
enum Action { idle @0; left @1; right @2; cancel @3; }
}
}
struct DriverStateV2 {
-2
View File
@@ -85,8 +85,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"LiveTorqueParameters", {PERSISTENT | DONT_LOG, BYTES}},
{"LocationFilterInitialState", {PERSISTENT, BYTES}},
{"LateralManeuverMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}},
{"FordChannelTestMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL, "0"}},
{"FordChannelKeyboardMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL, "0"}},
{"LongitudinalManeuverMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}},
{"LongitudinalPersonality", {PERSISTENT | BACKUP, INT, std::to_string(static_cast<int>(cereal::LongitudinalPersonality::STANDARD))}},
{"NetworkMetered", {PERSISTENT | BACKUP, BOOL}},
+2 -22
View File
@@ -16,7 +16,6 @@ from opendbc.car.vehicle_model import VehicleModel
from openpilot.selfdrive.controls.lib.drive_helpers import clip_curvature
from openpilot.selfdrive.controls.lib.ford_model_action import FordModelActionController, select_model_action_controller
from openpilot.selfdrive.controls.lib.ford_path import FordPath
from openpilot.selfdrive.controls.lib.ford_channel_test import FordChannelTest, use_maneuver_reference, selected as ford_channel_test_selected
from openpilot.selfdrive.controls.lib.latcontrol import LatControl
from openpilot.selfdrive.controls.lib.latcontrol_pid import LatControlPID
from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle, STEER_ANGLE_SATURATION_THRESHOLD
@@ -59,8 +58,6 @@ class Controls(ControlsExt):
self.ford_path_controller = select_model_action_controller(self.CP, self.params.get_bool("FordModelActionController"),
c0_time_based=self.params.get_bool("FordC0TimeBased"))
self.ford_model_action = isinstance(self.ford_path_controller, FordModelActionController)
self.ford_channel_test = (FordChannelTest(keyboard=self.params.get_bool('FordChannelKeyboardMode'))
if ford_channel_test_selected(self.CP, self.params) else None)
if self.CP.brand == "ford":
cloudlog.event("Ford path controller selected",
controller=type(self.ford_path_controller).__name__ if self.ford_model_action else "upstream")
@@ -152,7 +149,7 @@ class Controls(ControlsExt):
# Steering PID loop and lateral MPC
# Reset desired curvature to current to avoid violating the limits on engage
if use_maneuver_reference(self.sm['lateralManeuverPlan'], self.sm.valid['lateralManeuverPlan'], self.ford_channel_test is not None):
if self.sm.valid['lateralManeuverPlan']:
new_desired_curvature = self.sm['lateralManeuverPlan'].desiredCurvature if CC.latActive else self.curvature
else:
new_desired_curvature = model_v2.action.desiredCurvature if CC.latActive else self.curvature
@@ -171,8 +168,7 @@ class Controls(ControlsExt):
if self.CP.brand == "ford":
ford_model = model_v2 if self.sm.valid['modelV2'] else None
if self.ford_model_action:
reference_service = ('lateralManeuverPlan' if use_maneuver_reference(self.sm['lateralManeuverPlan'],
self.sm.valid['lateralManeuverPlan'], self.ford_channel_test is not None) else 'modelV2')
reference_service = 'lateralManeuverPlan' if self.sm.valid['lateralManeuverPlan'] else 'modelV2'
self.ford_path = self.ford_path_controller.update(
ford_model, self.desired_curvature, current_curvature=self.curvature, yaw_rate=-CS.yawRate, speed=CS.vEgo, now=time.monotonic(),
measurement_time=self.sm.logMonoTime['carState'] * 1e-9,
@@ -182,22 +178,6 @@ class Controls(ControlsExt):
driver_pressed=CS.steeringPressed, driver_torque=CS.steeringTorque,
pscm_status=self.sm['carStateSP'].fordPscmStatus if self.sm.valid['carStateSP'] else None,
)
if self.ford_channel_test is not None:
override = self.ford_channel_test.update(
self.sm['lateralManeuverPlan'], plan_valid=self.sm.valid['lateralManeuverPlan'],
plan_time=self.sm.logMonoTime['lateralManeuverPlan']*1e-9, now=time.monotonic(),
normal=self.ford_path, active=CC.latActive,
healthy=(CS.cruiseState.enabled or self.ford_channel_test.keyboard)
and self.sm.all_checks(['carStateSP', 'carState', 'vehicleParameters', 'modelV2']),
driver_input=CS.steeringPressed or not math.isfinite(CS.steeringTorque) or abs(CS.steeringTorque) > 1.
or (CS.gasPressed and not self.ford_channel_test.keyboard) or CS.brakePressed,
speed=CS.vEgo,
)
if override is not None:
self.ford_path = override
self.ford_path_controller.diagnostics['command'] = (override.path_offset, override.path_angle, 0., 0.)
if self.sm.frame % 20 == 0:
cloudlog.event('Ford channel test command', **self.ford_channel_test.diagnostics)
if not self.ford_path.valid:
CC.latActive = False
if self.sm.frame % 20 == 0:
@@ -1,84 +0,0 @@
"""Opt-in output isolation for the normal lateral maneuver controller path."""
import math
from opendbc.car.ford.values import FordFlags
from openpilot.common.constants import CV
from openpilot.selfdrive.controls.lib.ford_path import FordPath
PARAM = 'FordChannelTestMode'
KEYBOARD_PARAM = 'FordChannelKeyboardMode'
CONFLICTS = ('LateralManeuverMode', 'LongitudinalManeuverMode', 'JoystickDebugMode')
SPEEDS = (15.*CV.MPH_TO_MS, 20.*CV.MPH_TO_MS)
MAX_SPEED_ERROR = .7
MAX_RUN_S = 4. # normal suite ~2.6 s, keyboard step 3 s; allow scheduling/cadence variation
def selected(CP, params):
return bool(CP.brand == 'ford' and CP.flags & FordFlags.CANFD and params.get_bool('FordModelActionController')
and (params.get_bool(PARAM) or params.get_bool(KEYBOARD_PARAM)) and not any(params.get_bool(key) for key in CONFLICTS))
def is_channel_plan(plan):
test = getattr(plan, 'fordChannelTest', None)
return test is not None and test.channel != 'none'
def is_channel_maneuver(plan):
return is_channel_plan(plan) and plan.fordChannelTest.phase == 'maneuver'
def use_maneuver_reference(plan, valid, channel_test_enabled):
return valid and (not is_channel_plan(plan) or (channel_test_enabled and is_channel_maneuver(plan)))
class FordChannelTest:
"""Check the maneuver lease, then select one already-computed controller field.
The normal curvature limiter, model mapping and PI feedback run unchanged.
This class supplies no waveform, captured command, gain or feedback reset.
"""
def __init__(self, keyboard=False):
self.keyboard = keyboard
self.clear()
def clear(self):
self.run_id = self.channel = self.start = self.last_time = None
self.aborted = False
self.diagnostics = {}
def fail(self, reason):
self.aborted = True
self.diagnostics.update(status='aborted', reason=reason, command=(0., 0., 0., 0.))
return FordPath()
def update(self, plan, *, plan_valid, plan_time, now, normal, active, healthy, driver_input, speed):
test = plan.fordChannelTest
fresh = math.isfinite(now) and math.isfinite(plan_time) and -.005 <= now-plan_time <= .15
if not plan_valid and (fresh or self.run_id is None):
self.clear()
return None
if self.aborted:
return FordPath()
if not fresh or not plan_valid:
return self.fail('stale maneuver plan')
if not is_channel_maneuver(plan) or test.runId == 0 or test.delta != 0. or bool(test.keyboardRequestId) != self.keyboard:
return self.fail('invalid maneuver identity')
if not active or not healthy or not normal.valid or driver_input:
return self.fail('driver input, disengagement or invalid service')
if not all(math.isfinite(x) for x in (speed, test.speed, plan.desiredCurvature, normal.path_offset, normal.path_angle)):
return self.fail('nonfinite input')
if not any(abs(test.speed-v) < 1e-5 for v in SPEEDS) or abs(speed-test.speed) > MAX_SPEED_ERROR:
return self.fail('speed out of range')
if self.run_id is None:
self.run_id, self.channel, self.start, self.target_speed = test.runId, str(test.channel), now, test.speed
if (test.runId != self.run_id or test.channel != self.channel or test.speed != self.target_speed
or (self.last_time is not None and not .002 <= now-self.last_time <= .1) or now-self.start > MAX_RUN_S):
return self.fail('maneuver identity or timing changed')
self.last_time = now
command = FordPath(True, normal.path_offset if self.channel == 'c0' else 0., normal.path_angle if self.channel == 'c1' else 0., 0., 0.)
self.diagnostics = {'status': 'active', 'run_id': self.run_id, 'channel': self.channel,
'desired_curvature': plan.desiredCurvature, 'target_speed': test.speed,
'keyboard_request_id': test.keyboardRequestId, 'keyboard_phase': str(test.keyboardPhase),
'target_accel': test.targetAccel,
'command': (command.path_offset, command.path_angle, 0., 0.)}
return command
@@ -1,245 +0,0 @@
"""Compare normal maneuver injection and controller output with channel isolation."""
import math
from collections import defaultdict
from types import SimpleNamespace
import pytest
from opendbc.can import CANParser
from opendbc.car import Bus, structs
from opendbc.car.ford.carcontroller import CarController
from opendbc.car.ford.values import FordFlags
from openpilot.cereal import custom, log, messaging
from openpilot.common.params import Params, ParamKeyFlag
from openpilot.selfdrive.car.helpers import convert_carControlSP
from openpilot.selfdrive.controls.lib.drive_helpers import clip_curvature
from openpilot.selfdrive.controls.lib.ford_channel_test import FordChannelTest, SPEEDS, use_maneuver_reference
from openpilot.selfdrive.controls.lib.ford_path import FordPath
from openpilot.selfdrive.controls.tests.test_ford_model_action import straight
from openpilot.selfdrive.controls.tests.test_ford_model_action_adapter import Subscriptions, pipeline # noqa: F401
from openpilot.selfdrive.controls.tests.test_ford_model_action_selection import car_params, startup
from openpilot.tools.lateral_maneuvers import lateral_maneuversd as daemon
def plan(channel='c0', phase='maneuver', speed=SPEEDS[0], run_id=1, curvature=.01, keyboard_request_id=0):
msg = log.LateralManeuverPlan.new_message(desiredCurvature=curvature)
msg.fordChannelTest = {'runId': run_id, 'channel': channel, 'phase': phase, 'speed': speed, 'keyboardRequestId': keyboard_request_id}
with log.LateralManeuverPlan.from_bytes(msg.to_bytes()) as reader:
return reader.as_builder()
def update(test, now=10., msg=None, **changes):
args = {'plan_valid': True, 'plan_time': now, 'now': now, 'normal': FordPath(True, .24, .08),
'active': True, 'healthy': True, 'driver_input': False, 'speed': SPEEDS[0]}
return test.update(msg or plan(), **(args | changes))
@pytest.mark.parametrize('changes', [
{'active': False}, {'healthy': False}, {'driver_input': True}, {'speed': SPEEDS[0]+.71}, {'speed': math.nan},
{'normal': FordPath()}, {'normal': FordPath(True, math.nan, 0.)}, {'plan_time': 9.8},
])
def test_active_faults_zero_output_and_latch(changes):
test = FordChannelTest()
assert update(test).valid
assert update(test, 10.01, **changes) == FordPath()
assert update(test, 10.02) == FordPath()
assert update(test, 10.03, plan_valid=False) is None
assert update(test, 10.04, plan(run_id=2)).valid
@pytest.mark.parametrize('msg', [plan(channel='c1'), plan(run_id=2), plan(speed=SPEEDS[1]), plan(phase='pulse')])
def test_midrun_identity_changes_abort(msg):
test = FordChannelTest()
update(test)
assert update(test, 10.01, msg) == FordPath()
def test_legacy_raw_pulse_cannot_actuate():
msg = plan(phase='pulse')
msg.fordChannelTest.delta = 1.28
assert update(FordChannelTest(), msg=msg) == FordPath()
def test_fresh_but_frozen_maneuver_times_out():
test = FordChannelTest()
for i in range(401):
assert update(test, 10.+i*.01).valid
assert update(test, 14.01) == FordPath()
def test_mode_selection_and_transient_parameter(tmp_path):
params = Params(str(tmp_path))
params.put_bool('FordModelActionController', True, block=True)
assert startup(params=params).ford_channel_test is None
params.put_bool('FordChannelTestMode', True, block=True)
assert isinstance(startup(params=params).ford_channel_test, FordChannelTest)
for key in ('FordModelActionController', 'LateralManeuverMode', 'LongitudinalManeuverMode', 'JoystickDebugMode'):
params.put_bool(key, key != 'FordModelActionController', block=True)
assert startup(params=params).ford_channel_test is None
params.put_bool(key, key == 'FordModelActionController', block=True)
for cp in (car_params(brand='toyota'), car_params(flags=0)):
assert startup(cp, params).ford_channel_test is None
for flag in (ParamKeyFlag.CLEAR_ON_MANAGER_START, ParamKeyFlag.CLEAR_ON_OFFROAD_TRANSITION):
params.put_bool('FordChannelTestMode', True, block=True)
params.clear_all(flag)
assert not params.get_bool('FordChannelTestMode')
@pytest.mark.parametrize('channel', ['c0', 'c1'])
@pytest.mark.parametrize('speed', SPEEDS)
@pytest.mark.parametrize('limited', [False, True])
@pytest.mark.parametrize('keyboard', [False, True])
def test_real_injection_and_wire_match_normal_controller_on_selected_channel(pipeline, channel, speed, limited, keyboard): # noqa: F811
call, publication = pipeline
normal = startup()
mode = 'FordChannelKeyboardMode' if keyboard else 'FordChannelTestMode'
isolated = startup(params=SimpleNamespace(get_bool=lambda key: key in ('FordModelActionController', mode)))
controllers = (normal, isolated)
cp = structs.CarParams(flags=int(FordFlags.CANFD), carFingerprint=normal.CP.carFingerprint)
sender = CarController({Bus.pt: 'ford_lincoln_base_pt'}, cp, structs.CarParamsSP())
vehicle = SimpleNamespace(out=structs.CarState(vEgo=speed, vEgoRaw=speed), acc_tja_status_stock_values=defaultdict(int),
lkas_status_stock_values=defaultdict(int), buttons_stock_values=defaultdict(int))
parser = CANParser('ford_lincoln_base_pt', [('LateralMotionControl2', 100)], sender.CAN.main)
for control in controllers:
control.sm, control.desired_curvature, control.curvature = Subscriptions(True), 0., 0.
model = straight()
model.action = SimpleNamespace(desiredCurvature=-.15) # opposite model must lose to the actual maneuver injection
cs = SimpleNamespace(vEgo=speed, yawRate=0., canValid=True, steeringPressed=False, steeringTorque=0.,
gasPressed=keyboard, brakePressed=False, cruiseState=SimpleNamespace(enabled=not keyboard))
for i in range(251):
now = 10.+i*.01
request = (.5 if i < 100 else -.5)/speed**2
if keyboard:
request = (.5 if 50 <= i < 150 else 0.)/speed**2
for control in controllers:
sm = control.sm
sm.messages['lateralManeuverPlan'] = plan(channel=channel if control is isolated else 'none', curvature=request, speed=speed,
keyboard_request_id=1 if keyboard and control is isolated else 0)
sm.logMonoTime = dict.fromkeys(sm.logMonoTime, round(now*1e9))
status = sm['carStateSP'].fordPscmStatus
status.valid, status.canMonoTime, status.lateralState, status.limit = True, round(now*1e9), 2, 2 if limited else 0
cc = structs.CarControl(latActive=True)
cs.brakePressed = i == 250
exec(call, {'self': control, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': model, 'lp': SimpleNamespace(roll=0.),
'use_maneuver_reference': use_maneuver_reference, 'clip_curvature': clip_curvature,
'time': SimpleNamespace(monotonic=lambda t=now: t), 'math': math})
assert isolated.desired_curvature == normal.desired_curvature
assert isolated.ford_path_controller.core.correction == normal.ford_path_controller.core.correction
assert isolated.ford_path_controller.core.proportional == normal.ford_path_controller.core.proportional
if i != 250:
assert isolated.ford_path == FordPath(True, normal.ford_path.path_offset if channel == 'c0' else 0.,
normal.ford_path.path_angle if channel == 'c1' else 0.)
assert isolated.ford_path_controller.diagnostics['reference_age'] == pytest.approx(0.)
msg = custom.CarControlSP.new_message()
exec(publication, {'self': isolated, 'CC_SP': msg})
_, packets = sender.update(cc.as_reader(), convert_carControlSP(msg.as_reader()), vehicle, round(now*1e9))
parser.update([round(now*1e9), packets])
wire = parser.vl['LateralMotionControl2']
assert wire['LatCtlPathOffst_L_Actl'] == pytest.approx(-isolated.ford_path.path_offset, abs=1e-7)
assert wire['LatCtlPath_An_Actl'] == pytest.approx(-isolated.ford_path.path_angle, abs=1e-7)
assert wire['LatCtl_D2_Rq'] == (0 if i == 250 else 2)
assert wire['LatCtlCurv_No_Actl'] == wire['LatCtlCrv_NoRate2_Actl'] == 0.
if i == 50:
assert isolated.desired_curvature > 0.
if i == 200:
if keyboard:
assert isolated.desired_curvature == 0.
else:
assert isolated.desired_curvature < 0.
def run_daemon(monkeypatch, interrupt=False):
class Finished(Exception):
pass
plans, alerts, checks = [], [], []
clock = [10.]
pulse_samples = [0]
cp = daemon.car.CarParams.new_message().to_bytes()
class SM(messaging.SubMaster):
def __init__(self, services, **kwargs):
super().__init__(services, **kwargs)
self.events = {s: messaging.new_message(s) for s in services}
def update(self):
clock[0] += .05
assert clock[0] < 400., 'Suite did not finish'
cs = self.events['carState'].carState
cs.vEgo = plans[-1].lateralManeuverPlan.fordChannelTest.speed if plans else SPEEDS[0]
cs.canValid, cs.cruiseState.enabled = True, True
cs.steeringPressed = interrupt and pulse_samples[0] == 5
self.events['carControl'].carControl.latActive = True
self.events['carControl'].carControl.orientationNED = [0., 0., 0.]
self.events['selfdriveState'].selfdriveState.enabled = True
for msg in self.events.values():
msg.valid, msg.logMonoTime = True, round(clock[0]*1e9)
self.update_msgs(clock[0], [m.as_reader() for m in self.events.values()])
checks.append(self.all_checks())
if cs.steeringPressed:
pulse_samples[0] += 1
class PM:
def __init__(self, _services):
pass
def send(self, service, msg):
msg.logMonoTime = round(clock[0]*1e9)
(plans if service == 'lateralManeuverPlan' else alerts).append(msg)
if service == 'lateralManeuverPlan' and msg.valid:
pulse_samples[0] += 1
if service == 'alertDebug' and msg.alertDebug.alertText1 == 'Maneuvers Finished':
raise Finished
with monkeypatch.context() as mp:
mp.setattr(daemon.messaging, 'SubMaster', SM)
mp.setattr(daemon.messaging, 'PubMaster', PM)
mp.setattr(daemon, 'Params', lambda: SimpleNamespace(get=lambda *_a, **_kw: cp))
with pytest.raises(Finished):
daemon.main(ford_channels=True)
assert all(checks[2:])
return plans, alerts
@pytest.mark.parametrize('interrupt', [False, True])
def test_actual_daemon_reuses_all_normal_waveforms_repeats_and_retry(monkeypatch, interrupt):
plans, alerts = run_daemon(monkeypatch, interrupt)
groups = defaultdict(list)
for msg in plans:
if msg.valid:
groups[msg.lateralManeuverPlan.fordChannelTest.runId].append(msg.lateralManeuverPlan)
# 16 maneuvers, each performed 3 times. An interrupted attempt is retried.
assert len(groups) == 48+interrupt
assert sum(a.alertDebug.alertText1 == 'Complete' for a in alerts) == 48
expected_runs = []
for maneuver in daemon.channel_maneuvers():
for _ in range(3):
reference = daemon.Maneuver(maneuver.description, maneuver.actions, initial_speed=maneuver.initial_speed)
expected = []
while not reference.finished:
value = reference.get_accel(maneuver.initial_speed, True, 0., 0.)
if reference.active and not reference._run_completed:
expected.append(value/maneuver.initial_speed**2)
expected_runs.append((maneuver.channel, maneuver.initial_speed, expected))
completed = list(groups.values())[1:] if interrupt else list(groups.values())
for actual, (channel, speed, expected) in zip(completed, expected_runs, strict=True):
assert [p.desiredCurvature for p in actual] == pytest.approx(expected, abs=1e-7)
assert all(p.fordChannelTest.channel == channel and p.fordChannelTest.speed == pytest.approx(speed) for p in actual)
assert all(p.fordChannelTest.delta == 0. and p.fordChannelTest.phase == 'maneuver' for p in actual)
if interrupt:
assert len(next(iter(groups.values()))) == 5
@pytest.mark.parametrize('phase', ['maneuver', 'pulse'])
def test_channel_payload_is_ignored_when_toggle_off(pipeline, phase): # noqa: F811
controls = startup()
sm = Subscriptions(True)
sm.messages['lateralManeuverPlan'] = plan(phase=phase, curvature=-.1)
controls.sm, controls.desired_curvature, controls.curvature = sm, 0., 0.
model = straight()
model.action = SimpleNamespace(desiredCurvature=.1)
cc = structs.CarControl(latActive=True)
cs = SimpleNamespace(vEgo=20., yawRate=0., canValid=True, steeringPressed=False, steeringTorque=0.)
exec(pipeline[0], {'self': controls, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': model, 'lp': SimpleNamespace(roll=0.),
'use_maneuver_reference': use_maneuver_reference, 'clip_curvature': clip_curvature, 'time': SimpleNamespace(monotonic=lambda: 1.)})
assert controls.desired_curvature > 0. and controls.ford_path.path_angle > 0.
@@ -22,7 +22,6 @@ from openpilot.selfdrive.car.helpers import convert_carControlSP
from openpilot.selfdrive.controls.lib.drive_helpers import clip_curvature
from openpilot.selfdrive.controls.lib.ford_model_action import FordModelActionController, encode_model_action
from openpilot.selfdrive.controls.lib.ford_path import FordPath
from openpilot.selfdrive.controls.lib.ford_channel_test import use_maneuver_reference
from openpilot.selfdrive.controls.tests.test_ford_model_action import circle, straight
from openpilot.selfdrive.controls.tests.test_ford_model_action_selection import CANFD_CARS, car_params, startup
@@ -141,7 +140,7 @@ def pipeline():
controls_file = root/'selfdrive/controls/controlsd.py'
body = _method(controls_file, 'Controls', 'state_control').body
# Execute the actual source choice, upstream limiter and Ford integration.
selection = next(n for n in body if isinstance(n, ast.If) and "self.sm.valid['lateralManeuverPlan']" in ast.unparse(n.test))
selection = next(n for n in body if isinstance(n, ast.If) and ast.unparse(n.test) == "self.sm.valid['lateralManeuverPlan']")
limiter = next(n for n in body if isinstance(n, ast.Assign) and isinstance(n.value, ast.Call) and
isinstance(n.value.func, ast.Name) and n.value.func.id == 'clip_curvature')
branch = next(n for n in body if isinstance(n, ast.If) and ast.unparse(n.test) == "self.CP.brand == 'ford'")
@@ -184,7 +183,7 @@ def test_actual_controlsd_selection_limiting_publication_and_downstream_can(pipe
cc = structs.CarControl(latActive=True)
cs = SimpleNamespace(vEgo=20., yawRate=-.0072, canValid=True, steeringPressed=False, steeringTorque=0.)
environment = {'self': controls, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': model, 'lp': SimpleNamespace(roll=0.),
'use_maneuver_reference': use_maneuver_reference, 'clip_curvature': clip_curvature, 'time': SimpleNamespace(monotonic=lambda: 1.)}
'clip_curvature': clip_curvature, 'time': SimpleNamespace(monotonic=lambda: 1.)}
exec(call, environment)
expected_curvature = (-1 if maneuver else 1)*.000125
assert controls.desired_curvature == pytest.approx(expected_curvature)
@@ -228,7 +227,7 @@ def test_actual_controlsd_service_gates(pipeline, maneuver, failed):
model = straight()
model.action = SimpleNamespace(desiredCurvature=.1)
exec(pipeline[0], {'self': controls, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': model, 'lp': SimpleNamespace(roll=0.),
'use_maneuver_reference': use_maneuver_reference, 'clip_curvature': clip_curvature, 'time': SimpleNamespace(monotonic=lambda: 1.)})
'clip_curvature': clip_curvature, 'time': SimpleNamespace(monotonic=lambda: 1.)})
assert controls.ford_path.valid == cc.latActive == (failed == 'lateralManeuverPlan' and not maneuver)
@@ -257,7 +256,7 @@ def test_feedback_through_actual_controlsd_publication_and_100hz_sender(pipeline
controls.curvature, cs.steeringTorque = measured, torque
sm.logMonoTime.update(carState=round(now*1e9), modelV2=round(now*1e9))
environment = {'self': controls, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': model,
'lp': SimpleNamespace(roll=0.), 'use_maneuver_reference': use_maneuver_reference, 'clip_curvature': clip_curvature,
'lp': SimpleNamespace(roll=0.), 'clip_curvature': clip_curvature,
'time': SimpleNamespace(monotonic=lambda now=now: now)}
exec(call, environment)
msg = custom.CarControlSP.new_message()
@@ -300,7 +299,7 @@ def test_actual_controlsd_passes_only_valid_pscm_service_to_feedback(pipeline, s
status = sm['carStateSP'].fordPscmStatus
status.valid, status.canMonoTime, status.limit, status.lateralState = True, round(now*1e9), 2, 2
exec(pipeline[0], {'self': controls, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': model,
'lp': SimpleNamespace(roll=0.), 'use_maneuver_reference': use_maneuver_reference, 'clip_curvature': clip_curvature,
'lp': SimpleNamespace(roll=0.), 'clip_curvature': clip_curvature,
'time': SimpleNamespace(monotonic=lambda now=now: now)})
controller = controls.ford_path_controller
assert controller.diagnostics['pscm_limited'] is service_valid
@@ -336,7 +335,7 @@ def test_continuous_pi_reversal_through_selected_limited_request_and_actual_can(
sm.logMonoTime.update(carState=round(now*1e9), modelV2=round(now*1e9), lateralManeuverPlan=round(now*1e9))
before = core.c0, core.c1, core.correction
exec(call, {'self': controls, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': model,
'lp': SimpleNamespace(roll=0.), 'use_maneuver_reference': use_maneuver_reference, 'clip_curvature': clip_curvature,
'lp': SimpleNamespace(roll=0.), 'clip_curvature': clip_curvature,
'time': SimpleNamespace(monotonic=lambda now=now: now)})
assert_current_request(core, controls.desired_curvature, cs.vEgo)
increment = .25*speed*(controls.desired_curvature-controls.curvature)*.01
@@ -391,7 +390,7 @@ def test_unwind_and_catchup_through_selected_request_and_actual_can(pipeline, si
sm.logMonoTime.update(carState=round(now*1e9), modelV2=round(now*1e9), lateralManeuverPlan=round(now*1e9))
before = core.c0, core.c1, core.correction
exec(call, {'self': controls, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': model,
'lp': SimpleNamespace(roll=0.), 'use_maneuver_reference': use_maneuver_reference, 'clip_curvature': clip_curvature,
'lp': SimpleNamespace(roll=0.), 'clip_curvature': clip_curvature,
'time': SimpleNamespace(monotonic=lambda now=now: now)})
assert_current_request(core, controls.desired_curvature, cs.vEgo)
if frame == 129:
@@ -441,7 +440,7 @@ def test_heading_overflow_and_release_through_actual_can(pipeline, sign, fingerp
controls.curvature = clip_curvature(cs.vEgo, controls.desired_curvature, desired, 0.)[0]
sm.logMonoTime.update(carState=round(now*1e9), modelV2=round(now*1e9))
exec(call, {'self': controls, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': model,
'lp': SimpleNamespace(roll=0.), 'use_maneuver_reference': use_maneuver_reference, 'clip_curvature': clip_curvature,
'lp': SimpleNamespace(roll=0.), 'clip_curvature': clip_curvature,
'time': SimpleNamespace(monotonic=lambda now=now: now)})
assert_current_request(core, controls.desired_curvature, cs.vEgo)
assert core.correction == 0.
@@ -498,7 +497,7 @@ def test_toggle_off_preserves_upstream_actuators_and_can(pipeline, fingerprint,
now = 1.+frame*.01
sm.logMonoTime.update(carState=round(now*1e9), modelV2=round(now*1e9))
exec(call, {'self': controls, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': model,
'lp': SimpleNamespace(roll=0.), 'use_maneuver_reference': use_maneuver_reference, 'clip_curvature': clip_curvature})
'lp': SimpleNamespace(roll=0.), 'clip_curvature': clip_curvature})
assert cc.actuators.curvature == before and cc.latActive == active
msg = custom.CarControlSP.new_message()
exec(publication, {'self': controls, 'CC_SP': msg})
@@ -11,7 +11,6 @@ from opendbc.car.ford.values import CAR, FordFlags
from openpilot.common.params import Params, ParamKeyFlag, ParamKeyType
from openpilot.selfdrive.controls.lib.ford_model_action import C1_INTEGRAL_GAIN, C1_PROPORTIONAL_GAIN, FordModelActionController, select_model_action_controller
from openpilot.selfdrive.controls.lib.ford_path import FordPath
from openpilot.selfdrive.controls.lib.ford_channel_test import FordChannelTest, selected as ford_channel_test_selected
CANFD_CARS = [car for car in CAR if car.config.flags & FordFlags.CANFD]
@@ -35,7 +34,6 @@ def startup(cp=None, params=None):
environment = {'self': controls, 'FordFlags': FordFlags, 'FordPath': FordPath,
'FordModelActionController': FordModelActionController,
'select_model_action_controller': select_model_action_controller,
'FordChannelTest': FordChannelTest, 'ford_channel_test_selected': ford_channel_test_selected,
'cloudlog': SimpleNamespace(event=lambda *args, **kwargs: None)}
exec(compile(ast.Module(body=body[start:end+1], type_ignores=[]), str(filename), 'exec'), environment)
return controls
@@ -167,7 +167,6 @@ class DeveloperLayout(Widget):
self._params.put_bool("SshEnabled", state, block=True)
def _on_joystick_debug_mode(self, state: bool):
self._params.put_bool('FordChannelKeyboardMode', False, block=True)
self._params.put_bool("JoystickDebugMode", state, block=True)
self._params.put_bool("LongitudinalManeuverMode", False, block=True)
self._long_maneuver_toggle.action_item.set_state(False)
@@ -175,7 +174,6 @@ class DeveloperLayout(Widget):
self._lat_maneuver_toggle.action_item.set_state(False)
def _on_long_maneuver_mode(self, state: bool):
self._params.put_bool('FordChannelKeyboardMode', False, block=True)
self._params.put_bool("LongitudinalManeuverMode", state, block=True)
self._params.put_bool("JoystickDebugMode", False, block=True)
self._joystick_toggle.action_item.set_state(False)
@@ -183,7 +181,6 @@ class DeveloperLayout(Widget):
self._lat_maneuver_toggle.action_item.set_state(False)
def _on_lat_maneuver_mode(self, state: bool):
self._params.put_bool('FordChannelKeyboardMode', False, block=True)
self._params.put_bool("LateralManeuverMode", state, block=True)
self._params.put_bool("ExperimentalMode", False, block=True)
self._params.put_bool("JoystickDebugMode", False, block=True)
@@ -1,5 +1,4 @@
from collections.abc import Callable
from opendbc.car.ford.values import FordFlags
from openpilot.common.time_helpers import system_time_valid
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle, BigParamControl, BigCircleParamControl, GreyBigButton
@@ -80,9 +79,6 @@ class DeveloperLayoutMici(NavScroller):
self._lat_maneuver_toggle = BigToggle("lateral maneuver mode",
initial_state=ui_state.params.get_bool("LateralManeuverMode"),
toggle_callback=self._on_lat_maneuver_mode)
self._ford_channel_test_toggle = BigToggle("Ford C0 / C1 test",
initial_state=ui_state.params.get_bool("FordChannelTestMode"),
toggle_callback=self._on_ford_channel_test)
self._alpha_long_toggle = BigToggle("alpha longitudinal",
initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"),
toggle_callback=self._on_alpha_long_enabled)
@@ -97,7 +93,6 @@ class DeveloperLayoutMici(NavScroller):
self._joystick_toggle,
self._long_maneuver_toggle,
self._lat_maneuver_toggle,
self._ford_channel_test_toggle,
self._alpha_long_toggle,
self._debug_mode_toggle,
])
@@ -109,13 +104,11 @@ class DeveloperLayoutMici(NavScroller):
("JoystickDebugMode", self._joystick_toggle),
("LongitudinalManeuverMode", self._long_maneuver_toggle),
("LateralManeuverMode", self._lat_maneuver_toggle),
("FordChannelTestMode", self._ford_channel_test_toggle),
("AlphaLongitudinalEnabled", self._alpha_long_toggle),
("ShowDebugInfo", self._debug_mode_toggle),
)
onroad_blocked_toggles = (self._adb_toggle, self._joystick_toggle)
release_blocked_toggles = (self._joystick_toggle, self._long_maneuver_toggle, self._lat_maneuver_toggle,
self._ford_channel_test_toggle, self._alpha_long_toggle)
release_blocked_toggles = (self._joystick_toggle, self._long_maneuver_toggle, self._lat_maneuver_toggle, self._alpha_long_toggle)
engaged_blocked_toggles = (self._long_maneuver_toggle, self._lat_maneuver_toggle, self._alpha_long_toggle)
# Hide non-release toggles on release builds
@@ -125,8 +118,6 @@ class DeveloperLayoutMici(NavScroller):
# Disable toggles that require offroad
for item in onroad_blocked_toggles:
item.set_enabled(lambda: ui_state.is_offroad())
self._ford_channel_test_toggle.set_enabled(lambda: ui_state.is_offroad())
self._ford_channel_test_toggle.set_visible(False) # revealed after CarParams gating on show
# Disable toggles that require not engaged
for item in engaged_blocked_toggles:
@@ -149,9 +140,6 @@ class DeveloperLayoutMici(NavScroller):
def _update_toggles(self):
ui_state.update_params()
cp = ui_state.CP
self._ford_channel_test_toggle.set_visible(bool(cp is not None and cp.brand == 'ford' and cp.flags & FordFlags.CANFD
and not ui_state.is_release and ui_state.params.get_bool('FordModelActionController')))
# CP gating
if ui_state.CP is not None:
@@ -175,7 +163,6 @@ class DeveloperLayoutMici(NavScroller):
item.set_checked(ui_state.params.get_bool(key))
def _on_joystick_debug_mode(self, state: bool):
self._clear_ford_channel_test()
ui_state.params.put_bool("JoystickDebugMode", state, block=True)
ui_state.params.put_bool("LongitudinalManeuverMode", False, block=True)
self._long_maneuver_toggle.set_checked(False)
@@ -183,7 +170,6 @@ class DeveloperLayoutMici(NavScroller):
self._lat_maneuver_toggle.set_checked(False)
def _on_long_maneuver_mode(self, state: bool):
self._clear_ford_channel_test()
ui_state.params.put_bool("LongitudinalManeuverMode", state, block=True)
ui_state.params.put_bool("JoystickDebugMode", False, block=True)
self._joystick_toggle.set_checked(False)
@@ -192,7 +178,6 @@ class DeveloperLayoutMici(NavScroller):
restart_needed_callback()
def _on_lat_maneuver_mode(self, state: bool):
self._clear_ford_channel_test()
ui_state.params.put_bool("LateralManeuverMode", state, block=True)
ui_state.params.put_bool("ExperimentalMode", False, block=True)
ui_state.params.put_bool("JoystickDebugMode", False, block=True)
@@ -201,21 +186,6 @@ class DeveloperLayoutMici(NavScroller):
self._long_maneuver_toggle.set_checked(False)
restart_needed_callback()
def _clear_ford_channel_test(self):
ui_state.params.put_bool('FordChannelTestMode', False, block=True)
ui_state.params.put_bool('FordChannelKeyboardMode', False, block=True)
self._ford_channel_test_toggle.set_checked(False)
def _on_ford_channel_test(self, state: bool):
ui_state.params.put_bool('FordChannelKeyboardMode', False, block=True)
ui_state.params.put_bool('FordChannelTestMode', state, block=True)
for key, toggle in (('LateralManeuverMode', self._lat_maneuver_toggle),
('LongitudinalManeuverMode', self._long_maneuver_toggle), ('JoystickDebugMode', self._joystick_toggle)):
ui_state.params.put_bool(key, False, block=True)
toggle.set_checked(False)
ui_state.params.put_bool('ExperimentalMode', False, block=True)
restart_needed_callback()
def _on_alpha_long_enabled(self, state: bool):
def do_toggle(_state: bool):
ui_state.params.put_bool("AlphaLongitudinalEnabled", _state, block=True)
@@ -8,7 +8,6 @@ from openpilot.common.params import Params
from openpilot.common.hardware import PC, COMMA_HARDWARE
from openpilot.system.manager.process import PythonProcess, NativeProcess, DaemonProcess
from openpilot.common.hardware.hw import Paths
from openpilot.selfdrive.controls.lib.ford_channel_test import selected as ford_channel_test_selected
from openpilot.sunnypilot.mapd.mapd_manager import MAPD_PATH
@@ -51,9 +50,6 @@ def long_maneuver(started: bool, params: Params, CP: car.CarParams) -> bool:
def lat_maneuver(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and params.get_bool("LateralManeuverMode")
def ford_channel_test(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and ford_channel_test_selected(CP, params)
def not_long_maneuver(started: bool, params: Params, CP: car.CarParams) -> bool:
return started and not params.get_bool("LongitudinalManeuverMode")
@@ -150,7 +146,6 @@ procs = [
PythonProcess("plannerd", "openpilot.selfdrive.controls.plannerd", not_long_maneuver),
PythonProcess("maneuversd", "openpilot.tools.longitudinal_maneuvers.maneuversd", long_maneuver),
PythonProcess("lateral_maneuversd", "openpilot.tools.lateral_maneuvers.lateral_maneuversd", lat_maneuver),
PythonProcess("ford_maneuversd", "openpilot.tools.lateral_maneuvers.ford_maneuversd", ford_channel_test),
PythonProcess("radard", "openpilot.selfdrive.controls.radard", only_onroad),
PythonProcess("hardwared", "openpilot.system.hardware.hardwared", always_run),
PythonProcess("modem", "openpilot.common.hardware.comma.modem", always_run, enabled=COMMA_HARDWARE),
-3
View File
@@ -5,9 +5,6 @@
With joystick_control, you can connect your laptop to your comma device over the network and debug controls using a joystick or keyboard.
joystick_control uses [inputs](https://pypi.org/project/inputs) which supports many common gamepads and joysticks.
For the custom Ford C0/C1 controller, use the [Ford keyboard channel test](../lateral_maneuvers/FORD_CHANNEL_TEST.md#keyboard-triggered-steps-acc-or-mads).
It preserves the normal controller and ACC/MADS, unlike stock Joystick Debug Mode below.
## Usage
The car must be off, and openpilot must be offroad before starting `joystick_control`.
@@ -1,137 +0,0 @@
#!/usr/bin/env python3
"""Keyboard frontend for Ford isolated-channel tests. Keeps controlsd and ACC/MADS running."""
import argparse
import math
import time
from opendbc.car.ford.values import FordFlags
from opendbc.car.structs import car
from openpilot.cereal import log, messaging
from openpilot.common.constants import CV
from openpilot.common.params import Params
from openpilot.common.realtime import Ratekeeper
from openpilot.selfdrive.controls.lib.ford_channel_test import CONFLICTS, KEYBOARD_PARAM, PARAM, SPEEDS
from openpilot.tools.lateral_maneuvers.ford_keyboard import MIN_ACCEL, MAX_ACCEL, ACCEL_INCREMENT
from openpilot.tools.lib.kbhit import KBHit
HELP = '1 C0 | 2 C1 | V 15/20 mph | +/- strength | A left step | D right step | R cancel | Q quit'
class KeyboardControl:
def __init__(self, speed=15, accel=.5):
if speed not in (15, 20) or not math.isfinite(accel) or not MIN_ACCEL <= accel <= MAX_ACCEL:
raise ValueError(f'Use 15/20 mph and {MIN_ACCEL}–{MAX_ACCEL} m/s²')
self.request = log.Joystick.FordKeyboard.new_message(channel='c0', speed=speed*CV.MPH_TO_MS, accel=accel, action='idle')
self.last_key, self.last_key_time = '', -math.inf
def action(self, action, now):
self.request.requestId = max(self.request.requestId+1, int(now*1e9))
self.request.action = action
def key(self, key, now):
key = key.lower()
repeated = key == self.last_key and now-self.last_key_time < .5
self.last_key, self.last_key_time = key, now
if key in ('a', 'd'):
if not repeated:
self.action('left' if key == 'a' else 'right', now)
elif key in ('r', 'q'):
self.action('cancel', now)
else:
self.request.action = 'idle'
if key in ('1', '2'):
self.request.channel = 'c0' if key == '1' else 'c1'
elif key == 'v':
self.request.speed = SPEEDS[1] if abs(self.request.speed-SPEEDS[0]) < 1e-5 else SPEEDS[0]
elif key in ('+', '=', '-', '_'):
increment = ACCEL_INCREMENT if key in ('+', '=') else -ACCEL_INCREMENT
self.request.accel = min(MAX_ACCEL, max(MIN_ACCEL, self.request.accel+increment))
return key != 'q'
def message(self):
msg = messaging.new_message('testJoystick')
msg.valid = True
# Normal joystick consumers receive no gas/brake or steering-axis request.
msg.testJoystick.axes = [0., 0.]
msg.testJoystick.fordKeyboard = self.request
return msg
def enable(params):
offroad = params.get_bool('IsOffroad')
if not offroad and (not params.get_bool(KEYBOARD_PARAM) or any(params.get_bool(k) for k in (*CONFLICTS, PARAM))):
raise ValueError('Start this tool while the car is off and openpilot is offroad.')
if not params.get_bool('FordModelActionController'):
raise ValueError('Enable the Ford model-action controller first.')
raw = params.get('CarParamsPersistent')
if not raw:
raise ValueError('Drive once to identify the car before enabling this test.')
cp = messaging.log_from_bytes(raw, car.CarParams)
if cp.brand != 'ford' or not cp.flags & FordFlags.CANFD:
raise ValueError('This test requires a CAN FD Ford.')
if not offroad:
return # Reconnect to an already-selected keyboard mode without changing any driving mode.
for key in (*CONFLICTS, PARAM):
params.put_bool(key, False, block=True)
params.put_bool(KEYBOARD_PARAM, True, block=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--speed', type=int, choices=(15, 20), default=15, help='target mph (default: 15)')
parser.add_argument('--accel', type=float, default=.5, help=f'step strength in m/s², {MIN_ACCEL}–{MAX_ACCEL} (default: 0.5)')
args = parser.parse_args()
params = Params()
try:
control = KeyboardControl(args.speed, args.accel)
kb = KBHit()
enable(params)
except (ValueError, OSError) as exc:
parser.exit(1, f'{exc}\n')
pm = messaging.PubMaster(['testJoystick'])
sm = messaging.SubMaster(['alertDebug', 'carState', 'carStateSP', 'carControlSP'])
rk = Ratekeeper(20, print_delay_threshold=None)
print(f'\nFord keyboard test connected. Use ACC or MADS with manual throttle.\n{HELP}\n', flush=True)
print('Strength is a maneuver target, not a percentage of C0/C1. R cancels the test; normal lateral control remains active.', flush=True)
running = True
try:
while running:
# Bound input work so pasted text cannot stall the heartbeat/watchdog.
for _ in range(32):
if not kb.kbhit():
break
key = kb.getch()
if not key:
running = False
break
running = control.key(key, time.monotonic())
if not running:
break
if not params.get_bool(KEYBOARD_PARAM):
print('\nKeyboard test mode cleared; exiting.', flush=True)
break
pm.send('testJoystick', control.message())
sm.update(0)
if rk.frame % 10 == 0:
req, cs = control.request, sm['carState']
path, pscm = sm['carControlSP'].fordLateralPath, sm['carStateSP'].fordPscmStatus
telemetry = (f'C0 {path.pathOffset:+.2f}m C1 {path.pathAngle:+.4f}rad | wheel {cs.steeringAngleDeg:+.1f}° | limit {pscm.limit}'
if sm.all_alive() and sm.all_valid() else 'Waiting for live vehicle data')
print(f'{str(req.channel).upper()} | {req.speed*CV.MS_TO_MPH:.0f}mph | {req.accel:.2f}m/s² | '
+ f'{sm["alertDebug"].alertText1} | {telemetry}', flush=True)
rk.keep_time()
except KeyboardInterrupt:
pass
finally:
control.action('cancel', time.monotonic())
pm.send('testJoystick', control.message())
kb.set_normal_term()
# Keep the daemon alive onroad so it can explicitly publish an inactive plan;
# otherwise controlsd would only see the last, now-stale active plan.
if params.get_bool('IsOffroad'):
params.put_bool(KEYBOARD_PARAM, False, block=True)
print('\nKeyboard disconnected; no further steps will start.', flush=True)
if __name__ == '__main__':
main()
@@ -1,54 +0,0 @@
# Ford lateral maneuvers, one output channel at a time
Enable **Settings → Developer → Ford C0 / C1 test** on comma four while offroad. The Ford model-action controller must be enabled on a CAN FD Ford. This runs the existing lateral maneuver tool with a single output channel selected.
The suite uses the **normal lateral-acceleration targets**, converted to desired curvature by the normal maneuver tool. That request goes through the existing curvature/jerk limits and the normal Ford controller. **C0-only sends its normal C0 output with C1 zero. C1-only sends its normal C1 output, including P/I feedback, with C0 zero.** C2/C3 stay zero. The diagnostic does not replace the controller mapping, freeze a starting command, reset feedback, or inject a percentage of the field range.
At **15 mph**, it runs the standard step right, step left, 0.5 Hz sine and jitter through C0, then through C1. It repeats the same sequence at **20 mph**. Each maneuver retains the standard three runs: **48 completed runs total**.
The step includes the normal opposite-direction command; the sine and jitter keep the existing action arrays and timing. These tests therefore exercise turn-in and reversal through the real controller. Equal desired acceleration does not guarantee that either channel alone can achieve it. This measures the controller and vehicle together with one output selected, not the PSCM channel in isolation from controller feedback.
Use the same clear, straight test area as the regular lateral maneuver suite. Set ACC to the displayed speed and engage lateral control. The normal maneuver readiness/countdown checks apply. Driver input, disengagement or invalid inputs stop the attempted run. **Completed runs remain completed; the interrupted run retries when the standard readiness conditions recover.** After steering intervention alone, another disengagement is not required. Brake/ACC disengagement requires reengaging ACC and lateral control.
The former pulse-specific 15° wheel and 1 m/s² aborts are gone. They would cut off normal maneuvers. Normal controller limits, vehicle fault handling, field bounds, driver intervention and a stale-plan watchdog remain. PSCM limit-reached uses the normal controller behavior, including its integral anti-windup, rather than terminating the maneuver.
When “Maneuvers Finished” appears, finish the route and upload all logs. Going offroad or restarting clears the test toggle and resets suite progress.
Generate the report as usual:
```sh
python openpilot/tools/lateral_maneuvers/generate_report.py DEVICE/ROUTE
```
Channel runs use the standard lateral maneuver report, grouped by maneuver, speed and channel. It shows desired versus measured lateral acceleration, actual wheel angle, speed/jerk/roll, and decoded C0/C1 CAN commands. The report checks that the unused field remained zero. Archived raw-pulse routes still use their original report.
## Keyboard-triggered steps (ACC or MADS)
A laptop keyboard over SSH is enough. This mode keeps normal `controlsd` running, including ACC/MADS and the tested Ford formulas. **Do not enable stock Joystick Debug Mode:** that replaces `controlsd` and bypasses this controller. The keyboard tool selects its own transient mode and disables the automatic maneuver modes.
With the car off and openpilot offroad, SSH into the comma and run from `/data/openpilot`:
```sh
python openpilot/tools/joystick/ford_keyboard_control.py --speed 15
```
Leave that terminal connected, start the car, and engage lateral control. You may use **ACC**, or **MADS with manual accelerator input**. Hold the selected speed, 15 or 20 mph, within ±0.7 m/s (about ±1.6 mph). Braking, steering intervention, loss of lateral engagement, invalid data, or leaving the speed window aborts the current run. MADS is engaged normally; this tool does not enable it or override its engagement rules. Use a passenger to operate the keyboard while you drive in the test area.
| Key | Action |
| --- | --- |
| `1` / `2` | Select C0-only / C1-only for the next step |
| `V` | Select 15 / 20 mph for the next step |
| `+` / `-` | Adjust the next target by 0.25 m/s²; default 0.5, range 0.25–3.0 |
| `A` / `D` | Trigger one left / right step when the screen says Ready |
| `R` | Cancel the test and return to normal lateral control |
| `Q` or Ctrl-C | Cancel and exit the keyboard tool |
The strength is a **lateral-acceleration target**, not a percentage of the C0/C1 field range. The upper setting matches the existing normal lateral-acceleration limit; normal curvature/jerk limits and field bounds still apply. For a different starting strength, add `--accel 1.0` to the command. Settings changed during a run apply only to the next run.
Each keypress starts one **0.5 s baseline → 1.0 s step → 1.5 s release** sequence. The reference is the captured starting curvature plus the requested acceleration divided by current speed squared. Release returns the **target** to that baseline; it does not forcibly zero C1's normal P/I correction. The unused channel and C2/C3 remain zero throughout the sequence. Afterward, normal model following resumes with both channels. The terminal displays computed C0/C1, measured wheel angle, and PSCM limit status; the route also records actual transmitted CAN commands.
The tool requires two seconds of stable, straight driving before accepting a trigger. A keypress while unready or busy is discarded, not queued. **It never automatically starts another run or retries an interrupted one.** Losing the keyboard heartbeat for more than 0.2 s aborts the run; the normal controller also retains its independent stale-plan/run-duration checks. `R` and `Q` cancel only the test, not MADS or ACC. Disengage lateral control normally if you want it off. The keyboard mode clears when the car goes offroad or manager restarts.
If SSH disconnects or you exit the keyboard program, reconnect and run the same command. Reconnecting onroad is accepted only when keyboard mode is already selected; it does not enable a new driving mode. An interrupted step stays aborted and needs a fresh trigger.
Use the same report command above after uploading the route. Keyboard runs add a PSCM limit-status plot and markers for the baseline/step/release transitions. These test the real controller with one output selected; C1 feedback and the vehicle response are both part of the measurement.
@@ -5,8 +5,6 @@
Test your vehicle's lateral control tuning with this tool. The tool will test the vehicle's ability to follow a few lateral maneuvers and includes a tool to generate a report from the route.
Ford CAN FD with the model-action controller also has a separate [C0 / C1 comparison](FORD_CHANNEL_TEST.md) at 15 and 20 mph.
## Instructions
1. Check out a development branch such as `master` on your comma device.
@@ -1,133 +0,0 @@
"""Manually triggered curvature steps through the normal Ford channel test path."""
import math
import time
from openpilot.cereal import messaging
from openpilot.common.constants import CV
from openpilot.common.realtime import DT_MDL, Ratekeeper
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.controls.lib.drive_helpers import MAX_LATERAL_ACCEL_NO_ROLL, MIN_SPEED
from openpilot.selfdrive.controls.lib.ford_channel_test import SPEEDS, MAX_SPEED_ERROR
from openpilot.tools.lateral_maneuvers.lateral_maneuversd import MAX_CURV, MAX_ROLL, TIMER
INPUT_TIMEOUT = .2
ACCEL_INCREMENT = .25
MIN_ACCEL, MAX_ACCEL = ACCEL_INCREMENT, MAX_LATERAL_ACCEL_NO_ROLL
BASELINE_S, STEP_S, RELEASE_S = .5, 1., 1.5
class KeyboardManeuver:
def __init__(self):
self.last_request_id = 0
self.ready_since = self.start = self.last_update = None
self.request_id = self.run_id = 0
self.channel, self.speed, self.accel, self.baseline = 'none', SPEEDS[0], 0., 0.
self.phase, self.status = 'waiting', 'Waiting for keyboard'
@property
def active(self):
return self.start is not None
def stop(self, status, phase='aborted'):
self.start = self.ready_since = None
self.phase, self.status = phase, status
def update(self, now, request, *, input_time, input_valid, healthy, ready, speed, curvature):
# Consume every new request, including ones received while busy/unready/stale.
# A rejected keypress must never become a delayed automatic start.
new_request = request.requestId > self.last_request_id
self.last_request_id = max(request.requestId, self.last_request_id)
valid_input = (input_valid and math.isfinite(now) and math.isfinite(input_time)
and -.005 <= now-input_time <= INPUT_TIMEOUT
and request.channel in ('c0', 'c1') and request.action in ('idle', 'left', 'right', 'cancel')
and math.isfinite(request.speed) and any(abs(request.speed-v) < 1e-5 for v in SPEEDS)
and math.isfinite(request.accel) and MIN_ACCEL-1e-6 <= request.accel <= MAX_ACCEL+1e-6)
continuous = self.last_update is None or 0. < now-self.last_update <= .15
self.last_update = now
if not valid_input or not healthy or not continuous or not all(math.isfinite(v) for v in (speed, curvature)):
self.stop('Aborted: input/data lost' if self.active else 'Waiting for keyboard and active lateral control')
return
if new_request and request.action == 'cancel':
self.stop('Aborted: keyboard cancel')
return
if self.active:
if abs(speed-self.speed) > MAX_SPEED_ERROR:
self.stop('Aborted: speed out of range')
else:
elapsed = now-self.start
if elapsed >= BASELINE_S+STEP_S+RELEASE_S:
self.stop('Complete', 'complete')
else:
self.phase = 'baseline' if elapsed < BASELINE_S else ('pulse' if elapsed < BASELINE_S+STEP_S else 'release')
self.status = f'Active {self.channel.upper()} {self.phase}'
return
if not ready or abs(speed-request.speed) > MAX_SPEED_ERROR:
self.ready_since = None
self.status = f'Set {request.speed*CV.MS_TO_MPH:.0f} mph; straight and steady'
else:
if self.ready_since is None:
self.ready_since = now
self.status = 'Ready: A left / D right' if now-self.ready_since >= TIMER else 'Waiting: steady for 2 seconds'
if new_request and request.action in ('left', 'right'):
if self.ready_since is None or now-self.ready_since < TIMER:
self.status = 'Not ready; press A/D again when ready'
return
self.start = now
self.run_id += 1
self.request_id = request.requestId
self.channel, self.speed = str(request.channel), request.speed
# Ford's desired-curvature coordinates are right-positive, like normal lat man.
self.accel = request.accel * (-1 if request.action == 'left' else 1)
self.baseline = curvature
self.phase, self.status = 'baseline', f'Active {self.channel.upper()} baseline'
def plan(self, speed):
msg = messaging.new_message('lateralManeuverPlan')
msg.valid = self.active
plan = msg.lateralManeuverPlan
if self.active:
plan.desiredCurvature = self.baseline + (self.accel if self.phase == 'pulse' else 0.) / max(speed, MIN_SPEED)**2
plan.fordChannelTest = {'runId': self.run_id, 'channel': self.channel, 'speed': self.speed,
'phase': 'maneuver' if self.active else self.phase, 'delta': 0.,
'keyboardRequestId': self.request_id, 'keyboardPhase': self.phase, 'targetAccel': self.accel}
return msg
@property
def description(self):
direction = 'right' if self.accel > 0 else 'left'
return f'step {direction} {abs(self.accel):.2f}m/s² {self.speed*CV.MS_TO_MPH:.0f}mph {self.channel.upper()} keyboard'
def main():
services = ['carState', 'carControl', 'controlsState', 'selfdriveState', 'modelV2', 'carStateSP', 'vehicleParameters']
sm = messaging.SubMaster(services+['testJoystick'], frequency=1./DT_MDL)
pm = messaging.PubMaster(['lateralManeuverPlan', 'alertDebug'])
test = KeyboardManeuver()
rk = Ratekeeper(1./DT_MDL, print_delay_threshold=None)
previous = None
while True:
sm.update(0)
cs, cc, status = sm['carState'], sm['carControl'], sm['carStateSP'].fordPscmStatus
now = time.monotonic()
healthy = (sm.all_checks(services) and cs.canValid and cc.latActive
and not (cs.steerFaultTemporary or cs.steerFaultPermanent or cs.steeringPressed or cs.brakePressed)
and math.isfinite(cs.steeringTorque) and abs(cs.steeringTorque) <= 1.)
curvature, roll = sm['controlsState'].desiredCurvature, sm['vehicleParameters'].roll
ready = (abs(curvature) < MAX_CURV and abs(sm['controlsState'].curvature) < MAX_CURV and abs(roll) < MAX_ROLL
and status.valid and -.005 <= now-status.canMonoTime*1e-9 <= .15 and status.lateralState == 2
and not status.denied and status.limit != 3)
test.update(now, sm['testJoystick'].fordKeyboard, input_time=sm.logMonoTime['testJoystick']*1e-9,
input_valid=sm.valid['testJoystick'], healthy=healthy, ready=ready, speed=cs.vEgo, curvature=curvature)
alert = messaging.new_message('alertDebug')
alert.valid = True
alert.alertDebug.alertText1 = test.status
alert.alertDebug.alertText2 = test.description if test.run_id else 'Ford keyboard C0 / C1 test'
pm.send('alertDebug', alert)
pm.send('lateralManeuverPlan', test.plan(cs.vEgo))
identity = (test.run_id, test.phase, test.status)
if identity != previous:
cloudlog.event('Ford keyboard test', run_id=test.run_id, request_id=test.request_id, phase=test.phase, status=test.status,
channel=test.channel, target_accel=test.accel, speed=cs.vEgo, wheel_angle=cs.steeringAngleDeg,
pscm_limit=status.limit)
previous = identity
rk.keep_time()
@@ -1,17 +0,0 @@
#!/usr/bin/env python3
"""Run the existing lateral maneuver suite through one Ford output at a time."""
from openpilot.tools.lateral_maneuvers.lateral_maneuversd import main as suite_main
from openpilot.common.params import Params
from openpilot.selfdrive.controls.lib.ford_channel_test import KEYBOARD_PARAM
from openpilot.tools.lateral_maneuvers.ford_keyboard import main as keyboard_main
def main():
if Params().get_bool(KEYBOARD_PARAM):
keyboard_main()
else:
suite_main(ford_channels=True)
if __name__ == '__main__':
main()
@@ -1,245 +0,0 @@
"""Report raw Ford channel pulses without treating them as curvature targets."""
import base64
import io
from dataclasses import dataclass, field
from html import escape
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
from opendbc.can import CANParser
from opendbc.car.ford.fordcan import CanBus, calculate_lat_ctl2_checksum
from openpilot.common.constants import CV
from openpilot.selfdrive.controls.lib.ford_channel_test import is_channel_plan
SERVICES = {'lateralManeuverPlan', 'carControl', 'carState', 'carStateSP', 'sendcan'}
@dataclass
class Run:
run_id: int
channel: str
speed: float
messages: list = field(default_factory=list)
outcome: str = 'incomplete'
class ChannelRuns:
def __init__(self):
self.runs = []
self.current = None
def add(self, msg):
kind = msg.which()
if kind == 'lateralManeuverPlan' and is_channel_plan(msg.lateralManeuverPlan):
test = msg.lateralManeuverPlan.fordChannelTest
active = msg.valid and str(test.phase) in ('baseline', 'pulse', 'release')
if active and (self.current is None or self.current.run_id != test.runId):
self.current = Run(test.runId, str(test.channel), test.speed)
self.runs.append(self.current)
if not active and self.current is not None:
self.current.outcome = str(test.phase)
self.current.messages.append(msg)
self.current = None
if self.current is not None and kind in SERVICES:
self.current.messages.append(msg)
def first_sustained(t, mask, duration=.05):
start = None
previous = None
for stamp, passed in zip(t, mask, strict=True):
if not passed or (previous is not None and stamp-previous > .05):
start = None
if passed:
if start is None:
start = stamp
if stamp-start >= duration:
return float(start)
previous = stamp
return None
def analyze(run, CP):
parser = CANParser('ford_lincoln_base_pt', [('LateralMotionControl2', 100)], CanBus(CP).main)
address = parser.dbc.name_to_msg['LateralMotionControl2'].address
plans, wheels, commands = [], [], []
problems = set()
if run.outcome != 'complete':
problems.add(run.outcome)
for msg in sorted(run.messages, key=lambda m: m.logMonoTime):
t, kind = msg.logMonoTime*1e-9, msg.which()
if kind != 'lateralManeuverPlan' and not msg.valid:
problems.add(f'invalid {kind}')
if kind == 'lateralManeuverPlan':
p = msg.lateralManeuverPlan.fordChannelTest
plans.append((t, str(p.phase), p.delta))
elif kind == 'carState':
cs = msg.carState
wheels.append((t, cs.steeringAngleDeg, cs.vEgo*CV.MS_TO_MPH))
if cs.steeringPressed or abs(cs.steeringTorque) > 1. or cs.gasPressed or cs.brakePressed:
problems.add('driver input')
if not cs.canValid or cs.steerFaultTemporary or cs.steerFaultPermanent or not cs.cruiseState.enabled:
problems.add('vehicle fault')
if abs(cs.vEgo-run.speed) > .7:
problems.add('speed out of range')
elif kind == 'carControl' and not msg.carControl.latActive:
problems.add('lateral inactive')
elif kind == 'carStateSP':
p = msg.carStateSP.fordPscmStatus
if not p.valid or not -.005 <= t-p.canMonoTime*1e-9 <= .15 or p.denied or p.limit in (2, 3) or p.lateralState != 2:
problems.add('PSCM invalid, denied or limited')
elif kind == 'sendcan':
for packet in msg.sendcan:
if packet.address == address and packet.src == CanBus(CP).main:
parser.update([msg.logMonoTime, [(packet.address, packet.dat, packet.src)]])
wire = parser.vl['LateralMotionControl2']
commands.append((t, -wire['LatCtlPathOffst_L_Actl'], -wire['LatCtlPath_An_Actl']))
if wire['LatCtl_D2_Rq'] != 2 or wire['LatCtlCurv_No_Actl'] != 0. or wire['LatCtlCrv_NoRate2_Actl'] != 0.:
problems.add('unexpected CAN mode or C2/C3')
if wire['LatCtlPath_No_Cs'] != calculate_lat_ctl2_checksum(int(wire['LatCtl_D2_Rq']), int(wire['LatCtlPath_No_Cnt']), packet.dat):
problems.add('CAN checksum')
seen = {m.which() for m in run.messages}
if missing := SERVICES-seen:
problems.add('missing '+', '.join(sorted(missing)))
pulse = next((t for t, phase, _ in plans if phase == 'pulse'), None)
release = next((t for t, phase, _ in plans if phase == 'release'), None)
if not plans or plans[0][1] != 'baseline' or pulse is None or release is None:
problems.add('missing test phases')
data = {'problems': problems, 'plans': plans, 'wheels': np.array(wheels), 'commands': np.array(commands), 'pulse': pulse, 'release': release,
'onset': None, 'peak': None, 'residual': None, 'wire_on': None, 'wire_off': None, 'threshold': None, 'delta': None}
if len(wheels) < 10 or len(commands) < 10:
problems.add('insufficient wheel or CAN samples')
if pulse is None or release is None or len(wheels) < 10 or len(commands) < 10:
return data
w, c = data['wheels'], data['commands']
if not np.isfinite(w).all() or not np.isfinite(c).all():
problems.add('nonfinite data')
return data
for name, samples in (('wheel', w), ('CAN', c)):
if np.max(np.diff(samples[:, 0])) > .1:
problems.add(f'{name} data gap')
bw, bc = w[(w[:, 0] >= pulse-.2) & (w[:, 0] < pulse)], c[(c[:, 0] >= pulse-.2) & (c[:, 0] < pulse)]
if len(bw) < 3 or len(bc) < 3:
problems.add('insufficient baseline')
return data
base_angle, base_command = np.median(bw[:, 1]), np.median(bc[:, 1:], axis=0)
selected = 1 if run.channel == 'c0' else 2
other = 2 if selected == 1 else 1
held = c[c[:, 0] >= plans[0][0]+.06] # allow the initial plan to reach controlsd and the sender
if not len(held) or np.max(np.abs(held[:, other]-base_command[other-1])) > (1e-5 if other == 2 else 1e-4):
problems.add('other field changed')
delta = next(d for _, p, d in plans if p == 'pulse')
tolerance = .00026 if selected == 2 else .0051
pulse_samples = c[(c[:, 0] >= pulse) & (c[:, 0] < release)]
matching = pulse_samples[np.abs(pulse_samples[:, selected]-base_command[selected-1]-delta) < tolerance]
if not len(matching):
problems.add('pulse absent from CAN')
return data
on = matching[0, 0]
release_samples = c[c[:, 0] >= release]
returned = release_samples[np.abs(release_samples[:, selected]-base_command[selected-1]) < tolerance]
off = returned[0, 0] if len(returned) else None
if off is None:
problems.add('release absent from CAN')
# Ignore the one-cycle boundary uncertainty; verify the rest of each phase.
for start, end, expected in ((pulse+.06, release, base_command[selected-1]+delta),
(release+.06, c[-1, 0]+.001, base_command[selected-1])):
samples = c[(c[:, 0] >= start) & (c[:, 0] < end)]
if not len(samples) or np.max(np.abs(samples[:, selected]-expected)) > tolerance:
problems.add('selected field not held')
response = w[w[:, 0] >= on]
change = response[:, 1]-base_angle
threshold = max(.5, 3.*float(np.ptp(bw[:, 1])))
onset = first_sustained(response[:, 0], np.abs(change) >= threshold)
tail = response[response[:, 0] >= response[-1, 0]-.2]
data.update(onset=None if onset is None else onset-on, peak=float(np.max(np.abs(change))),
residual=float(np.mean(tail[:, 1])-base_angle), wire_on=on, wire_off=off, threshold=threshold, delta=delta,
base_angle=base_angle)
return data
def report(platform, route, CP, ID, runs, output_dir=None):
output_dir = Path(output_dir) if output_dir else Path(__file__).parent/'lateral_reports'
output_dir.mkdir(parents=True, exist_ok=True)
output = output_dir/f'{platform}_{route.replace("/", "_").replace("|", "_")}_ford_channels.html'
html = ['<!doctype html><meta charset="utf-8"><title>Ford C0 / C1 test</title>',
'<style>body{font:17px system-ui;max-width:1050px;margin:40px auto;padding:0 24px;color:#18232e}img{width:100%}.bad{color:#a21c24}</style>',
'<h1>Ford C0 / C1 test</h1>', f'<p>{escape(platform)} · {escape(route)}<br>Commit {escape(ID.gitCommit)}</p>',
'<p>Each pulse holds the other field constant. All plots use left-positive coordinates. '
+ 'C0 and C1 pulse sizes are not assumed to produce equal steering. These measurements describe these trials; '
+ 'they do not establish a universal PSCM delay or predict intersection turns.</p>',
'<p>Onset means wheel movement from baseline exceeding max(0.5°, 3 × baseline range) for 50 ms, '
+ 'timed from the first changed CAN command. Peak covers the pulse and release. Residual is the last 200 ms '
+ 'of wheel angle minus baseline. A larger response can cross the onset threshold earlier without having less delay.</p>']
for index, run in enumerate(runs, 1):
d = analyze(run, CP)
direction = ('left' if d['delta'] > 0 else 'right') if d['delta'] is not None else 'unknown direction'
title = f'{index}. {run.channel.upper()} {direction} · {run.speed*CV.MS_TO_MPH:.0f} mph'
html.append(f'<h2>{escape(title)}</h2>')
if d['problems']:
html.append(f'<p class="bad">Exclude from comparison: {escape(", ".join(sorted(d["problems"])))}</p>')
else:
html.append('<p>Complete; command isolation and logged validity checks passed.</p>')
if d['peak'] is not None:
onset = f'{d["onset"]:.3f} s' if d['onset'] is not None else 'not detected'
html.append(f'<p>Onset: <b>{onset}</b> (threshold {d["threshold"]:.2f}°) · '
+ f'Peak movement: <b>{d["peak"]:.2f}°</b> · End residual: <b>{d["residual"]:+.2f}°</b></p>')
if not len(d['wheels']) or not len(d['commands']):
html.append('<p>Insufficient wheel or CAN data to plot.</p>')
continue
origin = d['wire_on'] if d['wire_on'] is not None else d['commands'][0, 0]
fig, axes = plt.subplots(4, 1, figsize=(11, 9), sharex=True, layout='constrained')
c, w = d['commands'], d['wheels']
axes[0].step(c[:, 0]-origin, c[:, 1], where='post', label='C0 sent', color='#007ea7')
axes[1].step(c[:, 0]-origin, c[:, 2], where='post', label='C1 sent', color='#ba5d07')
axes[2].plot(w[:, 0]-origin, w[:, 1]-d.get('base_angle', w[0, 1]), label='Wheel movement', color='#7d3fa2')
axes[3].plot(w[:, 0]-origin, w[:, 2], label='Speed', color='#007453')
for ax, label in zip(axes, ('C0 (m)', 'C1 (rad)', 'Wheel Δ (°)', 'Speed (mph)'), strict=True):
ax.set_ylabel(label)
ax.grid(alpha=.2)
ax.legend(loc='upper right')
if d['wire_on'] is not None:
ax.axvline(0., color='#444', linestyle='--')
if d['wire_off'] is not None:
ax.axvline(d['wire_off']-origin, color='#444', linestyle=':')
axes[-1].set_xlabel('Seconds from CAN pulse start · dotted line = CAN return to baseline')
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=120)
plt.close(fig)
html.append(f'<img alt="{escape(title)} command and wheel response" src="data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}">')
output.write_text(''.join(html))
return output
def channel_commands(msgs, CP, channel, start_time):
"""Decode the actual output for normal maneuver reports, in left-positive units."""
bus = CanBus(CP).main
parser = CANParser('ford_lincoln_base_pt', [('LateralMotionControl2', 100)], bus)
address = parser.dbc.name_to_msg['LateralMotionControl2'].address
samples, problems = [], set()
for msg in msgs:
if msg.which() != 'sendcan':
continue
for packet in msg.sendcan:
if packet.address != address or packet.src != bus:
continue
parser.update([msg.logMonoTime, [(packet.address, packet.dat, packet.src)]])
wire = parser.vl['LateralMotionControl2']
t = (msg.logMonoTime-start_time)*1e-9
samples.append((t, -wire['LatCtlPathOffst_L_Actl'], -wire['LatCtlPath_An_Actl']))
if t >= .06: # initial plan delivery to the controller/sender
if not msg.valid or wire['LatCtl_D2_Rq'] != 2:
problems.add('inactive/invalid CAN output')
other = wire['LatCtlPath_An_Actl'] if channel == 'c0' else wire['LatCtlPathOffst_L_Actl']
if abs(other) > 1e-7 or wire['LatCtlCurv_No_Actl'] != 0. or wire['LatCtlCrv_NoRate2_Actl'] != 0.:
problems.add('channel isolation failed')
if wire['LatCtlPath_No_Cs'] != calculate_lat_ctl2_checksum(int(wire['LatCtl_D2_Rq']), int(wire['LatCtlPath_No_Cnt']), packet.dat):
problems.add('CAN checksum')
data = np.asarray(samples).reshape(-1, 3)
if len(data) < 2:
problems.add('missing CAN output')
elif np.max(np.diff(data[:, 0])) > .1:
problems.add('CAN data gap')
return data, problems
@@ -14,12 +14,10 @@ from openpilot.common.utils import tabulate
from opendbc.car.structs import car
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.selfdrive.controls.lib.latcontrol_torque import LP_FILTER_CUTOFF_HZ
from openpilot.selfdrive.controls.lib.ford_channel_test import is_channel_maneuver
from openpilot.tools.lib.logreader import LogReader
from openpilot.common.hardware.hw import Paths
from openpilot.common.constants import CV
from openpilot.tools.longitudinal_maneuvers.generate_report import format_car_params
from openpilot.tools.lateral_maneuvers.ford_report import ChannelRuns, channel_commands, report as ford_channel_report
def lat_accel(curvature, v):
@@ -60,47 +58,33 @@ def report(platform, route, _description, CP, ID, maneuvers):
t_controlsState, controlsState = zip(*[(m.logMonoTime, m.controlsState) for m in msgs if m.which() == 'controlsState'], strict=True)
t_lateralPlan, lateralPlan = zip(*[(m.logMonoTime, m.lateralManeuverPlan) for m in msgs if m.which() == 'lateralManeuverPlan' and m.valid], strict=True)
t_carOutput, carOutput = zip(*[(m.logMonoTime, m.carOutput) for m in msgs if m.which() == 'carOutput'], strict=True)
channel = str(lateralPlan[0].fordChannelTest.channel) if is_channel_maneuver(lateralPlan[0]) else None
keyboard = bool(channel and lateralPlan[0].fordChannelTest.keyboardRequestId)
origin = t_lateralPlan[0]
commands, command_problems = channel_commands(msgs, CP, channel, origin) if channel else (None, set())
# make time relative seconds
t_carControl = [(t - (origin if channel else t_carControl[0])) / 1e9 for t in t_carControl]
t_carState = [(t - (origin if channel else t_carState[0])) / 1e9 for t in t_carState]
t_controlsState = [(t - (origin if channel else t_controlsState[0])) / 1e9 for t in t_controlsState]
t_carControl = [(t - t_carControl[0]) / 1e9 for t in t_carControl]
t_carState = [(t - t_carState[0]) / 1e9 for t in t_carState]
t_controlsState = [(t - t_controlsState[0]) / 1e9 for t in t_controlsState]
t_lateralPlan = [(t - t_lateralPlan[0]) / 1e9 for t in t_lateralPlan]
t_carOutput = [(t - (origin if channel else t_carOutput[0])) / 1e9 for t in t_carOutput]
t_carOutput = [(t - t_carOutput[0]) / 1e9 for t in t_carOutput]
# maneuver validity
latActive = [m.latActive for m in carControl]
maneuver_valid = all(latActive) and not any(cs.steeringPressed for cs in carState) and not command_problems
maneuver_valid = all(latActive) and not any(cs.steeringPressed for cs in carState)
_open = 'open' if maneuver_valid else ''
title = f'Run #{int(run)+1}' + (' <span style="color: red">(invalid maneuver!)</span>' if not maneuver_valid else '')
builder.append(f"<details {_open}><summary><h3 style='display: inline-block;'>{title}</h3></summary>\n")
if channel:
builder.append(f'<p>Normal maneuver target through {channel.upper()} only; normal controller feedback remains active.</p>')
if keyboard:
builder.append('<p>Keyboard-triggered step: baseline, one-second target, then return to the baseline target. '
+ 'C1 feedback can remain nonzero during release. Accelerator input is allowed with MADS. '
+ 'The PSCM limit plot records limitReached=2; this does not by itself abort the step.</p>')
if command_problems:
builder.append(f'<p>CAN validation: {", ".join(sorted(command_problems))}</p>')
baseline_accel = lat_accel(controlsState[0].curvature, carState[0].vEgo)
v_ego = [m.vEgo for m in carState]
v_plan = np.interp(t_lateralPlan, t_carState, v_ego) if channel else v_ego
v_controls = np.interp(t_controlsState, t_carState, v_ego) if channel else v_ego
cross_markers = []
if description.startswith(('sine', 'jitter')):
amplitude = max(abs(lat_accel(lp.desiredCurvature, v) - baseline_accel)
for lp, v in zip(lateralPlan, v_plan, strict=False))
for lp, v in zip(lateralPlan, v_ego, strict=False))
threshold = amplitude * 0.5
builder.append('<h3 style="font-weight: normal">50% peak')
for t, cs, v in zip(t_controlsState, controlsState, v_controls, strict=False):
for t, cs, v in zip(t_controlsState, controlsState, v_ego, strict=False):
actual = lat_accel(cs.curvature, v) - baseline_accel
if abs(actual) > threshold:
builder.append(f', <strong>crossed in {t:.3f}s</strong>')
@@ -114,10 +98,10 @@ def report(platform, route, _description, CP, ID, maneuvers):
if maneuver_valid:
target_cross_times.setdefault(description, [])
else:
action_targets = [(0, lat_accel(lateralPlan[0].desiredCurvature, v_plan[0]) - baseline_accel)]
for i in range(1, min(len(lateralPlan), len(v_plan))):
action_targets = [(0, lat_accel(lateralPlan[0].desiredCurvature, v_ego[0]) - baseline_accel)]
for i in range(1, min(len(lateralPlan), len(v_ego))):
if abs(lateralPlan[i].desiredCurvature - lateralPlan[i - 1].desiredCurvature) > 0.001:
desired = lat_accel(lateralPlan[i].desiredCurvature, v_plan[i]) - baseline_accel
desired = lat_accel(lateralPlan[i].desiredCurvature, v_ego[i]) - baseline_accel
action_targets.append((i, desired))
for j, (start_i, act_target) in enumerate(action_targets):
@@ -126,7 +110,7 @@ def report(platform, route, _description, CP, ID, maneuvers):
builder.append(f'<h3 style="font-weight: normal">aTarget: {round(act_target, 1)} m/s^2')
prev_crossed = False
for t, cs, v in zip(t_controlsState, controlsState, v_controls, strict=False):
for t, cs, v in zip(t_controlsState, controlsState, v_ego, strict=False):
if not (start_time <= t <= end_time):
continue
actual_accel = lat_accel(cs.curvature, v) - baseline_accel
@@ -146,20 +130,19 @@ def report(platform, route, _description, CP, ID, maneuvers):
target_cross_times.setdefault(description, [])
plt.rcParams['font.size'] = 40
fig = plt.figure(figsize=(30, 55 if keyboard else (50 if channel else 40)))
ratios = [5, 5, 3, 3, 3] + ([3, 3] if channel else []) + ([2] if keyboard else [])
ax = fig.subplots(len(ratios), 1, sharex=True, gridspec_kw={'height_ratios': ratios})
fig = plt.figure(figsize=(30, 40))
ax = fig.subplots(5, 1, sharex=True, gridspec_kw={'height_ratios': [5, 5, 3, 3, 3]})
ax[0].grid(linewidth=4)
desired_label = 'lateralManeuverPlan.desiredCurvature * vEgo^2'
desired_lat_accel = [lat_accel(m.desiredCurvature, v) for m, v in zip(lateralPlan, v_plan, strict=False)]
desired_lat_accel = [lat_accel(m.desiredCurvature, v) for m, v in zip(lateralPlan, v_ego, strict=False)]
if description.startswith(('sine', 'jitter')):
ax[0].plot(t_lateralPlan[:len(desired_lat_accel)], desired_lat_accel, 'C1', label=desired_label, linewidth=6)
else:
t_desired = [t_lateralPlan[0]] + t_lateralPlan[:len(desired_lat_accel)]
desired_lat_accel = [baseline_accel] + desired_lat_accel
ax[0].step(t_desired, desired_lat_accel, 'C1', label=desired_label, linewidth=6, where='post')
actual_lat_accel = [lat_accel(cs.curvature, v) for cs, v in zip(controlsState, v_controls, strict=False)]
actual_lat_accel = [lat_accel(cs.curvature, v) for cs, v in zip(controlsState, v_ego, strict=False)]
ax[0].plot(t_controlsState[:len(actual_lat_accel)], actual_lat_accel, 'g', label='controlsState.curvature * vEgo^2', linewidth=6)
ax[0].set_ylabel('Lateral Accel (m/s^2)')
for ct, cv in cross_markers:
@@ -177,35 +160,6 @@ def report(platform, route, _description, CP, ID, maneuvers):
ax[1].plot(t_carOutput, [getattr(m.actuatorsOutput, steer_field) for m in carOutput], 'g', label=f'carOutput.actuatorsOutput.{steer_field}', linewidth=6)
ax[1].set_ylabel(steer_ylabel)
ax[1].legend(prop={'size': 30})
if channel:
ax[1].clear()
ax[1].grid(linewidth=4)
ax[1].plot(t_carState, [cs.steeringAngleDeg for cs in carState], 'g', label='Actual wheel angle', linewidth=6)
ax[1].set_ylabel('Wheel angle (deg)')
ax[1].legend(prop={'size': 30})
for idx, label in ((1, 'C0 sent (m)'), (2, 'C1 sent (rad)')):
ax[4+idx].step(commands[:, 0], commands[:, idx], where='post', linewidth=6, label=label)
ax[4+idx].set_ylabel(label)
ax[4+idx].grid(linewidth=4)
ax[4+idx].legend(prop={'size': 30})
if keyboard:
pscm = [(float((m.logMonoTime-origin)*1e-9), m.carStateSP.fordPscmStatus.limit)
for m in msgs if m.which() == 'carStateSP' and m.valid and m.carStateSP.fordPscmStatus.valid]
if pscm:
times, limits = zip(*pscm, strict=True)
ax[7].step(times, limits, where='post', linewidth=6, label='PSCM limit status')
else:
ax[7].text(.05, .5, 'No valid PSCM status logged', transform=ax[7].transAxes)
ax[7].set_yticks([0, 1, 2, 3])
ax[7].set_ylabel('PSCM limit\n2 = reached')
ax[7].grid(linewidth=4)
previous_phase = None
for t, plan in zip(t_lateralPlan, lateralPlan, strict=True):
phase = str(plan.fordChannelTest.keyboardPhase)
if phase != previous_phase:
for axis in ax:
axis.axvline(t, color='#777777', linestyle='--', linewidth=2)
previous_phase = phase
ax[2].grid(linewidth=4)
ax[2].plot(t_carState, [v * CV.MS_TO_MPH for v in v_ego], label='carState.vEgo', linewidth=6)
@@ -279,10 +233,8 @@ if __name__ == '__main__':
maneuvers: list[tuple[str, list[list]]] = []
active_prev = False
description_prev = None
channel_runs = ChannelRuns()
for msg in lr:
channel_runs.add(msg)
if msg.which() == 'alertDebug':
active = 'Active' in msg.alertDebug.alertText1 or msg.alertDebug.alertText1 == 'Complete'
if active and not active_prev:
@@ -296,9 +248,4 @@ if __name__ == '__main__':
if active_prev:
maneuvers[-1][1][-1].append(msg)
if channel_runs.runs:
output = ford_channel_report(platform, args.route, CP, ID, channel_runs.runs)
print(f'Opening Ford channel report: {output}')
webbrowser.open_new_tab(str(output))
else:
report(platform, args.route, args.description, CP, ID, maneuvers)
report(platform, args.route, args.description, CP, ID, maneuvers)
@@ -9,7 +9,6 @@ from openpilot.common.realtime import DT_MDL
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.controls.lib.drive_helpers import MIN_SPEED
from openpilot.selfdrive.controls.lib.ford_channel_test import SPEEDS
from openpilot.tools.longitudinal_maneuvers.maneuversd import Action, Maneuver as _Maneuver
# thresholds for starting maneuvers
@@ -21,7 +20,6 @@ TIMER = 2.0 # sec stable conditions before starting maneuver
@dataclass
class Maneuver(_Maneuver):
_baseline_curvature: float = 0.0
channel: str = 'none'
def get_accel(self, v_ego: float, lat_active: bool, curvature: float, roll: float) -> float:
self._run_completed = False
@@ -102,34 +100,21 @@ MANEUVERS = [
]
def channel_maneuvers():
# Reuse the normal action arrays, timing, repeat count and readiness logic.
templates = [m for m in MANEUVERS if m.initial_speed == MANEUVERS[0].initial_speed]
return [Maneuver(f'{m.description.rsplit(" ", 1)[0]} {speed*CV.MS_TO_MPH:.0f}mph {channel.upper()}',
m.actions, repeat=m.repeat, initial_speed=speed, channel=channel)
for speed in SPEEDS for channel in ('c0', 'c1') for m in templates]
def main(ford_channels=False):
def main():
params = Params()
cloudlog.info("lateral_maneuversd is waiting for CarParams")
messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams)
services = ['carState', 'carControl', 'controlsState', 'selfdriveState', 'modelV2']
if ford_channels:
services += ['carStateSP', 'vehicleParameters']
sm = messaging.SubMaster(services, poll='modelV2')
sm = messaging.SubMaster(['carState', 'carControl', 'controlsState', 'selfdriveState', 'modelV2'], poll='modelV2')
pm = messaging.PubMaster(['lateralManeuverPlan', 'alertDebug'])
maneuvers = iter(channel_maneuvers() if ford_channels else MANEUVERS)
maneuvers = iter(MANEUVERS)
maneuver = None
complete_cnt = 0
aborted_cnt = 0
abort_reason = ''
display_holdoff = 0
prev_text = ''
run_id = 0
was_active = False
while True:
sm.update()
@@ -153,19 +138,16 @@ def main(ford_channels=False):
elif maneuver is not None:
# any driver input aborts the maneuver
CS = sm['carState']
invalid = ford_channels and (not sm.all_checks() or not CS.canValid or not CS.cruiseState.enabled
or not sm['carControl'].latActive or CS.steerFaultTemporary or CS.steerFaultPermanent)
if CS.steeringPressed or CS.gasPressed or (ford_channels and CS.brakePressed) or invalid:
if CS.steeringPressed or CS.gasPressed:
aborted_cnt = int(1.0 / DT_MDL)
abort_reason = ('Waiting: engage ACC/lateral; valid data' if invalid else
('steering pressed' if CS.steeringPressed else ('brake pressed' if CS.brakePressed else 'gas pressed'))).ljust(20)
abort_reason = ('steering pressed' if CS.steeringPressed else 'gas pressed').ljust(20)
aborted = aborted_cnt > 0
speed_out_of_range = maneuver.active and abs(v_ego - maneuver.initial_speed) > MAX_SPEED_DEV
if aborted or speed_out_of_range:
maneuver.reset()
roll = sm['carControl'].orientationNED[0] if len(sm['carControl'].orientationNED) == 3 else 0.0
accel = maneuver.get_accel(v_ego, sm['carControl'].latActive and not (ford_channels and (invalid or aborted)), curvature, roll)
accel = maneuver.get_accel(v_ego, sm['carControl'].latActive, curvature, roll)
if maneuver._run_completed:
complete_cnt = int(1.0 / DT_MDL)
@@ -209,18 +191,9 @@ def main(ford_channels=False):
pm.send('alertDebug', alert_msg)
plan_send.valid = maneuver is not None and maneuver.active and complete_cnt == 0 and (not ford_channels or not maneuver.finished)
plan_send.valid = maneuver is not None and maneuver.active and complete_cnt == 0
if plan_send.valid:
plan_send.lateralManeuverPlan.desiredCurvature = maneuver._baseline_curvature + accel / max(v_ego, MIN_SPEED) ** 2
if ford_channels:
if plan_send.valid and not was_active:
run_id += 1
test = plan_send.lateralManeuverPlan.fordChannelTest
test.runId = run_id
test.channel = maneuver.channel if maneuver is not None else 'none'
test.speed = maneuver.initial_speed if maneuver is not None else 0.
test.phase = 'maneuver' if plan_send.valid else ('complete' if complete_cnt > 0 else 'waiting')
was_active = plan_send.valid
pm.send('lateralManeuverPlan', plan_send)
if maneuver is not None and maneuver.finished and complete_cnt == 0:
@@ -1,237 +0,0 @@
"""Offline keyboard, maneuver, and real controller/CAN checks; no hardware actuation."""
import math
from types import SimpleNamespace
import pytest
from opendbc.car.structs import car
from openpilot.cereal import log, messaging
from openpilot.common.params import Params, ParamKeyFlag
from openpilot.selfdrive.controls.lib.ford_channel_test import KEYBOARD_PARAM, PARAM, SPEEDS, FordChannelTest
from openpilot.selfdrive.controls.tests.test_ford_model_action_selection import startup
from openpilot.tools.joystick.ford_keyboard_control import KeyboardControl, enable
from openpilot.tools.lateral_maneuvers import ford_keyboard as daemon
from openpilot.tools.lateral_maneuvers.ford_keyboard import KeyboardManeuver, BASELINE_S, STEP_S, RELEASE_S
def request(**kw):
return log.Joystick.FordKeyboard.new_message(**({'requestId': 0, 'channel': 'c0', 'speed': SPEEDS[0],
'accel': .5, 'action': 'idle'} | kw))
def step(test, t, req, **kw):
test.update(t, req, **({'input_time': t, 'input_valid': True, 'healthy': True, 'ready': True,
'speed': req.speed, 'curvature': .001} | kw))
return test.plan(kw.get('speed', req.speed))
def ready(test, req, start=10.):
for i in range(42):
step(test, start+i*.05, req)
assert test.status == 'Ready: A left / D right'
return start+2.10
@pytest.mark.parametrize('channel', ['c0', 'c1'])
@pytest.mark.parametrize('speed', SPEEDS)
@pytest.mark.parametrize('direction,sign', [('left', -1), ('right', 1)])
@pytest.mark.parametrize('amplitude', [.25, .5, 3.])
def test_timed_step_uses_normal_curvature_then_returns_to_baseline(channel, speed, direction, sign, amplitude):
test = KeyboardManeuver()
req = request(channel=channel, speed=speed, accel=amplitude)
start = ready(test, req)
req.requestId, req.action = 1, direction
plans = [step(test, start+i*.05, req) for i in range(61)]
assert plans[0].valid and not plans[-1].valid
assert plans[-1].lateralManeuverPlan.fordChannelTest.phase == 'complete'
phases = [str(p.lateralManeuverPlan.fordChannelTest.keyboardPhase) for p in plans[:-1]]
assert {'baseline', 'pulse', 'release'} == set(phases)
assert sum(x == 'pulse' for x in phases)*.05 == pytest.approx(STEP_S, abs=.05)
for p in plans[:-1]:
plan = p.lateralManeuverPlan
expected = .001+(sign*amplitude/speed**2 if plan.fordChannelTest.keyboardPhase == 'pulse' else 0.)
assert plan.desiredCurvature == pytest.approx(expected)
assert plan.fordChannelTest.phase == 'maneuver' and plan.fordChannelTest.delta == 0.
assert plan.fordChannelTest.keyboardRequestId == 1
assert plan.fordChannelTest.targetAccel == pytest.approx(sign*amplitude)
for i in range(1, 81):
step(test, start+3.+i*.05, req)
assert not test.active # holding the last trigger never repeats a step
@pytest.mark.parametrize('fault', [
{'input_valid': False}, {'input_time': 1.}, {'input_time': 100.}, {'healthy': False},
{'speed': SPEEDS[0]+.71}, {'speed': math.nan}, {'curvature': math.nan},
])
def test_abort_never_queues_or_retries_old_request(fault):
test, req = KeyboardManeuver(), request()
start = ready(test, req)
req.requestId, req.action = 1, 'left'
assert step(test, start, req).valid
assert not step(test, start+.05, req, **fault).valid
for i in range(1, 61):
assert not step(test, start+.05+i*.05, req).valid
req.requestId = 2
assert step(test, start+3.10, req).valid
def test_unready_and_busy_keypresses_are_consumed_not_queued():
test, req = KeyboardManeuver(), request(requestId=1, action='right')
start = ready(test, req)
assert not test.active
req.requestId = 2
assert step(test, start, req).valid
req.requestId, req.action, req.channel, req.accel = 3, 'left', 'c1', 3.
for i in range(1, 121):
p = step(test, start+i*.05, req)
if p.valid:
assert p.lateralManeuverPlan.fordChannelTest.channel == 'c0'
assert p.lateralManeuverPlan.fordChannelTest.targetAccel == .5
assert test.run_id == 1 and not test.active
@pytest.mark.parametrize('changes', [{'channel': 'none'}, {'accel': math.nan}, {'accel': 3.25}, {'accel': 0.}, {'speed': 0.}])
def test_bad_input_cannot_trigger(changes):
test, req = KeyboardManeuver(), request()
start = ready(test, req)
req.requestId, req.action = 1, 'right'
for k, v in changes.items():
setattr(req, k, v)
assert not step(test, start, req).valid
def test_frozen_time_clock_gap_and_cancel_abort():
for delta in [0., -.1, .151]:
test, req = KeyboardManeuver(), request()
start = ready(test, req)
req.requestId, req.action = 1, 'right'
step(test, start, req)
assert not step(test, start+delta, req).valid
test, req = KeyboardManeuver(), request()
start = ready(test, req)
req.requestId, req.action = 1, 'right'
step(test, start, req)
req.requestId, req.action = 2, 'cancel'
assert not step(test, start+.05, req).valid
assert test.phase == 'aborted'
def test_keyboard_mapping_heartbeat_repeat_suppression_and_cancel():
control = KeyboardControl()
for key in ('2', '+', 'v'):
assert control.key(key, 10.)
control.key('a', 11.)
p = control.message().testJoystick
assert list(p.axes) == [0., 0.]
assert p.fordKeyboard.channel == 'c1' and p.fordKeyboard.action == 'left'
assert p.fordKeyboard.accel == .75 and p.fordKeyboard.speed == pytest.approx(SPEEDS[1])
first = p.fordKeyboard.requestId
for i in range(1, 101):
control.key('a', 11.+i*.05)
assert control.message().testJoystick.fordKeyboard.requestId == first
control.key('a', 17.)
assert control.request.requestId > first
control.key('r', 18.)
assert control.request.action == 'cancel'
assert not control.key('q', 19.)
@pytest.mark.parametrize('kwargs', [{'speed': 10}, {'accel': math.nan}, {'accel': math.inf}, {'accel': 0.}, {'accel': 3.1}])
def test_cli_rejects_invalid_settings(kwargs):
with pytest.raises(ValueError):
KeyboardControl(**kwargs)
def test_offroad_setup_and_mode_lifecycle_use_real_params(tmp_path):
params = Params(str(tmp_path))
cp = car.CarParams.new_message(brand='ford', flags=1)
params.put('CarParamsPersistent', cp.to_bytes(), block=True)
params.put_bool('FordModelActionController', True, block=True)
with pytest.raises(ValueError, match='offroad'):
enable(params)
params.put_bool('IsOffroad', True, block=True)
params.put_bool('JoystickDebugMode', True, block=True)
params.put_bool(PARAM, True, block=True)
enable(params)
assert params.get_bool(KEYBOARD_PARAM) and not params.get_bool(PARAM) and not params.get_bool('JoystickDebugMode')
assert startup(params=params).ford_channel_test.keyboard
params.put_bool('IsOffroad', False, block=True)
enable(params) # reconnect to the selected mode, without an ignition cycle
params.put_bool(PARAM, True, block=True)
with pytest.raises(ValueError, match='offroad'):
enable(params)
assert params.get_bool(PARAM) # a refused onroad mode change has no side effects
params.put_bool(PARAM, False, block=True)
for flag in (ParamKeyFlag.CLEAR_ON_MANAGER_START, ParamKeyFlag.CLEAR_ON_OFFROAD_TRANSITION):
params.put_bool(KEYBOARD_PARAM, True, block=True)
params.clear_all(flag)
assert not params.get_bool(KEYBOARD_PARAM)
for brand, flags in [('toyota', 1), ('ford', 0)]:
params.put_bool('IsOffroad', True, block=True)
params.put('CarParamsPersistent', car.CarParams.new_message(brand=brand, flags=flags).to_bytes(), block=True)
with pytest.raises(ValueError, match='CAN FD Ford'):
enable(params)
def test_keyboard_plan_requires_keyboard_mode():
test, req = KeyboardManeuver(), request()
start = ready(test, req)
req.requestId, req.action = 1, 'right'
p = step(test, start, req).lateralManeuverPlan
from openpilot.selfdrive.controls.tests.test_ford_channel_test import update
assert not update(FordChannelTest(), msg=p).valid
assert update(FordChannelTest(keyboard=True), msg=p).valid
@pytest.mark.parametrize('keyboard', [False, True])
def test_manager_imported_entrypoint_selects_the_correct_daemon(monkeypatch, keyboard):
# Manager imports the module and calls main(); it does not execute __main__.
from openpilot.tools.lateral_maneuvers import ford_maneuversd
calls = []
monkeypatch.setattr(ford_maneuversd, 'Params', lambda: SimpleNamespace(get_bool=lambda _key: keyboard))
monkeypatch.setattr(ford_maneuversd, 'keyboard_main', lambda: calls.append('keyboard'))
monkeypatch.setattr(ford_maneuversd, 'suite_main', lambda **kw: calls.append(kw))
ford_maneuversd.main()
assert calls == (['keyboard'] if keyboard else [{'ford_channels': True}])
def test_real_daemon_allows_mads_manual_throttle_and_publishes_complete_run(monkeypatch):
class Finished(Exception):
pass
clock, plans = [10.], []
class SM(messaging.SubMaster):
def __init__(self, services, **kwargs):
super().__init__(services, **kwargs)
self.events = {s: messaging.new_message(s) for s in services}
def update(self, _timeout):
clock[0] += .05
assert clock[0] < 20
cs = self.events['carState'].carState
cs.vEgo, cs.canValid, cs.gasPressed, cs.cruiseState.enabled = SPEEDS[0], True, True, False
self.events['carControl'].carControl.latActive = True # MADS, selfdriveState.enabled remains false
self.events['carStateSP'].carStateSP.fordPscmStatus = {'valid': True, 'canMonoTime': round(clock[0]*1e9), 'lateralState': 2}
self.events['testJoystick'].testJoystick.fordKeyboard = request(requestId=1 if clock[0] > 12.5 else 0, action='right')
for msg in self.events.values():
msg.valid, msg.logMonoTime = True, round(clock[0]*1e9)
self.update_msgs(clock[0], [m.as_reader() for m in self.events.values()])
class PM:
def __init__(self, _services):
pass
def send(self, service, msg):
if service == 'lateralManeuverPlan':
plans.append(msg)
if msg.lateralManeuverPlan.fordChannelTest.phase == 'complete':
raise Finished
monkeypatch.setattr(daemon.messaging, 'SubMaster', SM)
monkeypatch.setattr(daemon.messaging, 'PubMaster', PM)
monkeypatch.setattr(daemon, 'time', SimpleNamespace(monotonic=lambda: clock[0]))
monkeypatch.setattr(daemon, 'Ratekeeper', lambda *_a, **_kw: SimpleNamespace(keep_time=lambda: None))
with pytest.raises(Finished):
daemon.main()
active = [p for p in plans if p.valid]
assert len(active)*.05 == pytest.approx(BASELINE_S+STEP_S+RELEASE_S, abs=.05)
assert {str(p.lateralManeuverPlan.fordChannelTest.keyboardPhase) for p in active} == {'baseline', 'pulse', 'release'}
@@ -1,201 +0,0 @@
"""Synthetic log tests validate report math, not the truck's physical response."""
from types import SimpleNamespace
import pytest
from opendbc.can import CANPacker
from opendbc.car import structs
from opendbc.car.ford.fordcan import CanBus, create_lat_ctl2_msg
from openpilot.cereal import messaging
from openpilot.selfdrive.controls.lib.ford_channel_test import SPEEDS
from openpilot.tools.lateral_maneuvers.ford_report import ChannelRuns, analyze, first_sustained, report
# Archived raw-pulse routes remain readable after replacing the diagnostic.
AMPLITUDE = {'c0': 1.28, 'c1': .125}
def event(kind, t):
m = messaging.new_message(kind, size=1 if kind == 'sendcan' else None)
m.logMonoTime, m.valid = round(t*1e9), True
return m
def fixture(channel='c0', direction=1, other_changes=False, response=True, abort=False, pulse_frames=100, amplitude=None):
collector = ChannelRuns()
packer = CANPacker('ford_lincoln_base_pt')
cp = structs.CarParams(safetyConfigs=[structs.CarParams.SafetyConfig()])
bus = CanBus(cp)
amplitude = AMPLITUDE[channel] if amplitude is None else amplitude
release_frame, complete_frame = 50+pulse_frames, 250+pulse_frames
for i in range(complete_frame+1):
t = 10.+i*.01
if i % 5 == 0:
p = event('lateralManeuverPlan', t)
phase = 'baseline' if i < 50 else ('pulse' if i < release_frame else 'release')
if i == complete_frame:
phase, p.valid = ('aborted' if abort else 'complete'), False
p.lateralManeuverPlan.fordChannelTest = {'runId': 1, 'channel': channel, 'phase': phase,
'delta': direction*amplitude if phase == 'pulse' else 0., 'speed': SPEEDS[0]}
collector.add(p)
delta = direction*amplitude if 50 <= i < release_frame else 0.
c0, c1 = (.01+delta, .002) if channel == 'c0' else (.01, .002+delta)
if other_changes and 70 <= i < 90:
if channel == 'c0':
c1 += .001
else:
c0 += .01
address, data, src = create_lat_ctl2_msg(packer, bus, 2, -c0, -c1, 0., 0., i % 16)
m = event('sendcan', t)
m.sendcan[0].address, m.sendcan[0].dat, m.sendcan[0].src = address, data, src
collector.add(m)
m = event('carState', t)
m.carState.canValid, m.carState.vEgo = True, SPEEDS[0]
m.carState.cruiseState.enabled = True
m.carState.steeringAngleDeg = (direction*2. if response and 70 <= i < release_frame+50 else 0.)
collector.add(m)
m = event('carControl', t)
m.carControl.latActive = True
collector.add(m)
m = event('carStateSP', t)
m.carStateSP.fordPscmStatus = {'valid': True, 'canMonoTime': round(t*1e9), 'lateralState': 2}
collector.add(m)
assert len(collector.runs) == 1
return cp, collector.runs[0]
@pytest.mark.parametrize('channel', ['c0', 'c1'])
@pytest.mark.parametrize('direction', [-1, 1])
@pytest.mark.parametrize('legacy', [False, True])
def test_command_aligned_metrics(channel, direction, legacy):
amplitude = {'c0': .03, 'c1': .01}[channel] if legacy else AMPLITUDE[channel]
pulse_frames = 50 if legacy else 100
cp, run = fixture(channel, direction, pulse_frames=pulse_frames, amplitude=amplitude)
d = analyze(run, cp)
assert not d['problems']
assert d['onset'] == pytest.approx(.2)
assert d['wire_on'] == pytest.approx(10.5)
assert d['wire_off'] == pytest.approx(10.5+pulse_frames*.01)
assert d['peak'] == pytest.approx(2.)
assert d['residual'] == pytest.approx(0.)
assert d['delta'] == pytest.approx(direction*amplitude)
@pytest.mark.parametrize('channel', ['c0', 'c1'])
def test_contaminated_field_excluded(channel):
cp, run = fixture(channel, other_changes=True)
assert 'other field changed' in analyze(run, cp)['problems']
def test_no_movement_is_not_reported_as_zero_delay():
cp, run = fixture(response=False)
d = analyze(run, cp)
assert d['onset'] is None and d['peak'] == 0.
assert not d['problems']
def test_abort_remains_visible_in_report(tmp_path):
cp, run = fixture(abort=True)
assert 'aborted' in analyze(run, cp)['problems']
output = report('FORD', 'device/route', cp, SimpleNamespace(gitCommit='synthetic'), [run], tmp_path)
html = output.read_text()
assert 'Exclude from comparison: aborted' in html
assert 'data:image/png;base64,' in html
def test_incomplete_missing_and_bad_checksum():
cp, run = fixture()
run.outcome = 'incomplete'
run.messages = [m for m in run.messages if m.which() != 'carStateSP']
m = next(m for m in run.messages if m.which() == 'sendcan')
data = bytearray(m.sendcan[0].dat)
data[1] ^= 1
m.sendcan[0].dat = bytes(data)
problems = analyze(run, cp)['problems']
assert {'incomplete', 'missing carStateSP', 'CAN checksum'} <= problems
def test_single_sample_and_gap_cannot_count_as_onset():
assert first_sustained([0., .01, .02, .03], [False, True, False, False]) is None
assert first_sustained([0., .5], [True, True]) is None
@pytest.mark.parametrize('channel', ['c0', 'c1'])
@pytest.mark.parametrize('keyboard', [False, True])
def test_normal_channel_report_uses_real_targets_and_decoded_output(channel, keyboard, tmp_path, monkeypatch):
from pathlib import Path
from openpilot.tools.lateral_maneuvers import generate_report as generator
from openpilot.tools.lateral_maneuvers.ford_report import channel_commands
cp = structs.CarParams(carFingerprint='FORD_SYNTHETIC_REPORT_TEST', steerControlType=structs.CarParams.SteerControlType.curvature,
safetyConfigs=[structs.CarParams.SafetyConfig()])
packer, bus = CANPacker('ford_lincoln_base_pt'), CanBus(cp)
messages = []
speed = SPEEDS[0]
for i in range(250):
t = 10.+i*.01
curvature = (.5 if i < 105 else -.5)/speed**2
if i % 5 == 0:
m = event('lateralManeuverPlan', t)
m.lateralManeuverPlan.desiredCurvature = curvature
m.lateralManeuverPlan.fordChannelTest = {'runId': 1, 'channel': channel, 'phase': 'maneuver', 'speed': speed,
'keyboardRequestId': 1 if keyboard else 0,
'keyboardPhase': 'pulse' if i < 105 else 'release'}
messages.append(m)
m = event('carState', t)
m.carState.vEgo, m.carState.steeringAngleDeg = speed, 16. if i < 110 else -16.
messages.append(m)
m = event('controlsState', t)
m.controlsState.curvature = curvature*.8 if i > 10 else 0.
m.controlsState.desiredCurvature = curvature
messages.append(m)
m = event('carControl', t)
m.carControl.latActive = True
m.carControl.orientationNED = [0., 0., 0.]
messages.append(m)
messages.append(event('carOutput', t))
if keyboard:
m = event('carStateSP', t)
m.carStateSP.fordPscmStatus = {'valid': True, 'canMonoTime': m.logMonoTime, 'lateralState': 2, 'limit': 2 if 70 < i < 90 else 0}
messages.append(m)
c0, c1 = (24.5*curvature, 0.) if channel == 'c0' else (0., speed*curvature)
address, data, src = create_lat_ctl2_msg(packer, bus, 2, -c0, -c1, 0., 0., i % 16)
m = event('sendcan', t)
m.sendcan[0].address, m.sendcan[0].dat, m.sendcan[0].src = address, data, src
messages.append(m)
m = event('alertDebug', 12.5)
m.alertDebug.alertText1 = 'Complete'
messages.append(m)
commands, problems = channel_commands(messages, cp, channel, 10_000_000_000)
assert not problems
legacy_collector = ChannelRuns()
for msg in messages:
legacy_collector.add(msg)
assert not legacy_collector.runs # CLI dispatches these targets to the normal report
assert (commands[:, 2 if channel == 'c0' else 1] == 0.).all()
captured = []
savefig = generator.plt.Figure.savefig
def capture_figure(fig, *args, **kwargs):
captured.append([axis.get_ylabel() for axis in fig.axes])
# Render the real figure at preview resolution to keep this check fast.
savefig(fig, *args, **(kwargs | {'dpi': 40}))
monkeypatch.setattr(generator.plt.Figure, 'savefig', capture_figure)
opened = []
monkeypatch.setattr(generator.webbrowser, 'open_new_tab', opened.append)
monkeypatch.setattr(generator, '__file__', str(tmp_path/'generate_report.py'))
generator.report('FORD_SYNTHETIC_REPORT_TEST', f'synthetic-{channel}', None, cp,
SimpleNamespace(gitCommit='synthetic only', gitBranch='test', gitRemote='local'),
[(f'step right 15mph {channel.upper()}', [messages])])
output = Path(opened[0])
html = output.read_text()
output.rename(tmp_path/output.name)
assert f'Normal maneuver target through {channel.upper()} only' in html
assert 'invalid maneuver!' not in html
expected = ['Lateral Accel (m/s^2)', 'Wheel angle (deg)', 'Velocity (mph)', 'Jerk (m/s^3)', 'Roll (deg)', 'C0 sent (m)', 'C1 sent (rad)']
assert captured == [expected + (['PSCM limit\n2 = reached'] if keyboard else [])]
if keyboard:
assert 'Keyboard-triggered step' in html and 'Accelerator input is allowed with MADS' in html
assert 'data:image/webp;base64,' in html
# A live nonzero unused field must invalidate the isolated-channel measurement.
assert 'channel isolation failed' in channel_commands(messages, cp, 'c1' if channel == 'c0' else 'c0', 10_000_000_000)[1]