diff --git a/docs/ford_model_action_damping.md b/docs/ford_model_action_damping.md new file mode 100644 index 0000000000..d13ea4ceeb --- /dev/null +++ b/docs/ford_model_action_damping.md @@ -0,0 +1,85 @@ +# Experimental Ford offset damping, v2 + +Segment 10 of the supplied route9b recording shows measured turning persisting +as requested right curvature falls. At about 643.0 s, before strong driver +intervention, device-gyro curvature is approximately 0.01786/m against a +0.01172/m request. Around 643.9 s, heading demand has reversed slightly but +C0 still requests approximately +0.12 m into the turn. Strong column input +starts around 643.852 s; later motion cannot establish autonomous recovery. +Earlier light driver input also exists. + +The outgoing CAN commands match preceding publications. All 70,937 decoded +frames have zero C2/C3 and valid checksums. Focused exit diagnostics have fresh +model/carState inputs and targets within normal quantization of the outputs. +This supports trying less residual C0; it does not identify PSCM dynamics or +prove C0 alone caused the physical oversteer. + +## Change + +C0 starts from the clipped current model offset at 7 m. When C0 and measured +host yaw point in the same direction, compute: + +``` +requested_yaw = max(0, sign(C0) * speed * desiredCurvature) +excess = max(0, sign(C0) * yaw - requested_yaw - 0.02 rad/s) +reduction = 7 m * 0.2 s * excess +target = sign(C0) * max(0, abs(C0) - reduction) +``` + +Opposing centering demand is unchanged. The correction cannot increase the +target's magnitude or reverse its sign. Opposite-direction planned curvature +cannot amplify a small yaw bias into a correction. Existing 4 m/s C0 slew still +applies; this target bound is not a claim that every stateful output is smaller than a +separate v1 controller after arbitrary direction reversals. C1 construction, +clipping and slew are unchanged; C2=C3=0. Only C0/C1 slew states persist. +There is no integral, model-history filter, turn state machine or fitted plant. + +Host yaw is `-carState.yawRate`, as in the existing Ford call path. The +0.02 rad/s deadband exceeds the approximately 0.008 rad/s offset measured +against the device gyro on quiet straights. The 0.2 s scale is an initial +engineering choice, not an identified delay or gain. Both remain physically +unvalidated. Large biased or noisy yaw within the existing sanity gate can +still attenuate useful centering; fixed-input replay cannot establish stability. + +## Offline evidence + +The complete 12-rlog route is replayed at original controls publication times, +with exact consumed model geometry, causal carState, and carControl matched +within 5 ms. These times proxy computation; full SubMaster health is unavailable. +V1 reconstruction is within one field quantum of all 64,701 paired active +publications. V2 has identical eligibility and exactly identical C1. + +On 381.78 seconds of driver-clean low requests above 8 m/s, only 3 of 37,937 cycles +change C0, each by one 0.01 m quantum. Across the 642.7–643.852 s exit window, +C0 changes on all 115 cycles, averaging 0.080 m reduction. Entry/peak C0 +maximum stays 2.80 m; some entry-window samples decrease by up to 0.05 m. +These are command comparisons on recorded inputs, not predicted tracking. +Low request means requested lateral acceleration below 0.15 m/s²; it is a +proxy for straight driving and does not establish a physically straight path. + +The original routes90/95 also run through v2. Their zero-yaw baseline pass +checks archived v1 compatibility; the measured-yaw adapter pass checks current +construction, eligibility, field limits and packing. It is not an exact match +to v1 or v8. Randomized testing checks the damping against an independent +piecewise oracle, mirror symmetry, resets and slew, with real Float32/CAN +round trips. See `ford_model_action_damping_validation.json` for counts and hashes. + +Final validation passes 325 tests and 26 subtests, with 100% controller +statement/branch coverage, 204,946 original route cycles and 628,030 CAN round +trips. The module is 166 total lines, including 107 code lines excluding +comments, blanks and docstrings. Standards and Spec reviews have no remaining findings. + +## Reproduce + +Use the dependency setup and suite command in the [drive-test guide](ford_model_action_drive_test.md). +The new route replay requires the deployment opendbc pin recorded there: + +```sh +python -m tools.ford_pscm_lab.damping_replay /path/to/complete/rlogs --output /path/to/separate/results +python -m tools.ford_pscm_lab.stress_model_action --cycles 200000 --seed 20260907 --opendbc-revision c21a9013700734dd20b09e05aa68329ad8cc20f9 --output /path/to/stress.json +``` + +The same default-off Sunnylink toggle selects v2; no additional setting is +introduced. Updating an installation with the toggle already enabled selects +v2 at the next controlsd startup. `calibration_approved=false` remains explicit. +No physical fix, hardware build or device boot is established by these checks. diff --git a/docs/ford_model_action_damping_validation.json b/docs/ford_model_action_damping_validation.json new file mode 100644 index 0000000000..4354f2c5ac --- /dev/null +++ b/docs/ford_model_action_damping_validation.json @@ -0,0 +1,164 @@ +{ + "date": "2026-09-07", + "baseline_commit": "5fc16abc7662020706e29f57d31a6d5e2bc1293a", + "deployment_target": { + "repository": "sunnypilot/sunnypilot", + "branch": "hiimisaac-dev" + }, + "hypothesis": "model-action-c0-c1-yaw-damping-v2", + "scope": "Experimental bounded offset damping; fixed-input offline evidence only.", + "calibration_approved": false, + "hardware_build_and_device_boot": "not performed", + "controller_size": { + "total_lines": 166, + "code_lines_excluding_blanks_comments_docstrings": 107, + "core_persistent_values": 2, + "adapter_timestamps": 3 + }, + "checks": { + "combined_ford_params_sunnylink_suite": "325 passed, 26 subtests passed; no skips", + "suite_log_sha256": "69cb8ab40e93e00a9ff7b6ea1933e4c3554336746860efb53dad3b29f94fc9d3", + "coverage": { + "statements": 98, + "branches": 28, + "percent": 100.0 + }, + "ruff": "pass", + "ty_controller_and_lab": "pass", + "settings_compiler_check": "pass", + "standards_review_remaining_findings": 0, + "spec_review_remaining_findings": 0, + "review_resolutions": [ + "Prevent opposite-direction planned curvature amplifying small yaw bias; eight new cases failed before the fix and passed after it.", + "Relabel requested-acceleration cohort as low request; it does not establish physically straight driving." + ], + "mutation_probe": "Disabling damping fails all four mirrored recorded-exit cases." + }, + "stress": { + "random_cycles": 200000, + "mirrored_core_updates": 200000, + "field_boundary_cases": 18138, + "float32_can_round_trips": 218138, + "bounded_excess_yaw_damping_checked": true, + "opendbc_import_head": "c21a9013700734dd20b09e05aa68329ad8cc20f9" + }, + "routes": { + "route90": { + "cycles": 78812, + "core_exact_archived_match": true, + "adapter_active_cycles": 73055, + "adapter_matches_current_core_with_yaw_and_fresh_engagement_dt": true, + "adapter_validity_differs_from_archive_cycles": 0, + "adapter_command_differs_from_archive_cycles": 2280, + "adapter_max_absolute_command_difference_c0_c1": [ + 0.1900000000000004, + 0.0020000000000000018 + ], + "float32_can_round_trips": 157624, + "opendbc_import_head": "72a775d35e54c21ff5c5798acef22016eedcc0a7" + }, + "route95": { + "cycles": 54738, + "core_exact_archived_match": true, + "adapter_active_cycles": 37614, + "adapter_matches_current_core_with_yaw_and_fresh_engagement_dt": true, + "adapter_validity_differs_from_archive_cycles": 0, + "adapter_command_differs_from_archive_cycles": 2051, + "adapter_max_absolute_command_difference_c0_c1": [ + 0.22999999999999998, + 0.0010000000000000009 + ], + "float32_can_round_trips": 109476, + "opendbc_import_head": "72a775d35e54c21ff5c5798acef22016eedcc0a7" + }, + "route9b": { + "cycles": 71396, + "eligible_cycles": 64701, + "same_validity": true, + "c1_exactly_unchanged": true, + "field_slew_zero_c2_c3_pass": true, + "float32_can_round_trips": 142792, + "v1_reconstruction_vs_recorded": { + "paired_cycles": 64701, + "within_one_quantum_cycles": 64701, + "maximum_absolute_error_c0_c1": [ + 0.010000114440917862, + 0.0005000143051147043 + ] + }, + "timing": "Publication-time proxy, causal carState, exact consumed model; full SubMaster health unavailable.", + "baseline": "Current adapter with zero yaw retains v1 targets and actual-yaw sanity gate.", + "opendbc_head": "c21a9013700734dd20b09e05aa68329ad8cc20f9", + "cohorts": { + "driver_clean_low_request_above_8mps": { + "cycles": 37937, + "seconds": 381.77672216900055, + "changed_c0_cycles": 3, + "mean_absolute_c0_change_m": 8.419774473786704e-07, + "max_absolute_c0_change_m": 0.009999999999999787, + "v1_peak_absolute_c0_m": 0.08999999999999986, + "v2_peak_absolute_c0_m": 0.08999999999999986 + }, + "segment10_entry_peak": { + "cycles": 298, + "seconds": 2.9932484459999387, + "changed_c0_cycles": 100, + "mean_absolute_c0_change_m": 0.007564164795694466, + "max_absolute_c0_change_m": 0.04999999999999982, + "v1_peak_absolute_c0_m": 2.8000000000000003, + "v2_peak_absolute_c0_m": 2.8000000000000003 + }, + "segment10_exit_before_strong_input": { + "cycles": 115, + "seconds": 1.156770048999988, + "changed_c0_cycles": 115, + "mean_absolute_c0_change_m": 0.0799313220721162, + "max_absolute_c0_change_m": 0.1200000000000001, + "v1_peak_absolute_c0_m": 0.75, + "v2_peak_absolute_c0_m": 0.7000000000000002 + } + }, + "exit_c0_strictly_lower_on_all_115_cycles": true, + "rlog_sha256_by_segment": { + "0": "22746f7119109b73ed7f2c26ce8c99f87136e9124fb7fc14c9554409a28a7c3f", + "1": "4c2e1d7083c31a2b37d0f8dd3be4d330898511b7e02c26f7d40ca9bc2779397d", + "2": "62f3e049e220cd3681fadf386f2969537bd571998ae2f6ba2d08479428b5a28f", + "3": "83bf0131b2d36b2ba7e5ba050bbc13c0a3350feb5c9b89dc9c87d3a37abebfb3", + "4": "430985a80dd6e10f7abeb89457a17022e6bb6978617f415c905f584b1647603e", + "5": "8c0c5ae6323ec33b3e14f84ca834f70cb56f6b29f471a350f1e3efc06b6ba553", + "6": "db53dfa8156b9d66792c3eff0b2ce5d31b71ad41cc580dec85f528845593c184", + "7": "91b0b3be10cb7d7d7f7dd2024d8f9ee99d1e9fd2204203a3a9a2f2f1c6e3fa03", + "8": "687dbbfc49837efbfe8fa6bc091e40f7fad2908832234d7884f4616d1bc9ccff", + "9": "a88ec4d25b04cdbf5844686fc77f6b28dca920c9b164e37ebf69844a3ae398fc", + "10": "fe6b29580a6c94e1c236d13e18db4cd9f31cc1b25d52e1e6e19a5021125c9932", + "11": "150d31b1944d7a1b8c562f3aee20b66cefa6c4e8d02660ec889907d835142f45" + } + } + }, + "total_original_route_cycles": 204946, + "total_float32_can_round_trips": 628030, + "source_sha256": { + "docs/ford_model_action_damping.md": "1aca8ca8e78d953beeda5b0c9803161a1d7c58556b1966040c71f809c3960bf8", + "docs/ford_model_action_drive_test.md": "3ce8bf6cb511a4461fa7abf194f47020af7ef5a6c90fcae7ed09571d1f750846", + "openpilot/selfdrive/controls/lib/ford_model_action.py": "59d66297a017557f3d4f28b115be3f6220b800566c11935e2284b3814783fb7e", + "openpilot/selfdrive/controls/tests/test_ford_controlsd_logging.py": "318742bd707ae526d0f5181bf7f081660c2c55de4bdf28518ccdf50d60e88080", + "openpilot/selfdrive/controls/tests/test_ford_model_action_adapter.py": "de6f8524347f7c4a339941bc8565ccaa131cb93aa0418c75006ce08ab7edeb99", + "openpilot/selfdrive/controls/tests/test_ford_model_action_damping.py": "76466997ab4fef435f44339a6cb2d06303d5f0ab8717d487f27655297bd429d1", + "openpilot/sunnypilot/sunnylink/settings_ui.json": "dbb78c98f57eef532f0dff0cb0b38396442882876e6d115f0d3c36159f64baf5", + "openpilot/sunnypilot/sunnylink/settings_ui_src/pages/vehicle.yaml": "b92b55e23467e74227988fb39fff13a4ebba3c960fb90e34a6b341411699fc95", + "tools/ford_pscm_lab/damping_replay.py": "2f52fef12116ce87c0a1f465cf13d6af5e9fd76ce05d4318d72e34b70bc6a11f", + "tools/ford_pscm_lab/model_action_replay.py": "05a658dfcaf81693bf0d92184c0edff0172802f351a61a9e866a7967e74ae46d", + "tools/ford_pscm_lab/stress_model_action.py": "3e308733f4af0101ad0c414fbd724f6a99f56269ed8e791f0e15526d8bfd8f17" + }, + "artifact_sha256": { + "route9b/report.json": "1b9317850c1724f433269c6a58349d0a0ee4eb6c9a03d6ec5858fa17713796c5", + "route9b/commands.npz": "d9f553a5384c84416a27c52b5dda0e751dda25edc5d6620fc311977f34a7a946", + "route90/report.json": "e37f71dc032e375b1c9b0beb4d6e0c915257bf72e59b0785ac0ca49c2472d2c2", + "route95/report.json": "d42d5a080fef8a1f0b3c7ae2cabcad20c88ce01d91be07ab33770ff5948c06b6", + "stress.json": "06e69a23340e3f5ed174e8e0b2e5791b320686dce7df963233d50a9982dca17b", + "coverage.json": "8f7915b9bd884abedfdbc2e0c18ef4474737e27225a714414384242535cc396f", + "mutation.txt": "67a76549fd7bb71e7092a155d4d0c3459be7b04dc2ca36eef9b54fbb574ad83d", + "bias_regression_red.txt": "8903c5a967e8c376050db85f7cf5f73abf71ee972f6025b25eb874479f94c62c", + "segment10_damping.png": "7d60cf9aabdcca9000fcf49bd14bd6130418aa6ab1b57af2678b171498ee9505" + } +} diff --git a/docs/ford_model_action_drive_test.md b/docs/ford_model_action_drive_test.md index 70cf6875a3..6eeb9963c0 100644 --- a/docs/ford_model_action_drive_test.md +++ b/docs/ford_model_action_drive_test.md @@ -1,8 +1,9 @@ # Ford selected-action drive-test branch The candidate is selectable on the **Ford CAN FD F-150 Lightning** behind -its own persistent, default-off Sunnylink toggle. The command law and input -gates from the [offline candidate](ford_model_action_candidate.md) are unchanged. +its own persistent, default-off Sunnylink toggle. Version 2 adds +[bounded excess-yaw offset damping](ford_model_action_damping.md) to the +[original candidate](ford_model_action_candidate.md). Input gates are unchanged. `calibration_approved=false`: offline checks do not establish physical tracking, turn-exit behavior or closed-loop stability. @@ -19,7 +20,7 @@ turn-exit behavior or closed-loop stability. The startup log event `Ford path controller selected` should report `FordModelActionController`. Periodic `Ford C2-free path tracking` events -identify `hypothesis=model-action-c0-c1-v1` and report the command tuple. +identify `hypothesis=model-action-c0-c1-yaw-damping-v2` and report host yaw and the command tuple. Turning the new toggle off and completing another offroad-to-onroad cycle restores **PSCM Coefficient Observer** if selected, otherwise the original @@ -53,16 +54,13 @@ returns separate strings owned by the parameter handle. Regression tests check distinct registered keys across flags, and toggle tests check its persistence and backup registration using the rebuilt native library. -The current validation record is `ford_model_action_drive_test_validation.json`. -The final combined Ford, Params and Sunnylink suite passes **284 tests and -26 subtests**, with no skips. The candidate has **100% statement and branch -coverage** (87 statements, 26 branches). Ruff, Ty, settings compilation and -both review axes pass. The fresh route/stress runs check **485,238 Float32/CAN -round trips**, including 200,000 randomized and 200,000 mirrored core updates. -The controller is 145 total lines, including 95 code lines excluding comments, -blanks and docstrings; v8's 469-line module is removed. +The current validation record is `ford_model_action_damping_validation.json`; +the [damping notes](ford_model_action_damping.md) explain its scope and limitations. +`ford_model_action_drive_test_validation.json` archives v1 wiring validation +at the recorded source hashes, including 284 tests and 26 subtests. Its counts +and 145-line controller size describe v1. The 469-line v8 module remains removed. -The previous 133,550-cycle route reconstruction, 485,238 packing round trips +The original 133,550-cycle route reconstruction, 485,238 packing round trips and mutation probes remain recorded separately in `ford_model_action_validation.json` at the offline-stage source hashes. diff --git a/openpilot/selfdrive/controls/lib/ford_model_action.py b/openpilot/selfdrive/controls/lib/ford_model_action.py index 9f01b43b3f..a2da158985 100644 --- a/openpilot/selfdrive/controls/lib/ford_model_action.py +++ b/openpilot/selfdrive/controls/lib/ford_model_action.py @@ -14,6 +14,8 @@ from openpilot.selfdrive.controls.lib.ford_path import FordPath, _model_path OFFSET_STATION_M = 7.0 HEADING_TIME_S = 1.0 +EXCESS_YAW_DEADBAND = .02 # rad/s; above the observed approximately .008 rad/s Ford yaw offset +EXCESS_YAW_LOOKAHEAD_S = .2 # engineering choice, not an identified PSCM delay CALIBRATION_APPROVED = False @@ -50,11 +52,27 @@ def encode_model_action(model, desired_curvature, speed): return FordPath(True, c0, c1, 0., 0.) if _finite(c0, c1) else FordPath() +def damp_offset(c0, desired_curvature, speed, yaw_rate): + """Attenuate same-direction C0 demand when yaw exceeds the requested turn. + + Inputs are finite and range-checked by the caller. The deadband avoids + chasing small yaw offsets. Opposing centering demand is left intact. + """ + if c0*yaw_rate <= 0.: + return c0 + direction = math.copysign(1., c0) + # An opposed plan must not amplify near-zero yaw bias into a large correction. + requested_yaw = max(0., direction*speed*desired_curvature) + excess = max(0., direction*yaw_rate-requested_yaw-EXCESS_YAW_DEADBAND) + reduction = OFFSET_STATION_M*EXCESS_YAW_LOOKAHEAD_S*excess + return direction*max(0., abs(c0)-reduction) + + class ModelActionController: """Only two control states: unquantized, independently slewed C0 and C1. - Freshness and engagement belong to the caller. No measured yaw, model - history, heading integral, blending or release modes enter the law. + Freshness and engagement belong to the caller. Excess yaw attenuates the + offset target without model history, an integral or release modes. """ __slots__ = ('c0', 'c1') @@ -64,8 +82,9 @@ class ModelActionController: def reset(self): self.c0 = self.c1 = 0. - def update(self, model, desired_curvature, *, speed, dt, active=True, valid=True): - if not active or not valid or not _finite(dt) or not .002 <= dt <= .1: + def update(self, model, desired_curvature, *, speed, dt, yaw_rate=0., active=True, valid=True): + # Zero yaw preserves the archived v1 command construction for lab comparisons. + if not active or not valid or not _finite(dt, yaw_rate) or not .002 <= dt <= .1 or abs(yaw_rate) > 3: self.reset() return FordPath() target = encode_model_action(model, desired_curvature, speed) @@ -73,6 +92,7 @@ class ModelActionController: self.reset() return FordPath() c0 = float(np.clip(target.path_offset, -5.11, 5.11)) + c0 = damp_offset(c0, desired_curvature, speed, yaw_rate) c1 = float(np.clip(target.path_angle, -.5, .5)) self.c0 += float(np.clip(c0-self.c0, -4.*dt, 4.*dt)) self.c1 += float(np.clip(c1-self.c1, -.5*dt, .5*dt)) @@ -87,9 +107,9 @@ class FordModelActionController: core. Its timestamps and diagnostics never affect the targets. Raw model geometry is checked on every cycle, even at a repeated model timestamp. - Yaw is checked only for the inherited finite/range input gate. Engagement - and downstream driver arbitration still apply. This controller does not use - PSCM status or driver torque as control-law inputs. + Validated host-coordinate yaw supplies stateless offset damping. Engagement + and downstream driver arbitration still apply. PSCM status and driver torque + are not control-law inputs. """ def __init__(self): self.core = ModelActionController() @@ -98,7 +118,7 @@ class FordModelActionController: def reset(self, status='inactive'): self.core.reset() self.last_time = self.last_measurement_time = self.last_model_time = None - self.diagnostics = {'status': status, 'hypothesis': 'model-action-c0-c1-v1', + self.diagnostics = {'status': status, 'hypothesis': 'model-action-c0-c1-yaw-damping-v2', 'calibration_approved': CALIBRATION_APPROVED, 'command': (0., 0., 0., 0.)} def update(self, model, desired_curvature, *, yaw_rate, speed, now, measurement_time, model_time, reference_time, @@ -124,13 +144,14 @@ class FordModelActionController: ): self.reset('timing_reset') return FordPath() - command = self.core.update(model, desired_curvature, speed=speed, dt=dt) + command = self.core.update(model, desired_curvature, speed=speed, dt=dt, yaw_rate=yaw_rate) if not command.valid: self.reset('invalid_path') return command self.last_time, self.last_measurement_time, self.last_model_time = now, measurement_time, model_time - self.diagnostics = {'status': 'active', 'hypothesis': 'model-action-c0-c1-v1', + self.diagnostics = {'status': 'active', 'hypothesis': 'model-action-c0-c1-yaw-damping-v2', 'calibration_approved': CALIBRATION_APPROVED, 'desired_curvature': desired_curvature, + 'yaw_rate': yaw_rate, 'model_age': now - model_time, 'measurement_age': now - measurement_time, 'reference_age': now - reference_time, 'dt': dt, 'offset_request': self.core.c0, 'heading_request': self.core.c1, 'command': (command.path_offset, command.path_angle, 0., 0.)} diff --git a/openpilot/selfdrive/controls/tests/test_ford_controlsd_logging.py b/openpilot/selfdrive/controls/tests/test_ford_controlsd_logging.py index 1e5fdfbf3c..f5a5f5a761 100644 --- a/openpilot/selfdrive/controls/tests/test_ford_controlsd_logging.py +++ b/openpilot/selfdrive/controls/tests/test_ford_controlsd_logging.py @@ -53,7 +53,7 @@ class TestFordControlsLogging(unittest.TestCase): controls = SimpleNamespace(ford_path_controller=controller, desired_curvature=.005, curvature=.0025, sm=SimpleNamespace(logMonoTime={'modelV2': 123456789, 'carState': 123450000})) record = self.emit_controls_event('Ford C2-free path tracking', controls) - self.assertEqual(record['hypothesis'], 'model-action-c0-c1-v1') + self.assertEqual(record['hypothesis'], 'model-action-c0-c1-yaw-damping-v2') self.assertIs(record['calibration_approved'], False) self.assertEqual(record['command'][2:], [0., 0.]) self.assertEqual(record['status'], controller.diagnostics['status']) diff --git a/openpilot/selfdrive/controls/tests/test_ford_model_action_adapter.py b/openpilot/selfdrive/controls/tests/test_ford_model_action_adapter.py index d705fb627c..91d2a2573e 100644 --- a/openpilot/selfdrive/controls/tests/test_ford_model_action_adapter.py +++ b/openpilot/selfdrive/controls/tests/test_ford_model_action_adapter.py @@ -162,7 +162,8 @@ class Subscriptions: @pytest.mark.parametrize('maneuver', [False, True]) -def test_actual_controlsd_selection_limiting_publication_and_downstream_can(pipeline, maneuver): +@pytest.mark.parametrize('host_yaw', [.0072, .3]) +def test_actual_controlsd_selection_limiting_publication_and_downstream_can(pipeline, maneuver, host_yaw): call, publication = pipeline sm = Subscriptions(maneuver) controls = startup() @@ -171,14 +172,16 @@ def test_actual_controlsd_selection_limiting_publication_and_downstream_can(pipe model = straight(.4) model.action = SimpleNamespace(desiredCurvature=.1) cc = structs.CarControl(latActive=True) - cs = SimpleNamespace(vEgo=20., yawRate=-.0072, canValid=True, steeringPressed=False, steeringTorque=0.) + cs = SimpleNamespace(vEgo=20., yawRate=-host_yaw, canValid=True, steeringPressed=False, steeringTorque=0.) environment = {'self': controls, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': model, 'lp': SimpleNamespace(roll=0.), 'clip_curvature': clip_curvature, 'time': SimpleNamespace(monotonic=lambda: 1.)} exec(call, environment) expected_curvature = (-1 if maneuver else 1)*.000125 assert controls.desired_curvature == pytest.approx(expected_curvature) assert controls.ford_path.path_angle == pytest.approx(20.*expected_curvature) - assert controls.ford_path.path_offset == pytest.approx(.04) + expected_offset = .04 if host_yaw < .02 else .01 + assert controls.ford_path.path_offset == pytest.approx(expected_offset) + assert controller.diagnostics['yaw_rate'] == host_yaw assert cc.latActive and cc.actuators.curvature == 0. assert controller.diagnostics['reference_age'] == pytest.approx(.01 if maneuver else .02) diff --git a/openpilot/selfdrive/controls/tests/test_ford_model_action_damping.py b/openpilot/selfdrive/controls/tests/test_ford_model_action_damping.py new file mode 100644 index 0000000000..8e9115754e --- /dev/null +++ b/openpilot/selfdrive/controls/tests/test_ford_model_action_damping.py @@ -0,0 +1,75 @@ +"""Bounded excess-yaw damping: remove offset demand without integral or modes.""" +import math +import pytest + +from openpilot.selfdrive.controls.lib.ford_model_action import ModelActionController, damp_offset +from openpilot.selfdrive.controls.lib.ford_path import FordPath +from openpilot.selfdrive.controls.tests.test_ford_model_action import straight + + +@pytest.mark.parametrize('sign', [-1., 1.]) +def test_recorded_turn_exit_reduces_same_direction_offset_before_driver_intervention(sign): + # Route9b, segment10, about643.0s; fresh Ford yaw in host coordinates. + c0, desired, speed, yaw = sign*.5, sign*.0117238564, 9.71, sign*.180 + reduced = damp_offset(c0, desired, speed, yaw) + assert reduced == pytest.approx(sign*(.5-1.4*(.180-9.71*.0117238564-.02))) + assert 0. < sign*reduced < .45 + + +@pytest.mark.parametrize('sign', [-1., 1.]) +def test_recorded_late_exit_removes_remaining_offset_without_creating_countersteer(sign): + # About643.9s. C1 is already slightly opposite; C0 still points into the turn. + assert damp_offset(sign*.12, sign*-.0004078, 10.77, sign*.1002) == pytest.approx(sign*.00772) + assert damp_offset(sign*.12, 0., 10.77, sign*.2) == 0. + + +@pytest.mark.parametrize('sign', [-1., 1.]) +@pytest.mark.parametrize('bias', [-.013, -.008, 0., .008, .013]) +def test_matched_turn_and_straight_bias_cannot_reduce_offset(sign, bias): + for desired in (0., sign*.01, sign*.05): + assert damp_offset(sign*.4, desired, 10., 10.*desired+bias) == sign*.4 + + +@pytest.mark.parametrize('sign', [-1., 1.]) +@pytest.mark.parametrize('bias', [-.013, -.008, 0., .008, .013, .02]) +def test_opposed_plan_cannot_amplify_small_yaw_bias(sign, bias): + for desired in (-sign*.01, -sign*.1): + assert damp_offset(sign*.4, desired, 20., sign*bias) == sign*.4 + + +@pytest.mark.parametrize('sign', [-1., 1.]) +def test_damping_begins_continuously_above_the_yaw_deadband(sign): + assert damp_offset(sign*.4, -sign*.1, 20., sign*.020001) == pytest.approx(sign*(.4-1.4e-6)) + + +@pytest.mark.parametrize('sign', [-1., 1.]) +def test_entry_deficit_opposing_centering_and_zero_offset_are_preserved(sign): + assert damp_offset(sign*2.75, sign*.053889, 5.283, sign*.272) == sign*2.75 + assert damp_offset(sign*-.25, sign*.001, 10., sign*.2) == sign*-.25 + assert damp_offset(0., sign*.001, 10., sign*.2) == 0. + + +@pytest.mark.parametrize('sign', [-1., 1.]) +def test_core_keeps_heading_unchanged_and_slews_offset_independently(sign): + baseline, damped = ModelActionController(), ModelActionController() + for i in range(300): + desired = sign*(.02 if i < 100 else .001) + a = baseline.update(straight(sign*.4), desired, speed=10., dt=.01) + b = damped.update(straight(sign*.4), desired, speed=10., dt=.01, yaw_rate=sign*.2) + assert a.path_angle == b.path_angle + assert a.curvature == b.curvature == a.curvature_rate == b.curvature_rate == 0. + assert a.path_offset == pytest.approx(sign*.4) + assert b.path_offset == pytest.approx(sign*.16) + # No damping memory: a fresh copied pair of actuator states behaves identically. + copied = ModelActionController() + copied.c0, copied.c1 = damped.c0, damped.c1 + assert copied.update(straight(sign*.4), 0., speed=10., dt=.01, yaw_rate=0.) == damped.update( + straight(sign*.4), 0., speed=10., dt=.01, yaw_rate=0.) + + +@pytest.mark.parametrize('yaw', [math.nan, math.inf, -math.inf, None, 'bad', 3.001, -3.001]) +def test_invalid_yaw_resets_core(yaw): + c = ModelActionController() + c.update(straight(.4), .01, speed=10., dt=.01) + assert c.update(straight(.4), .01, speed=10., dt=.01, yaw_rate=yaw) == FordPath() + assert c.c0 == c.c1 == 0. diff --git a/openpilot/sunnypilot/sunnylink/settings_ui.json b/openpilot/sunnypilot/sunnylink/settings_ui.json index 8903610d1b..75c5deacbe 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui.json +++ b/openpilot/sunnypilot/sunnylink/settings_ui.json @@ -2184,7 +2184,7 @@ "needs_onroad_cycle": true, "title": "Selected-Action Path Tracking (Experimental)", "description": "Follow the selected steering plan with nearby model-path centering on the Ford CAN FD F-150 Lightning.", - "details": "Uses nearby model-path offset and a heading request based directly on selected planned curvature. There is no accumulated measured-turning correction. Default off; physical tracking and turn-exit behavior are not road-validated. Enable only for controlled testing. On the Ford CAN FD F-150 Lightning this takes priority over PSCM Coefficient Observer; other vehicles retain their existing controller. Turning it off restores PSCM Coefficient Observer if selected, otherwise the original Ford path controller. Changes apply after a real offroad-to-onroad cycle, not immediately or on disengagement alone.", + "details": "Uses nearby model-path offset and a heading request based directly on selected planned curvature. Reduces same-direction offset demand when measured turning exceeds the requested turn, without accumulating a correction. Default off; this revised turn-exit behavior is not road-validated. Enable only for controlled testing. On the Ford CAN FD F-150 Lightning this takes priority over PSCM Coefficient Observer; other vehicles retain their existing controller. Turning it off restores PSCM Coefficient Observer if selected, otherwise the original Ford path controller. Changes apply after a real offroad-to-onroad cycle, not immediately or on disengagement alone.", "enablement": [ { "type": "offroad_only" diff --git a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/vehicle.yaml b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/vehicle.yaml index b4c1bf9e7d..12de51e585 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/vehicle.yaml +++ b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/vehicle.yaml @@ -15,7 +15,7 @@ sections: needs_onroad_cycle: true title: Selected-Action Path Tracking (Experimental) description: Follow the selected steering plan with nearby model-path centering on the Ford CAN FD F-150 Lightning. - details: Uses nearby model-path offset and a heading request based directly on selected planned curvature. There is no accumulated measured-turning correction. Default off; physical tracking and turn-exit behavior are not road-validated. Enable only for controlled testing. On the Ford CAN FD F-150 Lightning this takes priority over PSCM Coefficient Observer; other vehicles retain their existing controller. Turning it off restores PSCM Coefficient Observer if selected, otherwise the original Ford path controller. Changes apply after a real offroad-to-onroad cycle, not immediately or on disengagement alone. + details: Uses nearby model-path offset and a heading request based directly on selected planned curvature. Reduces same-direction offset demand when measured turning exceeds the requested turn, without accumulating a correction. Default off; this revised turn-exit behavior is not road-validated. Enable only for controlled testing. On the Ford CAN FD F-150 Lightning this takes priority over PSCM Coefficient Observer; other vehicles retain their existing controller. Turning it off restores PSCM Coefficient Observer if selected, otherwise the original Ford path controller. Changes apply after a real offroad-to-onroad cycle, not immediately or on disengagement alone. enablement: - $ref: '#/macros/offroad' - key: FordPscmObserver diff --git a/tools/ford_pscm_lab/damping_replay.py b/tools/ford_pscm_lab/damping_replay.py new file mode 100644 index 0000000000..1baf035326 --- /dev/null +++ b/tools/ford_pscm_lab/damping_replay.py @@ -0,0 +1,156 @@ +"""Compare v1 construction and v2 damping on complete local rlogs, offline. + +Original controls publication times proxy computation time. Consumed model +timestamps are exact; carState is causal and carControl is matched within 5 ms. +This compares commands on a fixed recording, never counterfactual vehicle motion. +""" +import argparse +from collections import Counter +import hashlib +import json +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import zstandard + +from openpilot.cereal import log +from openpilot.selfdrive.controls.lib import ford_model_action +from openpilot.selfdrive.controls.lib.ford_model_action import FordModelActionController +from tools.ford_pscm_lab.model_action_replay import WireCheck, field_checks, sample, verify_dependency + + +DEPLOYMENT_OPENDBC = 'c21a9013700734dd20b09e05aa68329ad8cc20f9' + + +def extract(directory): + columns = {'cs': 't valid can_valid speed yaw torque pressed', 'controls': 't valid desired model_ns', + 'cc': 't valid active', 'params': 't valid', 'path': 't valid active c0 c1', 'model': 't valid ns'} + rows = {name: [] for name in columns} + models, sources = [], {} + t0 = None + files = sorted(directory.glob('*--rlog.zst'), key=lambda p: int(p.name.split('--')[-2])) + if not files: + raise ValueError('No complete rlogs found') + for file in files: + compressed = file.read_bytes() + sources[file.name] = hashlib.sha256(compressed).hexdigest() + data = zstandard.ZstdDecompressor().stream_reader(compressed).read() + for event in log.Event.read_multiple_bytes(data): + kind, t, valid = event.which(), event.logMonoTime*1e-9, event.valid + if t0 is None: + t0 = t + if kind == 'carState': + cs = event.carState + rows['cs'].append((t, valid, cs.canValid, cs.vEgo, -cs.yawRate, cs.steeringTorque, cs.steeringPressed)) + elif kind == 'controlsState': + cs = event.controlsState + rows['controls'].append((t, valid, cs.desiredCurvature, cs.lateralPlanMonoTime)) + elif kind == 'carControl': + rows['cc'].append((t, valid, event.carControl.latActive)) + elif kind == 'vehicleParameters': + rows['params'].append((t, valid)) + elif kind == 'carControlSP': + path = event.carControlSP.fordLateralPath + rows['path'].append((t, valid, path.valid, path.pathOffset, path.pathAngle)) + elif kind == 'modelV2': + model = event.modelV2 + models.append(SimpleNamespace(position=SimpleNamespace(x=list(model.position.x), y=list(model.position.y)), + orientation=SimpleNamespace(z=list(model.orientation.z)))) + rows['model'].append((t, valid, event.logMonoTime)) + elif kind == 'lateralManeuverPlan': + raise ValueError('This replay requires routes without a separate maneuver reference') + streams = {} + for name, fields in columns.items(): + values = np.array(rows[name]) + if not len(values) or np.any(np.diff(values[:, 0]) < 0.): + raise ValueError(f'Missing or backward {name} stream') + streams[name] = dict(zip(fields.split(), values.T, strict=True)) + return streams, models, sources, t0 + + +def run(directory, output): + directory, output = directory.resolve(), output.resolve() + if output == directory or directory in output.parents: + raise ValueError('Output must be outside the source route directory') + verify_dependency(DEPLOYMENT_OPENDBC) + streams, models, sources, t0 = extract(directory) + controls, model = streams['controls'], streams['model'] + t = controls['t'] + cs, params = (sample(streams[name], t) for name in ('cs', 'params')) + cc, recorded = (sample(streams[name], t, nearest=True) for name in ('cc', 'path')) + mi = np.clip(np.searchsorted(model['ns'], controls['model_ns']), 0, len(models)-1) + exact = model['ns'][mi] == controls['model_ns'] + services = ((controls['valid'] == 1) & (cc['valid'] == 1) & (abs(cc['t']-t) < .005) & + (cs['valid'] == 1) & (cs['can_valid'] == 1) & (params['valid'] == 1) & + (t-params['t'] >= 0.) & (t-params['t'] <= .15) & exact & (model['valid'][mi] == 1)) + baseline, candidate, wire = FordModelActionController(), FordModelActionController(), WireCheck() + before, after = np.zeros((len(t), 4)), np.zeros((len(t), 4)) + eligible = np.zeros(len(t), bool) + reasons = Counter() + for i, now in enumerate(t): + kwargs = {'speed': cs['speed'][i], 'now': now, 'measurement_time': cs['t'][i], 'model_time': model['t'][mi[i]], + 'reference_time': model['t'][mi[i]], 'active': bool(cc['active'][i]), 'valid': bool(services[i])} + geometry = models[mi[i]] if exact[i] else None + # Zero yaw retains v1 targets. Both passes enforce the actual yaw range gate. + kwargs['valid'] &= bool(np.isfinite(cs['yaw'][i]) and abs(cs['yaw'][i]) <= 3.) + a = baseline.update(geometry, controls['desired'][i], yaw_rate=0., **kwargs) + b = candidate.update(geometry, controls['desired'][i], yaw_rate=cs['yaw'][i], **kwargs) + assert a.valid == b.valid and a.path_angle == b.path_angle + before[i] = a.path_offset, a.path_angle, a.curvature, a.curvature_rate + after[i] = b.path_offset, b.path_angle, b.curvature, b.curvature_rate + eligible[i] = b.valid + reasons[candidate.diagnostics['status']] += 1 + wire.check(a) + wire.check(b) + field_checks(before, eligible, t) + field_checks(after, eligible, t) + clean = eligible & (cs['pressed'] == 0) & (abs(cs['torque']) <= 1.) + # Erode driver eligibility by one second in each direction on original time. + bad = np.r_[0, np.cumsum(~clean)] + left, right = np.searchsorted(t, t-1.), np.searchsorted(t, t+1., side='right') + clean &= (bad[right] == bad[left]) & (t >= t[0]+1.) & (t <= t[-1]-1.) + relative = t-t0 + masks = {'eligible': eligible, 'driver_clean': clean, + 'driver_clean_low_request_above_8mps': clean & (cs['speed'] >= 8.) & (abs(controls['desired'])*cs['speed']**2 < .15), + 'turn': clean & (abs(controls['desired'])*cs['speed']**2 >= .5), + 'segment10_entry_peak': eligible & (relative >= 637.) & (relative < 640.), + 'segment10_exit_before_strong_input': eligible & (relative >= 642.7) & (relative < 643.852)} + weight = np.minimum(np.diff(t, append=t[-1]+.01), .03) + difference = abs(before[:, 0]-after[:, 0]) + cohorts = {} + for name, mask in masks.items(): + if mask.any(): + cohorts[name] = {'cycles': int(mask.sum()), 'seconds': float(weight[mask].sum()), + 'changed_c0_cycles': int((difference[mask] > 1e-9).sum()), + 'mean_absolute_c0_change_m': float(np.average(difference[mask], weights=weight[mask])), + 'max_absolute_c0_change_m': float(difference[mask].max()), + 'v1_peak_absolute_c0_m': float(abs(before[mask, 0]).max()), + 'v2_peak_absolute_c0_m': float(abs(after[mask, 0]).max())} + paired = eligible & (recorded['valid'] == 1) & (recorded['active'] == 1) & (abs(recorded['t']-t) < .005) + actual = np.column_stack((recorded['c0'], recorded['c1'])) + error = abs(before[:, :2]-actual) + report = {'scope': 'Fixed-input command replay only; no physical improvement or stability claim.', 'calibration_approved': False, + 'cycles': len(t), 'eligible_cycles': int(eligible.sum()), 'status_counts': dict(reasons), + 'c1_exactly_unchanged': True, 'same_validity': True, 'field_slew_zero_c2_c3_pass': True, + 'float32_can_round_trips': wire.count, 'cohorts': cohorts, + 'v1_reconstruction_vs_recorded': {'paired_cycles': int(paired.sum()), + 'within_one_quantum_cycles': int(np.all(error[paired] <= [.010001, .0005001], axis=1).sum()), + 'maximum_absolute_error_c0_c1': np.max(error[paired], axis=0).tolist()}, + 'timing': 'Publication-time proxy, causal carState, exact consumed model; full SubMaster health unavailable.', + 'baseline': 'Current adapter with zero yaw retains v1 targets and actual-yaw sanity gate.', + 'source_rlog_sha256': sources, 'opendbc_head': DEPLOYMENT_OPENDBC, + 'source_sha256': {str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in (Path(__file__), Path(ford_model_action.__file__))}} + output.mkdir(parents=True, exist_ok=True) + np.savez_compressed(output/'commands.npz', t=relative, before=before, after=after, eligible=eligible, + speed=cs['speed'], yaw=cs['yaw'], desired=controls['desired'], torque=cs['torque']) + (output/'report.json').write_text(json.dumps(report, indent=2, allow_nan=False)+'\n') + print(json.dumps({k: v for k, v in report.items() if k not in ('source_rlog_sha256', 'source_sha256')}, indent=2)) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('rlog_directory', type=Path) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + run(args.rlog_directory, args.output) diff --git a/tools/ford_pscm_lab/model_action_replay.py b/tools/ford_pscm_lab/model_action_replay.py index 4dca9d6759..0bd023b686 100644 --- a/tools/ford_pscm_lab/model_action_replay.py +++ b/tools/ford_pscm_lab/model_action_replay.py @@ -1,7 +1,7 @@ """Replay the selected core and its adapter on route90/95 original-time extracts. -The historical pass deliberately uses the archived eligibility mask to check -command compatibility. The separate adapter pass reconstructs input eligibility +The historical pass uses zero yaw and the archived eligibility mask to check +v1 command compatibility. The separate v2 adapter pass reconstructs input eligibility from service records, never from candidate/baseline output validity. Neither pass scores counterfactual motion. Source extracts and archived reports are read-only; --output selects a separate destination. @@ -143,11 +143,11 @@ def run(directory, output): adapter_valid[i] = new_gate.valid reasons[adapter.diagnostics['status']] += 1 wire.check(new_gate) - # Isolate the adapter's fresh 10 ms engagement tick from the archived - # harness, which used the preceding publication interval even on engage. + # Current core receives actual yaw and a fresh 10 ms engagement tick; + # the archived v1 construction above deliberately receives zero yaw. entry_dt = dt[i] if i > 0 and baseline['valid'][i-1] else .01 expected_adapter = entry_clock_core.update(selected_model, controls['desired'][i], speed=cs['speed'][i], dt=entry_dt, - active=bool(baseline['valid'][i])) + yaw_rate=cs['yaw'][i], active=bool(baseline['valid'][i])) assert new_gate == expected_adapter, f'Unexplained adapter difference at cycle {i}' np.testing.assert_array_equal(valid, baseline['valid']) np.testing.assert_array_equal(commands[:, :2], baseline['action_heading']) @@ -190,7 +190,7 @@ def run(directory, output): 'calibration_approved': False, 'executes_live_selector': False, 'cycles': len(t), 'core_active_cycles': int(valid.sum()), 'core_exact_archived_match': True, 'cohorts_reproduced': True, 'adapter_active_cycles': int(adapter_valid.sum()), 'adapter_status_counts': dict(reasons), - 'adapter_exact_match_with_fresh_engagement_dt': True, + 'adapter_matches_current_core_with_yaw_and_fresh_engagement_dt': True, 'core_active_path_shorter_than_7m_cycles': int(np.sum(valid & (coverage < 7.))), 'adapter_validity_differs_from_archive_cycles': int(np.sum(adapter_valid != valid)), 'adapter_command_differs_from_archive_cycles': int(np.any(abs(adapted-commands) > 1e-9, axis=1).sum()), @@ -199,7 +199,7 @@ def run(directory, output): 'timing': 'Original controls publication timestamps proxy computation time; repeated frames and gaps retained. No identified delay.', 'eligibility': 'Adapter checks recorded services independently; full SubMaster health is unavailable. Core uses archived validity.', 'reference': 'Recorded controlsState.desiredCurvature, already selected/limited. These two routes have no maneuver publications.', - 'host_yaw': 'Extract cs.yaw already equals -carState.yawRate. Used only for inherited finite/range gate, never feedback.', + 'host_yaw': 'Extract cs.yaw equals -carState.yawRate; v2 adapter uses it for bounded damping. Archived core pass uses zero yaw.', 'cohorts': cohorts, 'workspace_head': revision(root), 'opendbc_import_head': revision(dependency), 'opendbc_import_path': str(dependency), 'source_sha256': {str(p.resolve()): hashlib.sha256(p.read_bytes()).hexdigest() for p in sources}} diff --git a/tools/ford_pscm_lab/stress_model_action.py b/tools/ford_pscm_lab/stress_model_action.py index 4cce9cd6f2..ea129d6c47 100644 --- a/tools/ford_pscm_lab/stress_model_action.py +++ b/tools/ford_pscm_lab/stress_model_action.py @@ -56,6 +56,7 @@ def run(cycles, seed, output, opendbc_revision=PINNED_OPENDBC): offset, heading = float(rng.uniform(-8., 8.)), float(rng.uniform(-1.2, 1.2)) speed = float(rng.uniform(.3, 55.)) desired = float(rng.uniform(-.15, .15)) + yaw = float(rng.uniform(-3., 3.)) dt = dt_values[i % len(dt_values)] active = i % 137 != 0 valid = i % 211 != 0 @@ -64,14 +65,21 @@ def run(cycles, seed, output, opendbc_revision=PINNED_OPENDBC): model, mirror = line(offset, heading), line(-offset, -heading) if i % 401 == 0: model.position.y[4] = mirror.position.y[4] = math.nan - out = controller.update(model, desired, speed=speed, dt=dt, active=active, valid=valid) - other = mirrored.update(mirror, -desired, speed=speed, dt=dt, active=active, valid=valid) + out = controller.update(model, desired, speed=speed, dt=dt, yaw_rate=yaw, active=active, valid=valid) + other = mirrored.update(mirror, -desired, speed=speed, dt=dt, yaw_rate=-yaw, active=active, valid=valid) expected_valid = active and valid and dt <= .1 and i % 401 != 0 assert out.valid == other.valid == expected_valid previous = np.array([c0, c1]) if expected_valid: target = (max(-5.11, min(5.11, offset+7.*math.sin(heading))), max(-.5, min(.5, max(7., speed)*desired))) - c0 += max(-4.*dt, min(4.*dt, target[0]-c0)) + # Independent piecewise scalar oracle; do not call the production helper. + offset_target = target[0] + if offset_target > 0. and yaw > 0.: + offset_target = max(0., offset_target-1.4*max(0., yaw-max(0., speed*desired)-.02)) + elif offset_target < 0. and yaw < 0.: + offset_target = min(0., offset_target+1.4*max(0., -yaw-max(0., -speed*desired)-.02)) + assert abs(offset_target) <= abs(target[0]) and offset_target*target[0] >= 0. + c0 += max(-4.*dt, min(4.*dt, offset_target-c0)) c1 += max(-.5*dt, min(.5*dt, target[1]-c1)) step = abs(np.array([controller.c0, controller.c1])-previous) assert (step <= np.array(rates)*dt+1e-10).all() @@ -105,6 +113,7 @@ def run(cycles, seed, output, opendbc_revision=PINNED_OPENDBC): report = {'seed': seed, 'random_cycles': cycles, 'mirrored_core_updates': cycles, 'invalid_or_inactive_resets': resets, 'field_boundary_cases': boundary_cases, 'float32_can_round_trips': wire.count, 'analytic_targets_scalar_slew_and_mirror_checks_pass': True, + 'bounded_excess_yaw_damping_checked': True, 'direct_raw_float32_packing_matches_host_output': True, 'max_continuous_step_c0_c1': max_continuous_step.tolist(), 'calibration_approved': False, 'scope': 'Numerical construction only; no PSCM response or closed-loop performance claims.', 'opendbc_import_head': revision(dependency),