Ford: sample position and heading from one shared model point

Use the model-predicted one-second station with the existing seven-metre minimum and endpoint hold for both C0 and C1. Remove the extra yaw forecast and scalar-curvature heading reconstruction; retain C2/C3 zero, independent limits and slew, the existing toggle, and 20Hz sends. Reject scalar-only maneuver references explicitly.

Validate model clocks, geometry, CAN delivery, Panda TX acceptance, and randomized boundaries offline. Recorded-input replay does not establish improved physical tracking.

Assisted-by: OpenAI Codex
This commit is contained in:
Isaac Barham
2026-09-08 13:35:07 -04:00
parent e1cd61166c
commit 6f83b17457
15 changed files with 704 additions and 546 deletions
+59 -102
View File
@@ -1,123 +1,80 @@
# Ford selected-action drive-test branch
# Ford model-point drive-test branch
The candidate is selectable on the **Ford CAN FD F-150 Lightning** behind
its own persistent, default-off Sunnylink toggle. Version 6 uses calibrated
measured turn rate for C0's existing 150 ms vehicle-pose forecast. C1 still uses
selected, upstream-limited curvature. Raw Ford yaw remains an input-health and
diagnostic signal; calibrated motion now affects C0. This is yaw feedback whose
sensitivity depends on the existing preview time and path distance. There is no
fitted PSCM plant model, added strength multiplier, new filter or extra core state.
`calibration_approved=false`: offline checks do not establish physical tracking,
turn-exit behavior or closed-loop stability.
The default-off **Selected-Action Path Tracking (Experimental)** Sunnylink toggle
now selects the v7 model-point candidate on the **Ford CAN FD F-150 Lightning**.
The stored key remains `FordModelActionController`; an already enabled setting
selects this revision after updating and completing an offroad-to-onroad cycle.
The current cadence experiment transmits this controller's LMC2 requests at
**20 Hz (50 ms)** while its calculation remains at **100 Hz**. See
[cadence validation](ford_model_action_cadence.md). The v6 control law and
per-second C0/C1 slew are unchanged; this is not a demonstrated tracking fix.
The controller reads **model lateral position and model heading at the same
point**. Start with the model's predicted distance at one second, enforce the
existing seven-metre minimum, and hold the available endpoint when necessary.
C0 is that point's lateral position in metres; C1 is its unwrapped heading in
radians. C2 and C3 stay zero. The 150 ms yaw-based forecast and the reconstruction
of heading from selected curvature are removed.
This point choice is an engineering guess, not an identified Ford reference or
PSCM calibration. `calibration_approved=false`: offline tests do not establish
physical tracking, turn-exit behavior, or stability across different PSCMs.
See [the model-point decision and validation](ford_model_points.md).
## Select and restore
1. Install branch `hiimisaac-dev` from
`sunnypilot/sunnypilot` on the device using your normal branch-switch process.
Allow its build to finish before changing the setting.
2. While offroad, open Sunnylink device settings → Vehicle → Ford and enable
**Selected-Action Path Tracking (Experimental)** (`FordModelActionController`).
1. Install branch `hiimisaac-dev` from `sunnypilot/sunnypilot` and allow the build
to finish.
2. While offroad, open Sunnylink device settings → Vehicle → Ford. Keep or enable
**Selected-Action Path Tracking (Experimental)**.
3. Complete a real offroad-to-onroad cycle. `card` snapshots the toggle into
`CarParamsSP`; the sender and `controlsd` share that selection. Changing a
stored toggle or disengaging alone cannot swap an active
controller. Initial physical evaluation remains controlled testing.
stored toggle or disengaging alone cannot swap an active controller.
The startup log event `Ford path controller selected` should report
`FordModelActionController`. Periodic `Ford C2-free path tracking` events
identify `hypothesis=model-action-measured-pose-v6`. Active events report
`pose_source=measured` when fresh calibrated motion is used, otherwise
`pose_source=requested`. They also include `pose_yaw_rate`, `pose_age`, raw host
`yaw_rate`, selected curvature and the command tuple.
The startup event `Ford path controller selected` reports
`FordModelActionController`. Periodic `Ford C2-free path tracking` events report
`hypothesis=model-pose-one-second-v7`, `pose_source=model`, `preview_time_s=1.0`
and `minimum_station_m=7.0`, plus input ages, slew state and the command tuple.
Selected desired curvature is still logged, but no longer constructs C0/C1.
Turning the new toggle off and completing another offroad-to-onroad cycle
restores **PSCM Coefficient Observer** if selected, otherwise the original
Ford path controller. The stored observer selection is preserved. The candidate
takes priority on the supported vehicle, independently of EPS firmware query
results. Other vehicles retain their existing selection.
Turning the toggle off and completing another offroad-to-onroad cycle restores
**PSCM Coefficient Observer** if selected, otherwise the original Ford path
controller. Other vehicles keep their previous selection. The retired v8 toggle
cannot select this candidate.
The v8 implementation, its Sunnylink toggle and its dedicated tests are removed.
A leftover `FordVirtualAngleController=1` file cannot enable the new controller.
The shared Float32/CAN rounding helper now lives in `ford_model_action.py`;
unused v8 PSCM-feedback plumbing is removed. Historical v8 route evidence remains
in Git history and the archived validation documents.
## Wiring and limits
## Wiring and validation
Both model fields must have matching, finite, strictly increasing time arrays
starting at zero. Malformed geometry, stale required services, invalid timing,
or disengagement resets both actuator states. Freshness still requires model,
car-state and reference publications no older than 150 ms, with at most 5 ms
future skew. The valid control timestep remains 2100 ms.
`Controls.__init__` selects the candidate once at startup. It shares the
existing Ford call path, selected upstream-limited curvature, service gates,
invalid-output disengagement, Float32 publication and downstream CAN builder.
C2 and C3 stay zero. The cadence experiment updates the opendbc sender and
submodule pointer. Panda safety is unchanged.
Only the two unquantized C0/C1 slew positions persist in the core. Field caps are
±5.11 m / ±0.5 rad and slew rates are 4 m/s / 0.5 rad/s. Calculation stays at
100 Hz; the existing [cadence experiment](ford_model_action_cadence.md) sends this
candidate at 20 Hz. Float32 publication, host-to-wire negation, packing and
Panda safety are unchanged. The opendbc pin remains
`87ca78e6e641eefb2d654f260a6ab08df3058bd5`.
Measured-pose use requires healthy `deviceMotion` and `extrinsicsCalibration`
services, calibrated extrinsics no older than 1 s, valid angular velocity and
sensor/input flags, and finite calibrated yaw within ±3 rad/s. Both the motion
publication and its embedded filter-state timestamp must be no older than
150 ms; all three age checks allow at most 5 ms future skew. Calibration-only
updates rebuild the cached pose before the candidate uses it.
Unavailable, unhealthy or stale optional motion falls back to the v5
requested-curvature forecast without resetting C0/C1 slew. Existing base-input
failures still invalidate the command. C1, the ±5.11 m / ±0.5 rad field caps,
4 m/s / 0.5 rad/s slew rates, packing and two core states are unchanged.
Normal operation uses the model's point directly. The upstream scalar curvature
and its clipping still exist for logging/other controllers, but no longer bound
this candidate's heading target. Its C0/C1 field caps and slew still apply;
passing Panda TX checks does not establish an actual vehicle acceleration bound.
Lateral maneuver test mode supplies only a scalar curvature, not a model pose.
It explicitly invalidates/disengages this candidate as `unsupported_reference`.
Optional measured motion is no longer a command input.
Sunnylink publishes the toggle through its generated settings schema and
writes the registered Boolean through the existing parameter endpoint. The
offroad UI rule and `needs_onroad_cycle` metadata describe when it can be
changed and when it takes effect. An onroad backend write changes storage
only; the controller continues using its startup selection.
## Reproduce offline checks
Native validation also exposed a pre-existing `params_keys_by_flag` bug:
every returned buffer referenced the same reusable string. Sunnylink backup
key enumeration could therefore return corrupted names. The bridge now
returns separate strings owned by the parameter handle. Regression tests
check distinct registered keys across flags, and toggle tests check its
persistence and backup registration using the rebuilt native library.
The current validation record is `ford_model_action_measured_pose_validation.json`.
Its runtime replay exactly matches the reviewed offline candidate across
340,757 recorded cycles and 681,514 Float32/CAN round trips. C1 and eligibility
match v5 on all four extracts. The replay checks frozen recorded inputs; it does
not simulate how the vehicle would respond to different commands.
The candidate adds C0 during the recorded weak-bend turn-rate shortfalls and
reduces it during the older overshoot example. Tight-turn C1 saturation remains.
On the 70.24-second sustained quiet-path cohort, C0 amplitude RMS decreases but
per-cycle change RMS rises about 28%; physical centering and tracking still need
evaluation. All four routes came from the same truck, so cross-PSCM performance
has not been demonstrated.
`ford_model_action_no_yaw_damping_validation.json` archives v5 checks;
the [v5 notes](ford_model_action_no_yaw_damping.md) explain the prior damping removal.
`ford_model_action_full_prediction_validation.json` archives v4 checks;
the [full-prediction notes](ford_model_action_full_prediction.md) explain cap removal
and remaining physical uncertainty. `ford_model_action_prediction_validation.json`
archives the capped v3 evaluation. `ford_model_action_damping_validation.json`
archives the preceding v2 checks at their recorded source hashes.
`ford_model_action_drive_test_validation.json` archives v1 wiring validation
at the recorded source hashes, including 284 tests and 26 subtests. Its counts
and 145-line controller size describe v1. The 469-line v8 module remains removed.
The original 133,550-cycle route reconstruction, 485,238 packing round trips
and mutation probes remain recorded separately in
`ford_model_action_validation.json` at the offline-stage source hashes.
## Reproduce deployment checks
Initialize the branch's pinned opendbc submodule with `git submodule update --init opendbc_repo`
and build the native Params library from this branch before testing.
Initialize the pinned submodule and build the project's native Python dependencies:
```sh
git submodule update --init opendbc_repo
export PYTHONDONTWRITEBYTECODE=1
export PYTHONPATH=.:opendbc_repo
python -m pytest -q -p no:cacheprovider openpilot/selfdrive/controls/tests/test_ford_*.py tools/ford_pscm_lab openpilot/selfdrive/car/tests/test_ford_pscm_status.py openpilot/sunnypilot/sunnylink/tests openpilot/common/tests/test_params.py opendbc_repo/opendbc/car/ford/tests/test_ford.py
python -m tools.ford_pscm_lab.stress_model_action --cycles 200000 --seed 20260907 --opendbc-revision "$(git -C opendbc_repo rev-parse HEAD)" --output .cache/ford_model_action_drive_test/stress.json
python -m pytest -q openpilot/selfdrive/controls/tests/test_ford*.py tools/ford_pscm_lab opendbc_repo/opendbc/car/ford/tests/test_ford.py openpilot/sunnypilot/sunnylink/tests openpilot/sunnypilot/mads/tests openpilot/selfdrive/car/tests/test_ford_pscm_status.py openpilot/common/tests/test_params.py opendbc_repo/opendbc/safety/tests/test_ford.py
python -m tools.ford_pscm_lab.stress_model_action --cycles 200000 --seed 20260908 --opendbc-revision 87ca78e6e641eefb2d654f260a6ab08df3058bd5 --output .cache/ford_model_points/stress.json
```
The full hardware build and device boot are not performed by these offline
tests. Installing the branch and enabling the toggle are separate actions;
pushing the branch does not change a device's selected software or settings.
Historical v1v6 validation files retain their original source hashes and apply
to those revisions. In particular, `ford_model_action_measured_pose_validation.json`
describes v6, not the current model-point mapping. The hardware build and device
boot are not performed by these offline checks. Pushing a branch does not update
a device or change its stored settings.
+77
View File
@@ -0,0 +1,77 @@
# Ford model-point candidate v7
## Decision
Use the model's own position and orientation at one shared path point. This
implements the request to follow model geometry through C0/C1, with C2/C3 zero,
without a fitted PSCM plant, new strength gain, yaw integral, or release mode.
Let `s(t)` be cumulative planar arc distance along model position. Choose:
```
station = min(path_end, max(7 metres, s(1 second)))
C0_target = model.position.y at station
C1_target = unwrapped model.orientation.z at station
C2 = C3 = 0
```
Use the published model timestamps, not `speed × 1 second`, so the point also
follows the model's predicted acceleration/braking. The seven-metre floor keeps
the existing low-speed preview distance; it can select a time beyond one second.
If the entire path is shorter, both fields hold the same endpoint. Interpolation
uses the same arc segment and weights for position and heading.
One second keeps C1 near the old one-second heading scale in ordinary driving.
Using that same point for C0 represents a meaningful change in faster bends.
This is a chosen local approximation; Ford's expected reference point and
preview are unknown. Matching metres/radians does not prove PSCM equivalence.
The former 150 ms vehicle-pose forecast is removed. C1 no longer uses
`max(7, speed) × selected desiredCurvature`. Measured yaw remains an input-health
check only; calibrated yaw has no command role. Freshness, independent slew,
packing, the shared startup toggle, 20 Hz sends, and Panda safety remain.
The upstream scalar curvature is still logged but does not limit this geometry;
C0/C1 retain their existing amplitude and slew limits. The scalar-only lateral
maneuver test reference is explicitly unsupported and disengages this candidate.
## Offline evidence
The broad run passed **572 tests and 9,146 subtests**, with 178 inherited safety
cases skipped as inapplicable. Tests of the removed yaw forecast were retired;
new tests cover actual model clocks, a nonconstant-speed trajectory, shared-point
sampling, the distance floor, endpoint holding, heading unwrap, source selection,
invalid geometry, reset, independent slew, and actual CAN delivery.
The stress run covers 200,000 random cycles plus mirrored turns, 18,138 field
boundary cases, and 218,138 Float32/CAN round trips. An independent scalar oracle
checks analytic model points and slew. No vehicle plant is simulated.
Five recorded routes (a5, a2, a0, 9b, 9e) supply **379,718 controller cycles** and
**76,294 model frames**. Original rlog hashes and position/orientation timestamps
were checked. An independently implemented segment-weight oracle matches every
sampled target and eligible slew state. Every cycle passed packing/bound checks;
all **75,947** scheduled 20 Hz requests passed the actual unchanged Panda TX hook.
This is TX acceptance with controlled eligibility, not a full Panda RX watchdog
or vehicle-response replay. The [validation manifest](ford_model_points_validation.json)
binds the results to their source and input hashes.
On a5's 61.24-second driver-clean ordinary-bend cohort, mean absolute C0 changes
from 0.075 m recorded to 0.167 m replayed; C1 changes from 0.02287 to 0.02224 rad.
Neither field target clips in that cohort. These are command differences, not
predicted changes in steering strength or tracking error.
| a5 time | Recorded C0 / C1 | Candidate C0 / C1 | Selected station / time |
| --- | --- | --- | --- |
| 107.995 s, ordinary bend | +0.20 m / +0.068 rad | +0.55 m / +0.0685 rad | 13.71 m / 1.00 s |
| 390.681 s, sustained bend | 0.52 m / 0.1235 rad | 0.75 m / 0.1215 rad | 9.37 m / 1.00 s |
| 374.889 s, tight turn | 3.76 m / 0.50 rad | 3.46 m / 0.50 rad | 7.00 m / 1.49 s |
| 17.607 s, exit overshoot | 0.01 m / 0.015 rad | 0.05 m / 0.012 rad | 7.00 m / 1.85 s |
The exit example retains more C0 into the turn than v6; physical unwind behavior
must be evaluated. Tight-turn C1 clipping remains (14.92 eligible seconds on a5).
No root cause or physical fix is proven by frozen inputs. The latest a5 road
logs used 100 Hz sends, whereas the immediately preceding code revision already
changed to 20 Hz; a comparison against that drive also includes the cadence change.
`calibration_approved=false` remains. Installation instructions and restore
behavior are in the [drive-test guide](ford_model_action_drive_test.md).
+391
View File
@@ -0,0 +1,391 @@
{
"baseline_root": "e1cd61166c529f39cb47c815db47e80de720a778",
"opendbc": "87ca78e6e641eefb2d654f260a6ab08df3058bd5",
"scope": "Model-point construction only; physical tracking not validated.",
"calibration_approved": false,
"tests": {
"passed": 572,
"subtests_passed": 9146,
"inapplicable_skips": 178
},
"source_sha256": {
"openpilot/selfdrive/controls/lib/ford_model_action.py": "e7502ab68d04aef52edcffcef4b1d42c04e9008ab6892789aac3a865c100cb6d",
"openpilot/selfdrive/controls/controlsd.py": "bea720b8ec68d6b8a6ad4376ae37324e2404833fb13097a8ab7710a1cedf37a5",
"tools/ford_pscm_lab/stress_model_action.py": "0221f85ea2f5757d3b54d077ed8be1faaa6a5d2ed8371de1137f3b6f3d70bf47",
"openpilot/sunnypilot/sunnylink/settings_ui_src/pages/vehicle.yaml": "ec5e0d9023f7260ae619a4cc6ec538d39053ac13e4515bd0de7d854b79d8abf6",
"openpilot/sunnypilot/sunnylink/settings_ui.json": "62e5178a8deee32ef5a88026094cedde359130741688e8a4bcfa82155ef4cca3"
},
"routes": {
"a5": {
"route": "a5",
"scope": "Frozen inputs; command construction and Panda TX acceptance only. No counterfactual vehicle response.",
"cycles": 38961,
"models": 7817,
"round_trips": 38961,
"panda_accepted_sends": 7793,
"source_model_clocks_verified": true,
"independent_point_and_slew_oracle_passed": true,
"cohorts": {
"eligible": {
"seconds": 359.186785903,
"old_mean_abs_c0": 0.34434291232781916,
"new_mean_abs_c0": 0.3706073530276498,
"old_mean_abs_c1": 0.05784747586810226,
"new_mean_abs_c1": 0.06183462577629167,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 14.919213676999902
},
"road_speed": {
"seconds": 261.730126543,
"old_mean_abs_c0": 0.10481662146430282,
"new_mean_abs_c0": 0.16319455033344274,
"old_mean_abs_c1": 0.025151988851597765,
"new_mean_abs_c1": 0.02471229299756163,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 0.0
},
"low_speed": {
"seconds": 97.45665935999997,
"old_mean_abs_c0": 0.9876160024355487,
"new_mean_abs_c0": 0.9276362872461182,
"old_mean_abs_c1": 0.14565465097969882,
"new_mean_abs_c1": 0.16153056158564297,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 14.919213676999902
},
"clean": {
"seconds": 249.91415584299997,
"old_mean_abs_c0": 0.08470877473478762,
"new_mean_abs_c0": 0.1191542994782463,
"old_mean_abs_c1": 0.017726187650852487,
"new_mean_abs_c1": 0.018543416153046232,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 1.056498005999913
},
"clean_ordinary_bends": {
"seconds": 61.23773978999962,
"old_mean_abs_c0": 0.07537829679720884,
"new_mean_abs_c0": 0.16659385323910988,
"old_mean_abs_c1": 0.022868833214909796,
"new_mean_abs_c1": 0.022235380107927534,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 0.0
}
},
"points": [
{
"t": 17.606574576,
"old_c0_c1": [
-0.009999999776482582,
-0.014999999664723873
],
"new_c0_c1": [
-0.04999999999999982,
-0.01200000000000001
],
"raw_target": [
-0.04535145975205448,
-0.012038111718131041
],
"sample_station_m": 7.0,
"sample_time_s": 1.8525775632377612
},
{
"t": 107.99483939600002,
"old_c0_c1": [
0.20000000298023224,
0.06800000369548798
],
"new_c0_c1": [
0.5499999999999998,
0.0685
],
"raw_target": [
0.548033630847931,
0.06853501158101219
],
"sample_station_m": 13.711669224561392,
"sample_time_s": 1.0
},
{
"t": 374.888921539,
"old_c0_c1": [
-3.759999990463257,
-0.5
],
"new_c0_c1": [
-3.46,
-0.5
],
"raw_target": [
-3.497294441592979,
-0.6981158781127829
],
"sample_station_m": 7.0,
"sample_time_s": 1.490557194102553
},
{
"t": 390.68080671100006,
"old_c0_c1": [
-0.5199999809265137,
-0.12349999696016312
],
"new_c0_c1": [
-0.75,
-0.12150000000000005
],
"raw_target": [
-0.7522029416901727,
-0.1214686983398029
],
"sample_station_m": 9.370794504969545,
"sample_time_s": 1.0
}
],
"hashes": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "e7502ab68d04aef52edcffcef4b1d42c04e9008ab6892789aac3a865c100cb6d",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_model_points/replay.py": "762ebaac3b31737ad609d5b9788514147c696e548fd1ee09458314a0eea30434",
".cache/ford_routea5/route.npz": "815d1e248ff5c3e5e5cfc11dfbd0690d0d13ddafe1436890e239975a72dae9b8",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_model_points/a5_models.npz": "84cf4b6e0102d8d5a3f6c3e29bf332e929d7f28d0262eb46220fccc631b902aa"
}
},
"a2": {
"route": "a2",
"scope": "Frozen inputs; command construction and Panda TX acceptance only. No counterfactual vehicle response.",
"cycles": 71111,
"models": 14287,
"round_trips": 71111,
"panda_accepted_sends": 14223,
"source_model_clocks_verified": true,
"independent_point_and_slew_oracle_passed": true,
"cohorts": {
"eligible": {
"seconds": 437.0644468180001,
"old_mean_abs_c0": 0.2155311761983153,
"new_mean_abs_c0": 0.31863289785799315,
"old_mean_abs_c1": 0.04341179516514646,
"new_mean_abs_c1": 0.045887913019549746,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 11.38161301699995
},
"road_speed": {
"seconds": 328.02165902499996,
"old_mean_abs_c0": 0.06680861343584246,
"new_mean_abs_c0": 0.21392942512598495,
"old_mean_abs_c1": 0.025146318618058133,
"new_mean_abs_c1": 0.025146126320953914,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 0.0
},
"low_speed": {
"seconds": 109.04278779300012,
"old_mean_abs_c0": 0.6629172230811673,
"new_mean_abs_c0": 0.6336010633494195,
"old_mean_abs_c1": 0.09835785845941926,
"new_mean_abs_c1": 0.10828319309008405,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 11.38161301699995
},
"clean": {
"seconds": 332.6767952400001,
"old_mean_abs_c0": 0.07338523837198829,
"new_mean_abs_c0": 0.1946808147540538,
"old_mean_abs_c1": 0.023104103590461375,
"new_mean_abs_c1": 0.022903078905402382,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 2.902278352000053
},
"clean_ordinary_bends": {
"seconds": 111.90846122999926,
"old_mean_abs_c0": 0.07917757541079931,
"new_mean_abs_c0": 0.3589047266588909,
"old_mean_abs_c1": 0.03830142702025842,
"new_mean_abs_c1": 0.03807353528971872,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 0.0
}
},
"points": [],
"hashes": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "e7502ab68d04aef52edcffcef4b1d42c04e9008ab6892789aac3a865c100cb6d",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_model_points/replay.py": "762ebaac3b31737ad609d5b9788514147c696e548fd1ee09458314a0eea30434",
".cache/ford_routea2/route.npz": "af4128d278be6be0042f72a02255ddbec96a8a5aa435b9852c8202e4a5cb39e5",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_model_points/a2_models.npz": "c3a366796ca5fb27f8c0dc85732ccd6f0ec61b4c8646af3dfe55600e026c0810"
}
},
"a0": {
"route": "a0",
"scope": "Frozen inputs; command construction and Panda TX acceptance only. No counterfactual vehicle response.",
"cycles": 114582,
"models": 23038,
"round_trips": 114582,
"panda_accepted_sends": 22917,
"source_model_clocks_verified": true,
"independent_point_and_slew_oracle_passed": true,
"cohorts": {
"eligible": {
"seconds": 1061.1945591,
"old_mean_abs_c0": 0.16681197800600925,
"new_mean_abs_c0": 0.25477442195222594,
"old_mean_abs_c1": 0.036905620742387873,
"new_mean_abs_c1": 0.0381744756199198,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 14.894412850999998
},
"road_speed": {
"seconds": 901.0129238640001,
"old_mean_abs_c0": 0.038282177648169546,
"new_mean_abs_c0": 0.13938686570168032,
"old_mean_abs_c1": 0.016746723820478274,
"new_mean_abs_c1": 0.01614716305741802,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 0.0
},
"low_speed": {
"seconds": 160.18163523600006,
"old_mean_abs_c0": 0.889785064486564,
"new_mean_abs_c0": 0.9038231052276234,
"old_mean_abs_c1": 0.15029831167554059,
"new_mean_abs_c1": 0.1620769021843944,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 14.894412850999998
},
"clean": {
"seconds": 884.2159145080002,
"old_mean_abs_c0": 0.05133685312289168,
"new_mean_abs_c0": 0.13569127462997554,
"old_mean_abs_c1": 0.017060990972111164,
"new_mean_abs_c1": 0.01680535356441334,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 4.094436286000047
},
"clean_ordinary_bends": {
"seconds": 229.50590253700045,
"old_mean_abs_c0": 0.06277285849992259,
"new_mean_abs_c0": 0.29272144832989233,
"old_mean_abs_c1": 0.032915757288861924,
"new_mean_abs_c1": 0.03226126900978203,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 0.0
}
},
"points": [],
"hashes": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "e7502ab68d04aef52edcffcef4b1d42c04e9008ab6892789aac3a865c100cb6d",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_model_points/replay.py": "762ebaac3b31737ad609d5b9788514147c696e548fd1ee09458314a0eea30434",
".cache/ford_routea0/route.npz": "01ce8b0361c3154d07d5449525a650baa2cc261675095cb1f04ec9e37a9174d3",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_model_points/a0_models.npz": "72a72c950d244f5afcffe8cb1cd09b9731297f7565c93814197c728d4b25e8cb"
}
},
"9b": {
"route": "9b",
"scope": "Frozen inputs; command construction and Panda TX acceptance only. No counterfactual vehicle response.",
"cycles": 71396,
"models": 14346,
"round_trips": 71396,
"panda_accepted_sends": 14280,
"source_model_clocks_verified": true,
"independent_point_and_slew_oracle_passed": true,
"cohorts": {
"eligible": {
"seconds": 651.159063169,
"old_mean_abs_c0": 0.09686840630728118,
"new_mean_abs_c0": 0.16616909035237892,
"old_mean_abs_c1": 0.020769878239136775,
"new_mean_abs_c1": 0.02325818302804746,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 4.1034965929999885
},
"road_speed": {
"seconds": 549.860784016,
"old_mean_abs_c0": 0.025487998809066218,
"new_mean_abs_c0": 0.10707029577162376,
"old_mean_abs_c1": 0.011795500864550536,
"new_mean_abs_c1": 0.01141539602134342,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 0.0
},
"low_speed": {
"seconds": 101.29827915299992,
"old_mean_abs_c0": 0.484330929447169,
"new_mean_abs_c0": 0.48696535455636186,
"old_mean_abs_c1": 0.06948401455523531,
"new_mean_abs_c1": 0.08754243546441211,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 4.1034965929999885
}
},
"points": [],
"hashes": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "e7502ab68d04aef52edcffcef4b1d42c04e9008ab6892789aac3a865c100cb6d",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_model_points/replay.py": "762ebaac3b31737ad609d5b9788514147c696e548fd1ee09458314a0eea30434",
".cache/ford_route9b/route.npz": "8942b281b76eac012ee660f8ae21bbcad0df95d515291d2497ce5897d5738647",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_model_points/9b_models.npz": "fa444360a2775b0df38f274ab4c99568bbe47a3d2777c31ab0193705fce2b1fb"
}
},
"9e": {
"route": "9e",
"scope": "Frozen inputs; command construction and Panda TX acceptance only. No counterfactual vehicle response.",
"cycles": 83668,
"models": 16806,
"round_trips": 83668,
"panda_accepted_sends": 16734,
"source_model_clocks_verified": true,
"independent_point_and_slew_oracle_passed": true,
"cohorts": {
"eligible": {
"seconds": 751.5479497329998,
"old_mean_abs_c0": 0.09489001052394747,
"new_mean_abs_c0": 0.16056913479509022,
"old_mean_abs_c1": 0.02256825583285058,
"new_mean_abs_c1": 0.022300154628921095,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 7.398116462998587
},
"road_speed": {
"seconds": 624.6602415809975,
"old_mean_abs_c0": 0.023757802428838078,
"new_mean_abs_c0": 0.10203005034223132,
"old_mean_abs_c1": 0.01124063950870769,
"new_mean_abs_c1": 0.010770200237869725,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 0.0
},
"low_speed": {
"seconds": 126.88770815200223,
"old_mean_abs_c0": 0.4450694167092398,
"new_mean_abs_c0": 0.4487533818653312,
"old_mean_abs_c1": 0.07833340166635812,
"new_mean_abs_c1": 0.07906139809550021,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 7.398116462998587
},
"clean": {
"seconds": 639.296275887018,
"old_mean_abs_c0": 0.02396620128468532,
"new_mean_abs_c0": 0.09600545527466989,
"old_mean_abs_c1": 0.01021397220255641,
"new_mean_abs_c1": 0.010198642694698857,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 0.0
},
"clean_ordinary_bends": {
"seconds": 168.17771330400137,
"old_mean_abs_c0": 0.05087679617819314,
"new_mean_abs_c0": 0.2527786285635614,
"old_mean_abs_c1": 0.02663914589176354,
"new_mean_abs_c1": 0.026386933889696897,
"c0_target_capped_s": 0.0,
"c1_target_capped_s": 0.0
}
},
"points": [],
"hashes": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "e7502ab68d04aef52edcffcef4b1d42c04e9008ab6892789aac3a865c100cb6d",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_model_points/replay.py": "762ebaac3b31737ad609d5b9788514147c696e548fd1ee09458314a0eea30434",
".cache/ford_route9e/route.npz": "9f169338caf2b5a5377332d15914d1dde57be8b922e25c1bbe16264e0f1be6fb",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_model_points/9e_models.npz": "acacf8f0d16fd986b6d74e4a02d704fa5b5b3327fe66dcbe149806f2a5bc3d65"
}
}
}
}
+2 -9
View File
@@ -87,7 +87,7 @@ class Controls(ControlsExt):
self.sm.update(15)
if self.sm.updated["extrinsicsCalibration"]:
self.pose_calibrator.feed_extrinsics_calibration(self.sm['extrinsicsCalibration'])
if self.sm.updated["deviceMotion"] or (self.ford_model_action and self.sm.updated["extrinsicsCalibration"]):
if self.sm.updated["deviceMotion"]:
device_motion = Pose.from_device_motion(self.sm['deviceMotion'])
self.calibrated_pose = self.pose_calibrator.build_calibrated_pose(device_motion)
@@ -174,19 +174,12 @@ class Controls(ControlsExt):
assert isinstance(self.ford_path_controller, FordModelActionController)
reference_service = 'lateralManeuverPlan' if self.sm.valid['lateralManeuverPlan'] else 'modelV2'
now = time.monotonic()
motion = self.sm['deviceMotion']
pose_valid = (self.calibrated_pose is not None and self.pose_calibrator.calib_valid and
self.sm.all_checks(['deviceMotion', 'extrinsicsCalibration']) and
motion.angularVelocityDevice.valid and motion.sensorsOK and motion.inputsOK and
-.005 <= now - self.sm.logMonoTime['deviceMotion'] * 1e-9 <= .15 and
-.005 <= now - self.sm.logMonoTime['extrinsicsCalibration'] * 1e-9 <= 1.)
self.ford_path = self.ford_path_controller.update(
ford_model, self.desired_curvature, yaw_rate=-CS.yawRate, speed=CS.vEgo, now=now,
measurement_time=self.sm.logMonoTime['carState'] * 1e-9,
model_time=self.sm.logMonoTime['modelV2'] * 1e-9,
reference_time=self.sm.logMonoTime[reference_service] * 1e-9,
pose_yaw_rate=self.calibrated_pose.angular_velocity.z if pose_valid else None,
pose_time=motion.timestamp * 1e-9, pose_valid=pose_valid,
reference_source=reference_service,
active=CC.latActive, valid=CS.canValid and self.sm.all_checks(['carState', 'vehicleParameters', 'modelV2', reference_service]),
)
if not self.ford_path.valid:
@@ -1,7 +1,7 @@
"""Experimental Ford C2-free controller: nearby offset and selected-action heading.
"""Experimental Ford C2-free controller using one shared model-path point.
Selected only by its explicit toggle. The 7 m station and one-second scale are
engineering choices, not identified PSCM gains or physical calibration.
The one-second preview and seven-metre minimum are engineering choices,
not identified Ford reference points or PSCM calibration.
"""
import math
import struct
@@ -12,32 +12,9 @@ from opendbc.car.ford.values import FordFlags
from openpilot.selfdrive.controls.lib.ford_path import FordPath, _model_path
OFFSET_STATION_M = 7.0
HEADING_TIME_S = 1.0
MIN_STATION_M = 7.0
PREVIEW_TIME_S = 1.0
CALIBRATION_APPROVED = False
PREDICTION_TIME_S = .15 # geometric preview, not an identified actuator delay
def _predict_offset(path, c0, pose_curvature, speed):
"""Read the same path from a predicted constant-curvature vehicle pose.
A matched constant-radius path retains its offset. Developing/flattening
bends can move the target earlier. The core retains field limits and slew.
Use only available geometry; shortened horizons taper prediction to zero.
"""
station, longitudinal, lateral, _ = path
distance = min(speed*PREDICTION_TIME_S, max(0., station[-1]-OFFSET_STATION_M))
if distance == 0.:
return c0
x = float(np.interp(OFFSET_STATION_M+distance, station, longitudinal))
y = float(np.interp(OFFSET_STATION_M+distance, station, lateral))
rotation = pose_curvature*distance
# (1-cos(rotation))/curvature, evaluated without cancellation or division by zero.
translation = distance*math.sin(rotation/2)*float(np.sinc(rotation/(2*math.pi)))
predicted = math.cos(rotation)*y-math.sin(rotation)*x+translation
if not _finite(predicted):
return c0
return predicted
def _packed(value, resolution, offset):
@@ -53,40 +30,35 @@ def _finite(*values):
return False
def encode_model_action(model, desired_curvature, speed, *, pose_yaw_rate=None):
"""Encode predicted y(7) and max(7, v*1s)*selected limited curvature.
def encode_model_action(model, desired_curvature, speed):
"""Sample model lateral position and heading at the same arc station.
Preserve the reviewed core's endpoint hold when the path ends before 7 m.
This samples the available geometry; it does not extrapolate an unseen path.
Calibrated measured yaw predicts the vehicle pose when available; otherwise
retain the selected-curvature prediction. This is geometric yaw feedback,
whose sensitivity depends on the existing preview time and path distance.
Use the model's distance at one second, with a seven-metre minimum and an
endpoint hold. Selected curvature remains a health/diagnostic input; it does
not reconstruct heading or rotate the model geometry in this experiment.
"""
if not _finite(desired_curvature, speed) or not .3 <= speed <= 55 or abs(desired_curvature) > 1:
return FordPath()
try:
path = _model_path(model)
except OverflowError:
times = [float(t) for t in model.position.t]
heading_times = [float(t) for t in model.orientation.t]
except (AttributeError, TypeError, ValueError, OverflowError):
return FordPath()
if path is None or not all(_finite(*values) for values in path):
return FordPath()
station, _, lateral, _ = path
c0 = float(np.interp(min(OFFSET_STATION_M, station[-1]), station, lateral))
pose_curvature = desired_curvature
if pose_yaw_rate is not None and _finite(pose_yaw_rate) and abs(pose_yaw_rate) <= 3:
pose_curvature = pose_yaw_rate / speed
c0 = _predict_offset(path, c0, pose_curvature, speed)
c1 = max(OFFSET_STATION_M, speed*HEADING_TIME_S)*desired_curvature
station, _, lateral, heading = path
if (len(times) != len(station) or not times or times[0] != 0. or times != heading_times or
not _finite(*times) or any(b <= a for a, b in zip(times, times[1:], strict=False))):
return FordPath()
sample_station = min(station[-1], max(MIN_STATION_M, float(np.interp(PREVIEW_TIME_S, times, station))))
c0 = float(np.interp(sample_station, station, lateral))
c1 = float(np.interp(sample_station, station, heading))
return FordPath(True, c0, c1, 0., 0.) if _finite(c0, c1) else FordPath()
class ModelActionController:
"""Only two control states: unquantized, independently slewed C0 and C1.
Freshness and engagement belong to the caller. Raw Ford yaw checks input
health only. A separate calibrated yaw input can change the offset forecast;
heading always follows the selected limited curvature.
"""
"""Only two states: independently slewed C0/C1 model-point requests."""
__slots__ = ('c0', 'c1')
def __init__(self):
@@ -95,12 +67,12 @@ class ModelActionController:
def reset(self):
self.c0 = self.c1 = 0.
def update(self, model, desired_curvature, *, speed, dt, yaw_rate=0., active=True, valid=True, pose_yaw_rate=None):
def update(self, model, desired_curvature, *, speed, dt, yaw_rate=0., active=True, valid=True):
# Raw Ford yaw remains an input-health check, not a pose measurement.
if not active or not valid or not _finite(dt, yaw_rate) or not .002 <= dt <= .1 or abs(yaw_rate) > 3:
self.reset()
return FordPath()
target = encode_model_action(model, desired_curvature, speed, pose_yaw_rate=pose_yaw_rate)
target = encode_model_action(model, desired_curvature, speed)
if not target.valid:
self.reset()
return FordPath()
@@ -112,17 +84,11 @@ class ModelActionController:
class FordModelActionController:
"""Input adapter for the opt-in selected-action controller.
"""Freshness, engagement and reference checks for model-point tracking.
controlsd owns upstream selection/limiting and service health. This adapter
checks ages and clock order, then supplies elapsed time to the two-state
core. Pose age gates measured-motion use; diagnostics do not feed back into
the core. Raw model geometry is checked even at a repeated model timestamp.
Raw Ford yaw supplies diagnostics and input-health checks. Fresh, healthy
calibrated motion supplies pose prediction; unavailable motion falls back
to the existing requested-pose forecast without resetting the slew states.
PSCM status and driver torque are not control-law inputs.
Scalar-only maneuver references cannot supply this controller's model pose.
Reject them explicitly rather than silently following a different reference.
Measured yaw checks input health only; it never modifies valid geometry.
"""
def __init__(self):
self.core = ModelActionController()
@@ -131,14 +97,16 @@ class FordModelActionController:
def reset(self, status='inactive'):
self.core.reset()
self.last_time = self.last_measurement_time = self.last_model_time = None
self.diagnostics = {'status': status, 'hypothesis': 'model-action-measured-pose-v6',
self.diagnostics = {'status': status, 'hypothesis': 'model-pose-one-second-v7',
'calibration_approved': CALIBRATION_APPROVED, 'command': (0., 0., 0., 0.)}
def update(self, model, desired_curvature, *, yaw_rate, speed, now, measurement_time, model_time, reference_time,
active, valid=True, pose_yaw_rate=None, pose_time=None, pose_valid=False):
active, valid=True, reference_source="modelV2"):
reason = None
if not active:
reason = 'inactive'
elif reference_source != 'modelV2':
reason = 'unsupported_reference'
elif not valid:
reason = 'invalid_service'
elif not _finite(desired_curvature, yaw_rate, speed, now, measurement_time, model_time, reference_time):
@@ -157,21 +125,15 @@ class FordModelActionController:
):
self.reset('timing_reset')
return FordPath()
pose_age = now - pose_time if _finite(pose_time) else None
if not _finite(pose_age):
pose_age = None
use_pose = (pose_valid and pose_yaw_rate is not None and pose_age is not None and
_finite(pose_yaw_rate) and abs(pose_yaw_rate) <= 3 and -.005 <= pose_age <= .15)
command = self.core.update(model, desired_curvature, speed=speed, dt=dt, yaw_rate=yaw_rate,
pose_yaw_rate=pose_yaw_rate if use_pose else None)
command = self.core.update(model, desired_curvature, speed=speed, dt=dt, yaw_rate=yaw_rate)
if not command.valid:
self.reset('invalid_path')
return command
self.last_time, self.last_measurement_time, self.last_model_time = now, measurement_time, model_time
self.diagnostics = {'status': 'active', 'hypothesis': 'model-action-measured-pose-v6',
self.diagnostics = {'status': 'active', 'hypothesis': 'model-pose-one-second-v7',
'calibration_approved': CALIBRATION_APPROVED, 'desired_curvature': desired_curvature,
'yaw_rate': yaw_rate, 'pose_source': 'measured' if use_pose else 'requested',
'pose_yaw_rate': pose_yaw_rate if use_pose else None, 'pose_age': pose_age,
'yaw_rate': yaw_rate, 'pose_source': 'model',
'preview_time_s': PREVIEW_TIME_S, 'minimum_station_m': MIN_STATION_M,
'model_age': now - model_time, 'measurement_age': now - measurement_time, 'reference_age': now - reference_time,
'dt': dt, 'offset_request': self.core.c0, 'heading_request': self.core.c1,
'command': (command.path_offset, command.path_angle, 0., 0.)}
@@ -49,16 +49,15 @@ class TestFordControlsLogging(unittest.TestCase):
controller = FordModelActionController()
for active, valid in ((False, True), (True, True), (True, False)):
controller.update(circle(.01), .005, yaw_rate=.05, speed=20., now=1.,
measurement_time=1., model_time=1., reference_time=1., active=active, valid=valid,
pose_yaw_rate=.04, pose_time=.98, pose_valid=True)
measurement_time=1., model_time=1., reference_time=1., active=active, valid=valid)
controls = SimpleNamespace(ford_path_controller=controller, desired_curvature=.005, curvature=.0025,
sm=SimpleNamespace(logMonoTime={'modelV2': 123456789, 'carState': 123450000}))
record = self.emit_controls_event('Ford C2-free path tracking', controls)
self.assertEqual(record['hypothesis'], 'model-action-measured-pose-v6')
self.assertEqual(record['hypothesis'], 'model-pose-one-second-v7')
self.assertIs(record['calibration_approved'], False)
self.assertEqual(record['command'][2:], [0., 0.])
self.assertEqual(record['status'], controller.diagnostics['status'])
if active and valid:
self.assertEqual(record['pose_source'], 'measured')
self.assertAlmostEqual(record['pose_yaw_rate'], .04)
self.assertAlmostEqual(record['pose_age'], .02)
self.assertEqual(record['pose_source'], 'model')
self.assertEqual(record['preview_time_s'], 1.)
self.assertEqual(record['minimum_station_m'], 7.)
@@ -12,7 +12,8 @@ from openpilot.selfdrive.controls.lib.ford_model_action import ModelActionContro
def make_model(x, y, heading):
return SimpleNamespace(position=SimpleNamespace(x=x, y=y), orientation=SimpleNamespace(z=heading))
times = np.linspace(0., 3., len(x))
return SimpleNamespace(position=SimpleNamespace(t=times, x=x, y=y), orientation=SimpleNamespace(t=times, z=heading))
def circle(curvature):
@@ -20,26 +21,26 @@ def circle(curvature):
return make_model(np.sin(curvature*s)/curvature, (1-np.cos(curvature*s))/curvature, curvature*s)
def straight(offset=0.):
def straight(offset=0., heading=0.):
x = np.linspace(0., 60., 121)
return make_model(x, np.full_like(x, offset), np.zeros_like(x))
return make_model(x*np.cos(heading), offset+x*np.sin(heading), np.full_like(x, heading))
def test_selected_action_controls_heading_even_when_model_previews_another_turn():
def test_model_heading_is_used_even_when_scalar_action_differs():
model = circle(.02)
assert encode_model_action(model, 0., 20.).path_angle == 0.
assert encode_model_action(model, -.004, 20.).path_angle == pytest.approx(-.08)
assert encode_model_action(model, 0., 20.).path_offset > 0.
for desired in (0., -.004, .004):
target = encode_model_action(model, desired, 20.)
assert target.path_angle == pytest.approx(.4)
assert target.path_offset == pytest.approx((1-math.cos(.4))/.02)
def test_straight_centering_and_matched_curves_keep_base_gain_across_speed():
def test_straight_centering_and_matched_model_circles():
for speed in (2., 7., 20., 35.):
target = encode_model_action(straight(.4), 0., speed)
assert target == FordPath(True, .4, 0., 0., 0.)
assert encode_model_action(straight(.4), 0., speed) == FordPath(True, .4, 0., 0., 0.)
for sign in (-1, 1):
target = encode_model_action(circle(sign*.01), sign*.01, 20.)
assert target.path_offset == pytest.approx(sign*(1-math.cos(.07))/.01, abs=1e-6)
assert target.path_angle == pytest.approx(sign*.2) # No 10 m cap at highway speed.
assert target.path_offset == pytest.approx(sign*(1-math.cos(.2))/.01)
assert target.path_angle == pytest.approx(sign*.2)
def test_two_actuator_positions_are_sufficient_for_every_next_output():
@@ -54,31 +55,26 @@ def test_two_actuator_positions_are_sufficient_for_every_next_output():
assert controller.update(model, desired, **kwargs) == copied.update(model, desired, **kwargs)
def test_held_turn_releases_with_geometric_countersteering_and_no_retained_bias():
def test_held_turn_releases_using_new_model_geometry_without_retained_bias():
for sign in (-1., 1.):
controller = ModelActionController()
for _ in range(400):
out = controller.update(circle(sign*.01), sign*.01, speed=20., dt=.01)
assert out.path_angle == pytest.approx(sign*.2)
previous = np.array([controller.c0, controller.c1])
opposed = False
for desired in sign*np.linspace(.01, 0., 101):
out = controller.update(straight(), desired, speed=20., dt=.01)
rotation = desired*3.
predicted = (1.-math.cos(rotation))/desired-10.*math.sin(rotation) if desired else 0.
expected = previous+np.clip([predicted, 20.*desired]-previous, [-.04, -.005], [.04, .005])
for _ in range(100):
out = controller.update(straight(), sign*.01, speed=20., dt=.01)
expected = previous+np.clip(-previous, [-.04, -.005], [.04, .005])
values = np.array([controller.c0, controller.c1])
np.testing.assert_allclose(values, expected, atol=1e-10)
opposed |= sign*out.path_offset < 0.
previous = values
assert opposed
assert out == FordPath(True, 0., 0., 0., 0.)
def test_current_model_replacement_leaves_only_independent_actuator_slew():
controller = ModelActionController()
for _ in range(150):
controller.update(straight(1.), .04, speed=20., dt=.01)
controller.update(straight(1.-20*math.sin(.4), .4), .04, speed=20., dt=.01)
for _ in range(25):
out = controller.update(straight(), 0., speed=20., dt=.01)
assert out.path_offset == pytest.approx(0.)
@@ -113,7 +109,7 @@ def test_selected_core_reversal_through_float32_and_wire_keeps_sign_and_zero_c2(
previous = np.zeros(2)
for i in range(600):
sign = 1. if i < 300 else -1.
out = controller.update(straight(sign*8.), sign*.1, speed=30., dt=.01)
out = controller.update(straight(sign*8., sign*.8), sign*.1, speed=30., dt=.01)
fields = np.array([out.path_offset, out.path_angle])
assert (abs(fields) <= [5.1100001, .5000001]).all()
assert (abs(fields-previous) <= [.0500001, .0055001]).all()
@@ -132,7 +128,7 @@ def test_selected_core_reversal_through_float32_and_wire_keeps_sign_and_zero_c2(
def test_short_path_holds_available_endpoint_without_extrapolation():
model = make_model([0., 1.], [0., .1], [0., 0.])
assert encode_model_action(model, .01, 20.) == FordPath(True, .1, .2, 0., 0.)
assert encode_model_action(model, .01, 20.) == FordPath(True, .1, 0., 0., 0.)
def test_overflowing_arc_resets_instead_of_publishing_invalid_geometry():
@@ -181,14 +177,13 @@ def test_domain_and_elapsed_time_boundaries(field, value, valid):
assert ModelActionController().update(straight(.4), **kwargs).valid == valid
def test_arc_station_not_forward_x_or_model_heading_determines_offset():
def test_arc_station_floor_not_forward_x_determines_offset():
x = np.array([0., 6., 12.])
y = .4+x*.75
target = encode_model_action(make_model(x, y, [2., -2., 1.]), -.01, 20.)
# Arc length is 1.25*x. At station 10, x=8 and y=6.4; use the full predicted pose.
expected = math.cos(-.03)*6.4-math.sin(-.03)*8.+(1.-math.cos(-.03))/-.01
assert target.path_offset == pytest.approx(expected)
assert target.path_angle == pytest.approx(-.2)
target = encode_model_action(make_model(x, y, [.4, .4, .4]), -.01, 20.)
# At one second arc station is 5 m; the 7 m minimum gives x=5.6, y=4.6.
assert target.path_offset == pytest.approx(4.6)
assert target.path_angle == pytest.approx(.4)
def test_duplicate_stations_keep_valid_geometry_and_first_cycle_slew():
@@ -196,4 +191,4 @@ def test_duplicate_stations_keep_valid_geometry_and_first_cycle_slew():
assert encode_model_action(model, 0., 20.) == FordPath(True, .4, 0., 0., 0.)
out = ModelActionController().update(model, .01, speed=20., dt=.002)
assert out.path_offset == pytest.approx(.01)
assert out.path_angle == pytest.approx(.001)
assert out.path_angle == 0.
@@ -16,18 +16,17 @@ 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
from openpilot.cereal import custom
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
from openpilot.selfdrive.controls.lib.ford_path import FordPath
from openpilot.selfdrive.controls.tests.test_ford_model_action import circle, straight
from openpilot.selfdrive.controls.tests.test_ford_model_action_selection import startup
from openpilot.selfdrive.locationd.helpers import Pose, PoseCalibrator
def update(controller, now=1., **overrides):
kwargs = {'model': straight(.4), 'desired_curvature': .01, 'speed': 20., 'yaw_rate': 0., 'now': now,
kwargs = {'model': straight(.4, .1), 'desired_curvature': .01, 'speed': 20., 'yaw_rate': 0., 'now': now,
'model_time': now, 'measurement_time': now, 'reference_time': now, 'active': True}
kwargs.update(overrides)
return controller.update(**kwargs)
@@ -82,7 +81,7 @@ def test_repeated_measurements_do_not_freeze_slew_or_cache_invalid_model_geometr
controller = FordModelActionController()
for i in range(10):
result = update(controller, 1.+i*.01, measurement_time=1., model_time=1., reference_time=1.)
assert result.path_offset == pytest.approx(.14) # Full preview accounts for selected turning toward the offset path.
assert result.path_offset == pytest.approx(.4)
assert result.path_angle == pytest.approx(.05)
broken = straight(.4)
broken.position.y[5] = math.nan
@@ -96,7 +95,7 @@ def test_yaw_offset_does_not_change_the_base():
for i in range(100):
outputs = [update(c, 1.+i*.01, **kwargs) for c, kwargs in zip(controllers, variants, strict=True)]
assert all(out == outputs[0] for out in outputs)
assert outputs[0].path_angle == pytest.approx(.2)
assert outputs[0].path_angle == pytest.approx(.1)
def test_reference_source_can_change_to_an_older_but_fresh_publication():
@@ -105,7 +104,7 @@ def test_reference_source_can_change_to_an_older_but_fresh_publication():
assert update(controller, 1.01, reference_time=.98).valid
def test_release_keeps_current_geometry_and_may_grow_c0_while_c1_decreases():
def test_current_model_geometry_controls_both_fields_independently_of_scalar_action():
for sign in (-1., 1.):
controller = FordModelActionController()
for i in range(100):
@@ -113,11 +112,10 @@ def test_release_keeps_current_geometry_and_may_grow_c0_while_c1_decreases():
for i in range(100):
after = update(controller, 2.+i*.01, model=circle(sign*.02), desired_curvature=sign*.004)
assert abs(after.path_offset) > abs(before.path_offset)
assert abs(after.path_angle) < abs(before.path_angle)
assert abs(after.path_angle) > abs(before.path_angle)
for i in range(100):
released = update(controller, 3.+i*.01, model=circle(sign*.02), desired_curvature=0.)
assert abs(released.path_offset) > abs(after.path_offset) # Less expected turning raises the future-frame offset.
assert released.path_angle == pytest.approx(0.)
assert released == after # A scalar reference change does not fabricate a different model pose.
def _method(filename, class_name, method):
@@ -168,21 +166,17 @@ class Subscriptions:
@pytest.mark.parametrize('maneuver', [False, True])
@pytest.mark.parametrize('host_yaw', [.0072, .3])
@pytest.mark.parametrize('initial_curvature', [0., .005])
@pytest.mark.parametrize('calibrated_yaw', [None, -.05, .05])
def test_actual_controlsd_selection_limiting_publication_and_downstream_can(pipeline, maneuver, host_yaw, initial_curvature, calibrated_yaw):
def test_actual_controlsd_selection_limiting_publication_and_downstream_can(pipeline, maneuver, host_yaw, initial_curvature):
call, publication = pipeline
sm = Subscriptions(maneuver)
controls = startup()
controller = controls.ford_path_controller
if calibrated_yaw is not None:
controls.pose_calibrator.calib_valid = True
controls.calibrated_pose = SimpleNamespace(angular_velocity=SimpleNamespace(z=calibrated_yaw))
initial_curvature *= -1 if maneuver else 1
controls.sm, controls.desired_curvature, controls.curvature = sm, initial_curvature, 0.
if initial_curvature:
# Start at the old target so startup slew cannot hide prediction on the real call path.
controller.core.c0, controller.core.c1 = .4, 20.*initial_curvature
model = straight(.4)
controller.core.c0, controller.core.c1 = .4, .1
model = straight(.4, .1)
model.action = SimpleNamespace(desiredCurvature=.1)
cc = structs.CarControl(latActive=True)
cs = SimpleNamespace(vEgo=20., yawRate=-host_yaw, canValid=True, steeringPressed=False, steeringTorque=0.)
@@ -192,17 +186,16 @@ def test_actual_controlsd_selection_limiting_publication_and_downstream_can(pipe
exec(call, environment)
expected_curvature = initial_curvature+(-1 if maneuver else 1)*.000125
assert controls.desired_curvature == pytest.approx(expected_curvature)
assert controls.ford_path.path_angle == pytest.approx(20.*expected_curvature)
expected_offset = .04
if initial_curvature:
grows = maneuver if calibrated_yaw is None else calibrated_yaw < 0.
expected_offset = .44 if grows else .36
assert controls.ford_path.path_offset == pytest.approx(expected_offset)
assert controller.diagnostics['yaw_rate'] == host_yaw
assert controller.diagnostics['pose_source'] == ('requested' if calibrated_yaw is None else 'measured')
assert controller.diagnostics['pose_yaw_rate'] == calibrated_yaw
assert cc.latActive and cc.actuators.curvature == 0.
assert controller.diagnostics['reference_age'] == pytest.approx(.01 if maneuver else .02)
if maneuver:
assert controls.ford_path == FordPath() and not cc.latActive
assert controller.diagnostics['status'] == 'unsupported_reference'
else:
assert controls.ford_path.path_offset == pytest.approx(.44 if initial_curvature else .04)
assert controls.ford_path.path_angle == pytest.approx(.1 if initial_curvature else .005)
assert controller.diagnostics['yaw_rate'] == host_yaw
assert controller.diagnostics['pose_source'] == 'model'
assert cc.latActive and cc.actuators.curvature == 0.
assert controller.diagnostics['reference_age'] == pytest.approx(.02)
cp = structs.CarParams(flags=int(FordFlags.CANFD), carFingerprint='FORD_F_150_LIGHTNING_MK1')
downstream = CarController({Bus.pt: 'ford_lincoln_base_pt'}, cp, controls.CP_SP)
@@ -226,7 +219,7 @@ def test_actual_controlsd_selection_limiting_publication_and_downstream_can(pipe
assert wire['LatCtlPathOffst_L_Actl'] == pytest.approx(-controls.ford_path.path_offset)
assert wire['LatCtlPath_An_Actl'] == pytest.approx(-controls.ford_path.path_angle)
assert wire['LatCtlCurv_No_Actl'] == wire['LatCtlCrv_NoRate2_Actl'] == 0.
assert wire['LatCtl_D2_Rq'] == (0 if fail else 2)
assert wire['LatCtl_D2_Rq'] == (0 if fail or maneuver else 2)
@pytest.mark.parametrize('maneuver', [False, True])
@@ -246,108 +239,31 @@ def test_actual_controlsd_service_gates(pipeline, maneuver, failed):
assert controls.ford_path.valid == cc.latActive == (failed == 'lateralManeuverPlan' and not maneuver)
@pytest.mark.parametrize('fault', ['missing_pose', 'uncalibrated', 'motion_service', 'calibration_service', 'yaw_invalid',
'sensors_invalid', 'inputs_invalid', 'stale_calibration', 'future_calibration',
'stale_motion', 'future_motion', 'stale_estimate', 'future_estimate', 'missing_estimate',
'nan_yaw', 'infinite_yaw', 'yaw_range'])
def test_actual_controlsd_unhealthy_pose_retains_requested_pose_and_engagement(pipeline, fault):
@pytest.mark.parametrize('service', ['deviceMotion', 'extrinsicsCalibration'])
def test_optional_pose_services_do_not_modify_model_point_requests(pipeline, service):
sm = Subscriptions(False)
sm.failed.add(service)
controls = startup()
controls.sm, controls.desired_curvature, controls.curvature = sm, .01, 0.
controls.pose_calibrator.calib_valid = True
controls.calibrated_pose = SimpleNamespace(angular_velocity=SimpleNamespace(z=.05))
motion = sm.messages['deviceMotion']
if fault == 'missing_pose':
controls.calibrated_pose = None
elif fault == 'uncalibrated':
controls.pose_calibrator.calib_valid = False
elif fault == 'motion_service':
sm.failed.add('deviceMotion')
elif fault == 'calibration_service':
sm.failed.add('extrinsicsCalibration')
elif fault == 'yaw_invalid':
motion.angularVelocityDevice.valid = False
elif fault == 'sensors_invalid':
motion.sensorsOK = False
elif fault == 'inputs_invalid':
motion.inputsOK = False
elif fault == 'stale_calibration':
sm.logMonoTime['extrinsicsCalibration'] = -1_000_000
elif fault == 'future_calibration':
sm.logMonoTime['extrinsicsCalibration'] = 1_006_000_000
elif fault == 'stale_motion':
sm.logMonoTime['deviceMotion'] = 849_000_000
elif fault == 'future_motion':
sm.logMonoTime['deviceMotion'] = 1_006_000_000
elif fault == 'stale_estimate':
motion.timestamp = 849_000_000
elif fault == 'future_estimate':
motion.timestamp = 1_006_000_000
elif fault == 'missing_estimate':
motion.timestamp = 0
elif fault == 'nan_yaw':
controls.calibrated_pose.angular_velocity.z = math.nan
elif fault == 'infinite_yaw':
controls.calibrated_pose.angular_velocity.z = math.inf
elif fault == 'yaw_range':
controls.calibrated_pose.angular_velocity.z = 3.01
controller = controls.ford_path_controller
controller.core.c0, controller.core.c1 = .4, .2
controls.calibrated_pose = None
controls.ford_path_controller.core.c0, controls.ford_path_controller.core.c1 = .4, .1
cc = structs.CarControl(latActive=True)
cs = SimpleNamespace(vEgo=20., yawRate=-.3, canValid=True, steeringPressed=False, steeringTorque=0.)
model = straight(.4)
model = straight(.4, .1)
model.action = SimpleNamespace(desiredCurvature=.01)
exec(pipeline[0], {'FordModelActionController': FordModelActionController, 'self': controls, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': model,
'lp': SimpleNamespace(roll=0.), 'clip_curvature': clip_curvature, 'time': SimpleNamespace(monotonic=lambda: 1.)})
exec(pipeline[0], {'FordModelActionController': FordModelActionController, 'self': controls, 'CS': cs, 'CC': cc,
'actuators': cc.actuators, 'model_v2': model, 'lp': SimpleNamespace(roll=0.),
'clip_curvature': clip_curvature, 'time': SimpleNamespace(monotonic=lambda: 1.)})
assert cc.latActive and controls.ford_path.valid
assert controls.ford_path.path_offset == pytest.approx(.36)
assert controls.ford_path.path_angle == pytest.approx(.195) # Upstream acceleration limiting still applies.
assert controller.diagnostics['pose_source'] == 'requested'
assert controller.diagnostics['pose_yaw_rate'] is None
json.dumps(controller.diagnostics, allow_nan=False)
assert controls.ford_path.path_offset == pytest.approx(.44)
assert controls.ford_path.path_angle == pytest.approx(.1)
assert controls.ford_path_controller.diagnostics['pose_source'] == 'model'
@pytest.mark.parametrize('pose_time', [None, math.nan, math.inf, -math.inf, 'bad', .849, 1.006])
def test_invalid_pose_timestamp_uses_fallback_without_a_reset(pose_time):
def test_maneuver_reference_clears_existing_model_point_requests():
controller = FordModelActionController()
controller.core.c0, controller.core.c1 = .4, .2
out = update(controller, pose_yaw_rate=.05, pose_time=pose_time, pose_valid=True)
assert out.path_offset == pytest.approx(.36)
assert out.path_angle == pytest.approx(.2)
assert controller.diagnostics['pose_source'] == 'requested'
json.dumps(controller.diagnostics, allow_nan=False)
def test_pose_fallback_and_recovery_preserve_slew_states():
controller = FordModelActionController()
controller.core.c0, controller.core.c1 = .4, .2
states = []
for i, healthy in enumerate((True, False, True)):
now = 1.+i*.01
out = update(controller, now, pose_yaw_rate=-.1, pose_time=now, pose_valid=healthy)
states.append(controller.core.c0)
assert out.path_angle == pytest.approx(.2)
assert controller.diagnostics['pose_source'] == ('measured' if healthy else 'requested')
assert states == pytest.approx([.44, .4, .44])
def test_calibration_only_update_refreshes_pose_before_ford_uses_it():
controls = startup()
controls.pose_calibrator = PoseCalibrator()
motion = log.DeviceMotion.new_message()
motion.angularVelocityDevice.x = .5
motion.angularVelocityDevice.z = .1
controls.calibrated_pose = controls.pose_calibrator.build_calibrated_pose(Pose.from_device_motion(motion))
sm = Subscriptions(False)
sm.updated = {'extrinsicsCalibration': True, 'deviceMotion': False}
sm.update = lambda timeout: None
sm.messages.update(deviceMotion=motion, extrinsicsCalibration=log.ExtrinsicsCalibration.new_message(
rpyCalib=[0., .2, 0.], calStatus='calibrated'))
controls.sm = sm
controls_file = Path(__file__).resolve().parents[3]/'selfdrive/controls/controlsd.py'
method = _method(controls_file, 'Controls', 'update')
environment = {'Pose': Pose}
exec(compile(ast.Module(body=[method], type_ignores=[]), str(controls_file), 'exec'), environment)
environment['update'](controls)
assert controls.pose_calibrator.calib_valid
assert controls.calibrated_pose.angular_velocity.z == pytest.approx(math.sin(.2)*.5+math.cos(.2)*.1)
update(controller)
assert update(controller, 1.01, reference_source='lateralManeuverPlan') == FordPath()
assert controller.diagnostics['status'] == 'unsupported_reference'
assert (controller.core.c0, controller.core.c1) == (0., 0.)
assert update(controller, 1.02).path_angle == pytest.approx(.005)
@@ -88,7 +88,7 @@ def test_core_slew_per_second_and_actual_panda_acceptance():
safety.set_controls_allowed(True)
frames = []
for frame in range(100):
command = core.update(straight(10.), .1, speed=20., dt=.01, pose_yaw_rate=0.)
command = core.update(straight(10., 1.), .1, speed=20., dt=.01)
assert core.c0 == pytest.approx((frame + 1) * .04)
assert core.c1 == pytest.approx((frame + 1) * .005)
sp.fordLateralPath.valid = command.valid
@@ -1,89 +0,0 @@
import math
from types import SimpleNamespace
import numpy as np
import pytest
from openpilot.selfdrive.controls.lib.ford_model_action import ModelActionController, encode_model_action
from openpilot.selfdrive.controls.lib.ford_path import _model_path
def circle(k, length=70.0):
s = np.linspace(0.0, length, 1401)
if k:
x, y = np.sin(k * s) / k, (1 - np.cos(k * s)) / k
else:
x, y = s, np.zeros_like(s)
return SimpleNamespace(position=SimpleNamespace(x=x, y=y), orientation=SimpleNamespace(z=k * s))
@pytest.mark.parametrize('speed', [0.3, 4.0, 15.0, 30.0, 55.0])
@pytest.mark.parametrize('curvature', [-0.08, -0.005, 0.0, 0.005, 0.08])
def test_measured_pose_equals_requested_pose_when_tracking(speed, curvature):
m = circle(curvature)
# Values over the measurement's health range fall back to the same baseline.
assert encode_model_action(m, curvature, speed, pose_yaw_rate=speed * curvature) == encode_model_action(m, curvature, speed)
@pytest.mark.parametrize('measured_curvature', [-0.02, 0.0, 0.004, 0.008, 0.02])
@pytest.mark.parametrize('speed', [4.0, 15.0, 30.0])
def test_offset_is_exact_rigid_transform_using_measured_pose(speed, measured_curvature):
m = circle(0.008)
out = encode_model_action(m, 0.008, speed, pose_yaw_rate=speed * measured_curvature)
s, x, y, _ = _model_path(m)
d = min(speed * 0.15, max(0.0, s[-1] - 7.0))
angle = measured_curvature * d
vehicle_x = math.sin(angle) / measured_curvature if measured_curvature else d
vehicle_y = (1 - math.cos(angle)) / measured_curvature if measured_curvature else 0.0
target_x = np.interp(7.0 + d, s, x)
target_y = np.interp(7.0 + d, s, y)
expected = -math.sin(angle) * (target_x - vehicle_x) + math.cos(angle) * (target_y - vehicle_y)
assert out.path_offset == pytest.approx(expected, abs=1e-12)
assert out.path_angle == encode_model_action(m, 0.008, speed).path_angle
@pytest.mark.parametrize('invalid', [None, float('nan'), float('inf'), -float('inf'), 3.01, -3.01])
def test_unavailable_measurement_retains_baseline_target(invalid):
m = circle(0.01)
assert encode_model_action(m, 0.01, 15.0, pose_yaw_rate=invalid) == encode_model_action(m, 0.01, 15.0)
def test_undertracking_adds_offset_and_overtracking_releases_offset():
m = circle(0.01)
base = encode_model_action(m, 0.01, 15.0)
under = encode_model_action(m, 0.01, 15.0, pose_yaw_rate=0.10)
over = encode_model_action(m, 0.01, 15.0, pose_yaw_rate=0.20)
assert under.path_offset > base.path_offset > over.path_offset
assert under.path_angle == base.path_angle == over.path_angle
def test_two_state_core_keeps_caps_slew_and_reset():
c = ModelActionController()
old = (0.0, 0.0)
for _ in range(200):
out = c.update(circle(0.2), 0.2, speed=20.0, dt=0.01, pose_yaw_rate=0.1)
assert out.valid and out.curvature == out.curvature_rate == 0.0
assert abs(c.c0 - old[0]) <= 0.0400000001
assert abs(c.c1 - old[1]) <= 0.0050000001
assert abs(out.path_offset) <= 5.11 and abs(out.path_angle) <= 0.5
old = c.c0, c.c1
assert c.__slots__ == ('c0', 'c1')
assert not c.update(circle(0.01), 0.01, speed=15.0, dt=0.01, active=False, pose_yaw_rate=0.1).valid
assert (c.c0, c.c1) == (0.0, 0.0)
def test_measurement_fallback_and_recovery_keep_slew_and_selected_c1():
c = ModelActionController()
original = ModelActionController()
old = (0.0, 0.0)
for index in range(180):
curvature = 0.02 if index < 90 else -0.015
measured = 0.05 if index < 50 else None if index < 100 else -0.03
model = circle(curvature)
out = c.update(model, curvature, speed=15.0, dt=0.01, pose_yaw_rate=measured)
ref = original.update(model, curvature, speed=15.0, dt=0.01)
assert out.path_angle == ref.path_angle
assert abs(c.c0 - old[0]) <= 0.0400000001
assert abs(c.c1 - old[1]) <= 0.0050000001
assert out.curvature == out.curvature_rate == 0.0
old = c.c0, c.c1
@@ -1,89 +0,0 @@
"""Geometric prediction checks; these do not simulate the Ford steering plant."""
import math
import numpy as np
import pytest
from openpilot.selfdrive.controls.lib.ford_model_action import encode_model_action
from openpilot.selfdrive.controls.tests.test_ford_model_action import circle, make_model, straight
def geometric_offset(model):
station = np.r_[0., np.cumsum(np.hypot(np.diff(model.position.x), np.diff(model.position.y)))]
return float(np.interp(7., station, model.position.y))
@pytest.mark.parametrize('sign', [-1., 1.])
def test_developing_bend_uses_full_prediction_beyond_former_cap(sign):
x = np.linspace(0., 30., 3001)
model = make_model(x, sign*.001*x**3, np.zeros_like(x))
base = geometric_offset(model)
target = encode_model_action(model, 0., 10.)
assert sign*target.path_offset > sign*base+.05
station = np.r_[0., np.cumsum(np.hypot(np.diff(x), np.diff(model.position.y)))]
assert target.path_offset == pytest.approx(np.interp(8.5, station, model.position.y))
assert abs(target.path_offset-base) > .25*abs(base)
assert target.path_angle == target.curvature == target.curvature_rate == 0.
@pytest.mark.parametrize('sign', [-1., 1.])
def test_flattening_bend_reduces_offset_before_the_near_path_disappears(sign):
station = np.linspace(0., 30., 3001)
heading = sign*.01*np.minimum(station, 5.)
ds = station[1]-station[0]
x = np.r_[0., np.cumsum(np.cos((heading[:-1]+heading[1:])/2)*ds)]
y = np.r_[0., np.cumsum(np.sin((heading[:-1]+heading[1:])/2)*ds)]
model = make_model(x, y, heading)
base = geometric_offset(model)
target = encode_model_action(model, sign*.01, 20.)
assert 0. < sign*target.path_offset < sign*base-.02
assert target.path_angle == pytest.approx(sign*.2)
@pytest.mark.parametrize('speed', [.3, 5., 20., 55.])
@pytest.mark.parametrize('curvature', [-.05, -.01, .01, .05])
def test_matched_constant_circle_is_not_given_a_blanket_gain_increase(speed, curvature):
model = circle(curvature)
assert encode_model_action(model, curvature, speed).path_offset == pytest.approx(geometric_offset(model), abs=1e-4)
@pytest.mark.parametrize('offset', [-.4, 0., .4])
@pytest.mark.parametrize('curvature', [-1e-300, 0., 1e-300])
def test_straight_centering_and_near_zero_curvature_remain_well_conditioned(offset, curvature):
for speed in (.3, 20., 55.):
assert encode_model_action(straight(offset), curvature, speed).path_offset == pytest.approx(offset, abs=1e-12)
@pytest.mark.parametrize('offset', [-4., -.4, -.001, 0., .001, .4, 4.])
def test_full_rotated_path_prediction_is_independent_of_base_offset_magnitude(offset):
station = np.linspace(0., 30., 301)
for heading in (-.3, .3):
model = make_model(station*np.cos(heading), offset+station*np.sin(heading), np.full_like(station, heading))
value = encode_model_action(model, 0., 20.).path_offset
assert value == pytest.approx(offset+10.*math.sin(heading))
assert abs(value-geometric_offset(model)) > .15
@pytest.mark.parametrize('sign', [-1., 1.])
def test_zero_near_offset_can_request_opposing_centering_from_the_predicted_pose(sign):
curvature = sign*.01
target = encode_model_action(straight(), curvature, 20.)
expected = (1.-math.cos(curvature*3.))/curvature-10.*math.sin(curvature*3.)
assert target.path_offset == pytest.approx(expected)
assert sign*target.path_offset < -.25
assert target.path_angle == pytest.approx(sign*.2)
def test_short_horizon_holds_endpoint_and_available_prediction_tapers_to_zero():
for length in (1., 6.99, 7.):
assert encode_model_action(make_model([0., length], [.4, .4], [0., 0.]), .01, 20.).path_offset == .4
near = encode_model_action(make_model([0., 7.000001], [.4, .4], [0., 0.]), .01, 20.).path_offset
assert abs(near-.4) < 1e-6
def test_unrepresentable_predicted_geometry_keeps_the_valid_current_offset():
large = 1.79e308
model = make_model([-large, np.nextafter(-large, 0.)], [large, large], [0., 0.])
target = encode_model_action(model, .3, 20.)
assert target.valid and math.isfinite(target.path_offset)
assert target.path_offset == large
@@ -0,0 +1,54 @@
"""Model geometry contract, independent of any PSCM response model."""
from types import SimpleNamespace
import numpy as np
import pytest
from openpilot.selfdrive.controls.lib.ford_model_action import encode_model_action
def model_points(t, x, y, heading):
return SimpleNamespace(position=SimpleNamespace(t=t, x=x, y=y), orientation=SimpleNamespace(t=t, z=heading))
def test_both_fields_sample_the_same_model_time_without_constant_speed_assumption():
# Accelerating plan: one second is 12 m along this straight inclined path.
s = np.array([0., 4., 12., 30.])
heading = .1
m = model_points([0., .5, 1., 2.], s*np.cos(heading), .3+s*np.sin(heading), np.full(4, heading))
out = encode_model_action(m, -.01, 20.)
assert out.path_offset == pytest.approx(.3+12*np.sin(heading))
assert out.path_angle == pytest.approx(.1)
assert out.curvature == out.curvature_rate == 0.
assert encode_model_action(m, .01, 30.) == out
def test_low_speed_floor_uses_one_shared_seven_metre_station():
m = model_points([0., 1., 2.], [0., 3., 9.], [0., 0., 0.], [0., .03, .09])
out = encode_model_action(m, .01, 3.)
assert out.path_offset == 0.
assert out.path_angle == pytest.approx(.07)
def test_short_plan_holds_both_endpoint_values_without_extrapolation():
m = model_points([0., .5], [0., 2.], [0., .4], [0., .2])
out = encode_model_action(m, -.01, 20.)
assert out.path_offset == .4
assert out.path_angle == pytest.approx(.2)
@pytest.mark.parametrize('times', [[], [0.], [0., 0., 1.], [0., 1., .5], [0., float('nan'), 1.], [.1, .5, 1.]])
def test_invalid_model_clock_cannot_publish_an_active_path(times):
m = model_points(times, [0., 10., 20.], [0., .1, .4], [0., .02, .04])
assert not encode_model_action(m, .01, 20.).valid
def test_orientation_and_position_must_describe_the_same_times():
m = model_points([0., .5, 1.], [0., 10., 20.], [0., .1, .4], [0., .02, .04])
m.orientation.t = [0., .6, 1.]
assert not encode_model_action(m, .01, 20.).valid
def test_model_heading_unwraps_before_interpolation():
m = model_points([0., .5, 1.5], [0., 10., 30.], [0., 0., 0.], [3., 3.1, -3.1])
assert encode_model_action(m, 0., 20.).path_angle == pytest.approx(np.pi)
@@ -2183,8 +2183,8 @@
"widget": "toggle",
"needs_onroad_cycle": true,
"title": "Selected-Action Path Tracking (Experimental)",
"description": "Follow the selected steering plan with nearby model-path centering on the Ford CAN FD F-150 Lightning.",
"details": "Uses measured vehicle motion to predict the nearby model path, plus a heading request based on selected planned curvature. If the motion measurement is unavailable, prediction uses the requested turn rate. The existing command limits and rate limits remain in effect. Default off; this revised turn-entry and exit behavior is not road-validated. Enable only for controlled testing. On the Ford CAN FD F-150 Lightning this takes priority over PSCM Coefficient Observer; other vehicles retain their existing controller. Turning it off restores PSCM Coefficient Observer if selected, otherwise the original Ford path controller. Changes apply after a real offroad-to-onroad cycle, not immediately or on disengagement alone.",
"description": "Follow model position and heading from one shared path point on the Ford CAN FD F-150 Lightning.",
"details": "Uses the model's lateral position and heading one second ahead, with a seven-metre minimum distance and no extrapolation beyond the available path. Sends requests at 20 Hz with the existing command limits and rate limits. Default off; this model-point experiment is not road-validated. Enable only for controlled testing. Lateral maneuver test mode is unsupported and disengages this controller. On the Ford CAN FD F-150 Lightning this takes priority over PSCM Coefficient Observer; other vehicles retain their existing controller. Turning it off restores PSCM Coefficient Observer if selected, otherwise the original Ford path controller. Changes apply after a real offroad-to-onroad cycle, not immediately or on disengagement alone.",
"enablement": [
{
"type": "offroad_only"
@@ -14,8 +14,8 @@ sections:
widget: toggle
needs_onroad_cycle: true
title: Selected-Action Path Tracking (Experimental)
description: Follow the selected steering plan with nearby model-path centering on the Ford CAN FD F-150 Lightning.
details: Uses measured vehicle motion to predict the nearby model path, plus a heading request based on selected planned curvature. If the motion measurement is unavailable, prediction uses the requested turn rate. The existing command limits and rate limits remain in effect. Default off; this revised turn-entry and exit behavior is not road-validated. Enable only for controlled testing. On the Ford CAN FD F-150 Lightning this takes priority over PSCM Coefficient Observer; other vehicles retain their existing controller. Turning it off restores PSCM Coefficient Observer if selected, otherwise the original Ford path controller. Changes apply after a real offroad-to-onroad cycle, not immediately or on disengagement alone.
description: Follow model position and heading from one shared path point on the Ford CAN FD F-150 Lightning.
details: Uses the model's lateral position and heading one second ahead, with a seven-metre minimum distance and no extrapolation beyond the available path. Sends requests at 20 Hz with the existing command limits and rate limits. Default off; this model-point experiment is not road-validated. Enable only for controlled testing. Lateral maneuver test mode is unsupported and disengages this controller. On the Ford CAN FD F-150 Lightning this takes priority over PSCM Coefficient Observer; other vehicles retain their existing controller. Turning it off restores PSCM Coefficient Observer if selected, otherwise the original Ford path controller. Changes apply after a real offroad-to-onroad cycle, not immediately or on disengagement alone.
enablement:
- $ref: '#/macros/offroad'
- key: FordPscmObserver
+11 -19
View File
@@ -1,8 +1,8 @@
"""Deterministic numerical stress and exhaustive field-boundary CAN checks.
Analytic straight/rotated paths and an independent rigid-motion transform
check geometric prediction. The reference slew uses scalar arithmetic. Packing is checked against direct
Float32/CAN packing of the continuous state, independently of host _packed.
Analytic inclined lines check the shared model point; the slew oracle uses
scalar arithmetic. Packing is checked against direct Float32/CAN packing of the
continuous state, independently of host _packed.
No synthetic plant is fitted or used to claim vehicle tracking performance.
"""
import argparse
@@ -21,10 +21,10 @@ from opendbc.car.ford.fordcan import create_lat_ctl2_msg
from tools.ford_pscm_lab.model_action_replay import PINNED_OPENDBC, WireCheck, verify_dependency, revision
def line(offset, heading=0.):
def line(offset, heading=0., speed=20.):
s = np.linspace(0., 30., 33)
return SimpleNamespace(position=SimpleNamespace(x=s*math.cos(heading), y=offset+s*math.sin(heading)),
orientation=SimpleNamespace(z=np.full_like(s, heading)))
return SimpleNamespace(position=SimpleNamespace(t=s/speed, x=s*math.cos(heading), y=offset+s*math.sin(heading)),
orientation=SimpleNamespace(t=s/speed, z=np.full_like(s, heading)))
def check_raw_packing(wire, controller, path):
@@ -62,7 +62,7 @@ def run(cycles, seed, output, opendbc_revision=PINNED_OPENDBC):
valid = i % 211 != 0
if i % 307 == 0:
dt = .101
model, mirror = line(offset, heading), line(-offset, -heading)
model, mirror = line(offset, heading, speed), line(-offset, -heading, speed)
if i % 401 == 0:
model.position.y[4] = mirror.position.y[4] = math.nan
out = controller.update(model, desired, speed=speed, dt=dt, yaw_rate=yaw, active=active, valid=valid)
@@ -71,16 +71,8 @@ def run(cycles, seed, output, opendbc_revision=PINNED_OPENDBC):
assert out.valid == other.valid == expected_valid
previous = np.array([c0, c1])
if expected_valid:
distance, rotation = speed*.15, desired*speed*.15
# Independent full pose transform, including stable small-angle series.
if abs(rotation) < 1e-5:
ego_x = distance*(1.-rotation**2/6.+rotation**4/120.)
ego_y = distance*(rotation/2.-rotation**3/24.+rotation**5/720.)
else:
ego_x, ego_y = math.sin(rotation)/desired, (1.-math.cos(rotation))/desired
future_x, future_y = (7.+distance)*math.cos(heading), offset+(7.+distance)*math.sin(heading)
predicted = math.cos(rotation)*(future_y-ego_y)-math.sin(rotation)*(future_x-ego_x)
target = (max(-5.11, min(5.11, predicted)), max(-.5, min(.5, max(7., speed)*desired)))
station = min(30., max(7., speed))
target = (max(-5.11, min(5.11, offset+station*math.sin(heading))), max(-.5, min(.5, heading)))
# Independent scalar slew oracle. Valid measured yaw cannot alter demand.
c0 += max(-4.*dt, min(4.*dt, target[0]-c0))
c1 += max(-.5*dt, min(.5*dt, target[1]-c1))
@@ -110,14 +102,14 @@ def run(cycles, seed, output, opendbc_revision=PINNED_OPENDBC):
selected = float(np.clip(scalar, low, high))
offset, heading = (selected, 0.) if field == 0 else (0., selected)
controller.c0, controller.c1 = offset, heading
out = controller.update(line(offset), heading/20., speed=20., dt=.01)
out = controller.update(line(offset-20.*math.sin(heading), heading), heading/20., speed=20., dt=.01)
check_raw_packing(wire, controller, out)
boundary_cases += 1
report = {'seed': seed, 'random_cycles': cycles, 'mirrored_core_updates': cycles,
'invalid_or_inactive_resets': resets, 'field_boundary_cases': boundary_cases,
'float32_can_round_trips': wire.count, 'analytic_targets_scalar_slew_and_mirror_checks_pass': True,
'valid_yaw_does_not_affect_targets_checked': True,
'full_geometric_prediction_checked': True,
'shared_model_point_checked': True,
'direct_raw_float32_packing_matches_host_output': True, 'max_continuous_step_c0_c1': max_continuous_step.tolist(),
'calibration_approved': False, 'scope': 'Numerical construction only; no PSCM response or closed-loop performance claims.',
'opendbc_import_head': revision(dependency),