Ford: simplify C1 feedback with continuous PI unwind

This commit is contained in:
Isaac Barham
2026-09-13 12:30:22 -04:00
parent 5e6993aabb
commit 08b3a14ad4
17 changed files with 798 additions and 574 deletions
+82
View File
@@ -0,0 +1,82 @@
# Ford continuous PI drive trial (v7)
The selected controller follows the current, upstream-limited desired curvature
with P=0.50 and I=0.25. It replaces v6's request-change, C0-confirmation and
unwind-catchup release rules. C0 mapping, heading overflow allocation, input
health gates, driver/PSCM arbitration and final output limits remain unchanged.
Only C0, C1 and I carry control history. C2/C3 stay zero.
```text
D = max(7 m, speed × 1 s)
error = selected desired curvature measured steering-derived curvature
base = clip(D × desired curvature, 0.5, +0.5)
P = 0.50 × D × error
increment = 0.25 × speed × error × fresh steering measurement interval
C1 target = clip(base + P + I, 0.5, +0.5)
```
After PSCM outward-accumulation arbitration, the increment first cancels up to
its own magnitude of opposing I. It cannot cross zero in that step. Any remainder
is bounded by the combined command's amplitude/slew headroom. C1 output still
slews at 0.5 rad/s. This allows old I to unwind even when the output is already
slewing. It adds no timer, request history, turn detection or position threshold.
P doubles relative to v6, and I builds at one quarter the previous rate for an
identical error and fresh measurement interval. Lower I also unwinds more slowly
for an identical existing state and error; its replay benefit comes mainly from
storing less correction. Zero error removes P and holds I. Persistent tracking
bias may need that holding correction. There is no claim that all I is unwanted.
## Evidence and limitations
Six offline settings were compared across fourteen routes and 1,578,250 source
cycles before selecting this candidate. The production selector and adapter now
exactly reproduce the selected P=0.50/I=0.25 archived commands, validity, P, I,
feedforward, C0 overflow and feedback/PSCM gates on every cycle of all fourteen
routes. These comprise Lightning 112117, a0, a2, a5, a9, b8, b9, ca and Raptor 02.
The integration replay performs 1,578,250 actual Float32/CAN round trips.
A further 20,000 randomized cycles with mirrored and independent C0 paths perform
60,000 CAN checks on production, checking independent scalar arithmetic,
C0-independent feedback, symmetry, reset, amplitude and slew behavior.
The Ford, Sunnylink, params, sender and safety suite passes 679 tests and
9,145 subtests; 178 are skipped by the platform test suite. Removed maneuver
heuristic tests are replaced with continuous-error, cancellation, freshness,
three-state reproduction and actual controlsd-to-CAN entry/exit checks.
Historical untracked offline experiment tests are outside this deployment suite.
At a previously reviewed route-115 exit (133.595 s), the original small PI
controller requested +0.1090 rad C1; this trial requests +0.0175. That sample
includes driver context. Reviewed large entries remain similar, but commands
are not identical everywhere. In a previously well-tracked route-116 bend
(112 s), C1 falls from +0.1530 to +0.1355 rad. Lower I could weaken a persistent
bend, while higher P can increase response to measurement fluctuations.
Recorded wheel motion remains fixed in replay. These checks establish software
behavior and exact integration of the candidate; they do not establish improved
physical tracking or stability. Gains remain experimental, not an identified
universal PSCM calibration. No device build, boot or new drive is claimed.
## Selection and reproduction
Use the existing default-off Sunnylink **Selected-Action Path Tracking
(Experimental)** toggle on any Ford CAN FD, followed by a real offroad-to-onroad
cycle. Logs identify `model-action-c1-pi-v7`, `proportional_gain=0.5`,
`integral_gain=0.25`. Toggle-off selects upstream Ford control. See the
[drive instructions](ford_model_action_drive_test.md).
With the built cereal/opendbc environment and archived local extracts:
```sh
export PYTHONPATH=.:opendbc_repo:.cache/ford_v6/test_deps
export PYTHONDONTWRITEBYTECODE=1
export PARAMS_ROOT=/tmp/ford-v7-params
export LOG_ROOT=/tmp/ford-v7-logs
python -m tools.ford_pscm_lab.minimal_pi_validate --output .cache/ford_minimal_tuning/production --workers 4
python -m tools.ford_pscm_lab.minimal_pi_production_stress --cycles 20000 --output .cache/ford_minimal_tuning/production_stress.json
```
The validation JSON records route and source hashes. The archived six-setting
sweep is local evidence, not a checked-in dataset. Historical v5/v6 lab tools
load their pinned controller revisions so their baseline comparisons retain
their original meaning after production changes.
+332
View File
@@ -0,0 +1,332 @@
{
"scope": "Production integration of the selected continuous PI drive trial; fixed recorded motion, not a physical tracking prediction.",
"parent_revision": "5e6993aab",
"previous_controller_revision": "bf00bc691def830e1beb15363d05416714c1dc42",
"hypothesis": "model-action-c1-pi-v7",
"kp": 0.5,
"ki": 0.25,
"control_history": [
"c0",
"c1",
"correction"
],
"physical_lines": {
"module": 201,
"core_class": 58,
"core_update": 42
},
"production_source_sha256": "a899b8595e903d6fc4401392a8d123ef77725f6d6d7fa10392a7b3fe72e732b8",
"route_cycles": 1578250,
"production_can_round_trips": 1638250,
"tests": {
"passed": 679,
"skipped": 178,
"subtests_passed": 9145,
"log_sha256": "2bc3dfd7ba08d4985d3dea98f235eb1fc99b23e5bbb3927e823a2439c8e9a481",
"scope": "Ford controls, tracked PSCM lab tests, PSCM status, Sunnylink, params, Ford car and safety suites; historical untracked experiments excluded."
},
"ruff": "pass",
"ty_production": "pass",
"routes": [
{
"scope": "Verify production commands against the archived P=.50/I=.25 offline candidate.\n\nFixed recorded motion verifies integration parity, not physical tracking.\n",
"route": "112",
"cycles": 108971,
"can_round_trips": 108971,
"production_matches_archived_trial_exactly": true,
"opendbc_revision": "64aa61b9b3fd26e70a7caa915acab207ff3cd64a",
"hypothesis": "model-action-c1-pi-v7",
"kp": 0.5,
"ki": 0.25,
"source_sha256": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route112/route.npz": "2b08a2fb636f7d14556d7df4035eafc1d1b97932237955528562a16db2b31d3e",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route112/model_paths.npz": "9837afe78aab4cad288cad98a595a5777fa8a66bb235986b1272a7f7c54e559a",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route112/metadata.json": "726d78a7e7aa45307dcfe27cb00775c20ecc9d54538eb7f26a0166fc216226ce",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/112/report.json": "d8cbcf8b676a67a90310390611da31783b4e025a0a965239ef4672caa47aae56",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/112/commands.npz": "8d91f449c7f8741ce465ec4326949a9d9527ed6b5de7a526ad7d30212f16c469",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/minimal_pi_validate.py": "35b5334f52eb6069b79fc3ed3be71ceaa81a3db8e1f76e8b1ab6e363cd4f18ae",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "a899b8595e903d6fc4401392a8d123ef77725f6d6d7fa10392a7b3fe72e732b8"
}
},
{
"scope": "Verify production commands against the archived P=.50/I=.25 offline candidate.\n\nFixed recorded motion verifies integration parity, not physical tracking.\n",
"route": "113",
"cycles": 49614,
"can_round_trips": 49614,
"production_matches_archived_trial_exactly": true,
"opendbc_revision": "64aa61b9b3fd26e70a7caa915acab207ff3cd64a",
"hypothesis": "model-action-c1-pi-v7",
"kp": 0.5,
"ki": 0.25,
"source_sha256": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route113/route.npz": "774ca4a21b7113c2706d6130bc180c3216ea4833155300ab01e75b3486e36327",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route113/model_paths.npz": "93c41761eb85263f534f5371b905482cf7c948582eb1e9149966594be1d3768f",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route113/metadata.json": "1c0ca74dd48b90ab9d5444c5ca7f8aa9361700bbdf98bd5e853be50ad2895d7f",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/113/report.json": "e2dfeba5d3fe8a1147f82bce9ddf2ad3f14271bda35fac1639d383f2a0189228",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/113/commands.npz": "3890c802072dd2d38b4cded5f9f80310d1f86eb8a8f3598138235a0e2cc16460",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/minimal_pi_validate.py": "35b5334f52eb6069b79fc3ed3be71ceaa81a3db8e1f76e8b1ab6e363cd4f18ae",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "a899b8595e903d6fc4401392a8d123ef77725f6d6d7fa10392a7b3fe72e732b8"
}
},
{
"scope": "Verify production commands against the archived P=.50/I=.25 offline candidate.\n\nFixed recorded motion verifies integration parity, not physical tracking.\n",
"route": "114",
"cycles": 61027,
"can_round_trips": 61027,
"production_matches_archived_trial_exactly": true,
"opendbc_revision": "64aa61b9b3fd26e70a7caa915acab207ff3cd64a",
"hypothesis": "model-action-c1-pi-v7",
"kp": 0.5,
"ki": 0.25,
"source_sha256": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route114/route.npz": "83524f07d61104b84b004ddc46bb751ca7f61da67798aa47db56d307765f5b3e",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route114/model_paths.npz": "14d14362d1a3e46398edd8e22b7cc4e36277a7596a73f546192d0e14c6642b07",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route114/metadata.json": "ec677b9275c1707e477ebd0ffa235d49b5ec63a1ee717b16040c3acdbcd3bdc0",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/114/report.json": "eb630bb5f35a43c8902e15a97b53d83db3dc3cfa843a14d09cb8a1e6dab89289",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/114/commands.npz": "899033d0c9eeffe7929aa1289bfbc0e7da5608833b03989ce10a87d975344d3d",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/minimal_pi_validate.py": "35b5334f52eb6069b79fc3ed3be71ceaa81a3db8e1f76e8b1ab6e363cd4f18ae",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "a899b8595e903d6fc4401392a8d123ef77725f6d6d7fa10392a7b3fe72e732b8"
}
},
{
"scope": "Verify production commands against the archived P=.50/I=.25 offline candidate.\n\nFixed recorded motion verifies integration parity, not physical tracking.\n",
"route": "115",
"cycles": 40037,
"can_round_trips": 40037,
"production_matches_archived_trial_exactly": true,
"opendbc_revision": "64aa61b9b3fd26e70a7caa915acab207ff3cd64a",
"hypothesis": "model-action-c1-pi-v7",
"kp": 0.5,
"ki": 0.25,
"source_sha256": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route115/route.npz": "e0df32d9e80f1c6b7d56327b37cf07af60070ffc212b8d111e343306148bff23",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route115/model_paths.npz": "52f3ed9e188e4947618a57a3ed872fd1966a887fe1014999df741553b6c13bc0",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route115/metadata.json": "d3c73035bad8eb5059e07962fd274c19cb70c8952b6f1514835d312d08b87a16",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/115/report.json": "58cee499da3191808aedbd79a9497d9c1553f49007f24543c44bc2b58ff971fa",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/115/commands.npz": "09c2da33dabbc0072c61578224cd5ab27ed44690394315350c1c33708b0a67bc",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/minimal_pi_validate.py": "35b5334f52eb6069b79fc3ed3be71ceaa81a3db8e1f76e8b1ab6e363cd4f18ae",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "a899b8595e903d6fc4401392a8d123ef77725f6d6d7fa10392a7b3fe72e732b8"
}
},
{
"scope": "Verify production commands against the archived P=.50/I=.25 offline candidate.\n\nFixed recorded motion verifies integration parity, not physical tracking.\n",
"route": "116",
"cycles": 68793,
"can_round_trips": 68793,
"production_matches_archived_trial_exactly": true,
"opendbc_revision": "64aa61b9b3fd26e70a7caa915acab207ff3cd64a",
"hypothesis": "model-action-c1-pi-v7",
"kp": 0.5,
"ki": 0.25,
"source_sha256": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route116/route.npz": "13df1d49edd0c92bd4a4236c766ee76baa6dad477824b9de4b79927c98410394",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route116/model_paths.npz": "85d1112b5e89340e8a955b7fe4040b3135cf1c91a54ff756826c6119cc9cc01d",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route116/metadata.json": "d52fe63d013fa546524ad9522a26943d9372730d397d92d75dfd9d75c63ce766",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/116/report.json": "01d2e608a53f9fd805baee853d18acc63b5506319e0f0ceeeb3e59a92bae0385",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/116/commands.npz": "2186a1a1fea2ea98390fb90c9df1288d321cd165f70e3aef0c268cbbd2fd8c95",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/minimal_pi_validate.py": "35b5334f52eb6069b79fc3ed3be71ceaa81a3db8e1f76e8b1ab6e363cd4f18ae",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "a899b8595e903d6fc4401392a8d123ef77725f6d6d7fa10392a7b3fe72e732b8"
}
},
{
"scope": "Verify production commands against the archived P=.50/I=.25 offline candidate.\n\nFixed recorded motion verifies integration parity, not physical tracking.\n",
"route": "117",
"cycles": 27301,
"can_round_trips": 27301,
"production_matches_archived_trial_exactly": true,
"opendbc_revision": "64aa61b9b3fd26e70a7caa915acab207ff3cd64a",
"hypothesis": "model-action-c1-pi-v7",
"kp": 0.5,
"ki": 0.25,
"source_sha256": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route117/route.npz": "65e4ea6c76dce73018b636479b3777116f1e58bb9813e6c1a7b79018b219140e",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route117/model_paths.npz": "079f6003968287530e1ed6f2243c727e80c2ef3d16fb8a7d945d7eca744d54e7",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_route117/metadata.json": "348396b3059c5f54d7938cd3acf2bb268a813b47ff47561da7a94cb75c3f3114",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/117/report.json": "80efcf48f018d3a887e8b9fdb36f67d9c5d9a57b09f117c8fdecd4d5556ff80b",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/117/commands.npz": "c2056b5d9faf765cb4713f73e9c4937bb24c9eeb1d4dac0d21e141322d52609e",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/minimal_pi_validate.py": "35b5334f52eb6069b79fc3ed3be71ceaa81a3db8e1f76e8b1ab6e363cd4f18ae",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "a899b8595e903d6fc4401392a8d123ef77725f6d6d7fa10392a7b3fe72e732b8"
}
},
{
"scope": "Verify production commands against the archived P=.50/I=.25 offline candidate.\n\nFixed recorded motion verifies integration parity, not physical tracking.\n",
"route": "a0",
"cycles": 114582,
"can_round_trips": 114582,
"production_matches_archived_trial_exactly": true,
"opendbc_revision": "64aa61b9b3fd26e70a7caa915acab207ff3cd64a",
"hypothesis": "model-action-c1-pi-v7",
"kp": 0.5,
"ki": 0.25,
"source_sha256": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routea0/route.npz": "01ce8b0361c3154d07d5449525a650baa2cc261675095cb1f04ec9e37a9174d3",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routea0/model_paths.npz": "5d0c69d10a6d13882b47d950ae64c4d98f7ca2b59eab27e4e9557eef3bc4917b",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routea0/metadata.json": "00496a11afbee475e9b5d66838a4d1663a3c0063cd562b4d1408cce6d71e586e",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/a0/report.json": "5f3ca84b700e019cc72201d9e4ff85c42fbd15e9a3850434caf1bac688b332d8",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/a0/commands.npz": "5c52d587ba45446117677686aabc6af81d5d2eacab5c04b02b53fe6fb95e36d0",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/minimal_pi_validate.py": "35b5334f52eb6069b79fc3ed3be71ceaa81a3db8e1f76e8b1ab6e363cd4f18ae",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "a899b8595e903d6fc4401392a8d123ef77725f6d6d7fa10392a7b3fe72e732b8"
}
},
{
"scope": "Verify production commands against the archived P=.50/I=.25 offline candidate.\n\nFixed recorded motion verifies integration parity, not physical tracking.\n",
"route": "a2",
"cycles": 71111,
"can_round_trips": 71111,
"production_matches_archived_trial_exactly": true,
"opendbc_revision": "64aa61b9b3fd26e70a7caa915acab207ff3cd64a",
"hypothesis": "model-action-c1-pi-v7",
"kp": 0.5,
"ki": 0.25,
"source_sha256": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routea2/route.npz": "af4128d278be6be0042f72a02255ddbec96a8a5aa435b9852c8202e4a5cb39e5",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routea2/model_paths.npz": "5a5a7c4fc30c7b3bc7f2413daa8b11fe86b69dd3249e8df2d35b0a0636a49717",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routea2/metadata.json": "cfecd240216e409a1e2591c6c2e6f0bad002ecd2a50c8f6dbf22a8f9174708da",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/a2/report.json": "de61021f0c13af16e98688b17aadae9d61c99d20be82329b7cc235836defa41a",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/a2/commands.npz": "fd263ab667b97905d840751577179ad75472421104cf20663e3ee51bf32dd2ee",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/minimal_pi_validate.py": "35b5334f52eb6069b79fc3ed3be71ceaa81a3db8e1f76e8b1ab6e363cd4f18ae",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "a899b8595e903d6fc4401392a8d123ef77725f6d6d7fa10392a7b3fe72e732b8"
}
},
{
"scope": "Verify production commands against the archived P=.50/I=.25 offline candidate.\n\nFixed recorded motion verifies integration parity, not physical tracking.\n",
"route": "a5",
"cycles": 38961,
"can_round_trips": 38961,
"production_matches_archived_trial_exactly": true,
"opendbc_revision": "64aa61b9b3fd26e70a7caa915acab207ff3cd64a",
"hypothesis": "model-action-c1-pi-v7",
"kp": 0.5,
"ki": 0.25,
"source_sha256": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routea5/route.npz": "815d1e248ff5c3e5e5cfc11dfbd0690d0d13ddafe1436890e239975a72dae9b8",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routea5/model_paths.npz": "72e014b844322df6318f6af588c27175ce68d9a37bda62b70eafca5bdfe6dfe4",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routea5/metadata.json": "c533b06d1b7c598330b36b117bdb3624221bc4193ded8354246d5c86f36b2628",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/a5/report.json": "dc6c05043c5caef6bf12b12eff69ef53d213fe8606f59865859d2c475e0aa3ee",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/a5/commands.npz": "45f551b32a354a65bff585b81a89091abc5edad406b6ca100a8dcabc50c5dbe3",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/minimal_pi_validate.py": "35b5334f52eb6069b79fc3ed3be71ceaa81a3db8e1f76e8b1ab6e363cd4f18ae",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "a899b8595e903d6fc4401392a8d123ef77725f6d6d7fa10392a7b3fe72e732b8"
}
},
{
"scope": "Verify production commands against the archived P=.50/I=.25 offline candidate.\n\nFixed recorded motion verifies integration parity, not physical tracking.\n",
"route": "a9",
"cycles": 286319,
"can_round_trips": 286319,
"production_matches_archived_trial_exactly": true,
"opendbc_revision": "64aa61b9b3fd26e70a7caa915acab207ff3cd64a",
"hypothesis": "model-action-c1-pi-v7",
"kp": 0.5,
"ki": 0.25,
"source_sha256": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routea9/route.npz": "3fa8cabfb729dd42d689de38618d1d21c8965c9d60f914546f4d7cc58db8c975",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routea9/model_paths.npz": "7f3646ad24aa52de5982c65600813de5bbc5f7664d92a2516bbba35da6e7b240",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routea9/metadata.json": "403d35504bf596144845ac2060ce067d571f49a2955d16223af28a79a7a66e93",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/a9/report.json": "17c41415b9fb9fcd8d72835347a205113c32d32d955c0de68850a9764968b3e9",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/a9/commands.npz": "27af5f6fb8f6e08f31d54e24c90430d30c72d31b1448bc1fbcb31f35804afe8a",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/minimal_pi_validate.py": "35b5334f52eb6069b79fc3ed3be71ceaa81a3db8e1f76e8b1ab6e363cd4f18ae",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "a899b8595e903d6fc4401392a8d123ef77725f6d6d7fa10392a7b3fe72e732b8"
}
},
{
"scope": "Verify production commands against the archived P=.50/I=.25 offline candidate.\n\nFixed recorded motion verifies integration parity, not physical tracking.\n",
"route": "b8",
"cycles": 160431,
"can_round_trips": 160431,
"production_matches_archived_trial_exactly": true,
"opendbc_revision": "64aa61b9b3fd26e70a7caa915acab207ff3cd64a",
"hypothesis": "model-action-c1-pi-v7",
"kp": 0.5,
"ki": 0.25,
"source_sha256": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routeb8/route.npz": "6f5dd369b70eaed4b95b28c8b25c9f2e9b830fa07a334881a185505481667c8b",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routeb8/model_paths.npz": "939af6cf7e74251d8842581cc078d26d9fbfd22a0d7817cb0e368697d419b615",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routeb8/metadata.json": "73b439132d1de37ec187b544c04d2b05c80965065515a4b7dec29ba57ae37e7c",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/b8/report.json": "5c6b52222abbb95147dc756a34e62ada97c42aa550b952fd6ae7b26d8d9639fa",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/b8/commands.npz": "9c158eff98009bcb039a4e7c29b97b0d3a9c67d61160a4e4766a176174de52d7",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/minimal_pi_validate.py": "35b5334f52eb6069b79fc3ed3be71ceaa81a3db8e1f76e8b1ab6e363cd4f18ae",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "a899b8595e903d6fc4401392a8d123ef77725f6d6d7fa10392a7b3fe72e732b8"
}
},
{
"scope": "Verify production commands against the archived P=.50/I=.25 offline candidate.\n\nFixed recorded motion verifies integration parity, not physical tracking.\n",
"route": "b9",
"cycles": 90774,
"can_round_trips": 90774,
"production_matches_archived_trial_exactly": true,
"opendbc_revision": "64aa61b9b3fd26e70a7caa915acab207ff3cd64a",
"hypothesis": "model-action-c1-pi-v7",
"kp": 0.5,
"ki": 0.25,
"source_sha256": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routeb9/route.npz": "b07c789d8155335f5d120d0262fced6e4d5803fe767b0ff49b6413dce4140b5c",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routeb9/model_paths.npz": "6b1f87897c050273fdc05af051307a049b6fc3a93072e7cda1721195ce7c3861",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routeb9/metadata.json": "9ce452220cab61b81883f32fc2fcaf5db6c78a674cb255a49cc77d5029580fee",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/b9/report.json": "9eeda0c24ede7a55ef8a924e0ebe0bcb0dea7a9612457af0c5aff898a828c195",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/b9/commands.npz": "2dcc0865591c988d8a08671311a9631f3c4aa3ded4e90ee6f76d28b1654809ad",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/minimal_pi_validate.py": "35b5334f52eb6069b79fc3ed3be71ceaa81a3db8e1f76e8b1ab6e363cd4f18ae",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "a899b8595e903d6fc4401392a8d123ef77725f6d6d7fa10392a7b3fe72e732b8"
}
},
{
"scope": "Verify production commands against the archived P=.50/I=.25 offline candidate.\n\nFixed recorded motion verifies integration parity, not physical tracking.\n",
"route": "ca",
"cycles": 327448,
"can_round_trips": 327448,
"production_matches_archived_trial_exactly": true,
"opendbc_revision": "64aa61b9b3fd26e70a7caa915acab207ff3cd64a",
"hypothesis": "model-action-c1-pi-v7",
"kp": 0.5,
"ki": 0.25,
"source_sha256": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routeca/route.npz": "ae9d46770eaf0dbbac6af86aebc926320eed0cf114eb43d5f78b0676e8e0dbf9",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routeca/model_paths.npz": "bf17deb442383aaa79432566cd382df24a1bbbbd0521d0cafab956618f5bdd96",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_routeca/metadata.json": "a759d5cdf878df8b05d91db637b1935b6b4bdd87af96f0f256b67e7d809b3525",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/ca/report.json": "025f2f49f68ec2164a10474a4c6ec0eb1b6022990e135fc38134a052a8710dff",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/ca/commands.npz": "cbe9f63955a409495f57466dc5811d3eed65af2cf096ee060d924437488c3f76",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/minimal_pi_validate.py": "35b5334f52eb6069b79fc3ed3be71ceaa81a3db8e1f76e8b1ab6e363cd4f18ae",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "a899b8595e903d6fc4401392a8d123ef77725f6d6d7fa10392a7b3fe72e732b8"
}
},
{
"scope": "Verify production commands against the archived P=.50/I=.25 offline candidate.\n\nFixed recorded motion verifies integration parity, not physical tracking.\n",
"route": "raptor02",
"cycles": 132881,
"can_round_trips": 132881,
"production_matches_archived_trial_exactly": true,
"opendbc_revision": "64aa61b9b3fd26e70a7caa915acab207ff3cd64a",
"hypothesis": "model-action-c1-pi-v7",
"kp": 0.5,
"ki": 0.25,
"source_sha256": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_raptor_route02/route.npz": "8a4cfe53994988d053b47d9caadf21ebd064fd20e598fa05ddc2da245bd0e980",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_raptor_route02/model_paths.npz": "d5dc6f72d91472b8f2fb1add1680cdaea1e122774ddd6d8519fd7673daf71bb5",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_raptor_route02/metadata.json": "9602e08efc2bd784133837c4156c075bb89bad3bedd6b309f15e884ed20e338a",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/raptor02/report.json": "2c2dda2a5e4e8a764a10852cca3e5caf319b62e3d1d841ef9e1b8c11be77ebf6",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/.cache/ford_minimal_tuning/sweep/raptor02/commands.npz": "e0440d0a4035b975a9c4a47d85335b5b0f0903fcd00dd7e7c858856deffd1249",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/minimal_pi_validate.py": "35b5334f52eb6069b79fc3ed3be71ceaa81a3db8e1f76e8b1ab6e363cd4f18ae",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "a899b8595e903d6fc4401392a8d123ef77725f6d6d7fa10392a7b3fe72e732b8"
}
}
],
"production_stress": {
"cycles": 20000,
"kp": 0.5,
"ki": 0.25,
"controller_updates": 60000,
"can_round_trips": 60000,
"seed": 20260913,
"independent_c0_comparisons": 20000,
"checks": "Independent scalar PI/unwind-first/anti-windup arithmetic, mirror symmetry, exact C0 independence, limits, slew, resets, CAN.",
"source_sha256": {
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/minimal_pi_production_stress.py": "fa9058826512498be1938797efba25666df832080fb7ad6ff18788ee4bda333d",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/openpilot/selfdrive/controls/lib/ford_model_action.py": "a899b8595e903d6fc4401392a8d123ef77725f6d6d7fa10392a7b3fe72e732b8"
}
},
"limits": [
"No hardware build, device boot or new physical drive performed.",
"Matched offline commands do not establish improved tracking or closed-loop stability.",
"Higher P can amplify measurement fluctuations; lower I can take longer to correct persistent error.",
"Toggle defaults off and off selects upstream Ford control; on applies to any Ford CAN FD."
]
}
+18 -30
View File
@@ -1,11 +1,9 @@
# Ford selected-action drive-test branch
The current experiment adds [measured-curvature C1 feedback](ford_c1_feedback.md)
and [conditional correction release](ford_c1_carryover.md) to the restored
original v1 mapping, with [base C1 overflow allocated to C0](ford_c1_overflow.md)
and [completed-unwind correction release](ford_unwind_catchup.md).
V6 adds [explicit proportional C1 feedback with P=0.25](ford_c1_pi.md), retaining
the existing feedback timing. This is an initial drive-trial gain.
The current v7 experiment uses [continuous C1 PI feedback](ford_c1_minimal_pi.md)
with **P=0.50 and I=0.25**, replacing the earlier conditional-release rules.
Only C0, C1 and accumulated error carry control history. The restored model
mapping and [base C1 overflow allocation to C0](ford_c1_overflow.md) remain.
It is selectable on **any Ford CAN FD vehicle**
through the existing persistent, default-off Sunnylink
toggle. Offline checks establish software behavior; physical tracking,
@@ -23,19 +21,12 @@ turn-exit behavior and closed-loop stability remain unvalidated.
The startup event `Ford path controller selected` should report
`FordModelActionController`. Periodic `Ford C2-free path tracking` events
identify **`hypothesis=model-action-c1-pi-v6`**. They report desired and
measured curvature, base heading, proportional and accumulated correction, applied heading,
feedback timing and driver/PSCM gating. `carryover_release_count` counts
conditional releases since the last controller reset; it does not control
steering. `offset_overflow` reports the extra C0 target in meters before C0
amplitude and slew limits. `calibration_approved=false` remains.
`request_release` reports correction retired on the current cycle when a changed
request and sufficiently large measured error agree. Periodic logs can miss
individual retirement cycles.
`unwind_direction` remembers an unfinished unwind; `unwind_release` reports
correction retired when a confirmed unwind catches the selected curvature.
`heading_proportional`, `proportional_gain`, `feedback_curvature` and
`feedback_error` separate the new P contribution and its reference from I.
identify **`hypothesis=model-action-c1-pi-v7`**. They report desired and measured
curvature, base heading, proportional and accumulated correction, applied heading,
feedback timing and driver/PSCM gating. `proportional_gain=0.5` and
`integral_gain=0.25` identify the trial. `offset_overflow` reports the extra C0
target in meters before C0 amplitude and slew limits. `calibration_approved=false`
remains. The retired request/unwind/reversal diagnostic fields are removed.
Turning the toggle off and completing another offroad-to-onroad cycle restores
**upstream Ford curvature control**: 20 Hz steering messages, limited mode on
@@ -54,15 +45,11 @@ error without accumulating. Repeated publications may advance P and output slew
but cannot integrate the same elapsed interval twice.
Driver override clears P and I. A fresh PSCM reached-limit flag stops
extra outward accumulation while preserving unwind and base model changes.
With fresh feedback, the controller can discard an opposing correction when
it prevents C1 from following the direction shared by original model C0, applied C0
and base C1, while measured curvature is still opposite. A separate bounded
release now handles changed requests within the same turn when measured error
also opposes the old correction. V5 additionally retires dominant unwind memory
at catch-up when the selected request reaches zero/new-side curvature and both
C0 requests confirm that side. Steady requests cannot arm this release, and
catching an old-side bend retains correction. Final output slew still applies.
See [completed-unwind release](ford_unwind_catchup.md).
With fresh feedback, the part of the error increment that cancels existing I
is applied before the ordinary accumulation clamp. Any remainder must fit the
combined feedforward/P/I amplitude and slew envelope. There is no C0 confirmation
threshold or remembered turn direction. Zero error removes P and holds I; it
does not trigger a release. Final command limits still apply.
C0 starts with the original 7 m model-path mapping. When the raw base heading
exceeds ±0.5 rad, C0 additionally receives 7 m times the clipped-away heading.
@@ -74,8 +61,9 @@ place. An explicit selection flag distinguishes upstream mode from an invalid
experimental command; invalid experimental input cannot switch to upstream.
The opendbc sender restores upstream behavior when that flag is false.
See [proportional feedback trial and validation](ford_c1_pi.md) and
`ford_c1_pi_validation.json` for current evidence and reproduction commands.
See [continuous PI trial and validation](ford_c1_minimal_pi.md) and
`ford_c1_minimal_pi_validation.json` for current evidence and reproduction commands.
[Proportional feedback](ford_c1_pi.md) and its validation JSON record v6.
[Completed-unwind release](ford_unwind_catchup.md) and
`ford_unwind_catchup_validation.json` record v5. [Changed-request release](ford_c1_request_release.md) and its
validation JSON record v4. The [overflow specification](ford_c1_overflow.md) and
@@ -1,13 +1,8 @@
"""Experimental Ford C2-free controller with measured-curvature C1 PI feedback.
"""Opt-in Ford C2-free model mapping with measured-curvature PI feedback.
Selected only by its explicit toggle. The 7 m station and one-second scale are
engineering choices. Feeding integrated heading mismatch into C1 at 1:1 is an
explicit feedback-strength choice, not an identified PSCM model or calibration.
The selected experiment adds an explicit proportional heading-error term.
Opposed correction may be released when both path commands confirm the turn.
Changed requests retire opposing correction only while measured error agrees.
Completed, direction-confirmed unwinds release dominant old correction.
Base heading clipped by C1 is allocated to C0 at the existing 7 m reference.
C0 samples the model at 7 m, including clipped base-heading overflow. C1
combines the selected curvature's heading with proportional and integrated
tracking error. Reference distance and gains are explicit trial choices.
"""
import math
import struct
@@ -20,7 +15,8 @@ from openpilot.selfdrive.controls.lib.ford_path import FordPath, _model_path
OFFSET_STATION_M = 7.0
HEADING_TIME_S = 1.0
C1_PROPORTIONAL_GAIN = 0.25 # Initial drive-trial gain, not a learned calibration.
C1_PROPORTIONAL_GAIN = 0.50 # Drive-trial gains, not a learned calibration.
C1_INTEGRAL_GAIN = 0.25
CALIBRATION_APPROVED = False
@@ -58,127 +54,62 @@ def encode_model_action(model, desired_curvature, speed):
class ModelActionController:
"""C0/C1 slew, C1 correction, last feedback request and unwind direction.
"""Only C0 slew, C1 slew and integrated tracking error carry control history.
Feedback integrates requested minus measured curvature over traveled distance.
Freshness, measurement cadence and driver/PSCM arbitration belong to the caller.
Zero P is the v5 reference; onroad selection supplies the explicit trial gain.
"""
__slots__ = ('c0', 'c1', 'correction', 'carryover_release_count', 'last_feedback_desired', 'request_release',
'unwind_direction', 'unwind_release', 'proportional_gain', 'proportional', 'feedback_curvature')
__slots__ = ('c0', 'c1', 'correction', 'proportional_gain', 'integral_gain', 'proportional', 'feedback_curvature')
def __init__(self, proportional_gain=0.):
if not _finite(proportional_gain) or proportional_gain < 0.:
raise ValueError('Proportional gain must be finite and nonnegative')
self.proportional_gain = float(proportional_gain)
def __init__(self, proportional_gain=C1_PROPORTIONAL_GAIN, integral_gain=C1_INTEGRAL_GAIN):
if not _finite(proportional_gain, integral_gain) or min(proportional_gain, integral_gain) < 0.:
raise ValueError('PI gains must be finite and nonnegative')
self.proportional_gain, self.integral_gain = float(proportional_gain), float(integral_gain)
self.reset()
def reset(self):
self.c0 = self.c1 = self.correction = 0.
self.carryover_release_count = 0 # Diagnostic only; never feeds the command law.
self.last_feedback_desired = None
self.request_release = 0. # Diagnostic radians retired on this cycle.
self.unwind_direction = 0.
self.unwind_release = 0. # Diagnostic only; final output still obeys slew.
self.proportional = self.feedback_curvature = 0.
self.c0 = self.c1 = self.correction = self.proportional = self.feedback_curvature = 0.
def update(self, model, desired_curvature, *, current_curvature, speed, dt, active=True, valid=True,
feedback_dt=None, feedback_enabled=True, pscm_limited=False, feedback_curvature=None):
feedback_dt = dt if feedback_dt is None else feedback_dt
feedback_curvature = desired_curvature if feedback_curvature is None else feedback_curvature
if (not active or not valid or not _finite(dt, feedback_dt, current_curvature, feedback_curvature) or not .002 <= dt <= .1
or not 0. <= feedback_dt <= .15 or abs(current_curvature) > 1. or abs(feedback_curvature) > 1.):
reference = desired_curvature if feedback_curvature is None else feedback_curvature
if (not active or not valid or not _finite(dt, feedback_dt, current_curvature, reference) or not .002 <= dt <= .1
or not 0. <= feedback_dt <= .15 or abs(current_curvature) > 1. or abs(reference) > 1.):
self.reset()
return FordPath()
target = encode_model_action(model, desired_curvature, speed)
if not target.valid:
self.reset()
return FordPath()
self.feedback_curvature = feedback_curvature
feedback_error = feedback_curvature-current_curvature
self.proportional = self.proportional_gain*max(OFFSET_STATION_M, speed*HEADING_TIME_S)*feedback_error if feedback_enabled else 0.
self.feedback_curvature = reference
error = reference-current_curvature
self.proportional = self.proportional_gain*max(OFFSET_STATION_M, speed*HEADING_TIME_S)*error if feedback_enabled else 0.
if not _finite(self.proportional):
self.reset()
return FordPath()
base_c1 = float(np.clip(target.path_angle, -.5, .5))
# Preserve the linear path reference at 7 m when the base heading clips.
# This is instantaneous geometry, not stored error or C1 feedback spill.
c0 = float(np.clip(target.path_offset + OFFSET_STATION_M*(target.path_angle-base_c1), -5.11, 5.11))
self.c0 += float(np.clip(c0-self.c0, -4.*dt, 4.*dt))
lower = max(-.5, self.c1-.5*dt)
upper = min(.5, self.c1+.5*dt)
self.request_release = 0.
self.unwind_release = 0.
if not feedback_enabled:
self.correction = 0.
self.unwind_direction = 0.
else:
error = desired_curvature-current_curvature
if feedback_dt > 0.:
# Remember an unwind only when a relaxing request and excess measured
# steering call for leaving the old turn. This may precede accumulation
# while the output is still slewing. A steady request cannot arm it.
previous = self.last_feedback_desired
if (previous is not None and (desired_curvature-previous)*previous < 0.
and current_curvature*previous > 0. and error*previous < 0.):
self.unwind_direction = math.copysign(1., error)
direction = self.unwind_direction
if direction and error*direction <= 0.:
# Steering has caught the request. Retire dominant unwind memory only
# after the selected request crosses zero and both path offsets also
# confirm the new side. Catching a steady old-side bend retains I.
if (desired_curvature*direction >= 0. and self.correction*direction > abs(base_c1)
and min(target.path_offset*direction, self.c0*direction) >= .01):
self.unwind_release = self.correction
self.correction = 0.
self.unwind_direction = 0.
# A changed target can make old correction counterproductive before
# steering reverses. Retire at most the heading change, and only when
# fresh measured error calls for that same change, by at least a C1 DBC
# step. Never cross zero or discard a steady tracking correction just
# because error changes sign.
# Require the heading mismatch over the existing reference distance to
# cover the correction: small tracking noise must not erode a useful I.
# Evaluate both requests at today's speed so speed changes alone do not
# release anything. Duplicate measurements leave this history untouched.
if feedback_dt > 0. and self.last_feedback_desired is not None:
distance = max(OFFSET_STATION_M, speed*HEADING_TIME_S)
previous_base = float(np.clip(distance*self.last_feedback_desired, -.5, .5))
change = base_c1-previous_base
if (abs(change) >= .0005 and change*error > 0. and change*self.correction < 0.
and abs(distance*error) >= abs(self.correction)):
self.request_release = float(np.clip(change, min(-self.correction, 0.), max(-self.correction, 0.)))
self.correction += self.request_release
direction = math.copysign(1., base_c1)
# Release only correction that prevents C1 from requesting the direction
# shared by model C0, slewed C0 and base C1, while measured steering is
# still opposite. One DBC step confirms each request is nonzero. Matched
# steering, neutral/conflicting centering and duplicate samples retain I.
if (feedback_dt > 0. and abs(base_c1) >= .0005 and current_curvature*direction < 0.
and min(target.path_offset*direction, self.c0*direction) >= .01
and (base_c1+self.correction)*direction <= 0.):
self.correction = 0.
self.carryover_release_count += 1
increment = feedback_error*speed*feedback_dt
# LimitReached inhibits only extra demand in the measured turn direction.
# Opposing correction and changes to the model request remain available.
base = float(np.clip(target.path_angle, -.5, .5))
offset = float(np.clip(target.path_offset+OFFSET_STATION_M*(target.path_angle-base), -5.11, 5.11))
self.c0 += float(np.clip(offset-self.c0, -4.*dt, 4.*dt))
lower, upper = max(-.5, self.c1-.5*dt), min(.5, self.c1+.5*dt)
if feedback_enabled:
increment = self.integral_gain*error*speed*feedback_dt
if not _finite(increment):
self.reset()
return FordPath()
direction = current_curvature if current_curvature else self.c1
if pscm_limited and increment*direction > 0.:
# An old opposing correction may return to zero; don't trap it below
# the base request just because the PSCM now reports a limit.
increment = float(np.clip(increment, min(-self.correction, 0.), max(-self.correction, 0.)))
# Include P in the available headroom so I cannot wind up behind it.
# Integrate only as far as this cycle's amplitude/slew envelope permits.
# If the base moved outside that envelope, allow increments toward it;
# never rewrite existing correction merely because the base changed.
request = base_c1+self.proportional+self.correction
# Retire existing I before limiting new accumulation; never cross zero
# through this step. The final command still obeys amplitude and slew.
relief = float(np.clip(increment, min(-self.correction, 0.), max(-self.correction, 0.)))
self.correction += relief
increment -= relief
request = base+self.proportional+self.correction
self.correction += float(np.clip(increment, min(lower-request, 0.), max(upper-request, 0.)))
if self.correction*self.unwind_direction < 0.:
self.unwind_direction = 0.
if self.last_feedback_desired is None or feedback_dt > 0. or not feedback_enabled:
self.last_feedback_desired = desired_curvature
c1 = float(np.clip(base_c1+self.proportional+self.correction, -.5, .5))
self.c1 += float(np.clip(c1-self.c1, -.5*dt, .5*dt))
else:
self.correction = 0.
request = float(np.clip(base+self.proportional+self.correction, -.5, .5))
self.c1 += float(np.clip(request-self.c1, -.5*dt, .5*dt))
return FordPath(True, _packed(self.c0, .01, -5.12), _packed(self.c1, .0005, -.5), 0., 0.)
@@ -194,9 +125,9 @@ class FordModelActionController:
clears the correction. Fresh PSCM limits only inhibit outward integration;
neither a limit nor a repeated measurement freezes the model request.
"""
def __init__(self, proportional_gain=0.):
self.core = ModelActionController(proportional_gain=proportional_gain)
self.hypothesis = 'model-action-c1-pi-v6' if proportional_gain else 'model-action-c1-feedback-v5'
def __init__(self, proportional_gain=C1_PROPORTIONAL_GAIN, integral_gain=C1_INTEGRAL_GAIN):
self.core = ModelActionController(proportional_gain=proportional_gain, integral_gain=integral_gain)
self.hypothesis = 'model-action-c1-pi-v7'
self.reset()
def reset(self, status='inactive'):
@@ -255,11 +186,8 @@ class FordModelActionController:
'offset_overflow': OFFSET_STATION_M*(raw_heading-base_heading),
'heading_correction': self.core.correction, 'feedback_enabled': feedback_enabled,
'heading_proportional': self.core.proportional, 'proportional_gain': self.core.proportional_gain,
'feedback_curvature': self.core.feedback_curvature,
'integral_gain': self.core.integral_gain, 'feedback_curvature': self.core.feedback_curvature,
'feedback_error': self.core.feedback_curvature-current_curvature,
'request_release': self.core.request_release,
'unwind_direction': self.core.unwind_direction, 'unwind_release': self.core.unwind_release,
'carryover_release_count': self.core.carryover_release_count,
'driver_override': driver_override, 'pscm_limited': pscm_limited, 'pscm_status_fresh': bool(status_fresh),
'command': (command.path_offset, command.path_angle, 0., 0.)}
return command
@@ -269,5 +197,5 @@ def select_model_action_controller(CP, enabled):
"""Only opt-in Ford CAN FD vehicles override upstream curvature control."""
compatible = CP.brand == 'ford' and CP.flags & FordFlags.CANFD
if enabled and compatible:
return FordModelActionController(proportional_gain=C1_PROPORTIONAL_GAIN)
return FordModelActionController(proportional_gain=C1_PROPORTIONAL_GAIN, integral_gain=C1_INTEGRAL_GAIN)
return None
@@ -53,7 +53,7 @@ class TestFordControlsLogging(unittest.TestCase):
controls = SimpleNamespace(ford_path_controller=controller, desired_curvature=.03, curvature=.015,
sm=SimpleNamespace(logMonoTime={'modelV2': 123456789, 'carState': 123450000}))
record = self.emit_controls_event('Ford C2-free path tracking', controls)
self.assertEqual(record['hypothesis'], 'model-action-c1-feedback-v5')
self.assertEqual(record['hypothesis'], 'model-action-c1-pi-v7')
self.assertIs(record['calibration_approved'], False)
self.assertEqual(record['command'][2:], [0., 0.])
self.assertEqual(record['status'], controller.diagnostics['status'])
@@ -42,14 +42,12 @@ def test_centering_information_is_independent_of_action_and_not_scaled_with_spee
assert target.path_angle == pytest.approx(sign*.2) # No 10 m cap at highway speed.
def test_five_control_states_are_sufficient_for_every_next_output():
def test_three_control_states_are_sufficient_for_every_next_output():
controller = ModelActionController()
assert not hasattr(controller, '__dict__')
for i in range(300):
copied = ModelActionController()
copied.c0, copied.c1, copied.correction = controller.c0, controller.c1, controller.correction
copied.last_feedback_desired = controller.last_feedback_desired
copied.unwind_direction = controller.unwind_direction
model = straight(.2*math.sin(i*.1))
kwargs = {'speed': 20., 'dt': .01}
desired = .005*math.cos(i*.03)
@@ -180,9 +180,9 @@ def test_actual_controlsd_selection_limiting_publication_and_downstream_can(pipe
exec(call, environment)
expected_curvature = (-1 if maneuver else 1)*.000125
assert controls.desired_curvature == pytest.approx(expected_curvature)
assert controller.core.proportional == pytest.approx(.25*20.*expected_curvature)
assert controller.core.proportional == pytest.approx(.5*20.*expected_curvature)
assert controller.core.correction == 0. # First measurement has no elapsed feedback time.
assert controls.ford_path.path_angle == pytest.approx((-1 if maneuver else 1)*.003)
assert controls.ford_path.path_angle == pytest.approx((-1 if maneuver else 1)*.0035)
assert controls.ford_path.path_offset == pytest.approx(.04)
assert cc.latActive and cc.actuators.curvature == 0.
assert controller.diagnostics['reference_age'] == pytest.approx(.01 if maneuver else .02)
@@ -239,11 +239,11 @@ def test_feedback_through_actual_controlsd_publication_and_100hz_sender(pipeline
lkas_status_stock_values=defaultdict(int), buttons_stock_values=defaultdict(int))
parser = CANParser('ford_lincoln_base_pt', [('LateralMotionControl2', 100)], downstream.CAN.main)
frame = 0
# A .005 rad P step consumes one slew interval before I can accumulate;
# going from -.005 to +.005 consumes two. Matched steering removes P only.
for measured, torque, count, expected in [(sign*.004, 0., 100, 0.), (sign*.003, 0., 100, sign*.0198),
(sign*.004, 0., 100, sign*.0198), (sign*.005, 0., 100, 0.),
(sign*.003, 0., 100, sign*.0196), (0., 1.0625, 5, 0.)]:
# P spends output slew headroom before new I accumulates. Existing I can
# unwind throughout that slew. Matched steering removes P and preserves I.
for measured, torque, count, expected in [(sign*.004, 0., 100, 0.), (sign*.003, 0., 100, sign*.0049),
(sign*.004, 0., 100, sign*.0049), (sign*.005, 0., 100, -sign*.0001),
(sign*.003, 0., 100, sign*.0048), (0., 1.0625, 5, 0.)]:
for _ in range(count):
now = 1.+frame*.01
controls.curvature, cs.steeringTorque = measured, torque
@@ -268,7 +268,7 @@ def test_feedback_through_actual_controlsd_publication_and_100hz_sender(pipeline
assert wire['LatCtlPath_No_Cs'] == calculate_lat_ctl2_checksum(2, frame % 16, packet[1])
frame += 1
core = controls.ford_path_controller.core
expected_p = .25*20.*(sign*.004-measured) if torque == 0. else 0.
expected_p = .5*20.*(sign*.004-measured) if torque == 0. else 0.
assert core.proportional == pytest.approx(expected_p)
assert core.correction == pytest.approx(expected)
assert core.c1 == pytest.approx(sign*.08+expected_p+expected)
@@ -284,7 +284,7 @@ def test_actual_controlsd_passes_only_valid_pscm_service_to_feedback(pipeline, s
model.action = SimpleNamespace(desiredCurvature=.004)
cc = structs.CarControl(latActive=True)
cs = SimpleNamespace(vEgo=20., yawRate=0., canValid=True, steeringPressed=False, steeringTorque=0.)
for frame in range(102):
for frame in range(103):
now = 1.+frame*.01
controls.curvature = .004 if frame < 100 else .003
sm.logMonoTime.update(carState=round(now*1e9), modelV2=round(now*1e9))
@@ -296,16 +296,16 @@ def test_actual_controlsd_passes_only_valid_pscm_service_to_feedback(pipeline, s
'time': SimpleNamespace(monotonic=lambda now=now: now)})
controller = controls.ford_path_controller
assert controller.diagnostics['pscm_limited'] is service_valid
assert controller.core.proportional == pytest.approx(.005)
# First error sample spends the slew allowance on P; the second may add I.
assert controller.core.correction == pytest.approx(0. if service_valid else .0002)
assert controller.core.proportional == pytest.approx(.01)
# Two error samples spend the slew allowance on P; the third may add I.
assert controller.core.correction == pytest.approx(0. if service_valid else .00005)
assert cc.latActive and controls.ford_path.valid
@pytest.mark.parametrize('sign', [-1., 1.])
@pytest.mark.parametrize('maneuver', [False, True])
@pytest.mark.parametrize('same_turn', [False, True])
def test_carryover_release_through_selected_limited_request_and_actual_can(pipeline, sign, maneuver, same_turn):
def test_continuous_pi_reversal_through_selected_limited_request_and_actual_can(pipeline, sign, maneuver, same_turn):
call, publication = pipeline
controls, sm = startup(), Subscriptions(maneuver)
controls.sm, controls.desired_curvature = sm, sign*.004
@@ -318,7 +318,6 @@ def test_carryover_release_through_selected_limited_request_and_actual_can(pipel
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)], downstream.CAN.main)
request_releases = 0
for frame in range(280):
now = 1.+frame*.01
desired = sign*(.004 if frame < 200 else .01 if same_turn else -.001)
@@ -327,12 +326,13 @@ def test_carryover_release_through_selected_limited_request_and_actual_can(pipel
sm.messages['lateralManeuverPlan'].desiredCurvature = desired
controls.curvature = sign*(.004 if frame < 100 else .006 if same_turn else .001 if frame < 200 else .003)
sm.logMonoTime.update(carState=round(now*1e9), modelV2=round(now*1e9), lateralManeuverPlan=round(now*1e9))
before = core.c0, core.c1
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.), 'clip_curvature': clip_curvature,
'time': SimpleNamespace(monotonic=lambda now=now: now)})
assert abs(core.c0-before[0]) <= .0400000001 and abs(core.c1-before[1]) <= .0050000001
request_releases += core.request_release != 0.
increment = .25*speed*(controls.desired_curvature-controls.curvature)*.01
assert abs(core.correction-before[2]) <= abs(increment)+1e-10
msg = custom.CarControlSP.new_message()
exec(publication, {'self': controls, 'CC_SP': msg})
_, packets = downstream.update(cc.as_reader(), convert_carControlSP(msg.as_reader()), vehicle, round(now*1e9))
@@ -347,26 +347,21 @@ def test_carryover_release_through_selected_limited_request_and_actual_can(pipel
packet = next(packet for packet in packets if packet[0] == address)
assert wire['LatCtlPath_No_Cs'] == calculate_lat_ctl2_checksum(2, frame % 16, packet[1])
if frame == 199:
# P spends one or three slew intervals before the remaining I updates.
assert core.correction == pytest.approx(sign*(-.0198 if same_turn else .0582))
assert core.carryover_release_count == 0
assert core.carryover_release_count == (0 if same_turn else 1)
assert controls.ford_path_controller.diagnostics['carryover_release_count'] == core.carryover_release_count
assert controls.ford_path_controller.diagnostics['hypothesis'] == 'model-action-c1-pi-v6'
assert sign*core.correction < 0. if same_turn else sign*core.correction > 0.
assert controls.ford_path_controller.diagnostics['hypothesis'] == 'model-action-c1-pi-v7'
if same_turn:
assert request_releases > 0
assert controls.desired_curvature == pytest.approx(sign*.01)
assert sign*controls.ford_path.path_angle >= speed*.01 # No old unwind correction left below the new base.
else:
assert sign*controls.ford_path.path_angle < 0.
assert controls.ford_path.path_offset == pytest.approx(sign*(.2 if same_turn else -.2))
controls.ford_path_controller.reset()
assert core.carryover_release_count == 0
assert core.c0 == core.c1 == core.correction == 0.
@pytest.mark.parametrize('sign', [-1., 1.])
@pytest.mark.parametrize('maneuver', [False, True])
def test_completed_unwind_release_through_selected_request_and_actual_can(pipeline, sign, maneuver):
def test_unwind_and_catchup_through_selected_request_and_actual_can(pipeline, sign, maneuver):
call, publication = pipeline
controls, sm = startup(), Subscriptions(maneuver)
controls.sm, controls.desired_curvature = sm, -sign*.02
@@ -378,7 +373,6 @@ def test_completed_unwind_release_through_selected_request_and_actual_can(pipeli
vehicle = SimpleNamespace(out=structs.CarState(vEgo=4., vEgoRaw=4.), 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)], downstream.CAN.main)
release_count = 0
for frame in range(180):
now = 1.+frame*.01
desired = -sign*(.02 if frame < 30 else .01 if frame < 80 else 0.)
@@ -392,12 +386,12 @@ def test_completed_unwind_release_through_selected_request_and_actual_can(pipeli
'lp': SimpleNamespace(roll=0.), 'clip_curvature': clip_curvature,
'time': SimpleNamespace(monotonic=lambda now=now: now)})
assert abs(core.c0-before[0]) <= .0400000001 and abs(core.c1-before[1]) <= .0050000001
release_count += core.unwind_release != 0.
if frame == 129:
assert sign*core.correction > .04 and core.unwind_direction == sign
assert 0. < sign*core.correction < .02
if frame == 130:
assert core.unwind_release == before[2] and core.correction == 0.
assert core.c1 == pytest.approx(before[1]-sign*.005)
# Zero measured error removes P, but does not arbitrarily erase I.
assert core.correction == before[2]
assert core.proportional == 0.
msg = custom.CarControlSP.new_message()
exec(publication, {'self': controls, 'CC_SP': msg})
_, packets = downstream.update(cc.as_reader(), convert_carControlSP(msg.as_reader()), vehicle, round(now*1e9))
@@ -411,8 +405,8 @@ def test_completed_unwind_release_through_selected_request_and_actual_can(pipeli
assert wire['LatCtl_D2_Rq'] == 2 and wire['LatCtlPath_No_Cnt'] == frame % 16
packet = next(packet for packet in packets if packet[0] == address)
assert wire['LatCtlPath_No_Cs'] == calculate_lat_ctl2_checksum(2, frame % 16, packet[1])
assert release_count == 1
assert controls.ford_path.path_angle == core.correction == 0.
assert controls.ford_path.path_angle == pytest.approx(core.correction, abs=.00025)
assert 0. < sign*core.correction < .02
assert controls.ford_path.path_offset == pytest.approx(sign*.05)
@@ -438,7 +432,7 @@ def test_heading_overflow_and_release_through_actual_can(pipeline, sign, fingerp
# Match the selected request after its real upstream limiter, isolating base allocation.
controls.curvature = clip_curvature(cs.vEgo, controls.desired_curvature, desired, 0.)[0]
sm.logMonoTime.update(carState=round(now*1e9), modelV2=round(now*1e9))
before = core.c0, core.c1
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.), 'clip_curvature': clip_curvature,
'time': SimpleNamespace(monotonic=lambda now=now: now)})
@@ -15,117 +15,9 @@ def tick(controller, desired, measured, **overrides):
return controller.update(straight(.4), desired, **kwargs)
@pytest.mark.parametrize('sign', [-1., 1.])
def test_old_turn_correction_does_not_keep_c1_in_old_direction_after_reversal(sign):
controller = ModelActionController()
# Build an actual correction through feedback, rather than injecting a state.
for _ in range(100):
controller.update(straight(), sign*.004, current_curvature=sign*.004, speed=20., dt=.01)
for _ in range(100):
controller.update(straight(), sign*.004, current_curvature=sign*.001, speed=20., dt=.01)
assert controller.correction == pytest.approx(sign*.06)
# The request has reversed, but measured steering still points into the old
# turn. C0 also confirms the new direction. The existing slew permits
# crossing zero within these 0.4 seconds.
for _ in range(40):
before = controller.c1
out = controller.update(straight(-sign*.2), -sign*.001, current_curvature=sign*.003, speed=20., dt=.01)
assert abs(controller.c1-before) <= .0050000001
assert sign*out.path_angle < 0., 'Stored old-turn correction still overrides the new C1 direction'
assert out.path_offset == pytest.approx(-sign*.2)
@pytest.mark.parametrize('sign', [-1., 1.])
@pytest.mark.parametrize('limited', [False, True])
def test_carryover_release_uses_fresh_feedback_and_preserves_final_slew(sign, limited):
controller = ModelActionController()
controller.c0, controller.c1, controller.correction = -sign*.2, sign*.03, sign*.05
kwargs = {'speed': 20., 'dt': .01, 'current_curvature': sign*.003, 'pscm_limited': limited}
controller.update(straight(-sign*.2), -sign*.001, feedback_dt=0., **kwargs)
assert controller.correction == pytest.approx(sign*.05)
before = controller.c1
out = controller.update(straight(-sign*.2), -sign*.001, **kwargs)
assert controller.correction == 0.
assert controller.carryover_release_count == 1
assert out.path_angle == pytest.approx(before-sign*.005)
for _ in range(30):
controller.update(straight(-sign*.2), -sign*.001, **kwargs)
assert controller.carryover_release_count == 1
@pytest.mark.parametrize('sign', [-1., 1.])
@pytest.mark.parametrize('case', ['matched', 'same_turn', 'already_turning_new_way', 'c0_opposes', 'c0_neutral',
'c0_sub_resolution', 'target_c0_opposes', 'c1_sub_resolution',
'neutral_request', 'neutral_measurement', 'correction_helps', 'correction_not_dominant'])
def test_carryover_release_preserves_steady_correction_and_ambiguous_requests(sign, case):
controller = ModelActionController()
offset, desired, measured, correction = -.2, -.001, .003, .05
if case == 'matched':
measured = desired
elif case == 'same_turn':
measured = -.0005
elif case == 'already_turning_new_way':
measured = -.003
elif case == 'c0_opposes':
offset = .2
elif case == 'c0_neutral':
offset = 0.
elif case == 'c0_sub_resolution':
offset = -.001
elif case == 'c1_sub_resolution':
desired = -.000001
elif case == 'neutral_request':
desired = 0.
elif case == 'neutral_measurement':
measured = 0.
elif case == 'correction_helps':
correction = -.05
elif case == 'correction_not_dominant':
correction = .01
controller.c0, controller.c1, controller.correction = sign*offset, sign*(20.*desired+correction), sign*correction
if case == 'target_c0_opposes':
offset = .2 # The old slewed C0 alone is not enough to confirm the request.
controller.update(straight(sign*offset), sign*desired, current_curvature=sign*measured, speed=20., dt=.01)
assert controller.carryover_release_count == 0
assert abs(controller.correction-sign*correction) <= abs(desired-measured)*20.*.01+1e-10
@pytest.mark.parametrize('sign', [-1., 1.])
def test_carryover_release_waits_for_c0_to_finish_opposing_the_new_request(sign):
controller = ModelActionController()
controller.c0, controller.c1, controller.correction = sign*.2, sign*.03, sign*.05
for _ in range(4):
controller.update(straight(-sign*.2), -sign*.001, current_curvature=sign*.003, speed=20., dt=.01)
assert controller.carryover_release_count == 0
for _ in range(3):
controller.update(straight(-sign*.2), -sign*.001, current_curvature=sign*.003, speed=20., dt=.01)
assert controller.carryover_release_count == 1
@pytest.mark.parametrize('sign', [-1., 1.])
def test_steady_opposing_correction_is_preserved_through_small_error_crossings(sign):
controller = ModelActionController()
controller.c0, controller.c1, controller.correction = -sign*.2, sign*.03, sign*.05
for i in range(200):
measured = sign*(-.001+(-1 if i % 2 else 1)*.000001)
controller.update(straight(-sign*.2), -sign*.001, current_curvature=measured, speed=20., dt=.01)
assert controller.carryover_release_count == 0
assert controller.correction == pytest.approx(sign*.05)
@pytest.mark.parametrize('sign', [-1., 1.])
@pytest.mark.parametrize('offset', [.009999, .01, .010001])
@pytest.mark.parametrize('heading', [.0004999, .0005, .0005001])
def test_carryover_direction_confirmation_uses_existing_dbc_steps(sign, offset, heading):
controller = ModelActionController()
controller.c0, controller.c1, controller.correction = -sign*offset, sign*.03, sign*.05
controller.update(straight(-sign*offset), -sign*heading/20., current_curvature=sign*.003, speed=20., dt=.01)
assert controller.carryover_release_count == int(offset >= .01 and heading >= .0005)
@pytest.mark.parametrize('sign', [-1., 1.])
def test_feedback_builds_holds_and_unwinds_without_changing_c0(sign):
controller, matched = ModelActionController(), ModelActionController()
controller, matched = ModelActionController(proportional_gain=0., integral_gain=1.), ModelActionController(proportional_gain=0., integral_gain=1.)
for _ in range(100):
tick(controller, sign*.004, sign*.004)
for _ in range(100):
@@ -147,7 +39,7 @@ def test_feedback_builds_holds_and_unwinds_without_changing_c0(sign):
@pytest.mark.parametrize('sign', [-1., 1.])
def test_amplitude_and_slew_limits_do_not_store_unavailable_feedback(sign):
controller = ModelActionController()
controller = ModelActionController(proportional_gain=0., integral_gain=1.)
# The unchanged model request is already ahead of the output slew.
for _ in range(10):
tick(controller, sign*.01, 0.)
@@ -169,7 +61,7 @@ def test_amplitude_and_slew_limits_do_not_store_unavailable_feedback(sign):
@pytest.mark.parametrize('sign', [-1., 1.])
def test_pscm_limit_only_blocks_feedback_further_into_measured_turn(sign):
controller = ModelActionController()
controller = ModelActionController(proportional_gain=0., integral_gain=1.)
for _ in range(100):
tick(controller, sign*.004, sign*.004)
for _ in range(100):
@@ -186,7 +78,7 @@ def test_pscm_limit_only_blocks_feedback_further_into_measured_turn(sign):
@pytest.mark.parametrize('sign', [-1., 1.])
def test_pscm_limit_cannot_trap_old_correction_below_the_model_request(sign):
controller = ModelActionController()
controller = ModelActionController(proportional_gain=0., integral_gain=1.)
controller.correction = -sign*.02
controller.c1 = sign*.06
for _ in range(200):
@@ -196,7 +88,7 @@ def test_pscm_limit_cannot_trap_old_correction_below_the_model_request(sign):
def test_driver_intervention_clears_feedback_through_existing_output_slew():
controller = ModelActionController()
controller = ModelActionController(proportional_gain=0., integral_gain=1.)
for _ in range(100):
tick(controller, .004, .004)
for _ in range(100):
@@ -216,7 +108,7 @@ def test_driver_intervention_clears_feedback_through_existing_output_slew():
('current_curvature', 1.01), ('feedback_dt', math.nan),
('feedback_dt', -.001), ('feedback_dt', .151), ('active', False)])
def test_bad_feedback_inputs_and_disengagement_clear_every_control_state(field, value):
controller = ModelActionController()
controller = ModelActionController(proportional_gain=0., integral_gain=1.)
controller.correction = .03
out = tick(controller, .004, .003, **{field: value})
assert out == FordPath()
@@ -237,7 +129,7 @@ def status(now, **overrides):
def test_repeated_steering_samples_only_advance_output_slew():
controller = FordModelActionController()
controller = FordModelActionController(proportional_gain=0., integral_gain=1.)
for i in range(100):
adapter_tick(controller, 1.+i*.01, current_curvature=.004)
before = controller.core.correction
@@ -255,7 +147,7 @@ def test_repeated_steering_samples_only_advance_output_slew():
{'pscm_status': status(2.01, denied=True)},
{'pscm_status': status(2.01, lateralState=1)}])
def test_adapter_clears_feedback_when_driver_or_pscm_overrides(overrides):
controller = FordModelActionController()
controller = FordModelActionController(proportional_gain=0., integral_gain=1.)
for i in range(101):
adapter_tick(controller, 1.+i*.01)
assert controller.core.correction > 0.
@@ -268,7 +160,7 @@ def test_adapter_clears_feedback_when_driver_or_pscm_overrides(overrides):
({'canMonoTime': 0}, False), ({'canMonoTime': 1_800_000_000}, False),
({'canMonoTime': 2_020_000_000}, False), ({'limit': 1}, False)])
def test_only_fresh_reached_pscm_limit_blocks_outward_integration(overrides, limited):
controller = FordModelActionController()
controller = FordModelActionController(proportional_gain=0., integral_gain=1.)
for i in range(100):
adapter_tick(controller, 1.+i*.01, current_curvature=.004)
adapter_tick(controller, 2., pscm_status=status(2., **{'limit': 2, **overrides}))
@@ -279,7 +171,7 @@ def test_only_fresh_reached_pscm_limit_blocks_outward_integration(overrides, lim
def test_measurement_cadence_preserves_elapsed_distance_integration():
results = []
for period in (1, 2, 5):
controller = FordModelActionController()
controller = FordModelActionController(proportional_gain=0., integral_gain=1.)
for i in range(101):
now = 1.+i*.01
adapter_tick(controller, now, current_curvature=.004)
@@ -59,7 +59,7 @@ def test_extra_offset_releases_at_existing_slew_without_stored_overflow(sign):
@pytest.mark.parametrize('sign', [-1., 1.])
def test_c1_feedback_saturation_does_not_spill_correction_into_c0(sign):
controller = ModelActionController()
controller = ModelActionController(proportional_gain=0., integral_gain=1.)
for _ in range(200):
out = controller.update(straight(sign*.2), sign*.02, current_curvature=0., speed=20., dt=.01)
assert out.path_angle == pytest.approx(sign*.5)
@@ -77,14 +77,3 @@ def test_overflow_is_base_geometry_with_existing_feedback_gates(sign, enabled, l
assert out.path_offset == pytest.approx(sign*.9)
assert out.path_angle == pytest.approx(sign*.5)
assert controller.correction == 0.
@pytest.mark.parametrize('sign', [-1., 1.])
def test_overflow_cannot_replace_model_centering_confirmation_for_carryover_release(sign):
controller = ModelActionController()
controller.c0, controller.c1, controller.correction = sign*.6, -sign*.1, -sign*.6
for _ in range(20):
controller.update(straight(-sign*.1), sign*.03, current_curvature=-sign*.002, speed=20., dt=.01)
assert sign*controller.c0 > 0. # Overflow agrees with heading; model centering still opposes it.
assert controller.carryover_release_count == 0
assert sign*controller.correction < -.4
@@ -1,129 +0,0 @@
"""Changed requests should not wait for obsolete C1 correction to integrate away.
These exercise command delay, not a simulated PSCM or predicted wheel angle.
"""
import pytest
from openpilot.selfdrive.controls.lib.ford_model_action import FordModelActionController, ModelActionController
from openpilot.selfdrive.controls.tests.test_ford_model_action import straight
@pytest.mark.parametrize('sign', [-1., 1.])
@pytest.mark.parametrize('unwind', [False, True])
def test_changed_request_retires_opposing_correction_within_output_slew(sign, unwind):
controller = ModelActionController()
desired = sign*.035
model = straight(sign*.4)
for _ in range(100):
controller.update(model, desired, current_curvature=desired, speed=4., dt=.01)
# Build old unwind (or turn-in) correction while retaining the same request.
old_error = sign*(.01 if unwind else -.01)
for _ in range(50):
controller.update(model, desired, current_curvature=desired-old_error, speed=4., dt=.01)
assert controller.correction == pytest.approx(old_error*2.)
# The model now moves in the opposite direction to the stored correction.
# Actual steering is on the other side of the new target: the old correction
# is delaying exactly the response now needed. Both remain in the same turn.
changed = desired-old_error
before = controller.c1
for _ in range(20):
out = controller.update(model, changed, current_curvature=desired, speed=4., dt=.01)
assert abs(controller.c1-before) <= .100000001
assert out.path_offset == pytest.approx(sign*.4)
assert (out.path_angle-7.*changed)*old_error <= 1e-10, 'Old correction still opposes the changed model request'
@pytest.mark.parametrize('sign', [-1., 1.])
@pytest.mark.parametrize('case', ['unchanged', 'matched', 'still_short', 'helpful', 'small_error', 'small_change', 'speed_only', 'clipped_base'])
def test_request_release_preserves_correction_without_confirming_evidence(sign, case):
controller = ModelActionController()
previous, desired, measured, correction, speed = .035, .025, .035, .02, 4.
if case == 'unchanged':
desired = previous
measured = .05
elif case == 'matched':
measured = desired
elif case == 'still_short':
measured = .015
elif case == 'helpful':
correction = -.02
elif case == 'small_error':
measured = desired+.00001
elif case == 'small_change':
desired, measured = previous-.00001, .05
elif case == 'speed_only':
desired, measured, speed = previous, .05, 10.
elif case == 'clipped_base':
previous, desired, measured = .1, .09, .11
controller.c0, controller.c1, controller.correction = sign*.4, sign*.2, sign*correction
controller.last_feedback_desired = sign*previous
controller.update(straight(sign*.4), sign*desired, current_curvature=sign*measured, speed=speed, dt=.01)
assert controller.request_release == 0.
assert abs(controller.correction-sign*correction) <= abs(desired-measured)*speed*.01+1e-10
@pytest.mark.parametrize('sign', [-1., 1.])
def test_small_request_change_cannot_erase_the_entire_correction(sign):
controller = ModelActionController()
controller.c0, controller.c1, controller.correction = sign*.4, sign*.265, sign*.02
controller.last_feedback_desired = sign*.035
controller.update(straight(sign*.4), sign*.034, current_curvature=sign*.05, speed=4., dt=.01)
assert sign*controller.correction > .01 # Most of the original 0.02 rad must survive.
assert 0. < -sign*controller.request_release <= .0070000001
@pytest.mark.parametrize('sign', [-1., 1.])
def test_small_target_and_measurement_noise_cannot_erase_steady_correction(sign):
controller = ModelActionController()
controller.c0, controller.c1, controller.correction = sign*.4, sign*.265, sign*.02
controller.last_feedback_desired = sign*.035
for i in range(1000):
desired = sign*(.035+(.00001 if i % 2 else -.00001))
measured = sign*.035
controller.update(straight(sign*.4), desired, current_curvature=measured, speed=4., dt=.01)
assert controller.request_release == 0.
assert controller.correction == pytest.approx(sign*.02)
@pytest.mark.parametrize('sign', [-1., 1.])
@pytest.mark.parametrize('limited', [False, True])
def test_retiring_correction_at_pscm_limit_never_builds_extra_turn_demand(sign, limited):
controller = ModelActionController()
controller.c0, controller.c1, controller.correction = sign*.4, sign*.225, -sign*.02
controller.last_feedback_desired = sign*.035
controller.update(straight(sign*.4), sign*.045, current_curvature=sign*.035, speed=4., dt=.01, pscm_limited=limited)
assert controller.correction == 0. # Slew/limit still prevents a new outward increment here.
assert controller.request_release == pytest.approx(sign*.02)
assert controller.c1 == pytest.approx(sign*.23)
def test_adapter_retires_request_once_per_fresh_measurement_and_reset_clears_history():
controller = FordModelActionController()
def tick(now, desired, measured, stamp, **kwargs):
return controller.update(straight(.4), desired, current_curvature=measured, speed=4., yaw_rate=0., now=now,
measurement_time=stamp, model_time=now, reference_time=now, active=True, **kwargs)
for i in range(150):
now = 1.+i*.01
tick(now, .035, .035 if i < 100 else .025, now)
before = controller.core.correction
tick(2.50, .025, .035, 2.49)
assert controller.core.correction == before
assert controller.core.last_feedback_desired == .035
assert controller.diagnostics['request_release'] == 0.
tick(2.51, .025, .035, 2.51)
assert controller.diagnostics['request_release'] == pytest.approx(-before)
assert controller.core.last_feedback_desired == .025
tick(2.52, .025, .035, 2.51)
assert controller.diagnostics['request_release'] == 0.
tick(2.53, .025, .035, 2.53, valid=False)
assert controller.core.last_feedback_desired is None
assert controller.core.request_release == 0.
def test_driver_override_clears_correction_and_cannot_leave_a_pending_release():
controller = ModelActionController()
controller.correction, controller.last_feedback_desired = .02, .035
controller.update(straight(.4), .025, current_curvature=.035, speed=4., dt=.01, feedback_enabled=False)
assert controller.correction == controller.request_release == 0.
controller.update(straight(.4), .025, current_curvature=.035, speed=4., dt=.01)
assert controller.request_release == 0.
@@ -9,7 +9,7 @@ import pytest
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_PROPORTIONAL_GAIN, FordModelActionController, select_model_action_controller
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
@@ -46,8 +46,9 @@ def test_actual_startup_priority(candidate, observer, fingerprint):
selected = startup(car_params(carFingerprint=fingerprint), params=SimpleNamespace(get_bool=settings.__getitem__))
if candidate:
assert type(selected.ford_path_controller) is FordModelActionController
assert selected.ford_path_controller.core.proportional_gain == C1_PROPORTIONAL_GAIN == .25
assert selected.ford_path_controller.diagnostics['hypothesis'] == 'model-action-c1-pi-v6'
assert selected.ford_path_controller.core.proportional_gain == C1_PROPORTIONAL_GAIN == .50
assert selected.ford_path_controller.core.integral_gain == C1_INTEGRAL_GAIN == .25
assert selected.ford_path_controller.diagnostics['hypothesis'] == 'model-action-c1-pi-v7'
else:
assert selected.ford_path_controller is None
assert selected.ford_model_action == candidate
@@ -1,127 +1,78 @@
"""Unwind correction must not become a new turn after a confirmed catch-up.
import math
These reproduce controller memory, not the PSCM's physical steering response.
"""
import pytest
from openpilot.selfdrive.controls.lib.ford_model_action import FordModelActionController, ModelActionController
from openpilot.selfdrive.controls.tests.test_ford_model_action import straight
from openpilot.selfdrive.controls.lib.ford_model_action import ModelActionController
def unwind(controller, sign):
for _ in range(30):
controller.update(straight(-sign*.5), -sign*.02, current_curvature=-sign*.02, speed=4., dt=.01)
# Selected curvature relaxes, but the wheel is still too far into the old
# turn. Feedback builds the same opposite-direction correction seen in 115.
@pytest.mark.parametrize('sign', [-1., 1.])
def test_existing_integral_can_unwind_while_output_slews(sign):
core = ModelActionController(.5, .25)
core.c1, core.correction = sign*.07, sign*.03
core.update(straight(), sign*.002, current_curvature=sign*.004, speed=20., dt=.01)
assert core.correction == pytest.approx(sign*.0299)
assert core.c1 == pytest.approx(sign*.065)
@pytest.mark.parametrize('sign', [-1., 1.])
def test_unwinding_cannot_charge_opposite_correction_through_a_slew_limit(sign):
core = ModelActionController(.5, .25)
core.c1, core.correction = sign*.07, sign*.03
core.update(straight(), 0., current_curvature=sign*.5, speed=20., dt=.01, feedback_dt=.15)
assert core.correction == 0.
assert core.c1 == pytest.approx(sign*.065)
@pytest.mark.parametrize('ki', [0., .25, .5, 1.])
def test_integral_gain_scales_fresh_error_only(ki):
core = ModelActionController(0., ki)
core.c1 = .04
core.update(straight(), .002, current_curvature=.001, speed=20., dt=.01)
assert core.correction == pytest.approx(ki*.0002)
before = core.correction
core.update(straight(), 0., current_curvature=.001, speed=20., dt=.01, feedback_dt=0.)
assert core.correction == before
def test_zero_error_does_not_erase_holding_correction():
core = ModelActionController(.5, .25)
core.c1, core.correction = .14, .1
for _ in range(50):
controller.update(straight(-sign*.5), -sign*.01, current_curvature=-sign*.04, speed=4., dt=.01)
assert sign*controller.correction > .04
assert controller.unwind_direction == sign
for _ in range(30):
controller.update(straight(sign*.05), 0., current_curvature=-sign*.01, speed=4., dt=.01)
assert sign*controller.correction > .04
core.update(straight(), .002, current_curvature=.002, speed=20., dt=.01)
assert core.correction == .1
@pytest.mark.parametrize('ki', [-1., math.inf, math.nan])
def test_invalid_integral_gain_is_rejected(ki):
with pytest.raises(ValueError):
ModelActionController(.25, ki)
def test_overflowing_integral_increment_resets():
core = ModelActionController(.25, 1e308)
assert not core.update(straight(), 1., current_curvature=0., speed=55., dt=.01).valid
assert core.c0 == core.c1 == core.correction == 0.
@pytest.mark.parametrize('sign', [-1., 1.])
@pytest.mark.parametrize('requested', [0., .0001])
def test_completed_unwind_does_not_keep_requesting_a_new_turn(sign, requested):
controller = ModelActionController()
unwind(controller, sign)
before = controller.c1
# The model path now confirms the unwind direction and actual steering has
# caught the near-zero/new-direction request. Allow the original output slew
# to finish; the old unwind correction must no longer prop up C1.
for _ in range(20):
out = controller.update(straight(sign*.05), sign*requested, current_curvature=sign*requested, speed=4., dt=.01)
assert abs(controller.c1-before) <= .100000001
assert out.path_offset == pytest.approx(sign*.05)
assert controller.correction == 0., 'Stored unwind correction remains after catch-up'
assert abs(out.path_angle-7.*sign*requested) <= .000500001
assert controller.unwind_direction == 0.
def test_centering_cannot_gate_continuous_heading_correction(sign):
commands = []
for offset in (-.1, -.010001, -.01, -.009999, 0., .009999, .01, .010001, .1):
core = ModelActionController()
core.c0, core.c1, core.correction = offset, sign*.07, sign*.03
out = core.update(straight(offset), sign*.002, current_curvature=sign*.004, speed=20., dt=.01)
commands.append((out.path_angle, core.correction))
assert len(set(commands)) == 1
@pytest.mark.parametrize('sign', [-1., 1.])
@pytest.mark.parametrize('case', ['not_caught', 'old_side_request', 'model_c0_old', 'model_c0_neutral', 'slewed_c0_old',
'correction_not_dominant', 'opposite_correction', 'no_unwind', 'duplicate'])
def test_unwind_release_requires_catchup_and_confirmed_new_direction(sign, case):
controller = ModelActionController()
unwind(controller, sign)
desired = measured = 0.
offset, feedback_dt = sign*.05, .01
if case == 'not_caught':
measured = -sign*.005
elif case == 'old_side_request':
desired = measured = -sign*.001
elif case == 'model_c0_old':
offset = -sign*.05
elif case == 'model_c0_neutral':
offset = 0.
elif case == 'slewed_c0_old':
controller.c0 = -sign*.5
elif case == 'correction_not_dominant':
desired = measured = sign*.02
elif case == 'opposite_correction':
controller.correction = -sign*.01
elif case == 'no_unwind':
controller.unwind_direction = 0.
elif case == 'duplicate':
feedback_dt = 0.
before = controller.correction
controller.update(straight(offset), desired, current_curvature=measured, speed=4., dt=.01, feedback_dt=feedback_dt)
assert controller.unwind_release == 0.
assert abs(controller.correction-before) <= abs(desired-measured)*4.*feedback_dt+1e-10
@pytest.mark.parametrize('sign', [-1., 1.])
@pytest.mark.parametrize('desired', [0., .004])
def test_steady_tracking_correction_survives_matched_steering_and_noise(sign, desired):
controller = ModelActionController()
controller.correction, controller.c1, controller.c0 = sign*.05, sign*(7.*desired+.05), sign*.05
for i in range(300):
measured = sign*(desired+(1 if i % 2 else -1)*.000001)
controller.update(straight(sign*.05), sign*desired, current_curvature=measured, speed=4., dt=.01)
assert controller.unwind_direction == controller.unwind_release == 0.
assert controller.correction == pytest.approx(sign*.05)
@pytest.mark.parametrize('limited', [False, True])
def test_catchup_release_uses_existing_slew_and_is_consumed_once(limited):
controller = ModelActionController()
unwind(controller, 1.)
before, correction = controller.c1, controller.correction
controller.update(straight(.05), 0., current_curvature=.001, speed=4., dt=.01, pscm_limited=limited)
assert controller.unwind_release == correction
assert controller.c1 == pytest.approx(before-.005)
assert controller.correction <= 0. # Remaining feedback can only correct the overshoot.
assert controller.unwind_direction == 0.
controller.update(straight(.05), 0., current_curvature=.001, speed=4., dt=.01)
assert controller.unwind_release == 0.
@pytest.mark.parametrize('override', ['driver', 'invalid', 'inactive'])
def test_unwind_history_is_cleared_by_override_and_reset(override):
controller = ModelActionController()
unwind(controller, 1.)
kwargs = {'driver': {'feedback_enabled': False}, 'invalid': {'valid': False}, 'inactive': {'active': False}}[override]
controller.update(straight(.05), 0., current_curvature=0., speed=4., dt=.01, **kwargs)
assert controller.unwind_direction == controller.unwind_release == controller.correction == 0.
def test_duplicate_adapter_measurement_cannot_consume_unwind_until_fresh_catchup():
controller = FordModelActionController()
for frame in range(130):
now = 1.+frame*.01
desired = -.02 if frame < 30 else -.01 if frame < 80 else 0.
measured = -.02 if frame < 30 else -.04 if frame < 80 else -.01
controller.update(straight(-.5 if frame < 80 else .05), desired, current_curvature=measured,
speed=4., yaw_rate=0., now=now, measurement_time=now, model_time=now, reference_time=now, active=True)
assert controller.core.unwind_direction == 1.
before = controller.core.correction
for now, stamp in [(2.30, 2.29), (2.31, 2.31), (2.32, 2.31)]:
controller.update(straight(.05), 0., current_curvature=0., speed=4., yaw_rate=0., now=now,
measurement_time=stamp, model_time=now, reference_time=now, active=True)
if now == 2.30:
assert controller.core.correction == before and controller.core.unwind_direction == 1.
else:
assert controller.core.correction == controller.core.unwind_direction == 0.
assert controller.diagnostics['unwind_release'] == (before if now == 2.31 else 0.)
def test_duplicate_measurements_cannot_retire_integral(sign, limited):
core = ModelActionController()
core.c1, core.correction = sign*.07, sign*.03
core.update(straight(), -sign*.002, current_curvature=sign*.004, speed=20., dt=.01,
feedback_dt=0., pscm_limited=limited)
assert core.correction == sign*.03
assert core.proportional == pytest.approx(-sign*.06)
assert core.c1 == pytest.approx(sign*.065)
+11 -6
View File
@@ -16,7 +16,7 @@ from types import ModuleType, SimpleNamespace
import numpy as np
from openpilot.selfdrive.controls.lib import ford_model_action
from openpilot.selfdrive.controls.lib.ford_model_action import C1_PROPORTIONAL_GAIN, FordModelActionController, ModelActionController
from openpilot.selfdrive.controls.lib.ford_model_action import C1_INTEGRAL_GAIN, C1_PROPORTIONAL_GAIN, FordModelActionController
from tools.ford_pscm_lab.model_action_replay import WireCheck, field_checks, sample, table, verify_dependency
@@ -89,7 +89,7 @@ def replay(directory, output, baseline_revision=BASELINE):
feedback = {'current_curvature': c['measured'][i], 'driver_pressed': bool(cs['pressed'][i]),
'driver_torque': cs['torque'][i], 'pscm_status': status}
previous = old.update(model, c['desired'][i], **common, **(feedback if old_has_feedback else {}))
previous_count, previous_correction = controller.core.carryover_release_count, controller.core.correction
previous_count, previous_correction = getattr(controller.core, 'carryover_release_count', 0), controller.core.correction
command = controller.update(model, c['desired'][i], **feedback, **common)
for destination, result in ((baseline, previous), (commands, command)):
destination[i] = result.path_offset, result.path_angle, result.curvature, result.curvature_rate
@@ -111,7 +111,7 @@ def replay(directory, output, baseline_revision=BASELINE):
'correction_after_rad': float(correction[i]), 'base_c1_rad': float(d['heading_feedforward']),
'desired_angle_deg': float(c['desired_angle'][i]), 'actual_angle_deg': float(c['actual_angle'][i]),
'baseline_c0_c1': baseline[i, :2].tolist(), 'candidate_c0_c1': commands[i, :2].tolist()})
if controller.core.carryover_release_count > previous_count:
if getattr(controller.core, 'carryover_release_count', 0) > previous_count:
releases.append({'time_s': float(now-metadata['t0']), 'correction_before_rad': float(previous_correction),
'correction_after_rad': float(correction[i]), 'base_c1_rad': float(d['heading_feedforward']),
'desired_angle_deg': float(c['desired_angle'][i]), 'actual_angle_deg': float(c['actual_angle'][i]),
@@ -124,7 +124,8 @@ def replay(directory, output, baseline_revision=BASELINE):
assert np.all(abs(correction) <= 1.+1e-10)
weight = np.minimum(np.diff(t, append=t[-1]+.01), .03)
report = {'scope': __doc__, 'baseline_revision': baseline_revision, 'baseline_source_sha256': baseline_hash,
'proportional_gain': C1_PROPORTIONAL_GAIN,
'proportional_gain': C1_PROPORTIONAL_GAIN, 'integral_gain': C1_INTEGRAL_GAIN,
'hypothesis': controller.hypothesis,
'calibration_approved': False, 'cycles': len(t), 'active_cycles': int(valid.sum()),
'validity_matches_baseline_exactly': True, 'status_counts': dict(reasons),
'c0_matches_baseline_exactly': bool(np.array_equal(commands[:, 0], baseline[:, 0])),
@@ -170,7 +171,10 @@ def replay(directory, output, baseline_revision=BASELINE):
def stress(cycles, output):
verify_dependency(OPENDBC)
rng = np.random.default_rng(20260913)
controller, mirror, wire = ModelActionController(), ModelActionController(), WireCheck()
# This historical stress compares v5's conditional release with v4.
candidate_revision = '22d188776cb557acea459a1fca70812bdb2df46c'
candidate, candidate_hash = original_controller(candidate_revision)
controller, mirror, wire = candidate.core, type(candidate.core)(), WireCheck()
old, baseline_hash = original_controller(FEEDBACK_V4)
releases = 0
request_releases = 0
@@ -263,10 +267,11 @@ def stress(cycles, output):
'request_release_cycles': request_releases,
'unwind_release_cycles': unwind_releases,
'exact_unchanged_state_and_commands_without_unwind_release': unchanged_without_unwind_release,
'candidate_revision': candidate_revision, 'candidate_source_sha256': candidate_hash,
'exact_v4_match_after_only_declared_retirement': cycles,
'checks': 'Symmetry, resets, amplitude/slew, bounded retirement, carryover confirmation, integration, PSCM limits, CAN.',
'scope': 'Numerical software invariants only; no model of vehicle motion.', 'calibration_approved': False,
'controller_sha256': hashlib.sha256(Path(ford_model_action.__file__).read_bytes()).hexdigest()}
'controller_sha256': candidate_hash}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2)+'\n')
print(json.dumps(report, indent=2))
@@ -0,0 +1,83 @@
"""Independent arithmetic and C0-independence stress for production PI."""
import argparse
import hashlib
import json
import math
from pathlib import Path
from types import SimpleNamespace
import numpy as np
from openpilot.selfdrive.controls.lib import ford_model_action
from tools.ford_pscm_lab.model_action_replay import WireCheck
from openpilot.selfdrive.controls.lib.ford_model_action import ModelActionController
def stress(cycles, output):
wire = WireCheck()
kp, ki = .5, .25
rng = np.random.default_rng(20260913)
core, mirror, other = (ModelActionController() for _ in range(3))
for i in range(cycles):
desired, measured = rng.uniform(-.1, .1, 2)
speed, dt, offset, alternate = rng.uniform(.3, 55.), rng.uniform(.002, .1), *rng.uniform(-8., 8., 2)
active, enabled, limited = i % 211 != 0, i % 97 != 0, i % 7 == 0
feedback_dt = 0. if i % 5 == 0 else rng.uniform(.002, .15)
before = core.c0, core.c1, core.correction
args = {'speed': speed, 'dt': dt, 'feedback_dt': feedback_dt, 'active': active,
'feedback_enabled': enabled, 'pscm_limited': limited}
def model(y):
return SimpleNamespace(position=SimpleNamespace(x=[0., 20.], y=[y, y]), orientation=SimpleNamespace(z=[0., 0.]))
outputs = [core.update(model(offset), desired, current_curvature=measured, **args),
mirror.update(model(-offset), -desired, current_curvature=-measured, **args),
other.update(model(alternate), desired, current_curvature=measured, **args)]
state = np.array([core.c0, core.c1, core.correction, core.proportional])
np.testing.assert_allclose(state, -np.array([mirror.c0, mirror.c1, mirror.correction, mirror.proportional]), atol=1e-10, rtol=0.)
assert (core.c1, core.correction, core.proportional) == (other.c1, other.correction, other.proportional)
if active:
distance = max(7., speed)
base = min(.5, max(-.5, distance*desired))
target_c0 = min(5.11, max(-5.11, offset+7.*(distance*desired-base)))
assert abs(core.c0-(before[0]+min(4.*dt, max(-4.*dt, target_c0-before[0])))) <= 1e-10
p = kp*distance*(desired-measured) if enabled else 0.
integral = 0.
if enabled:
increment = ki*(desired-measured)*speed*feedback_dt
direction = measured if measured else before[1]
if limited and increment*direction > 0.:
increment = min(max(increment, min(-before[2], 0.)), max(-before[2], 0.))
integral = before[2]
if increment*integral < 0.:
cancel = math.copysign(min(abs(increment), abs(integral)), increment)
integral += cancel
increment -= cancel
lower, upper = max(-.5, before[1]-.5*dt), min(.5, before[1]+.5*dt)
request = base+p+integral
integral += min(max(increment, min(lower-request, 0.)), max(upper-request, 0.))
assert core.proportional == p and abs(core.correction-integral) <= 1e-10
request = min(.5, max(-.5, base+p+integral))
assert abs(core.c1-(before[1]+min(.5*dt, max(-.5*dt, request-before[1])))) <= 1e-10
assert abs(core.c0) <= 5.11+1e-10 and abs(core.c1) <= .5+1e-10 and abs(core.correction) <= 1.+1e-10
else:
assert np.all(state == 0.)
for command in outputs:
assert command.curvature == command.curvature_rate == 0.
wire.check(command)
result = {'cycles': cycles, 'kp': kp, 'ki': ki, 'controller_updates': 3*cycles,
'can_round_trips': wire.count, 'seed': 20260913, 'independent_c0_comparisons': cycles,
'checks': 'Independent scalar PI/unwind-first/anti-windup arithmetic, mirror symmetry, exact C0 independence, limits, slew, resets, CAN.',
'source_sha256': {str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in
(Path(__file__).resolve(), Path(ford_model_action.__file__).resolve())}}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(result, indent=2)+'\n')
print(json.dumps(result))
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--cycles', type=int, default=20_000)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
stress(args.cycles, args.output)
+109
View File
@@ -0,0 +1,109 @@
"""Verify production commands against the archived P=.50/I=.25 offline candidate.
Fixed recorded motion verifies integration parity, not physical tracking.
"""
import argparse
from concurrent.futures import ProcessPoolExecutor, as_completed
import hashlib
import json
from pathlib import Path
from types import SimpleNamespace
import numpy as np
from openpilot.selfdrive.controls.lib import ford_model_action
from openpilot.selfdrive.controls.lib.ford_model_action import select_model_action_controller
from opendbc.car.ford.values import FordFlags
from tools.ford_pscm_lab.feedback_replay import OPENDBC
from tools.ford_pscm_lab.model_action_replay import WireCheck, field_checks, sample, table, verify_dependency
ROUTES = ('112', '113', '114', '115', '116', '117', 'a0', 'a2', 'a5', 'a9', 'b8', 'b9', 'ca', 'raptor02')
SELECTED = 4
def replay(label, output):
directory = Path('.cache/ford_raptor_route02' if label == 'raptor02' else f'.cache/ford_route{label}').resolve()
previous = Path('.cache/ford_minimal_tuning/sweep')/label
output = (output/label).resolve()
if output == directory or directory in output.parents or output == previous.resolve():
raise ValueError('Output must preserve previous experiments and input extracts')
verify_dependency(OPENDBC)
archived_report = json.loads((previous/'report.json').read_text())
assert archived_report['settings'][SELECTED] == ['unwind_p50_i25', .5, .25, True]
for filename in ('route.npz', 'model_paths.npz', 'metadata.json'):
path = directory/filename
assert hashlib.sha256(path.read_bytes()).hexdigest() == archived_report['source_sha256'][str(path)]
archived = dict(np.load(previous/'commands.npz', allow_pickle=False))
metadata = json.loads((directory/'metadata.json').read_text())
with np.load(directory/'route.npz', allow_pickle=False) as raw:
r = {key: table(raw, key) for key in ('controls', 'cs', 'cc', 'model', 'params', 'pscm')}
if 'maneuver' in raw and len(raw['maneuver']):
raise ValueError('Maneuver reference reconstruction is not available in this sweep')
with np.load(directory/'model_paths.npz', allow_pickle=False) as raw:
model_ns, paths = raw['ns'], raw['paths']
c, t = r['controls'], r['controls']['t']
assert all(np.all(np.diff(stream['t']) >= 0.) for stream in r.values())
np.testing.assert_allclose(archived['t'], t-metadata['t0'], rtol=0., atol=1e-8)
cs, pa, ps = (sample(r[key], t) for key in ('cs', 'params', 'pscm'))
cc = sample(r['cc'], t, nearest=True)
mi = np.clip(np.searchsorted(r['model']['ns'], c['model_ns']), 0, len(r['model']['ns'])-1)
exact = r['model']['ns'][mi] == c['model_ns']
np.testing.assert_array_equal(model_ns, r['model']['ns'])
models = [SimpleNamespace(position=SimpleNamespace(x=p[0], y=p[1]), orientation=SimpleNamespace(z=p[2])) for p in paths]
cp = SimpleNamespace(brand='ford', flags=int(FordFlags.CANFD))
assert select_model_action_controller(cp, False) is None
controller = select_model_action_controller(cp, True)
assert controller is not None
assert (controller.core.proportional_gain, controller.core.integral_gain) == (.5, .25)
shape = (len(t),)
commands = np.zeros((*shape, 4))
valid = np.zeros(shape, bool)
names = ('heading_proportional', 'heading_correction', 'heading_feedforward', 'feedback_enabled', 'offset_overflow', 'pscm_limited')
diagnostics = {name: np.zeros(shape) for name in names}
wire = WireCheck()
for i, now in enumerate(t):
model_time = r['model']['t'][mi[i]]
services_valid = bool(c['valid'][i] and cc['valid'][i] and cs['valid'][i] and cs['can_valid'][i]
and pa['valid'][i] and r['model']['valid'][mi[i]] and exact[i]
and abs(cc['t'][i]-now) < .005 and 0. <= now-pa['t'][i] <= .15)
model = models[mi[i]] if exact[i] else None
status = SimpleNamespace(valid=bool(ps['valid'][i] and ps['status_valid'][i]), canMonoTime=round(ps['stamp'][i]*1e9),
limit=int(ps['limit'][i]), lateralState=int(ps['lateral_state'][i]), denied=bool(ps['denied'][i]))
kwargs = {'speed': cs['speed'][i], 'yaw_rate': cs['yaw'][i], 'now': now, 'measurement_time': cs['t'][i],
'model_time': model_time, 'reference_time': model_time, 'active': bool(cc['active'][i]), 'valid': services_valid,
'current_curvature': c['measured'][i], 'driver_pressed': bool(cs['pressed'][i]),
'driver_torque': cs['torque'][i], 'pscm_status': status}
command = controller.update(model, c['desired'][i], **kwargs)
commands[i] = command.path_offset, command.path_angle, command.curvature, command.curvature_rate
valid[i] = command.valid
for name in names:
diagnostics[name][i] = controller.diagnostics.get(name, 0.)
wire.check(command)
field_checks(commands, valid, t)
np.testing.assert_array_equal(commands, archived['commands'][SELECTED])
np.testing.assert_array_equal(valid, archived['valid'][SELECTED])
for name in names:
np.testing.assert_array_equal(diagnostics[name], archived[name][SELECTED])
output.mkdir(parents=True, exist_ok=True)
sources = (directory/'route.npz', directory/'model_paths.npz', directory/'metadata.json',
previous/'report.json', previous/'commands.npz', Path(__file__).resolve(), Path(ford_model_action.__file__).resolve())
result = {'scope': __doc__, 'route': label, 'cycles': len(t), 'can_round_trips': wire.count,
'production_matches_archived_trial_exactly': True, 'opendbc_revision': OPENDBC,
'hypothesis': controller.hypothesis, 'kp': .5, 'ki': .25,
'source_sha256': {str(path.resolve()): hashlib.sha256(path.read_bytes()).hexdigest() for path in sources}}
(output/'report.json').write_text(json.dumps(result, indent=2, allow_nan=False)+'\n')
return result
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--routes', nargs='+', choices=ROUTES, default=ROUTES)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--workers', type=int, default=4)
args = parser.parse_args()
with ProcessPoolExecutor(max_workers=args.workers) as pool:
jobs = {pool.submit(replay, label, args.output): label for label in args.routes}
for job in as_completed(jobs):
result = job.result()
print(json.dumps({'completed': jobs[job], 'cycles': result['cycles'], 'can_checks': result['can_round_trips']}), flush=True)
+5 -4
View File
@@ -12,13 +12,12 @@ from types import SimpleNamespace
import numpy as np
from openpilot.selfdrive.controls.lib import ford_model_action
from openpilot.selfdrive.controls.lib.ford_model_action import FordModelActionController
from tools.ford_pscm_lab.feedback_replay import OPENDBC, original_controller
from tools.ford_pscm_lab.model_action_replay import WireCheck, field_checks, sample, table, verify_dependency
BASELINE = '22d188776cb557acea459a1fca70812bdb2df46c'
CANDIDATE = 'bf00bc691def830e1beb15363d05416714c1dc42'
GAINS = (0., .1, .25, .5)
DELAYS = (0., .2, .4)
@@ -42,7 +41,8 @@ def replay(directory, output):
np.testing.assert_array_equal(model_ns, r['model']['ns'])
models = [SimpleNamespace(position=SimpleNamespace(x=p[0], y=p[1]), orientation=SimpleNamespace(z=p[2])) for p in paths]
settings = [(gain, delay) for delay in DELAYS for gain in GAINS]
controllers = [FordModelActionController(proportional_gain=gain) for gain, _ in settings]
candidate, candidate_hash = original_controller(CANDIDATE)
controllers = [type(candidate)(proportional_gain=gain) for gain, _ in settings]
reference, reference_hash = original_controller(BASELINE)
delayed = {}
for delay in DELAYS:
@@ -91,12 +91,13 @@ def replay(directory, output):
assert np.all(integral[k, ~enabled[k]] == 0.)
assert np.all(abs(integral[k]) <= 1.+1e-10)
report = {'scope': __doc__, 'baseline_revision': BASELINE, 'baseline_source_sha256': reference_hash,
'candidate_revision': CANDIDATE, 'candidate_source_sha256': candidate_hash,
'cycles': len(t), 'candidate_updates': len(settings)*len(t), 'can_round_trips': wire.count,
'zero_gain_zero_delay_matches_v5_exactly': True, 'all_c0_and_activation_match_exactly': True,
'calibration_approved': False, 'settings': [],
'source_sha256': {str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in
(directory/'route.npz', directory/'model_paths.npz', directory/'metadata.json',
Path(__file__).resolve(), Path(ford_model_action.__file__).resolve())},
Path(__file__).resolve())},
'limitations': ['Recorded model, steering, driver and PSCM inputs stay fixed; no tracking improvement score.',
'0.2/0.4 s are diagnostic timing alternatives, not fitted or validated PSCM delays.',
'Gain sweep does not identify a physically optimal or stable gain.',
+7 -7
View File
@@ -7,19 +7,18 @@ from types import SimpleNamespace
import numpy as np
from openpilot.selfdrive.controls.lib import ford_model_action
from openpilot.selfdrive.controls.lib.ford_model_action import ModelActionController
from tools.ford_pscm_lab.feedback_replay import OPENDBC, original_controller
from tools.ford_pscm_lab.model_action_replay import WireCheck, verify_dependency
from tools.ford_pscm_lab.pi_replay import BASELINE
from tools.ford_pscm_lab.pi_replay import BASELINE, CANDIDATE
def stress(cycles, gain, output):
verify_dependency(OPENDBC)
rng = np.random.default_rng(20260913)
controller = ModelActionController(proportional_gain=gain)
mirror = ModelActionController(proportional_gain=gain)
zero = ModelActionController()
candidate, candidate_hash = original_controller(CANDIDATE)
controller = type(candidate.core)(proportional_gain=gain)
mirror = type(candidate.core)(proportional_gain=gain)
zero = type(candidate.core)()
original, original_hash = original_controller(BASELINE)
wire = WireCheck()
release_count = 0
@@ -75,12 +74,13 @@ def stress(cycles, gain, output):
wire.check(out)
report = {'cycles': cycles, 'gain': gain, 'seed': 20260913, 'mirrored_updates': cycles,
'zero_gain_exact_v5_comparisons': cycles, 'can_round_trips': wire.count,
'candidate_revision': CANDIDATE, 'candidate_source_sha256': candidate_hash,
'baseline_revision': BASELINE, 'baseline_source_sha256': original_hash,
'release_cycles': release_count, 'calibration_approved': False,
'checks':
'Independent scalar PI arithmetic, combined anti-windup, mirror symmetry, slew/amplitude, driver/PSCM gates, resets, zero-P v5 parity and CAN.',
'scope': __doc__, 'source_sha256': {str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in
(Path(__file__).resolve(), Path(ford_model_action.__file__).resolve())}}
(Path(__file__).resolve(),)}}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2)+'\n')
print(json.dumps(report, indent=2))