Ford: blend geometry assistance into the action reference

This commit is contained in:
Isaac Barham
2026-09-16 12:59:09 -04:00
parent 01f5d54292
commit 9d34f77c86
21 changed files with 1129 additions and 32 deletions
+3
View File
@@ -1,5 +1,8 @@
# Direct model-path C0/C1 trial
Historical trial at `01f5d5429`. The existing geometry toggle now selects the
[geometry-assisted action reference](ford_geometry_action_hybrid_offline.md).
This trial takes priority over the untested filtered-driver change. Driver
arbitration is restored to the last driven baseline, `18ded0380`: a raw torque
crossing above 1 Nm, filtered driver input, or fresh PSCM driver override still
+162
View File
@@ -0,0 +1,162 @@
# Geometry-assisted action reference: offline experiment
Status: the revised selector is now wired behind `FordGeometryReference`, labelled
**Model Geometry Assist (Experimental)** in Sunnylink. This supersedes the direct
path trial at `01f5d5429`. The initial offline experiment below preceded wiring.
The user authorized deployment after reviewing the numeric comparison.
The requested behavior is geometry's earlier/stronger turn request, with the
original action's ordinary driving and release behavior. This experiment uses
the earlier smoothed orientation-derived curvature as geometry, not the newest
direct-path C0/C1 mapping. That direct mapping is a separate baseline.
## Proposed selection rule
`openpilot/selfdrive/controls/lib/ford_geometry_action.py` returns a single curvature reference.
Both C0 and C1 then use the existing scalar controller, including measured-wheel
feedback. No P/I gains, driver gates, upstream curvature limits, or CAN field
bounds change.
For same-sign action `A` and geometry `G`, with `|G| > |A|`:
```
H = A + smoothstep((|G| - start) / (full - start))
* (|A| / max(start, action_peak))**power * (G - A)
```
The smoothstep argument is clamped to [0, 1]. Otherwise the result is `A`.
Defaults: `start=0.010/m`, `full=0.020/m`, `power=2`.
These are experimental tuning choices, not Ford specifications. This is not a
claim of a gainless controller: the existing feedback gains remain, and the
reference selector introduces a curvature band and release exponent.
`action_peak` is the largest same-direction action magnitude in the current
geometry excursion. It resets when inactive, geometry becomes invalid, geometry
changes direction, or geometry drops below the start threshold. Repeated control
cycles using the same model frame do not accumulate extra assistance.
The denominator's `start` floor is essential. The first version normalized by
`action_peak` alone. On route 15b, geometry briefly fell below its gate and then
rebounded. A tiny action rebound rearmed full geometry: at 46.73 s, action's 3.2°
wheel-equivalent reference became 103°. The revised version produces 4.3°, with
C1 -0.0095 rad instead of -0.1805 rad. A minimized regression test failed in both
directions before the fix and passed afterward.
## Replay scope and results
Five routes: 149, 151, 157, 15a, 15b. 578,741 unique control cycles, approximately
55.7 minutes of active valid input. The 86 existing marked windows overlap;
they are not 86 independent turns or statistically independent observations.
Compare original action, earlier geometry, newest direct path, stronger
same-direction source, 50/50 blend, and the proposed hybrid. All use identical
recorded motion, driver input and PSCM status. Feedback is recalculated at native
control cadence; new vehicle motion is not simulated.
- On 15b's large left, hybrid/geometry/action peak references are 359°/361°/218°.
Hybrid C0 peaks at 4.33 m versus geometry's 4.41 m and action's 1.27 m.
Hybrid and geometry both reach C1's 0.50 rad field bound.
- The hybrid reference crosses below 25° at the action's time, 0.85 s before
geometry. At approximately 173 s, hybrid and action C0/C1 are both
-0.26 m / -0.0745 rad. These are commands, not measured unwind times.
- Across 76 eligible marked entry windows, median retained geometry extra demand
is 91%, with a 10th percentile of 51%. Eligibility includes same-direction
samples before the marked action peak, geometry above 50° wheel-equivalent and
more than 5° stronger than action. It excludes conflicting-direction preview.
- In 83 windows with paired sustained release crossings, median added reference
delay versus action is 0 s; maximum is 0.102 s. Route 157 event 5 disengages
before a sustained hybrid release can be observed. First threshold crossings
do not rule out later rebounds; final crossings and complete traces were also
inspected. Some windows include another turn or driver intervention.
- For gentle raw sources (both below 0.010/m), the selector returns action.
Limiter and integrator history can still differ. Pooled 95th-percentile
C0/C1 differences are zero, but worst C1 difference is 0.0755 rad; on 15a the
C1 95th-percentile difference is 0.002 rad.
## Limits of the design
1. A temporary action dip looks like an exit to the selector. In route 151,
before the action later recovers, action/geometry/hybrid are approximately
26°/122°/30°. This rule cannot prove that reducing assistance there is correct.
2. Conflicting-direction preview is discarded. On 15b event 4, the hybrid retains
only about 5% of eligible extra geometry demand before the marked action peak.
3. Large geometry requests can be inherited, including approximately 807° on an
older route. Similar command numbers do not establish correct path following.
4. Rapid low-speed commands remain. Across these routes, at below 15 mph,
hybrid has 1,054 adjacent C0 changes over 0.25 m and 583 C1 changes over
0.05 rad, versus geometry's 972/558 and action's 658/478. These include recorded
driver-gate transitions and are not a physical comfort measurement.
5. The selector has peak memory. Closely spaced same-direction turns can inherit
the earlier action peak until geometry crosses the reset condition.
This is a promising numeric division of work, not evidence of improved physical
tracking. No PSCM model, neural-model rerun, or camera replay was used. The recent
routes log action and geometry separately and join by exact model timestamp.
Older geometry is reconstructed with recorded delay/model smoothing; speed is
approximated by the first control sample consuming the model frame.
## Validation and artifacts
Production integration carries the geometry reference inside the same `modelV2`
message as the original action. Neither `modelV2.action` nor
`drivingModelData.action` is overwritten. controlsd selects the hybrid at control
cadence before the existing curvature limiter; desired curvature/steering angle,
C0/C1 and measured-wheel feedback all use that combined reference. Missing,
invalid or mismatched geometry falls back to action and clears selector memory.
Inactive/invalid controller inputs and lateral-maneuver priority also clear it.
Diagnostics identify `geometry-assisted-action-feedback-v18` and include geometry
assistance weight and the stored action peak. Geometry telemetry's selected field
now describes the smoothed geometry source, not the final controlsd target.
Keep **Selected-Action Path Tracking** and **Model Geometry Assist** enabled for
the hybrid. The stored geometry key is unchanged, so an enabled direct-path trial
becomes this hybrid after updating and restarting. Geometry assist off selects
action-only; the main controller toggle off selects upstream Ford control even
if the geometry key remains enabled. Settings apply after an offroad/onroad cycle.
The production adapter was replayed over all five routes again. Every value in
every output column exactly matches the approved offline candidate, including
reference, C0/C1, correction state and validity. See
`ford_geometry_action_hybrid_validation.json` for counts, source hashes and
the equality check. Real serialized messages are additionally tested through
the actual controlsd selection/limiter/feedback branch and real CAN packing.
- 20 prototype tests, including finite inputs/configuration, repeated frames,
direction changes, causality and the real exit-rebound regression.
- Six-variant replay checked finite outputs, field bounds, zero C2/C3 and driver
feedback suppression; real CAN packing/unpacking every tenth control cycle
supplied 347,256 checks. The revised hybrid added 57,876 checks.
- Unchanged baseline columns can be reused when iterating the selector. A complete
fresh revised 15a run exactly matched every output in the cached-baseline run.
- Twelve reference settings on each recent route: start 0.006/0.010/0.015 per m,
full twice start, release power 1/2/4/8. These alternatives check references
only; the selected configuration also has complete C0/C1 replay. Larger powers
sacrifice more entry demand and can increase command discontinuities.
Outputs are under `.cache/ford_hybrid` (first version),
`.cache/ford_hybrid_guarded` (revised), and `.cache/ford_hybrid_verify/15a`
(fresh replay equivalence check). Per-route JSON records runtime source hashes;
older first-version hashes intentionally differ from the revised source. HTML
and six plots are in the existing report server directory, at
`http://127.0.0.1:52447/geometry-action-hybrid.html`.
Reproduce using the configured Python environment and existing cached route data:
```sh
PYTHONPATH=.:opendbc_repo python -m pytest -q -p no:cacheprovider \
openpilot/selfdrive/controls/tests/test_ford_geometry_action.py
PYTHONPATH=.:opendbc_repo python tools/ford_pscm_lab/hybrid_reference_replay.py \
--route 15a --output .cache/ford_hybrid_fresh
PYTHONPATH=.:opendbc_repo python tools/ford_pscm_lab/hybrid_reference_sweep.py \
--route 15a --output .cache/ford_hybrid_fresh
PYTHONPATH=.:opendbc_repo python tools/ford_pscm_lab/hybrid_reference_report.py \
--root .cache/ford_hybrid_guarded --initial-root .cache/ford_hybrid \
--output /path/to/report/directory
```
Repeat full replay for the other route labels and sensitivity for 15b. The report
includes both favorable and unfavorable examples; it does not tune to a single
turn or claim the replayed feedback trajectory would survive changed actuation.
@@ -0,0 +1,118 @@
{
"baseline_commit": "01f5d54292056a64aafa7750f30d2a7871304d04",
"hypothesis": "geometry-assisted-action-feedback-v18",
"settings": {
"controller": "FordModelActionController",
"geometry_assist": "FordGeometryReference",
"start_curvature": 0.01,
"full_curvature": 0.02,
"release_power": 2.0
},
"regression_tests_passed": 678,
"regression_subtests_passed": 2,
"hybrid_unit_and_integration_tests": 35,
"ruff": "passed",
"sunnylink_schema_compile_check": "passed",
"validation_scope": "Frozen-motion replay and code/message/CAN tests; physical steering response is not simulated.",
"routes": {
"149": {
"cycles": 132334,
"wire_checks": 13234,
"every_output_column_exactly_matches_offline_candidate": true,
"active_valid_seconds": 1247.577060015,
"geometry_source": "Reconstructed geometry with recorded delay/model smoothing; speed approximated by first consuming control sample",
"input_and_runtime_sha256": {
".cache/ford_route149/full/route.npz": "aa5902877343cd033ee286b3668d91a85336ffbf740861849fb0b76b0ca24ade",
".cache/ford_route149/full/metadata.json": "624fff03c25eb298661cb7b25f3dbe6d214d863f799d93635c0f4f05fc0d2b32",
".cache/ford_route149/intake.npz": "503919c4f1566ef850007c59f16ce074697ba57732f21beb7b158c1ca7be3a9d",
".cache/ford_route149/intake.json": "80f8c4ba69b4259ed308e0db110123063c6917357d26b49c9b6585695c9b24ae",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/hybrid_reference_replay.py": "068c746a1a6c5642a3430d6efb44b43d1ae6951cc4ad0e65721f1275dae31add",
"openpilot/selfdrive/controls/lib/ford_geometry_action.py": "f92ee4a952049be980229bbb2b4fb3f6d61d8d5876c6df8e7bace418b1fac80f",
"openpilot/selfdrive/controls/lib/ford_model_action.py": "5d90f819c03b109877bb8aa7ed44db30083518da24e61d9612d13540e7ace9ad",
".cache/ford_hybrid_guarded/149/commands.npz": "bf40a870d33edbc0dec0d90206b7f082fee3a54f807bd61dd87e56102a571ba8",
".cache/ford_hybrid_guarded/149/report.json": "963ed7e2c7ffe647ad5ea7d9c4530df54fa6f555423532efc89710aa20cc054a"
}
},
"151": {
"cycles": 325708,
"wire_checks": 32571,
"every_output_column_exactly_matches_offline_candidate": true,
"active_valid_seconds": 1355.493226190003,
"geometry_source": "Reconstructed geometry with recorded delay/model smoothing; speed approximated by first consuming control sample",
"input_and_runtime_sha256": {
".cache/ford_route151/full/route.npz": "41a5b8bd388cf5a3d553f784542376ac9355fcdc5be4f427053d0504537babe1",
".cache/ford_route151/full/metadata.json": "937825317a0edd470c54647240b922be8f79dda5b3365ffdd61281f0aca877a1",
".cache/ford_route151/intake.npz": "18bbefb7738cebd0071dc987e90f74469a0c8ed456f9412324030592cafd68c9",
".cache/ford_route151/intake.json": "0cfe327b5889f2603d902d2a01e915bf9a27b7a312137111d892a5e8540e03aa",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/hybrid_reference_replay.py": "068c746a1a6c5642a3430d6efb44b43d1ae6951cc4ad0e65721f1275dae31add",
"openpilot/selfdrive/controls/lib/ford_geometry_action.py": "f92ee4a952049be980229bbb2b4fb3f6d61d8d5876c6df8e7bace418b1fac80f",
"openpilot/selfdrive/controls/lib/ford_model_action.py": "5d90f819c03b109877bb8aa7ed44db30083518da24e61d9612d13540e7ace9ad",
".cache/ford_hybrid_guarded/151/commands.npz": "b01843e65718f51695a0e7642bb90c894a74e576e15e3e28b9101528b9052953",
".cache/ford_hybrid_guarded/151/report.json": "8849332e600372ef405d098cb0f4eeeb4dc87100041af3ecddfe0d24b88ebd20"
}
},
"157": {
"cycles": 76554,
"wire_checks": 7656,
"every_output_column_exactly_matches_offline_candidate": true,
"active_valid_seconds": 527.940320743,
"geometry_source": "Reconstructed geometry with recorded delay/model smoothing; speed approximated by first consuming control sample",
"input_and_runtime_sha256": {
".cache/ford_route157/full/route.npz": "e2e2573f904aa11ee9e10450e7f5b965d475657b61127e827a67eadcd6b857fb",
".cache/ford_route157/full/metadata.json": "f2d72c9a877ed6694e4da6831841113dc0c05987e2c76cca51f358d12411b235",
".cache/ford_route157/intake.npz": "5dffd86fde68197727d6c6b9c5eae82320a456fa1206c4cf18e5e9c69419dde4",
".cache/ford_route157/intake.json": "1d82c7f87d42229b96771bce3dd43c6b1651aa4fb8bb0ca3b1886d5a1a1c1a3c",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/hybrid_reference_replay.py": "068c746a1a6c5642a3430d6efb44b43d1ae6951cc4ad0e65721f1275dae31add",
"openpilot/selfdrive/controls/lib/ford_geometry_action.py": "f92ee4a952049be980229bbb2b4fb3f6d61d8d5876c6df8e7bace418b1fac80f",
"openpilot/selfdrive/controls/lib/ford_model_action.py": "5d90f819c03b109877bb8aa7ed44db30083518da24e61d9612d13540e7ace9ad",
".cache/ford_hybrid_guarded/157/commands.npz": "49f02f7471d6fbb548f7f46a5615153a4079c2d29bc33cedf63987089eb9fb4b",
".cache/ford_hybrid_guarded/157/report.json": "5aca689ca41bc98796f081f211212799d5a58ca90af0f94232fcc841d8af9e43"
}
},
"15a": {
"cycles": 21867,
"wire_checks": 2187,
"every_output_column_exactly_matches_offline_candidate": true,
"active_valid_seconds": 65.37018141000004,
"geometry_source": "Logged original action and selected geometry; exact model timestamp joins",
"input_and_runtime_sha256": {
".cache/ford_route15a/rlog_full/route.npz": "057ce69ce028b2c9c1b42ab8110f35c888a6c398ddcc4156e74afc98c83d119d",
".cache/ford_route15a/rlog_full/metadata.json": "bea47c7f0c0b583964ced3484fe814ec9a7a24abbfe6272e70f9dde12580a756",
".cache/ford_route15a/rlog_full/model_paths.npz": "ef820d9df14afa308bf46f95463b636a599ff24fd08bffaff1ae671b3932c24a",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/hybrid_reference_replay.py": "068c746a1a6c5642a3430d6efb44b43d1ae6951cc4ad0e65721f1275dae31add",
"openpilot/selfdrive/controls/lib/ford_geometry_action.py": "f92ee4a952049be980229bbb2b4fb3f6d61d8d5876c6df8e7bace418b1fac80f",
"openpilot/selfdrive/controls/lib/ford_model_action.py": "5d90f819c03b109877bb8aa7ed44db30083518da24e61d9612d13540e7ace9ad",
".cache/ford_hybrid_guarded/15a/commands.npz": "b2648c067840336ddf49ec732459a9325e7d02e15215b514438364df6733c0c7",
".cache/ford_hybrid_guarded/15a/report.json": "9ac33ad43dc8e395402a89ef0fcee44875428e4e7e39c7cc31c4e3d8e8b1153f"
}
},
"15b": {
"cycles": 22278,
"wire_checks": 2228,
"every_output_column_exactly_matches_offline_candidate": true,
"active_valid_seconds": 148.29076710100003,
"geometry_source": "Logged original action and selected geometry; exact model timestamp joins",
"input_and_runtime_sha256": {
".cache/ford_route15b/rlog_full/route.npz": "f95e1c16a4677fee0ab57002d714e22716bf11873c308280553312fa9d18aab0",
".cache/ford_route15b/rlog_full/metadata.json": "545ef8d6740e57237f35b2ca684111fc2d774e83aa1d6c8a5a0fff1b71270e85",
".cache/ford_route15b/rlog_full/model_paths.npz": "a90be55943ce0009afbf38ef2035b3fff4de8ab00ea266636247bcc5a5012eae",
"/Users/ibpersonal/.codex/worktrees/1a1c/sunnypilot/tools/ford_pscm_lab/hybrid_reference_replay.py": "068c746a1a6c5642a3430d6efb44b43d1ae6951cc4ad0e65721f1275dae31add",
"openpilot/selfdrive/controls/lib/ford_geometry_action.py": "f92ee4a952049be980229bbb2b4fb3f6d61d8d5876c6df8e7bace418b1fac80f",
"openpilot/selfdrive/controls/lib/ford_model_action.py": "5d90f819c03b109877bb8aa7ed44db30083518da24e61d9612d13540e7ace9ad",
".cache/ford_hybrid_guarded/15b/commands.npz": "11c35c5c35680d60471aeb50c14479226d7a5494c9edb509e8bfb790509d44cf",
".cache/ford_hybrid_guarded/15b/report.json": "245fb9d5ae00391d3c5a0a0acbbc01726b02fe33cea5ed6196911b660ca36b33"
}
}
},
"source_sha256": {
"openpilot/cereal/log.capnp": "836fe271f91ce39603889bcbca37439ca62c62271a7ab91579a4a9dd4ccb3b65",
"openpilot/cereal/custom.capnp": "854840fae9bb3b7dda26a3532004ffc3450235dab7cdb2e735cbeeb41d0bf728",
"openpilot/selfdrive/controls/controlsd.py": "d54d5838499a7f151f8475a2ea45dc7fd624fba69d49fdf6edc9525a3ee9bd12",
"openpilot/selfdrive/controls/lib/ford_geometry_action.py": "f92ee4a952049be980229bbb2b4fb3f6d61d8d5876c6df8e7bace418b1fac80f",
"openpilot/selfdrive/controls/lib/ford_model_action.py": "5d90f819c03b109877bb8aa7ed44db30083518da24e61d9612d13540e7ace9ad",
"openpilot/sunnypilot/modeld_v2/ford_geometry.py": "55d144886dfa7cb8c300f7cbfed435d9bd0072d95dcfa295107b227ef63702b9",
"openpilot/selfdrive/controls/tests/test_ford_geometry_action.py": "1454e068faf7a603e6cbb61d07d85a02f1022e7f384e2a468707090f004d3698"
},
"unique_control_cycles": 578741,
"production_adapter_wire_checks": 57876
}
+3
View File
@@ -1,5 +1,8 @@
# Ford model geometry reference trial
Current behavior: [geometry-assisted action reference](ford_geometry_action_hybrid_offline.md).
The descriptions below document the earlier geometry-only trial.
This documents the initial curvature-reference trial at `18ded0380`. The
subsequent [direct model-path trial](ford_direct_path.md) reuses the geometry
toggle and changes controlsd's reference and C0/C1 mapping. modeld telemetry
+1 -1
View File
@@ -490,7 +490,7 @@ struct ModelDataV2SP @0xa1680744031fdb2d {
modelMonoTime @2 :UInt64;
actionDesiredCurvature @3 :Float32; # Original model action, with its own unchanged smoothing history.
rawCurvature @4 :Float32;
selectedCurvature @5 :Float32; # Published modelV2.action, before controlsd's normal limits/maneuver override.
selectedCurvature @5 :Float32; # Smoothed geometry; controlsd combines it with the original action before normal limits.
previewSeconds @6 :Float32;
smoothSeconds @7 :Float32;
}
+2
View File
@@ -1076,6 +1076,8 @@ struct ModelDataV2 {
# e2e lateral planner
action @26: Action;
# Atomic action/geometry pair for the opt-in Ford reference selector.
fordGeometryReference @28 :Custom.ModelDataV2SP.FordGeometryReference;
lateralPlannerSolutionDEPRECATED @25: Deprecated.LateralPlannerSolution;
leadsDEPRECATED @11 :List(LeadDataV2DEPRECATED);
+8 -3
View File
@@ -57,7 +57,7 @@ class Controls(ControlsExt):
self.desired_curvature = 0.0
self.ford_path_controller = select_model_action_controller(self.CP, self.params.get_bool("FordModelActionController"),
c0_time_based=self.params.get_bool("FordC0TimeBased"),
direct_path=self.params.get_bool("FordGeometryReference"))
geometry_assist=self.params.get_bool("FordGeometryReference"))
self.ford_model_action = isinstance(self.ford_path_controller, FordModelActionController)
if self.CP.brand == "ford":
cloudlog.event("Ford path controller selected",
@@ -151,9 +151,14 @@ class Controls(ControlsExt):
# Steering PID loop and lateral MPC
# Reset desired curvature to current to avoid violating the limits on engage
if self.sm.valid['lateralManeuverPlan']:
if self.ford_model_action and self.ford_path_controller.geometry_assist is not None:
self.ford_path_controller.geometry_assist.reset()
new_desired_curvature = self.sm['lateralManeuverPlan'].desiredCurvature if CC.latActive else self.curvature
elif self.ford_model_action and self.ford_path_controller.direct_path:
new_desired_curvature = self.ford_path_controller.path_curvature(model_v2, CS.vEgo) if CC.latActive else self.curvature
elif self.ford_model_action and self.ford_path_controller.geometry_assist is not None:
reference = self.ford_path_controller.select_reference(
model_v2, model_mono_time=self.sm.logMonoTime['modelV2'], active=CC.latActive,
valid=CS.canValid and self.sm.all_checks(['carState', 'vehicleParameters', 'modelV2']))
new_desired_curvature = reference if CC.latActive else self.curvature
else:
new_desired_curvature = model_v2.action.desiredCurvature if CC.latActive else self.curvature
self.desired_curvature, curvature_limited = clip_curvature(CS.vEgo, self.desired_curvature, new_desired_curvature, lp.roll)
@@ -0,0 +1,41 @@
"""Opt-in action baseline with geometry assistance for stronger turn requests.
The curvature band and release exponent are experimental choices. No PSCM
dynamics or physical tracking are modeled by this reference selector.
"""
import math
class GeometryActionHybrid:
def __init__(self, start_curvature=.01, full_curvature=.02, release_power=2.):
if not all(math.isfinite(x) for x in (start_curvature, full_curvature, release_power)) or not 0. < start_curvature < full_curvature or release_power <= 0:
raise ValueError('Expected positive curvature band and release exponent')
self.start, self.full, self.release_power = start_curvature, full_curvature, release_power
self.reset()
def reset(self):
self.direction = self.action_peak = self.weight = 0.
def update(self, action, geometry, *, active=True):
if not math.isfinite(action):
self.reset()
return 0.
if not active or not math.isfinite(geometry) or abs(geometry) <= self.start:
self.reset()
return action
direction = math.copysign(1., geometry)
if direction != self.direction:
self.reset()
self.direction = direction
self.weight = 0.
if action*geometry <= 0:
return action
self.action_peak = max(self.action_peak, abs(action))
if abs(geometry) <= abs(action):
return action
progress = min((abs(geometry)-self.start)/(self.full-self.start), 1.)
turn_weight = progress*progress*(3.-2.*progress)
# A tiny action rebound after a geometry reset must not authorize full boost.
release_weight = (abs(action)/max(self.start, self.action_peak))**self.release_power
self.weight = turn_weight*release_weight
return action+self.weight*(geometry-action)
@@ -15,6 +15,7 @@ import numpy as np
from opendbc.car.ford.values import CarControllerParams, FordFlags
from openpilot.selfdrive.controls.lib.drive_helpers import clip_curvature
from openpilot.selfdrive.controls.lib.ford_geometry_action import GeometryActionHybrid
from openpilot.selfdrive.controls.lib.ford_path import FordPath, _model_path
@@ -184,13 +185,28 @@ class FordModelActionController:
neither a limit nor a repeated measurement freezes the model request.
"""
def __init__(self, proportional_gain=C1_PROPORTIONAL_GAIN, integral_gain=C1_INTEGRAL_GAIN, *, c0_time_based=False,
c0_proportional_gain=C0_PROPORTIONAL_GAIN, direct_path=False):
c0_proportional_gain=C0_PROPORTIONAL_GAIN, direct_path=False, geometry_assist=False):
self.core = ModelActionController(proportional_gain=proportional_gain, integral_gain=integral_gain, c0_time_based=c0_time_based,
c0_proportional_gain=c0_proportional_gain)
self.direct_path = bool(direct_path)
self.geometry_assist = GeometryActionHybrid() if geometry_assist else None
self.hypothesis = 'model-path-direct-feedback-v17' if self.direct_path else 'model-action-curvature-c0-feedback-v15'
if self.geometry_assist is not None:
if self.direct_path:
raise ValueError('Geometry assistance uses the scalar C0/C1 mapper')
self.hypothesis = 'geometry-assisted-action-feedback-v18'
self.reset()
def select_reference(self, model, *, model_mono_time, active, valid):
"""Combine only an atomic, matching geometry/action pair; missing geometry uses action."""
action = float(model.action.desiredCurvature)
if self.geometry_assist is None:
return action
ref = getattr(model, 'fordGeometryReference', None)
matched = ref is not None and ref.enabled and ref.valid and ref.modelMonoTime == model_mono_time
geometry = float(ref.selectedCurvature) if matched else math.nan
return self.geometry_assist.update(action, geometry, active=active and valid)
def path_curvature(self, model, speed):
"""Heading-equivalent feedback target; controlsd limits and logs this value."""
target = encode_model_path(model, speed, c0_time_based=self.core.c0_time_based)
@@ -206,6 +222,8 @@ class FordModelActionController:
def reset(self, status='inactive'):
self.core.reset()
if self.geometry_assist is not None:
self.geometry_assist.reset()
self.last_time = self.last_measurement_time = self.last_model_time = None
self.diagnostics = {'status': status, 'hypothesis': self.hypothesis,
'c0_time_based': self.core.c0_time_based,
@@ -271,13 +289,15 @@ class FordModelActionController:
'feedback_error': self.core.feedback_curvature-current_curvature,
'driver_override': driver_override, 'pscm_limited': pscm_limited, 'pscm_status_fresh': bool(status_fresh),
'command': (command.path_offset, command.path_angle, 0., 0.)}
if self.geometry_assist is not None:
self.diagnostics.update(geometry_assistance=self.geometry_assist.weight, action_peak=self.geometry_assist.action_peak)
return command
def select_model_action_controller(CP, enabled, *, c0_time_based=False, direct_path=False):
def select_model_action_controller(CP, enabled, *, c0_time_based=False, direct_path=False, geometry_assist=False):
"""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, integral_gain=C1_INTEGRAL_GAIN,
c0_time_based=c0_time_based, direct_path=direct_path)
c0_time_based=c0_time_based, direct_path=direct_path, geometry_assist=geometry_assist)
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-curvature-c0-distance-pi-v14')
self.assertEqual(record['hypothesis'], 'model-action-curvature-c0-feedback-v15')
self.assertIs(record['calibration_approved'], False)
self.assertEqual(record['command'][2:], [0., 0.])
self.assertEqual(record['status'], controller.diagnostics['status'])
@@ -107,22 +107,22 @@ def test_feedback_tracks_path_heading_and_raw_torque_override_is_still_the_drive
@pytest.mark.parametrize('geometry', [False, True])
@pytest.mark.parametrize('maneuver', [False, True])
def test_actual_controlsd_path_selection_feedback_logging_publication_and_can(pipeline, geometry, maneuver): # noqa: F811
def test_missing_geometry_reference_falls_back_to_action_through_controlsd_and_can(pipeline, geometry, maneuver): # noqa: F811
settings = {'FordModelActionController': True, 'FordGeometryReference': geometry}
controls = startup(params=SimpleNamespace(get_bool=lambda key: settings.get(key, False)))
controls.sm, controls.desired_curvature, controls.curvature = Subscriptions(maneuver), 0., 0.
assert controls.ford_path_controller.direct_path == geometry
assert not controls.ford_path_controller.direct_path
assert (controls.ford_path_controller.geometry_assist is not None) == geometry
model = circle(.01)
model.action = SimpleNamespace(desiredCurvature=-.03) # Deliberately opposite to the model path.
cc = structs.CarControl(latActive=True)
cs = SimpleNamespace(vEgo=5., yawRate=0., canValid=True, steeringPressed=False, steeringTorque=0.)
exec(pipeline[0], {'self': controls, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': model,
'lp': SimpleNamespace(roll=0.), 'clip_curvature': clip_curvature, 'time': SimpleNamespace(monotonic=lambda: 1.)})
direct = geometry and not maneuver
expected = .002 if direct else -.002
expected = -.002
assert controls.desired_curvature == pytest.approx(expected)
assert controls.ford_path_controller.core.feedback_curvature == controls.desired_curvature
assert controls.ford_path_controller.diagnostics['direct_path'] == direct
assert not controls.ford_path_controller.diagnostics['direct_path']
assert controls.ford_path.path_angle*expected > 0.
msg = custom.CarControlSP.new_message()
exec(pipeline[1], {'self': controls, 'CC_SP': msg})
@@ -0,0 +1,185 @@
import math
from types import SimpleNamespace
import pytest
from openpilot.selfdrive.controls.lib.ford_geometry_action import GeometryActionHybrid
from openpilot.selfdrive.controls.lib.ford_model_action import FordModelActionController
from openpilot.selfdrive.controls.tests.test_ford_model_action_adapter import Subscriptions, pipeline # noqa: F401
from openpilot.selfdrive.controls.tests.test_ford_model_action_selection import startup
@pytest.mark.parametrize('sign', [-1., 1.])
def test_build_and_hold_keep_geometry_strength_then_release_follows_action(sign):
h = GeometryActionHybrid()
assert h.update(sign*.005, sign*.04) == pytest.approx(sign*(.005+.25*.035))
for action in [.01, .02]+[.02]*100:
assert h.update(sign*action, sign*.04) == pytest.approx(sign*.04)
# Half the action peak leaves a quarter of the geometry excess.
assert h.update(sign*.01, sign*.04) == pytest.approx(sign*(.01+.25*.03))
assert h.update(0., sign*.04) == 0.
assert h.update(-sign*.005, sign*.04) == pytest.approx(-sign*.005)
def test_small_action_dip_does_not_switch_all_assistance_off():
h = GeometryActionHybrid()
h.update(.02, .04)
assert .035 < h.update(.019, .04) < .04
assert h.update(.02, .04) == pytest.approx(.04)
@pytest.mark.parametrize('sign', [-1., 1.])
def test_tiny_action_cannot_restart_full_assistance_after_geometry_dips(sign):
h = GeometryActionHybrid()
h.update(sign*.03, sign*.04)
h.update(-sign*.001, sign*.003)
# Route 15b's exit: geometry rebounds after falling below the turn band.
# A tiny same-direction action must not authorize the entire geometry request.
assert abs(h.update(sign*.0008, sign*.025)) < .0012
@pytest.mark.parametrize('action,geometry', [(0., .04), (-.01, .04), (.02, .01), (.003, .006), (-.003, -.009)])
def test_conflicting_weaker_and_gentle_geometry_preserve_action(action, geometry):
assert GeometryActionHybrid().update(action, geometry) == action
def test_exit_cannot_rearm_from_small_action_rebound_until_the_geometry_excursion_ends():
h = GeometryActionHybrid()
h.update(.03, .04)
h.update(0., .04)
assert h.update(.003, .04) < .004
h.update(0., 0.)
assert h.update(.003, .04) == pytest.approx(.003+.09*.037)
def test_repeated_model_frames_are_idempotent_and_inactive_invalid_inputs_reset():
h = GeometryActionHybrid()
h.update(.02, .04)
expected = h.update(.01, .04)
for _ in range(100):
assert h.update(.01, .04) == expected
assert h.update(.01, .04, active=False) == .01 and h.action_peak == 0.
assert h.update(.01, math.nan) == .01 and h.action_peak == 0.
assert h.update(math.nan, .04) == 0. and h.action_peak == 0.
def test_sign_change_starts_a_fresh_excursion():
h = GeometryActionHybrid()
h.update(.02, .04)
assert h.update(-.003, -.04) == pytest.approx(-(.003+.09*.037))
def test_causal_output_stays_between_sources_for_both_directions():
h = GeometryActionHybrid()
for i in range(10000):
action, geometry = .05*math.sin(i*.012), .08*math.sin(i*.008)
result = h.update(action, geometry)
assert min(action, geometry)-1e-12 <= result <= max(action, geometry)+1e-12
assert 0. <= h.weight <= 1.
@pytest.mark.parametrize('values', [(0., .02, 2.), (.02, .01, 2.), (.01, .02, 0.), (.01, .02, math.nan), (math.nan, .02, 2.)])
def test_invalid_configuration_is_rejected(values):
with pytest.raises(ValueError):
GeometryActionHybrid(*values)
def test_future_predictions_cannot_change_earlier_outputs():
prefix = [(.003+i*.001, .04) for i in range(20)]
results = []
for future in [[(.005, .04)]*20, [(-.005, -.04)]*20]:
h = GeometryActionHybrid()
results.append([h.update(a, g) for a, g in prefix+future][:len(prefix)])
assert results[0] == results[1]
@pytest.mark.parametrize('controller_enabled', [False, True])
@pytest.mark.parametrize('geometry_enabled', [False, True])
def test_actual_startup_selects_upstream_action_or_hybrid(controller_enabled, geometry_enabled):
settings = {'FordModelActionController': controller_enabled, 'FordGeometryReference': geometry_enabled}
controls = startup(params=SimpleNamespace(get_bool=lambda key: settings.get(key, False)))
if not controller_enabled:
assert controls.ford_path_controller is None
return
assert not controls.ford_path_controller.direct_path
assert (controls.ford_path_controller.geometry_assist is not None) == geometry_enabled
expected = 'geometry-assisted-action-feedback-v18' if geometry_enabled else 'model-action-curvature-c0-feedback-v15'
assert controls.ford_path_controller.hypothesis == expected
@pytest.mark.parametrize('invalid', ['missing', 'disabled', 'invalid', 'older', 'future', 'nonfinite', 'inactive', 'service'])
def test_adapter_rejects_missing_mismatched_or_invalid_geometry_and_clears_peak(invalid):
controller = FordModelActionController(geometry_assist=True)
ref = SimpleNamespace(enabled=True, valid=True, modelMonoTime=1_000_000_000, selectedCurvature=.04)
model = SimpleNamespace(action=SimpleNamespace(desiredCurvature=.02), fordGeometryReference=ref)
kwargs = {'model_mono_time': 1_000_000_000, 'active': True, 'valid': True}
assert controller.select_reference(model, **kwargs) == .04
model.action.desiredCurvature = .003
if invalid == 'missing':
del model.fordGeometryReference
elif invalid == 'disabled':
ref.enabled = False
elif invalid == 'invalid':
ref.valid = False
elif invalid in ('older', 'future'):
ref.modelMonoTime += -1 if invalid == 'older' else 1
elif invalid == 'nonfinite':
ref.selectedCurvature = math.nan
else:
kwargs['active' if invalid == 'inactive' else 'valid'] = False
assert controller.select_reference(model, **kwargs) == .003
assert controller.geometry_assist.action_peak == controller.geometry_assist.weight == 0.
def test_incompatible_direct_and_hybrid_modes_are_rejected():
with pytest.raises(ValueError):
FordModelActionController(direct_path=True, geometry_assist=True)
@pytest.mark.parametrize('maneuver', [False, True])
def test_atomic_model_publication_through_controlsd_limiter_feedback_and_can(pipeline, maneuver): # noqa: F811
from opendbc.car import structs
from openpilot.cereal import log
from openpilot.selfdrive.controls.lib.drive_helpers import clip_curvature
from openpilot.sunnypilot.modeld_v2.tests.test_ford_geometry import messages, apply
from openpilot.sunnypilot.modeld_v2.ford_geometry import FordGeometryReference
from tools.ford_pscm_lab.model_action_replay import WireCheck
controls = startup(params=SimpleNamespace(get_bool=lambda key: key in ('FordModelActionController', 'FordGeometryReference')))
controls.sm, controls.desired_curvature, controls.curvature = Subscriptions(maneuver), 0., 0.
expected_selector = GeometryActionHybrid()
baseline = FordModelActionController()
publisher = FordGeometryReference()
previous = 0.
wire = WireCheck()
# Build, hold, release, tiny rebound and direction change. Each model frame is
# consumed five times at control cadence, with a real Cap'n Proto round trip.
for i, action in enumerate([.015]*20+[.008, .004, .001, 0., .0008, -.005, -.015]):
m, d, sp, _ = messages()
now_model = .98+i*.05
m.logMonoTime = round(now_model*1e9)
m.modelV2.action.desiredCurvature = d.drivingModelData.action.desiredCurvature = action
apply(publisher, m, d, sp)
with log.Event.from_bytes(m.to_bytes()) as decoded:
model = decoded.modelV2
for j in range(5):
now = 1.+i*.05+j*.01
controls.sm.logMonoTime.update(modelV2=m.logMonoTime, carState=round(now*1e9), lateralManeuverPlan=round(now*1e9))
cc = structs.CarControl(latActive=True)
cs = SimpleNamespace(vEgo=5., yawRate=0., canValid=True, steeringPressed=False, steeringTorque=0.)
target = -.1 if maneuver else expected_selector.update(model.action.desiredCurvature, model.fordGeometryReference.selectedCurvature)
previous, _ = clip_curvature(5., previous, target, 0.)
expected = baseline.update(model, previous, current_curvature=0., speed=5., yaw_rate=0., now=now,
measurement_time=now, model_time=now_model, reference_time=now if maneuver else now_model,
active=True, curvature_scale=controls.VM.get_steer_from_curvature(1., 5., 0.) /
(controls.CP.steerRatio*controls.CP.wheelbase),
reference_source='lateralManeuverPlan' if maneuver else 'modelV2')
exec(pipeline[0], {'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 controls.desired_curvature == pytest.approx(previous)
assert controls.ford_path == expected
assert controls.ford_path_controller.core.feedback_curvature == pytest.approx(previous)
assert controls.ford_path_controller.diagnostics['hypothesis'] == 'geometry-assisted-action-feedback-v18'
if maneuver:
assert controls.ford_path_controller.geometry_assist.action_peak == 0.
wire.check(controls.ford_path)
@@ -35,10 +35,10 @@ class FordGeometryReference:
return raw, selected
def apply(self, model_msg, driving_msg, sp_msg, *, speed, preview, smooth_seconds, smoothing_enabled):
"""Keep the original action separately; both published actions use the selected reference.
"""Publish geometry alongside the untouched action for controlsd's selector.
The caller retains its original prev_action, so this experiment cannot feed
geometry into the learned action's smoothing or change longitudinal control.
Embed the pair in modelV2 as well as diagnostic telemetry so controlsd never
has to join independently delivered publications from different frames.
"""
model = model_msg.modelV2
ref = sp_msg.modelDataV2SP.fordGeometryReference
@@ -51,6 +51,7 @@ class FordGeometryReference:
speed=speed, preview=preview, smooth_seconds=smooth_seconds, smoothing_enabled=smoothing_enabled)
ref.valid = result is not None
if result is not None:
ref.rawCurvature, model.action.desiredCurvature = result
driving_msg.drivingModelData.action.desiredCurvature = model.action.desiredCurvature
ref.selectedCurvature = model.action.desiredCurvature
ref.rawCurvature, ref.selectedCurvature = result
else:
ref.selectedCurvature = model.action.desiredCurvature
model.fordGeometryReference = ref
+1 -1
View File
@@ -418,7 +418,7 @@ def main(demo=False):
ford_model_action = params.get_bool("FordModelActionController")
ford_geometry = select_ford_geometry_reference(CP, ford_model_action, params.get_bool("FordGeometryReference"))
if CP.brand == 'ford':
cloudlog.event('Ford model reference selected', source='geometry' if ford_geometry is not None else 'action')
cloudlog.event('Ford model reference selected', source='geometry_assist_inputs' if ford_geometry is not None else 'action')
# TODO Move smooth seconds to action function
long_delay = CP.longitudinalActuatorDelay + model.LONG_SMOOTH_SECONDS
@@ -102,6 +102,7 @@ def test_publication_preserves_original_history_and_longitudinal_and_serializes_
apply(FordGeometryReference(), m, d, sp)
assert original.to_dict() == before # prev_action held by modeld must remain the learned action.
assert m.modelV2.action.to_dict() == d.drivingModelData.action.to_dict()
assert m.modelV2.action.to_dict() == before
assert m.modelV2.action.desiredAcceleration == original.desiredAcceleration
assert m.modelV2.action.shouldStop == original.shouldStop
with log.Event.from_bytes(sp.to_bytes()) as decoded:
@@ -109,7 +110,7 @@ def test_publication_preserves_original_history_and_longitudinal_and_serializes_
assert ref.enabled and ref.valid == (not invalid)
assert ref.modelMonoTime == m.logMonoTime
assert ref.actionDesiredCurvature == original.desiredCurvature
assert ref.selectedCurvature == m.modelV2.action.desiredCurvature
assert ref.to_dict() == m.modelV2.fordGeometryReference.to_dict()
if invalid:
assert ref.selectedCurvature == original.desiredCurvature
else:
@@ -121,14 +122,15 @@ def test_published_geometry_flows_through_actual_controlsd_selection_feedback_an
from opendbc.car import structs
m, d, sp, _ = messages()
m.modelV2.action.desiredCurvature = d.drivingModelData.action.desiredCurvature = .015
apply(FordGeometryReference(), m, d, sp)
controls = startup()
controls = startup(params=SimpleNamespace(get_bool=lambda k: k in ('FordModelActionController', 'FordGeometryReference')))
controls.sm, controls.desired_curvature, controls.curvature = Subscriptions(maneuver), 0., 0.
cc = structs.CarControl(latActive=True)
cs = SimpleNamespace(vEgo=5., yawRate=0., canValid=True, steeringPressed=False, steeringTorque=0., steeringAngleDeg=0.)
exec(pipeline[0], {'self': controls, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': m.modelV2,
'lp': SimpleNamespace(roll=0.), 'clip_curvature': clip_curvature, 'time': SimpleNamespace(monotonic=lambda: 1.)})
# Geometry asks right; original action and maneuver both ask left. The unchanged
# Both sources ask right; maneuver asks left and retains priority. The unchanged
# upstream jerk limit permits 0.002 curvature in this first 10 ms step.
expected = -.002 if maneuver else .002
assert controls.desired_curvature == pytest.approx(expected)
@@ -167,5 +169,7 @@ def test_actual_modeld_hook_uses_exact_timing_after_original_action_history_is_s
return
expected = smooth_value(get_curvature_from_plan(list(m.modelV2.orientation.z), list(m.modelV2.orientationRate.z),
TIMES, 5., .743946), 0., .1)
assert m.modelV2.action.desiredCurvature == pytest.approx(expected)
assert m.modelV2.action.desiredCurvature == pytest.approx(-.003)
assert sp.modelDataV2SP.fordGeometryReference.selectedCurvature == pytest.approx(expected)
assert m.modelV2.fordGeometryReference.to_dict() == sp.modelDataV2SP.fordGeometryReference.to_dict()
assert sp.modelDataV2SP.fordGeometryReference.previewSeconds == pytest.approx(.743946)
@@ -2184,7 +2184,7 @@
"needs_onroad_cycle": true,
"title": "Selected-Action Path Tracking (Experimental)",
"description": "Follow the selected model reference using path-offset and heading commands with measured steering feedback on any Ford CAN FD vehicle.",
"details": "Uses the selected desired curvature, or the model path when Model Geometry Reference is enabled, with correction based on requested versus measured steering. Uses remaining path-offset range when the base heading request reaches its limit. The correction holds when steering matches and clears on driver override. Default off; physical tracking and turn-exit behavior are not road-validated. Enable only for controlled testing. Turning it off restores upstream Ford curvature control, regardless of any previously stored experimental settings. Only Ford CAN FD vehicles can use this experiment. Changes apply after a real offroad-to-onroad cycle, not immediately or on disengagement alone.",
"details": "Uses the selected desired curvature, with geometry assistance when Model Geometry Assist is enabled, with correction based on requested versus measured steering. Uses remaining path-offset range when the base heading request reaches its limit. The correction holds when steering matches and clears on driver override. Default off; physical tracking and turn-exit behavior are not road-validated. Enable only for controlled testing. Turning it off restores upstream Ford curvature control, regardless of any previously stored experimental settings. Only Ford CAN FD vehicles can use this experiment. Changes apply after a real offroad-to-onroad cycle, not immediately or on disengagement alone.",
"enablement": [
{
"type": "offroad_only"
@@ -2195,9 +2195,9 @@
"key": "FordGeometryReference",
"widget": "toggle",
"needs_onroad_cycle": true,
"title": "Model Geometry Reference (Experimental)",
"description": "Sample model lateral position for C0 and model heading for C1, with measured steering feedback.",
"details": "On uses direct model-path commands. C0 samples at 7 metres unless the C0 distance option is enabled; C1 samples at the larger of 7 metres or one second of travel. Feedback follows the heading from that same path. Command bounds and request limits remain active. Off restores the original model-action mapping. Requires Selected-Action Path Tracking; otherwise upstream Ford control remains selected. This changes the steering reference and has not been road-validated. Default off. Changes apply after an offroad-to-onroad cycle.",
"title": "Model Geometry Assist (Experimental)",
"description": "Add model geometry for stronger turn requests, then fade that assistance as the model action unwinds.",
"details": "Uses the model action for gentle requests. Adds stronger geometry demand when both sources agree on turn direction, and reduces the extra demand as the action falls from its peak. Both path commands follow the combined reference with measured steering feedback. Temporary action dips can also reduce assistance. Command bounds and request limits remain active. Off restores action-only control. Requires Selected-Action Path Tracking; turning that main toggle off restores upstream Ford control. Experimental and default off. Changes apply after an offroad-to-onroad cycle.",
"enablement": [
{
"type": "offroad_only"
@@ -15,15 +15,15 @@ sections:
needs_onroad_cycle: true
title: Selected-Action Path Tracking (Experimental)
description: Follow the selected model reference using path-offset and heading commands with measured steering feedback on any Ford CAN FD vehicle.
details: Uses the selected desired curvature, or the model path when Model Geometry Reference is enabled, with correction based on requested versus measured steering. Uses remaining path-offset range when the base heading request reaches its limit. The correction holds when steering matches and clears on driver override. Default off; physical tracking and turn-exit behavior are not road-validated. Enable only for controlled testing. Turning it off restores upstream Ford curvature control, regardless of any previously stored experimental settings. Only Ford CAN FD vehicles can use this experiment. Changes apply after a real offroad-to-onroad cycle, not immediately or on disengagement alone.
details: Uses the selected desired curvature, with geometry assistance when Model Geometry Assist is enabled, with correction based on requested versus measured steering. Uses remaining path-offset range when the base heading request reaches its limit. The correction holds when steering matches and clears on driver override. Default off; physical tracking and turn-exit behavior are not road-validated. Enable only for controlled testing. Turning it off restores upstream Ford curvature control, regardless of any previously stored experimental settings. Only Ford CAN FD vehicles can use this experiment. Changes apply after a real offroad-to-onroad cycle, not immediately or on disengagement alone.
enablement:
- $ref: '#/macros/offroad'
- key: FordGeometryReference
widget: toggle
needs_onroad_cycle: true
title: Model Geometry Reference (Experimental)
description: Sample model lateral position for C0 and model heading for C1, with measured steering feedback.
details: On uses direct model-path commands. C0 samples at 7 metres unless the C0 distance option is enabled; C1 samples at the larger of 7 metres or one second of travel. Feedback follows the heading from that same path. Command bounds and request limits remain active. Off restores the original model-action mapping. Requires Selected-Action Path Tracking; otherwise upstream Ford control remains selected. This changes the steering reference and has not been road-validated. Default off. Changes apply after an offroad-to-onroad cycle.
title: Model Geometry Assist (Experimental)
description: Add model geometry for stronger turn requests, then fade that assistance as the model action unwinds.
details: Uses the model action for gentle requests. Adds stronger geometry demand when both sources agree on turn direction, and reduces the extra demand as the action falls from its peak. Both path commands follow the combined reference with measured steering feedback. Temporary action dips can also reduce assistance. Command bounds and request limits remain active. Off restores action-only control. Requires Selected-Action Path Tracking; turning that main toggle off restores upstream Ford control. Experimental and default off. Changes apply after an offroad-to-onroad cycle.
enablement:
- $ref: '#/macros/offroad'
- type: param
@@ -0,0 +1,2 @@
"""Compatibility import for the offline replay; production owns the tested selector."""
from openpilot.selfdrive.controls.lib.ford_geometry_action import GeometryActionHybrid as GeometryActionHybrid
@@ -0,0 +1,240 @@
"""Offline action/geometry reference comparison on frozen recorded vehicle motion.
All variants share the current gains, driver gates and PSCM status. The direct
baseline keeps its independent C0 path mapping; scalar variants use the driven
curvature-to-C0/C1 mapper. This predicts commands, never the resulting trajectory.
"""
import argparse
import hashlib
import json
from pathlib import Path
from types import SimpleNamespace
import numpy as np
from opendbc.car.vehicle_model import VehicleModel
from openpilot.selfdrive.controls.lib.drive_helpers import clip_curvature
from openpilot.selfdrive.controls.lib.ford_model_action import FordModelActionController
from openpilot.sunnypilot.modeld_v2.constants import ModelConstants
from openpilot.sunnypilot.modeld_v2.ford_geometry import FordGeometryReference
from tools.ford_pscm_lab.model_action_replay import WireCheck, sample, table
def load_route(label):
root = Path('.cache')/f'ford_route{label}'
recent = label in ('15a', '15b')
source = root/('rlog_full' if recent else 'full')
meta = json.loads((source/'metadata.json').read_text())
with np.load(source/'route.npz') as z:
r = {k: table(z, k) for k in ('controls', 'cs', 'cc', 'path', 'params', 'pscm', 'model')}
if recent:
r['geometry'] = table(z, 'geometry')
c = r['controls']
t = c['t']
cs, pa, ps = [sample(r[k], t) for k in ('cs', 'params', 'pscm')]
cc, sent = [sample(r[k], t, nearest=True) for k in ('cc', 'path')]
sources = [source/'route.npz', source/'metadata.json']
if recent:
with np.load(source/'model_paths.npz') as z:
paths, ns = z['paths'], z['ns']
g = r['geometry']
gi = np.clip(np.searchsorted(g['model_ns'], ns), 0, len(g['t'])-1)
assert np.array_equal(g['model_ns'][gi], ns)
action = g['action'][gi]
geometry = np.where(g['valid'][gi]*g['reference_valid'][gi] > 0, g['selected'][gi], action)
geometry_method = 'Logged original action and selected geometry; exact model timestamp joins'
sources.append(source/'model_paths.npz')
else:
with np.load(root/'intake.npz') as z:
m, paths, delay = z['model'], z['plans'], z['delay']
intake = json.loads((root/'intake.json').read_text())
settings = intake['settings'][0]['params']
bundle = next(b for b in intake['bundles'].values() if b['internalName'] == settings['ModelManager_ActiveBundleChestnut'])
tau = float(next(o['value'] for o in bundle['overrides'] if o['key'] == 'lat'))
ns, action = m[:, 3], m[:, 7]
ci = np.clip(np.searchsorted(c['model_ns'], ns), 0, len(t)-1)
speed = np.maximum(cs['speed'][ci], 0.)
learned_delay = delay[np.clip(np.searchsorted(delay[:, 0], m[:, 0], side='right')-1, 0, len(delay)-1), 3]
preview = learned_delay+tau+.075+.4*np.clip((30-speed/.44704)/15, 0, 1)
reference = FordGeometryReference()
geometry = np.empty(len(m))
for j, path in enumerate(paths):
result = reference.update(path[2], path[3], ModelConstants.T_IDXS, speed=speed[j], preview=preview[j],
smooth_seconds=tau, smoothing_enabled=bundle['generation'] >= 10)
geometry[j] = action[j] if result is None else np.float32(result[1])
geometry_method = 'Reconstructed geometry with recorded delay/model smoothing; speed approximated by first consuming control sample'
sources += [root/'intake.npz', root/'intake.json']
mi = np.clip(np.searchsorted(ns, c['model_ns']), 0, len(ns)-1)
exact = ns[mi] == c['model_ns']
models = [SimpleNamespace(position=SimpleNamespace(x=p[0], y=p[1]), orientation=SimpleNamespace(z=p[2]),
action=SimpleNamespace(desiredCurvature=float(action[j])),
fordGeometryReference=SimpleNamespace(enabled=True, valid=True, modelMonoTime=int(ns[j]),
selectedCurvature=float(geometry[j]))) for j, p in enumerate(paths)]
active = cc['active'].astype(bool)
valid = c['valid']*cs['valid']*cs['can_valid']*pa['valid'] > 0
valid &= exact & (abs(t-cs['t']) <= .15) & (t-c['model_ns']*1e-9 >= -.005) & (t-c['model_ns']*1e-9 <= .15)
valid &= (cs['speed'] >= .3) & (cs['speed'] <= 55.)
car = meta['car'][0]
cp = SimpleNamespace(**{k: car[k] for k in ('mass', 'wheelbase', 'centerToFront', 'steerRatioRear', 'tireStiffnessFront', 'tireStiffnessRear')},
steerRatio=car['steer_ratio'], rotationalInertia=0.)
summary = (Path('.cache/ford_routes158_159')/f'{label}_summary.json') if recent else root/'summary.json'
events = json.loads(summary.read_text())['events']
return SimpleNamespace(label=label, source=source, meta=meta, c=c, t=t, cs=cs, pa=pa, ps=ps, sent=sent,
active=active, valid=valid, models=models, mi=mi, action=action[mi], geometry=geometry[mi], cp=cp,
events=events, sources=sources, geometry_method=geometry_method)
def sustained(t, mask, seconds=.2):
indices = np.flatnonzero(mask)
if not len(indices):
return None
breaks = np.r_[0, np.flatnonzero((np.diff(indices) > 1) | (np.diff(t[indices]) > .03))+1, len(indices)]
for start, end in zip(breaks[:-1], breaks[1:], strict=True):
if t[indices[end-1]]-t[indices[start]] >= seconds:
return float(t[indices[start]])
return None
def summarize(d, a, names):
t = a['t']
live = a['valid'].astype(bool)
dt = np.minimum(np.diff(t, append=t[-1]+.01), .03)
consecutive = live[1:] & live[:-1] & (np.diff(t) < .03)
low = consecutive & (a['speed'][1:] < 15*.44704)
ordinary = live & (abs(d.action) < .01) & (abs(d.geometry) < .01)
results = {'cycles': len(t), 'active_valid_s': float(dt[live].sum()), 'geometry_source': d.geometry_method,
'ordinary_definition': 'Both action and geometry below 0.01 1/m; includes state carried out of earlier turns',
'variants': {}, 'events': []}
for name in names:
results['variants'][name] = {
'c0_bound_s': float(dt[live & (abs(a[name+'_c0']) >= 5.105)].sum()),
'c1_bound_s': float(dt[live & (abs(a[name+'_c1']) >= .49975)].sum()),
'low_speed_c0_steps_over_025m': int((abs(np.diff(a[name+'_c0'])[low]) > .250001).sum()),
'low_speed_c1_steps_over_005rad': int((abs(np.diff(a[name+'_c1'])[low]) > .050001).sum()),
'ordinary_difference_from_action_p50_p95_max': {
k: np.quantile(abs(a[name+'_'+k][ordinary]-a['action_'+k][ordinary]), [.5, .95, 1]).tolist() for k in ('angle', 'c0', 'c1')},
}
for e in d.events:
lo, hi = e['window']
window = live & (t >= lo) & (t <= hi)
if not window.any():
continue
sign = 1 if e['direction'] == 'left' else -1
action_peak_i = np.flatnonzero(window)[np.argmax(sign*a['action_angle'][window])]
action_peak_t = t[action_peak_i]
build = window & (t <= action_peak_t)
release = window & (t >= action_peak_t)
item = {'id': e['id'], 'direction': e['direction'], 'window': [lo, hi], 'action_peak_t': float(action_peak_t), 'variants': {}}
extra = sign*(a['geometry_angle']-a['action_angle'])
assist = build & (extra > 5.) & (sign*a['action_angle'] > 0.) & (sign*a['geometry_angle'] > 50.)
for name in names:
angle = sign*a[name+'_angle']
last_above = np.flatnonzero(release & (angle >= 25.))
last_c1 = np.flatnonzero(release & (-sign*a[name+'_c1'] >= .05))
item['variants'][name] = {
'peak_angle_deg': float(max(angle[window])),
'entry_50_s': sustained(t, build & (angle >= 50.)),
'release_25_s': sustained(t, release & (angle < 25.)),
'last_above_25_s': float(t[last_above[-1]]) if len(last_above) else None,
'peak_abs_c0': float(max(abs(a[name+'_c0'][window]))), 'peak_abs_c1': float(max(abs(a[name+'_c1'][window]))),
'c1_release_005_s': sustained(t, release & (-sign*a[name+'_c1'] < .05)),
'c1_last_above_005_s': float(t[last_c1[-1]]) if len(last_c1) else None,
'entry_geometry_extra_retained_fraction': (float(np.sum(sign*(a[name+'_angle']-a['action_angle'])[assist]*dt[assist]) /
np.sum(extra[assist]*dt[assist])) if assist.any() else None),
}
results['events'].append(item)
return results
def run(label, output, baseline=None):
d = load_route(label)
names = ['action', 'geometry', 'direct', 'stronger', 'half', 'hybrid']
evaluated = ['hybrid'] if baseline else names
cores = [FordModelActionController(direct_path=name == 'direct', geometry_assist=name == 'hybrid') for name in evaluated]
wires = [WireCheck() for _ in evaluated]
hybrid_controller = cores[evaluated.index('hybrid')]
hybrid = hybrid_controller.geometry_assist
selected = np.zeros(len(evaluated))
vm = VehicleModel(d.cp)
columns = ['t', 'valid', 'active', 'speed', 'driver', 'wheel', 'weight', 'action_peak', 'action_raw', 'geometry_raw']
for name in names:
columns.extend(name+'_'+k for k in ('reference', 'angle', 'c0', 'c1', 'i'))
rows = np.empty((len(d.t), len(columns)))
saved_columns = list(range(10))+[columns.index(name+'_'+k) for name in evaluated for k in ('reference', 'angle', 'c0', 'c1', 'i')]
if baseline:
with np.load(baseline/label/'commands.npz') as z:
assert z['names'].tolist() == columns
rows[:] = z['rows']
np.testing.assert_allclose(rows[:, 0], d.t-d.meta['t0'], atol=1e-9)
for i, now in enumerate(d.t):
cs, pa, ps, c = d.cs, d.pa, d.ps, d.c
vm.update_params(max(pa['stiffness'][i], .1), max(pa['steer_ratio'][i], .1))
scale = vm.get_steer_from_curvature(1., cs['speed'][i], 0.)/(d.cp.steerRatio*d.cp.wheelbase)
error = c['desired'][i]-c['measured'][i]
angle_scale = -vm.sR/vm.curvature_factor(cs['speed'][i])*180./np.pi
if abs(error) > 1e-5:
angle_scale = (c['desired_angle'][i]-c['actual_angle'][i])/error
scale = -np.radians(angle_scale)/(d.cp.steerRatio*d.cp.wheelbase)
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]))
action, geometry = float(d.action[i]), float(d.geometry[i])
maximum = geometry if action*geometry > 0 and abs(geometry) > abs(action) else action
combined = hybrid_controller.select_reference(d.models[d.mi[i]], model_mono_time=int(c['model_ns'][i]),
active=bool(d.active[i]), valid=bool(d.valid[i]))
targets = {'action': action, 'geometry': geometry, 'stronger': maximum, 'half': .5*(action+geometry), 'hybrid': combined}
if 'direct' in evaluated:
targets['direct'] = cores[evaluated.index('direct')].path_curvature(d.models[d.mi[i]], cs['speed'][i])
row = [now-d.meta['t0'], False, d.active[i], cs['speed'][i],
cs['pressed'][i] or abs(cs['torque'][i]) > 1. or status.limit == 3, c['actual_angle'][i],
hybrid.weight, hybrid.action_peak, action, geometry]
validity = []
for j, (core, name) in enumerate(zip(cores, evaluated, strict=True)):
target = targets[name]
selected[j], _ = clip_curvature(cs['speed'][i], selected[j], target if d.active[i] else c['measured'][i], pa['roll'][i])
command = core.update(d.models[d.mi[i]], selected[j], current_curvature=c['measured'][i],
speed=cs['speed'][i], yaw_rate=cs['yaw'][i], now=now, measurement_time=cs['t'][i],
model_time=c['model_ns'][i]*1e-9, reference_time=c['model_ns'][i]*1e-9,
active=bool(d.active[i]), valid=bool(d.valid[i]),
driver_pressed=bool(cs['pressed'][i]), driver_torque=cs['torque'][i],
pscm_status=status, curvature_scale=scale, roll=pa['roll'][i])
assert np.isfinite([command.path_offset, command.path_angle]).all()
assert abs(command.path_offset) <= 5.1100001 and abs(command.path_angle) <= .5000001
assert command.curvature == command.curvature_rate == 0.
if command.valid and row[4] and (status.limit != 3 or (status.valid and -.005 <= now-ps['stamp'][i] <= .15)):
assert not core.diagnostics['feedback_enabled']
assert core.core.correction == core.core.proportional == core.core.offset_proportional == 0.
if i % 10 == 0:
wires[j].check(command)
angle = c['desired_angle'][i]+angle_scale*(selected[j]-c['desired'][i])
row.extend([selected[j], angle, command.path_offset, command.path_angle, core.core.correction])
validity.append(command.valid)
assert all(v == validity[0] for v in validity)
row[1] = validity[0]
if baseline:
assert rows[i, 1] == row[1]
rows[i, saved_columns] = row
a = dict(zip(columns, rows.T, strict=True))
results = summarize(d, a, names)
results['wire_checks'] = sum(w.count for w in wires)
results['variants_evaluated_this_run'] = evaluated
results['cached_baseline'] = str(baseline/label) if baseline else None
results['reference_design'] = {'start_curvature': hybrid.start, 'full_curvature': hybrid.full, 'release_power': hybrid.release_power}
files = d.sources+[Path(__file__), Path('openpilot/selfdrive/controls/lib/ford_geometry_action.py'),
Path('openpilot/selfdrive/controls/lib/ford_model_action.py')]
if baseline:
files += [baseline/label/'commands.npz', baseline/label/'report.json']
results['sources_sha256'] = {str(f): hashlib.sha256(f.read_bytes()).hexdigest() for f in files}
dest = output/label
dest.mkdir(parents=True, exist_ok=True)
np.savez_compressed(dest/'commands.npz', names=columns, rows=rows)
(dest/'report.json').write_text(json.dumps(results, indent=2, allow_nan=False)+'\n')
print(json.dumps({'route': label, 'cycles': results['cycles'], 'wire_checks': results['wire_checks'], 'events': len(results['events'])}), flush=True)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--route', required=True)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--baseline', type=Path, help='Reuse unchanged baseline columns and replay the hybrid only')
args = parser.parse_args()
run(args.route, args.output, args.baseline)
@@ -0,0 +1,222 @@
"""Build an offline command-comparison report; no simulated PSCM response."""
# ruff: noqa: E501
import argparse
import hashlib
import json
from pathlib import Path
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np
LABELS = ['149', '151', '157', '15a', '15b']
VARIANTS = ['action', 'geometry', 'direct', 'stronger', 'half', 'hybrid']
TITLES = {'action': 'Action only', 'geometry': 'Geometry only', 'direct': 'Direct path',
'stronger': 'Stronger source', 'half': '50/50 blend', 'hybrid': 'Hybrid'}
COLORS = {'action': '#1971c2', 'geometry': '#d18a00', 'hybrid': '#087f5b', 'initial': '#bd3545'}
def load(path):
with np.load(path) as z:
return dict(zip(z['names'], z['rows'].T, strict=True))
def cells(values):
return '<tr>'+''.join('<td>'+str(v)+'</td>' for v in values)+'</tr>'
def table(headers, rows):
return '<div class="scroll"><table><thead><tr>'+''.join('<th>'+x+'</th>' for x in headers)+'</tr></thead><tbody>'+''.join(rows)+'</tbody></table></div>'
def plot(a, dest, title, window, direction, initial=None):
t = a['t']
mask = (t >= window[0]) & (t <= window[1])
live = a['valid'][mask].astype(bool)
x = t[mask]
sign = 1 if direction == 'left' else -1
fig, axes = plt.subplots(4, 1, figsize=(11.4, 9.2), sharex=True, gridspec_kw={'height_ratios': [2, 1.35, 1.35, .8]})
fig.suptitle(title, x=.09, ha='left', fontsize=17, fontweight='bold')
labels = ['Reference\n(wheel-equivalent °)', 'C0 command\n(m)', 'C1 command\n(rad)']
for ax, key, ylabel in zip(axes, ['angle', 'c0', 'c1'], labels, strict=False):
for name in ['geometry', 'action', 'hybrid']:
y = a[name+'_'+key][mask]*sign*(1 if key == 'angle' else -1)
ax.plot(x, np.where(live, y, np.nan), label=TITLES[name], color=COLORS[name], lw=2.1 if name == 'hybrid' else 1.6)
if initial is not None:
y = initial['hybrid_'+key][mask]*sign*(1 if key == 'angle' else -1)
ax.plot(x, np.where(live, y, np.nan), label='Rejected first hybrid', color=COLORS['initial'], ls='--', lw=1.5)
ax.set_ylabel(ylabel)
axes[0].legend(loc='best', fontsize=9, framealpha=.95, ncol=2)
axes[-1].plot(x, a['weight'][mask]*100, color=COLORS['hybrid'], lw=1.5)
axes[-1].set_ylabel('Geometry\nassistance (%)')
axes[-1].set_ylim(-5, 105)
axes[-1].set_yticks([0, 50, 100])
axes[-1].set_xlabel('Seconds from route start · positive values point into the named turn')
for ax in axes:
ax.grid(alpha=.2)
ax.axhline(0, color='#868e96', lw=.7)
ax.fill_between(x, 0, 1, where=(a['driver'][mask] > 0) & live, color='#89939e', alpha=.10,
transform=ax.get_xaxis_transform(), linewidth=0)
ax.spines[['top', 'right']].set_visible(False)
ax.margins(x=0)
fig.text(.09, .018, 'Gray shading: recorded driver/override indication. These are replayed requests, not predicted wheel motion.',
fontsize=9, color='#495057')
fig.subplots_adjust(left=.10, right=.98, top=.92, bottom=.075, hspace=.12)
fig.savefig(dest, dpi=150, facecolor='white')
plt.close(fig)
def run(root, initial_root, dest):
dest.mkdir(parents=True, exist_ok=True)
arrays = {s: load(root/s/'commands.npz') for s in LABELS}
reports = {s: json.loads((root/s/'report.json').read_text()) for s in LABELS}
initial = {s: load(initial_root/s/'commands.npz') for s in ['15a', '15b']}
all_a = {k: np.concatenate([a[k] for a in arrays.values()]) for k in arrays['15a']}
live = all_a['valid'].astype(bool)
ordinary = live & (abs(all_a['action_raw']) < .01) & (abs(all_a['geometry_raw']) < .01)
events = [(s, e) for s, r in reports.items() for e in r['events']]
retained, release_delta, entry_lead = [], [], []
censored = []
for s, e in events:
a, h = [e['variants'][v] for v in ['action', 'hybrid']]
if h['entry_geometry_extra_retained_fraction'] is not None:
retained.append(h['entry_geometry_extra_retained_fraction'])
if None not in (a['release_25_s'], h['release_25_s']):
release_delta.append(h['release_25_s']-a['release_25_s'])
elif a['release_25_s'] is not None:
censored.append([s, e['id']])
if None not in (a['entry_50_s'], h['entry_50_s']):
entry_lead.append(a['entry_50_s']-h['entry_50_s'])
metrics = {
'route_labels': LABELS, 'unique_control_cycles': len(live), 'active_valid_cycles': int(live.sum()),
'active_valid_seconds': sum(r['active_valid_s'] for r in reports.values()),
'marked_windows_including_overlaps': len(events), 'entry_extra_eligible_windows': len(retained),
'entry_geometry_extra_retained_p10_p50_p90': np.quantile(retained, [.1, .5, .9]).tolist(),
'paired_reference_releases': len(release_delta), 'reference_release_delay_p50_p90_max': np.quantile(release_delta, [.5, .9, 1]).tolist(),
'hybrid_release_not_observed_before_disengagement': censored,
'paired_reference_entries': len(entry_lead), 'reference_entry_lead_p50_p90_max': np.quantile(entry_lead, [.5, .9, 1]).tolist(),
'ordinary_cycles': int(ordinary.sum()), 'ordinary_p95_difference_from_action': {}, 'totals': {},
'comparison_baseline_commit': '01f5d5429', 'report_scope': 'offline_command_comparison',
}
ordinary_rows, totals_rows, route_rows = [], [], []
for v in VARIANTS:
ordinary_diff = {k: np.quantile(abs(all_a[v+'_'+k]-all_a['action_'+k])[ordinary], [.5, .95, 1]).tolist() for k in ['angle', 'c0', 'c1']}
metrics['ordinary_p95_difference_from_action'][v] = ordinary_diff
ordinary_rows.append(cells([TITLES[v], *[f'{ordinary_diff[k][1]:.4f}' for k in ['angle', 'c0', 'c1']]]))
sums = {k: sum(r['variants'][v][k] for r in reports.values()) for k in [
'c0_bound_s', 'c1_bound_s', 'low_speed_c0_steps_over_025m', 'low_speed_c1_steps_over_005rad']}
metrics['totals'][v] = sums
totals_rows.append(cells([TITLES[v], f"{sums['c0_bound_s']:.1f}", f"{sums['c1_bound_s']:.1f}",
sums['low_speed_c0_steps_over_025m'], sums['low_speed_c1_steps_over_005rad']]))
for s, r in reports.items():
route_rows.append(cells([s, f"{r['cycles']:,}", f"{r['active_valid_s']/60:.2f}", len(r['events']),
'Logged separately' if s in ['15a', '15b'] else 'Reconstructed from logged plans']))
sweep_rows = []
for s in ['15a', '15b']:
for row in json.loads((root/s/'sweep.json').read_text()):
sweep_rows.append(cells([s, f"{row['start']:.3f}{row['full']:.3f}", f"{row['power']:.0f}",
f"{row['geometry_entry_extra_retained_p10_p50_p95_max'][1]*100:.1f}%",
f"{row['release_later_than_action_s_p10_p50_p95_max'][-1]:.3f}", row['target_steps_over_20deg']]))
event_rows = []
for s, id_ in [('15a', 1), ('15a', 5), ('15b', 1), ('15b', 4), ('15b', 7)]:
e = next(e for e in reports[s]['events'] if e['id'] == id_)
a, g, h = [e['variants'][v] for v in ['action', 'geometry', 'hybrid']]
event_rows.append(cells([f'{s} / {id_} {e["direction"]}',
f"{a['peak_angle_deg']:.0f} / {g['peak_angle_deg']:.0f} / <b>{h['peak_angle_deg']:.0f}</b>",
f"{a['peak_abs_c0']:.2f} / {g['peak_abs_c0']:.2f} / <b>{h['peak_abs_c0']:.2f}</b>",
f"{a['peak_abs_c1']:.3f} / {g['peak_abs_c1']:.3f} / <b>{h['peak_abs_c1']:.3f}</b>",
f"{h['release_25_s']-a['release_25_s']:+.3f}"]))
plots = [
('15b', 'large-left', '15b · large left: geometry strength, action-led release', [164., 176.], 'left', False),
('15a', 'exit', '15a · left turn: geometry rebound during the exit', [188., 198.], 'left', False),
('15b', 'rearm', '15b · exit rebound caught and fixed offline', [43., 48.5], 'left', True),
('15b', 'conflict', '15b · source disagreement: the hybrid follows the action', [79., 85.], 'left', False),
('151', 'dip', '151 · a temporary action dip also reduces geometry assistance', [2590., 2603.], 'right', False),
('157', 'large-geometry', '157 · stronger requests do not establish that the path is correct', [166., 180.], 'right', False),
]
for s, slug, title, window, direction, show_initial in plots:
plot(arrays[s], dest/f'hybrid-{slug}.png', title, window, direction, initial.get(s) if show_initial else None)
def get_image(slug):
return f'<img src="hybrid-{slug}.png" alt="Reference, C0, C1 and geometry assistance over time">'
report = f'''<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ford action + geometry · offline hybrid</title><style>
:root{{color-scheme:light}}*{{box-sizing:border-box}}body{{font-family:system-ui,-apple-system,sans-serif;color:#17232e;background:#f4f6f8;margin:0;line-height:1.55}}
main{{max-width:1190px;margin:auto;padding:35px 25px 70px}}h1{{font-size:38px;letter-spacing:-1.2px;line-height:1.15;margin:12px 0 20px}}h2{{font-size:24px;margin:30px 0 12px}}h3{{font-size:19px}}
p{{max-width:1000px}}.eyebrow{{color:#556373;font-size:13px;letter-spacing:1px;text-transform:uppercase}}.lead{{font-size:21px;max-width:950px}}.card{{background:white;padding:24px;border:1px solid #dce3e9;border-radius:12px;margin:20px 0}}
.stats{{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}}.stats div{{background:#e4f3ec;padding:20px;border-radius:10px}}.stats strong{{font-size:30px;display:block;color:#087f5b}}.note{{color:#526171;font-size:14px}}.caution{{background:#fff4dc;border-left:4px solid #d18a00;padding:16px 20px}}
img{{max-width:100%;display:block;border:1px solid #e0e6eb;border-radius:8px;margin:16px 0}}table{{border-collapse:collapse;width:100%;font-size:14px}}th,td{{padding:11px 12px;border-bottom:1px solid #e1e7ed;text-align:left;white-space:nowrap}}th{{background:#edf2f6;font-size:13px}}.scroll{{overflow:auto}}code{{background:#eaf0f4;padding:2px 5px;border-radius:4px}}pre{{white-space:pre-wrap;background:#eef3f7;padding:16px;border-radius:8px}}summary{{cursor:pointer;font-weight:600;padding:12px 0}}a{{color:#1263a0}}li{{margin:8px 0}}
@media(max-width:700px){{main{{padding:20px 12px}}h1{{font-size:30px}}.stats{{grid-template-columns:1fr}}.card{{padding:16px}}}}
</style></head><body><main>
<div class="eyebrow">Ford Lightning · offline experiment · 16 September 2026</div>
<h1>Geometry for turn strength.<br>Action to lead the unwind.</h1>
<p class="lead">The revised hybrid produces the intended command pattern in many turns: requests close to geometry at the peak, then close to the action at the exit. It preserves the action reference for gentle bends. It does not resolve which source is right when they disagree.</p>
<div class="stats"><div><strong>4.33 vs 4.41 m</strong>Hybrid vs geometry peak C0<br>15b, large left</div><div><strong>0.50 vs 0.50 rad</strong>Same peak C1 on that turn<br>Both reach the field bound</div><div><strong>0.85 s earlier</strong>Hybrid reference releases before geometry<br>Matches actions 25° crossing</div></div>
<p class="note">These are replayed requests using recorded vehicle motion. They are not predictions of the wheel response, path accuracy, comfort, or safety. This report evaluates requests offline; the repository deployment notes describe the current integration.</p>
<section class="card"><h2>The proposed rule</h2>
<ol><li><b>Start with the original model action.</b> Gentle requests stay with the action.</li>
<li><b>Add geometrys extra demand</b> when geometry describes a tighter turn, both sources agree on direction, and the action supports meaningful demand.</li>
<li><b>Fade that extra demand as the action falls from its peak.</b> Half the peak action leaves at most a quarter of the extra geometry request. Zero or opposite action removes the boost.</li></ol>
<p>Both C0 and C1 receive <b>one combined curvature reference</b> through the existing scalar mapper and feedback. C0 uses the existing 7 m circular-arc calculation. C1 uses the existing heading calculation. This does not give one source to C0 and a conflicting source to C1.</p>
<p>Geometry here is the earlier, smoothed orientation-derived curvature reference from the first geometry drives. The latest direct-path controller is included as a separate comparison.</p>
<details><summary>Formula and tuning choices</summary><pre>H = A + gate(|G|) × (|A| / max(0.01, A_peak))² × (G A)
Apply assistance only when A and G have the same sign and |G| &gt; |A|.
gate = smoothstep from 0 at |G| = 0.01/m to 1 at |G| = 0.02/m.
A_peak = largest same-direction action magnitude during the current geometry excursion.
Reset at inactive/invalid geometry, geometry sign change, or |G| ≤ 0.01/m.</pre>
<p>The 0.01/0.02 band and squared fade are explicit experimental tuning choices. They are not Ford constants. P/I gains, ordinary controller gates, upstream curvature limits and CAN field bounds are unchanged. The selector has peak memory; it is not a stateless average.</p></details></section>
<section class="card"><h2>Turn strength really reaches C0/C1</h2>
<p>On the large left, the hybrid reaches a 359° wheel-equivalent reference versus geometrys 361° and actions 218°. Around 170 s, its C0/C1 are 4.04 m / 0.50 rad, close to geometrys 4.09 m / 0.50 rad. Around 173 s, hybrid and action both request 0.26 m / 0.0745 rad.</p>
{get_image('large-left')}
<p class="note">Raw CAN command signs above are retained. The chart flips signs so positive means into this left turn. C1s 0.50 rad bound is a message-field bound; this replay cannot identify the PSCMs physical limit.</p>
<p>Each cell below lists <b>action / geometry / hybrid</b>. Peaks need not occur at the same instant.</p>
{table(['Window','Peak reference °','Peak |C0| m','Peak |C1| rad','Hybrid action release, s'],event_rows)}
<p class="note">Release is the first reference below 25° in the turn direction, sustained for 0.2 s after the action peak. It is not a measured wheel unwind time.</p></section>
<section class="card"><h2>Exit behavior: useful, but not a perfect phase detector</h2>
<p>In this 15a exit, the action-led fade removes geometrys later extra demand. The hybrid reaches the reference release threshold with the action, roughly 0.50 s before geometry.</p>{get_image('exit')}
<p>The first prototype had a real flaw: after a geometry dip reset its memory, a tiny action rebound could authorize full geometry again. At 46.73 s on 15b, action requested 3.2°, but that prototype requested 103°. The revised denominator limits it to <b>4.3°</b>; C1 becomes 0.0095 rad rather than 0.1805 rad.</p>{get_image('rearm')}
<p class="caution"><b>The remaining ambiguity:</b> a temporary dip in the action also reduces assistance. The selector cannot know from that dip alone whether the road is ending the turn or the action is briefly weak. When sources point opposite ways, it follows the action and can lose geometrys early entry.</p>
<details><summary>See the unfavorable cases</summary>
<p>15b: the action and geometry disagree on turn phase/direction. This rule keeps only about 5% of geometrys eligible extra demand before the marked action peak. That is a deliberate source choice, not evidence the road needed less steering.</p>{get_image('conflict')}
<p>151: before a later action peak, the action briefly falls to a 26° request while geometry still requests 122°. Hybrid falls to 30°. This is the unresolved tradeoff in using action reductions to lead release.</p>{get_image('dip')}
<p>157: some geometry requests are far larger than the action (about 807° versus 266° peak in this window). The hybrid can inherit that demand and encounter the existing field bounds. Matching previous geometry numbers does not show that the model path is correct.</p>{get_image('large-geometry')}</details></section>
<section class="card"><h2>Across five routes</h2>
<p><b>{len(live):,} unique control cycles</b>, {metrics['active_valid_seconds']/60:.1f} minutes of active valid input, and {len(events)} previously marked windows, some overlapping. Median retained geometry extra demand during eligible entries is <b>{np.median(retained)*100:.0f}%</b>; the 10th percentile is {np.quantile(retained,.1)*100:.0f}%. This metric includes only same-direction entry samples where geometry already requests at least 50° and exceeds the action by more than 5°.</p>
<p>For {len(release_delta)} paired reference-release crossings, median added delay versus action is <b>{np.median(release_delta):.2f} s</b>, with a maximum of {max(release_delta):.2f} s. One additional window (157 / 5) disengages before hybrids 0.2 s release crossing can be observed. First crossings do not rule out a later rebound; the plots and final-crossing checks matter.</p>
{table(['Route suffix','Control cycles','Active valid minutes','Marked windows','Geometry source'],route_rows)}
<h3>Gentle requests stay near action</h3>
<p>For {int(ordinary.sum()):,} active valid samples where both source curvatures are below 0.01/m, these are the 95th-percentile absolute differences from action. Earlier integrator state and curvature-limiter history can still differ: hybrids worst C1 difference in this cohort is {metrics['ordinary_p95_difference_from_action']['hybrid']['c1'][2]:.4f} rad.</p>
{table(['Candidate','Reference difference °','C0 difference m','C1 difference rad'],ordinary_rows)}
<h3>Sharp low-speed changes remain</h3>
<p>At below 15 mph, count adjacent valid command changes greater than 0.25 m C0 or 0.05 rad C1. These counts include source changes and recorded driver-gate transitions; they are a diagnostic proxy, not a comfort score. The hybrid is <b>not a general fix for rapid actuation</b>.</p>
{table(['Candidate','C0 at field bound, s','C1 at field bound, s','C0 jumps','C1 jumps'],totals_rows)}
<p>The always-stronger rule retains geometry on exits. A 50/50 blend weakens peaks and changes gentle driving. The hybrid more closely matches the requested division of work, but requires an explicit compromise when source demand drops and then recovers.</p></section>
<section class="card"><h2>Sensitivity and validation</h2>
<p>Twelve settings were checked on each recent route. Smaller thresholds admit more preview, including more small-action assistance. Larger release exponents clear the extra request faster but discard more entry demand and often increase abrupt target changes. The selected 0.0100.020/m band with power 2 is a compromise, not an optimized or vehicle-validated calibration.</p>
<details><summary>All 24 sensitivity results</summary>
{table(['Route','Curvature band /m','Fade power','Median entry extra retained','Max release delay vs action, s','Target jumps &gt;20°'],sweep_rows)}
<p class="note">Sensitivity alternatives replay references through the upstream curvature limiter. Full C0/C1/controller replay was run for the selected configuration, both initial and revised, and all five comparison baselines.</p></details>
<ul><li>20 prototype tests pass, including the exit-rebound regression, direction changes, input validity, causality and repeated model frames.</li>
<li>Native control-cycle replay checks finite C0/C1, field bounds, zero C2/C3, driver feedback suppression and identical validity gates. Real CAN packing/unpacking is checked every tenth cycle.</li>
<li>The first six-variant replay performed 347,256 CAN checks; the revised hybrid added 57,876. Reusing unchanged baseline columns was checked against a complete fresh 15a run: every output value matched exactly.</li>
<li>15a/15b join separately logged original action and geometry by exact model timestamp. Older geometry is reconstructed with recorded delay/model smoothing; speed uses the first consuming control sample.</li>
<li>All candidates see the same recorded wheel motion, road inputs and driver interventions. Their feedback states are recomputed, but the vehicle never responds to the hypothetical new commands. No new camera/model inference or PSCM dynamics are simulated.</li></ul>
<p><b>Recommendation:</b> keep this as the candidate hybrid design. It achieves the requested numeric pattern on several major turns and catches an exit flaw before deployment. Retain the action-dip and source-disagreement cases as explicit unresolved limitations. Comparison baseline: <code>01f5d5429</code>. The installed hybrid identifies itself as <code>geometry-assisted-action-feedback-v18</code>.</p>
<p><a href="geometry-action-hybrid-validation.json">Validation numbers and source fingerprints</a></p></section>
</main></body></html>'''
(dest/'geometry-action-hybrid.html').write_text(report)
metrics['sources_sha256'] = {str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in [
Path(__file__), Path('tools/ford_pscm_lab/geometry_action_hybrid.py'),
*[root/s/'report.json' for s in LABELS], *[root/s/'sweep.json' for s in ['15a', '15b']]]}
(dest/'geometry-action-hybrid-validation.json').write_text(json.dumps(metrics, indent=2, allow_nan=False)+'\n')
print(json.dumps({'report': str(dest/'geometry-action-hybrid.html'), 'metrics': metrics}, indent=2))
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--root', type=Path, default=Path('.cache/ford_hybrid_guarded'))
parser.add_argument('--initial-root', type=Path, default=Path('.cache/ford_hybrid'))
args = parser.parse_args()
run(args.root, args.initial_root, args.output)
@@ -0,0 +1,89 @@
"""Reference-only sensitivity check; the selected default also has full command replay."""
import argparse
import json
from pathlib import Path
import numpy as np
from openpilot.selfdrive.controls.lib.drive_helpers import clip_curvature
from tools.ford_pscm_lab.geometry_action_hybrid import GeometryActionHybrid
from tools.ford_pscm_lab.hybrid_reference_replay import load_route, sustained
def quantiles(values):
return np.quantile(values, [.1, .5, .95, 1.]).tolist() if values else None
def run(label, root):
d = load_route(label)
with np.load(root/label/'commands.npz') as z:
a = dict(zip(z['names'], z['rows'].T, strict=True))
with np.load(Path('.cache/ford_routes158_159')/f'{label}_fullrate_aligned.npz') as z:
scale = z['angle_scale']
np.testing.assert_allclose(z['t'], a['t'])
events = json.loads((root/label/'report.json').read_text())['events']
t, live = a['t'], a['valid'].astype(bool)
consecutive = live[1:] & live[:-1] & (np.diff(t) < .03)
results = []
curves = {'t': t}
for start in [.006, .01, .015]:
for power in [1., 2., 4., 8.]:
h = GeometryActionHybrid(start, 2*start, power)
selected = 0.
curve = np.zeros(len(t))
weights = np.zeros(len(t))
for i in range(len(t)):
target = h.update(float(d.action[i]), float(d.geometry[i]), active=bool(d.active[i] and d.valid[i]))
selected, _ = clip_curvature(d.cs['speed'][i], selected, target if d.active[i] else d.c['measured'][i], d.pa['roll'][i])
curve[i] = a['action_angle'][i]+scale[i]*(selected-a['action_reference'][i])
weights[i] = h.weight
if start == .01 and power == 2.:
np.testing.assert_allclose(curve[live], a['hybrid_angle'][live], atol=1e-5)
retained, entry_leads, exit_delays = [], [], []
unavailable_release = unavailable_entry = 0
details = []
for e in events:
sign = 1 if e['direction'] == 'left' else -1
window = live & (t >= e['window'][0]) & (t <= e['window'][1])
build = window & (t <= e['action_peak_t'])
release = window & (t >= e['action_peak_t'])
extra = sign*(a['geometry_angle']-a['action_angle'])
assist = build & (extra > 5.) & (sign*a['action_angle'] > 0.) & (sign*a['geometry_angle'] > 50.)
keep = float(np.sum(sign*(curve-a['action_angle'])[assist])/np.sum(extra[assist])) if assist.any() else None
if keep is not None:
retained.append(keep)
entry = sustained(t, build & (sign*curve >= 50.))
end = sustained(t, release & (sign*curve < 25.))
action_entry, action_exit = [e['variants']['action'][k] for k in ('entry_50_s', 'release_25_s')]
if action_entry is not None:
if entry is None:
unavailable_entry += 1
else:
entry_leads.append(action_entry-entry)
if action_exit is not None:
if end is None:
unavailable_release += 1
else:
exit_delays.append(end-action_exit)
details.append({'id': e['id'], 'retained': keep, 'entry': entry, 'release': end})
key = f'{start:.3f}_p{power:.0f}'
curves[key] = curve
results.append({'start': start, 'full': 2*start, 'power': power,
'geometry_entry_extra_retained_p10_p50_p95_max': quantiles(retained),
'entry_lead_vs_action_s_p10_p50_p95_max': quantiles(entry_leads),
'release_later_than_action_s_p10_p50_p95_max': quantiles(exit_delays),
'unavailable_entry_when_action_has_one': unavailable_entry,
'unavailable_release_when_action_has_one': unavailable_release,
'target_steps_over_20deg': int((abs(np.diff(curve)[consecutive]) > 20.).sum()),
'events': details})
np.savez_compressed(root/label/'sweep_curves.npz', **curves)
(root/label/'sweep.json').write_text(json.dumps(results, indent=2, allow_nan=False)+'\n')
print(json.dumps({'route': label, 'settings': len(results)}), flush=True)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--route', choices=['15a', '15b'], required=True)
parser.add_argument('--output', type=Path, default=Path('.cache/ford_hybrid'))
args = parser.parse_args()
run(args.route, args.output)