From 744a97d9bc08d8743b250eceff7c88585b5480de Mon Sep 17 00:00:00 2001 From: Isaac Barham Date: Mon, 7 Sep 2026 17:00:05 -0400 Subject: [PATCH] Ford: damp excess-yaw offset demand in selected-action controller Attenuate same-direction C0 when measured yaw exceeds the nonnegative requested turn plus a deadband. Keep C1, two slew states, input gates and zero C2/C3. Opposed planned curvature cannot amplify small yaw bias. Segment 10 replay reduces residual exit demand while preserving peak entry C0. This remains an experimental, physically unvalidated candidate under the existing default-off Sunnylink toggle. Validation: 325 tests and 26 subtests; 100% controller statement/branch coverage; 204,946 route cycles; 628,030 Float32/CAN round trips; independent standards/spec reviews. Assisted-by: OpenAI Codex --- docs/ford_model_action_damping.md | 85 +++++++++ .../ford_model_action_damping_validation.json | 164 ++++++++++++++++++ docs/ford_model_action_drive_test.md | 22 ++- .../controls/lib/ford_model_action.py | 41 +++-- .../tests/test_ford_controlsd_logging.py | 2 +- .../tests/test_ford_model_action_adapter.py | 9 +- .../tests/test_ford_model_action_damping.py | 75 ++++++++ .../sunnypilot/sunnylink/settings_ui.json | 2 +- .../settings_ui_src/pages/vehicle.yaml | 2 +- tools/ford_pscm_lab/damping_replay.py | 156 +++++++++++++++++ tools/ford_pscm_lab/model_action_replay.py | 14 +- tools/ford_pscm_lab/stress_model_action.py | 15 +- 12 files changed, 549 insertions(+), 38 deletions(-) create mode 100644 docs/ford_model_action_damping.md create mode 100644 docs/ford_model_action_damping_validation.json create mode 100644 openpilot/selfdrive/controls/tests/test_ford_model_action_damping.py create mode 100644 tools/ford_pscm_lab/damping_replay.py 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),