Compare commits

...

338 Commits

Author SHA1 Message Date
whoisdomi a699c11569 Build 2026-11-03 20:47:42 -06:00
whoisdomi 7d740b8a31 c3 and c3x aggressive cooling 2026-11-03 20:47:17 -06:00
whoisdomi a726bda691 Match Drive Mode with Accel/Decel Profiles 2026-11-03 20:47:11 -06:00
whoisdomi 72c450092d Highway Smoothing 2026-11-03 20:46:52 -06:00
whoisdomi 39649958ca Force Stop: add dashboard signal back 2026-11-02 08:30:13 -06:00
whoisdomi f65bb49d0a Hold CEM for cross traffic leads 2026-11-02 08:15:15 -06:00
whoisdomi b16bd8c7a9 Force Stop Temp Hold 2026-11-01 22:25:07 -06:00
firestar5683 2ee94f3643 Use saved vehicle settings in learner recovery tests 2026-11-01 21:57:07 -06:00
firestar5683 9e4cba8695 Test angle demand conditioning in StarPilot Controller 2026-11-01 21:28:45 -06:00
firestar5683 7cb8ea919f Isolate preference and replay clock test fixtures 2026-11-01 21:10:04 -06:00
firestar5683 f2f8cfffa8 Stop Auto Learn when learning is switched off 2026-11-01 21:01:08 -06:00
firestar5683 45c081b181 Balance independent Ford lifecycle cases across CI shards 2026-11-01 19:46:33 -06:00
firestar5683 736580721a Match Heatdeath safety builds and stream source verification 2026-11-01 18:59:11 -06:00
firestar5683 374a4134f6 Complete Honda active observation Card fixture 2026-11-01 18:36:08 -06:00
firestar5683 b16fc29ed5 Match radar isolation fixture to process health API 2026-11-01 18:17:07 -06:00
Dom c1e2b5f181 Complete Card fixtures and make SLC test timing deterministic 2026-11-01 17:44:39 -06:00
firestar5683 2c7f252feb Align developer command tests with JotPluggler alias 2026-11-01 17:25:38 -06:00
firestar5683 e522e5c6a3 Share immutable FLM evidence to bound learning memory 2026-11-01 17:14:56 -06:00
firestar5683 55f23c7fd5 Restore separate StarPilot Ford controller choices 2026-11-01 17:12:54 -06:00
firestar5683 61ad53c61f Keep automatic Tesla AP1 discovery on normal wiring 2026-11-01 16:55:04 -06:00
firestar5683 36e0e37086 Keep driving independent of UI restarts 2026-11-01 16:54:44 -06:00
firestar5683 407288852f Preserve Ford AOL through startup and brake transitions 2026-11-01 16:52:24 -06:00
firestar5683 0384be1253 Keep AOL through Nostalgia paddle handoff 2026-11-01 16:50:51 -06:00
firestar5683 a13b6395d4 Build stable traffic mode icon 2026-11-01 16:46:05 -06:00
firestar5683 e7cca0a136 Keep traffic mode icon stable on C4 2026-11-01 16:44:22 -06:00
firestar5683 c0bbed7762 Launch JotPluggler with juggle shortcut 2026-11-01 16:33:44 -06:00
firestar5683 6df04fa8e0 Detect Prius TSS2 steering rack retrofits automatically 2026-11-01 16:23:55 -06:00
firestar5683 40cd8ca264 Keep selected Tesla discovery on its declared CAN bus 2026-11-01 16:15:36 -06:00
firestar5683 d9b60fddc1 Balance measured Ford lifecycle work 2026-11-01 13:45:31 -06:00
firestar5683 153b95a88f Keep Auto Learn evidence current 2026-11-01 13:13:40 -06:00
firestar5683 5b6bdbe8ef Use typed gradient test fixtures 2026-11-01 12:39:39 -06:00
firestar5683 b70b7e16c4 Batch path gradient interpolation 2026-11-01 12:17:17 -06:00
firestar5683 195b7000aa Keep CI shard capacity and reviewed replay metadata consistent 2026-11-01 00:23:15 -05:00
firestar5683 1c7e21c43e Repair controller observation and current regression contracts 2026-11-01 00:08:07 -05:00
Dom e61c7993af Complete Card test interface fixtures for CAN observation 2026-10-31 23:59:05 -05:00
firestar5683 b158bc378a Preserve Ford expiry guards alongside native-first AOL braking 2026-10-31 23:15:31 -05:00
firestar5683 72f16053b5 Keep Ford curvature handoffs within native steering limits 2026-10-31 22:57:13 -05:00
Dom 1fe202b904 Narrow optional Bolt test intent before latch assignment 2026-10-31 22:40:12 -05:00
firestar5683 33bc40d061 Avoid invisible onroad texture draws 2026-10-31 22:35:49 -05:00
firestar5683 0102ad2a35 Review model producer after lane correction removal 2026-10-31 22:08:42 -05:00
firestar5683 5b0f81caa1 Reconcile queued Mach-E inputs before declaring a source fault 2026-10-31 21:56:17 -05:00
firestar5683 77ff308c76 Preserve GM lateral intent across brief Card source gaps 2026-10-31 21:48:35 -05:00
Dom cd585f55c2 Repair alert fixture and document weather epoch clock 2026-10-31 21:06:07 -05:00
firestar5683 feb1f442c2 Repair Ford BluePilot startup fixtures and native test loader identity 2026-10-31 20:44:19 -05:00
firestar5683 a7f4ba8254 Simplify Ford steering settings and preserve independent lateral intent 2026-10-31 20:36:04 -05:00
firestar5683 0f71f4669f Reduce FLM evidence allocations in Controls 2026-10-31 19:27:54 -05:00
firestar5683 2bc4f59d41 Report current FLM admission blockers 2026-10-31 19:25:18 -05:00
firestar5683 e8256ee5b2 Avoid repeated schema misses in the onroad display 2026-10-31 19:11:51 -05:00
firestar5683 1738b3798d Build Galaxy dashboard weather 2026-10-31 18:21:15 -05:00
firestar5683 d69ba777b5 Show outside temperature on Galaxy dashboard 2026-10-31 18:19:40 -05:00
firestar5683 0206e5dfc1 Default lane-centering model breakaway to fifty percent 2026-10-31 18:18:13 -05:00
Dom ff15fc4fae ci: restore selfdrived executable file mode
Restore the existing shebang script's executable mode without changing its source blob.
2026-10-31 17:57:44 -05:00
firestar5683 83e6132b6d Prefer another FLM coordinate after rejected oscillation 2026-10-31 17:56:27 -05:00
firestar5683 31b34665ce Preserve Ford AOL across gas override and lag recovery 2026-10-31 17:55:34 -05:00
firestar5683 8df7979373 Build matching GV60 HDA1 AOL artifacts 2026-10-31 17:43:02 -05:00
firestar5683 6f88e79025 Support captured GV60 HDA1 Alpha Long AOL 2026-10-31 17:32:21 -05:00
firestar5683 23123aefb4 Keep radar failures from disabling independent lateral steering 2026-10-31 17:25:44 -05:00
firestar5683 ddd67e58bc Reuse exact GM configuration checks in driving hot paths 2026-10-31 17:00:37 -05:00
firestar5683 b6e25bfa6d Match CAN-FD longitudinal parsers to fuel and camera layout 2026-10-31 16:55:25 -05:00
firestar5683 f10a7e229b Preserve stock CAN-FD AOL intent through held-gas recovery 2026-10-31 16:45:21 -05:00
firestar5683 ac318aa31e Fix Prius stop-and-go settings and stopped resume 2026-10-31 16:40:36 -05:00
firestar5683 15134f1ec2 Build Panda host binaries for comma hardware 2026-10-31 16:12:56 -05:00
Dom cb0c1507de test: complete default metadata lookup in remaining preference fixtures
Delegate the isolated Ioniq5PE, Toyota shell and GM pitch mock default lookups to real Params metadata. Preserve existing assertions, no-setting-write checks and runtime behavior.
2026-10-31 15:48:59 -05:00
firestar5683 3926f11699 Avoid unnecessary AOL recovery profile validation 2026-10-31 15:46:58 -05:00
firestar5683 8a760a3381 Scope ordinary Ford native lookup to configured Ford cars 2026-10-31 14:54:27 -05:00
firestar5683 58c38a4834 Honor default AOL buttons on classic HKG 2026-10-31 14:52:46 -05:00
Dom eb56047d57 test: supply real defaults and fresh onroad state in isolated fixtures
Delegate mock default lookup to Params metadata and supply the existing healthy UI fixtures with their post-start receive frame and onroad state. Preserve all runtime code, assertions and negative authority coverage.
2026-10-31 14:45:06 -05:00
alan-polk 67ad8ff05b Merge BluePilot Ford lateral library v0.2.1 2026-10-31 13:51:21 -05:00
firestar5683 fdbce123c8 Build matching BluePilot Ford firmware and host binaries 2026-10-31 13:40:02 -05:00
firestar5683 5235f58e07 Keep FLM trial input tapes current between motion deliveries 2026-10-31 13:39:45 -05:00
firestar5683 f26ab5e7f1 Integrate pinned BluePilot defaults and qualify Ford AOL lifecycle 2026-10-31 13:29:29 -05:00
alan-polk 14acf1ed31 Merge BluePilot Ford lateral library v0.2.1 2026-10-31 13:29:27 -05:00
firestar5683 6d2e382e92 Renew FLM from already received motion during FIFO ingest 2026-10-31 13:06:52 -05:00
firestar5683 fcb59fa1da Validate each FLM binding once per witness call 2026-10-31 12:54:03 -05:00
firestar5683 8c404b3b8a Align AOL reset and favorites fixtures with independent controls 2026-10-31 12:23:09 -05:00
firestar5683 3498e84770 Reuse validated immutable FLM source identity content 2026-10-31 12:15:20 -05:00
firestar5683 72f04816c3 Add metric-aware extra stopped-lead spacing 2026-10-31 12:04:00 -05:00
firestar5683 9068ec74f7 Keep quiet-camera HKG discovery on normal wiring 2026-10-31 11:58:04 -05:00
firestar5683 2df9c98963 Keep bookmark shortcuts working while alerts are displayed 2026-10-31 11:49:51 -05:00
firestar5683 70f523edc5 Renew owned FLM trials when admitted queued input becomes ready 2026-10-31 10:46:02 -05:00
firestar5683 c1f37c8314 Renew owned FLM trials when admitted queued input becomes ready 2026-10-31 10:26:39 -05:00
firestar5683 15ed9799cb Renew owned FLM trials when admitted queued input becomes ready 2026-10-31 09:43:22 -05:00
Dom f9dccd0c67 test(ford): return native transmit outcomes to joined angle fixture
Feed exact emitted steering payloads and their accepted/rejected host-bus receipts on the next parser cycle. Preserve all assertions and runtime behavior.
2026-10-31 09:36:31 -05:00
Dom 6a972ae87e test(gm): feed actual steering transmit receipts in healthy fixtures
Keep existing assertions and runtime behavior unchanged. Model healthy transport by returning only actually emitted steering commands; retain missing, stale, wrong-bus and rejected receipt coverage.
2026-10-31 09:25:04 -05:00
firestar5683 5197fe4568 Fix live network label typing and generated catalog lint policy 2026-10-31 08:51:50 -05:00
firestar5683 642d9d657d Record additive engagement receipt enum in schema compatibility review 2026-10-31 08:42:32 -05:00
firestar5683 cd0f46b9d6 Prevent stale lead handoff from reacquiring a clear stopped hold 2026-10-31 08:38:43 -05:00
firestar5683 a6030fa25d Feed native shadow receipts through Ford angle recovery tests 2026-10-31 08:27:09 -05:00
firestar5683 b8200e7bab Revisit pending FLM motion before another evidence drain 2026-10-31 08:19:02 -05:00
firestar5683 f9b678a7c3 Restore HKG AOL with auxiliary Pandas and default buttons 2026-10-31 08:14:06 -05:00
firestar5683 68fca49aab Avoid repeated static Bolt and empty-fault classification in Card 2026-10-31 08:11:52 -05:00
firestar5683 731a9e6c79 Keep Ford angle handovers quiet and preserve gas-override AOL 2026-10-31 08:09:22 -05:00
firestar5683 f506c8f299 Wait for Bolt steering transmit receipts before advancing counters 2026-10-31 08:04:17 -05:00
firestarsdog 6c38692ff7 Preserve SLC decisions to prevent source churn 2026-10-31 06:13:47 -04:00
firestarsdog 5668bafc4c Fix Vision units to prevent inflated metric speed limits 2026-10-31 04:13:19 -04:00
firestarsdog 859145dd8a Use SLC on RES to match SET 2026-10-31 03:41:55 -04:00
Dom 37341d2bca Fix CI type narrowing, fixtures and executable metadata
Preserve SLC validation behavior with explicit None narrowing, use existing Mock test doubles, and restore controlsd executable metadata without content changes. Keep exact torque gain expectations while matching the existing list representation.

Validated 1095 related tests, targeted ty and Ruff; prior baseline comparison and 4236 guard-equivalence cases preserve existing rejection semantics. Refresh the inventory receipt only for these validated changes.
2026-10-31 00:00:28 -05:00
firestarsdog 23a7b93df0 Rebuild Params and cereal for SLC changes 2026-10-31 00:53:56 -04:00
firestarsdog 7bce4121e5 Drop legacy SLC offsets for simpler setup
Use one saved schedule with editable zero defaults. Preserve current schedules and round displayed offsets and edits to whole units without conversion drift.
2026-10-31 00:24:31 -04:00
alan-polk 5c32d95d7b BluePilot lateral library: v0.2.1
Re-vendored at the v0.2.1 release (2b9dc6c), which adds StarPilot as a host.
Since the last copy: the matrix tests only re-key SAFETY_PARAM when the host's
stock base defines it (IQ.Pilot's iqdbc doesn't; StarPilot's does, so no change
here), and a FORKS.md note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:50:58 -05:00
alan-polk a0d80165df Ford AOL: a rejected host actuator frame doesn't hand the bus to the camera
The previous commit let the camera's actuator through again as soon as one host frame was rejected. Mid-steer
that put the camera's inactive frame (its own counter) on the bus in place of the blocked one, and
the F-150's PSCM ramped out and reported lateral control faulty for several seconds, from a single
blocked frame. Without AOL, static blocking makes a rejected frame a missed one, which the PSCM rides
through.

Block the camera's copy while the host is sending, accepted or not; only a host that has stopped
sending for the AOL host timeout gives the actuator back to the camera.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:50:58 -05:00
alan-polk 67f4250d7e Ford AOL: one steering actuator sender while the host's frames are accepted
With AOL, the camera's actuator (0x3D3/0x3D6/0x3CA) is forwarded unless AOL owns steering, but the
host sends its own every frame, active or not. When AOL isn't steering, the PSCM hears both, with
unrelated counters. On a 2021 F-150 this flipped LatCtlSte_D_Stat between Unavailable and Available
every 50-100 ms for seconds at a time, worst when the two counters ran 1-2 apart: steerFaultTemporary
alerts, the panda's LKA-ready check denying lateral, and AOL dropping its latch on re-engagement.
BluePilot's angle-mode release during a manual turn made it easy to hit.

Block the camera's actuator while the host's last frame was accepted and is within the AOL host
timeout, as static blocking does without AOL. A rejected or stale host frame lets the camera's
copy through again. Permission and display ownership are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:49:32 -05:00
alan-polk ed3b1c4c73 Settings: BluePilot lateral for Fords
On the Steering page, for every Ford that can run BluePilot lateral (not the Transit), in Galaxy and the
native settings alike:
- BluePilot Lateral (off by default), Primary Control (curvature/angle) and Use Pinion Yaw Sensor (platforms
  with a pinion geometry row) change while parked; the switch and the pinion sensor apply at the next vehicle
  startup, and Safe Mode keeps BluePilot lateral off.
- the tuning rows of the current mode change any time, since the library reads them every frame; rows that
  depend on Lane Positioning or the custom profile wait for it.
Ranges come from the library's manifest and defaults from params_keys.h, so the rows can't drift from what the
controller reads. A change is checked against the row it was made on, so a request from before its switch
changed is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:49:32 -05:00
alan-polk 49b651af70 openpilot: BluePilot lateral startup bits, per-frame inputs and settings registry
- params_keys.h: every setting in the library's manifest (python -m opendbc.bluepilot_lateral.params.manifest),
  without BACKUP, which StarPilot's params don't have. disable_BP_lat_UI defaults to "1": BluePilot lateral
  is off until the user turns it on.
- car init (VehicleStartupPreferences.prepare): init_ford_safety_param_starpilot ORs the library's startup
  bits into the Ford param. They stay off without the openpilot toggle, in Safe Mode, or when a startup
  setting can't be read.
- card (configure_controller): the Ford CarController gets a BluePilotInputs, which fills the library's
  LateralInputs from modelV2, vehicleParameters and lateralDelay each frame and re-reads the per-frame
  settings every 3 s.
- test_bluepilot_lateral.py: the init path's bits, the real CarController and libsafety with the same
  param, for a CAN and a CAN FD Ford, off/on/on+pinion/pinion-only, curvature and angle mode: every
  steering and LKA frame passes, and only angle mode with BluePilot on steers through the path angle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:49:32 -05:00
alan-polk 40147f9f74 BluePilot lateral library: Mondeo pinion geometry, platforms without a pinion row
Re-vendored from the library (stamped copy, unedited).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:49:32 -05:00
alan-polk bcb0abe576 Ford: hook the BluePilot lateral library into opendbc
- safety/modes/ford.h: includes the library's safety with StarPilot's lateral permission
  (lateral_controls_allowed(), which includes AOL) as its lateral binding. ford_init hands the library
  bits 9-14 of the Ford param (never on the Transit); with the bit set, LateralMotionControl/2 go through
  the library's 4-signal checks instead of the stock ones, Lane_Assist_Data1 carries the angle-mode state,
  and the pinion source replaces yaw and joins the selected RX list (stock, stock-switch or AOL).
- car/ford/carcontroller.py: one BluePilotLateral per Ford; while it's active it sends the steering and
  LKA messages, otherwise the stock path runs unchanged. StarPilot's openpilot side attaches the per-frame
  inputs and settings as bluepilot_inputs.
- safety tests: test.sh runs the library's safety tests by directory and gates the library headers'
  coverage in a run of their own; test_ford_bluepilot_hooks.py covers where the bits ride, the Transit,
  the pinion RX check on every list and the full-list guard. The qualification and mutation runners
  include the library's safety tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:49:32 -05:00
alan-polk 7b36024f6d Ford safety tests: reset the alternative experience after AOL fixtures
The CAN FD display fixtures and the stock-switch class left alternative_experience at 32. Now that the
stock words are AOL words, a later Ford test could run with AOL configured; they reset it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:49:32 -05:00
alan-polk c5ce014573 BluePilot lateral library (stamped copy)
opendbc_repo/opendbc/bluepilot_lateral is BluePilotDev/bluepilot-lateral as released, made by the
library's tools/vendor.sh and checked by its tools/check_vendored.sh (VERSION names the release and
commit). Nothing inside the folder is edited here: changes go to the library, get released, and are
vendored again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:49:32 -05:00
alan-polk 8da0c9d916 Ford: remove the interim BluePilot port; AOL on the stock profiles
StarPilot's angle-only bluepilot_lateral adaptation and the BluePilot-derived curvature strategies (Mach-E,
classic, Explorer, generic CAN FD, new-port curvature, manual-turn latch, curvature preview, pinion
measurement) are removed, with their extended safety profiles 16/32/64 and the interim angle word 128. Ford
lateral is upstream's stock curvature path until the BluePilot lateral library is mounted.

StarPilot's own Ford features stay, re-keyed from the extended words to the stock ones:
- AOL: classic 32 -> 0, Mach-E 18 and the CAN FD trucks 66 -> 2 (+1 with openpilot long); the CAN FD
  retained-display rules apply to word 2. Edge/Mondeo/Transit and the manual bit (256) are unchanged.
- release CAN FD long: word 3 (was 19/67); 19 and 67 are now retired and denied.
- stock cruise switch, CAN FD camera-lead radar handling, hands-free cluster: keyed on the Ford family.
- classic stock ACC (0) keeps the no-ACCDATA TX list the extended classic profile had.

Bits 9-14 of the Ford param are reserved for the BluePilot lateral library's startup bits
(safety/modes/ford_param.h); AOL and pandad mask them out of the profile word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:49:32 -05:00
firestarsdog 2fd0741f56 fix(slc): prevent confirmation prompts when longitudinal control is disengaged 2026-10-30 23:31:59 -04:00
firestar5683 c3d5939ffe Keep Polestar discovery on normal harness wiring 2026-10-30 22:09:43 -05:00
firestar5683 7f8aa3dceb Restore stable effective gain across StarPilot torque controllers 2026-10-30 22:07:21 -05:00
firestarsdog a1fccf70b4 Draw unset SET dash to avoid font fallback 2026-10-30 23:01:32 -04:00
firestarsdog aac227a103 Bind SET engagement to the selected speed limit 2026-10-30 22:53:13 -04:00
firestar5683 0d8c81e294 Renew an owned FLM trial before draining queued evidence 2026-10-30 21:49:47 -05:00
firestarsdog b9744f1cab Cycle shared personality widget on tap 2026-10-30 22:30:42 -04:00
firestarsdog 70b705782c Unify Onroad Long Indicator 2026-10-30 22:28:58 -04:00
firestar5683 efc8527820 Restore measured FLM receiver after queue scheduling regressions 2026-10-30 21:27:10 -05:00
firestar5683 d421abeee1 Yield FLM queues only when actual motion needs service 2026-10-30 21:05:45 -05:00
firestar5683 7da9cea0f6 Yield FLM evidence queues before fresh pose and trial delivery 2026-10-30 20:41:23 -05:00
firestar5683 24b3d17c58 Load live FLM trial validation before the driving loop 2026-10-30 19:51:36 -05:00
firestar5683 64eba05e62 Capture genuine FLM checkpoints before the reconstruction age ceiling 2026-10-30 19:27:50 -05:00
Dom 87736cf206 Use the established sound-pack mock in audio callback test 2026-10-30 19:10:31 -05:00
firestar5683 fb20651274 Retain native-visible FLM experiments through missing-evidence pauses 2026-10-30 19:03:46 -05:00
Dom 84bc481dd0 Align Galaxy test fixtures with current developer controls 2026-10-30 18:58:02 -05:00
firestar5683 e438a1a374 Play one temporary steering warning per AOL fault episode 2026-10-30 18:01:31 -05:00
firestar5683 f7c38a24a8 Show vehicle battery voltage on the Galaxy dashboard 2026-10-30 17:56:44 -05:00
Dom d56266b63b Restore selfdrived executable permission 2026-10-30 17:53:32 -05:00
firestar5683 6984f1da29 Add calibration reset beside driver monitoring in Galaxy 2026-10-30 17:43:17 -05:00
firestar5683 ce6545467b Keep AOL steering active with doors open 2026-10-30 17:36:45 -05:00
firestar5683 1a89d5577a Cover HKG torque steering independence from Alpha Long 2026-10-30 17:26:45 -05:00
firestar5683 39298d97be Remove optional Volt longitudinal tune 2026-10-30 17:21:28 -05:00
firestar5683 d84abc611d Keep door-open AOL pauses free of takeover alerts 2026-10-30 17:19:22 -05:00
firestar5683 60b0f1e826 Reuse exact native primitive validation during FLM input packing 2026-10-30 17:04:36 -05:00
firestar5683 2487154a1e Restore flashed Volt LKAS button mapping 2026-10-30 16:53:57 -05:00
firestar5683 5bedae0794 Refresh FLM producer clock pin for current modeld 2026-10-30 16:44:27 -05:00
Dom 1ab130e4a5 Align updater test fixtures with metadata checks 2026-10-30 16:41:09 -05:00
firestar5683 320e399f98 Keep alternate HKG CAN-FD discovery on normal wiring 2026-10-30 16:29:48 -05:00
firestar5683 1d7e01f9f1 Preserve Mach-E AOL across angle and pinion handovers 2026-10-30 16:26:07 -05:00
firestar5683 37bbd2711d Add toggle defaults reset to Software 2026-10-30 16:12:04 -05:00
Dom d513137550 Repair CI test collection and fixtures 2026-10-30 16:07:17 -05:00
firestar5683 0d32c47b56 Preserve FLM discovery against preselection backlog 2026-10-30 16:05:31 -05:00
firestar5683 6bc374c7d4 Refresh the release inventory after updater source changes 2026-10-30 16:04:39 -05:00
mtawata 867ed373d6 fix fast updater hang; improve version selection list; schedule update install 2026-10-30 13:58:52 -07:00
firestar5683 0fd2353c55 Publish the intended common archive as an ordinary Git blob 2026-10-30 15:37:32 -05:00
firestar5683 21f675eae0 Validate each FLM control graph once during probe preparation 2026-10-30 15:32:49 -05:00
firestar5683 f4dde57839 Keep FLM input reception responsive and numerical work off Controls 2026-10-30 15:09:04 -05:00
firestar5683 b2604f4b8a Ship the executable Params library 2026-10-30 13:23:44 -05:00
firestar5683 ebe7f28aae Update from the live installed Git revision 2026-10-30 13:18:48 -05:00
firestar5683 bf0243f41b Build matching Outback MAIN lateral firmware 2026-10-30 13:15:36 -05:00
firestar5683 da0d0d204b Restore Toyota stop-and-go assistance setting 2026-10-30 13:09:55 -05:00
firestar5683 e9e7481416 Offer both controllers on every torque-tuned vehicle 2026-10-30 13:08:26 -05:00
firestar5683 0fcf127704 Arm Outback lateral on physical cruise MAIN 2026-10-30 13:07:30 -05:00
Dom f7b85976a5 Align lane-centering test fixtures 2026-10-30 13:00:19 -05:00
James Vecellio-Grant 2b3e186822 controls: Blendful lateral policy (alpha) (#2073) 2026-10-30 12:40:57 -05:00
firestar5683 8daabf71f9 Keep live plots refresh cadence and layout steady 2026-10-30 12:02:00 -05:00
firestar5683 1fcfefc977 Show classic HKG AOL icons before cruise engagement 2026-10-30 11:59:48 -05:00
firestar5683 01529d935d Keep FLM numerical preparation within its input budget 2026-10-30 11:27:24 -05:00
firestar5683 ba0e051293 Fix language font loading on device 2026-10-30 11:10:30 -05:00
firestar5683 bfca5476e6 Preserve manager executable permission 2026-10-30 11:07:21 -05:00
firestar5683 bfa9427dbd Fix typed lane centering default enrollment 2026-10-30 11:06:09 -05:00
Dom 02588a19a2 Use typed localization test resources and fixtures 2026-10-30 10:52:25 -05:00
firestar5683 f4ec1c187e Share corroborated CEM lead departure across vehicle configurations 2026-10-30 10:48:59 -05:00
Dom bc494bd7d5 Restore manager executable mode 2026-10-30 10:43:58 -05:00
firestar5683 0b94e10fd9 Preserve Toyota stop engagement while the brake is held 2026-10-30 10:39:14 -05:00
firestar5683 f09bf1fc28 Reuse validated immutable FLM input metadata 2026-10-30 10:34:03 -05:00
firestar5683 de00bb2f11 Preserve missing lane centering preferences on upgrades 2026-10-30 10:28:21 -05:00
firestar5683 7acb954a6d Translate Galaxy and native UI through the shared language setting 2026-10-30 10:27:37 -05:00
firestar5683 5e057b9848 Share display translations across Galaxy and native UI 2026-10-30 10:27:37 -05:00
firestar5683 4fcf11f07a Enable lane centering by default on new installs 2026-10-30 10:22:47 -05:00
Dom 6120b36217 Register existing blindspot setting descriptions 2026-10-30 09:59:21 -05:00
Dom 5c662d9e43 Keep sound selection tests stable with blindspot settings 2026-10-30 09:53:20 -05:00
Dom b64d7cf9e9 Share banked Hyundai CAN-FD RX workspace to recover Panda RAM 2026-10-30 09:07:01 -05:00
firestar5683 18432adb3d Build blindspot alert 2026-10-30 08:41:03 -05:00
firestar5683 fae3f5ccfc Blindspot alert 2026-10-30 08:37:21 -05:00
firestar5683 8fdee1d44e Remove redundant wheel control default buttons 2026-10-30 08:19:47 -05:00
firestar5683 b3b96adecc Preserve Palisade AOL through camera echo races and braking 2026-10-30 07:48:14 -05:00
firestar5683 df9898673e Stabilize GM startup test collection across CI shards 2026-10-30 03:34:54 -05:00
firestar5683 66beca6de7 Avoid overlapping subscriptions for the flashed Volt parser 2026-10-30 02:40:34 -05:00
Dom 717a82245f Account for Galaxy-only accelerator preference in page parity test 2026-10-30 02:23:15 -05:00
firestar5683 d955ebd7a2 Keep schema and caller checks aligned with reviewed source contracts 2026-10-30 02:22:25 -05:00
firestar5683 2cca20624c Finalize the admitted Sportage long AOL startup profile 2026-10-30 02:12:43 -05:00
firestar5683 7db42dd5d6 Distinguish native-rejected Ford UI from transmitted display frames 2026-10-30 01:49:30 -05:00
firestarsdog eff809d839 Add DisengageOnAccelerator to Galaxy 2026-10-30 02:41:22 -04:00
Dom b3a4b964c2 Use typed Quick Select test fixtures
Retain real composition and input-routing methods with typed drawing boundaries.
Preserve existing assertions and validate optional settings requests explicitly.
2026-10-30 01:40:41 -05:00
firestarsdog 0114eb04bb Add compact ui DisengageOnAccelerator 2026-10-30 02:17:36 -04:00
firestarsdog 890c30c3cb Rebuild Params and cereal for SLC lookahead 2026-10-30 02:13:07 -04:00
firestar5683 7e00ee80c4 Align regression and settings coverage with current AOL contracts 2026-10-30 01:05:49 -05:00
firestarsdog e00fb66bc8 Firefox Arabesque Updater 2026-10-30 01:49:51 -04:00
firestarsdog 0d4422552b Large UI: replace radial Quick Select with a stable overlay 2026-10-30 01:43:43 -04:00
firestar5683 c7f3de2416 Clarify speed-limit type validation and settings test contracts 2026-10-30 00:28:07 -05:00
firestar5683 4ea9adbdb9 Order Ford standby display after inactive steering proof 2026-10-30 00:21:06 -05:00
firestar5683 9e7e4dde52 Keep factory wheel roles explicit and restore CI source hygiene 2026-10-30 00:16:33 -05:00
firestarsdog a8bc9d1f24 Merge remote-tracking branch 'origin/Dom' into Dom 2026-10-30 01:10:22 -04:00
firestarsdog 63395a56f1 SLC: add timed adoption of upcoming limits 2026-10-30 01:03:30 -04:00
firestar5683 0a786d37e3 Show factory AOL roles and fix wheel controls safety qualification 2026-10-30 00:01:17 -05:00
firestarsdog 6fe76b728b Mapd: add lookahead for early speed-limit adoption 2026-10-30 00:41:31 -04:00
Dom c65296f6df Initialize native settings localization test fixture
Share isolated real Params with the localization owner and initialize the RHD row.
Preserve existing saved-state and parked-state assertions.
2026-10-29 23:05:03 -05:00
firestar5683 008687eb87 Build Sportage AOL and shared wheel controls fixes 2026-10-29 23:03:49 -05:00
firestar5683 dc7ff90070 Restore Sportage AOL and show effective wheel controls 2026-10-29 22:46:29 -05:00
firestar5683 c6ef6b138a Avoid duplicate native packet validation in FLM 2026-10-29 22:39:28 -05:00
Dom 56c997a284 Restore controlsd executable mode
Preserve the source blob while restoring the script's executable bit.
Refresh the source inventory receipt for the mode-only correction.
2026-10-29 22:22:17 -05:00
firestar5683 f1f8f5696e Use Metric 2026-10-29 22:14:25 -05:00
firestar5683 16f31abd75 RHD Fix 2026-10-29 22:13:23 -05:00
firestar5683 77babc4c85 Release an existing Force Stop hold with RES 2026-10-29 22:10:01 -05:00
firestar5683 d1c2bc14a4 Localize steering command publication stalls 2026-10-29 21:57:08 -05:00
firestar5683 86315e76cb Restore flashed Volt OBD alpha longitudinal profile 2026-10-29 21:55:21 -05:00
firestar5683 52347a3593 Preserve AOL intent through temporary steering overrides 2026-10-29 21:40:46 -05:00
firestar5683 0933e58afc Keep AOL border lit while its latch is armed 2026-10-29 21:30:07 -05:00
firestar5683 cc125cf1eb Preserve Toyota cruise during accelerator override 2026-10-29 21:15:56 -05:00
firestar5683 c073497d44 Recognize short deliberate bookmark swipes 2026-10-29 21:14:56 -05:00
Dom b817c3c26e Fix Subaru init parameter MISRA compliance
Normalize a same-type local copy instead of modifying the input parameter.
Existing safety logic and all 34 paired firmware artifacts remain byte-identical.
Validated 67 debug and 67 release native cases; refresh source inventory receipt.
2026-10-29 20:40:23 -05:00
Dom 075d5c0ffe Cover Impreza MAIN words in the exhaustive AOL protocol contract 2026-10-29 19:41:11 -05:00
Dom 1f1b1d5db9 Match persisted language and bounded proof wait test fixtures 2026-10-29 19:24:19 -05:00
firestar5683 4dd24beabc Separate Volt CC steering minimum from cruise engagement 2026-10-29 19:19:20 -05:00
firestar5683 ad7bfa4899 Build matching 2020 Impreza MAIN lateral artifacts 2026-10-29 19:11:18 -05:00
firestar5683 5910ddb754 Arm 2020 Impreza lateral on physical cruise MAIN 2026-10-29 19:09:50 -05:00
firestar5683 21a3921b16 Keep GM steering active through axis proof delivery races 2026-10-29 19:07:31 -05:00
firestar5683 33e87cea5a Recognize standard HDA2 wiring before diagnostic queries 2026-10-29 18:59:39 -05:00
firestar5683 e3fbbdddb9 Add language selection to Galaxy localization 2026-10-29 18:42:16 -05:00
Dom 76d33aaa81 Keep native test fixtures aligned with current lifecycle contracts 2026-10-29 18:26:08 -05:00
Dom ff62737ac4 Align GM interface fixtures with registered identities and Gen2 Drive 2026-10-29 17:43:51 -05:00
Dom 87b8b2ada5 Cover Polestar receiver counter wraps and fail-closed faults 2026-10-29 17:19:31 -05:00
Dom b43db836a5 Label the non-pedal Bolt camera profile as ACC 2026-10-29 17:17:33 -05:00
firestar5683 1b9bb0cd51 Keep FLM snapshot preparation below live capture priority 2026-10-29 17:03:56 -05:00
Dom c3de3defc3 Keep parked power tests in their own collected module 2026-10-29 17:03:45 -05:00
Dom 268370640d Type Jetlink test parameters for model selections 2026-10-29 17:00:01 -05:00
firestar5683 0d795930ff Keep the FLM live receiver at normal CPU priority 2026-10-29 16:58:34 -05:00
firestar5683 ec5739441e Expose parked shutdown settings in Galaxy 2026-10-29 16:58:07 -05:00
Dom e94198f98e Clarify Subaru MAIN observer for MISRA without changing output 2026-10-29 16:41:26 -05:00
Zeph Leggett 5f8638e894 Jetlink: Active Big chooses Jetlink's model by its ONNX 2026-10-29 16:38:54 -05:00
firestar5683 98d2865a29 Publish the Controls scheduling repair for FLM 2026-10-29 16:28:51 -05:00
firestar5683 0e55c02b28 Prioritize Controls CPU time during FLM learning 2026-10-29 16:28:50 -05:00
Dom 3d7df63099 Normalize continuation indentation within source size limits 2026-10-29 16:26:33 -05:00
firestar5683 47e4855580 Build Polestar counter correction firmware 2026-10-29 16:18:37 -05:00
firestar5683 b33e4a071b Correct Polestar LCA counters at modulo-15 wraps 2026-10-29 16:17:07 -05:00
firestar5683 bc3dce2c0c Keep manager shutdown nonblocking and preserve paused AOL intent 2026-10-29 16:12:56 -05:00
Dom ed5e9e56f2 Assert registered Trax layout without changing stock control contracts 2026-10-29 16:07:54 -05:00
dirwin31 30625319b3 Improve Android Auto reconnection and report screen resolution to Bugsink 2026-10-29 16:06:11 -05:00
Zeph Leggett 51e866a038 Jetlink: Active Big chooses Jetlink's model by its ONNX
With Jetlink running the big model (on, supported, no Chestnut), Active Big
in Galaxy and on the device is Jetlink's choice: the model manager marks a
big model selectable when its catalog entry names the ONNX it was built from
(source_sha256), downloaded or not, since Jetlink's host fetches the ONNX
itself. The choice is the new JetlinkModel param, {sha256, displayName}, the
adapter's big-model key; Chestnut's own Active Big is left as it was. None
leaves the model to the Jetlink app, and the manager words it.

A newer choice made in the Jetlink app is written back into JetlinkModel by
jetlink, so Active Big shows the model that drives; one StarPilot does not
list is named as the app's.

JetlinkModel is a new key in params_keys.h: the native params libraries need
their usual rebuild.
2026-10-29 16:03:03 -05:00
Zeph Leggett 21c34c8f4d Jetlink v0.8.12 2026-10-29 16:03:03 -05:00
firestar5683 2800e5b452 Keep unarmed startup steering restrictions from displaying takeover warnings 2026-10-29 15:49:53 -05:00
firestar5683 85997f719f Quiet incomplete drive history and recover full logs 2026-10-29 15:42:32 -05:00
firestar5683 138da851ee Guard two-line centering against conflicting corrections 2026-10-29 15:37:16 -05:00
dirwin31 556f90458e Merge branch 'Dom' into AA-AutoConnectFix-Dom 2026-10-29 13:11:06 -07:00
firestar5683 e2fdc2a4dc Remember unchanged model verification across restarts 2026-10-29 14:59:31 -05:00
firestar5683 312eb9e94d Recover model verification after a worker failure 2026-10-29 14:51:14 -05:00
firestar5683 37fc53c48a Explain community Polaris models in Galaxy 2026-10-29 14:48:32 -05:00
firestar5683 efbe7a776f Materialize matching Panda host object files 2026-10-29 14:48:03 -05:00
firestar5683 7c5e70fd13 Build matching Ford standby-handover firmware 2026-10-29 14:37:09 -05:00
firestar5683 06a74465c0 Keep Ford standby display through the final steering packet 2026-10-29 14:34:35 -05:00
firestar5683 42057a3f8f Limit low-speed steering advisories to engaged or armed steering 2026-10-29 14:33:29 -05:00
firestar5683 d85372232f Animate Polaris highlights and preserve emoji colors 2026-10-29 14:30:06 -05:00
firestar5683 a952a8f8a8 Keep Mach-E native LONG withdrawal separate from manual steering 2026-10-29 14:27:37 -05:00
firestar5683 0826f3b22d Highlight custom Polaris models 2026-10-29 14:25:17 -05:00
firestar5683 58fb8e820b Identify the 2025 Trax from its CAN layout and VIN 2026-10-29 14:21:21 -05:00
firestar5683 38e14008b9 Build Impreza physical MAIN lateral firmware 2026-10-29 13:41:17 -05:00
firestar5683 4c015f6f49 Arm Impreza lateral on physical MAIN and show stock ACC engagement 2026-10-29 13:39:31 -05:00
firestar5683 256628e50b Keep Ford AOL through Cancel during accelerator override 2026-10-29 13:07:02 -05:00
firestar5683 fa25ea0567 Publish native permission for the bypassed-camera Volt configuration 2026-10-29 12:51:00 -05:00
Dom 99f52a7048 Align Bolt fixtures with Drive feeds and explicit ACC faults 2026-10-29 12:44:53 -05:00
firestar5683 eb2c90b80e Refresh queued GM permission during combined control 2026-10-29 12:37:07 -05:00
firestar5683 53b82a1726 Put Cruise Intervals In Wheel Controls 2026-10-29 12:31:54 -05:00
firestar5683 3b04ff3cc4 Keep downloaded models selectable offline 2026-10-29 12:27:56 -05:00
firestar5683 6e11c50dfc Build jacobw Subaru angle adapter firmware 2026-10-29 12:08:10 -05:00
firestar5683 34ae63cbf6 Adapt jacobw Subaru angle control to StarPilot 2026-10-29 12:06:38 -05:00
firestar5683 1f79ca865f Recognize Outback Wilderness camera firmware and credit jacobw Subaru support 2026-10-29 11:54:05 -05:00
Dom 87cb8f9a63 Assert exact four-byte Toyota button capability in variant parity 2026-10-29 11:52:04 -05:00
firestar5683 21b9fd8136 Build Gen2 Bolt pedal firmware 2026-10-29 11:49:58 -05:00
firestar5683 d317185c00 Allow Drive for Gen2 Bolt pedal 2026-10-29 11:48:24 -05:00
firestar5683 f47452c4b1 Restore radar-filter Toyota distance buttons and wheel mapping 2026-10-29 11:23:58 -05:00
firestar5683 6e124d1e45 Keep inactive ACC traffic flowing for every qualified GM owner 2026-10-29 11:17:06 -05:00
firestar5683 50b4da94ba Maintain inactive ACC streams during ordinary SDGM startup 2026-10-29 11:00:17 -05:00
firestar5683 66bf42ac1e Keep Bolt pedal control available after secondary brake loss 2026-10-29 10:55:11 -05:00
firestar5683 8aa35ed00f Report verified models in usage statistics 2026-10-29 10:51:59 -05:00
firestar5683 6639a95904 Keep lane centering responsive with fresh inputs and continuous tracking 2026-10-29 10:22:45 -05:00
dirwin31 2b069cffc2 Build 2026-10-29 06:43:30 -07:00
dirwin31 ac97f5f447 Add supported Message 2026-10-29 06:31:46 -07:00
dirwin31 e7b53f3099 Bugsink Screen Resolutions 2026-10-29 06:21:31 -07:00
dirwin31 6418bfd5b7 Let the car connect first and clear stuck Android Auto Bluetooth links
At ignition the head unit pages the comma for hands-free at the same moment
the supervisor paged the car. The crossed connections can leave the
controller holding a link the 4.9 kernel no longer knows ("ACL packet for
unknown connection handle 7"), after which every SDP/RFCOMM connect to the
car fails with EALREADY and the session retries the same sequence until the
car gives up.

- Ignition-triggered sessions wait up to 8 s for the car's own hands-free
  link before paging it.
- EALREADY on SDP or the cached RFCOMM channel fails the attempt at once,
  keeps the cached channel, drops the stray handle with a raw HCI Disconnect
  (only when BlueZ reports the car disconnected and the kernel does not list
  that handle), and lets the car connect first on the retry.
2026-10-29 06:21:10 -07:00
Dom 6d87ba090c Assert retired SLC fallback stays inactive after source loss 2026-10-29 03:12:15 -05:00
Dom 3fb884b5ba Follow renamed conditional planner integration fixture 2026-10-29 02:27:25 -05:00
firestarsdog aa99e3b6ca Build for SLC Refinement 2026-10-29 03:04:02 -04:00
firestarsdog ad397f89cc Retire SLC fallback to unify behavior 2026-10-29 02:57:30 -04:00
Dom 4e3775fa06 Refresh source inventory after speed-limit UI cleanup 2026-10-29 01:36:13 -05:00
firestarsdog 01416612a1 Remove "Use X Speed" 2026-10-29 02:16:51 -04:00
firestar5683 8fcd77c953 Recover FLM learning windows and deliver current trial evidence 2026-10-29 00:44:04 -05:00
firestar5683 9e002886f9 Build matching Ford cluster-handover firmware 2026-10-29 00:15:33 -05:00
firestar5683 1f9825e594 Keep Ford cluster standby through driver handover 2026-10-29 00:11:34 -05:00
firestar5683 4a82e92c73 Avoid repeated single-line lane-centering acquisition stalls 2026-10-29 00:09:26 -05:00
firestarsdog 507e464c59 Arabesque Firefox Character Fix 2026-10-29 00:32:05 -04:00
firestar5683 b6b548fc9a Admit fresh Toyota lateral input with cruise already off 2026-10-28 23:17:50 -05:00
firestar5683 434c89ce71 Normalize positive model-origin roundoff for stopped release 2026-10-28 22:57:27 -05:00
firestar5683 50781a9736 Fit StarPort torque widgets to square head-unit layouts 2026-10-28 22:37:17 -05:00
firestar5683 31b4abe4c6 Keep camera ACC messages flowing throughout GM startup 2026-10-28 22:28:52 -05:00
firestar5683 cdc97b58df Recognize HKG HDA1 wiring before diagnostic queries 2026-10-28 22:10:42 -05:00
Dom 4a3a989b3c Publish exact Panda bootstub bytes as ordinary Git blobs 2026-10-28 21:48:37 -05:00
firestar5683 b75d9f8baf Build repaired Panda CAN power-save firmware 2026-10-28 21:37:45 -05:00
firestar5683 b12ed577c0 Preserve stock Hyundai AOL across fresh input transitions 2026-10-28 21:34:28 -05:00
firestar5683 2567a32f70 Preserve Panda power-save state across CAN reinitialization 2026-10-28 21:29:24 -05:00
firestar5683 639866e6f7 Keep Hyundai brake withdrawal guard within source budget 2026-10-28 21:08:08 -05:00
firestar5683 ccdddea522 Preserve classic Hyundai AOL through brake withdrawal 2026-10-28 21:02:30 -05:00
Dom b5f1b8a8e3 Complete ready-state fixture for CAN clock contract 2026-10-28 20:45:58 -05:00
firestar5683 14835e1b42 Ignore logging-only Honda EPS diagnostic responses 2026-10-28 20:21:19 -05:00
firestar5683 f1bc3a8c5b Restore legacy USB Panda bootstub identification 2026-10-28 20:20:40 -05:00
Dom cbeaffba9a Refresh audited GPU capability fixture fingerprints 2026-10-28 20:11:42 -05:00
firestar5683 6537a58bd6 Avoid repeated Toyota parent admission projections 2026-10-28 19:52:55 -05:00
firestar5683 5a12f032ed Reuse exact GM steering profile qualification 2026-10-28 19:48:36 -05:00
firestar5683 094c9ae84e Reuse MEB static eligibility within each longitudinal update 2026-10-28 19:47:36 -05:00
firestar5683 83d4bf30a8 Reuse constant Chrysler AOL qualification masks 2026-10-28 19:46:32 -05:00
firestar5683 fdaaae86b0 Share exact static configuration cache across vehicle makes 2026-10-28 19:44:05 -05:00
firestar5683 8995ddb0df Build Toyota configuration overhead fix 2026-10-28 19:27:04 -05:00
firestar5683 390438432e Reduce Toyota pedal runtime configuration overhead 2026-10-28 19:25:58 -05:00
Dom 26ab9745af Align startup fixtures with GM neutral continuity contract 2026-10-28 19:03:46 -05:00
firestar5683 d8ccb7fd68 Honor StarPort head unit display insets 2026-10-28 18:59:25 -05:00
Dom 03feefed84 Preserve Toyota behavior with single-exit safety helpers 2026-10-28 18:20:31 -05:00
firestar5683 1fa29f6c33 Reverse Proxy 2026-10-28 18:07:26 -05:00
firestar5683 34dfc2e1b5 Build Toyota and Lexus pedal camera-copy parity 2026-10-28 18:04:12 -05:00
firestar5683 5309a0d6bc Preserve Toyota and Lexus primary pedal ownership with camera copies 2026-10-28 18:03:06 -05:00
firestar5683 3c770dc283 Build legacy Prius DSU and pedal receiver port 2026-10-28 17:52:38 -05:00
firestar5683 265ca6c17d Merge current Dom checks for legacy Prius receiver 2026-10-28 17:51:29 -05:00
firestar5683 04b5dd0ad5 Port legacy Prius retrofit DSU and pedal receiver from StarPilot 2026-10-28 17:51:23 -05:00
firestar5683 cdef286dbf Cover HKG stop permissions across native policy families 2026-10-28 17:37:03 -05:00
firestar5683 41692084c9 Build Prius retrofit native DSU mirror fix 2026-10-28 17:31:22 -05:00
firestar5683 dbba95d7fb Keep Prius retrofit stock control when native DSU traffic is mirrored 2026-10-28 17:30:07 -05:00
Dom fee87a4576 Keep transport pause comments within source size budget 2026-10-28 17:22:40 -05:00
firestar5683 df8e234ff6 Preserve CCNC AOL intent through full-engagement stop pauses 2026-10-28 17:05:42 -05:00
firestar5683 ba2275363f Maintain neutral steering throughout GM startup 2026-10-28 16:42:50 -05:00
Dom a4e552c7a3 Classify the compressed Ford regression fixture as binary 2026-10-28 15:36:55 -05:00
firestar5683 65ac3bccf6 Restore Dom predictive stopping across HKG 2026-10-28 15:35:36 -05:00
firestar5683 3e7a3dded8 Release corroborated Mach-E lead departure from CEM standstill hold 2026-10-28 15:25:31 -05:00
Dom 62dd77d6bb Keep transport intent documentation within the text size limit 2026-10-28 15:02:57 -05:00
firestar5683 7a7b6d2208 Identify blocking work inside Controls overruns 2026-10-28 14:58:41 -05:00
firestar5683 b8aa3b9eca Arm Ford Alpha AOL on the first physical MAIN edge 2026-10-28 14:41:17 -05:00
firestar5683 b0e93e304f Retain Ford AOL across first gas-held native grant 2026-10-28 14:29:26 -05:00
firestar5683 3baf4f7c82 Skip unrelated GM steering checks on other vehicles 2026-10-28 14:23:29 -05:00
firestar5683 621c8d31e3 Restore Stinger forecast stop intent with the smooth planner 2026-10-28 14:20:00 -05:00
firestar5683 e095ab6b71 Retain real FLM source packets for bounded frequency accounting 2026-10-28 14:01:27 -05:00
1056 changed files with 248158 additions and 34396 deletions
+3
View File
@@ -57,3 +57,6 @@ openpilot/selfdrive/pandad/panda_safety.o -text
openpilot/selfdrive/pandad/panda_usb.o -text
openpilot/selfdrive/pandad/pandad.o -text
openpilot/selfdrive/pandad/spi.o -text
# Compressed recorded regression fixture; retain exact binary bytes.
openpilot/starpilot/tests/testdata/ford_mache_lead_departure.json.gz -text
+6 -4
View File
@@ -38,7 +38,9 @@ jobs:
if: github.event_name == 'push' && github.ref == 'refs/heads/Dom'
run: python3 tools/ci/check_prebuilt_receipt.py --revision HEAD
- name: Check shipped device runtime dependencies
run: python3 -m unittest tools.laptop_device_build.tests.test_runtime_dependencies -v
run: |
python3 -m unittest tools.laptop_device_build.tests.test_runtime_dependencies -v
python3 -c 'from pathlib import Path; assert b"starting pandad (comma hardware)" in Path("openpilot/selfdrive/pandad/pandad").read_bytes(), "Shipped pandad must use the comma hardware build profile"'
- name: Validate tracked dependencies
run: python3 tools/vendor/check.py --revision HEAD
- run: ./tools/op.sh setup
@@ -256,7 +258,7 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]
shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]
steps:
- uses: actions/checkout@v7
- name: Provide JavaScript runtime for Galaxy contract tests
@@ -289,7 +291,7 @@ jobs:
timeout-minutes: 15
env:
RAYLIB_BACKEND: headless
run: .venv/bin/python -m tools.ci.custom_pytest_shards run --index "${{ matrix.shard }}" --count 24 --output "ci-results/custom-pytest-${{ matrix.shard }}"
run: .venv/bin/python -m tools.ci.custom_pytest_shards run --index "${{ matrix.shard }}" --count 32 --output "ci-results/custom-pytest-${{ matrix.shard }}"
- name: Save collection, every outcome, JUnit and durable worker diagnostics
if: always()
uses: actions/upload-artifact@v4
@@ -312,7 +314,7 @@ jobs:
pattern: custom-pytest-*
path: ci-results/custom-pytest-shards
- name: Require every case passed or audited capability-unavailable
run: python3 -m tools.ci.custom_pytest_shards aggregate --directory ci-results/custom-pytest-shards --count 24 --output ci-results/custom-pytest-complete.json
run: python3 -m tools.ci.custom_pytest_shards aggregate --directory ci-results/custom-pytest-shards --count 32 --output ci-results/custom-pytest-complete.json
- name: Save complete custom catalog verdict
if: always()
uses: actions/upload-artifact@v4
+16 -10
View File
@@ -2,7 +2,7 @@
## Jetlink external inference
The comma-side Jetlink client and USB transport in `jetlink_repo` are by Zeph Leggett and the [Jetlink contributors](https://github.com/zoompilot/jetlink). They are vendored from revision `bf4dc940e98f5d3ed9b7163adb021439010d634f` under the MIT license, retained in [jetlink_repo/LICENSE](jetlink_repo/LICENSE). StarPilot's adapter (`openpilot/starpilot/models/jetlink_adapter.py`) builds on Jetlink's openpilot layer and owns its param names, Always On Lateral rule, model runner reset, settings and alerts. Companion applications and inference servers remain distributed by Jetlink.
The comma-side Jetlink client and USB transport in `jetlink_repo` are by Zeph Leggett and the [Jetlink contributors](https://github.com/zoompilot/jetlink). They are vendored from revision `a863c3801da6fd925d14eb1f5b1e250f8efcc649` under the MIT license, retained in [jetlink_repo/LICENSE](jetlink_repo/LICENSE). StarPilot's adapter (`openpilot/starpilot/models/jetlink_adapter.py`) builds on Jetlink's openpilot layer and owns its param names, Always On Lateral rule, model runner reset, settings and alerts. Companion applications and inference servers remain distributed by Jetlink.
## MRR35 Hyundai angle and radar
@@ -12,15 +12,11 @@ The copied MRR35 generator source and DBC definition are at `opendbc_repo/opendb
## Ford support adapted from BluePilot
The Ford lateral strategies, extended CAN constructors, camera-radar filtering and native checks are adapted from StarPilot `2efe6017bfe14cee4807833e05f9a7f6ba255cdb`. Their lineage includes BluePilot's `bp-7.0` Ford work, principally by Alan Polk, with contributions from John Christman, Jacob Neulight, tonesto7, Haibin Wen and other sunnypilot and BluePilot contributors. The reference snapshot is [`e1d051d7ba270261b4455068bd68f1a58db15a4a`](https://github.com/BluePilotDev/bluepilot/commit/e1d051d7ba270261b4455068bd68f1a58db15a4a); the exact checkout used for the earlier StarPilot import was not recorded.
Ford lateral control vendors [BluePilot lateral](https://github.com/BluePilotDev/bluepilot-lateral) v0.2.1 (`2b9dc6cc784faac47a1885d0bda0561f7b76f74d`), developed by Alan Polk (`alan-polk`) and the BluePilot contributors. Alan provided the [StarPilot integration in PR #269](https://github.com/StarPilotHQ/StarPilot/pull/269); his commits and authorship are retained. BluePilot maintains the library and its integration; StarPilot keeps its settings, AOL policy, packaging and integration tests separate. Complete contributor and license notices remain in [the library credits](opendbc_repo/opendbc/bluepilot_lateral/CREDITS.md) and [license](opendbc_repo/opendbc/bluepilot_lateral/LICENSE).
Relevant lineage includes the controller and integration work in [`db2bdff05`](https://github.com/BluePilotDev/bluepilot/commit/db2bdff05df103d71df62f45c2a3cb5211aba6e6) and [`d0aac605f`](https://github.com/BluePilotDev/bluepilot/commit/d0aac605f99d37e9da205e419f7989c1e9eaa386), extended CAN and safety work in [`8f8d6d15f`](https://github.com/BluePilotDev/bluepilot/commit/8f8d6d15f0a590f42b78de964ffb0d0af7f5d63d), and manual-turn detection in [`97867c1eb`](https://github.com/BluePilotDev/bluepilot/commit/97867c1eb57b7472f6fc3de62f0fef576e5a5497).
The Ford CAN FD camera-lead radar filtering is adapted from StarPilot `2efe6017bfe14cee4807833e05f9a7f6ba255cdb`. Its lineage includes BluePilot's `bp-7.0` Ford work, principally by Alan Polk, with contributions from John Christman, Jacob Neulight, tonesto7, Haibin Wen and other sunnypilot and BluePilot contributors. The reference snapshot is [`e1d051d7ba270261b4455068bd68f1a58db15a4a`](https://github.com/BluePilotDev/bluepilot/commit/e1d051d7ba270261b4455068bd68f1a58db15a4a); the exact checkout used for the earlier StarPilot import was not recorded.
The vehicle strategy and CAN/native owners preserve that attribution while adapting to the current controller interfaces and shared safety limits. Upstream contributors do not maintain or endorse this adaptation. Applicable notices are retained in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
## Optional Ford angle controller
The vendored `opendbc_repo/opendbc/bluepilot_lateral` angle core, codecs and Ford-only safety hooks are adapted directly from [BluePilot bp-7.0 e1d051d7ba270261b4455068bd68f1a58db15a4a](https://github.com/BluePilotDev/bluepilot/commit/e1d051d7ba270261b4455068bd68f1a58db15a4a), principally by Alan Polk, with John Christman, Jacob Neulight, Nathan Ingraham, Praeuner and other BluePilot, sunnypilot and comma contributors. Exact source paths and intentionally stricter safety checks are recorded in that folder. Both upstream `LICENSE` and `LICENSE.md` notices are retained. Its layout follows Alan Polk's September 2026 library proposal; this is a StarPilot adapter, not a published BluePilot library release.
The radar interface preserves that attribution while adapting to the current interfaces. Upstream contributors do not maintain or endorse this adaptation. Applicable notices are retained in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
## RAV4 Continental ARS510 receiver
@@ -34,6 +30,16 @@ Spotify's Bluetooth output binds the comma's BlueALSA A2DP source to the selecte
The Pre-AP configuration, pedal codec/feedback, VirtualDAS feedforward table, grade compensation, torque-zero estimator and vehicle-specific gains preserve firestar5683's intentional Dom work, including `7837a3c1aab`, `aed0b7fdb9e9` and pinned `b220a37f4bd119e402225a7aa16a7cd84878d0af`. Original author attribution: firestar5683 <168790843+firestar5683@users.noreply.github.com>. Startup/calibration ownership, current upstream button/PID interfaces and native command bounds are adapted to the current StarPilot architecture; the old permissive native gateway is not transplanted. Existing source/license notices remain intact.
## Ford optional pinion measurement
## Subaru angle steering from Jacob Waller's fork
The default-off Use Pinion Yaw Sensor measurement option is adapted from jrneulight’s BluePilot PR #145 (https://github.com/BluePilotDev/bluepilot/pull/145), merged as `fe5b019568c8c13fb894dc6d8d779d60985051a8`. Its per-platform geometry, native pinion measurement, integrity requirements and host vehicle-model compensation are retained. StarPilot adapts the option to its existing exact safety-profile word, modern curvature checks, AOL authority and Galaxy/native settings; the upstream maintainers do not maintain this integration. Existing upstream copyright and license notices remain in THIRD_PARTY_NOTICES.md.
Subaru angle support includes implementation maintained and integrated by **Jacob Waller (jacobw)** in [jacobwaller/openpilot, jul-angle-based](https://github.com/jacobwaller/openpilot/tree/542816196a18dba5f6f0af093c1ece4f415b5a22), with its [opendbc implementation at 8acbeccdab45a47aa7af170d2d17b61f0eb5e4f0](https://github.com/jacobwaller/opendbc/tree/8acbeccdab45a47aa7af170d2d17b61f0eb5e4f0). Jacob authored the VehicleModel angle-limit type correction and integrated the Crosstrek support. The branch also retains the original angle implementation and follow-up work by **lukasloetkolben and elkoled**, alongside comma contributors; their authorship remains theirs.
The inherited source covers Subaru angle platform/firmware definitions, `ES_Status` cruise state, angle controller/CAN integration, vehicle-model limits and native angle enforcement. StarPilot has additional platform-specific limits, driver handoff, discovery and AOL safety guards; this attribution does not claim identical tuning or complete parity with the reference fork. Original source commits, author names, emails and titles are recorded in [the Subaru origin history](opendbc_repo/opendbc/car/subaru/ORIGIN_HISTORY.tsv).
The explicit StarPilot [jacobw Subaru adapter](opendbc_repo/opendbc/car/subaru/JACOBW.md) additionally integrates that branch's low-speed filter, target-first limiting and Crosstrek EPS envelope. Its preserved stricter guards and platform scope are documented alongside the code.
## StarPilot Ford demand tuning
The previous optional StarPilot Controller, formerly in `openpilot/starpilot/car/ford/starpilot_tuning.py`, restored demand tuning from StarPilot `8da0c9d916^`, descended from BluePilot bp-7.0 `e1d051d7ba270261b4455068bd68f1a58db15a4a` through StarPilot `2efe6017bfe14cee4807833e05f9a7f6ba255cdb`. Contributors to that lineage include Alan Polk, John Christman, Jacob Neulight, tonesto7 and Haibin Wen. The current protocol adapter is maintained by StarPilot separately from the pinned BluePilot library; this attribution does not imply BluePilot maintains or endorses the adapted tune.
The current StarPilot Controller replaces that historical curvature-primary tune with a separately maintained adaptation of BluePilot v0.2.1's angle strategy (`2b9dc6cc784faac47a1885d0bda0561f7b76f74d`, `core/lateral_angle_ext.py`). Alan Polk is the principal controller author; John Christman contributed angle-mode anti-stall work, Jacob Neulight the pinion/shadow measurement, Nathan Ingraham and Praeuner high-speed damping, and other contributors are retained in the pinned library's `CREDITS.md`. StarPilot adds bounded small-signal conditioning ahead of the unchanged angle limit chain. This adaptation is not maintained or endorsed by BluePilot; the pinned library and its imported hooks are unchanged.
+11 -11
View File
@@ -73,17 +73,7 @@ the exclusion of the other.
## BluePilot Ford support
The Ford lateral strategies, CAN constructors, camera-radar filtering and native checks include work adapted from BluePilot's `bp-7.0` source, through StarPilot `2efe6017bfe14cee4807833e05f9a7f6ba255cdb`. See [CREDITS.md](CREDITS.md#ford-support-adapted-from-bluepilot) for contributors and source revisions. The upstream extension-file and `LICENSE.md` notices reproduced above also apply to the Ford extension sources; they are retained without resolving the difference between their published license descriptions.
## Optional BluePilot angle library adapter
The angle-only adapter in `opendbc_repo/opendbc/bluepilot_lateral` is sourced directly from BluePilot bp-7.0 commit e1d051d7ba270261b4455068bd68f1a58db15a4a. The complete upstream MIT notice is retained in that folder's `LICENSE`, and the simultaneously published Custom MIT notice is retained verbatim in `LICENSE.md`. The applicable SUNNYPILOT acknowledgments reproduced above are also retained in its `CREDITS.md`. This preserves the published terms without resolving their scope or representing the adapted extension as unambiguously plain MIT.
The optional, default-disabled angle lane-centering trim additionally derives from
BluePilot bp-dev e22afa6be9b881fa784c92ebb316db47728a3d81. Its exact source and
contributors are recorded in the library CREDITS.md; the same root/opendbc MIT
and Custom MIT notices coexist unchanged at that revision. This adds attribution,
not a license-scope decision or permission grant.
The Ford CAN FD camera-radar filtering includes work adapted from BluePilot's `bp-7.0` source, through StarPilot `2efe6017bfe14cee4807833e05f9a7f6ba255cdb`. See [CREDITS.md](CREDITS.md#ford-support-adapted-from-bluepilot) for contributors and source revisions. The upstream extension-file and `LICENSE.md` notices reproduced above also apply to that source; they are retained without resolving the difference between their published license descriptions.
## Continental ARS510 radar
@@ -140,3 +130,13 @@ dbus-next 0.2.3 (MIT), ifaddr 0.2.0 (MIT) and Zeroconf 0.151.3
`vendor/VENDOR.json` records each PyPI source file and its SHA-256. Zeroconf is
kept as separate, unmodified source files that can be replaced with another
compatible version, as the LGPL requires.
## Subaru angle support from jacobw
The Subaru angle implementation includes work from Jacob Waller's `jul-angle-based` openpilot/opendbc branches and their upstream contributors. See [CREDITS.md](CREDITS.md#subaru-angle-steering-from-jacob-wallers-fork) and `opendbc_repo/opendbc/car/subaru/ORIGIN_HISTORY.tsv` for pinned sources, original authors and titles. The source repositories publish the comma MIT license; the complete applicable notice remains in the project `LICENSE` and `opendbc_repo/LICENSE`. StarPilot's adaptations preserve additional native and caller safety guards.
## StarPilot Ford demand tuning
The historical tuning formerly in `openpilot/starpilot/car/ford/starpilot_tuning.py` included the BluePilot bp-7.0-derived work identified in [CREDITS.md](CREDITS.md#starpilot-ford-demand-tuning). The published upstream license notices retained above and in the pinned BluePilot library remain applicable to the adapted source. StarPilot's separate protocol adapter does not modify those notices or resolve their scope.
The angle-primary adaptation in `openpilot/starpilot/car/ford/starpilot_controller.py` is derived from BluePilot v0.2.1. It retains the complete MIT copyright and permission notice in `opendbc_repo/opendbc/bluepilot_lateral/LICENSE`, including Comma.ai, BluePilot and StarPilot contributors. See the contributor attribution in `CREDITS.md`.
+1 -1
View File
@@ -10,4 +10,4 @@ jetlink: run openpilot's large driving models on an attached Jetson.
# so does the release check (macos/scripts/check-version.sh). The comma runs
# jetlink from a checkout, not an install, so this is also all it has. The Swift
# reads it from Pinned.swift: run JetlinkKit/Scripts/make_pins.py after a bump.
__version__ = '0.8.9'
__version__ = '0.8.12'
+20 -6
View File
@@ -224,12 +224,16 @@ class JetlinkClient:
# -- handshake ------------------------------------------------------------
def hello(self, timeout: float = 5.0) -> dict:
def hello(self, timeout: float = 5.0, pick: dict | None = None) -> dict:
"""Introduce this client. The server starts its session over on a hello,
so this is also how a new owner of the gadget takes over one the server
never saw end; see Session.greet in JetlinkServer."""
never saw end; see Session.greet in JetlinkServer. `pick` is the comma's
own big-model pick and its age, {ref, sha256, nbytes, name, age_s}; the
answer's `pick` is the newer of it and the server's (PickStore)."""
seq = self._next_seq()
client = {'nonce': self.nonce, 'name': self.name}
if pick is not None:
client['pick'] = pick
try:
link = self.t.link_info()
except Exception:
@@ -301,7 +305,8 @@ class JetlinkClient:
frame_skip: int = DEFAULT_FRAME_SKIP,
progress: ProgressFn | None = None,
build_timeout: float = 900.0,
should_stop: StopFn | None = None) -> ModelSpec:
should_stop: StopFn | None = None,
fetch: bool = False, ref: str | None = None) -> ModelSpec:
"""Make the server ready to run this model, uploading and building if needed.
Blocks until ready or the build fails, and returns the spec the server
@@ -309,8 +314,12 @@ class JetlinkClient:
parser rejects an export (tinygrad and the org.tinygrad domain) still runs.
`onnx_path` None means the caller cannot upload (modeld) and a missing
engine is EngineMissing. `progress(stage, frac, msg)` has stage in
upload/patch/parse/build/load. `should_stop` is polled during long waits.
engine is EngineMissing. `fetch` asks a server that lacks the model to
download it itself (state downloading, stage download) rather than answer
need_upload; one that cannot, or is too old to, answers need_upload as
before. `ref` is the catalog ref the model is known by.
`progress(stage, frac, msg)` has stage in
download/upload/patch/parse/build/load. `should_stop` is polled during long waits.
"""
self.progress_cb = progress
self._should_stop = should_stop or (lambda: False)
@@ -318,7 +327,12 @@ class JetlinkClient:
self._engine_state = None
seq = self._next_seq()
self.t.send_json(P.Msg.ENGINE_REQ, seq, {'sha256': sha256, 'nbytes': nbytes, 'frame_skip': frame_skip})
request = {'sha256': sha256, 'nbytes': nbytes, 'frame_skip': frame_skip}
if fetch:
request['fetch'] = True
if ref:
request['ref'] = ref
self.t.send_json(P.Msg.ENGINE_REQ, seq, request)
resp = json.loads(bytes(self._expect(P.Msg.ENGINE_RESP, seq, 60.0).payload))
self._engine_state = resp
log.info("server engine state: %s (%s)", resp['state'], resp.get('detail', ''))
+1 -1
View File
@@ -55,7 +55,7 @@ POLL = 0.5
# what a borrower passes on of the server's hello (Loan.note_server): the owner
# never speaks the protocol, and sleep_after is how it knows whether letting
# go of the gadget lets the Jetson sleep. The rest is for its status record
SERVER_FIELDS = ('protocol', 'device', 'backend', 'runtime_version', 'trt_version', 'sleep_after')
SERVER_FIELDS = ('protocol', 'device', 'backend', 'runtime_version', 'trt_version', 'sleep_after', 'pick', 'fetches')
def _send(conn: socket.socket, msg: dict) -> None:
+10 -2
View File
@@ -45,15 +45,23 @@ class Keys:
offroad: str # BOOL: is the car parked (manager's IsOffroad)
progress: str # JSON {stage, frac, msg, drops}: provisioning and join progress
spec: str # JSON: the built model's spec and whether its engine is built
pointers: str # JSON: catalog ref -> {oid, size}
pointers: str # JSON: catalog ref, or a hash-named pick's sha256 -> {oid, size}
# sunnypilot's model manager: the big-model pick, JSON {ref, displayName},
# and the big-model catalog, JSON {bundles}. Without one both are None, and
# jetlink runs its own default big model
# jetlink runs its own default big model, or the one picked on the server.
# A fork with a picker of its own and no catalog (StarPilot) declares only
# big_model, and may name its pick by the ONNX's hash, {sha256,
# displayName}; a newer pick made on the server is written into it
big_model: str | None = None
catalog: str | None = None
# BOOL: an iPhone on a direct cable is asked to charge from the comma
# (comma.port). Off when unset, and without the key
charge_phone: str | None = None
# STRING: where the model manager takes a pick by its ref and stores it in
# big_model (sunnypilot's ModelManager_DownloadRef). A newer pick made on
# the server is written there, so the comma's own list shows it; without
# the key it drives all the same
select: str | None = None
@dataclass(frozen=True)
+25 -11
View File
@@ -43,6 +43,8 @@ PRESENT_TIMEOUT = 5.0
# hardware; the ceiling is only there so a server that has stopped answering
# does not hold the caller for the rest of the day.
BUILD_TIMEOUT = 1800.0
# and on top of that, a server downloading the model itself: 1.75 GB at 1 MB/s
FETCH_TIMEOUT = 1800.0
class ModelMissing(RuntimeError):
@@ -226,12 +228,15 @@ def identity(parts, entry: dict) -> tuple[str, int]:
"""The picked model's sha256 and byte count, from the catalog model's LFS
pointer, so the comma can name the model without holding or hashing the
ONNX. Looked up once per model ever and kept in a param: the one part of
provisioning that needs the internet."""
provisioning that needs the internet. A model picked by its hash alone is
only sized."""
sha256, nbytes = entry.get('oid'), entry.get('size')
if sha256 and nbytes:
return sha256, int(nbytes)
parts.progress.report('connect', 0.0, 'finding model', code='finding_model')
return parts.models.resolve_pointer(entry['ref'])
if entry.get('ref'):
return parts.models.resolve_pointer(entry['ref'])
return parts.models.resolve_size(sha256)
# What has already been hashed in this process, keyed on the file as it was
@@ -278,21 +283,27 @@ def verified_upload(log, model_path: Path | None, sha256: str, nbytes: int) -> P
def ensure(parts, client, sha256: str, nbytes: int, model_path: Path | None, *,
progress=None, should_stop=None, build_timeout: float = BUILD_TIMEOUT):
progress=None, should_stop=None, build_timeout: float = BUILD_TIMEOUT, ref: str | None = None,
fetch: bool = True):
"""Make the server ready for this model and remember what it answered.
Asks without the file first: the server answers from the sha alone when it
has the model, which is every parked poll and every join. EngineMissing means
the Jetson has neither the plan nor the bytes, and neither has the caller."""
has the model, which is every parked poll and every join. Without the file
here either, the server is asked to download the model itself, which only
the Jetson needs. EngineMissing means the Jetson has neither the plan nor
the bytes and could not get them, and neither has the caller. `fetch` False
never asks for the download: a stand-in is a model the server has. `ref`,
the catalog's name for the model, goes with a download."""
from jetlink.client import EngineMissing
ask = functools.partial(client.ensure_engine, sha256, nbytes, progress=progress,
build_timeout=build_timeout, should_stop=should_stop)
fetch = fetch and model_path is None
ask = functools.partial(client.ensure_engine, sha256, nbytes, progress=progress, should_stop=should_stop)
try:
spec = ask(onnx_path=None)
spec = ask(onnx_path=None, fetch=fetch, ref=ref if fetch else None,
build_timeout=build_timeout + (FETCH_TIMEOUT if fetch else 0.0))
except EngineMissing:
upload = verified_upload(parts.log, model_path, sha256, nbytes)
if upload is None:
raise
spec = ask(onnx_path=upload)
spec = ask(onnx_path=upload, build_timeout=build_timeout)
parts.spec.store(spec)
return spec
@@ -346,11 +357,14 @@ def open_link(parts, link: Link, should_stop=None):
# booting; both resolve on their own
client = connect_patiently(link)
try:
hello = client.hello(timeout=10.0)
hello = parts.models.heard(client.hello(timeout=10.0, pick=parts.models.hello_pick()))
parts.log.warning("jetlink: %s trt %s, engine %s, loaded %s",
hello.get('device'), hello.get('trt_version'),
hello.get('engine_state'), str(hello.get('loaded'))[:16])
link.note_server(hello)
# a newer pick on the server drives, and is ours from now on
parts.models.adopt()
selected = parts.models.selected_model() or selected
sha256, nbytes = identity(parts, selected)
path = parts.models.shipped_model_path()
built = parts.spec.ready_spec()
@@ -370,7 +384,7 @@ def open_link(parts, link: Link, should_stop=None):
# server that restarted reloads from the plan cache, 13 to 25 s; one
# that has never seen this model builds it, 102 to 294 s
spec = ensure(parts, client, sha256, nbytes, path, progress=parts.progress.report_with_eta,
should_stop=should_stop)
should_stop=should_stop, ref=selected.get('ref'), fetch=standin is None)
client.deadline = INFERENCE_TIMEOUT
return client, spec
except EngineMissing as e:
+196 -28
View File
@@ -16,16 +16,25 @@ export, and jetlink's registry follows that to comma's model repo; see
jetlink.registry.lfs.
Without a model manager (Keys.big_model and Keys.catalog None) there is no
pick and no catalog, and the large model is jetlink's own default, named by its
ref: its pointer resolves from the ref as any pick's does.
pick and no catalog, and the large model is the one picked on the server, else
jetlink's own default, named by its ref: its pointer resolves from the ref as
any pick's does.
A fork with a picker of its own but no catalog jetlink reads (Keys.big_model
without Keys.catalog, StarPilot's) may name its pick by the ONNX's sha256
instead of a ref, which is all its catalog knows of a model. Its size is looked
up from comma's LFS servers once (resolve_size). With no catalog no model
manager owns the slot either, so jetlink writes a newer pick from the server
into it itself, and the fork's picker shows what drives.
"""
from __future__ import annotations
import os
import re
import time
from pathlib import Path
from jetlink.registry.catalog import DEFAULT_BIG_MODEL_NAME, DEFAULT_BIG_MODEL_REF
from jetlink.registry.catalog import DEFAULT_BIG_MODEL_NAME, DEFAULT_BIG_MODEL_REF, is_ref, is_sha256
POINTER_TIMEOUT = 10.0
_REF = re.compile(r'[0-9a-f]{40}')
@@ -39,13 +48,32 @@ INDEX_TTL = 2.0
# on from the .part the last left, so a stall costs the wait, not the bytes
# (lfs.lfs_download)
DOWNLOAD_RETRY_DELAYS = (5.0, 15.0, 30.0, 60.0, 120.0)
# server_pick() before this process has heard a hello
_UNHEARD = object()
def _dated(pick) -> dict | None:
"""A server's pick as its hello carries it, dated by this clock:
{ref, sha256, nbytes, name, at}. None for none, or one naming no model."""
if not isinstance(pick, dict) or not isinstance(pick.get('age_s'), (int, float)):
return None
ref = pick['ref'] if is_ref(pick.get('ref')) else None
sha256 = pick['sha256'] if is_sha256(pick.get('sha256')) else None
if ref is None and sha256 is None:
return None
return {'ref': ref, 'sha256': sha256, 'nbytes': pick.get('nbytes'), 'name': pick.get('name'),
'at': time.time() - float(pick['age_s'])}
class Models:
def __init__(self, op):
self.op = op
self._index_cache: tuple[float, list[dict], dict[str, dict]] | None = None
self._slot_cache: tuple[float, dict | None] | None = None
self._slot_cache: tuple[float, dict | None, float | None] | None = None
# the pick of the last hello this process heard, and the owner's record
# of the last hello any process heard
self._heard: dict | None | object = _UNHEARD
self._record_cache: tuple[float, dict | None] | None = None
def catalog(self) -> list[dict]:
"""sunnypilot's big-model bundles as {name, ref}, newest first, as the model
@@ -77,13 +105,28 @@ class Models:
def resolve_pointer(self, ref: str) -> tuple[str, int]:
"""The oid and size behind a catalog model, fetched the first time and kept for good."""
return self._resolve(ref, self.fetch_pointer)
def fetch_size(self, sha256: str) -> tuple[str, int]:
"""The ONNX with this hash and its byte count, from the LFS servers a
download asks."""
from jetlink.registry.lfs import fetch_size as registry_fetch_size
pointer = registry_fetch_size(sha256, self.lfs_endpoints(), timeout=POINTER_TIMEOUT)
return pointer.oid, pointer.size
def resolve_size(self, sha256: str) -> tuple[str, int]:
"""A model picked by its hash, sized the first time and kept for good with
the catalog's pointers, under the hash."""
return self._resolve(sha256, self.fetch_size)
def _resolve(self, key: str, fetch) -> tuple[str, int]:
known = self.pointers()
if ref in known:
return known[ref]['oid'], int(known[ref]['size'])
oid, size = self.fetch_pointer(ref)
known[ref] = {'oid': oid, 'size': size}
if key in known:
return known[key]['oid'], int(known[key]['size'])
oid, size = fetch(key)
known[key] = {'oid': oid, 'size': size}
self._keep_pointers(known)
self.op.log.warning("jetlink: %s is %s, %d MB", ref[:10], oid[:16], size >> 20)
self.op.log.warning("jetlink: %s is %s, %d MB", key[:10], oid[:16], size >> 20)
return oid, size
def _keep_pointers(self, known: dict[str, dict]) -> None:
@@ -92,9 +135,11 @@ class Models:
self._index_cache = None
@staticmethod
def _row(name: str, ref: str, known: dict[str, dict]) -> dict:
p = known.get(ref) or {}
return {'name': name, 'ref': ref, 'oid': p.get('oid'), 'size': int(p['size']) if p.get('size') else None}
def _row(name: str, ref: str | None, known: dict[str, dict], sha256: str | None = None) -> dict:
"""A model as a catalog row, by its ref's pointer, else by its hash, sized
once resolve_size has looked it up."""
p = known.get(ref) or known.get(sha256) or {}
return {'name': name, 'ref': ref, 'oid': p.get('oid') or sha256, 'size': int(p['size']) if p.get('size') else None}
def _index(self) -> tuple[list[dict], dict[str, dict]]:
"""The catalog's rows and the pointers behind them, read together once per INDEX_TTL."""
@@ -110,18 +155,27 @@ class Models:
return self._index()[0]
def selected_slot(self) -> dict | None:
"""The big-model slot's pick as {name, ref}, from the raw param rather than a
parsed bundle, and memoised: the UI asks every frame."""
"""The big-model slot's pick as {name, ref, sha256}, from the raw param
rather than a parsed bundle, and memoised with when it was made
(picked_at): the UI asks every frame. It names its model by the catalog's
ref, by the ONNX's sha256 (a fork whose catalog knows only that), or by
both; the one it lacks is None."""
now = time.monotonic()
if self._slot_cache is not None and now - self._slot_cache[0] < INDEX_TTL:
return self._slot_cache[1]
key = self.op.keys.big_model
slot = self.op.get(key) if key else None
ref = slot.get('ref') if isinstance(slot, dict) else None
pick = None
if isinstance(ref, str) and ref:
pick = {'name': str(slot.get('displayName') or ref[:10]), 'ref': ref}
self._slot_cache = (now, pick)
slot = slot if isinstance(slot, dict) else {}
ref = slot['ref'] if isinstance(slot.get('ref'), str) and slot['ref'] else None
sha256 = slot['sha256'] if is_sha256(slot.get('sha256')) else None
pick, at = None, None
if ref or sha256:
pick = {'name': str(slot.get('displayName') or (ref or sha256)[:10]), 'ref': ref, 'sha256': sha256}
try:
at = os.stat(Path(self.op.params_dir()) / key).st_mtime
except OSError:
pass
self._slot_cache = (now, pick, at)
return pick
def default_model(self) -> dict | None:
@@ -142,12 +196,129 @@ class Models:
return _TRAILING_DATE.sub('', model['name']) if model else None
def selected_model(self) -> dict | None:
"""The slot's pick, listed in the catalog or not: a ref is enough to find its
pointer. With no pick, default_model()."""
if (pick := self.selected_slot()) is not None:
return self._row(pick['name'], pick['ref'], self._index()[1])
"""The model picked last, here or on the server: the slot's pick, listed
in the catalog or not (a ref is enough to find its pointer), unless the
server's last hello named a newer one. With neither, default_model()."""
if (theirs := self._newer_server_pick()) is not None:
return self._server_row(theirs)
if (own := self.selected_slot()) is not None:
return self._own_row(own)
return self.default_model()
def _own_row(self, own: dict) -> dict:
return self._row(own['name'], own['ref'], self._index()[1], own['sha256'])
# -- picks on both ends -----------------------------------------------------
# A pick is made on either end: in the comma's list, or on the server (its
# web page, an app's Use Model). Each hello carries the comma's own pick and
# how long ago it was made, and the server answers with the newer of that
# and its own (PickStore in JetlinkServer). The answer is kept with the rest
# of the hello (gadget.SERVER, through the owner), dated by this clock, and
# selected_model() takes whichever pick is newer; so a model picked on the
# server drives without being picked here too, and one picked here goes to
# the server at the next hello.
def picked_at(self) -> float | None:
"""When the slot was last written, by this clock: when the pick was made.
None with no pick of our own, or no date for it."""
return self._slot_cache[2] if self.selected_slot() is not None and self._slot_cache else None
def hello_pick(self) -> dict | None:
"""Our own pick, for the hello: {ref, sha256, nbytes, name, age_s}, the
pointer where it is resolved already (no network here). None with nothing
picked here, or no date for it: the server's pick, if any, stands."""
own, at = self.selected_slot(), self.picked_at()
if own is None or at is None:
return None
row = self._own_row(own)
return {'ref': row['ref'], 'sha256': row['oid'], 'nbytes': row['size'], 'name': row['name'],
'age_s': round(max(0.0, time.time() - at), 1)}
def heard(self, hello: dict) -> dict:
"""The server's hello with its `pick` dated by this clock (_dated), which
is how the owner keeps it (lending.SERVER_FIELDS) and server_pick() reads
it. This process goes by it at once, whatever the owner does with it."""
self._heard = _dated(hello.get('pick'))
return {**hello, 'pick': self._heard} if self._heard is not None else hello
def server_pick(self) -> dict | None:
"""The pick the server last answered a hello with, {ref, sha256, nbytes,
name, at}, or None: a server too old to say, or one with no pick. What
this process heard last, else the owner's record of the last hello: the
UI says no hello, and reads it every frame."""
if self._heard is not _UNHEARD:
return self._heard
now = time.monotonic()
if self._record_cache is None or now - self._record_cache[0] >= INDEX_TTL:
from jetlink.comma import gadget
pick = (gadget.read_record(gadget.SERVER) or {}).get('pick')
self._record_cache = (now, pick if isinstance(pick, dict) and isinstance(pick.get('at'), (int, float)) else None)
return self._record_cache[1]
def _newer_server_pick(self) -> dict | None:
"""The server's last pick, when it is another model than ours and was
picked after it, or we have none."""
theirs, own = self.server_pick(), self.selected_slot()
if theirs is None or (own is not None and (self._same(theirs, self._own_row(own)) or
theirs['at'] <= (self.picked_at() or 0.0))):
return None
return theirs
@staticmethod
def _same(pick: dict, row: dict) -> bool:
"""One model: by the catalog ref where both have one, since two refs can
share one export, else by the hash. The server's PickStore.sameModel."""
if pick['ref'] and row['ref']:
return pick['ref'] == row['ref']
return bool(pick['sha256']) and pick['sha256'] == row['oid']
def _server_row(self, pick: dict) -> dict:
"""The server's pick as a catalog row: ours where we list it, with the
server's oid and size, which name the bytes it has."""
listed = next((m for m in self.model_index() if pick['ref'] and m['ref'] == pick['ref']), None)
row = self._row(listed['name'] if listed else pick.get('name') or (pick['ref'] or pick['sha256'])[:16],
pick['ref'], self._index()[1], pick['sha256'])
if pick['sha256'] and pick.get('nbytes'):
row['oid'], row['size'] = pick['sha256'], int(pick['nbytes'])
return row
def adopt(self) -> None:
"""Keep the server's pointer for its pick, by ref and by hash, so naming
the model here needs no lookup; and make a newer pick from the server
ours. With a catalog, ask the model manager for it (Keys.select), so the
comma's own list shows what drives: only a model the catalog lists, which
is all the model manager takes; any other drives all the same
(selected_model). Without one, no model manager owns the slot, so the
pick is written into it, whatever the model. Never raises."""
try:
if (theirs := self.server_pick()) is None:
return
known = self.pointers()
if theirs['sha256'] and theirs.get('nbytes'):
pointer = {'oid': theirs['sha256'], 'size': int(theirs['nbytes'])}
missing = {k: pointer for k in (theirs['ref'], theirs['sha256']) if k and k not in known}
if missing:
self._keep_pointers({**known, **missing})
if self._newer_server_pick() is None:
return
model, keys = self._server_row(theirs), self.op.keys
if keys.catalog is None:
if keys.big_model:
self.op.log.warning("jetlink: %s was picked on the server, picking it here too", model['name'])
slot = {k: v for k, v in (('displayName', model['name']), ('ref', model['ref']), ('sha256', model['oid'])) if v}
self.op.put(keys.big_model, slot, block=True)
self._slot_cache = None
return
# a download the user asked for that the model manager has not taken
# yet is left alone: the next hello asks again
key = keys.select
if key and model['ref'] and self.op.get(key) is None and any(m['ref'] == model['ref'] for m in self.model_index()):
self.op.log.warning("jetlink: %s was picked on the server, picking it here too", model['name'])
self.op.put(key, model['ref'])
self._slot_cache = None
except Exception:
self.op.log.exception("jetlink: could not take the server's pick")
def selected_model_name(self) -> str | None:
selected = self.selected_model()
return selected['name'] if selected else None
@@ -227,11 +398,8 @@ class Models:
def _fetch_once(self, pointer, dest: Path, progress, should_stop) -> Path:
from jetlink.registry.catalog import NetworkError
from jetlink.registry.lfs import lfs_download, lfs_resolve
for endpoint in self.lfs_endpoints():
href = lfs_resolve(endpoint, pointer)
if href is None:
continue
from jetlink.registry.lfs import lfs_download, lfs_hrefs
for endpoint, href in lfs_hrefs(self.lfs_endpoints(), pointer):
self.op.log.warning("jetlink: fetching the large model (%d MB) from %s", pointer.size >> 20, endpoint)
return lfs_download(href, pointer, dest, progress=progress, should_stop=should_stop)
raise NetworkError(f"no LFS server has {pointer.oid[:16]}")
+53 -32
View File
@@ -126,10 +126,8 @@ class ProvisioningRun:
return path
def needs_download(self) -> bool:
"""Does the comma have to fetch the picked model before a Jetson can build
it? Only while its engine is not known to be built and the file is not here.
Asked without the Jetson: a download needs only the internet, and a car
whose Jetson goes off with the ignition has no Jetson while parked."""
"""Could the Jetson need the picked model from the comma? Only while its
engine is not known to be built and the file is not here."""
parts = self.parts
entry = parts.models.selected_model()
if entry is None:
@@ -140,12 +138,17 @@ class ProvisioningRun:
return parts.models.shipped_model_path() is None
def provision(self) -> bool:
"""Make the Jetson ready for the selected model; a host is attached. run()
has fetched the file already unless the spec record said the model was
built; a server that lost it since asks for the bytes, and they are
fetched then."""
"""Make the Jetson ready for the picked model; a host is attached. The
hello settles which pick that is, the comma's or a newer one made on the
server. A server without the model downloads it itself; only one that
cannot (no internet, or too old) gets the bytes from the comma, fetched
here then."""
from jetlink.client import EngineMissing
parts = self.parts
parts.progress.report('connect', 0.0, 'connecting', code='connecting')
hello = self.hello()
self.log.warning("jetlink: server %s trt %s", hello.get('device'), hello.get('trt_version'))
parts.models.adopt()
entry = parts.models.selected_model()
if entry is None:
# no catalog yet; not an error
@@ -154,27 +157,24 @@ class ProvisioningRun:
return False
sha256, nbytes = link.identity(parts, entry)
# only needed if the server turns out not to have this model; None is a
# legitimate state here, see EngineMissing below
# only needed if the server turns out not to have this model and cannot
# get it; None is a legitimate state here, see EngineMissing below
model_path = parts.models.shipped_model_path()
self.log.warning("jetlink: provisioning %s (%d MB, sha %s)",
entry.get('name', sha256[:16]), nbytes >> 20, sha256[:16])
parts.progress.report('connect', 0.0, 'connecting', code='connecting')
def ensure(path):
return link.ensure(parts, self.client, sha256, nbytes, path, progress=parts.progress.report_with_eta,
should_stop=lambda: self.stop)
should_stop=lambda: self.stop, ref=entry.get('ref'))
hello = self.hello()
self.log.warning("jetlink: server %s trt %s", hello.get('device'), hello.get('trt_version'))
try:
spec = ensure(model_path)
except EngineMissing:
# the server has nothing to build from, and neither have we: fetch the
# model and hand it over in this run. The owner lends the link for the
# whole run; leaving after the download would put the build a
# WORKER_BACKOFF later
# the server has nothing to build from and could not download it, and
# neither have we: fetch the model and hand it over in this run. The
# owner lends the link for the whole run; leaving after the download
# would put the build a WORKER_BACKOFF later
if model_path is not None or (model_path := self.fetch_model()) is None:
raise
# a download of minutes can outlast the session; a hello starts a new one
@@ -186,16 +186,35 @@ class ProvisioningRun:
return True
def hello(self) -> dict:
"""Say hello, and pass the answer on to the owner, which needs its
sleep_after to decide whether letting go of the gadget is worth what it
costs and never speaks the protocol to learn it."""
hello = self.client.hello(timeout=10.0)
"""Say hello with our pick, and pass the answer on to the owner, which
needs its sleep_after to decide whether letting go of the gadget is worth
what it costs, never speaks the protocol to learn it, and keeps the
server's pick and whether it downloads for the comma (Models.heard)."""
models = self.parts.models
hello = models.heard(self.client.hello(timeout=10.0, pick=models.hello_pick()))
if self.loan is not None:
self.loan.note_server(hello)
return hello
# -- the parked car -------------------------------------------------------
def without_jetson(self) -> None:
"""No Jetson came to the bind: a car whose Jetson is switched with the
ignition has none while parked. Only the Jetson needs the model, and one
that can download it gets it itself when it is on; the comma fetches it
now only for a Jetson that cannot (no internet when it was last here, or
too old to say), so the build can start the moment it is."""
if not self.has_work():
return
if self.needs_download():
if server_fetches():
self.parts.progress.report('waiting', 0.0, 'the jetson downloads it when it is on', code='waiting_for_host')
return
if self.fetch_model() is None:
return
self.parts.progress.report('waiting', 0.0, 'downloaded, builds when the jetson is on',
code='downloaded_waiting_host')
def has_work(self) -> bool:
"""Is there a reason to wake the Jetson? Only things the link can fix count."""
spec = self.parts.spec.load()
@@ -259,25 +278,21 @@ class ProvisioningRun:
self.log.warning("jetlink: no warp built for this camera, nothing to provision for")
return True
if not self.has_work():
# Nothing to build, unless a model was picked on the server since: a
# Jetson awake on the bus is asked, which costs a hello and no wake. Not
# a phone's cable, which is there only once the app dials in
if not self.has_work() and not (gadget.host_attached() and gadget.link_kind(self.parts.settings.mode()) == 'usb'):
self.log.warning("jetlink: nothing to provision")
return True
finished = False
try:
# the bytes first while no host is on the cable: a parked car whose
# Jetson is switched with the ignition never has one to wait for. A host
# that is there is asked first, since one with the model built needs
# nothing from the comma; provision() fetches it if the host has not
if self.needs_download() and not gadget.host_attached() and self.fetch_model() is None:
return False
if not self.open_link():
return False
if not self.wait_for_jetson():
self.log.warning("jetlink: no jetson within %.0f s, leaving it for the next run", WAKE_TIMEOUT)
if self.parts.models.shipped_model_path() is not None:
self.parts.progress.report('waiting', 0.0, 'downloaded, builds when the jetson is on',
code='downloaded_waiting_host')
self.close_link()
self.without_jetson()
return False
finished = self.provision()
except Exception:
@@ -288,6 +303,12 @@ class ProvisioningRun:
return finished
def server_fetches() -> bool:
"""Did the server the comma last spoke to say it downloads a model itself
(its hello's `fetches`, as the owner keeps it)? No for one too old to say."""
return (gadget.read_record(gadget.SERVER) or {}).get('fetches') is True
def main(argv: list[str] | None = None) -> None:
"""One provisioning round over the fork's adapter, as the owner starts it.
Exits 0 when nothing is left to do, and 1 when the owner should try again."""
+11 -5
View File
@@ -31,14 +31,17 @@ PROGRESS_MIN_INTERVAL = 0.25
# what a progress record's `code` may be, for a fork that words the line
# itself; `msg` stays the English one. `args`, where a code has them:
# downloading and upload {got_mb, total_mb}, download_stalled {retry_s},
# reconnecting {why: lost, behind, load_failed or bad_output}, build {eta_s}.
# From upload on they are stage names, and the server's stages are its own:
# downloading, download and upload {got_mb, total_mb}, download_stalled
# {retry_s}, reconnecting {why: lost, behind, load_failed or bad_output},
# build {eta_s}. downloading is the comma fetching the model, download the
# server fetching it itself. From download on they are stage names, and the
# server's stages are its own:
# a server can add one, so a fork falls back to `msg` for a code it does not
# know. A server message with no stage has no code
PROGRESS_CODES = ('waiting_for_host', 'finding_model', 'model_missing', 'ready_waiting_window', 'reconnecting',
'connecting', 'downloading', 'download_stalled', 'download_failed', 'downloaded_waiting_host',
'engine_ready', 'failed', 'upload', 'patch', 'parse', 'convert', 'compile', 'build', 'load', 'warm')
'engine_ready', 'failed', 'download', 'upload', 'patch', 'parse', 'convert', 'compile', 'build',
'load', 'warm')
def estimated_build_seconds(size: int) -> int:
@@ -104,12 +107,15 @@ class Progress:
size. Only the build: the upload reports MB of MB and a connect has nothing
to predict."""
args = None
size = self._size() if self._size is not None and stage in ('build', 'upload') else None
size = self._size() if self._size is not None and stage in ('build', 'upload', 'download') else None
if size and stage == 'build':
left = estimated_build_seconds(size) * max(0.0, 1.0 - frac)
msg, args = _eta(left), {'eta_s': round(left)}
elif size:
args = {'got_mb': int(frac * size) >> 20, 'total_mb': size >> 20}
if stage == 'download' and args:
# the server downloading the model itself: not the comma's own 'downloading'
msg = f"jetson downloading {args['got_mb']} of {args['total_mb']} MB"
self.report(stage, frac, msg, code=stage or None, args=args)
+30
View File
@@ -294,6 +294,36 @@ def lfs_resolve(endpoint: str, pointer: Pointer, timeout: float = CONNECT_TIMEOU
return None
def lfs_hrefs(endpoints, pointer: Pointer, timeout: float = CONNECT_TIMEOUT, opener=None):
"""Each of `endpoints` that has the object, in order, with its download href."""
for endpoint in endpoints:
href = lfs_resolve(endpoint, pointer, timeout=timeout, opener=opener)
if href is not None:
yield endpoint, href
def fetch_size(oid: str, endpoints=LFS_ENDPOINTS, timeout: float = POINTER_TIMEOUT, opener=None) -> Pointer:
"""The pointer for an ONNX known by its oid alone, as a fork whose catalog
names a model by its hash knows it (StarPilot's). comma's LFS servers hand
out a download address for any oid they hold, whatever size the batch asks
for, and the first byte of that download says how many there are. No
server with it is NotFound; one that would not say, a NetworkError."""
opener = opener or urllib.request.urlopen
for endpoint, href in lfs_hrefs(endpoints, Pointer(oid, 1), timeout=timeout, opener=opener):
request = urllib.request.Request(href, headers={'Range': 'bytes=0-0'})
try:
with opener(request, timeout=timeout) as response:
# a server that ignores the range answers 200 with the whole length
total = (response.headers.get('Content-Range') or '').rpartition('/')[2] \
if response.status == 206 else response.headers.get('Content-Length')
except (OSError, ValueError) as e:
raise NetworkError(f"could not size {oid[:16]} at {endpoint}: {e}") from e
if not str(total).isdigit() or int(total) <= 0:
raise NetworkError(f"{endpoint} did not say how big {oid[:16]} is")
return Pointer(oid, int(total))
raise NotFound(f"no LFS server has {oid[:16]}")
def lfs_download(href: str, pointer: Pointer, dest: Path, progress: ProgressFn | None = None,
should_stop: StopFn | None = None, opener=None) -> Path:
"""Stream to a .part file, hashing as we go, and only then take the name.
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${ROOT_DIR}/scripts/host_tool_runner.sh" jotpluggler "$@"
+5
View File
@@ -291,6 +291,11 @@ struct MapdOut @0xa4f1eb3323f5f582 {
computedMonoTime @31 :UInt64; # Computation boot-time nanoseconds, not send time.
sourceGeneration @32 :UInt64;
producerSession @33 :UInt64; # Random nonzero process session.
# Data-only preview from the same qualified GPS fix; no controller smoothing.
previewSpeedLimit @34 :Float32;
previewDistance @35 :Float32;
previewBoundaryId @36 :Text;
previewWayId @37 :Int64;
enum SampleStatus {
unknown @0;
+357 -309
View File
@@ -2887,12 +2887,12 @@ type MapdOut capnp.Struct
const MapdOut_TypeID = 0xa4f1eb3323f5f582
func NewMapdOut(s *capnp.Segment) (MapdOut, error) {
st, err := capnp.NewStruct(s, capnp.ObjectSize{DataSize: 112, PointerCount: 6})
st, err := capnp.NewStruct(s, capnp.ObjectSize{DataSize: 128, PointerCount: 7})
return MapdOut(st), err
}
func NewRootMapdOut(s *capnp.Segment) (MapdOut, error) {
st, err := capnp.NewRootStruct(s, capnp.ObjectSize{DataSize: 112, PointerCount: 6})
st, err := capnp.NewRootStruct(s, capnp.ObjectSize{DataSize: 128, PointerCount: 7})
return MapdOut(st), err
}
@@ -3260,12 +3260,54 @@ func (s MapdOut) SetProducerSession(v uint64) {
capnp.Struct(s).SetUint64(104, v)
}
func (s MapdOut) PreviewSpeedLimit() float32 {
return math.Float32frombits(capnp.Struct(s).Uint32(76))
}
func (s MapdOut) SetPreviewSpeedLimit(v float32) {
capnp.Struct(s).SetUint32(76, math.Float32bits(v))
}
func (s MapdOut) PreviewDistance() float32 {
return math.Float32frombits(capnp.Struct(s).Uint32(112))
}
func (s MapdOut) SetPreviewDistance(v float32) {
capnp.Struct(s).SetUint32(112, math.Float32bits(v))
}
func (s MapdOut) PreviewBoundaryId() (string, error) {
p, err := capnp.Struct(s).Ptr(6)
return p.Text(), err
}
func (s MapdOut) HasPreviewBoundaryId() bool {
return capnp.Struct(s).HasPtr(6)
}
func (s MapdOut) PreviewBoundaryIdBytes() ([]byte, error) {
p, err := capnp.Struct(s).Ptr(6)
return p.TextBytes(), err
}
func (s MapdOut) SetPreviewBoundaryId(v string) error {
return capnp.Struct(s).SetText(6, v)
}
func (s MapdOut) PreviewWayId() int64 {
return int64(capnp.Struct(s).Uint64(120))
}
func (s MapdOut) SetPreviewWayId(v int64) {
capnp.Struct(s).SetUint64(120, uint64(v))
}
// MapdOut_List is a list of MapdOut.
type MapdOut_List = capnp.StructList[MapdOut]
// NewMapdOut creates a new list of MapdOut.
func NewMapdOut_List(s *capnp.Segment, sz int32) (MapdOut_List, error) {
l, err := capnp.NewCompositeList(s, capnp.ObjectSize{DataSize: 112, PointerCount: 6}, sz)
l, err := capnp.NewCompositeList(s, capnp.ObjectSize{DataSize: 128, PointerCount: 7}, sz)
return capnp.StructList[MapdOut](l), err
}
@@ -3395,312 +3437,318 @@ func NewMapdOut_GpsSource_List(s *capnp.Segment, sz int32) (MapdOut_GpsSource_Li
return capnp.NewEnumList[MapdOut_GpsSource](s, sz)
}
const schema_b526ba661d550a59 = "x\xda\x94Z}lT\xd7\x95?\xe7\xbd\xb1\xc7\x9f\x8c" +
"\x877\x04\x0c&\x13(\xd0\x98\xc6i\x8c\xf16\xa6I" +
"]c\x03\xc1\x1a\x83\xc7\xc3G@A\xea\xf5\xbc\xeb\x99" +
"\x07\xcf\xefM\xde{c{\x10\x11\x84\x82\x04YP\x93" +
"n\xd2\x92*lC\x02R\xda\x0d-\xed\x86\x15\x1bQ" +
"-AD\"Y\x90\x92\xaa\xa9\x9a4\xbbI\xa3JI" +
"\xba\x89\xd2v\x13m\xb6\xdb\xe8\xad\xce\xbd\xf3\xf1\xfc\xd1" +
"\x10\xff\xc3\xf8\xfd\xcey\xe7\x9e{\xee\xb9\xe7\x9e\xdf}" +
"\xdc\xf1\xf5\xfao\x86\xda\x1b3\x0d\xa0$\x1f\xae\xaa\xf6" +
"\xa3{v\xbccy?9\x00\xd1E\xe8\xef\xa8\xdb\xba" +
"x\xe4\xf9\x15\xe7!\x14\x06\xe8h\xae\xde\x89Z[u" +
"\x18T\xff\xa7\x1f'\xbav\xfe\xf1\xf2\x83\x93\xb5\xaa\x84" +
"Zm\xf5^\xd4\x16W\x87\x01\xb4\xe6\xeaw\x01\xfd{" +
"{/\x7fgx\xc5\x0f\x0fCt\x91RQ\x06\xec\xc0" +
"p?j\xf3\xc2\xf4R4l\x87\x00\xfd\xb6\x97\xf2\xf7" +
"\xfe\xb8\xf9\xf8#\x90l\xc3Z\xff\xb2\xf5o\xf5\xdb\xae" +
"\xdc\xf7\xdfP\x85\xa4s\xbc\xfe!\xd4\xce\xd4\xd3\x9f\xa7" +
"\xea\xdfR\x00\xfd\x7f\xfc\xcaGyc\xef\xce\xefA\xf4" +
"K\x8a\x7f\xf0\x93O\xbe\xd4\xf1_\x7f:M\x96_\x9f" +
"s\x10\xb5\x0f\xe7\x90\x0f\xef\xcf\xf9>\xa0?\xf79\xcc" +
"d\xae^\x7fb\x86iuE\x86Q\x1b\x88\xd0\xb4\xda" +
"~\x1bW\xfb\xc2\xd9S3h\xb5Fv\xa2v\xb7\xd0" +
"2\xd5\x8eo~:\\\xfd4$\x17\xe1\xb4\xd97\x93" +
"^[D\xbe\xb2\x1d\x01+~%\x17\xe1\x9c\x80:E" +
"\xa8\xe3d\xd3B\xd4\xce6}\x19\xa0\xe3\xa5\xa6\xf10" +
"\xa0\xdf\xd7{\xe9\xe5\xb3o=}fZ\xb4\x92\xcd\x07" +
"Q\xe3\xcd4'\xd6\xfc5@\x7f\xeb\xe9\xe4\xeb\xb7\x8d" +
"]?3\x83\xb7\xbcy'j\x85f\xf2\xf6'\xbfM" +
"?\xf6\xf7\xcf|\xf4O\xd3\xecmm^S\xb1\xb7\x19" +
"\xd0\xdfu\xfe\xdd\xf6\xef|\xf6\xd6Of\xb0\x97'{" +
"G\x85\xbd\xee\xb7\xfe\xf0\xc0\xc0-\xdb\xcf\xcd8\xeap" +
"i\xd4\xab\x98Xb\xa4\xad\x0b3hm%[\x86\xd0" +
"\xca^\xfc\x87GS_\xef\xfb\xc5\x0cZ\xeb\xc8\xd6\x0e" +
"\xa1uH]\xfd6\xbfk\xd9\xe5\x19\xb4:Ik\xa3" +
"\xd0\xfa\xe8\x9f\xbf\xf1\xea\x9dw\xef\xbe:uU\x14\xd2" +
"[\xde<\x17\xb5N\x9akG{s\x9cVe\xcd\x8e" +
"\xed9\xf3\xb5\x1f\xfe\xfbLC/\xa4\xa1\x17\x92\xd1W" +
"\x0e>\x99\xf9\xbf7\xbew}\xa6\xa1Ik\xa3\xd0Z" +
"|%\xfd\xe6\x9b\xde\xc1W\xa7\x85x\xf9\xc2\xb5\xa8u" +
".\x14\xc3.\x14\xc9\xd0\xd5u\xf1\xd4\xb5\x87\xfe\xe7\xd7" +
"\xe4\xa5:\xc5\xe2\x92E\xfd\xa8u.\xa2\x05i_D" +
"\x1b\xa73\xd1\xf7\xed\x13\xdb\xbf\xf7\xc6\x0c\xa3\xcfk\xd9" +
"\x89Zk\x0b\x8d\xde\xf5\xed\x97\x7f\xf0\xfd\xaa\xef\xff'" +
"\x99T\xa6\xa8U\xb5\xacEm^\x0b\x99\x8c\xb6\x8c\x03" +
"\xfa\xb7\xbe\xfa\xeb\x8fn[\xf7\xdc\x9f\xa7\xed\x98|\xcb" +
"n\xd4\x8e\x0a\xcd\xc3-\x19\xc0\xca\xe6\x9blVn\xc5" +
"\x9f\x91\xf6\x8b-\xf3\x01:\xae\xb7\x84)m\x9bO\x9e" +
"\xdcX\xfb\xc1M\x1f\xcf\xe0\xebk\xcbv\xa2\xf6\xfe2" +
"\xf2\xf5\xe9P\xee\xb3\xaf\x1f:\xfe\xbf3h\xbdHZ" +
"\xaf\x0b\xad\x95\xef-\xdf\xf4\xee}\xdf\xf8\xcb\xb4x\x9e" +
"_6\x8c\xdaK\xcb\xc8\xc9\x17\x97\xed\x07\xf4\x9fr\x07" +
"\xaf\xbe\xbc\xeb\xe9\xbfLuR,z\xd5\xf2\x83\xa85" +
"/\x17\xe1Z.\xa2\x1f\xfd\x97\xf1#\x1f\xf6\x0c\xffu" +
"\x86\xe1\x1f[1\x8c\xda\x8fV\xd0\xf0\xfbO\x9c{7" +
"u\xe2\x88?5\x93\xc4\xcc\x0f\xafx\x1e\xb5\x93+\xc8" +
"\x85\xc7W\xfc\x14\xda\xfc4w83\xbf\x9a\x0e\xe5]" +
"\xcf\x1e\xfdjZ\xfc\xdc\x9ef9+\xb7\xa6W<\x0c" +
"q\x97;c\\\xd5W\x0f\"~\xae~\xcac\xce\xa0" +
"a\xda^/sz\xbbm\xcbsls\x101\xd9\xa4" +
"\x86\x00B\x08\x10e;\x01\x92\xdfR1i*\x18E" +
"\x8c!\x81\xc6w\x01\x92\xa6\x8a\xc9\x09\x05\xa3\x8a\x12C" +
"\x05 \x9a'MO\xc5\xe4\x01\x05\xa3\xaa\x1aC\x15 " +
"\xfa@?@r\x9f\x8a\xc9#\x0a\xfa\xd9\xbc\xdeKc" +
"\x80j\x9b8\x17\x14\x9c\x0b\xe8\xbb\x1e\xe7\x8eae0" +
"a\x8c\x1a\xdeFk\x04\xed\xb2\xccbcF\x86y\x06" +
"\xa8\xb6U\x06sY\xdb\xe2\x1br.\x00`S\xa5\x80" +
"\x03b\x13|\xfel\x07XN\xdfh\xe5\xf2\xde\x96B" +
"\x8e\x03\xd0D/\x92\xebZ\x9b\xd2\x0f\x80s\xb4V\xe5" +
"\xe7\x00\x18\xd1Z\x95\xa7\xc8\xb8\xd6\xaa\xfc\x18\x00\xa3Z" +
"\xabr\x19\x00\xe7j\xad\xca\x1b\x00X\xaf\xb5)\xc3\x00" +
"\xa8i\xad\xca\xcb\x00\x18\xd3\xda\xc4/j\xed\xca^\x00" +
"T\xb46e7\x00\xce\xd3Z\xc5\xf3M\xdar\xe5\x0f" +
"\x008_kU~\x09\x80\x0b\xb46\xe5\x1d\x00l\xd6" +
"\xda\xc5\xf3B\xadS\xf9\x01\x00.\xd2:\xc5\xb8-Z" +
"\xa7\xb0\xb7X\xeb\x12\xcf7k]\xc2/\xb5\xf8\x1c\xd2" +
"\xba\x84?q\xadK\xd8\xbdE\xbb[\xd8[\xd2\xd1\xa3" +
"\xacA\x00\xac\xd26*\xcf\x03`\xb5\xb6Q8\xd0\xa8" +
"\xadSv\x02`\x83\xd6#\x1c[\xaa\xdd-^\xfc\x92" +
"\xd6#\x0c/+\xfe.\xef\xe8Q\xe6\x92\x81:m\xa3" +
"\xf2\x10\x00\xae\xd06*\x7f\x06\xc0/w\x0c(KI" +
"p\xab\xb6U\x84\xa0\xb5c\x87\xa2\x10\xb0\xb2c\x97\xfc" +
"\xe3+\x1aS\xbe\x0b\x80\xb7iL\xbc\xda\xa61\x11\xbc" +
"p\xf1\xb9Fc\xcaA\x00\xac\xd5v\x89\xdf\xdb\xb5\x1d" +
"\xe4\x83\xaf\xdb\xe3\x96i3\x1d\x00|\x97{[\x98\x93" +
"\xe1\xe8%\x98\xc7\x1df\xc6{\xd2in\x12\x9e\xcaq" +
"\xae\xcb\x14\xd9<2\x12v\xb97\x05\xed\xb5\xad\x08e" +
"/\xc1\x03,\xd7\x9b\xafr\xc6\xb8\x90\xf7\xca\xb4\x06\x97" +
"{\xdb\x0c\xd7\xb0\xad\xde\xfc$\x91*_J\xd8\x99\x04" +
"\x87\xf0\x98\x1cOh*RU\xf8D.\xf5H\x1f\x83" +
"\xb2\x01\xc3\x92\xe2m\x00\xbe\xc3i&)\x0e\xdd\x9eg" +
"X\x19\xd7w\xd9\x18Oq\xcf\x83\x88|\xe4\xde:\x8b" +
"\x0d\x9b\xd0-\x87\x9fjl\xab\xcb\x85\x9c\xa7\"%\xb1" +
"\x98\x8a2I\x96\xe3\x1c\xf5\xf2\xec\x151\xfb\x804\\" +
"|\xf3\x1e\xdb\xd4\x13\x0as\xbd\x14\xe7\x96P%M\xf4" +
"\x02Q\x16h?W\x9d=S\xc1\x9et\xb8\x18x\x81" +
"*\x12\xddb\x8c\xf2\xcd##.\xf7d \xfa\xf8\x08" +
"\xcb\xa3\xe9%\x98\xc5\xb7\x1ba\xdd\xcb\x96]\xc6R\xdc" +
"\xe2\"p>\x05\x86\xd41oz\x14\x11#L\x01!" +
"t\x88\xa7\xed\xaa\xd1Qn\xe9\\\x17\x12+\xe3\xd2Z" +
"\xa5L{\xbc\xcf\x1e\xb7\xd6\xdb\xce&>!\x1dHD" +
"h\xb2\x95\xb9o\xcdM\x92\x1a\xe1\xa2\x94\xe6\x9eRK" +
"s\xf6\xb6g\x0d\x93\xf7f\x99\x951\xacL\x8awK" +
"u1\xfa w\\4\\\x8f[\x1e\x09\xe4\xb2\xa5\x99" +
"\x95\xe6f\x9f\x0d\xdd27\x8b\xe9\xd1\xefR\x1d*>" +
"\xa4l\x88\xe4\x9d4\x17\x8b:\xe1qG\xb1\x98Y\x0e" +
"\xf3\xa4t\x14b,\x89\xe3\x89Is\x90\xd9;\xe8\x18" +
"q\xdb1\xbcB\x19W\xa5\x19r\x9a\x0f\xf1\xfb\xf3\x86" +
"\xc3]\xda\x0d9\xf4|F\xbf^*\x87\xa5\xd1\xe4r" +
"\x0c:\xdcu\x95\x0d\xcc\xddb\xf7\x145&\x8d\xd7\xa3" +
"\xef\xce\xbb*\x85_\xaefP\xab\x12;\x01*^e" +
"*\xb4\xea\xb6\x9a\xf7\xcaC\xd4\x8a!6\x8fq\xc71" +
"t^Q\xa4U\xeb\xb5-\xdd\xf0\x0c{j0J;" +
"0\x95e\xba=\xde\xcb\x9c\x94\xc7<\x14\xd1\x13\x10\x8e" +
"\x0f\xd8:7\xb7\xad\x92S)b\x1brn\xc2N\xb3" +
"\x08\x19$\xb8\xdf\xb5\xadAd^v\xbdi\xb3\xe2\xac" +
"\x05V\xcd\xbc\xec\x16>!\\\x10\x00\xf3\xb2k\xed\xe0" +
"\x90ESd\x89\xd6#\xe2X\xcc,\x1f\x16U78" +
"\x1a\xc9Y~\xbb8s\xe8\xd0X]:\x1d\xb5]\xb8" +
"\x17 u\x1f\xaa\x98\xcab\xe5\x80\xd48\xf6\x03\xa4t" +
"\xc2sX9#\xb5Q\x1c\x02H\x99\x84O`\xe5\x98" +
"\xd4\xf2B\xdf#\xfc\x00\xe1\xa1\x9a\x18\x86\x00\xb4\x07p" +
"\x15@j\x82\xf0C\x84W\xd5\xc6\xb0\x0a@{\x10w" +
"\x02\xa4\x0e\x10~\x8c\xf0\xea\xba\x18V\x03hG\xf1\x07" +
"\x00\xa9c\x84\x9f <\\\x1f\xa3&B{\x0c\x1f\x02" +
"H\x9d \xfc4\xe15\x0d1\xac\x01\xd0N\x09\xfd\xd3" +
"\x84\x9f#\xbc\xb61\x86\xb5\x00\xdaYt\x00R\xcf\x12" +
"~\x81\xf0\xbap\x0c\xeb\x00\xb4\xf3\xf8\x14@\xea\x02\xe1" +
"WP\xc1%\xf5J\x0c\xeb\x01\xb4\x17p\x0d@\xea\"" +
"\xe1WQAl\x88a\x03\xf5L\xb8\x12 u\x89\xe0" +
"7QA\xdf\xb5i\xcb\x0c\xd8\xd0m\xd9\x94VX\x0b" +
"\x0a\xd6\x02\xfa&\xf3\x0c/\xafs:\xd3\xebA\xc1z" +
"\xc2l+C /c\xcc\x9c\xa6\x17w\xc56\xaa" +
"\x03\x05\xeb\x00\xfda\xce\xa8\x9b\xe8\x03\x95g\xca`\xd6" +
"v\x8c\xbd\xb6\xe5!3{\xd2\xe9\xbc\xc3\xd4t\xa1," +
"\x1c\xe3\x8eg\xa4\x99\x89R\x94.\xd0\x018\xd9Z\x0f" +
"\x16E}A\xab\xae\xac\x94i\x88\x0bY\x19\xcf[\xc6" +
"\x84\x98\x9d\xeb\xb1\xd1\xdc\x80a\x86M\xc3\xc5*P\xb0" +
"\x0a\xb0;\xcb\xdc\xf5\xc6\x04\"(\x88\x80\x91\xb1M\xeb" +
"\xfap\x0e\xe0\xa0\x8a\xc2\xc0\x9c@\x0fS\xf57z\x98" +
"\xcdy\xef\xf6\x14\x1b\xcd\x99\x9c\x123\xef\x8a\xa4\\ " +
"\x9a\xb0\x9e\xb5\xd4\x88D\xbbVQ\x1f\x12m\xa7S_" +
"\x8d\xb6\x11\x18\x8a.\xa7^\xa0*\xbad7\xc0\xfe\xbc" +
"\xb5\xc7\xb2\xc7\xad\xb8eo\xc8\xb9\xbee\xf7\xdac\xdc" +
"a\xa0f\xf8~\xcb\x1e`^:\xeb\x8f\xd2\xbf\\\xdf" +
"\x04\xdd\xb6,!E \x01\xb2\x0a\x7f\xe1>4\xac\xb7" +
"\xaf\xbaQ#:\xa5q\xed\xba\x91>\x85\x81\xf61\x9d" +
"\x1a\x9b\xd5\xbc'\"PnZ\x1f\x7f\x08 \xf9\x84\x8a" +
"\xc9g\x02M\xeb\x19jEO\xab\x98<\x17hZ\xcf" +
"\xae\x04H>\xa3b\xf2J\xa0i}\x814/\xa9\x98" +
"\xbc\xa6 \x86\xc4N\x8c\xbet\x10 yU\xc5\xe4\xaf" +
"h\x1b\xa2\xd8\x86\xd1W\x87\x01\x92\xaf\xa8\x98|S\xc1" +
"r'\x83\x83\x8e\x9d\xa1:)\xfa\xd32a(\xf6\xa7" +
"n\xe9\x88\xa3~\x0c\x14l\x00\x8c\xe4\x98\x97-\xe5@" +
"S\x85\xb1\x01\x8al\xc8\xd9\xae\xa8\xa8\xb2\xdf-\x93\xaf" +
"\xa2=\xd3\xb6sC\xcc\xe3\xd8C\x0b\x98\xe1\x95\xdc-" +
"I <`Xe\xb4\x14T\xf5o\xe5\x96\x9a\xf7\x92" +
"5\x18\xbc\xea\xa8\xdd\x1d`q\xb5C~)\xf5 B" +
"\xc9\xe7o\xc8\xb9)\xda\xd7\x80<y\xa4\\\x19\x7f\xa4" +
"\xae\xa5\xca\xa2ReQ\x03\x95\xf1\xacJ\x95\xe2\x19\xc2" +
"\x9fS\x03\x95\xf1g*U\xc0s\x84_T\x15\xc4b" +
"a\xfcW\x95\x0a\xdd\x05\x82\xaf\x90z\x08ea|A" +
"\xa5\xc2{\x89\xf0k\x84W)\xb20\xbe\xa4>\x0f\x90" +
"\xbaF\xf8o\x08\xafVea|M\x0c\xfb\x0a\xe1o" +
"\x12\x1e\x0e\xc9\xc2\xf8\xba\xb0\xff\x1b\xc2\x7fOx\x8d*" +
"\x0b\xe3\xefT*\x8c\xbf'\xfc\x8f\x84\xd7\x86da\xfc" +
"P\xa5\xc2\xf8\x01\xe1\x9f\x12^W%\x0b\xe3'\xeaw" +
"\x01R\x9f\x12\x1e\x0a)\x18\xad\xaf\x96\x85\x11C\xbf\x04" +
"H\xd5\x84TL\xc5\x08ox[V\xc6h\x88\xfci" +
" |\x01\xe1\x8d\x8bc\xd8\x08\xa0\xcd\x0bQ\xc1o\"" +
"\xbc\x85\xf09\xbf\x8b\xe1\x1c\x00\xad9D~. |" +
"\x19\xe1\x91\x9a\x18F\x00\xb4%\xa1\x97\x01R\xb7\x12\xbe" +
"\x9a\xf0\xa6\xda\x186\x11\x9f\x0fQ|\xee \xfc.\xc2" +
"\xa3u1\x8c\x02h]!\x9a\xd7]\x84\xdfC\xf8\xdc" +
"H\x0c\xe7\x02h\xebB\xc3\x00\xa9>\xc2\x07\x09\xd7\xea" +
"c\xa8\x01h\x03\xa1\x9f\x03\xa4\x06\x09\xbf\x8f\xf0XC" +
"\x0cc\x00\xda\x8e\x10\x1d(\xf7\x11\x9e%|^c\x0c" +
"\xe7\xd1\xc1\x17\xa2\xf8\xe8\x84\xe7\x08\xbf\xa9%\x867\xd1" +
"\xc1'\xf4s\x84\xef#|\xfe;1\x9c\x0f\xa0\x15\x84" +
"?\xfb\x08?B\xf8\x82\xc51\\\x00\xa0\x1d\x0e\xed\x06" +
"H\x1d\"\xfca\xc2\x9bkb\xd8\x0c\xa0\x1d\x17\xf19" +
"B\xf8\xa3\x84/\xac\x8a\xe1B\x00\xed\x11\xe1\xe7\xa3\x84" +
"?I\xf8\xa2\x9bc\xb8\x08@;)\xfcy\x82\xf0g" +
"\x08o\x89\xc7\xb0\x05@;#\xe2y\x9a\xf0s\x84/" +
"^\x16\xc3\xc5\x94\x9e!:\xa0\x9f%\xfc\x02\xe17\xd7" +
"\xc5\xf0f:\xf8D|\x9e#\xfc\x12\xe1\xf1\xfa\x18\xc6" +
"\x01\xb4_\x88y]\"\xfc\x1a\xe1\xb74\xc4\xf0\x16\xca" +
"C\x81_#\xfc7\x84/i\x8c\xe1\x12\xca\xc3\xd0A" +
"\x80\xd4\xaf\x08\x7f;\xa4\xe0\xfeqV\xd8\xc4Fy\xa9" +
"\x14t\x8f\xb3\xc2\x10\x1f)=\xfa\x8e\xcdt\x92\x07\xaa" +
"\x85<v\x12\xc6(\xa8\x86W\xde\xd6V\xb1\x09\x86n" +
"\xd9rM\x13\xa0\xc4\xfb\x8cn\xd7\xa3\xf6\xb6\xa4\xd0\x9d" +
"e{\x99\xa3\x97\xad\x93\xfe=l/\x03u\x06\x10\x1d" +
"\xbd\xcf\xa0\xf7\xd5\x8a\x01\x9f\xe9c\x86k;\x05\x88\xa7" +
"&\x9d\xc4\xf4R\x8f>f \x09\xa5\x0b\xd3dJI" +
"V\xb4\x9b\xc6\x8ac\xb6\xc5\xb7\xb3B\xe9\xa0\x8c\x9b\xcc" +
"\xe2.V\x83\x82\xd5\x80\xbeg\x98<A\xb4Q\xe5z" +
"I\xa5\x1c\x19\xc5\xf0R\xf9L\x86\xbb\x1e\xd7\x85\xf1\xc0" +
"\x89\xee\x16\x05\xd0\xadOv\x97\xbb\x9e1J\x85T\x1f" +
"\xb2\x99\xbe\xdd\xd0U/[\x16\xd2:P\x17\x0ba>" +
"\xe1a\xa4r\xc7\x09\x88\x11@_7\x8aQ]\xef\xd8" +
"\xa3\xdbY\xa17\xce-j\xfa\xcb=F\x91xb\x89" +
"y\x06<\x1a%\xbe\xe4\x8c\xf1\xa9\xf1\x1bg\x85\x147" +
"y\x1a\xa9\xf8\xcb\x1b\x0b\x8cT\xae\xaa\x8a#\x97\xe6\x8c" +
"\x86\xec\xdc\xbd`@\xb2F&;\xce\x0a\xbd\x101\x99" +
"\xebb\xa4rq(\xdf\x8e\x8f\xb3\xc2F\xbd\xd4\x9a\xf8" +
"\xe9b\xf3>\x85\xabT2O\x14\xfem\x1c\xe2\x0eM" +
"\x07\xc3\xa0`\xb8\x18\x1d\xeaD@\xcd\xd3 \xe5\x93\xa3" +
"\xe8b\xa6r@`\xa4r\x94T\xa4\x03\xb6eo\x81" +
"p\xb0!L\xdb\xa3\xb9\xbc\xc7u\x142\x83v@Y" +
"&\xbb\xc8\x0d\x1c-\xee0y2\x96e9\xc7\xd6\xf3" +
"i\xee`\x8a\xbb\xe4cE\xf2\xb9\xbd~\x99\xaaHz" +
"+\xa2M-E\x8dh\x12\xa2kDSU\xbb\x16\x80" +
"v\x8fg\xa4\xf7\xe7\xb8\x93\xe6\x967\x9b\x9e\xa63\xd8" +
"\xd3\xcct\xfcR\xde\x89K8>!\xfa\x99\x061\xf8" +
"b\xd9\xd1\xcd[):\xba\xc6\xb5\x00\xfbG\x1c\xce\xc7" +
"Y!\x926\xbcB\xa9\x93\x9b\x8d'\xed\xb3\xea\xc6\xc2" +
"z\xfb\xeaY\xb6o_\x9b\xed\x00\x9d\xb3}\xa1\xfdF" +
"\xc1\x14w}hQ\x1cc\xe5\xbe\xf0\x01\xea\xf6&T" +
"L\x1e\x0a\xf4\x85\x0f\xae\xaa\\Qb\xb1-<\xbc\x14" +
" y@\xc5\xe41j\x0b\x9bd[x\x94\xde>\xa4" +
"b\xf2\xe1J#\x12=N\xbd\xe21\x15\x93'\x14\x8c" +
"x\x85\x1c\xa5x\xf9\xfbSq\x97\x8d\x10[-\xed\xeb" +
"\xb0\xeb9\xe5\xceo\xd8\xb6\xcd\xf2n\xdd]$\xae\xc1" +
"b?\x9b\x98\xdc1\xdb v\xdc\xe8\x85{\x8a\xf5\x83" +
"\xaa\x87\xd8\x0fw\x8a\xe8\x9c\x92)\xf9x\xbfH\xc9\xc7" +
"v\x0b\x92\xf1\xc8*A2\x8e\x0e\x09\x92q\x98T\xaa" +
"\xa3\x0f\x0c\x03`8Z \xb0&\x9awh\xa7F\xef" +
"\xa7\xf7\xea\xa2\xa3\xf4^}\xd4\xa0\x9f\x86('\xcd\xc6" +
"(\x0b\x90\x13\x7f\xd4\xf6lg\x9c\x15\x00\xa0\xf2w$" +
"aX{\xe2\x9e\x93\xb7\xf6\xf8\xe2\xdf\x84a\x01\xee\xd9" +
"\x9fs\x8cQ\xe6\x14\xfc\xe2o\x02\xc2\x86\xb5\xc7w9" +
"\x155\xe6\x00\x16*\x7f\xc7\x0bd\xc3\xf7\x88\xf81G" +
"\x98/\xff-\xcc\xfby+Ms6 2bp\xdd" +
"w\xb8k\xe8\x9c\xca\xbf\xc1L\xdf4\xc6\x0c+C\xfd" +
"\xaf\xc3\xb9wC\x9aBK:h\x1b\x96'/\x0f\x02" +
"W\xeb\xfd3]\xad\x0f\x01$\xb3*&=j\x8eC" +
"2\x1d\xef'0\xa7br\x1f\xa5c\x95L\xc7\xc2\xde" +
"J2\x7fa\x02\x9d\xce;c\xcc\xcb;P9h}" +
"O^`r\xe86m*)\xd3\xb8\xc2\x17\xd8\xf1\x1d" +
"_\x84\xb0\x0d\x96\x98\x8c(\xad\xe58\xb4R\x1cnU" +
"1\xb9:\x10\x87v\x9a\xf2\x1d*&\xef\x9a\xcd\xecn" +
"\xe0\x00\x11gAW\xc4\xd5\x9dX\x0d\x11\xdf\xd6\x95\"" +
"\xa1\x97\xc8\x84^\xdc/\x12\xbay)@\xc4\xb2-\xee" +
"\x1bt\x96[\xcc\xa4D\xe1\x13\xe5\xbf\xc3i\xe6|\xe1" +
"\xaf+\xa9\x88\xc7<\x9e\x0ca\xe0\x9bs\x14\x97\x867" +
"\xe4\xdc\xe4\x812k\xba_\xa5\xb67G4b_\x90" +
"5\x15\x04\xad\xd9G\xf8\x91 k:\xacR[z\x88" +
"\xf0\x87\x09WQ\xd2\xa6\xe3\xeaS\x00\xa9\x87\x09\x7fB" +
"\xd0&U\xd2\xa6\xc7\x85\xfd\x13\x84\x9f\x16\xb4)$i" +
"\xd3)\x95\xda\xd2'\x09\x7fV\xd0\xa6*I\x9b~$" +
"\xec<K\xf8\x05A\x9b\xaa%m:\xaf^\x9eD\xcb" +
"j\xc2\x926\xbd X\xdfE\xc2\xaf\x0a\xdaT#i" +
"\xd3\x8b\x82f]!\xfc\x15A\x9bj%m\xba.X" +
"_\x85\xae\xd5\xd7I\xda\xf4\x9a\xf0\xf3W\x84\xbfMx" +
"C\xbd\xa4M\xff!h\xd6\xdb\x84\x7f@xc\x83\xa4" +
"M\xef\xab\xd4\xb6\xbfG\xf8\xc7\x84\xcfi\x94\xb4\xe9O" +
"\x82\xc6}\xac\xaa8$X\xd3\x1c\xc9\x9a>\x13\xc3\xfe" +
"\x95\xd4k\x04k\x8aH\xd6T%\xba\xfc\x10u\xe7M" +
"\x8255I\xd6\xd4(XD\x85\xad\xcd\x8dJ\xd64" +
"/\xf4\xfc$V\xa6\xa9\x925-\x09\x91\x9b\xcb\x08\xbf" +
"C\xb0\xa6\xb9\x925\xb5\x09;\xb7\x11~\xa7`M\x9a" +
"dM\x9dB\xffN\xc2\xfb\x04k\x8aI\xd6\xd4#X" +
"M\x85\x95\xcd\x9f'Y\xd3\x80\xb0\x93 \xfc^\xc1\x9a" +
"n\x92\xaci\xab`\x17[\x08\xff\x96`M\xf3%k" +
"\xda%\xd8`\x85\xad-\xfc\xa6dM\\\x8c\x9b%\xdc" +
"\x13\xac\xa9G\xb2\xa6\xfb\x85\x9d\x0a[kY+YS" +
"A\xe0\x13\x84\x1f\x12\xac\xa9W\xb2\xa6\x07\x05~\x80\xf0" +
"c\x825\xf5I\xd6tT\xf8Yfk\x18\x97\xa4\xe9" +
"\x91\xd0R\x80\xd41\x82O\x84\x14\x14\xf7\xd9\xe6\xa0\xc3" +
"!\xe2\xba\x81\xee\x95\x99\xe3\xac\xe0n\xb6\xb0\xf85h" +
"]\xb7\xf8\xc4QQ\x18v\xd8\x1e\x9e02\x10\xcez" +
"n\x19\xd5\x99\x9b\x1d\xb6\x99\x83z\xe0f\xbb\\\xd3t" +
">\xe3X\xe5\x06^\xdcps\x1d\xa6K\x12\xb6\x95!" +
"ix\xa6\xf7\x94m\xdc)\x944\x82\xef\xef\xe7i{" +
"\x03gN\xf9\x8d\x11\xdbI\xf3^\x9b\x81\xeaze\xd0" +
"p\x07\x99\xb3\x87hAe\xe0\x1c\xcb\xbb<\xc1<\x88" +
"\xd0\xe4\xa7\xc0\xb6\x85\xa2\xfa\x19\x16C3@\x80l\xc7" +
"\xdb\xc0\x19`e<\xcfa##Fz\x00m]~" +
"#\x0a\x12\x84\x0csS\x1eK\xef\x09\x8e;j\xeb\x9c" +
"f\x01\x93&*\x0b\xdb\xa0\x03q\xfe\xb9\x8b\xd4\xd3m" +
"\x9a\xf6x0B\x95\xe5\xf0\xec\\\xca\xc8\xa0Ufq" +
"\xf2C\xcbt\xa3\x12O\xd8X\x8a'N\x95m\xe3X" +
"\x8a\xf7\x94ws\\g\xe6\x00\x9b\x80\xf8\x10\xcfpk" +
"2.\xe2\xd6\x93\xf6\x8c1^\x99\xb0\xc7]\x93\xf5\xf6" +
"\xae\x83n+\xc32\x01SEA\x1f\x1a.\x17\"\x9c" +
"&\xdb\x84\xb6\xd7\xe3\x8c\x06\x13Fd\xf3=4\xe1^" +
"'o\xb8\x81\x81D\xee\xcd(\xc9\xe5M\x97\xf7X:" +
"\xc47\x98\x86\xceKx8\x93sg\xf9\x8d{\xca\xb9" +
"|\xe7,;\xf7\xbf\xbb\x91\xfe\xf6\"+\x15\xa44\\" +
"\xc8\x89STv)\x9d\xb2-l\x1b\x12\xa7h\xab<" +
"E\x97\xf7\x8b\xb6p\xc9J\x80\xfd\xe9\xbc\xe3\x10i\xca" +
"9\\7\xd2\x1e\x07\xd4\xfd\x9c\xed\xba\xc6\xb0\xc9Kg" +
"\xab\xa5\x8b]\x10\x19a\x86y\xc3\x8b\xf0\xbe\xe2\x95k" +
"\xf9\xc6u\xca5\xf0\x1a\x80\xe4\xa3*&\x9f\x0c4\x16" +
"'\x87\x02w\xc3\x0aJ\xd7\xcf\xec\x0c\xdc\x0d\xab\x8al" +
"\xb0\xce\x1e\x04H>\xabb\xf2B\xe5\x1a\xf8<\xbd\xfd" +
"\x9c\x8a\xc9W\x02\xd7\xc0\xd7I\xf1\x9a\x8a\xc9\xf7\x14\xec" +
"f\"\xbb\xa6\xa4\xabIs\xad\xe4\x8e\xed1s\xbda" +
"\x82\xca]\xac\x01\x05k p}\xcc\xf5\xf5\x86\xc9]" +
"(K\xcc\xe2w+@\xb7tK\xdcP\xfcRP\x12" +
"a\x1f\xf7\x98a\xbaP\xb9F.\xff\xa7\x92\xe25\xf2" +
"l\x88\xc1\xa4,\xb8Q\xf0K\x9f\xd5\xfa\xba\xa5\x0f\x82" +
"\xba\x96\xd7`\x1d5w}*&\x07\x15,-\xc1\x00" +
"E;\xa1b\xf2\xde\xc0\x12l\xa5 nQ1\xf9-" +
"%0\xe5\x00\x15\x9a]\xd4\xfe?\x00\x00\xff\xff\xd6\xbd" +
"\x94\xef"
const schema_b526ba661d550a59 = "x\xda\x94zkp\x1c\xd5\x95\xff9\xdd3\x1a==" +
"\x1a\xdf1\xd8\x92\xc5\xd8\xc6\xc2\xc8A\xc4F\xf8\x1f\xe4" +
"\xe0\x08Y2\xb6U#\xacV\xdb\x16v\xe1\xaa\\M" +
"_\xcd\xb4\xdd\xea\x1ew\xf7H\x1a\x17.\x1b\xc7T\x01" +
"\x7f\xa8@6\x0f'\x156<LU\x92\x85\x84\xcd\xc2" +
"\x96\x97\"\xb5\x84\"U\x86\xc5U\x90\x0a\xa9<\xc8\xe2" +
"\xe4\x0bI\x96\x14\x9b,\xa9\xcdfC\xf5\xd6\xb9=\x8f" +
"\xd6#\x18}\xf1\xa8\x7f\xe7\xf4\xb9\xe7\x9e{\xee\xb9\xe7" +
"w\xdb[N\xb6\xdc\x16\xdb\xda\x96o\x05E{8\xde" +
"\x10\xa4\x8e\x1d\xfa\xb5\xed\x7f\xe74\xa4:18\xd4|" +
"\xa0k\xea\x85\xeb\x9e\x87X\x02\xa0oM\xc3ad\xbd" +
"\x0d\x09P\x83\xef~\x90\xed?\xfc\x9f/\xdf3_+" +
".\xd5\x9a\x1aN \xebjH\x00\xb05\x0d\xef\x02\x06" +
"w\x0e\xbd\xfc\xf9\xc9\xeb\xbeq/\xa4:\x95\xba2`" +
"\x1f&F\x90\xadJ\xd0K\xa9\x84\x13\x03\x0cz_-" +
"\xdd\xf9\xed5\x0f=\x02Z/6\x05/\xdb\xff\xdar" +
"\xf0\x95\xbb\xfe\x0b\xe2H:\x0f\xb5<\x80\xec\xa9\x16\xfa" +
"\xf3\xf1\x96w\x14\xc0\xe0\xef?\xf1~\xc9<q\xf8\xcb" +
"\x90\xbaV\x09\xce\xfc\xe9O\xd7\xf6\xfd\xc7\x1f\xce\x93\xe5" +
"\x9f\xad8\x83\xec\xf7+\xc8\x87\xdf\xae\xf8\x0a`\xb0\xf2" +
"9\xcc\xe7/^\xfa\xfa\x12\xd3\xeaON\"\x1bM\xd2" +
"\xb4z\x7f\x91Q\x87\x13\x85\xc7\x97\xd0\xeaI\x1eF\xb6" +
"CjYj\xdfm\x7f\x9elx\x12\xb4N\\4\xfb" +
"5\xa4\xd7\x9b\x0c_\x99@\xc0\xba_Z'\xb6G\xd4" +
"\xe5\xbc\x1fm\xef@\xf6L\xfb&\x80\xbeW\xdb'\x1a" +
"\x01\x83\xe1\xa1\x97^{\xe6\x9d'\x9fZ\x14\xadC\x1d" +
"g\x90Mw\xd0\x9c\xcc\x8eO\x01\x06\x07\xcek?\xbb" +
"a\xe6\xd2SKx;\xddq\x18\xd9=\x1d\xe4\xedw" +
"~\x91\xfb\xd2\xff\xff\xe6\xfb\xff\xb0\xc8\xde\x91\x8e\xedu" +
"{\xfb\x00\x83#\xcf\xbf\xbb\xf5\xf3\x1f\xbe\xf3\x9d%\xec" +
"\x9d${\x8fH{\x03\xef\xfc\xee\xe4\xe8\xba\x89g\x97" +
"\x1cu\xb2:\xeaE\xcc\xae7s\xf6\x85%\xb4\x8e\x90" +
"\xad\xe3R\xab\xf0\xe2\xdf}Q\xff\xf4\xf0\xf7\x97\xd0\x1a" +
"%[\\j\x9dUo\xbe,n\xdd\xf8\xf2\x12Z;" +
"HK\x93Z\xef\xff\xd3g\xde\xbce\xc7\xd1\x8b\x0bW" +
"E!\xbd\xde\x8e\x95\xc8vt\xc8\xe5\xee\xc8\xd0\xaal" +
"?4Q\xb4\xde\xfa\xc6\xbf-5t'\x0d\xddIF" +
"\xdf8\xf3X\xfe\x7f\x7f\xfe\xe5KK\x0dMZ\x9a\xd4" +
"\xeaz%\xf7\xf6\xdb\xfe\x997\x17\x85\xb8\xb7s'\xb2" +
"\x1d\x9dr\xd8N\x99\x0c\xfd\xfd/>\xfe\xfa\x03\xff\xfd" +
"\x13\xf2R]\x98bkG\x90\xedXK\x0b\xd2\xbf\x96" +
"6\xce\xb6\xec\xf0\xe7\xceM|\xf9\xe7K\x8c\xde\xd5u" +
"\x18\xd9\xd6.\x1a\xbd\xffs\xaf}\xed+\xf1\xaf\xfc;" +
"\x99T\x16\xa8\xb5u\xedD\xd6\xd5%\xf7b\xd7,`" +
"p\xfd\x9b?y\xff\x86]\xcf\xfdq\xd1\x8e9\xd9u" +
"\x14\xd9#R\xf3\xa1\xae<`}\xf3\xcd7\x1bn\xc5" +
"\x7f!\xedK]W\x03\xf4\xbd\xd5\x95H\x00\x06k\x1e" +
"}to\xd3{W}\xb0\x84\xaf\xbf\xec>\x8c\xec\x0f" +
"\xdd\xe4\xeb\x93\xb1\xe2\x87\x9f>\xfb\xd0\xff,\xa1u\x89" +
"\xb4~%\xb56\xff\xa6\xfb\x8ew\xef\xfa\xcc_\x16\xc5" +
"\xf3\xfb\xdd\x93\xc8\xde\xec&'/u\x9f\x02\x0c\x9e\xf0" +
"\xc6.\xbev\xe4\xc9\xbf,tR.z\xdbug\x90" +
"\xad\xbfN\x86\xeb:\x19\xfd\xd4?\xcf\xde\xf7\xfb\xc1\xc9" +
"\xbf.1\xfc\xa3\x9b&\x91\xfd\xe3&\x1a\xfe\xd4\xb9g" +
"\xdf\xd5\xcf\xdd\x17,\xcc\xa4\xb0\x08mz\x01\xd9S\x9b" +
"\xc8\x85\xc77}\x17z\x83\x9cp\x05\xb7>\x99\x8b\x95" +
"<\xdf\x99\xfedN\xfe\xdc\x98\xe3E\xbb\xb8}H>" +
"\x8c\x0bO\xb83B5n\x1eC\xfcH}\xdd\xe7\xee" +
"\x98i9\xfe\x10w\x87\x06\x1c\xdbw\x1dk\x0cQk" +
"Wc\x001\x04H\xf1\xc3\x00\xdagU\xd4,\x05S" +
"\x88i$\xd0\xfc\x02\x80f\xa9\xa8\xcd)\x98R\x944" +
"*\x00\xa9\x12i\xfa*j\xa7\x15L\xa9j\x1aU\x80" +
"\xd4\xc9\x11\x00\xedn\x15\xb5\xfb\x14\x0c\x0a%c\x88\xc6" +
"\x00\xd5\xb1p%(\xb8\x120\xf0|!\\\xd3\xcec" +
"\xd6\x9c6\xfd\xbd\xf6\x14:5\x99\xcdg\xcc<\xf7M" +
"P\x1d\xbb\x06\x16\x0b\x8e-v\x17=\x00\xc0\xf6z\x01" +
"\x07\xc4v\xf8\xe8\xd9\x8e\xf2\xa2\xb1\xd7.\x96\xfc\xfd\xe5" +
"\xa2\x00\xa0\x89\xbeH\xae\xb3^e\x04\x00W\xb0\x1e\xe5" +
"{\x00\x98d=\xca\x13d\x9c\xf5(\xdf\x06\xc0\x14\xeb" +
"Q^\x06\xc0\x95\xacG\xf99\x00\xb6\xb0^e\x12\x00" +
"\x19\xebQ^\x03\xc04\xeb\x95\xbf\xc8\xb6*'\x00P" +
"a\xbd\xcaQ\x00\\\xc5z\xe4\xf3U\xac[\xf9\x1d\x00" +
"^\xcdz\x94\x1f\x01\xe0j\xd6\xab\xfc\x1a\x00\xd7\xb0\xad" +
"\xf2\xb9\x83mS\xbe\x06\x80\x9dl\x9b\x1cw-\xdb&" +
"\xedu\xb1~\xf9|\x0d\xeb\x97~\xa9\x95\xe7\x18\xeb\x97" +
"\xfedX\xbf\xb4\xbb\x8e\xed\x90\xf6\xd6\xf7\x0d*\xdb\x11" +
"\x00\xe3l\xaf\xf2\x02\x006\xb0\xbd\xd2\x816\xb6K9" +
"\x0c\x80\xadlP:\xb6\x81\xed\x90/^\xcb\x06\xa5\xe1" +
"\x8d\x95\xdf\xee\xbeAe%\x19hf{\x95\x07\x00\xf0" +
":\xb6W\xf9#\x00n\xea\x1bU6\x90\xe0zv@" +
"\x86\xa0\xa7\xef\x90\xa2\x10\xb0\xb9\xefH\xf8\xc7'\x18W" +
"\xbe\x00\x8070._\xede\\\x06/Qynd" +
"\\9\x03\x80M\xec\x88\xfc\xbd\x91\x1d\"\x1f\x02\xc3\x99" +
"\xb5-\x87\x1b\x00\x10x\xc2\xdf\xcf\xdd\xbc@?\xcb}" +
"\xe1r+3\x98\xcb\x09\x8bp\xbd(\x84\x11\xa6\xc8\xbe" +
"\xa9\xa9\x84'\xfc\x05\xe8\x90c'){\x09\x1e\xe5\xc5" +
"\xa1R\xdc\x9d\x11R>\x14\xa65x\xc2?hz\xa6" +
"c\x0f\x95\xe6\x89\xd4\xf0\xa5\xac\x93\xcf\x0aH\xcc\x84\xe3" +
"IM%T\x95>\x91K\x83\xa1\x8fQ\xd9\xa8i\x87" +
"\xe2\x83\x00\x81+h&\xba\x80\x01\xdf7\xed\xbc\x17x" +
"|F\xe8\xc2\xf7!\x19>\x0a\x7f\x97\xcd'-\x18\x08" +
"\x87_h\xec\x80'\xa4\\\xe8\xc9\xaaXNE\x99'" +
"+\x0a\x81Fm\xf6\x8a\x9c}D\x9a\xa8\xbc\xb9\xc7\xb1" +
"\x8c\xac\xc2=_\x17\xc2\x96\xaa\xa4\x89~$\xca\x12\x1d" +
"\x11\xaa{l!8\x98KT\x02/Q%D\xf7\x9b" +
"\xd3b\xdf\xd4\x94'\xfc0\x10\xc3b\x8a\x97\xd0\xf2\xb3" +
"\xdc\x16\x13f\xc2\xf0\x0b5\x97\xb1\x1a\xb7\x8c\x0c\\@" +
"\x81!u,Y>E\xc4LP@\x08\x1d\x179'" +
">=-lC\x18Rb\xe7=Z+\xddrf\x87" +
"\x9dY\xfbv\xc7\xbdC\xcc\x85\x0ed\x934\xd9\xfa\xdc" +
"\x0f\x14\xe7I\xcdDEJs\xd7\xd5\xea\x9c\xfd\x89\x82" +
"i\x89\xa1\x02\xb7\xf3\xa6\x9d\xd7\xc5@\xa8.G\x1f\x13" +
"\xae\x87\xa6\xe7\x0b\xdb'A\xb8l9n\xe7\x845\xec" +
"\xc0@\x98\x9b\x95\xf4\x18\xf1\xa8\x0eU\x1et\x07\x92%" +
"7'\xe4\xa2\xce\xf9\xc2Uln\xd5\xc2</\x1d\xa5" +
"\x18\xab\xe2Lv\xde\x1c\xc2\xec\x1ds\xcd\x8c\xe3\x9a~" +
"\xb9\x86\xab\xa1\x19rZ\x8c\x8b\xe3%\xd3\x15\x1e\xed\x86" +
"\"\xfa\x01\xa7__/bu\xb4p9\xc6\\\xe1y" +
"\xcan\xee\xedw\x06+\x1a\xf3\xc6\x1b4\x8e\x96<\x95" +
"\xc2\x1f\xaefT\xab\x1e;\x09*~}*\xb4\xea\x8e" +
"Z\xf2kC4\xc9!\xf6\xcd\x08\xd75\x0dQW\xa4" +
"U\x1brl\xc3\xf4Mga0\xaa;P/p\xc3" +
"\x99\x1d\xe2\xae\xees\x1fe\xf4$\x84\xb3\xa3\x8e!\xac" +
"\x837\x85S\xa9`\xbb\x8b^\xd6\xc9\xf1$\x19$x" +
"\xc4s\xec1\xe4~\xe1v\xcb\xe1\x95YK\xac\x81\xfb" +
"\x85\xfdbN\xba \x01\xee\x17v:\xd1!+\xa6\xc8" +
"\x12\xadG\xd2\xb5\xb9U;,\xe2W8\x1a\xc9Yq" +
"\xa3<s\xe8\xd0\xb8\xb9z:\xb2#x\x02@\xbf\x0b" +
"U\xd4\x0bX? \x99\xc0\x11\x00\xdd \xbc\x88\xf53" +
"\x92M\xe38\x80n\x11>\x87\xf5c\x92\x95\xa4\xbeO" +
"\xf8i\xc2c\x8di\x8c\x01\xb0\x93x\x13\x80>G\xf8" +
"Y\xc2\xe3Mi\x8c\x03\xb0{\xf00\x80~\x9a\xf0\x07" +
"\x09ohNc\x03\x00\xbb\x1f\xbf\x06\xa0?H\xf89" +
"\xc2\x13-ij\"\xd8\x97\xf0\x01\x00\xfd\x1c\xe1\xe7\x09" +
"olMc#u\x14R\xff<\xe1\xcf\x12\xde\xd4\x96" +
"\xc6&\x00\xf6\x0c\xba\x00\xfa\xd3\x84_ \xbc9\x91\xc6" +
"f\x00\xf6<>\x01\xa0_ \xfc\x15Tp}\x8b\x92" +
"\xc6\x16\x00\xf6\x03\xdc\x0e\xa0\xbfH\xf8ET\x10[\xd3" +
"\xd8\x0a\xc0~\x88\x9b\x01\xf4\x97\x08~\x1b\x15\x0c<\x87" +
"\xb6\xcc\xa8\x03\x03\xb6Ci\x85M\xa0`\x13``q" +
"\xdf\xf4K\x86\xa03\xbd\x05\x14l!\xcc\xb1\xf3\x04\x02" +
"\x8a\x1a\xc6\xadEz\x19On\xa3fP\xb0\x190\x98" +
"\x14\x9c\xba\x89aPE\xbe\x06\x16\x1c\xd7<\xe1\xd8>" +
"rk0\x97+\xb9\\\xcd\x95k\xc2\x19\xe1\xfaf\x8e" +
"[\x18\x8are:\x00\xe7[\x1b\xc4\x8ah8j\xd5" +
"\x0b+e\x0e2RV\xc3K\xb69'g\xe7\xf9|" +
"\xba8jZ\x09\xcb\xf40\x0e\x0a\xc6\x01\x07\x0a\xdc\xbb" +
"\xdd\x9cC\x04\x05\x1109s\xc7\xaea\\\x018\xa6" +
"\xa24\xb0\"\xd2\xc3\xc4\xffF\x0f\xb3\xaf\xe4\xdf\xa8\xf3" +
"\xe9\xa2%(1K\x9eL\xca\xd5\xb2\x09\x1b\xdcI\x8d" +
"H\xaa\xff&\xeaCR[\xe9\xd4WS\xbd\x04\xc6R" +
"\xdd\xd4\x0b\xc4S\xeb\x8f\x02\x9c*\xd9\xc7lg\xd6\xce" +
"\xd8\xce\xee\xa2\x17\xd8\xce\x903#\\\x0ej^\x9c\xb2" +
"\x9dQ\xee\xe7\x0a\xc14\xfd+\x8c;`\xc0\x09KH" +
"\x05\xc8BX\x85?v\x1f\x9a0\xb6\xdet\xa5Ft" +
"A\xe3\xda\x7f%}\x0a\x03\xedc:5\xf6\xa9%_" +
"F\xa0\xd6\xb4~\xf5\x01\x00\xed\xeb*j\xdf\x8c4\xad" +
"OQ+z^E\xed\xd9H\xd3\xfa\xccf\x00\xed\x9b" +
"*j\xafD\x9a\xd6\x1f\x90\xe6K*j\xaf+\x881" +
"\xb9\x13S\xaf\x9e\x01\xd0.\xaa\xa8\xfd\x98\xb6!\xcam" +
"\x98zs\x12@{CE\xedm\x05k\x9d\x0c\x8e\xb9" +
"N\x9e\xea\xa4\xecOk\x84\xa1\xd2\x9fz\xd5#\x8e\xfa" +
"1P\xb0\x150Y\xe4~\xa1\x9a\x03\xedu\xc6\x06(" +
"\xb3\xa1\xe8x\xb2\xa2\x86\xfdn\x8d|U\xecY\x8eS" +
"\x1c\xe7\xbe\xc0AZ\xc0\xbc\xa8\xe7nU\x02\x89Q\xd3" +
"\xae\xa1\xd5\xa0\xaa\x7f+\xb7\xd4\x92\xaf5b\xf4\xaa\xa3" +
"\xe9h\x84\xc55\x8d\x07\xd5\xd4\x83$%_\xb0\xbb\xe8" +
"\xe9\xb4\xaf\x01\x85v\xaeV\x19\xd7\xc4v\x02\xe8\xe9\x98" +
"\x8a\xfa\xbaX\xa42v\xc5\xa8R\xac&|c,R" +
"\x19\xd7\xc7\xa8\x02\xae#\xfc\x86\x98\x82X)\x8c=1" +
"*t\xd7\x13|3\xa9\xc70,\x8c[cTx\xb7" +
"\x10~+\xe1q%,\x8c\xfd\xb1\x17\x00\xf4[\x09\xdf" +
"Cx\x83\x1a\x16\xc6]r\xd8\xdb\x08\xcf\x12\x9e\x88\x85" +
"\x85q\xaf\xb4\xbf\x87\xf0\xfd\x847\xaaaa\xd4bT" +
"\x18\xf7\x13\xfeY\xc2\x9bbaa<\x12\xa3\xc2x\x17" +
"\xe1\x05\xc2\x9b\xe3aa\x14\xb1/\x00\xe8\x05\xc2}\xc2" +
"[\x1a\xc2\xc2x<\xf6#*\xe0\x84\x9f%\xbc\xf5r" +
"X\x19\xef\x91\xfe\xdcM\xf8}\x84\xb7u\xa5\xb1\x0d\x80" +
"\xdd\x1b\xa3\x82\x7f\x9a\xf0\x07\x09_\xf1\xab4\xae\xa0\xc2" +
".\xfd\xbc\x8f\xf0/\x12\x9elLc\x12\x80=\x12{" +
"\x8d\x0a;\xe1\xe7\x09ooJc;\x15v\x19\x9f\xc7" +
"\x08\x7f\x9a\xf0Ts\x1aS\x00\xec[r^O\x13~" +
"\x81\xf0\x95\xc94\xae\xa4\xc2\x1e\x9b\x04\xd0\x9f#\xfc%" +
"\xc2YK\x1a\x19\x00\xfb~\xec{T\xc1\x09\x7f\x9d\xf0" +
"tk\x1a\xd3\x00\xec\xd5\x18\x1d(\xaf\x13\xfeS\xc2W" +
"\xb5\xa5q\x15\x00{K\xc6\xe7\xc7\x84_&\xfc\xaa\xb5" +
"i\xbc\x0a\x80\xfdR\xea_&\xfc=\xc2\xaf\xfeu\x1a" +
"\xaf\x06`\xbf\x95\xfe\xbcG\xf8\x9f\x09_\xdd\x95\xc6\xd5" +
"\x00\xecO\xb1\xa3\x00\xfa\x071\x15\xc7\xe3\x0a\xa6\xd64" +
"\xa6q\x0d\x00\xfbP\x86\xe7\xcf\xa4\x1e#\xbc#\x9e\xc6" +
"\x0e\x00\x86qr3\x16WQo'\xbc\xf3\x9a4v" +
"\x02\xb0\xb68\xb9\xd3J\xf8j\xc2\xd7f\xd2\xb8\x16\x80" +
"\xad\x8aS8\xd3\x84\xaf#\xbckc\x1a\xbb(;\xe3" +
"t>\xaf%\xfcz\xc2\xafiN\xe35\x00\xac;N" +
"\xe1\xd9H\xf8\x16\xc23-i\xcc\x10q\x8c\xd3\xb4\xb6" +
"\x10~+\xe1\xebZ\xd3\xb8\x8e\xd2P\xe2\xb7\x12\xbe\x87" +
"\xf0\xf5mi\\Oi\x18?\x03\xa0\x0f\x13>F\xf8" +
"\x06\x96\xc6\x0d\x00l4N\xe93F\xf8]\x84_\xbb" +
"6\x8d\xd7\x02\xb0CR\xffN\xc2\x0d\xc276\xa4q" +
"#\x00\xe3R\xdf \xbcHxw2\x8d\xdd\xd4_\xc4" +
")l\x16\xe1sq\x05O\xcd\xf2\xf2\x1d|ZT+" +
"\xcd\xc0,/\x8f\x8b\xa9\xeac\xe0:\xdc y\xa4\x18" +
"\x85\xa7Z\xd6\x9c\x06\xd5\xf4kU\xc3\xae\xf4\xd80\x10" +
"vt\x8b\x04\x18\xe2\xc3\xe6\x80\xe7S\xf7\\U\x18(" +
"\xf0\x13\xdc5j\xd6I\x7f\x0f?\xc1A]\x02D\xd7" +
"\x186\xe9}\xb5n \xe0\xc6\x8c\xe99n\x192\xfa" +
"\xbc\x83\x9e^\x1a4fL$a\xe8\xc2\"\x99R\x95" +
"U\xec\xe6\xb0\xee\x98c\x8b\x09^\xae\x9e\xc3\x19\x8b\xdb" +
"\xc2\xc3\x06P\xb0\x010\xf0MKd\x89\x95\xaa\xc2\xa8" +
"\xaa\xd4\"\xa3\x98\xbe^\xca\xe7\x85\xe7\x0bC\x1a\x8f4" +
"\x0c^E\x00\x03\xc6|w\x85\xe7\x9b\xd3T\xa7\x8dq" +
"\x87\x1b\x13\xa6\xa1\xfa\x85\x9a\x90\xd6\x81\x9adH\x889" +
"\x1f\x93\xf5+T@L\x02\x06\x86Y\x89\xea\xed\xae3" +
"=\xc1\xcbC\x19a\x13\xa7\xa8\xb50\x15^\x8bUb" +
"\x1b\xf1h\x9a\xe8\x98;#\x16\xc6o\x96\x97ua\x89" +
"\x1c\xd2\xd9\x12^\x88`\xb2~\x13V\x19\xb9:g4" +
"Cb\xe0G\x03R0\xf3\x85Y^\x1e\x82\xa4\xc5=" +
"\x0f\x93\xf5{\xc9\xf0\xed\xcc,/\xef5\xaa\x9dO\x90" +
"\xabp\x83\x05T\xa8\x9ey\xf2\\9( \xe3\xd2t" +
"0\x01\x0a&*\xd1\xa1F\x07\xd4\x12\x0dR;\x98*" +
".\xe6\xeb\xe7\x0f&\xeb'U]:\xea\xd8\xce~H" +
"D\xfb\xcd\x9c3],\xf9\xc2@)3i\x07\xd4d" +
"a\x93\xba[\xa0-\\\x1e\x1e\xbc5Y\xd1u\x8cR" +
"N\xb8\xa8\x0b\x8f|\x8cJ\xc4\x8c)fu\xac3\xed" +
"Z\xa8+\xb2a\xac,#,\x94\xecD\xa7d\x1b\xdc" +
"-\xef\xc5\xfa\xae\xa8\xc8& 9?\x8a\x1f\xc5Yj" +
"\x94+\xa4\xe9rY\xa95j\x94\xcdNj\xbbl\x0e" +
"\x9bv\x02\xd06\xf5\xcd\xdc\xa9\xa2ps\xc2\xf6\x97\xd3" +
"\x9bm\x8b\xf6fK\xb5\x11\x94\xe0\xf22Q\xcc\xc9\xbe" +
"\xacU\x0e\xde\x15v\xa6\xab6\xcb\xce\xb4m'\xc0\xa9" +
")W\x88Y^N\xe6L\xbf\\\xedH\x97\xe3\xc9\xd6" +
"eu\x95\x09c\xeb\xcd\xcblC?\xb5\xdc\x01\xb6-" +
"\xf7\x85\xadW\x0a\xa6\xbc\xb3D\x9b\xe2\x98\xae\xf5\xb7'" +
"\xa9k\x9dSQ;\x1b\xe9o\xef\xb9\xa9~\xd5\x8a\x95" +
"\xf6\xf6\xde\x0d\x00\xdai\x15\xb5\x07\xa9\xbdm\x0f\xdb\xdb" +
"\xfb\xe9\xed\xb3*j\x0f\xd7\x1b\xaa\xd4C\xd4\xf3>\xa8" +
"\xa2vN\xc1\xa4_.\xd2^\xaa}G\xabl\xe7)" +
"b\xdd\xd5\xdcMx\xbe[\xeb`'\x1d\xc7\xaa\x95\x85" +
"\xa3\x15\x02\x1e=U\x96\x13\x93-\xcb\x0db\xdf\x95^" +
"\xd8S)TT\xa6\xe4~\xb8EF\xe7\xf10%\xbf" +
":\"S\xf2KG%Yz\xe4&I\x96\xee\x1f\x97" +
"d\xe9^RiH\x9d\x9c\x04\xc0D\xaaL`c\xaa" +
"\xe4RIH\x1d\xa7\xf7\x9aS\xd3\xf4^K\xca\xa4\x9f" +
"\xd6\x94 \xcd\xb6\x14\x8f\x90\xac`\xda\xf1\x1dw\x96\x97" +
"\x01\xa0\xfew2k\xda\xc72\xbe[\xb2\x8f\x05\xf2\xdf" +
"\xaci\x03\x1e;Ut\xcdi\xee\x96\x83\xcao\x16\x12" +
"\xa6},\xf0\x04UO\xee\x02\x96\xeb\x7fg\xcad#" +
"\xf0\x89\xc0rW\x9a\xaf\xfd-\xcd\x07%;Gs6" +
"!9e\x0a#p\x85g\x1a\x82\xce\x19\x93[\x81e" +
"\xce\x98v\x9e\xfaxW\x08\xff\x8at\x8b\x96t\xcc1" +
"m?\xbc\x04\x89|\"\x18Y\xea\x13\xc18\x80VP" +
"Q\xf3\xa9\xc9\x8f\x85\xe9x\x9c\xc0\xa2\x8a\xda\xdd\x94\x8e" +
"\xf10\x1d\xcb'\xea\xc9\xfc\xb1/\x02r%w\x86\xfb" +
"%\x17\xea'z\xe0\x87\x17\xb1\x02\x06,\x87J\xca\"" +
"\xce\xf31v|\xdf\xc7!\x9ecUF&Kk-" +
"\x0e=\x14\x87\xebU\xd4n\x8e\xc4a+My\x8b\x8a" +
"\xda\xad\xcb\x99\xdd\x15\x1c\xd8W\xf2o\x94\xb4K^A" +
"\xca\xd5\x90\xf1\xed\xd9,\x13z}\x98\xd0]#2\xa1" +
"\xd7l\x00H\xda\x8e-\x02\x93\x9a\x06\x9b[\x94(b" +
"\xae\xf6w\"\xc7\xdd\x8f\xfd\x95HO\xfa\xdc\x17Z\x0c" +
"#\xdf\xceS\xb8!\xb1\xbb\xe8i\xa7k\xec\xef\xb8J" +
"}hQUQ\xbf[\x8d\xb0\xbf\xb2J\xf4\xecn\xc2" +
"\xefS#\xec\xef^\x95\xfa\xeb\xb3\x84?L\xb8\x8a!" +
"\xfd{H}\x02@\x7f\x98\xf0\xaf\x13\x1eSC\xfa\xf7" +
"Ui\xff\x1c\xe1\xe7\x09\x8f\xc7B\xfa\xf7\xb8J\xfd\xf2" +
"c\x84?MxC<\xa4\x7f\xdf\x92v\x9e&\xfc\x02" +
"\xe1\x89\x86\x90\xfe=\xaf\xbe\x0c\xa0_ \xfc\x15\xc2\x1b" +
"\x13!\xfd\xfb\x81J\xec\xf5E\xc2/\x12\xde\xd4\x18\xd2" +
"\xbf\x1f\xaa\xc4\x1b^!\xfc\x0d\xc2\x9b\x9bB\xfawI" +
"%\xf6\xfa:\xe1?%\xbc\xa59\xa4\x7foI?\x7f" +
"L\xf8e\xc2[[B\xfa\xf7K\x95\xfa\xf7\xcb\x84\xbf" +
"Gx[kH\xff~\xab\x12\xff\xf8\x0d\xe1\x1f\x10\xbe" +
"\xa2-\xa4\x7f\x7fP\x89&}\xa0\xaa8.\xd9\xdf\x8a" +
"\x90\xfd}(\x87\xfd+\xa97J\xf6\x97\x0c\xd9_\\" +
"\xb2\xb9\x18\xd1\xa4v\xc9\xfe\xdaC\xf6\xd7&\xd9Y+" +
"\xe1\xab%\xfbK\x85\xeco\x95d\xcduR\xce\xd4\x90" +
"\xfd\xad\x97\xacv#\xe1[$\xfb[\x19\xb2\xbf^i" +
"\xe7\x06\xc2o\x91\xec\x8f\x85\xeco\x9b\xd4\xbf\x85\xf0a" +
"\xc9\xfe\xd2!\xfb\x1b\x94,r\x98\xf01\xc9\xfeV\x85" +
"\xecoT\xda\xc9\x12~\xa7d\x7fW\x85\xec\xef@\xec" +
"\xcc<\xf6\xbd\xe6\xea\x90\xfe\x1d\x91\xac\xb6\xce\xbe;n" +
"\x0b\xe9\xdfB\xf6\xdd9\x18\xd2\xbf\xe3\xd2N\x91\xf0\xbb" +
"\x09_\xbb3\xa4\x7fe\x89\xd7Yy\xd7PH\xff\xee" +
"\x91x\x9d}_3\x1c\xd2\xbf\xfb\xa5\x9f5\xf6\x8d\x99" +
"\x90\xfd=\x12\xdb\x00\xa0?H\xf0\xb9\x98\x82\xf2^\xde" +
"\x1as\x05$=/\xd2&sk\x96\x97\xbd}6V" +
"\xbej\xed\x1a\x90\x9fj\xea\x0a\x93.?&\xb2f\x1e" +
"\x12\x05\xdf\xab\xa1\x06\xf7\x0a\x93\x0ew\xd1\x88\xdc\xd0\xd7" +
"j\x9a!\x96\x1c\xab\xc6\x14\xe4M\xbd0`\xb1$\xeb" +
"\xd8y\x92&\x96zO9(\xdcrU#\xfa\xfe)" +
"\x91sv\x0b\xee\xd6\xde\x98r\xdc\x9c\x18r8\xa8\x9e" +
"_\x03Mo\x8c\xbb\xc7\x88\x7f\xd4\x07.\xf2\x92'\xb2" +
"\xdc\x87$M~\x01\xec\xd8(\xab\x9fis\xb4\"L" +
"\xcbq\xfd\xdd\x82\x03\xd6\xc7\xf3]>5e\xe6F\xd1" +
"1\xc2o]Q&\x92\xe7\x9e\xee\xf3\xdc\xb1\xe8\xb8\xd3" +
"\x8e!h\x160o\xa2aa\x1bs!#>r\x91" +
"\x06\x07,\xcb\x99\x8dF\xa8\xbe\x1c\xbeS\xd4\xcd<\xda" +
"5\xba\x18~0Zl4\xc4\xb3\x0eV\xe3\x89\x0be" +
"\x07\x05V\xe3\xbd\xe0\xdd\xa20\xb85\xca\xe7 3." +
"\xf2\xc2\x9e\x8f\xcb\xb8\x0d\xe6|sF\xd4'\xec\x0b\xcf" +
"\xe2CC\xbb`\xc0\xce\xf3|\xc4TE@\xfcCH" +
"\x11.\x92\xdd\x81\x8e?\xe8NG\x13Ff\xf3\x1e\x9a" +
"\xf0\x90[2\xbd\xc8@2\xf7\x96\x94\x14K\x96'\x06" +
"m\x032\xbb-\xd3\x10U<\x91/z\xcb\xfcV\xbf" +
"\xe0\\\xbee\x99\x9d\xfb\xff\xbb\x92\xfeD\x85\xfeJ\xf6" +
"\x9b(\x17\xe5)\x1av)\xdb\xc2\xb6\xb0w\\\x9e\xa2" +
"=\xe1)\xda=\"\xdb\xc2\xf5\x9b\x01N\xe5J\xaeK" +
"\xa4\xa9\xe8\x0a\xc3\xcc\xf9\x02\xd0\x08\x8a\x8e\xe7\x99\x93\x96" +
"\xa8\x9e\xad\xb6!wAr\x8a\x9b\xd6\x15/\xf4\x87+" +
"W\xc7\xb5\x9b\xe3\x05\xd7\xd9\xdb\x01\xb4/\xaa\xa8=\x16" +
"i,\x1e\x1d\x8f\xdcq+\x18\xba\xfe\xd4\xe1\xc8\x1d\xb7" +
"\xaa\x84\x0d\xd63g\x00\xb4\xa7U\xd4.\xd4\xaf\xb3\x9f" +
"\xa7\xb7\x9fSQ{#r\x9d}\x89\x14_WQ\xfb" +
"\x8d\x82\x03\\f\xd7\x82t\xb5h\xae\xf5\xdcq|n" +
"\xddnZ\xa0\x0a\x0f\x1bA\xc1F\x88\\\x83\x0b\xe3v" +
"\xd3\x12\x1e\xd4$V\xe5\xfb\x1b\xa0W\xbd\xedn\xad|" +
"\xf1\xa8\x8apX\xf8\xdc\xb4<\xa8_\x87\xd7\xfesL" +
"\xe5:|9\xc4`^\x16\\)\xf8\xd5\xcf\x83\xc3\x03" +
"\xa1\x0f\x92\xba\xd6\xd6`\x175w\xc3*jc\x0aV" +
"\x97`\x94\xa2\x9dUQ\xbb3\xb2\x04\x07(\x88\xfbU" +
"\xd4>\xabD\xa6\x1c\xa1B\xcb\x8b\xda\xff\x05\x00\x00\xff" +
"\xff1<\xb37"
func RegisterSchema(reg *schemas.Registry) {
reg.Register(&schemas.Schema{
+1
View File
@@ -0,0 +1 @@
{"description":"Synthetic Go-produced v2 current-road and data-only preview fixture; no real road data.","event_base64":"AAAAACQAAAAAAAAAAgABAADyBSoBAAAAjwAAAAAAAAAAAAAAEAAHAAAAoEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIABQABAAAAAAAAAAIAAAAAAHBBgAELJwEAAAAA8gUqAQAAAAEAAAAAAAAAKQAAAAAAAAAAAHBCAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAoCAABhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhAAAAAAAAAAA="}
+52 -2
View File
@@ -1,7 +1,6 @@
package cereal
import (
"bytes"
"capnproto.org/go/capnp/v3"
"encoding/base64"
"encoding/json"
@@ -61,7 +60,58 @@ func TestMapdV1HostWireFixture(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(raw, expected) {
actualMessage, err := capnp.Unmarshal(raw)
if err != nil {
t.Fatal(err)
}
oldMessage, err := capnp.Unmarshal(expected)
if err != nil {
t.Fatal(err)
}
actualRoot, err := actualMessage.Root()
if err != nil {
t.Fatal(err)
}
oldRoot, err := oldMessage.Root()
if err != nil {
t.Fatal(err)
}
// Appended default fields can enlarge storage without changing old values.
equal, err := capnp.Equal(actualRoot, oldRoot)
if err != nil || !equal {
t.Fatal("provider wire differs from shared host fixture; review both schemas")
}
}
func TestPreviewHostWireFixture(t *testing.T) {
data, err := os.ReadFile("testdata/mapd_preview_wire.json")
if err != nil {
t.Fatal(err)
}
var fixture struct {
EventBase64 string `json:"event_base64"`
}
if err := json.Unmarshal(data, &fixture); err != nil {
t.Fatal(err)
}
raw, err := base64.StdEncoding.DecodeString(fixture.EventBase64)
if err != nil {
t.Fatal(err)
}
msg, err := capnp.Unmarshal(raw)
if err != nil {
t.Fatal(err)
}
event, err := log.ReadRootEvent(msg)
if err != nil {
t.Fatal(err)
}
out, err := event.MapdOut()
if err != nil {
t.Fatal(err)
}
boundary, err := out.PreviewBoundaryId()
if err != nil || !event.Valid() || out.SampleVersion() != 2 || out.SpeedLimit() != 20 || out.PreviewSpeedLimit() != 15 || out.PreviewDistance() != 60 || len(boundary) != 64 || out.PreviewWayId() != 2 {
t.Fatal("provider preview differs from shared host fixture")
}
}
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"crypto/sha256"
"fmt"
"math"
"pfeifer.dev/mapd/cereal/custom"
"pfeifer.dev/mapd/maps"
)
const previewHorizonM = 500
const previewMaxWays = 64
type LimitPreview struct {
Speed float32
Distance float32
BoundaryID string
WayID int64
}
// A preview follows only a unique legal continuation, never a guessed turn.
func (s *State) previewLimit() LimitPreview {
if !s.ShadowOnly || s.SnapshotID == "" || s.CurrentWay.SelectionType != custom.WaySelectionType_current || s.MatchedLimitMps <= 0 {
return LimitPreview{}
}
way, forward, position := s.CurrentWay.Way, s.CurrentWay.OnWay.IsForward, s.Position
distance := float32(0)
seen := map[int64]bool{way.Id(): true}
for range previewMaxWays {
_, end := way.GetStartEnd(forward)
if distance+position.DistanceTo(end) > previewHorizonM {
return LimitPreview{}
}
segment, found, err := way.DistanceToNode(position, forward, end)
if err != nil || !found || segment < 0 || math.IsNaN(float64(segment)) || math.IsInf(float64(segment), 0) {
return LimitPreview{}
}
distance += segment
box := s.Data.OverlapBox()
if distance <= 0 || distance > previewHorizonM || !box.PosInside(end) {
return LimitPreview{}
}
matches, err := way.MatchingWays(&s.Data, end)
if err != nil {
return LimitPreview{}
}
legal := map[int64]maps.Way{}
for _, next := range matches {
first, last := next.GetStartEnd(true)
if first.Equals(last) && !next.OneWay() {
return LimitPreview{} // A two-way loop has two possible entry directions.
}
if next.Id() > 0 && (next.IsForwardFrom(end) || !next.OneWay()) {
legal[next.Id()] = next
}
}
if len(legal) != 1 {
return LimitPreview{}
}
var next maps.Way
for _, candidate := range legal {
next = candidate
}
if seen[next.Id()] {
return LimitPreview{}
}
nextForward := next.IsForwardFrom(end)
road := CurrentWay{Way: next, OnWay: maps.OnWayResult{IsForward: nextForward}}
speed := road.EffectiveMaxSpeed()
if speed <= 0 || math.IsNaN(speed) || math.IsInf(speed, 0) || speed > math.MaxFloat32 {
return LimitPreview{}
}
if math.Abs(speed-float64(s.MatchedLimitMps)) > .02 {
key := fmt.Sprintf("%s:%d:%d:%t:%.7f:%.7f", s.SnapshotID, way.Id(), next.Id(), nextForward, end.Lat(), end.Lon())
return LimitPreview{float32(speed), distance, fmt.Sprintf("%x", sha256.Sum256([]byte(key))), next.Id()}
}
seen[next.Id()] = true
way, forward, position = next, nextForward, end
}
return LimitPreview{}
}
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"crypto/sha256"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"testing"
"time"
"capnproto.org/go/capnp/v3"
"pfeifer.dev/mapd/cereal"
"pfeifer.dev/mapd/cereal/custom"
"pfeifer.dev/mapd/cereal/offline"
"pfeifer.dev/mapd/maps"
m "pfeifer.dev/mapd/math"
)
func previewState(t *testing.T, specs []syntheticWay, index int, forward bool, latitude float64) State {
t.Helper()
tile := packedRoadTile(t, specs)
return State{ShadowOnly: true, SnapshotID: "verified-snapshot", Data: tile,
Position: m.NewPosition(latitude, -97.9), MatchedLimitMps: float32(specs[index].speed),
CurrentWay: CurrentWay{Way: tile.Ways.At(index), OnWay: maps.OnWayResult{IsForward: forward}, SelectionType: custom.WaySelectionType_current}}
}
func previewRoads() []syntheticWay {
return []syntheticWay{
{1, 20, 35.1, -97.9, 35.101, -97.9, false, 2},
{2, 20, 35.101, -97.9, 35.102, -97.9, false, 2},
{3, 10, 35.102, -97.9, 35.103, -97.9, false, 2},
}
}
func TestPreviewRawDistanceAndStableBoundaryAcrossApproachWays(t *testing.T) {
s := previewState(t, previewRoads(), 0, true, 35.1005)
a := s.previewLimit()
s = previewState(t, previewRoads(), 1, true, 35.1015)
b := s.previewLimit()
if a.Speed != 10 || a.WayID != 3 || a.BoundaryID == "" || a.BoundaryID != b.BoundaryID || math.Abs(float64(a.Distance-b.Distance)-111.2) > 1 {
t.Fatalf("unexpected road preview: %+v %+v", a, b)
}
s.Position = m.NewPosition(35.1014, -97.9)
if s.previewLimit().Distance <= b.Distance {
t.Fatal("raw distance was held at a previous minimum")
}
s.SnapshotID = "changed-snapshot"
if s.previewLimit().BoundaryID == b.BoundaryID {
t.Fatal("boundary identity survived a different snapshot")
}
}
func TestPreviewDirectionUnknownLimitsBranchesAndHorizon(t *testing.T) {
s := previewState(t, previewRoads(), 2, false, 35.1025)
if p := s.previewLimit(); p.Speed != 20 || p.WayID != 2 || p.Distance <= 0 {
t.Fatalf("reverse traversal failed: %+v", p)
}
for _, mode := range []string{"branch", "unknown", "oneway", "horizon", "unqualified", "unverified"} {
t.Run(mode, func(t *testing.T) {
roads := previewRoads()
index, forward, latitude := 0, true, 35.1005
switch mode {
case "branch":
roads = append(roads, syntheticWay{4, 30, 35.101, -97.9, 35.102, -97.899, false, 2})
case "unknown":
roads[1].speed = 0
case "oneway":
roads[1].oneWay = true
index, forward, latitude = 2, false, 35.1025
case "horizon":
roads[0].lat1 = 35.09
latitude = 35.091
}
s := previewState(t, roads, index, forward, latitude)
if mode == "unqualified" {
s.CurrentWay.SelectionType = custom.WaySelectionType_possible
}
if mode == "unverified" {
s.SnapshotID = ""
}
if p := s.previewLimit(); p != (LimitPreview{}) {
t.Fatalf("uncertain preview escaped: %+v", p)
}
})
}
}
func TestPreviewClosedWayRequiresOneLegalDirection(t *testing.T) {
for _, tc := range []struct {
name string
oneWay, branch, wantPreview bool
}{
{"one-way loop", true, false, true},
{"two-way loop", false, false, false},
{"two-way loop plus continuation", false, true, false},
} {
t.Run(tc.name, func(t *testing.T) {
roads := []syntheticWay{
{1, 20, 35.1, -97.9, 35.101, -97.9, true, 2},
{2, 10, 35.101, -97.9, 35.102, -97.9, tc.oneWay, 2},
}
if tc.branch {
roads = append(roads, syntheticWay{3, 15, 35.101, -97.9, 35.102, -97.899, false, 2})
}
decoded, err := capnp.UnmarshalPacked(packedRoadTileBytes(t, roads))
if err != nil {
t.Fatal(err)
}
root, err := decoded.Root()
if err != nil {
t.Fatal(err)
}
msg, _, err := capnp.NewMessage(capnp.SingleSegment(nil))
if err != nil {
t.Fatal(err)
}
if err := msg.SetRoot(root); err != nil {
t.Fatal(err)
}
raw, err := offline.ReadRootOffline(msg)
if err != nil {
t.Fatal(err)
}
ways, err := raw.Ways()
if err != nil {
t.Fatal(err)
}
loop := ways.At(1)
loop.SetMaxLon(-97.898)
nodes, err := loop.NewNodes(4)
if err != nil {
t.Fatal(err)
}
for i, p := range [][2]float64{{35.101, -97.9}, {35.102, -97.9}, {35.102, -97.899}, {35.101, -97.9}} {
nodes.At(i).SetLatitude(p[0])
nodes.At(i).SetLongitude(p[1])
}
data, err := msg.MarshalPacked()
if err != nil {
t.Fatal(err)
}
tile := maps.ReadOffline(data)
if !tile.Loaded {
t.Fatal("closed road tile rejected")
}
s := State{ShadowOnly: true, SnapshotID: "verified-snapshot", Data: tile,
Position: m.NewPosition(35.1005, -97.9), MatchedLimitMps: 20,
CurrentWay: CurrentWay{Way: tile.Ways.At(0), OnWay: maps.OnWayResult{IsForward: true}, SelectionType: custom.WaySelectionType_current}}
p := s.previewLimit()
if tc.wantPreview {
if p.Speed != 10 || p.WayID != 2 || p.Distance <= 0 || p.BoundaryID == "" {
t.Fatalf("legal loop preview missing: %+v", p)
}
} else if p != (LimitPreview{}) {
t.Fatalf("ambiguous loop produced a preview: %+v", p)
}
})
}
}
func TestShadowPreviewSharesFixFreshnessWithoutControlOutputs(t *testing.T) {
roads := []syntheticWay{{1, 20, 35.149, -97.9, 35.151, -97.9, false, 2}, {2, 10, 35.151, -97.9, 35.153, -97.9, false, 2}}
data := packedRoadTileBytes(t, roads)
id := strings.Repeat("a", 64)
dir := filepath.Join(t.TempDir(), id)
area := maps.Area{Box: m.Box{MinPos: m.NewPosition(35, -98), MaxPos: m.NewPosition(35.25, -97.75)}}
path := maps.GenerateBoundsFileName(area, maps.OfflineSettings{OutputDirectory: dir})
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
rel, err := filepath.Rel(dir, path)
if err != nil {
t.Fatal(err)
}
load := maps.SnapshotLoader(dir, maps.SnapshotReceipt{Tiles: []maps.SnapshotTile{{Path: rel, Size: int64(len(data)), SHA256: fmt.Sprintf("%x", sha256.Sum256(data))}}})
s := State{ShadowOnly: true, SnapshotID: id}
s.Init()
boot := uint64(10_100_000_000)
s.bootNow = func() uint64 { return boot }
sample := syntheticSample(t, cereal.GpsSourceExternal, 10_000_000_000, 1, true, true)
s.ProcessGps(sample, true, load, time.Unix(0, 0))
sample.SourceChanged, sample.FixMonoTime = false, 10_050_000_000
s.ProcessGps(sample, true, load, time.Unix(0, 1))
event, output := decodedOutput(t, &s)
boundary, _ := output.PreviewBoundaryId()
if !event.Valid() || output.SpeedLimit() != 20 || output.PreviewSpeedLimit() != 10 || boundary == "" || output.PreviewDistance() <= 0 || output.NextSpeedLimit() != 0 || output.SuggestedSpeed() != 0 {
t.Fatalf("shadow preview: valid=%v status=%v way=%v limit=%v preview=%+v", event.Valid(), output.RoadStatus(), output.WaySelectionType(), output.SpeedLimit(), s.LimitPreview)
}
boot = 10_600_000_000
event, output = decodedOutput(t, &s)
boundary, _ = output.PreviewBoundaryId()
if event.Valid() || output.PreviewSpeedLimit() != 0 || boundary != "" {
t.Fatal("expired GPS retained an upcoming limit")
}
}
+12 -1
View File
@@ -11,6 +11,7 @@ import (
"os"
"reflect"
"runtime"
"slices"
"strconv"
"strings"
"syscall"
@@ -162,7 +163,6 @@ func GenerateOffline(s OfflineSettings) error {
MaxSpeedBackward: ParseMaxSpeed(tags["maxspeed:backward"]),
MaxSpeedAdvisory: ParseMaxSpeed(tags["maxspeed:advisory"]),
Lanes: uint8(lanes),
OneWay: tags["oneway"] == "yes",
Id: int64(way.ID),
HighwayClass: HighwayClassFromTag(tags["highway"]),
@@ -191,6 +191,17 @@ func GenerateOffline(s OfflineSettings) error {
tmpWay.Nodes[i].Latitude = n.Lat
tmpWay.Nodes[i].Longitude = n.Lon
}
switch tags["oneway"] {
case "yes", "1":
tmpWay.OneWay = true
case "-1":
tmpWay.OneWay = true
slices.Reverse(tmpWay.Nodes)
tmpWay.MaxSpeedForward, tmpWay.MaxSpeedBackward = tmpWay.MaxSpeedBackward, tmpWay.MaxSpeedForward
tmpWay.MaxSpeedForwardConditional, tmpWay.MaxSpeedBackwardConditional = tmpWay.MaxSpeedBackwardConditional, tmpWay.MaxSpeedForwardConditional
case "":
tmpWay.OneWay = tags["junction"] == "roundabout" || tags["highway"] == "motorway"
}
tmpWay.Box.MinPos = m.NewPosition(minLat, minLon)
tmpWay.Box.MaxPos = m.NewPosition(maxLat, maxLon)
scannedWays = append(scannedWays, tmpWay)
+78
View File
@@ -0,0 +1,78 @@
package maps
import (
"math"
"os"
"path/filepath"
"testing"
m "pfeifer.dev/mapd/math"
)
func TestGeneratorPreservesFixedOneWayDirectionAndSpeedTags(t *testing.T) {
for _, tc := range []struct {
name, oneWay, highway, junction string
wantOneWay, reverse bool
}{
{"ordinary", "", "residential", "", false, false},
{"yes", "yes", "residential", "", true, false},
{"numeric", "1", "residential", "", true, false},
{"reverse", "-1", "residential", "", true, true},
{"roundabout", "", "residential", "roundabout", true, false},
{"motorway", "", "motorway", "", true, false},
{"roundabout explicit no", "no", "residential", "roundabout", false, false},
{"roundabout explicit zero", "0", "residential", "roundabout", false, false},
{"motorway explicit no", "no", "motorway", "", false, false},
{"motorway explicit zero", "0", "motorway", "", false, false},
{"motorway link", "", "motorway_link", "", false, false},
{"reversible", "reversible", "residential", "roundabout", false, false},
{"alternating", "alternating", "motorway", "", false, false},
} {
t.Run(tc.name, func(t *testing.T) {
forwardRule, backwardRule := "15 @ (Mo-Fr 08:00-09:00)", "25 @ (Sa 10:00-11:00)"
tags := []string{"highway", tc.highway, "maxspeed", "30 mph",
"maxspeed:forward", "10", "maxspeed:backward", "20",
"maxspeed:forward:conditional", forwardRule, "maxspeed:backward:conditional", backwardRule}
if tc.oneWay != "" {
tags = append(tags, "oneway", tc.oneWay)
}
if tc.junction != "" {
tags = append(tags, "junction", tc.junction)
}
root := t.TempDir()
pbf := filepath.Join(root, "road.osm.pbf")
if err := os.WriteFile(pbf, syntheticPBF(tags...), 0600); err != nil {
t.Fatal(err)
}
output := filepath.Join(root, "tiles")
if err := GenerateOffline(OfflineSettings{InputFile: pbf, OutputDirectory: output, Overlap: .001,
Box: m.Box{MinPos: m.NewPosition(35, -98), MaxPos: m.NewPosition(35.25, -97.75)}}); err != nil {
t.Fatal(err)
}
tile, err := FindWaysAroundPositionIn(m.NewPosition(35.15, -97.9), output)
if err != nil || !tile.Loaded || tile.Ways.Len() != 1 {
t.Fatalf("generated road not loaded: %v", err)
}
way := tile.Ways.At(0)
if way.Nodes.Len() != 2 {
t.Fatalf("generated road has %d nodes", way.Nodes.Len())
}
first, last := 35.1, 35.2
forwardSpeed, backwardSpeed := 10./3.6, 20./3.6
if tc.reverse {
first, last = last, first
forwardSpeed, backwardSpeed = backwardSpeed, forwardSpeed
forwardRule, backwardRule = backwardRule, forwardRule
}
firstNode, lastNode := way.Nodes.At(0), way.Nodes.At(1)
if way.Id() != 42 || way.OneWay() != tc.wantOneWay ||
math.Abs(firstNode.Lat()-first) > 1e-9 || math.Abs(lastNode.Lat()-last) > 1e-9 ||
math.Abs(way.MaxSpeed()-13.4112) > 1e-6 ||
math.Abs(way.MaxSpeedForward()-forwardSpeed) > 1e-5 || math.Abs(way.MaxSpeedBackward()-backwardSpeed) > 1e-5 ||
way.ConditionalMaxSpeedRaw(true) != forwardRule || way.ConditionalMaxSpeedRaw(false) != backwardRule {
t.Fatalf("generated road lost direction or speed metadata: oneWay=%v forward=%v backward=%v",
way.OneWay(), way.MaxSpeedForward(), way.MaxSpeedBackward())
}
})
}
}
+12 -4
View File
@@ -309,17 +309,25 @@ func pbfBlock(kind string, raw []byte) []byte {
data = append(data, header...)
return append(data, blob...)
}
func syntheticPBF() []byte {
func syntheticPBF(tags ...string) []byte {
header := pbfBytesField(nil, 4, []byte("OsmSchema-V0.6"))
header = pbfBytesField(header, 5, []byte("LocationsOnWays"))
if len(tags) == 0 {
tags = []string{"highway", "residential", "maxspeed", "30 mph"}
}
var table []byte
for _, value := range []string{"", "highway", "residential", "maxspeed", "30 mph"} {
for _, value := range append([]string{""}, tags...) {
table = pbfBytesField(table, 1, []byte(value))
}
var keys, values []uint64
for i := 0; i < len(tags); i += 2 {
keys = append(keys, uint64(i+1))
values = append(values, uint64(i+2))
}
var way []byte
way = pbfVarintField(way, 1, 42)
way = pbfBytesField(way, 2, pbfPacked(1, 3))
way = pbfBytesField(way, 3, pbfPacked(2, 4))
way = pbfBytesField(way, 2, pbfPacked(keys...))
way = pbfBytesField(way, 3, pbfPacked(values...))
way = pbfBytesField(way, 8, pbfPacked(protowire.EncodeZigZag(1), protowire.EncodeZigZag(1)))
way = pbfBytesField(way, 9, pbfPacked(protowire.EncodeZigZag(351000000), protowire.EncodeZigZag(1000000)))
way = pbfBytesField(way, 10, pbfPacked(protowire.EncodeZigZag(-979000000), protowire.EncodeZigZag(0)))
+9 -6
View File
@@ -149,8 +149,12 @@ func (w *Way) IsForwardFrom(matchNode m.Position) bool {
return true
}
lastNode := w.Nodes.At(w.Nodes.Len()-1)
return !lastNode.Equals(matchNode)
lastNode := w.Nodes.At(w.Nodes.Len() - 1)
if lastNode.Equals(matchNode) {
// A closed one-way can be entered at its repeated endpoint in stored order.
return w.OneWay() && matchNode.Equals(w.Nodes.At(0))
}
return true
}
func IsForward(lineStart m.Position, lineEnd m.Position, bearing float64) bool {
@@ -431,7 +435,7 @@ func (w *Way) DistanceFrom(pos m.Position) (DistanceResult, error) {
minIdx := 0
for i := 0; i < w.Nodes.Len()-1; i++ {
nodeStart := w.Nodes.At(i)
nodeEnd := w.Nodes.At(i+1)
nodeEnd := w.Nodes.At(i + 1)
line := m.Line{Start: nodeStart, End: nodeEnd}
linePosition := line.NearestPosition(pos)
distance := pos.DistanceTo(linePosition.Pos)
@@ -447,7 +451,7 @@ func (w *Way) DistanceFrom(pos m.Position) (DistanceResult, error) {
onWayDistance := minNodeStart.DistanceTo(minLinePosition.Pos)
for i := range minIdx {
nodeStart := w.Nodes.At(i)
nodeEnd := w.Nodes.At(i+1)
nodeEnd := w.Nodes.At(i + 1)
onWayDistance += nodeStart.DistanceTo(nodeEnd)
}
@@ -491,8 +495,7 @@ func (w *Way) MatchingWays(offlineMaps *Offline, matchNode m.Position) ([]Way, e
continue
}
box := way.Box()
if box.Equals(w.Box()) {
if way.Id() == w.Id() {
continue
}
+45
View File
@@ -55,3 +55,48 @@ func TestBearingAlignmentIsAnUndirectedNonnegativePenalty(t *testing.T) {
}
}
}
func TestWayEntryDirectionForOpenAndClosedWays(t *testing.T) {
start, end := m.NewPosition(35.15, -97.9), m.NewPosition(35.151, -97.9)
for _, tc := range []struct {
name string
closed, oneWay, fromEnd, wantForward bool
}{
{"open start", false, true, false, true},
{"open end", false, true, true, false},
{"closed one-way", true, true, false, true},
{"closed two-way", true, false, false, false},
} {
t.Run(tc.name, func(t *testing.T) {
_, seg, err := capnp.NewMessage(capnp.SingleSegment(nil))
if err != nil {
t.Fatal(err)
}
raw, err := offline.NewRootWay(seg)
if err != nil {
t.Fatal(err)
}
raw.SetOneWay(tc.oneWay)
positions := []m.Position{start, end}
if tc.closed {
positions = append(positions, start)
}
nodes, err := raw.NewNodes(int32(len(positions)))
if err != nil {
t.Fatal(err)
}
for i, p := range positions {
nodes.At(i).SetLatitude(p.Lat())
nodes.At(i).SetLongitude(p.Lon())
}
from := start
if tc.fromEnd {
from = end
}
way := NewWay(raw)
if got := way.IsForwardFrom(from); got != tc.wantForward {
t.Fatalf("entry direction: got %v want %v", got, tc.wantForward)
}
})
}
}
+7 -1
View File
@@ -24,7 +24,7 @@ type syntheticWay struct {
// The actual offline Capnp representation is packed, decoded, and passed to
// State; tests do not mock GetCurrentWay or BuildMessage.
func packedRoadTile(tb testing.TB, specs []syntheticWay) maps.Offline {
func packedRoadTileBytes(tb testing.TB, specs []syntheticWay) []byte {
tb.Helper()
msg, seg, err := capnp.NewMessage(capnp.SingleSegment(nil))
if err != nil {
@@ -65,6 +65,12 @@ func packedRoadTile(tb testing.TB, specs []syntheticWay) maps.Offline {
if err != nil {
tb.Fatal(err)
}
return data
}
func packedRoadTile(tb testing.TB, specs []syntheticWay) maps.Offline {
tb.Helper()
data := packedRoadTileBytes(tb, specs)
parsed := maps.ReadOffline(data)
if !parsed.Loaded {
tb.Fatal("packed tile rejected")
+9
View File
@@ -46,6 +46,7 @@ type State struct {
SourceGeneration uint64
ProducerSession uint64
MatchedLimitMps float32
LimitPreview LimitPreview
lastMapLoadAttempt time.Time
bootNow func() uint64
// The fix the current road result was computed from. The loop runs at
@@ -90,6 +91,7 @@ func (s *State) clearRoadDerived() {
s.TargetVelocities = nil
s.MapCurveSpeed = 0
s.MatchedLimitMps = 0
s.LimitPreview = LimitPreview{}
s.NextHazard.Reset()
s.NextAdvisorySpeed.Reset()
s.clearSpeedLimitEvidence()
@@ -255,6 +257,7 @@ func (s *State) ProcessGps(sample cereal.GpsSample, success bool, load MapLoader
s.MatchedLimitMps = float32(limit)
s.RoadStatus = custom.MapdOut_SampleStatus_matchedLimit
}
s.LimitPreview = s.previewLimit()
}
func (s *State) SuggestedSpeed() float32 {
@@ -361,6 +364,12 @@ func (s *State) buildMessageAt(now uint64) (*capnp.Message, error) {
output.SetRoadName(s.CurrentWay.Way.Name())
output.SetSpeedLimit(s.MatchedLimitMps)
if s.LimitPreview.BoundaryID != "" {
output.SetPreviewSpeedLimit(s.LimitPreview.Speed)
output.SetPreviewDistance(s.LimitPreview.Distance)
output.SetPreviewBoundaryId(s.LimitPreview.BoundaryID)
output.SetPreviewWayId(s.LimitPreview.WayID)
}
output.SetConditionalSpeedLimit(s.CurrentWay.ConditionalMaxSpeedRaw())
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
.github export-ignore
@@ -0,0 +1 @@
__pycache__/
@@ -1,35 +1,53 @@
# BluePilot angle source
# Credits
Adapted from BluePilotDev/bluepilot bp-7.0 at
[e1d051d7ba270261b4455068bd68f1a58db15a4a](https://github.com/BluePilotDev/bluepilot/commit/e1d051d7ba270261b4455068bd68f1a58db15a4a),
principally Alan Polk, with John Christman, Jacob Neulight, Nathan Ingraham,
Praeuner, tonesto7, Haibin Wen and other BluePilot, sunnypilot and comma contributors.
Source paths: opendbc/sunnypilot/car/ford/lateral_angle_ext.py,
fordcan_ext.py, human_turn.py and opendbc/safety/modes/ford.h.
The simultaneous upstream LICENSE and LICENSE.md notices are retained.
This software is licensed under a custom license requiring permission for use.
This project uses software from Haibin Wen and SUNNYPILOT LLC and is licensed under a custom license requiring permission for use.
The BluePilot Ford lateral library: Ford's four-signal lateral control in curvature and angle
modes, the CAN messages that carry it, and the panda safety that checks it. Everything here is
under the MIT terms in [LICENSE](LICENSE).
This is a StarPilot adapter to the September 2026 proposed library shape.
It is not a released BluePilot library, tag, or claim of BluePilot validation.
The existing curvature controller remains StarPilot-owned. Pinion curvature,
curvature-primary settings and the unsafe native reset bypass are not imported.
## Contributors
The exact upstream root notices are additionally retained as upstream-root-LICENSE
and upstream-root-LICENSE.md; LICENSE/ LICENSE.md retain the opendbc notices.
ORIGIN_HISTORY.tsv records the inspected path history with commit IDs/authors/dates.
The private compact upstream root clone preserves its reachable root history.
Pre-conversion opendbc submodule objects are not included in that root history.
- **Alan Polk (`alan-polk`)**: principal author of the curvature and angle controllers, the
human-turn behavior, the angle-mode side channel and its panda safety, and the lane centering
trim.
- **tonesto7**: the CAN message builders and BluePilot's early releases.
- **John Christman**: lateral integration, platform data, and the angle-mode anti-stall work.
- **Jacob Neulight**: the steering-pinion curvature measurement and its panda safety, and the
shadow-curvature measurement.
- **Blue Genie (`blue-genie`)**: the split of BluePilot's lateral control into opendbc that this
library grew from.
- **ghbarker**: the lane centering trim's confidence blend, fallback, filter and rate limit.
- **Nathan Ingraham**: the angle-mode high-speed damping adjustment.
- **Praeuner**: the angle-mode damping range.
- **OneFast440**: the early Ford safety declarations.
- **nitrobass24**: the library's structure, its inputs and settings plumbing, and the safety tests
and CI.
Angle lane-centering trim is separately adapted from BluePilot bp-dev
[e22afa6be9b881fa784c92ebb316db47728a3d81](https://github.com/BluePilotDev/bluepilot/commit/e22afa6be9b881fa784c92ebb316db47728a3d81),
`opendbc/sunnypilot/car/ford/lane_center_trim.py` (blob
`5eedebd79e99493624f9d6af0750d1dd5713306c`). Alan Polk authored the initial
trim, model-position fallback, correction slew and default-strength changes;
ghbarker contributed reference/NaN guards and tests. The source explicitly credits
StarPilot/u/jc01rho for curvature-domain placement/geometry. The core adaptation
adds immutable host projection and conservative stale/driver-pressure suppression.
The pinned bp-7.0 controller baseline and default-disabled policy are unchanged.
Both simultaneous root/opendbc MIT and Custom MIT notices also exist unchanged at
this trim revision; all retained notices and permission acknowledgments apply
without assigning an exclusive license or claiming a new permission grant.
## Third-party code
### comma.ai's opendbc
MIT, Copyright (c) 2020, Comma.ai, Inc.
- `can/fordcan_ext.py`: `create_lat_ctl_msg` and `create_lat_ctl2_msg`, comma's Ford lateral
message builders from `opendbc/car/ford/fordcan.py`, with ramp-type and precision parameters
added.
- `safety/lateral.h`: the measured-table rate check, curvature-error band and lateral-accel cap,
from comma's earlier checks in `opendbc/safety/lateral.h`, and `ford_bp_curvature_cmd_checks`,
adapted from comma's `steer_curvature_cmd_checks`.
- `safety/ford.h`: the rate-of-change shape of the path angle and path offset checks, the
curvature-error band in the shadow-curvature check, and the message-rate formula.
- `core/lateral_curv_ext.py`: `apply_ford_curvature_limits_ext`, adapted from comma's
`apply_ford_curvature_limits`, with its road-roll and lateral-accel constants.
- Small pieces: the message packing and the yaw-rate curvature measurement in
`core/carcontroller_ext.py`, and the model time-index formula in `core/values_ext.py`.
- The BluePilot safety tests start from comma's Ford safety test skeletons.
### StarPilot
MIT, Copyright (c) 2026, firestar5683 and StarPilot contributors
- `core/lane_center_trim.py`: the lane-centering core (the correction formula, lookahead clip,
lane-line probability, std and width gates, the 0.004 correction limit and the lane-line
handling), ported from StarPilot's `selfdrive/controls/lib/lane_centering.py`
(`LaneCenteringController`, originally by jc01rho). The confidence blend, fallback, filter and
rate limit are BluePilot's.
@@ -0,0 +1,83 @@
# Bringing BluePilot lateral to a fork
How an openpilot fork takes BluePilot's Ford lateral control. There are two ways to do it:
1. **Use a release on your own** (preferred). Pin a release of this library and write your own hooks and settings against [HOSTS.md](HOSTS.md). BluePilot isn't in the loop and nothing waits on BluePilot: you consume lateral code that has been driven on Fords.
2. **Work with BluePilot.** BluePilot helps develop your hooks, keeps them in this repo, and tests the library on your fork in its CI, every PR and every night.
Either way the library is the same, and the same rules apply to it (below).
**This is a modified Ford safety model.** The panda safety allows signals and ranges that upstream's Ford safety blocks. Your fork decides whether to ship it, and whether BluePilot lateral is on or off by default for your users.
## The rules for either way
- **Pin a release, unmodified.** The library sits at `<pkg>/bluepilot_lateral/` in your opendbc (`<pkg>` is its package name) as one of:
- a **git submodule** pointing at a release tag, if your fork uses submodules; updating is moving the pointer;
- a **stamped copy** made with `tools/vendor.sh`, if it doesn't (see "The stamp tools").
- **Don't edit inside the library folder**, carry it as a git subtree with its history, or copy parts of it elsewhere. A fix to the library is a PR to this repo; once it's released, every fork gets it.
- **Everything else is yours:** the hooks in your opendbc, the code in your openpilot that fills the library's inputs every frame, and your settings UI.
## Option 1: use a release on your own
1. Pick a release from this repo's releases. Each one says what changed in the lateral control, the CAN messages and the panda safety, and what was validated on a car.
2. Mount it (submodule or stamped copy, above).
3. Write the hooks in your opendbc. [HOSTS.md](HOSTS.md) has the contract: the hook sites, the per-frame inputs and settings, the startup safety bits, and the host bindings to define if your safety function names differ from upstream opendbc's. Each release attaches the hook patch for sunnypilot/opendbc as a worked example.
4. Write your adapter: the small module that hands the library your fork's types (`hosts/sunnypilot/` is the example to copy). It lives in your fork, outside the library folder.
5. Run the library's tests in your tree ([HOSTS.md](HOSTS.md), "Running the tests") and drive it before you ship it.
6. To update, move to a newer release and rerun your tests.
BluePilot doesn't review or approve any of it. Questions and bugs in the library are welcome as issues in this repo.
## Option 2: work with BluePilot
For a fork that wants BluePilot to help with the hooks and keep them working. Open an issue in this repo, "Host: <fork>": where the fork's source and integration branch are, how it carries its opendbc, and how it takes outside changes. From there, this repo keeps for that fork:
- **its adapter**, `hosts/<fork>/`, plus host bindings if its safety names differ from upstream's;
- **its hooks**, `hosts/<fork>/hooks.patch`, until the fork carries them;
- **`hosts/<fork>/host.sh`**: `tree <dir>` builds the fork's opendbc at a pinned commit with the hooks and the library, placed by `vendor.sh`; `test <dir>` runs the library tests, the fork's safety suite with the BluePilot safety tests, and coverage; `make <dir>` writes `hooks.patch` back after a hook is edited in `<dir>/host`;
- **a CI job** that runs `host.sh tree` and `test` on every PR to this repo, so a library change that would break the fork fails here first;
- **a nightly check** that tries the fork's latest integration branch: a pass moves the pinned commit, a failure opens one issue saying which step broke. Once the fork carries the hooks it tests the fork's tree as it is, and runs `check_vendored.sh` on its copy.
The steps:
1. Develop and test the hooks and the fork's side in the fork, together.
2. Add the adapter, `hooks.patch`, `host.sh`, the CI job and the nightly check here, in one PR.
3. Release the library with them.
4. BluePilot opens the PR to the fork: the hooks, its settings UI and the library at that release. Later releases arrive the same way, as PRs the fork takes when it chooses.
An adapter lives in this repo only if this repo's CI tests the library on that fork.
## The stamp tools
For a fork that takes no submodules. Both run from a clone of this repo that has the release tag (`git fetch --tags`).
**`tools/vendor.sh <tag> <folder>`** makes `<folder>` exactly the library at `<tag>`:
- it empties the folder first, so files a release removed don't linger;
- it copies the release's files, without the library's own CI (`.github/`);
- it writes `<folder>/VERSION`: the tag and the commit, e.g. `v1.2.3 0a1b2c3d...`.
```bash
git clone https://github.com/BluePilotDev/bluepilot-lateral.git
cd bluepilot-lateral
tools/vendor.sh <tag> <fork checkout>/<path to its opendbc package>/bluepilot_lateral
```
Commit the folder in the fork. To update, run it again with the new tag: the diff shows exactly what changed between the two releases.
**`tools/check_vendored.sh <folder>`** reads `<folder>/VERSION` and compares the folder with that release, file by file (Python caches ignored). It lists every added, removed or changed file, and exits non-zero if there is one:
```bash
tools/check_vendored.sh <fork checkout>/<path to its opendbc package>/bluepilot_lateral
```
Run it in your CI to catch an edit to the copy. For a fork working with BluePilot, this repo's nightly check runs it too.
## Installer note: nested submodule
A fork that embeds the library as a *nested* submodule (Option 2 — e.g. `fork → opendbc → opendbc/bluepilot_lateral`) can end up with the library missing after an end-user install. Some installers run a non-recursive `git submodule update --init`, which checks out the first-level submodule but not the library nested inside it, and the build then fails to import it. Installs that use `git submodule update --init --recursive` are unaffected.
If your fork is installed that way, self-heal on launch: before the build imports the library, when its directory is empty, run a recursive submodule update scoped to the embedding submodule — guard on the library's `__init__.py`, retry, and fail soft so an offline boot is no worse than before.
## How BluePilot releases
- Lateral changes and changes to the library's safety headers are driven on a car before they're released.
- Every release says what changed in the lateral control, the CAN messages and the panda safety, and what was validated.
@@ -0,0 +1,129 @@
# Hosting the BluePilot lateral
A host is an opendbc fork with this library at `<pkg>/bluepilot_lateral/` and the hook patch applied, plus the openpilot on top of it that fills the library's inputs each frame. `<pkg>` is the fork's opendbc package: `opendbc` on sunnypilot/opendbc (ford-op/opendbc, under bp-sunnypilot), `iqdbc` on IQ.Pilot, `opendbc` on StarPilot. This page is what a host provides and what it gets back.
## Mounting the library
A host mounts this repo at `<pkg>/bluepilot_lateral/`, directly inside its opendbc package, as a git submodule (ford-op/opendbc) or, in a host that takes no submodules (IQPilot, at `artifacts/package_sources/iqdbc/iqdbc/bluepilot_lateral/`), as a stamped copy of a release. Updating is a bump of the submodule's commit, or vendoring the new release (below). The hook patch below carries every file ford-op/opendbc adds or changes outside the library, so since it mounts the library it also carries the mount's `.gitmodules` entry.
The library never names its host's package. Python imports are relative: the library's own modules by `.`/`..`, the host's opendbc by one more level (`from ...car.ford.values import CarControllerParams` in `core/`), and `tests/test_core_imports.py` fails on any absolute `opendbc`/`iqdbc`/`openpilot` import. Safety headers include each other as `../safety/<file>.h` and the host's declarations as `../../safety/declarations.h`, relative to the including file. The host includes the library's safety as `"<pkg>/bluepilot_lateral/safety/ford.h"`.
### Without submodules: a stamped copy
From a clone of this repo, `tools/vendor.sh <release tag> <host>/<pkg>/bluepilot_lateral` makes the folder exactly the library at that tag (files it no longer has are removed), without `.github/` (`.gitattributes`), plus a `VERSION` file: the tag and the commit. Nothing inside the folder is ever edited: a change goes into this repo, gets released, and the host vendors the new tag. `tools/check_vendored.sh <folder>` lists anything in the folder that differs from the release in its `VERSION` and fails if there is something; a host can run it in its CI, and this repo's IQPilot checks run it on IQPilot's copy.
### Host bindings
The safety headers are written against upstream opendbc's names. A host whose names differ defines these before it includes `safety/ford.h` (`safety/lateral_declarations.h` has the defaults):
| macro | default (upstream, sunnypilot) | IQ.Pilot |
|---|---|---|
| `BP_HOST_CURVATURE_LIMITS` | `CurvatureSteeringLimits` | `NativeCurvatureSteeringLimits` |
| `BP_HOST_CURVATURE_STATE` | `curvature_state` | `native_curvature_state` |
| `BP_HOST_RT_CURVATURE_RATE_CHECK` | `rt_curvature_rate_limit_check` | `rt_native_curvature_rate_limit_check` |
| `BP_HOST_LAT_ALLOWED()` | `(controls_allowed \|\| controls_allowed_lateral)` | `is_lat_active()` |
| `BP_HOST_SPEED_MISMATCH_CHECK()` | `speed_mismatch_check(vehicle_speed_2 ...)` | `((void)0)`: IQ's `ford_rx_hook` runs it |
The limits struct needs only `max_curvature`, `curvature_to_can`, `frequency`, `max_curvature_error` and `curvature_error_min_speed`.
## The hook patch
`tools/make_hook_patch.sh` prints it: every file outside the library that ford-op/opendbc changes from `master`, minus its own `.github/` (its CI). It fails unless the edited files are exactly the non-`.github` entries of `.github/bluepilot-hooks.txt`.
| file | what the hook does |
|---|---|
| `opendbc/car/ford/carcontroller.py` | Builds one `BluePilotLateral(CP, CP_SP, packer, CAN)`, the sunnypilot adapter (`hosts/sunnypilot/lateral.py`, below). Each frame `bp.update(CC_SP, frame)` says whether BluePilot lateral is active; if it is, `bp.step_lateral` sends the steering message at `STEER_STEP` (and returns the curvature to pass back next time) and `bp.step_lka` sends Lane_Assist_Data1 at `LKA_STEP`. Otherwise the stock path runs. |
| `opendbc/car/structs.py` | Imports `LateralInputs` from `host_types.py` and adds `CarControlSP.lateralInputs` and `CarParamsSP.lateralInputsRequired`. |
| `opendbc/sunnypilot/car/interfaces.py` | Calls `init_ford_safety_param_sp(CP, CP_SP, params_dict)` (`hosts/sunnypilot/safety_param.py`) at car init: the startup safety bits below, and `lateralInputsRequired` for every Ford. |
| `opendbc/safety/modes/ford.h` | Includes `safety/ford.h` and makes one call per hook site: counter and quality checks for the pinion message, the yaw measurement skipped while the pinion source is on, `ford_bp_rx_hook` (pinion measurement), `ford_bp_lka_tx` (angle-mode state from Lane_Assist_Data1), `ford_bp_lmc_tx` in place of the stock steering check when `BP_LATERAL` is set (the stock check runs unchanged when it isn't), and the pinion RX check list from `ford_bp_init`. |
| `opendbc/safety/tests/test_bluepilot_lateral.py` | New: hands the library's `safety/tests/` to upstream's test discovery, so upstream's `test.sh` and `mutation.py` run the BluePilot safety tests. |
## Every frame: inputs
A host fills these and the settings below for a car whose `CarParamsSP.lateralInputsRequired` is set: every Ford, BluePilot lateral on or off (`init_ford_safety_param_sp`).
`CarControlSP.lateralInputs` (`host_types.LateralInputs`):
| fields | from |
|---|---|
| `pathYawRate`, `pathX`, `pathY` | `modelV2.orientationRate.z`, `modelV2.position.x`, `.y` (33 points) |
| `laneLineLeftX/Y`, `laneLineRightX/Y` | `modelV2.laneLines[1]`, `[2]` |
| `laneLineProbs`, `laneLineStds` | `modelV2.laneLineProbs`, `.laneLineStds` |
| `laneChangeState`, `laneChangeDirection` | `modelV2.meta` |
| `steerRatio`, `stiffnessFactor`, `roll`, `angleOffsetDeg` | the learned vehicle parameters (`vehicleParameters`, paramsd) |
| `lateralDelay` | `lateralDelay.lateralDelay` |
| `valid` | true only when both the model and the vehicle parameters have been seen and every field above is filled |
Each path array has `host_types.MODEL_PATH_POINTS` (33) entries at the times in `host_types.MODEL_T_IDXS` (openpilot's `ModelConstants.T_IDXS`): the core interpolates over that grid. On a frame that isn't valid, or whose arrays aren't the full shape or contain a non-finite value, the library keeps the previous inputs.
## Every frame: settings
Every key in `opendbc.bluepilot_lateral.core.values_ext.BP_LATERAL_PARAMS` (the per-frame settings in `params/manifest.py`; hosts import the list from there), every frame, as (key, value) with the value as bytes (sunnypilot: `CarControlSP.params` entries): bools `"1"`/`"0"`, numbers as text. A missing, empty or unparsable value gives the manifest default. A host publishes its own registry default for a key nobody has set, so a host can carry its own defaults (BP7 uses 3.0 for `LC_PID_gain_UI_curv`). `python -m <pkg>.bluepilot_lateral.params.manifest` prints the keys as `params_keys.h` lines (IQ.Pilot drops `BACKUP`, which its params don't have).
## At startup: the safety bits
`values_ext.ford_safety_param(CP, params_dict)` reads the startup settings (`disable_BP_lat_UI`, `FordPrefSteerAngleCurvature`) and returns the bits. The host sends them to the panda in its second safety param (sunnypilot: the adapter ORs them into `CP_SP.safetyParam`). StarPilot has no second param: its adapter shifts them into bits 9-14 of its Ford `safetyParam` (`hosts/starpilot/safety_param.py`, clear of the bits StarPilot's Ford safety reads), and its `ford_init` hands `(param >> 9) & 0x3F` to `ford_bp_init`:
| bit | |
|---|---|
| 5 `BP_LATERAL` | the panda runs the BluePilot steering checks; the car controller sends the 4-signal messages only when it's set |
| 0 `STEER_ANGLE_CURVATURE`, 1-4 geometry index | the pinion angle replaces the yaw rate as the curvature measurement, on platforms with a row in the pinion table |
The panda keeps what it was given at init: changing `disable_BP_lat_UI` while driving takes full effect at the next init.
## Reading status
`BluePilotLateral.status()` returns `host_types.LateralStatus`, this frame's values for a BluePilot UI, under the names BP7's `controllerStateBP` publisher reads off its car controller:
| field | |
|---|---|
| `lateralUncertainty` | from whichever strategy ran |
| `angleRateLimited` | angle mode: the path angle soft rate limit bit this frame |
| `curvatureRateLimited` | angle mode: the equivalent curvature would have been rate-limited |
| `curvatureDeviationLimited` | either mode: the current-curvature error clip constrained the command |
| `humanTurnLateralPaused` | angle mode: lateral sent inactive during a manual turn |
| `stallBlipActive` | angle mode: the brief mode-0 pulse after a post-override stall |
| `disable_BP_lat_UI` | BluePilot lateral off: the setting, or the panda bit not set at init |
| `primary_lateral_control` | 0 = curvature, 1 = angle |
The four angle-mode values read false outside angle mode. bp-sunnypilot doesn't read status; it has no BluePilot UI.
## What the library uses from the host's opendbc
Every host is an opendbc fork, so the library uses these directly:
- `<pkg>.car`: `structs`, `DT_CTRL`, `ACCELERATION_DUE_TO_GRAVITY`, `scale_rot_inertia_per_mass` and `scale_tire_stiffness_per_mass` (or, on an opendbc from before the per-mass vehicle model, `scale_rot_inertia` and `scale_tire_stiffness`, at unit mass), `common.pid.PIDController`, `vehicle_model.VehicleModel` (either signature), `lateral` (`AngleSteeringLimits`, `apply_std_steer_angle_limits`, `ISO_LATERAL_ACCEL`), `ford.fordcan.CanBus`, `ford.values` (`CarControllerParams`, `FordFlags`).
- `can/fordcan_ext.py` sets LateralMotionControl2's checksum itself: upstream's packer fills Ford checksums (and overwrites it with the same value), IQ.Pilot's doesn't.
- The safety headers use `modes/ford.h`'s Ford constants and upstream's safety helpers, through the bindings above. `safety/lateral.h` and `can/fordcan_ext.py` copy four upstream functions, pinned per host package by `tests/test_bluepilot_upstream_copies.py`, so a host where one changed fails that test (a host with no pins skips it).
- The safety tests use the host's test harness: `<pkg>.safety.tests.test_ford`, `common` and `libsafety.libsafety_py`. `tests/host.py` names the host's own structs, adapter and libsafety setters for the tests that touch them.
## The core's API, and the host adapters
The core takes only the library's types:
- `core.carcontroller_ext.BluePilotLateral(CP, safety_param, packer, CAN)`: `safety_param` is the startup bits (`ford_safety_param`).
- `update(inputs, params, frame)`: `inputs` is a `host_types.LateralInputs`, `params` a `params.param_store.ParamStore` built from the frame's (key, value) settings. Then `step_lateral`, `step_lka` and `status()` as above.
Nothing under `core/`, `can/`, `params/` or `safety/` imports a host (`tests/test_core_imports.py`). `hosts/sunnypilot/`:
- `lateral.py`: a `BluePilotLateral` subclass with sunnypilot's types, `(CP, CP_SP, packer, CAN)` and `update(CC_SP, frame)`. It passes `CP_SP.safetyParam`, `CC_SP.lateralInputs` and the `CC_SP.params` entries as (key, value) pairs.
- `safety_param.py`: `init_ford_safety_param_sp(CP, CP_SP, params_dict)` ORs `ford_safety_param` into `CP_SP.safetyParam`. Its own module, so car init imports only `values_ext`.
`hosts/iqpilot/`, the same for IQ.Pilot's types:
- `lateral.py`: `BluePilotLateral(CP, CP_IQ, packer, CAN)` and `update(CC_IQ, frame)`, passing `CP_IQ.iqSafetyFlags`, `CC_IQ.lateralInputs` and the `CC_IQ.params` entries.
- `safety_param.py`: `init_ford_safety_param_iq(CP, CP_IQ, params_dict)` ORs `ford_safety_param` into `CP_IQ.iqSafetyFlags` (the panda's `current_safety_param_iq`) and sets `CP_IQ.lateralInputsRequired`.
`hosts/starpilot/`, for StarPilot, which has neither a second safety param nor CarControl / CarParams extension structs:
- `lateral.py`: `BluePilotLateral(CP, packer, CAN)`, reading the startup bits back out of the Ford `safetyParam`, and `update(frame_inputs, frame)`. `FrameInputs` carries `lateralInputs` and the `params` entries; StarPilot's openpilot side fills one each frame and hands it to the Ford CarController, in place of a CarControl extension.
- `safety_param.py`: `init_ford_safety_param_starpilot(CP, params_dict)` ORs `ford_safety_param << 9` into `CP.safetyConfigs[-1].safetyParam`; `bp_safety_param(CP)` reads them back; `lateral_inputs_required(CP)` says whether to fill `FrameInputs`. StarPilot's Transit (`FordFlags.LKA_STEERING`, steering through Lane_Assist_Data1 alone) gets neither.
Another host writes its own adapter against the same API.
## Running the tests
- Python tests: `python -m unittest discover -s <pkg>/bluepilot_lateral/tests -t .`, from the directory that holds `<pkg>`.
- Safety tests: the host's `<pkg>/safety/tests/test.sh` runs them, through a loader (ford-op/opendbc's `test_bluepilot_lateral.py`) or by naming `../../bluepilot_lateral/safety/tests` (IQ.Pilot: its cross-mode TX sweep reads `TX_MSGS` off every `Test*` class in its directory, so a loader there breaks it). Coverage of the BluePilot headers and `modes/ford.h`: `safety/tests/bluepilot_coverage.sh`, which runs the library's tests itself where the host has no loader; mutation: `safety/tests/bluepilot_mutation.py` (needs the host's `mutation.py`, which IQ.Pilot doesn't have).
- On a clean sunnypilot/opendbc checkout: `tools/test_on_host.sh <checkout>`, from a ford-op/opendbc checkout. It copies the library as committed and applies the hook patch to a temporary clone, then runs both.
- On IQPilot: `hosts/iqpilot/host.sh tree <dir>` builds iqdbc from IQ.Lvbs's repo at the pinned commit with `hosts/iqpilot/hooks.patch` (IQPilot's hooks, kept here until IQPilot carries them) and the library, vendored; `host.sh test <dir>` runs the library tests, IQPilot's safety suite with the BluePilot ones (minus `known_failures.txt`, IQPilot's own) and coverage; `host.sh make <dir>` writes the hook patch back after editing the hooks in `<dir>/host`. CI's `iqpilot` job runs tree and test. Every night `iqpilot pin check` tries IQ.Lvbs's latest `beta`: green moves the pin in a PR it merges itself (dropping `hooks.patch` once IQPilot carries the hooks), red keeps one issue open saying which step failed.
@@ -1,4 +1,6 @@
Copyright (c) 2020, Comma.ai, Inc.
Copyright (c) 2026, firestar5683 and StarPilot contributors
Copyright (c) 2024-2026, BluePilot contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
@@ -1,21 +0,0 @@
# Custom MIT License
Copyright (c) 2024, Haibin Wen, SUNNYPILOT LLC
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to view and modify the Software, subject to the following conditions:
1. **Permission Required**: Permission Required for Commercial, For-Profit, or Closed Source Use: Use of the Software, in whole or in part, for any commercial purposes, for-profit projects, or in closed source projects requires explicit written permission from the original author(s).
2. **Redistribution**: Any redistribution of the Software, modified or unmodified, must retain this license notice and the following acknowledgment:
"This software is licensed under a custom license requiring permission for use."
3. **Visibility**: Any project that uses the Software must visibly mention the following acknowledgment:
"This project uses software from Haibin Wen and SUNNYPILOT LLC and is licensed under a custom license requiring permission for use."
4. **No Warranty**: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Contact sunnypilot Support <support@sunnypilot.ai> for permission requests.
---
Haibin Wen, SUNNYPILOT LLC
@@ -1,38 +0,0 @@
9d190417bf74563abfa6f36996f1e673e9ee0a1b Praeuner 2026-08-13T22:26:15-05:00 Update dampening factor to .25
a881646ea5edc21c0db4c4e74076e0dc14671a4e alan-polk 2026-08-10T09:04:52-05:00 Merge pull request #176 from BluePilotDev/bp-no-stall
9012f76666a5c90764fcaec40832b9a607488c27 John Christman 2026-08-05T17:49:59-04:00 anti-stall
3610e3f18a0ad37d793d7ada61dbc739ab1077a3 Nathan Ingraham 2026-07-23T22:26:29-05:00 self.path_angle_gain_lowC_highV remains the platform-specific base gain. The toggle now controls separate self.user_dampening_factor. l ow_gain_calc uses: self.path_angle_gain_lowC_highV * self.user_dampening_factor UI placement, description, default, and 0.75–1.25 range remain unchanged.
1db52bc79e607ddb00214254dd4d732615e27fe4 Nathan Ingraham 2026-07-23T00:00:51-05:00 -Added “High Speed Dampening” directly below “High Speed Adjustment Factor”, range 0.75–1.25, step 0.01, and default 1.0.
5d994c24edf63f3b9004b2963f18ef4199e5d368 Jacob Neulight 2026-07-18T20:17:04-06:00 Ford safety: optional pinion-sourced angle_meas behind STEER_ANGLE_CURVATURE SP flag
699c17d9fde62c690eed9d68eed7d731744d5137 Jacob Neulight 2026-07-18T19:53:46-06:00 Ford: publish angle-mode shadow curvature from measured curvature during overrides
b7428fee4b116d4832f91c850d54cac2730de2ef alan-polk 2026-07-18T08:52:24-05:00 restore curvature safety checks to bp-6.0 logic.
97867c1eb57b7472f6fc3de62f0fef576e5a5497 alan-polk 2026-07-16T19:18:05-05:00 Human Turn Reset for Angle (#118)
7efee67e3e643d52b973895d6c6765c5cc12e314 alan-polk 2026-07-16T08:19:43-05:00 angle mode: rescale exit-detection threshold for 20Hz cadence
1958a3501ead10ae1f496f0d11e1e8cda6e2dbcc alan-polk 2026-07-14T15:22:38-05:00 Bp 7.0 variable cleanup (#111)
8f8d6d15f0a590f42b78de964ffb0d0af7f5d63d alan-polk 2026-07-14T08:53:28-05:00 Bp 7.0 panda2 (#109)
11ac3b760671b489ca449d522f1edf240a232884 alan-polk 2026-07-09T07:53:21-05:00 angle mode: fix ROC values tuned for 100Hz cadence running at actual 20Hz
dbdcea747e65f574d326a55c9bf1a9d6c9485e22 alan-polk 2026-07-08T09:21:32-05:00 Revert "ford: add current-curvature deviation check to angle mode (panda-side)"
69a9714563622729ba304ac75354ba3fd37b0b39 alan-polk 2026-07-08T09:17:09-05:00 ford: add current-curvature deviation check to angle mode (panda-side)
be7f86a5d0dc1ea3b3f0052d4b54ff0b8a70f843 John Christman 2026-07-06T21:53:55-04:00 cast on debug
5c1fbb07dd4d7d950a01cb2cd1faddd90d41e632 alan-polk 2026-07-06T14:20:49-05:00 controllerStateBP: add angleRateLimited + curvatureRateLimited diagnostics
06d654e5f48ea8fcffce6e98e48e3a30450b5097 alan-polk 2026-06-25T23:28:51-05:00 Revert "temp blend ratio change" — restore predicted-curvature blend to 0.50
540e69fcf4861a2ce5d7ca41467effc231638e27 alan-polk 2026-06-22T08:35:44-05:00 temp blend ratio change
e3819c7e630d85cfb44858840124fe7f72f1762d alan-polk 2026-06-13T20:47:09-05:00 ford.h: don't block angle mode on the inactive curvature signal
5d343738e30267688d0ffa6eebe2ec3ca57b10ee alan-polk 2026-06-13T18:28:30-05:00 ford.h: widen path_angle ROC to 2% looser than lateral_angle_ext _soft_roc
d0aac605f99d37e9da205e419f7989c1e9eaa386 alan-polk 2026-06-13T11:15:50-05:00 BluePilot: add angle-primary lateral control mode
51d5de8991508c862e7c51333e5055425b2b8d23 alan-polk 2026-04-11T12:00:10-05:00 fixing missing punctation in ford.h
8194ceb7e0bbc112c73d393a8f0add3261f6592d alan-polk 2026-04-11T11:51:49-05:00 make safety limits symetrical but control limits asymetrical. Add 1.5s duration to detect human turn
db2bdff05df103d71df62f45c2a3cb5211aba6e6 alan-polk 2026-04-09T23:47:30-05:00 move all external logic to lateral_curv_ext.py. Apply some order of operations fixes to try and eleminate panda blocking messages.
1a8ac23b5bd5d7952c15eb913c2b1e39f14ad92e Blue Genie 2026-04-08T15:10:24-05:00 Adjust panda limits
892be076daf898d806983f847c72357accff7312 Blue Genie 2026-04-08T11:45:28-05:00 Add logging functionality
ce7d27230657159332f64a253a0faea6cf8dcc95 alan-polk 2026-04-08T11:40:02-05:00 fix rate of change limits
021b5146e80ac58fe2e81dbd848bc7cd409ef08e alan-polk 2026-04-07T16:24:38-05:00 align ford.h limits with dbc and carcontroller
7f9212f7f8dc22c4a0a22443158554f4d3652a3c tonesto7 2026-04-05T21:28:37-04:00 Merge bp-6.1 upstream changes into bp-6.0-dev (#124)
dd1a9d3fd8f60462d3d8ac6de3e7860e70e1f667 alan-polk 2025-12-12T12:22:31-06:00 convert opendbc from a submodule to a subfolder
10101012d8a4cc37951a557023824cdd995443a6 alan-polk 2026-08-10T17:23:04Z BluePilot: angle-mode lane centering trim (advanced lane positioning)
560e80e9d593badca08aa60e8ddb692e58033425 alan-polk 2026-08-11T12:50:00Z BluePilot: angle-mode lane centering falls back to model position, not zero
94bf8144d4a683e9f3408f3babf892878f3b81f4 ghbarker 2026-08-12T01:31:33Z ford ALP: restore StarPilot guards (narrow lane, laneline std, NaN params) and cap applied correction at 0.0015
80825b81568ad684fe4ec37a797d3cbaa004fdcf ghbarker 2026-08-13T01:04:02Z split applied-correction ceiling into its own PR per review
e5b92cc48b34ea43ac756a96a96687c9851a188d ghbarker 2026-08-13T01:05:07Z add tests for the restored guards
af4bc410c9ef417ba723212ceaa2a77758dd8ecb alan-polk 2026-08-14T03:24:28Z BluePilot: rate-limit the lane centering correction, not just filter it
8eea9eb68e0c4126ee1c52f251e274d5865b6743 alan-polk 2026-08-19T13:33:00Z BluePilot: lower angle-mode lane centering default strength to 0.25
Can't render this file because it contains an unexpected character in line 14 and column 85.
@@ -1,81 +1,14 @@
# BluePilot Ford angle control
# BluePilot lateral
StarPilot adaptation of BluePilotDev/bluepilot `bp-7.0`, pinned to
[e1d051d7ba270261b4455068bd68f1a58db15a4a](https://github.com/BluePilotDev/bluepilot/commit/e1d051d7ba270261b4455068bd68f1a58db15a4a).
The layout follows Alan Polk's September 2026 library proposal. This is an
adaptation, not an upstream library release.
BluePilot's Ford lateral control as a library an opendbc fork mounts at `opendbc/bluepilot_lateral/`: both modes (curvature and angle), the CAN messages that carry them, the panda safety that checks them, and a settings manifest.
Existing curvature control remains the default. Explicit `FordLateralMode=1`
selects angle control next drive; the saved choice persists when unavailable.
Only this selector is exposed. Fixed defaults and bounds live in
`params/manifest.json`; pinion-yaw correction and curvature-primary tuning are
outside this library. Angle lane-centering trim is available as a default-disabled
core capability, separately sourced from BluePilot bp-dev
`e22afa6be9b881fa784c92ebb316db47728a3d81`; it adds no public settings.
**This is a modified Ford safety model.** The panda safety allows signals and ranges that upstream's Ford safety blocks. A fork chooses whether to ship it.
`core/` accepts scalar/model inputs without openpilot, Params or messaging
imports. `can/` owns encoding, `safety/ford_bp.h` owns native enforcement, and
`hosts/starpilot.py` connects the host. `params/protocol.json` generates the wire
constants. The folder is vendored without submodules, LFS or an external runtime.
**Status:** `v0.2.0`. It runs on bp-sunnypilot, a sunnypilot fork, through ford-op/opendbc (sunnypilot's opendbc plus the hooks).
## Admission
- [FORKS.md](FORKS.md): how a fork takes BluePilot lateral and works with BluePilot: carrying the library (a submodule or a stamped copy), the stamp tools, and adding a fork.
- [HOSTS.md](HOSTS.md): what a host provides, the hook patch, and how to run the tests.
- [can/LANE_ASSIST_DATA1.md](can/LANE_ASSIST_DATA1.md): the Lane_Assist_Data1 bits angle mode uses.
- [CREDITS.md](CREDITS.md) and [LICENSE](LICENSE) (MIT).
Classic: Bronco Sport Mk1, Escape Mk4, Focus Mk4, Maverick Mk1, Explorer Mk6.
CAN FD: Escape Mk4.5, Expedition Mk4, F150 Mk14, F150 Lightning Mk1, Ranger Mk2,
Mustang Mach-E Mk1.
Exact classic safety parameters 32/33 map to angle 128/129; CAN FD 18/19 and
66/67 map to 130/131. Parameter 131 requires DEBUG; RELEASE retains stock 130.
Longitudinal, radar, fingerprint and parser policy remain host-owned. Admission
uses the final CarParams contract, not brand or steering-control enum alone.
AOL, alternative experiences, Transit, Edge, Mondeo, SecOC, passive, dashcam,
notCar and unsupported static flags are excluded. Auxiliary noOutput requires
parameter zero; production deployments with two Pandas remain unqualified until
actual startup, bus routing and public acknowledgments are verified.
## Safety design
The extension uses small Ford hooks without changing shared safety headers or
existing curvature namespaces. Active steering requires ordinary controls
permission, healthy physical RX and a fresh matching public Panda acknowledgment
of safety parameter, alternative experience, controls, RX validity, heartbeat and
faults. Cruise, gas/brake, checksum, counter, quality and freshness checks apply.
Neutral mode cannot grant permission.
The BluePilot reset-bypass latch is omitted. Rejected frames preserve accepted
actuator history and withdraw the shadow lease; accepted neutral commands or
permission withdrawal reset history. Lost counters recover through neutral
resynchronization. Version or checksum failure cannot fall through to curvature
control.
Active frames carry neutral curvature, offset and curvature rate, with checked
mode, ramp, precision, path angle, counter, checksum and cadence. Native enforcement
checks the shadow against the actual path across gains 0.95–1.3, bounds both by
`min(0.02, 2.4114/v²)`, and bounds the full actual-curvature interval against
measured curvature above 10 m/s. Actual path also obeys the 3.5886 m/s³ equivalent
jerk limit and rolling message-rate limit. Core quantization and timing margins
keep packed commands inside these bounds; an impossible intersection is neutral.
The versioned 0x3CA announcement preserves StarPilot's existing byte 4 bit 1
curvature flag and requires it clear in angle mode. It carries a counter, signed
shadow curvature, protocol version and checksum; its lease expires after 60 ms.
The shadow derives from the final path after rate limiting and saturation.
Control computes at 20 Hz, announces through 33 Hz LKA, then sends the latest
announced result at 20 Hz, adding at most one steering tick. Permission withdrawal
sends neutral immediately. Human-turn recovery uses bounded neutral blips without
a native bypass. The retained Dom factory steering delay is 0.22 s; pinned
BluePilot uses 0.20 s.
These stricter native limits and versioned metadata intentionally differ from
BluePilot 7.0. Identical bytes or safety decisions are not claimed for affected
commands. Runtime and physical ECU qualification remain pending.
## Attribution and notices
See `CREDITS.md`, `ORIGIN_HISTORY.tsv` and the project `THIRD_PARTY_NOTICES.md`.
Both upstream MIT and Custom MIT notices are retained verbatim in `LICENSE`,
`LICENSE.md`, `upstream-root-LICENSE` and `upstream-root-LICENSE.md`. The maintainer
reports author permission to use this adaptation under MIT. Both published notices
remain intact.
History before 2026-09-15 lives in [BluePilotDev/bluepilot](https://github.com/BluePilotDev/bluepilot).
@@ -0,0 +1 @@
v0.2.1 2b9dc6cc784faac47a1885d0bda0561f7b76f74d
@@ -1,109 +0,0 @@
"""Adapter to the proposed BluePilot library; angle only, exact source pinned."""
from opendbc.bluepilot_lateral.can.ford import lateral, lka
from opendbc.bluepilot_lateral.core.angle import Command
from opendbc.bluepilot_lateral.hosts.starpilot import Host, qualified
class BluePilotLateral:
def __init__(self, CP, *, host=None):
if not qualified(CP):
raise ValueError("Angle mode is not configured")
self.host = host or Host(CP)
self.canfd = bool(CP.flags & 1)
self.pending = Command()
self.announced = Command()
self.announced_ns = 0
self.lka_counter = 0
self.control_counter = 0
self.control_sent_ns = 0
self.shadow_sent_ns = 0
self.control_echo_ns = 0
self.shadow_echo_ns = 0
self.control_returned_ns = 0
self.shadow_returned_ns = 0
self.control_echo_pending = False
self.shadow_echo_pending = False
self.control_return_payload = None
self.shadow_return_payload = None
self.pending_generation = 0
self.announced_generation = -1
self.sent_generation = -2
self.steering_rejection_ns = 0
self.apply_curvature_last = 0.0
def observe_can(self, can_packets, main_bus):
# Forwarded stock traffic also returns with src|0x80. Consume one matching
# return for our actual emitted payload; unrelated stock echoes cannot pace us.
# Preserve return ordering here;
# start the delay when consumed, in the controller's own clock domain.
control_address = 0x3D6 if self.canfd else 0x3D3
for timestamp, frames in can_packets:
if timestamp <= 0:
continue
for address, data, source in frames:
if len(data) != 8:
continue
if source == (main_bus | 0xC0) and address in (control_address, 0x3CA):
if timestamp > self.steering_rejection_ns:
self.steering_rejection_ns = timestamp
self.host.steering_rejected = True
continue
if source != (main_bus | 0x80):
continue
if address == control_address and data == self.control_return_payload and timestamp > self.control_returned_ns:
self.control_return_payload = None
self.control_returned_ns = timestamp
self.control_echo_pending = True
elif address == 0x3CA and data == self.shadow_return_payload and timestamp > self.shadow_returned_ns:
self.shadow_return_payload = None
self.shadow_returned_ns = timestamp
self.shadow_echo_pending = True
def step_lateral(self, CC, CS, packer, CAN, frame, now_ns):
if now_ns <= 0:
return []
if self.control_echo_pending:
self.control_echo_ns = now_ns
self.control_echo_pending = False
if ((self.control_sent_ns and now_ns - self.control_sent_ns < 50_000_000) or
(self.control_echo_ns and now_ns - self.control_echo_ns < 45_000_000) or
self.announced_generation <= self.sent_generation):
return []
self.pending = self.host.update(CC, CS)
self.pending_generation += 1
command = self.announced
# A pending permission withdrawal is immediate; an increase waits until
# the 33Hz side channel has announced the matching final actuator.
fresh = 0 < self.announced_ns <= now_ns <= self.announced_ns + 60_000_000
if not self.pending.active or not fresh:
command = Command()
self.announced = command
if not fresh:
# Native records the neutral actuator as zero. Do not resume an old
# nonzero path after a new announcement; restart through the normal limits.
self.host.controller.reset_actuator()
self.pending = Command()
self.control_sent_ns = now_ns
self.sent_generation = self.announced_generation
message = lateral(packer, CAN.main, command, canfd=self.canfd, counter=self.control_counter)
self.control_return_payload = message[1]
self.control_counter = (self.control_counter + 1) % 16
return [message]
def step_lka(self, packer, CAN, now_ns):
if now_ns <= 0:
return []
if self.shadow_echo_pending:
self.shadow_echo_ns = now_ns
self.shadow_echo_pending = False
if ((self.shadow_sent_ns and now_ns - self.shadow_sent_ns < 30_000_000) or
(self.shadow_echo_ns and now_ns - self.shadow_echo_ns < 25_000_000)):
return []
self.announced = self.pending
self.announced_generation = self.pending_generation
self.announced_ns = now_ns
self.shadow_sent_ns = now_ns
message = lka(packer, CAN.main, self.announced.shadow_curvature, self.lka_counter)
self.shadow_return_payload = message[1]
self.lka_counter = (self.lka_counter + 1) % 8
return [message]
@@ -0,0 +1,60 @@
# Lane_Assist_Data1: BluePilot's angle-mode side channel
In angle mode the curvature signal in `LateralMotionControl2` is held at 0 and path angle does the
steering, so the panda has no commanded curvature to check against the car's measured curvature.
BluePilot sends that curvature, the *shadow curvature*, plus an "angle mode engaged" flag, in bits of
`Lane_Assist_Data1` (0x3CA) that carry no DBC signal. openpilot sends this message itself at 33 Hz,
and the panda reads it in the TX hook as it goes out. A separate message was tried first: the panda
never receives its own TX, and an RX check for it blocked controls car-wide (2026-07-09).
Packed by `create_lka_msg` in `fordcan_ext.py`; read by `ford_bp_lka_tx` in `../safety/ford.h`. The
byte positions, mask and scale are named once on each side (`BP_LKA_*` in `fordcan_ext.py`,
`FORD_BP_LKA_*` in `../safety/ford_declarations.h`), and `../tests/test_lane_assist_data1.py` checks
that they agree with each other and with the DBC.
## Layout
Bit numbers are within the byte, 7 = most significant. Stock signals are from `ford_lincoln_base_pt.dbc`.
| byte | bits | stock Ford signal | BluePilot |
|---|---|---|---|
| 0 | 7-5 | `LkaActvStats_D2_Req` | |
| 0 | 4-2 | `LdwActvStats_D_Req` | |
| 0 | 1-0 | `LdwActvIntns_D_Req` | |
| 1-2 | 12 bits | `LaCurvature_No_Calc` | |
| 2-3 | 12 bits | `LaRefAng_No_Req` | |
| 4 | 7 | `LaRampType_B_Req` | |
| 4 | 6-5 | `LkaDrvOvrrd_D_Rq` | |
| 4 | 4-1 | none | free |
| 4 | 0 | none | **angle mode engaged** |
| 5-6 | all | none | **shadow curvature** |
| 7 | all | none | free |
## Fields
- **Angle mode engaged** (byte 4 bit 0): 1 while angle mode is the selected lateral mode and
BluePilot lateral is on.
- **Shadow curvature** (bytes 5-6): int16, big-endian, 1e-6 1/m per unit, clamped to
-32768..32767 (about ±0.0328 1/m). It is the curvature angle mode's path angle was derived from,
after the same error clip curvature mode applies; while the driver is pressing, inactive, or
during a human-turn or stall-blip pulse, it is the car's measured curvature instead. It is sent
negated, in Ford's sign convention, like the curvature and path angle in `LateralMotionControl2`.
## What the panda does
- It latches both fields from every `Lane_Assist_Data1` frame that goes through its TX hook,
including one it blocks for another reason.
- **Angle mode engaged widens the path-angle range.** While the flag is set, the lateral command's
path angle may use the DBC's full range (-0.5 to 0.5235 rad); otherwise it is held to ±0.25 rad
(`ford_lmc_checks`). The flag alone does it: curvature 0 without the flag keeps the tight range.
- **The shadow curvature check.** For a lateral command (`LateralMotionControl` on CAN or
`LateralMotionControl2` on CAN FD) with curvature 0, steering enabled and the flag set, the
panda scales the shadow curvature by 0.05 to curvature CAN units (2e-5 1/m, truncated toward
zero) and, above 10 m/s, blocks the frame if it is outside the measured curvature ± the curvature
error band + 1 (`ford_shadow_curvature_error_check`). It has no rate limit: path angle has its own.
- `ford_bp_init` clears the flag.
## Not here yet
A protocol-version byte and a shared bit allocation for other forks (StarPilot) are Phase 3 (the StarPilot host issue, #16):
they change bytes on the wire.
@@ -1,37 +0,0 @@
"""Ford angle codecs, adapted from BluePilot fordcan_ext.py at e1d051d7.
Core commands use planner signs; path and shadow are negated on the wire.
The v1 side channel retains byte4 bit1 for StarPilot's curvature announcement.
"""
from opendbc.bluepilot_lateral.params import protocol as p
def checksum4(dat):
return (15 - (sum(dat[:7]) & 15)) & 15
def lka(packer, bus, shadow, counter):
addr, original, _ = packer.make_can_msg("Lane_Assist_Data1", bus, {"LaRefAng_No_Req": 0., "LaCurvature_No_Calc": 0.})
dat = bytearray(original)
raw = max(-20000, min(20000, round(-shadow / p.SHADOW_SCALE))) & 0xFFFF
dat[4] |= p.ANGLE_MASK | ((counter % p.COUNTER_MODULUS) << p.COUNTER_SHIFT)
dat[5], dat[6] = raw >> 8, raw & 255
dat[7] = p.VERSION | (checksum4(dat) << p.CHECKSUM_SHIFT)
return addr, bytes(dat), bus
def lateral(packer, bus, command, *, canfd, counter):
values = {"HandsOffCnfm_B_Rq": 0, "LatCtlRampType_D_Rq": 2 if command.active else 0,
"LatCtlPrecision_D_Rq": command.precision, "LatCtlPathOffst_L_Actl": 0.0,
"LatCtlPath_An_Actl": -command.path_angle, "LatCtlCurv_No_Actl": 0.0}
if canfd:
values.update({"LatCtl_D2_Rq": int(command.active), "LatCtlCrv_NoRate2_Actl": 0.0,
"LatCtlPath_No_Cnt": counter % 16, "LatCtlPath_No_Cs": 0})
dat = packer.make_can_msg("LateralMotionControl2", bus, values)[1]
fields = ((dat[2] << 3) | (dat[3] >> 5), (dat[6] << 3) | (dat[7] >> 5),
((dat[3] & 31) << 6) | (dat[4] >> 2), ((dat[4] & 3) << 8) | dat[5])
total = int(command.active) + counter % 16 + sum(v + (v >> 8) for v in fields)
values["LatCtlPath_No_Cs"] = 255 - (total & 255)
return packer.make_can_msg("LateralMotionControl2", bus, values)
values.update({"LatCtl_D_Rq": int(command.active), "LatCtlRng_L_Max": 0, "LatCtlCurv_NoRate_Actl": 0.0})
return packer.make_can_msg("LateralMotionControl", bus, values)
@@ -0,0 +1,128 @@
"""
BluePilot Ford CAN message builder extensions (lateral only).
Extended versions of the stock fordcan.py lateral builders:
- Dynamic ramp_type and precision_type for the 4-signal lateral commands
- Lane_Assist_Data1 carries the angle-mode side channel read by bluepilot_lateral/safety/ford.h
Trimmed for the lateral proof-of-concept: the longitudinal, HUD and ICBM builders from
BluePilot's fordcan_ext.py are out of scope here.
"""
from ...car.ford.fordcan import CanBus
# BluePilot: the Lane_Assist_Data1 side channel, see LANE_ASSIST_DATA1.md. ford_declarations.h has the
# same values (tests/test_lane_assist_data1.py).
BP_LKA_ANGLE_MODE_BYTE = 4
BP_LKA_ANGLE_MODE_MASK = 0x1
BP_LKA_SHADOW_MSB_BYTE = 5 # shadow curvature: int16, big-endian, bytes 5-6
BP_LKA_SHADOW_LSB_BYTE = 6
BP_LKA_SHADOW_CURVATURE_SCALE = 1e-6 # 1/meter per raw unit
def create_lka_msg(packer, CAN: CanBus, lat_active: bool, hud_control,
angle_mode_engaged: bool = False, shadow_curvature: float = 0.0):
"""
Creates a CAN message for the Ford LKA Command.
BluePilot extension: accepts lat_active and hud_control parameters for future
lane departure warning integration (unused so far).
Also carries angle_mode_engaged + shadow_curvature (Ford sign) in bits with no DBC signal: the
layout and what the panda does with them are in LANE_ASSIST_DATA1.md.
Frequency is 33Hz.
"""
addr, dat, bus = packer.make_can_msg("Lane_Assist_Data1", CAN.main, {})
dat = bytearray(dat)
shadow_curvature_raw = int(round(shadow_curvature / BP_LKA_SHADOW_CURVATURE_SCALE))
shadow_curvature_raw = max(-32768, min(32767, shadow_curvature_raw)) & 0xFFFF
dat[BP_LKA_ANGLE_MODE_BYTE] |= BP_LKA_ANGLE_MODE_MASK if angle_mode_engaged else 0
dat[BP_LKA_SHADOW_MSB_BYTE] = (shadow_curvature_raw >> 8) & 0xFF
dat[BP_LKA_SHADOW_LSB_BYTE] = shadow_curvature_raw & 0xFF
return addr, bytes(dat), bus
def create_lat_ctl_msg(packer, CAN: CanBus, lat_active: bool, ramp_type: int, precision_type: int,
path_offset: float, path_angle: float, curvature: float, curvature_rate: float):
"""
Creates a CAN message for the Ford TJA/LCA Command (non-CAN FD).
BluePilot extension: dynamic ramp_type and precision_type parameters.
Stock hardcodes ramp_type=0 (Slow) and precision_type=1 (Precise).
Ford lane centering uses a third-order polynomial to describe the road centerline:
c0 (path_offset): lateral offset between vehicle and centerline (positive is right)
c1 (path_angle): heading angle between vehicle and centerline (positive is right)
c2 (curvature): curvature of the centerline (positive is left)
c3 (curvature_rate): rate of change of curvature
Frequency is 20Hz.
"""
values = {
"LatCtlRng_L_Max": 0, # Unknown [0|126] meter
"HandsOffCnfm_B_Rq": 0, # Unknown: 0=Inactive, 1=Active [0|1]
"LatCtl_D_Rq": 1 if lat_active else 0, # Mode: 0=None, 1=ContinuousPathFollowing, 2=InterventionLeft,
# 3=InterventionRight, 4-7=NotUsed [0|7]
"LatCtlRampType_D_Rq": ramp_type, # Ramp speed: 0=Slow, 1=Medium, 2=Fast, 3=Immediate [0|3]
"LatCtlPrecision_D_Rq": precision_type, # Precision: 0=Comfortable, 1=Precise, 2/3=NotUsed [0|3]
"LatCtlPathOffst_L_Actl": path_offset, # Path offset [-5.12|5.11] meter
"LatCtlPath_An_Actl": path_angle, # Path angle [-0.5|0.5235] radians
"LatCtlCurv_NoRate_Actl": curvature_rate, # Curvature rate [-0.001024|0.00102375] 1/meter^2
"LatCtlCurv_No_Actl": curvature, # Curvature [-0.02|0.02094] 1/meter
}
return packer.make_can_msg("LateralMotionControl", CAN.main, values)
def create_lat_ctl2_msg(packer, CAN: CanBus, mode: int, ramp_type: int, precision_type: int,
path_offset: float, path_angle: float, curvature: float,
curvature_rate: float, counter: int):
"""
Creates a CAN message for the Ford Lane Centering command (CAN FD).
BluePilot extension: dynamic ramp_type and precision_type parameters.
Stock hardcodes ramp_type=0 (Slow) and precision_type=1 (Precise).
This message replaces LateralMotionControl on CAN FD platforms and includes
counter and checksum fields.
Frequency is 20Hz.
"""
values = {
"LatCtl_D2_Rq": mode, # Mode: 0=None, 1=PathFollowingLimitedMode, 2=PathFollowingExtendedMode,
# 3=SafeRampOut, 4-7=NotUsed [0|7]
"LatCtlRampType_D_Rq": ramp_type, # 0=Slow, 1=Medium, 2=Fast, 3=Immediate [0|3]
"LatCtlPrecision_D_Rq": precision_type, # 0=Comfortable, 1=Precise, 2/3=NotUsed [0|3]
"LatCtlPathOffst_L_Actl": path_offset, # [-5.12|5.11] meter
"LatCtlPath_An_Actl": path_angle, # [-0.5|0.5235] radians
"LatCtlCurv_No_Actl": curvature, # [-0.02|0.02094] 1/meter
"LatCtlCrv_NoRate2_Actl": curvature_rate, # [-0.001024|0.001023] 1/meter^2
"HandsOffCnfm_B_Rq": 0, # 0=Inactive, 1=Active [0|1]
"LatCtlPath_No_Cnt": counter, # [0|15]
"LatCtlPath_No_Cs": 0, # [0|255]
}
# Computed here, not left to the packer: upstream's packer fills Ford checksums itself (and overwrites
# this with the same value), but a host's packer may not (IQ.Pilot's doesn't), and the PSCM drops a
# frame whose checksum is wrong.
dat = packer.make_can_msg("LateralMotionControl2", CAN.main, values)[1]
values["LatCtlPath_No_Cs"] = lat_ctl2_checksum(mode, counter, dat)
return packer.make_can_msg("LateralMotionControl2", CAN.main, values)
def lat_ctl2_checksum(mode: int, counter: int, dat: bytes) -> int:
"""LatCtlPath_No_Cs of a packed LateralMotionControl2: upstream's ford_checksum over the message's
checksum fields (mode, counter, curvature, curvature rate, path angle, path offset)."""
curvature = (dat[2] << 3) | (dat[3] >> 5)
curvature_rate = (dat[6] << 3) | (dat[7] >> 5)
path_angle = ((dat[3] & 0x1F) << 6) | (dat[4] >> 2)
path_offset = ((dat[4] & 0x3) << 8) | dat[5]
checksum = mode + counter
for sig_val in (curvature, curvature_rate, path_angle, path_offset):
checksum += sig_val + (sig_val >> 8)
return 0xFF - (checksum & 0xFF)
@@ -1,212 +0,0 @@
"""Angle-primary Ford steering adapted from Alan Polk's BluePilot bp-7.0.
Source e1d051d7ba270261b4455068bd68f1a58db15a4a lateral_angle_ext.py.
See ../LICENSE, ../LICENSE.md and ../CREDITS.md. This core has no vehicle,
openpilot, Params, messaging or model-constant imports.
"""
from dataclasses import dataclass
import math
from ..params import limits as bounds
from .trim import LaneCenterTrim, LaneGeometry, TrimConfig
def clip(value, low, high):
return max(low, min(high, value))
def interp(value, xs, ys):
if value <= xs[0]:
return ys[0]
for i in range(1, len(xs)):
if value < xs[i]:
return ys[i - 1] + (ys[i] - ys[i - 1]) * (value - xs[i - 1]) / (xs[i] - xs[i - 1])
return ys[-1]
@dataclass(frozen=True)
class Inputs:
active: bool
speed: float
measured_curvature: float
desired_curvature: float
steering_pressed: bool = False
steering_angle: float = 0.0
yaw_predictions: tuple = ()
time_indices: tuple = ()
lateral_delay: float = 0.15
lane_change: bool = False
lane_direction: int = 0
limit_status: int = 0
geometry: LaneGeometry | None = None
speed_min: float | None = None
speed_max: float | None = None
@dataclass(frozen=True)
class Command:
active: bool = False
path_angle: float = 0.0
shadow_curvature: float = 0.0
precision: int = 1
class AngleController:
def __init__(self, *, low_gain=1.0, high_gain=1.15, trim_config=None, recovery_blips=True):
self.trim_config = TrimConfig() if trim_config is None else trim_config
self.recovery_blips = recovery_blips
self.trim = LaneCenterTrim()
self.low_gain = low_gain
self.high_gain = high_gain
self.path_last = 0.0
self.speed_last = 0.1
self.desired_last = 0.0
self.human_timer = 0.0
self.press_last = False
self.preturned = False
self.press_timer = 0.0
self.blip_frames = 0
self.blip_cooldown = 0.0
self.blip_hold = 0.0
self.blip_count = 0
def reset_actuator(self):
self.trim.reset()
self.path_last = 0.0
self.speed_last = 0.1
def reset(self):
self.reset_actuator()
self.desired_last = 0.0
self.human_timer = 0.0
self.press_last = False
self.preturned = False
self.press_timer = 0.0
self.blip_frames = 0
self.blip_cooldown = 0.0
self.blip_hold = 0.0
self.blip_count = 0
def step(self, i: Inputs) -> Command:
values = (i.speed, i.measured_curvature, i.desired_curvature, i.steering_angle, i.lateral_delay)
if not i.active or not all(math.isfinite(v) for v in values) or i.speed < 0.0:
self.reset()
return Command()
speed_min = max(i.speed, 0.1) if i.speed_min is None else i.speed_min
speed_max = max(i.speed, 0.1) if i.speed_max is None else i.speed_max
if (not all(math.isfinite(v) for v in (speed_min, speed_max)) or
not 0 < speed_min <= max(i.speed, 0.1) <= speed_max):
self.reset()
return Command()
if i.steering_pressed and not self.press_last:
self.preturned = abs(i.steering_angle) > 45.0
self.press_last = i.steering_pressed
self.human_timer = self.human_timer + 0.05 if i.steering_pressed and abs(i.steering_angle) > 45.0 else 0.0
if self.human_timer + 1e-9 >= (3.0 if self.preturned else 1.5):
self.trim.reset()
self.path_last = 0.0
self.desired_last = i.desired_curvature
self.blip_frames = self.blip_count = 0
self.blip_hold = self.blip_cooldown = self.press_timer = 0.0
return Command()
if i.steering_pressed:
self.press_timer += 0.05
else:
if self.recovery_blips and self.press_timer >= 0.5 and self.blip_cooldown <= 0.0 and self.blip_frames <= 0 and abs(self.path_last) < 0.1:
self.blip_frames = 6
self.press_timer = 0.0
if self.blip_frames > 0:
self.blip_frames -= 1
self.trim.reset()
self.path_last = 0.0
self.desired_last = i.desired_curvature
if self.blip_frames == 0:
self.blip_cooldown = 2.0
return Command()
base = clip(i.lateral_delay, 0.1, 0.15) + 0.05
predictions_valid = (len(i.yaw_predictions) == len(i.time_indices) and len(i.time_indices) >= 17 and
all(math.isfinite(v) for v in (*i.yaw_predictions, *i.time_indices)) and
all(b > a for a, b in zip(i.time_indices, i.time_indices[1:], strict=False)))
curves = tuple(y / max(0.01, i.speed) for y in i.yaw_predictions) if predictions_valid else ()
entering = bool(curves) and abs(interp(base, i.time_indices, curves)) > abs(i.desired_curvature)
factor = 1.0 if entering else interp(abs(i.desired_curvature), (0.005, 0.020), (1.0, 0.0))
lookup = base + bounds.VLT_EXTRA * interp(i.speed, (11.176, 24.5872), (1.0, 0.0)) * factor
predicted = interp(lookup, i.time_indices, curves) if curves else 0.0
saturated = i.limit_status >= 2 or self.path_last >= 0.5235 * 0.9 or self.path_last <= -0.5 * 0.9
falling = abs(i.desired_curvature) < abs(self.desired_last) - 0.010
blend = bounds.BLEND * 0.25 if not entering and (i.limit_status >= 1 or saturated or falling) else bounds.BLEND
requested = predicted * blend + i.desired_curvature * (1.0 - blend)
self.desired_last = i.desired_curvature
precision = 0 if i.lane_change and ((i.lane_direction == 1 and requested < 0) or
(i.lane_direction == 2 and requested > 0)) else 1
# BluePilot's default lane-change factors are both 1.0.
# Trim requested curvature before every existing actuator/safety limiter.
requested = self.trim.update(requested, i.speed, i.geometry, self.trim_config,
active=i.active, lane_change=i.lane_change, steering_pressed=i.steering_pressed)
kappa = clip(requested, i.measured_curvature - 0.002, i.measured_curvature + 0.002) if i.speed > 9.0 else requested
low = interp(i.speed, (13.5, 26.82), (1.0, self.low_gain))
high = interp(i.speed, (13.5, 26.82), (1.3, self.high_gain))
gain = interp(abs(kappa), (0.0007, 0.001), (low, high))
path = kappa * i.speed * gain
if saturated:
last_mag, magnitude = abs(self.path_last), abs(path)
if magnitude > last_mag:
path = self.path_last
elif last_mag - magnitude > 0.02:
path = math.copysign(last_mag - 0.02, self.path_last)
elif i.limit_status >= 1:
path = clip(path, -abs(self.path_last), abs(self.path_last))
roc = interp(i.speed, (10.0, 15.0, 25.0), (0.055, 0.0425, 0.009))
path = clip(clip(path, -0.5235, 0.5), self.path_last - roc, self.path_last + roc)
# Tightening relative to bp-7.0: the shadow describes the final actuator,
# including ROC/saturation. A truthful derived curvature must fit both the
# measured-curvature interval and the modern ISO envelope. If those bands
# cannot meet the previous actuator ROC, send a neutral mode-0 frame.
effective_speed = max(i.speed, 0.1)
cap = min(bounds.CURVATURE_MAX, bounds.LATERAL_ACCEL / max(speed_max, 1.0) ** 2)
low_kappa, high_kappa = -cap, cap
if i.speed > 9.0:
low_kappa = max(low_kappa, i.measured_curvature - 0.0018)
high_kappa = min(high_kappa, i.measured_curvature + 0.0018)
# Native cannot trust a host-selected feel gain. Intersect every admitted
# gain (0.95..1.3), so the worst equivalent curvature remains bounded.
lower_gain = bounds.GAIN_MAX if low_kappa >= 0.0 else bounds.GAIN_MIN
upper_gain = bounds.GAIN_MIN if high_kappa >= 0.0 else bounds.GAIN_MAX
lower_speed = speed_max if low_kappa >= 0.0 else speed_min
upper_speed = speed_min if high_kappa >= 0.0 else speed_max
lower = max(-bounds.PATH_MAX, low_kappa * lower_speed * lower_gain, self.path_last - roc)
upper = min(-bounds.PATH_MIN, high_kappa * upper_speed * upper_gain, self.path_last + roc)
# Bound the actual-angle equivalent derivative at the worst admitted gain.
# 45ms core margin remains inside the native elapsed-time/50ms cap.
jerk_delta = bounds.LATERAL_JERK / max(i.speed - 1.0, 1.0) ** 2 * bounds.CORE_STEER_PERIOD_S * bounds.GAIN_MIN * effective_speed
jerk_center = self.path_last / self.speed_last * effective_speed
lower = max(lower, jerk_center - jerk_delta)
upper = min(upper, jerk_center + jerk_delta)
lower += bounds.PATH_ROUNDING_MARGIN
upper -= bounds.PATH_ROUNDING_MARGIN
if lower > upper:
self.trim.reset()
self.path_last = 0.0
return Command()
path = clip(path, lower, upper)
self.path_last = path
self.speed_last = effective_speed
shadow = path / (effective_speed * gain)
self.blip_cooldown = max(0.0, self.blip_cooldown - 0.05)
gap = i.desired_curvature - i.measured_curvature
stalled = (not i.steering_pressed and not i.lane_change and i.speed > 9.0 and abs(gap) > 0.004 and
abs(i.desired_curvature) > abs(i.measured_curvature))
if self.recovery_blips and stalled:
if abs(kappa - requested) > 1e-9 and self.blip_cooldown <= 0.0:
self.blip_hold += 0.05
if self.blip_hold >= 0.5 and self.blip_count < 3 and abs(path) < 0.1:
self.blip_frames = 6
self.blip_hold = 0.0
self.blip_count += 1
else:
self.blip_hold = 0.0
if i.steering_pressed or abs(gap) < 0.002:
self.blip_count = 0
return Command(True, path, shadow, precision)
@@ -0,0 +1,234 @@
"""
BluePilot Ford lateral: the one object the Ford CarController calls.
Holds the two strategies, LateralCurvExt and LateralAngleExt, with the inputs and state they
share, and does the strategy dispatch and message assembly. Lives above both strategy modules
(rather than inside either) so it can call into both without the circular import that would
result from one depending on the other.
The CarController owns apply_curvature_last, which the stock and BluePilot paths share: it is
passed into step_lateral and the result returned, so switching BluePilot lateral off mid-drive
continues from the last command, as before.
"""
import math
from types import SimpleNamespace
try:
from ...car import scale_rot_inertia_per_mass, scale_tire_stiffness_per_mass
except ImportError:
# a host on opendbc from before the per-mass vehicle model (IQ.Pilot): its mass-scaled helpers at unit
# mass are the per-mass values
from ...car import scale_rot_inertia, scale_tire_stiffness
def scale_rot_inertia_per_mass(wheelbase):
return scale_rot_inertia(1.0, wheelbase)
def scale_tire_stiffness_per_mass(wheelbase, center_to_front, tire_stiffness_factor):
return scale_tire_stiffness(1.0, wheelbase, center_to_front, tire_stiffness_factor)
from ...car.ford.values import CarControllerParams, FordFlags
from ...car.vehicle_model import VehicleModel
from ..can import fordcan_ext
from .lateral_angle_ext import LateralAngleExt
from .lateral_curv_ext import LateralCurvExt, PrimaryLateralControl
from ..params.manifest import value
from ..params.param_store import ParamStore
from .values_ext import FORD_PINION_WHEELBASE, FordSafetyFlagsSP
from .human_turn import HumanTurnDetector
from ..host_types import LateralInputs, LateralStatus
from .lateral_inputs import ModelView, VehicleParamsView, lateral_inputs_complete
def pinion_vehicle_model(CP):
"""Vehicle model for the pinion-angle curvature measurement: CarSpecs, except where BluePilot's
pinion geometry uses another wheelbase (FORD_PINION_WHEELBASE, the same value as the C table),
keeping the platform's weight split and tire model."""
wheelbase = FORD_PINION_WHEELBASE.get(CP.carFingerprint)
if wheelbase is None:
return VehicleModel(CP)
center_to_front = wheelbase * CP.centerToFront / CP.wheelbase
front, rear = scale_tire_stiffness_per_mass(wheelbase, center_to_front, CP.tireStiffnessFactor)
inertia = scale_rot_inertia_per_mass(wheelbase)
# Both VehicleModel signatures: upstream's per-mass fields, and the mass-scaled ones an older host's
# model reads, at unit mass (mass cancels out of the model, so the two are the same model).
return VehicleModel(SimpleNamespace(
rotationalInertiaPerMass=inertia, wheelbase=wheelbase,
centerToFront=center_to_front, steerRatio=CP.steerRatio,
tireStiffnessFrontPerMass=front, tireStiffnessRearPerMass=rear,
mass=1.0, rotationalInertia=inertia, tireStiffnessFront=front, tireStiffnessRear=rear, steerRatioRear=0.))
class InputTracker:
"""The model / vehicle-parameter inputs both strategies read, and the car's measured curvature.
Owned by BluePilotLateral and updated once per frame from the host's LateralInputs, before either
strategy runs."""
def __init__(self, CP, safety_param: int):
# Model / vehicle-parameter inputs arrive per frame from the host (see update)
self.VM = pinion_vehicle_model(CP) # used only for the pinion-angle curvature measurement
self.model = None
self.lp = None
self.lateral_delay = 0.0
# BluePilot: steering-angle curvature measurement (bad-yaw-sensor workaround).
# Mirrors the STEER_ANGLE_CURVATURE flag the safety firmware reads from
# current_safety_param_sp -- both layers must always agree, so this is init-time
# state from the startup safety bits (values_ext.ford_safety_param), never a live settings read:
# a live flip against stale firmware would fight the panda.
self.bp_pinion_curvature_enabled = bool(safety_param & FordSafetyFlagsSP.STEER_ANGLE_CURVATURE)
def get_current_curvature(self, CS):
"""Measured curvature of the car right now (OP sign convention).
The single measurement source for every BluePilot lateral consumer: the deviation
clip, the stall detector, and the shadow curvature published to ford.h's angle-mode
deviation check. The default source is the RCM yaw rate -- the same family stock
ford.h derives its angle_meas from. The shadow value judged against that check must
always come from the same measurement as the check's own reference, so route all
reads through here.
With the steering-angle curvature measurement enabled (FordPrefSteerAngleCurvature
toggle -> FordSafetyFlagsSP.STEER_ANGLE_CURVATURE), the pinion angle via the vehicle
model is used instead: some vehicles (e.g. a 2021 Explorer with a faulty RCM)
broadcast implausible VehYaw_W_Actl (sign-inverted vs IMU and steering geometry)
while its CAN quality flag still reads OK. The pinion angle (SteeringPinion_Data,
PSCM) is an equivalent measurement, independently validated against the comma IMU
(corr +0.99), and the panda safety angle_meas switches to the same source (see
bluepilot_lateral/safety/ford.h) -- the layers always agree. angleOffsetDeg/roll come from
liveParameters (paramsd, IMU-derived, not the car yaw sensor).
"""
if self.bp_pinion_curvature_enabled:
angle_offset_deg = self.lp.angleOffsetDeg if self.lp is not None else 0.0
roll = self.lp.roll if self.lp is not None else 0.0
return -self.VM.calc_curvature(math.radians(CS.out.steeringAngleDeg - angle_offset_deg),
CS.out.vEgoRaw, roll)
return -CS.out.yawRate / max(CS.out.vEgoRaw, 0.1)
def update(self, li: LateralInputs):
"""Take this frame's model / vehicle-parameter inputs from the host and update the vehicle model.
A frame is used only if it has the full modelV2 shape this code indexes into (33 path points,
both inner lane lines, 3+ probs/stds); anything else is ignored and the previous inputs are
kept, exactly as a stale SubMaster message was. That also makes fuzzed or partial inputs
safe: the producer's valid flag is necessary but not trusted on its own."""
if li.valid and lateral_inputs_complete(li):
self.model = ModelView(li)
self.lp = VehicleParamsView(li)
self.lateral_delay = li.lateralDelay
if self.lp is not None:
x = max(self.lp.stiffnessFactor, 0.1)
sr = max(self.lp.steerRatio, 0.1)
self.VM.update_params(x, sr)
class SharedLateralState:
"""The values both strategies read and write, so either mode sees what the other left, as when
the strategies were mixins on one object. bp_curvature_deviation_limited is the BluePilot UI's
deviation flag, from whichever mode ran."""
def __init__(self):
self.path_angle_last = 0.0
self.precision_type = 1 # 1=Precise, 0=Comfortable
self.bp_curvature_deviation_limited = False
self.human_turn_detector = HumanTurnDetector()
class BluePilotLateral:
def __init__(self, CP, safety_param: int, packer, CAN):
"""safety_param: the startup safety bits the panda was given (values_ext.ford_safety_param)."""
self.CP = CP
self.frame = 0
self.inputs = InputTracker(CP, safety_param)
self.shared = SharedLateralState()
self.curv = LateralCurvExt(self.inputs, self.shared)
self.angle = LateralAngleExt(CP, self.inputs, self.shared)
self.packer = packer
self.CAN = CAN
self.disable_BP_lat_UI = False
self.lateral_uncertainty = 0.0 # from whichever strategy ran last, for status()
# the panda only accepts 4-signal messages when this bit was set at init
self.bp_lateral_allowed = bool(safety_param & FordSafetyFlagsSP.BP_LATERAL)
def update(self, inputs: LateralInputs, params: ParamStore, frame):
"""Every frame: take this frame's model / vehicle-parameter inputs and settings, both from the host
(HOSTS.md). Returns whether BluePilot lateral is active."""
self.frame = frame
self.inputs.update(inputs)
self.curv.update_lateral_params(params)
self.angle.update_angle_params(params)
# One flag for every BP consumer (LatCtl dispatch, LKA angle-mode bits, mode reporting): the UI
# toggle, and the panda bit that was set at init. No 4-signal traffic to a stock-configured panda.
self.disable_BP_lat_UI = value(params, "disable_BP_lat_UI") or not self.bp_lateral_allowed
return not self.disable_BP_lat_UI
def step_lateral(self, CC, CS, actuators, apply_curvature_last):
"""At STEER_STEP: the steer message(s). Returns (can_sends, apply_curvature): the CarController
keeps it and passes it back next time as apply_curvature_last."""
# BluePilot: select the BP lateral strategy by primary control variable.
# angle -> LateralAngleExt: kappa -> path_angle (c1), apply_curvature held at 0.
# curvature -> LateralCurvExt: full 4-signal curvature-primary (default).
# Both return a LateralResult packed identically below.
# Do not run the stock curvature limits here or overwrite apply_curvature_last before the
# strategy runs. Panda rate-checks desired_curvature vs the last TX on the bus; that must match
# the prior frame's lat.apply_curvature only (not an intermediate stock-limited value).
angle_mode = self.curv.primary_lateral_control == PrimaryLateralControl.angle
if angle_mode:
lat = self.angle.update_angle_strategy(CC, CS, actuators)
else:
lat = self.curv.update(CC, CS, actuators, apply_curvature_last, self.CP)
apply_curvature = float(lat.apply_curvature)
self.lateral_uncertainty = float(lat.lateralUncertainty)
# BluePilot: angle-mode human-turn override -- send lateral inactive (mode 0) while the
# driver manually turns, so the PSCM releases cleanly instead of stalling 2-3 s on
# re-engage (observed on Mach-E). Panda-clean: every ford.h check has a legitimate
# !steer_control_enabled branch for the zeroed frames; on release, path_angle ramps back
# from 0 through the soft ROC. Curvature mode keeps its own reset_steering path (zeroed
# signals with mode still active) in LateralCurvExt.
# The stall blip (lateral_angle_ext.py) rides the same mode-0 path: a short pulse that
# resets the PSCM's post-override attenuation when the deviation clip deadlocks hands-free.
lat_active = CC.latActive and not (angle_mode and (self.angle.angle_human_turn_active or self.angle.angle_stall_blip_active))
can_sends = []
if self.CP.flags & FordFlags.CANFD:
mode = 1 if lat_active else 0
counter = (self.frame // CarControllerParams.STEER_STEP) % 0x10
can_sends.append(fordcan_ext.create_lat_ctl2_msg(
self.packer, self.CAN, mode, lat.ramp_type, lat.precision_type,
-lat.path_offset, -lat.path_angle, -lat.apply_curvature, -lat.curvature_rate, counter
))
else:
can_sends.append(fordcan_ext.create_lat_ctl_msg(
self.packer, self.CAN, lat_active, lat.ramp_type, lat.precision_type,
-lat.path_offset, -lat.path_angle, -lat.apply_curvature, -lat.curvature_rate
))
return can_sends, apply_curvature
def status(self) -> LateralStatus:
"""This frame's values for a BluePilot UI (HOSTS.md), as BP7 sets them on its car controller."""
angle_mode = self.curv.primary_lateral_control == PrimaryLateralControl.angle
return LateralStatus(
lateralUncertainty=self.lateral_uncertainty,
angleRateLimited=bool(angle_mode and self.angle.bp_angle_rate_limited),
curvatureRateLimited=bool(angle_mode and self.angle.bp_curvature_rate_limited),
curvatureDeviationLimited=bool(self.shared.bp_curvature_deviation_limited),
humanTurnLateralPaused=bool(angle_mode and self.angle.angle_human_turn_active),
stallBlipActive=bool(angle_mode and self.angle.angle_stall_blip_active),
disable_BP_lat_UI=bool(self.disable_BP_lat_UI),
primary_lateral_control=int(self.curv.primary_lateral_control),
)
def step_lka(self, CC, hud_control):
"""At LKA_STEP: the Lane_Assist_Data1 message."""
# BluePilot: tell bluepilot_lateral/safety/ford.h whether angle mode is engaged, packed into
# Lane_Assist_Data1's unused bits. shadow_curvature is negated to match the
# path_angle/apply_curvature wire convention that ford.h's angle_meas is calibrated against.
# Only called while BluePilot lateral is active (update returned True this frame), so the mode
# alone decides.
angle_mode_engaged = self.curv.primary_lateral_control == PrimaryLateralControl.angle
shadow_curvature = -self.angle.bp_kappa_cmd if angle_mode_engaged else 0.0
return fordcan_ext.create_lka_msg(
self.packer, self.CAN, CC.latActive, hud_control, angle_mode_engaged, shadow_curvature
)
@@ -7,8 +7,8 @@ each mode DOES with the signal differs: curvature-primary (``lateral_curv_ext``)
(reset_steering + post-reset ramp); angle-primary (``lateral_angle_ext``) forces lateral inactive
(mode 0 on the wire) and ramps path_angle back in from zero through its soft ROC on release.
"""
from opendbc.car import DT_CTRL
from opendbc.car.ford.values import CarControllerParams
from ...car import DT_CTRL
from ...car.ford.values import CarControllerParams
# Require sustained hands-on AND a large angle (avoids resetting on small wheel nudges in a curve).
HUMAN_TURN_ANGLE_DEG = 45.0
@@ -14,10 +14,10 @@ for a lane-centering nudge, and it bypasses every limiter angle mode already app
This class instead computes a small correction in the **curvature domain**, meant to be added to
``kappa_cmd`` before any of those limiters run -- so the correction automatically inherits all of
them, with no new safety-relevant code path. This mirrors where a GM-focused fork (StarPilot,
u/jc01rho -- ``selfdrive/controls/lib/drive_helpers.py::LaneCenteringController`` on
``feat/lane-centering-camera-offset``) places their own lane-centering trim: on the universal
``desired_curvature`` signal, upstream of any car-specific actuator conversion. The geometry here
them, with no new safety-relevant code path. The core is ported from StarPilot's
``selfdrive/controls/lib/lane_centering.py`` (``LaneCenteringController``, by jc01rho; see
CREDITS.md), which applies its trim the same way: on the universal ``desired_curvature`` signal,
upstream of any car-specific actuator conversion. The geometry here
(``raw_correction = 2*error / lookahead**2``) is the same ``y ~= 1/2 * kappa * x**2`` relation
BluePilot already uses for PSCM d_ref / path_angle elsewhere in this package, solved for the
curvature that would eliminate a lateral position error at the lookahead distance.
@@ -184,6 +184,12 @@ class LaneCenterTrim:
return 0.0, 0.0
if not (np.all(np.diff(left_x) > 0) and np.all(np.diff(right_x) > 0)):
return 0.0, 0.0
if not all(np.isfinite(v) for v in (probs[1], probs[2], stds[1], stds[2])):
# Unlike the arrays above, a NaN here doesn't raise -- it flows straight into min()/
# np.interp() and poisons confidence (and therefore the correction) with a NaN that
# survives every subsequent frame until a hard reset, instead of erroring or getting
# clipped like everything else in this function.
return 0.0, 0.0
left = float(np.interp(lookahead, left_x, left_y))
right = float(np.interp(lookahead, right_x, right_y))
@@ -194,7 +200,8 @@ class LaneCenterTrim:
# probability via min() -- a single missing/unreliable line (e.g. no line on the curb
# side, only a center stripe) drags confidence toward 0 on its own.
width_tolerance = float(np.interp(width, _WIDTH_TOLERANCE_BP, _WIDTH_TOLERANCE_V))
std_tolerance = float(np.interp(max(float(stds[1]), float(stds[2])), _STD_TOLERANCE_BP, _STD_TOLERANCE_V)) #StarPilot stopped at 0.3, this fades the std through the table
# StarPilot stopped at 0.3, this fades the std through the table
std_tolerance = float(np.interp(max(float(stds[1]), float(stds[2])), _STD_TOLERANCE_BP, _STD_TOLERANCE_V))
confidence = min(float(probs[1]), float(probs[2]), width_tolerance, std_tolerance) #confidence is the weakest signal
scale = float(np.clip(np.interp(confidence, _CONFIDENCE_BP, _CONFIDENCE_V), 0.0, 1.0))
center_y = 0.5 * (left + right)
@@ -2,55 +2,49 @@
BluePilot: Ford CAN-FD path-angle–primary lateral control (developer).
Steering intent is c1 from κ → θ. Converts planner/model curvature into ``path_angle`` using
PSCM short lookahead d_ref and y ≈ ½κ x² ⇒ path_angle = ½ κ d_ref (see
``bluepilot/agent_info/20_FORD_PSCM_KNOWLEDGE_PACK.md``). Predicted curvature (modelV2) is
blended with ``actuators.curvature`` per ``FordPathAngleBlendRatio`` (0 = planner only,
PSCM short lookahead d_ref and y ≈ ½κ x² ⇒ path_angle = ½ κ d_ref. Predicted curvature
(modelV2) is blended with ``actuators.curvature`` by ``path_angle_blend_ratio`` (0 = planner only,
1 = model only).
**c0 (path_offset) is always zero on the wire, unconditionally.** Angle mode has no centering
trim -- an earlier port attempt piped a small additive trim onto path_angle through the
curv-mode ``LC_PID_controller``, but it never actually tracked lane center correctly in this
mode and was removed; only the DBC-required zero c0 remains.
**c0 (path_offset) is always zero on the wire, unconditionally.** An earlier port attempt piped
a small additive trim onto path_angle through the curv-mode ``LC_PID_controller``, but it never
actually tracked lane center correctly in this mode: path_angle here is a derived quantity
(``kappa_cmd * v_ego * curvature_factor``), so an additive trim in that domain has the wrong
(inverted) speed-dependence for a lane-centering nudge, and it bypassed every limiter this file
applies to ``kappa_cmd``. That attempt was removed; only the DBC-required zero c0 remains.
**Lane centering trim (``lane_center_trim.py``)** replaces it: a small correction applied to
``kappa_cmd`` itself (see ``LaneCenterTrim``), before the deviation clip / gain table / PSCM
clamp / soft ROC below -- so it inherits every one of those limiters automatically instead of
bypassing them. Blends toward lane-line center by confidence (same formula as
``lateral_curv_ext``'s ``path_offset``) and falls back to the model's own predicted path -- not
to zero -- when lines are missing/unreliable, so the user's left/right offset still applies on
center-stripe-only roads. Disabled during lane changes, user-tunable (enable, offset, authority)
via ``enable_lane_positioning_ang`` / ``custom_path_offset_ang`` / ``lane_centering_strength_ang``.
**Human-turn override**: while the driver manually turns (same sustained-press + angle criteria
as ``lateral_curv_ext``, via the shared ``HumanTurnDetector``), lateral is forced inactive (mode
0, all-zero signals) instead of winding path_angle into a stale command the PSCM has to reconcile
on release -- on the Mach-E's PSCM that reconciliation cost 2-3 s of dead time before control
resumed. Mode 0 is panda-clean by construction: every ford.h check has a legitimate
!steer_control_enabled branch, so no reset-bypass latch involvement. On release, path_angle ramps
back in from zero through the soft ROC below (no jump seed) -- generous at human-turn speeds, and
admitted by ford.h's path_angle ROC check (2% looser) without any bypass.
!steer_control_enabled branch. On release, path_angle ramps back in from zero through the soft
ROC below (no jump seed) -- generous at human-turn speeds, and admitted by ford.h's path_angle
ROC check (2% looser).
"""
import numpy as np
from numpy import clip, interp
from opendbc.car import DT_CTRL
from opendbc.car.lateral import apply_std_steer_angle_limits
from opendbc.car.ford.values import CAR, CarControllerParams
from opendbc.sunnypilot.car.ford.lateral_curv_ext import LateralResult
from opendbc.sunnypilot.car.ford.human_turn import HumanTurnDetector
from opendbc.sunnypilot.car.ford.values_ext import BP_ANGLE_LIMITS
from selfdrive.modeld.constants import ModelConstants
# Hard-coded per-platform gain defaults.
# CAN vehicles (Escape MK4, Bronco Sport, Explorer, Maverick, Edge)
_GAIN_CAN = (1.00, 1.15)
# CAN-FD body-on-frame trucks (F-150, Lightning, Expedition, Ranger)
_GAIN_CANFD_BOF = (0.95, 0.95)
# CAN-FD unibody SUVs (Mustang Mach-E, Escape MK4.5)
_GAIN_CANFD_SUV = (1.00, 1.05)
_CANFD_BOF_CARS = frozenset({
CAR.FORD_F_150_MK14,
CAR.FORD_F_150_LIGHTNING_MK1,
CAR.FORD_EXPEDITION_MK4,
CAR.FORD_RANGER_MK2,
})
_CANFD_SUV_CARS = frozenset({
CAR.FORD_MUSTANG_MACH_E_MK1,
CAR.FORD_ESCAPE_MK4_5,
})
from ...car import DT_CTRL
from ...car.lateral import apply_std_steer_angle_limits
from ...car.ford.values import CarControllerParams
from .lateral_curv_ext import LateralResult
from .lane_center_trim import LaneCenterTrim
from .values_ext import (
ANGLE_GAIN_CAN, ANGLE_GAIN_CANFD_BOF, ANGLE_GAIN_CANFD_BOF_CARS, ANGLE_GAIN_CANFD_SUV, ANGLE_GAIN_CANFD_SUV_CARS,
BP_ANGLE_LIMITS, LANE_CHANGE_FACTOR_BP, LANE_CHANGE_FACTOR_LOW,
)
from .values_ext import MODEL_T_IDXS
from ..params.manifest import default, value
# DBC ``LatCtlPath_An_Actl`` (rad) — panda safety uses the same in ``ford.h``; PSCM enforces in firmware.
FORD_DBC_PATH_ANGLE_MIN = -0.5
@@ -125,49 +119,62 @@ def pscm_d_ref_m(v_ego_ms: float) -> float:
return d
# the settings angle mode reads (params/manifest.py): (attribute, key)
ANGLE_SETTINGS = (
("low_speed_curv_factor", "FordLowSpeedFactor_ang"),
("high_speed_curv_factor", "FordHighSpeedFactor_ang"),
("user_dampening_factor", "FordHighSpeedDampening_ang"),
("lane_change_factor_high_ang", "lane_change_factor_high_ang"),
("enable_lane_positioning_ang", "enable_lane_positioning_ang"), # the lane centering trim (lane_center_trim.py)
("custom_path_offset_ang", "custom_path_offset_ang"),
("lane_centering_strength_ang", "lane_centering_strength_ang"),
)
class LateralAngleExt:
def __init__(self, CP=None, CP_SP=None):
# Predicted-curvature blend for path_angle: pred * b + desired * (1-b); b from ``FordPathAngleBlendRatio``
def __init__(self, CP, inputs, shared):
self.CP = CP
# the user settings (updated from the host's params each frame), at their defaults until then
for attr, key in ANGLE_SETTINGS:
setattr(self, attr, default(key))
# BluePilotLateral's LateralInputs (read) and SharedLateralState (read and written), common to both strategies
self.inputs = inputs
self.shared = shared
# Predicted-curvature blend for path_angle: pred * b + desired * (1-b); b is this fixed default
self.path_angle_blend_ratio = _FORD_PATH_ANGLE_BLEND_RATIO_DEFAULT
# Max extra VLT above t_base; from ``FordVLTExtraMax`` param
# Max extra VLT above t_base (fixed)
self.vlt_extra_max = _VLT_T_EXTRA_MAX
# Telemetry: final path_angle (rad) after limits (see bp_card_publisher)
# Telemetry: final path_angle (rad) after limits (for the BluePilot UI's bp_card_publisher)
self.bp_path_angle_final = 0.0
# High-speed gain factors: set per-platform via carFingerprint in update_angle_params.
self.path_angle_gain_lowC_highV = 1.0 # dampening at high speed, low curvature
self.path_angle_gain_highC_highV = 1.0 # gain at high speed, high curvature
self.bp_path_angle_gain_lowC_highV = 1.0
self.bp_path_angle_gain_highC_highV = 1.0
# User-tunable "feel" multipliers: read from the angle-tuning Params below.
self.low_speed_curv_factor = 1.0
self.high_speed_curv_factor = 1.0
self.user_dampening_factor = 1.0
# Telemetry copies of the user "feel" multipliers (ANGLE_SETTINGS)
self.bp_low_speed_curv_factor = 1.0
self.bp_high_speed_curv_factor = 1.0
# BluePilot: angle mode's own lane-change scaling factor, independent of curvature mode's
# lane_change_factor_high_curv -- angle needs a boost (>1) where curvature needs a cut (<1).
self.lane_change_factor_high_ang = 1.0
# BluePilot: angle-mode lane centering trim (advanced lane positioning) -- see
# lane_center_trim.py and the module docstring above.
self.lane_center_trim = LaneCenterTrim()
# Telemetry: variable curvature lookup time used this frame (s)
self.bp_curvature_lookup_time = _VLT_T_EXTRA_MAX + 0.3725 # warm start at ~0.5s
# BluePilot: error-clipped kappa path_angle was derived from -- carcontroller.py reads this as
# BluePilot: error-clipped kappa path_angle was derived from -- carcontroller_ext.py reads this as
# shadow_curvature for ford.h's angle-mode deviation check. Actively consumed, not telemetry.
self.bp_kappa_cmd = 0.0
# BluePilot: rate-limit diagnostics (controllerStateBP)
# BluePilot: rate-limit diagnostics (the BluePilot UI's controllerStateBP)
self.bp_angle_rate_limited = False # path_angle soft-ROC clip actually bit this frame
self.bp_curvature_rate_limited = False # equivalent curvature would be rate-limited by curv-mode logic (sim)
self.bp_curvature_deviation_limited = False # current_curvature error-clip constrained kappa_cmd this frame
self.sim_curvature_last = 0.0 # shadow curvature-mode last for the curvatureRateLimited sim
# Exit detection: track previous desired curvature to sense when planner is actively reducing
self._desired_curvature_last = 0.0
# Human-turn override: while the driver manually turns, lateral is forced inactive (mode 0,
# all-zero signals) instead of winding path_angle into a stale command the PSCM can't cleanly
# reconcile on release (2-3 s re-engage dead time observed on Mach-E). See module docstring.
# Note: in CarController this attribute is shared with LateralCurvExt (same mixin instance) --
# only one lateral strategy runs per frame, so a single detector serves both.
self.human_turn_detector = HumanTurnDetector()
self.angle_human_turn_active = False # read by carcontroller to force mode 0
# The detector itself is in BluePilotLateral's shared state: one detector serves both modes.
self.angle_human_turn_active = False # read by BluePilotLateral.step_lateral to force mode 0
# Post-override stall blip state (see module constants). angle_stall_blip_active is read by
# carcontroller to force mode 0, exactly like angle_human_turn_active.
# BluePilotLateral.step_lateral to force mode 0, exactly like angle_human_turn_active.
self.stall_blip_hold_s = 0.0 # accumulated deviation-clip-binding time toward a pulse
self.stall_blip_frames_left = 0 # remaining pulse frames; > 0 -> mode 0 on the wire
self.stall_blip_cooldown_s = 0.0 # re-arm delay after a pulse
@@ -177,47 +184,28 @@ class LateralAngleExt:
def update_angle_params(self, params):
"""Sets per-platform gain defaults and reads user angle-tuning params."""
self._ensure_lateral_curv_initialized(self.CP)
fp = getattr(self.CP, 'carFingerprint', '')
if fp in _CANFD_BOF_CARS:
low, high = _GAIN_CANFD_BOF
elif fp in _CANFD_SUV_CARS:
low, high = _GAIN_CANFD_SUV
if fp in ANGLE_GAIN_CANFD_BOF_CARS:
low, high = ANGLE_GAIN_CANFD_BOF
elif fp in ANGLE_GAIN_CANFD_SUV_CARS:
low, high = ANGLE_GAIN_CANFD_SUV
else:
low, high = _GAIN_CAN
low, high = ANGLE_GAIN_CAN
self.path_angle_gain_lowC_highV = low
self.path_angle_gain_highC_highV = high
if params is not None and hasattr(params, "get"):
for attr, key, min_value, max_value in (
("low_speed_curv_factor", "FordLowSpeedFactor_ang", 0.5, 1.5),
("high_speed_curv_factor", "FordHighSpeedFactor_ang", 0.5, 1.5),
("user_dampening_factor", "FordHighSpeedDampening_ang", 0.25, 1.25),
):
try:
raw = params.get(key, return_default=True)
if raw is not None and raw != b"":
setattr(self, attr, float(clip(
float(raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw), min_value, max_value)))
except Exception:
pass
try:
raw = params.get("lane_change_factor_high_ang", return_default=True)
if raw is not None and raw != b"":
self.lane_change_factor_high_ang = float(clip(
float(raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw), 0.85, 1.50))
except Exception:
pass
if params is not None:
for attr, key in ANGLE_SETTINGS:
setattr(self, attr, value(params, key))
def update_angle_strategy(self, CC, CS, actuators, CP):
def update_angle_strategy(self, CC, CS, actuators):
"""
Curvature from planner (+ optional predicted blend) → path_angle via ½·κ·d_ref.
c0 (path_offset) is always zero on the wire -- no centering trim in angle mode. c2 and c3 are zero.
Curvature from planner (+ optional predicted blend, + lane centering trim) → path_angle via
½·κ·d_ref. c0 (path_offset) is always zero on the wire; the lane centering trim lives entirely
in the curvature domain (kappa_cmd), not on c0. c2 and c3 are zero.
Blended κ is not passed through Ford c2 rate / DBC limits (those target the curvature actuator).
"""
self._ensure_lateral_curv_initialized(CP)
v_ego = float(CS.out.vEgoRaw)
d_ref = pscm_d_ref_m(v_ego)
d_ref = pscm_d_ref_m(v_ego) # noqa: F841 # BluePilot: unused today; kept verbatim from bp-dev
curvature_rate = 0.0
path_offset = 0.0
@@ -227,12 +215,11 @@ class LateralAngleExt:
precision = 1
if not CC.latActive:
self.path_angle_last = 0.0
self.shared.path_angle_last = 0.0
self.bp_path_angle_final = 0.0
self.apply_curvature_last = 0.0
self.bp_angle_rate_limited = False
self.bp_curvature_rate_limited = False
self.bp_curvature_deviation_limited = False
self.shared.bp_curvature_deviation_limited = False
self.sim_curvature_last = 0.0
# Publish the shadow curvature from the measured curvature while inactive. LKA keeps
# carrying angle_mode_engaged whenever angle mode is configured (independent of
@@ -242,16 +229,17 @@ class LateralAngleExt:
# ford.h's deviation check compares a zero shadow against real measured curvature.
# (ford.h skips the check while steer_control_enabled is 0, so the value is free to
# follow the measurement during the inactive period itself.)
self.bp_kappa_cmd = self.get_current_curvature(CS)
self.human_turn_detector.reset()
self.bp_kappa_cmd = self.inputs.get_current_curvature(CS)
self.shared.human_turn_detector.reset()
self.angle_human_turn_active = False
self.lane_center_trim.reset()
self.stall_blip_hold_s = 0.0
self.stall_blip_frames_left = 0
self.stall_blip_cooldown_s = 0.0
self.stall_blip_count = 0
self.angle_stall_blip_active = False
self.press_timer_s = 0.0
self.precision_type = 1
self.shared.precision_type = 1
return LateralResult(
apply_curvature=0.0,
curvature_rate=0.0,
@@ -263,28 +251,28 @@ class LateralAngleExt:
)
# Human-turn override: sustained driver press + large wheel angle → force lateral inactive
# (carcontroller drops mode to 0; all signals are zero on the wire) so path_angle can't wind
# (BluePilotLateral.step_lateral drops mode to 0; all signals are zero on the wire) so path_angle can't wind
# into a stale command while the driver turns. Always on in angle mode (no param gate) -- the
# curv-suffixed human-turn toggle belongs to curvature mode's reset strategy, and the Mach-E
# PSCM re-engage stall this prevents is not something a user should be able to opt out of.
# On release, no jump seed: path_angle_last is 0, so the normal flow below ramps the command
# back in through the soft ROC -- generous at human-turn speeds, no panda bypass involved.
self.angle_human_turn_active = self.human_turn_detector.update(
# back in through the soft ROC -- generous at human-turn speeds.
self.angle_human_turn_active = self.shared.human_turn_detector.update(
True, CS.out.steeringPressed, CS.out.steeringAngleDeg)
if self.angle_human_turn_active:
self.path_angle_last = 0.0
self.shared.path_angle_last = 0.0
self.bp_path_angle_final = 0.0
self.apply_curvature_last = 0.0
self.bp_angle_rate_limited = False
self.bp_curvature_rate_limited = False
self.bp_curvature_deviation_limited = False
self.shared.bp_curvature_deviation_limited = False
self.sim_curvature_last = 0.0
# Truthful shadow during the override (mirrors the inactive path -- see the comment
# there): the driver is steering, so the honest command is the car's actual curvature,
# and the panda-latched shadow stays current for the re-engage frame.
self.bp_kappa_cmd = self.get_current_curvature(CS)
self.bp_kappa_cmd = self.inputs.get_current_curvature(CS)
# Keep exit detection current so resume doesn't compare against a stale pre-turn value.
self._desired_curvature_last = float(actuators.curvature)
self.lane_center_trim.reset()
# A human turn ends any stall episode -- its own mode 0 does the PSCM reset job. That also
# covers the press so far: only press time accumulated AFTER the latch releases should earn
# a hand-off pulse.
@@ -294,7 +282,7 @@ class LateralAngleExt:
self.stall_blip_count = 0
self.angle_stall_blip_active = False
self.press_timer_s = 0.0
self.precision_type = 1
self.shared.precision_type = 1
return LateralResult(
apply_curvature=0.0,
curvature_rate=0.0,
@@ -314,7 +302,7 @@ class LateralAngleExt:
else:
if (self.press_timer_s >= _PRESS_BLIP_MIN_S and self.stall_blip_cooldown_s <= 0.0
and self.stall_blip_frames_left <= 0
and abs(self.path_angle_last) < _BLIP_MAX_PATH_ANGLE):
and abs(self.shared.path_angle_last) < _BLIP_MAX_PATH_ANGLE):
self.stall_blip_frames_left = _STALL_BLIP_FRAMES
self.press_timer_s = 0.0
@@ -326,17 +314,17 @@ class LateralAngleExt:
if self.stall_blip_frames_left > 0:
self.stall_blip_frames_left -= 1
self.angle_stall_blip_active = True
self.path_angle_last = 0.0
self.shared.path_angle_last = 0.0
self.bp_path_angle_final = 0.0
self.apply_curvature_last = 0.0
self.bp_angle_rate_limited = False
self.bp_curvature_rate_limited = False
self.bp_curvature_deviation_limited = False
self.shared.bp_curvature_deviation_limited = False
self.sim_curvature_last = 0.0
# Truthful shadow during the blip (see the inactive-path comment).
self.bp_kappa_cmd = self.get_current_curvature(CS)
self.bp_kappa_cmd = self.inputs.get_current_curvature(CS)
self._desired_curvature_last = float(actuators.curvature)
self.precision_type = 1
self.lane_center_trim.reset()
self.shared.precision_type = 1
if self.stall_blip_frames_left <= 0:
self.stall_blip_cooldown_s = _STALL_COOLDOWN_S
return LateralResult(
@@ -350,9 +338,9 @@ class LateralAngleExt:
)
self.angle_stall_blip_active = False
self.precision_type = 1
self.shared.precision_type = 1
precision = 1
LP = self.lp
LP = self.inputs.lp # noqa: F841 # BluePilot: unused today; kept verbatim from bp-dev
desired_curvature = float(actuators.curvature)
# Variable lookup time: t_base tracks planner pre-compensation; extra tapers on high speed and large curves.
@@ -361,14 +349,14 @@ class LateralAngleExt:
# curvature, kappa_entering stays True, and the exit-biased blend is permanently disabled — causing the car
# to command max path_angle through the entire apex. 0.15s gives t_base ≤ 0.20s and VLT ≤ 0.33s, restoring
# the 2.8m lookahead that kept kappa_entering False at the apex in successful earlier runs.
_t_base = float(clip(self.sm['liveDelay'].lateralDelay, 0.1, 0.15)) + _DT_MDL
_t_base = float(clip(self.inputs.lateral_delay, 0.1, 0.15)) + _DT_MDL
_speed_factor = float(interp(v_ego, [_VLT_V_LOW_MS, _VLT_V_HIGH_MS], [1.0, 0.0]))
# Direction-aware kappa factor: on curve ENTRY (model shows more curvature at t_base than planner now),
# keep full lookahead so pre-steering begins early. On exit/apex, taper by magnitude to prevent unwind.
_kappa_at_t_base = 0.0
if self.model is not None and len(self.model.orientationRate.z) >= 17:
_curvatures_ref = np.array(self.model.orientationRate.z) / max(0.01, v_ego)
_kappa_at_t_base = abs(float(interp(_t_base, ModelConstants.T_IDXS, _curvatures_ref)))
if self.inputs.model is not None and len(self.inputs.model.orientationRate.z) >= 17:
_curvatures_ref = np.array(self.inputs.model.orientationRate.z) / max(0.01, v_ego)
_kappa_at_t_base = abs(float(interp(_t_base, MODEL_T_IDXS, _curvatures_ref)))
_kappa_entering = _kappa_at_t_base > abs(desired_curvature)
if _kappa_entering:
_kappa_factor = 1.0 # curve deepening ahead: full extra lookahead for gradual entry
@@ -378,10 +366,10 @@ class LateralAngleExt:
self.bp_curvature_lookup_time = curvature_lookup_time
predicted_curvature = 0.0
if self.model is not None and len(self.model.orientationRate.z) >= 17:
curvatures = np.array(self.model.orientationRate.z) / max(0.01, v_ego)
if self.inputs.model is not None and len(self.inputs.model.orientationRate.z) >= 17:
curvatures = np.array(self.inputs.model.orientationRate.z) / max(0.01, v_ego)
predicted_curvature = float(
interp(curvature_lookup_time, ModelConstants.T_IDXS, curvatures)
interp(curvature_lookup_time, MODEL_T_IDXS, curvatures)
)
b = float(self.path_angle_blend_ratio)
@@ -400,8 +388,8 @@ class LateralAngleExt:
# That caused a positive-feedback flat-line: under-steer → CtrSat → path_angle frozen → more under-steer.
# Use DBC-limit proximity instead: only block when path_angle is already near the ±0.5 rad CAN limits,
# which is the only condition where the anti-snap unwind rate cap makes physical sense.
_dbc_sat = (self.path_angle_last >= FORD_DBC_PATH_ANGLE_MAX * 0.90 or
self.path_angle_last <= FORD_DBC_PATH_ANGLE_MIN * 0.90)
_dbc_sat = (self.shared.path_angle_last >= FORD_DBC_PATH_ANGLE_MAX * 0.90 or
self.shared.path_angle_last <= FORD_DBC_PATH_ANGLE_MIN * 0.90)
_in_hard_sat = _pscm_lim >= 2 or _dbc_sat
# BluePilot: per-call delta threshold. The original 0.002 was authored 2026-05-07 on
# bp-sid-simple (9c3d000fd), which ran STEER_STEP=1 (true 100Hz, switched 2026-04-22) -- so it
@@ -415,26 +403,36 @@ class LateralAngleExt:
requested_curvature = predicted_curvature * b_blend + desired_curvature * (1.0 - b_blend)
self._desired_curvature_last = desired_curvature
if self.model is not None:
self.lane_change = self.model.meta.laneChangeState in (1, 2, 3)
if self.inputs.model is not None:
self.lane_change = self.inputs.model.meta.laneChangeState in (1, 2, 3)
else:
self.lane_change = False
lane_change_factor = interp(
v_ego, self.lane_change_factor_bp, [self.lane_change_factor_low, self.lane_change_factor_high_ang]
v_ego, LANE_CHANGE_FACTOR_BP, [LANE_CHANGE_FACTOR_LOW, self.lane_change_factor_high_ang]
)
if self.lane_change and self.model is not None:
if self.model.meta.laneChangeDirection == 1 and requested_curvature < 0:
if self.lane_change and self.inputs.model is not None:
if self.inputs.model.meta.laneChangeDirection == 1 and requested_curvature < 0:
requested_curvature *= lane_change_factor
precision = 0
elif self.model.meta.laneChangeDirection == 2 and requested_curvature > 0:
elif self.inputs.model.meta.laneChangeDirection == 2 and requested_curvature > 0:
requested_curvature *= lane_change_factor
precision = 0
self.precision_type = precision
self.shared.precision_type = precision
# Use planner / predicted κ directly for the κ → path_angle map; we are not sending κ on CAN.
kappa_cmd = float(requested_curvature)
# BluePilot: lane centering trim (advanced lane positioning) -- nudges kappa_cmd toward true
# lane-line center + user offset, gated on lane-line confidence and disabled during lane
# changes (see lane_center_trim.py). Applied here, before the deviation clip below, so the
# trimmed value inherits every limiter this file already applies to kappa_cmd instead of
# bypassing them.
kappa_cmd = self.lane_center_trim.update(
kappa_cmd, self.inputs.model, v_ego, self.enable_lane_positioning_ang,
self.custom_path_offset_ang, self.lane_centering_strength_ang,
CC.latActive, self.lane_change)
# BluePilot: clip kappa_cmd to current_curvature (measured, from yaw rate) +- CURVATURE_ERROR,
# mirroring lateral_curv_ext.py's apply_ford_curvature_limits_ext exactly (same formula, same
# v_ego > 9 gate, same CarControllerParams.CURVATURE_ERROR tolerance). Without this, kappa_cmd
@@ -444,20 +442,18 @@ class LateralAngleExt:
# routinely, not just on genuine pothole/override divergence. Curvature mode has always clipped
# here; this brings angle mode's actual steering intent in line with that proven behavior rather
# than only clipping the value reported to panda (which would make the check a no-op).
current_curvature = self.get_current_curvature(CS)
self.bp_curvature_deviation_limited = False
current_curvature = self.inputs.get_current_curvature(CS)
self.shared.bp_curvature_deviation_limited = False
if v_ego > 9:
_kappa_cmd_pre_error_clip = kappa_cmd
kappa_cmd = float(clip(kappa_cmd, current_curvature - CarControllerParams.CURVATURE_ERROR,
current_curvature + CarControllerParams.CURVATURE_ERROR))
# BluePilot: did this clip actually constrain kappa_cmd this frame (deviation from measured,
# not rate-of-change -- see carcontroller.py)?
self.bp_curvature_deviation_limited = bool(abs(kappa_cmd - _kappa_cmd_pre_error_clip) > 1e-9)
# not rate-of-change)? The BluePilot UI's deviation flag.
self.shared.bp_curvature_deviation_limited = bool(abs(kappa_cmd - _kappa_cmd_pre_error_clip) > 1e-9)
lateral_uncertainty = 0.0 # no curvature-limit ladder until angle-mode torque display is defined
# Speed-interpolated gain: at low speed both curves use 1.0; at high speed the params take effect.
self.low_gain_calc = interp(
v_ego, [13.5, 26.82], [1.0, (self.path_angle_gain_lowC_highV * self.user_dampening_factor)]
@@ -470,7 +466,6 @@ class LateralAngleExt:
path_angle_calc = kappa_cmd * v_ego * self.curvature_factor
path_angle = path_angle_calc
# PSCM authority limit clamp.
# On CANFD Fords in angle mode, LatCtlLim_D_Stat does not fire, so _pscm_lim stays 0.
# _in_hard_sat (computed above) combines _pscm_lim >= 2 with _dbc_sat (path_angle near ±0.5 rad limit).
@@ -479,7 +474,7 @@ class LateralAngleExt:
# Without the decrease cap, model+planner drop path_angle at ~0.36 rad/s at a sharp apex,
# driving desired steering 30°+ ahead of actual while the PSCM is pinned, causing a snap when released.
if _in_hard_sat:
_last = self.path_angle_last
_last = self.shared.path_angle_last
_last_mag = abs(_last)
_curr_mag = abs(path_angle)
if _curr_mag > _last_mag: # magnitude growing — block
@@ -488,7 +483,7 @@ class LateralAngleExt:
_limited_mag = _last_mag - _PSCM_SAT_UNWIND_RATE
path_angle = float(_limited_mag if _last >= 0 else -_limited_mag)
elif _pscm_lim >= 1: # LimitClose (F150/non-angle-mode only): block increases only
path_angle = float(clip(path_angle, -abs(self.path_angle_last), abs(self.path_angle_last)))
path_angle = float(clip(path_angle, -abs(self.shared.path_angle_last), abs(self.shared.path_angle_last)))
path_angle = min(FORD_DBC_PATH_ANGLE_MAX, max(FORD_DBC_PATH_ANGLE_MIN, path_angle))
@@ -504,12 +499,11 @@ class LateralAngleExt:
_soft_roc = float(interp(v_ego, [9., 10., 15., 25.], [0.055, 0.055, 0.0425, 0.009]))
_path_angle_pre_roc = path_angle
path_angle = float(clip(path_angle,
self.path_angle_last - _soft_roc,
self.path_angle_last + _soft_roc))
self.shared.path_angle_last - _soft_roc,
self.shared.path_angle_last + _soft_roc))
# BluePilot: did the soft ROC clip actually limit the path_angle we wanted to send this frame?
self.bp_angle_rate_limited = bool(abs(path_angle - _path_angle_pre_roc) > 1e-9)
# c0 always zero -- no centering trim in angle mode.
path_offset = 0.0
@@ -518,10 +512,9 @@ class LateralAngleExt:
self.bp_path_angle_gain_highC_highV = self.path_angle_gain_highC_highV
self.bp_low_speed_curv_factor = self.low_speed_curv_factor
self.bp_high_speed_curv_factor = self.high_speed_curv_factor
self.path_angle_last = path_angle
self.shared.path_angle_last = path_angle
self.bp_path_angle_final = path_angle
self.apply_curvature_last = 0.0
# BluePilot: the error-clipped kappa path_angle was derived from -- carcontroller.py reads this
# BluePilot: the error-clipped kappa path_angle was derived from -- carcontroller_ext.py reads this
# as shadow_curvature for ford.h's angle-mode deviation check (see fordcan_ext.create_lka_msg).
# Not just telemetry: an actively-consumed value, unlike the removed *_kappa_cmd_raw stubs.
# While the driver is pressing (before the human-turn override latches), the clipped planner
@@ -530,7 +523,7 @@ class LateralAngleExt:
# in-drive lateral safety block observed across ~3h of replayed road-test routes was exactly
# this (driver fighting a sustained curve with the mode still enabled). The honest command
# during a press is the driver's actual curvature.
self.bp_kappa_cmd = self.get_current_curvature(CS) if CS.out.steeringPressed else kappa_cmd
self.bp_kappa_cmd = self.inputs.get_current_curvature(CS) if CS.out.steeringPressed else kappa_cmd
# BluePilot: would the equivalent curvature (kappa_cmd) have been rate-limited by curvature-mode's
# ROC (apply_std_steer_angle_limits)? kappa_cmd is already error-clipped above (same clip
@@ -551,10 +544,10 @@ class LateralAngleExt:
and abs(_stall_gap) > _STALL_GAP_MIN
and abs(desired_curvature) > abs(current_curvature))
if _stalled:
if self.bp_curvature_deviation_limited and self.stall_blip_cooldown_s <= 0.0:
if self.shared.bp_curvature_deviation_limited and self.stall_blip_cooldown_s <= 0.0:
self.stall_blip_hold_s += _STEER_DT
if (self.stall_blip_hold_s >= _STALL_HOLD_S and self.stall_blip_count < _STALL_MAX_BLIPS
and abs(self.path_angle_last) < _BLIP_MAX_PATH_ANGLE):
and abs(self.shared.path_angle_last) < _BLIP_MAX_PATH_ANGLE):
self.stall_blip_frames_left = _STALL_BLIP_FRAMES
self.stall_blip_hold_s = 0.0
self.stall_blip_count += 1
@@ -565,13 +558,12 @@ class LateralAngleExt:
ramp_type = 2
return LateralResult(
apply_curvature=0.0,
curvature_rate=curvature_rate,
path_offset=path_offset,
path_angle=path_angle,
ramp_type=ramp_type,
precision_type=self.precision_type,
precision_type=self.shared.precision_type,
lateralUncertainty=lateral_uncertainty,
)
@@ -0,0 +1,448 @@
"""
BluePilot Ford lateral curvature extension.
Implements Ford's 4-signal lateral control (curvature, curvature_rate, path_offset, path_angle)
using predicted curvature from modelV2, PID-based lane centering, and laneline-aware path offset.
Held by BluePilotLateral (carcontroller_ext.py) as LateralCurvExt.
Ford uses four signals to control steering:
- curvature: primary steering command (also used in upstream, limited to 0.02 m^-1)
- curvature_rate: always 0, like upstream and stock Ford
- path_offset: lateral offset for lane centering (blends model + laneline data)
- path_angle: heading angle correction via PID controller
A detailed explanation of Ford's control protocol:
https://www.f150gen14.com/forum/threads/introducing-bluepilot-a-ford-specific-fork-for-comma3x-openpilot.24241/#post-457706
"""
from collections import namedtuple, deque
from enum import IntEnum
import numpy as np
from numpy import clip, interp
from ...car.common.pid import PIDController
from ...car import ACCELERATION_DUE_TO_GRAVITY
from ...car.lateral import ISO_LATERAL_ACCEL, apply_std_steer_angle_limits
from ...car.ford.values import CarControllerParams, FordFlags
from .values_ext import BP_ANGLE_LIMITS, CURVATURE_MAX, LANE_CHANGE_FACTOR_BP, LANE_CHANGE_FACTOR_LOW
from .values_ext import MODEL_T_IDXS
from ..params.manifest import default, value
# the settings curvature mode reads (params/manifest.py), each kept as an attribute of the same name
CURV_SETTINGS = ("enable_human_turn_detection_curv", "lane_change_factor_high_curv", "pc_blend_ratio_high_C_UI_curv",
"pc_blend_ratio_low_C_UI_curv", "enable_lane_positioning_curv", "custom_path_offset_curv",
"enable_lane_full_mode_curv", "custom_profile_curv", "LC_PID_gain_UI_curv")
class PrimaryLateralControl(IntEnum):
curvature = 0
angle = 1
# CAN FD lateral-accel cap (match opendbc/car/ford/carcontroller.py apply_ford_curvature_limits)
AVERAGE_ROAD_ROLL = 0.06 # ~3.4 degrees, 6% superelevation
MAX_LATERAL_ACCEL = ISO_LATERAL_ACCEL - (ACCELERATION_DUE_TO_GRAVITY * AVERAGE_ROAD_ROLL)
# Result namedtuple returned by LateralCurvExt.update()
LateralResult = namedtuple('LateralResult', [
'apply_curvature',
'curvature_rate',
'path_offset',
'path_angle',
'ramp_type',
'precision_type',
'lateralUncertainty',
])
def apply_ford_curvature_limits_ext(apply_curvature, apply_curvature_last, current_curvature,
v_ego_raw, steering_angle, lat_active, CP):
"""Extended version of apply_ford_curvature_limits that returns
(apply_curvature, max_curvature, curvature_deviation_limited).
The max_curvature value is used by _calculate_lateral_uncertainty() for the steering
torque bar visualization. Stock version returns only apply_curvature.
curvature_deviation_limited (BluePilot): True when the current_curvature +- CURVATURE_ERROR
clip below actually constrained the commanded curvature this frame -- i.e. the desired
maneuver was limited by deviation from measured current curvature, not by rate-of-change
(apply_std_steer_angle_limits below is a separate limiter with its own telemetry).
"""
max_curvature = 1 # large initial value
curvature_deviation_limited = False
# No blending at low speed due to lack of torque wind-up and inaccurate current curvature
if v_ego_raw > 9:
apply_curvature_pre_error_clip = apply_curvature
apply_curvature = np.clip(apply_curvature, current_curvature - CarControllerParams.CURVATURE_ERROR,
current_curvature + CarControllerParams.CURVATURE_ERROR)
curvature_deviation_limited = bool(abs(apply_curvature - apply_curvature_pre_error_clip) > 1e-9)
max_curvature = abs(current_curvature) + CarControllerParams.CURVATURE_ERROR
# Curvature rate limit after driver torque limit (same inputs/order as apply_ford_curvature_limits)
apply_curvature_before_std = apply_curvature
apply_curvature = apply_std_steer_angle_limits(apply_curvature_before_std, apply_curvature_last, v_ego_raw,
steering_angle, lat_active, BP_ANGLE_LIMITS)
# Max one-step envelope for UI: steer_up must use pre-limit curvature (see apply_std_steer_angle_limits)
steer_up = (apply_curvature_last * apply_curvature_before_std >= 0.0
and abs(apply_curvature_before_std) > abs(apply_curvature_last))
rate_limits = BP_ANGLE_LIMITS.ANGLE_RATE_LIMIT_UP if steer_up else BP_ANGLE_LIMITS.ANGLE_RATE_LIMIT_DOWN
std_steer_angle_rate_limit = np.interp(v_ego_raw, rate_limits[0], rate_limits[1])
std_steer_angle_limit = abs(apply_curvature_last) + abs(std_steer_angle_rate_limit)
max_curvature = np.minimum(max_curvature, std_steer_angle_limit)
# Ford CAN FD lateral acceleration limit (more torque available than CAN)
if CP.flags & FordFlags.CANFD:
curvature_accel_limit = MAX_LATERAL_ACCEL / (max(v_ego_raw, 1) ** 2)
apply_curvature = float(np.clip(apply_curvature, -curvature_accel_limit, curvature_accel_limit))
max_curvature = np.minimum(max_curvature, abs(curvature_accel_limit))
return apply_curvature, max_curvature, curvature_deviation_limited
class LateralCurvExt:
"""
BluePilot lateral control extension for Ford vehicles.
Held by BluePilotLateral (carcontroller_ext.py), which calls
update() during the lateral control loop (20Hz) to get the full
4-signal steering command instead of the upstream curvature-only approach.
"""
def __init__(self, inputs, shared):
# BluePilotLateral's LateralInputs (read) and SharedLateralState (read and written), common to both strategies
self.inputs = inputs
self.shared = shared
# Primary lateral control variable: consumed by BluePilotLateral's lateral dispatch.
self.primary_lateral_control = PrimaryLateralControl.curvature
# Settings (updated from the host's params each frame), at their defaults until then
for key in CURV_SETTINGS:
setattr(self, key, default(key))
# Precision/ramp control (precision_type is in the shared state)
self.lateralUncertainty = 0.0
# Predicted curvature blending
self.curvature_lookup_time = 0.2 # seconds into the future for curvature extraction
self.pc_blend_ratio = 0.5
self.pc_blend_ratio_bp = [0.0, 0.001] # curvature breakpoints (1/m)
self.pc_blend_ratio_low = 0.40
self.pc_blend_ratio_high = 0.40
self.pc_blend_ratio_low_C = 0.40 # from UI when custom_profile_curv == 1
self.pc_blend_ratio_high_C = 0.40 # from UI when custom_profile_curv == 1
# Lane change smoothing
self.lane_change = False
self.lane_change_last = False
# Post lane change transition
self.post_lane_change_timer = 0
self.post_lane_change_active = False
self.pre_lane_change_values = {'path_angle': 0.0, 'path_offset': 0.0}
self.max_path_angle_change = 0.00125
self.max_path_offset_change = 0.00125
# Human turn detection (the detector is in the shared state, used by both modes)
self.human_turn = False
self.post_reset_ramp_active = False
self.reset_steering_last = False
# Path offset
self.path_offset_lookup_time = 0.2 # seconds
self.min_laneline_confidence_bp = [0.6, 0.8]
# PID-based path angle for lane centering
self.path_angle_filter_samples = 3
self.path_angle_deque = deque(maxlen=self.path_angle_filter_samples)
self.LC_PID_k_p = 0.25
self.LC_PID_k_i = 0.05
self.LC_PID_controller = PIDController(k_p=self.LC_PID_k_p, k_i=self.LC_PID_k_i, rate=20)
self.LC_PID_speed_bp = [0.0, 9.0, 15.0] # m/s
self.LC_PID_speed_v = [0.0, 0.0, 1.0]
self.LC_path_angle_ROC_bp = [5, 15, 25] # m/s
self.LC_path_angle_ROC_v = [0.003, 0.0015, 0.002] # match panda limits
self.LC_path_angle_reset_counter = 0
self.LC_path_angle_reset_duration = 1.5 # seconds
# DBC signal max limits
self.path_angle_max = 0.5
self.path_offset_max = 2.0
self.curvature_max = CURVATURE_MAX # 0.02
# Previous frame values
self.path_offset_last = 0.0
def update_lateral_params(self, params):
"""Read lateral-related Params from the UI. Called each frame."""
for key in CURV_SETTINGS:
setattr(self, key, value(params, key))
try:
self.primary_lateral_control = PrimaryLateralControl(value(params, "FordPrefLateralControl"))
except ValueError: # a mode other than 0 or 1: curvature mode, the default (#66)
self.primary_lateral_control = PrimaryLateralControl.curvature
def update(self, CC, CS, actuators, apply_curvature_last, CP):
"""
Compute lateral steering signals for the current frame.
Called at 20Hz from BluePilotLateral.step_lateral() when inside the STEER_STEP block.
Args:
CC: CarControl with latActive, hudControl
CS: CarState with vEgoRaw, yawRate, steeringPressed, steeringAngleDeg
actuators: CC.actuators with desired curvature from planner
apply_curvature_last: previous frame's applied curvature
CP: CarParams with flags for CANFD etc.
Returns:
LateralResult namedtuple with all signals needed for CAN message construction.
"""
apply_curvature = 0.0
path_offset = 0.0
path_angle = 0.0
reset_steering = 0
ramp_type = 2
lateralUncertainty = 0.0
curvature_deviation_limited = False
if CC.latActive:
self.shared.precision_type = 1
steeringPressed = CS.out.steeringPressed
steeringAngleDeg_PV = CS.out.steeringAngleDeg
# Select tuning profile
if self.custom_profile_curv == 1:
self.pc_blend_ratio_low_C = self.pc_blend_ratio_low_C_UI_curv
self.pc_blend_ratio_high_C = self.pc_blend_ratio_high_C_UI_curv
self.pc_blend_ratio_v = [self.pc_blend_ratio_low_C, self.pc_blend_ratio_high_C]
# Current and desired curvature
current_curvature = self.inputs.get_current_curvature(CS)
desired_curvature = actuators.curvature
# Extract predicted curvature from modelV2
if self.inputs.model is not None and len(self.inputs.model.orientationRate.z) >= 17:
curvatures = np.array(self.inputs.model.orientationRate.z) / max(0.01, CS.out.vEgoRaw)
predicted_curvature = interp(self.curvature_lookup_time, MODEL_T_IDXS, curvatures)
else:
predicted_curvature = 0.0
# Blend predicted and desired curvature
self.pc_blend_ratio = interp(abs(desired_curvature), self.pc_blend_ratio_bp, self.pc_blend_ratio_v)
requested_curvature = (predicted_curvature * self.pc_blend_ratio) + (desired_curvature * (1 - self.pc_blend_ratio))
# Lane change detection
if self.inputs.model is not None:
self.lane_change = self.inputs.model.meta.laneChangeState in (1, 2, 3)
else:
self.lane_change = False
# Lane change curvature smoothing
lane_change_factor = interp(CS.out.vEgoRaw, LANE_CHANGE_FACTOR_BP,
[LANE_CHANGE_FACTOR_LOW, self.lane_change_factor_high_curv])
if self.lane_change and self.inputs.model is not None:
if self.inputs.model.meta.laneChangeDirection == 1 and requested_curvature < 0:
requested_curvature *= lane_change_factor
self.shared.precision_type = 0
elif self.inputs.model.meta.laneChangeDirection == 2 and requested_curvature > 0:
requested_curvature *= lane_change_factor
self.shared.precision_type = 0
# Human turn: sustained hands-on + large angle (not just a nudge) — see HumanTurnDetector
self.human_turn = self.shared.human_turn_detector.update(
self.enable_human_turn_detection_curv, steeringPressed, steeringAngleDeg_PV)
# Steering reset logic
if self.human_turn or (CS.out.vEgoRaw < 0.1):
reset_steering = 1
if reset_steering == 1:
requested_curvature = 0.0
# Apply curvature limits (extended version returning max_curvature)
apply_curvature, max_curvature, curvature_deviation_limited = apply_ford_curvature_limits_ext(
requested_curvature, apply_curvature_last, current_curvature,
CS.out.vEgoRaw, 0, CC.latActive, CP)
# Lateral uncertainty for torque bar visualization
lateralUncertainty = self._calculate_lateral_uncertainty(requested_curvature, apply_curvature, max_curvature)
# Human turn / standstill reset: after apply_ford_curvature_limits_ext (for torque-bar max_curvature),
# force κ=0 and (below) path_angle=0 on the bus. ford.h accepts that frame (curvature 0 skips the
# curvature checks), but checks the frames after it as usual: a ramp back that exceeds the rate
# limit or sits outside the curvature error band is blocked.
if reset_steering == 1:
apply_curvature = 0.0
self.post_reset_ramp_active = False
else:
# Detect transition from reset to normal
if self.reset_steering_last and not reset_steering:
self.post_reset_ramp_active = True
apply_curvature_last = 0.0 # Reset to ensure clean ramp from 0
# Post-reset ramp: gradually ramp from 0 to avoid safety limit trips
if self.post_reset_ramp_active:
apply_curvature = apply_std_steer_angle_limits(
requested_curvature, apply_curvature_last,
CS.out.vEgoRaw, 0, CC.latActive, BP_ANGLE_LIMITS)
curvature_error = abs(requested_curvature - apply_curvature)
curvature_threshold = max(abs(requested_curvature) * 0.1, 0.001)
if curvature_error < curvature_threshold:
self.post_reset_ramp_active = False
self.reset_steering_last = (reset_steering == 1)
# Path offset: blend model position with laneline data
if self.inputs.model is not None:
path_offset_position = interp(self.path_offset_lookup_time, MODEL_T_IDXS, self.inputs.model.position.y)
path_offset_lanelines = (self.inputs.model.laneLines[1].y[0] + self.inputs.model.laneLines[2].y[0]) / 2
# Laneline width tolerance (prevents jumps when lanes merge/diverge)
laneline_width = self.inputs.model.laneLines[2].y[0] + (-self.inputs.model.laneLines[1].y[0])
laneline_width_tolerance = interp(laneline_width, [3.75, 4.25], [0.81, 0.59])
# Laneline confidence
laneline_confidence = min(self.inputs.model.laneLineProbs[1], self.inputs.model.laneLineProbs[2], laneline_width_tolerance)
if not self.enable_lane_full_mode_curv:
laneline_confidence = 0.0
laneline_path_offset_scale = interp(laneline_confidence, self.min_laneline_confidence_bp, [0.0, 1.0])
path_offset = ((path_offset_position * (1 - laneline_path_offset_scale)) +
(path_offset_lanelines * laneline_path_offset_scale)) + self.custom_path_offset_curv
# No path offset during lane changes
if self.lane_change:
path_offset = 0
# PID-based path angle for lane centering
path_offset_error = path_offset * (self.LC_PID_gain_UI_curv / 100)
LC_PID_speed_factor = interp(CS.out.vEgoRaw, self.LC_PID_speed_bp, self.LC_PID_speed_v)
path_offset_error_adj = path_offset_error * LC_PID_speed_factor
if not self.enable_lane_positioning_curv:
path_offset_error_adj = 0.0
path_angle_low_c = self.LC_PID_controller.update(path_offset_error_adj)
if not self.enable_lane_positioning_curv:
path_angle_low_c = 0.0
if reset_steering == 1:
path_angle_low_c = 0.0
# Rate limit path angle for comfort
path_angle_roc = interp(abs(CS.out.vEgoRaw), self.LC_path_angle_ROC_bp, self.LC_path_angle_ROC_v)
path_angle_low_c = clip(path_angle_low_c, self.shared.path_angle_last - path_angle_roc, self.shared.path_angle_last + path_angle_roc)
# Reset PID if driver applies consistent steering pressure
if steeringPressed:
self.LC_path_angle_reset_counter += 1
else:
self.LC_path_angle_reset_counter = 0
if self.LC_path_angle_reset_counter > self.LC_path_angle_reset_duration * 20:
self.LC_PID_controller.reset()
path_angle = path_angle_low_c # path_angle_high_c not used currently
# Post lane change transition smoothing
path_angle, path_offset = self._handle_post_lane_change_transition(path_angle, path_offset)
# Final reset handling
if reset_steering == 1:
path_angle = 0.0
# Clip all signals to DBC limits
# float() is required: numpy's clip returns np.float64, and capnp rejects that type when
# apply_curvature reaches actuators.curvature via apply_curvature_last in carcontroller.py.
# The cast is exact (both are IEEE 754 doubles), so no output value changes.
apply_curvature = float(clip(apply_curvature, -self.curvature_max, self.curvature_max))
path_offset = float(clip(path_offset, -self.path_offset_max, self.path_offset_max))
path_angle = float(clip(path_angle, -self.path_angle_max, self.path_angle_max))
# Zero path_offset before CAN send (path_offset and path_angle can conflict, causing discomfort)
path_offset = 0.0
# Ramp type selection
if reset_steering == 1:
ramp_type = 3 # Immediate
self.path_angle_deque.clear()
self.LC_PID_controller.reset()
else:
ramp_type = 2 # Fast
else:
# Lateral control off — zero everything
apply_curvature = 0.0
path_offset = 0.0
path_angle = 0.0
self.path_angle_deque.clear()
self.LC_PID_controller.reset()
ramp_type = 0
lateralUncertainty = 0.0
# Update state for next frame
self.lateralUncertainty = lateralUncertainty
self.path_offset_last = path_offset
self.shared.path_angle_last = path_angle
# BluePilot: did the current_curvature +- CURVATURE_ERROR clip constrain the commanded
# curvature this frame (deviation from measured, not rate-of-change)? The BluePilot UI's deviation flag.
self.shared.bp_curvature_deviation_limited = curvature_deviation_limited
return LateralResult(
apply_curvature=apply_curvature,
curvature_rate=0.0, # always 0, like stock Ford
path_offset=path_offset,
path_angle=path_angle,
ramp_type=ramp_type,
precision_type=self.shared.precision_type,
lateralUncertainty=lateralUncertainty,
)
def _handle_post_lane_change_transition(self, path_angle, path_offset):
"""Smooth transition of control variables after lane change completes.
Rate-limits path_angle and path_offset back to target values
over 160 frames (~8 seconds at 20Hz).
"""
# Detect lane change completion (True → False transition)
if self.lane_change_last and not self.lane_change:
self.post_lane_change_active = True
self.post_lane_change_timer = 0
self.pre_lane_change_values = {'path_angle': 0.0, 'path_offset': 0.0}
self.lane_change_last = self.lane_change
if self.post_lane_change_active:
self.post_lane_change_timer += 1
new_path_angle = clip(path_angle,
self.pre_lane_change_values['path_angle'] - self.max_path_angle_change,
self.pre_lane_change_values['path_angle'] + self.max_path_angle_change)
new_path_offset = clip(path_offset,
self.pre_lane_change_values['path_offset'] - self.max_path_offset_change,
self.pre_lane_change_values['path_offset'] + self.max_path_offset_change)
self.pre_lane_change_values = {
'path_angle': new_path_angle,
'path_offset': new_path_offset,
}
if self.post_lane_change_timer >= 160:
self.post_lane_change_active = False
return (new_path_angle, new_path_offset)
return (path_angle, path_offset)
def _calculate_lateral_uncertainty(self, requested_curvature, apply_curvature, max_curvature):
"""Compute ratio of requested to max achievable curvature for the torque bar UI."""
max_curvature = np.clip(max_curvature, apply_curvature, self.curvature_max)
return float(requested_curvature / max_curvature)
@@ -0,0 +1,54 @@
"""
BluePilot: views over the host's LateralInputs (host_types.py) in the modelV2 / vehicleParameters shape
the lateral code reads, and the check that a frame is complete. The host fills LateralInputs once per frame.
"""
import math
from itertools import chain
from types import SimpleNamespace
from ..host_types import MODEL_PATH_POINTS
def lateral_inputs_complete(li) -> bool:
"""True when every array the lateral indexes into has the shape modelV2 always provides, and
every value is finite. A NaN from the model doesn't raise in the lateral math; it reaches the
CAN encoders, which do, so the whole frame is dropped here and the previous inputs are kept."""
return (len(li.pathYawRate) == MODEL_PATH_POINTS and len(li.pathX) == MODEL_PATH_POINTS and len(li.pathY) == MODEL_PATH_POINTS
and len(li.laneLineLeftX) == MODEL_PATH_POINTS and len(li.laneLineLeftY) == MODEL_PATH_POINTS
and len(li.laneLineRightX) == MODEL_PATH_POINTS and len(li.laneLineRightY) == MODEL_PATH_POINTS
and len(li.laneLineProbs) >= 3 and len(li.laneLineStds) >= 3
and all(map(math.isfinite, chain(li.pathYawRate, li.pathX, li.pathY, li.laneLineLeftX, li.laneLineLeftY,
li.laneLineRightX, li.laneLineRightY, li.laneLineProbs, li.laneLineStds,
(li.steerRatio, li.stiffnessFactor, li.roll, li.angleOffsetDeg, li.lateralDelay)))))
class _LaneLine:
def __init__(self, x, y):
self.x = x
self.y = y
class ModelView:
"""The subset of modelV2 the lateral code uses, with the same attribute paths."""
def __init__(self, li):
self.orientationRate = SimpleNamespace(z=li.pathYawRate)
self.position = SimpleNamespace(x=li.pathX, y=li.pathY)
# indices match modelV2.laneLines: 1 = left, 2 = right; 0 and 3 are not used
self.laneLines = [_LaneLine([], []),
_LaneLine(li.laneLineLeftX, li.laneLineLeftY),
_LaneLine(li.laneLineRightX, li.laneLineRightY),
_LaneLine([], [])]
self.laneLineProbs = li.laneLineProbs
self.laneLineStds = li.laneLineStds
self.meta = SimpleNamespace(laneChangeState=li.laneChangeState, laneChangeDirection=li.laneChangeDirection)
class VehicleParamsView:
"""The subset of vehicleParameters the lateral code uses."""
def __init__(self, li):
self.steerRatio = li.steerRatio
self.stiffnessFactor = li.stiffnessFactor
self.roll = li.roll
self.angleOffsetDeg = li.angleOffsetDeg
@@ -1,104 +0,0 @@
"""Pure angle lane-centering trim adapted from BluePilot bp-dev e22afa6b.
Alan Polk and ghbarker; the source also credits StarPilot/u/jc01rho.
See ../CREDITS.md, ../LICENSE and ../LICENSE.md. Geometry/confidence/fallback,
filter and slew follow lane_center_trim.py; this module receives immutable arrays
instead of a host model. Stale geometry and driver pressure additionally suppress
trim, and nonfinite confidence cannot create correction.
"""
from dataclasses import dataclass
import math
from ..params.trim import DEFAULTS, BOUNDS
def clip(value, low, high):
return max(low, min(high, value))
def interp(value, xs, ys):
if value <= xs[0]:
return ys[0]
for index in range(1, len(xs)):
if value < xs[index]:
return ys[index - 1] + (ys[index] - ys[index - 1]) * (value - xs[index - 1]) / (xs[index] - xs[index - 1])
return ys[-1]
@dataclass(frozen=True)
class TrimConfig:
enabled: bool = DEFAULTS["enabled"]
offset_m: float = DEFAULTS["offset_m"]
gain: float = DEFAULTS["gain"]
@dataclass(frozen=True)
class LaneGeometry:
position_x: tuple = ()
position_y: tuple = ()
left_x: tuple = ()
left_y: tuple = ()
right_x: tuple = ()
right_y: tuple = ()
probabilities: tuple = ()
standard_deviations: tuple = ()
fresh: bool = False
def sample(xs, ys, distance):
if (not isinstance(xs, tuple) or not isinstance(ys, tuple) or len(xs) < 2 or len(xs) != len(ys) or
not all(math.isfinite(v) for v in (*xs, *ys)) or not all(b > a for a, b in zip(xs, xs[1:], strict=False))):
return None
return interp(distance, xs, ys)
class LaneCenterTrim:
def __init__(self):
self.correction = 0.0
def reset(self):
self.correction = 0.0
def update(self, requested, speed, geometry, config, *, active, lane_change, steering_pressed=False):
try:
valid = (isinstance(config, TrimConfig) and config.enabled is True and active and not lane_change and
not steering_pressed and isinstance(geometry, LaneGeometry) and geometry.fresh is True and
math.isfinite(requested) and math.isfinite(speed) and speed >= 0.0 and
math.isfinite(config.offset_m) and BOUNDS["offset_m"][0] <= config.offset_m <= BOUNDS["offset_m"][1] and
math.isfinite(config.gain) and BOUNDS["gain"][0] <= config.gain <= BOUNDS["gain"][1])
authority = interp(speed, BOUNDS["speed_mps"], BOUNDS["speed_authority"]) if valid else 0.0
if not valid or authority <= 0.0:
self.reset()
return requested
distance = clip(speed, *BOUNDS["lookahead_m"])
model_y = sample(geometry.position_x, geometry.position_y, distance)
if model_y is None:
self.reset()
return requested
# Bad lane-only geometry retains the source's model-position/offset fallback.
scale, center = self.lane_blend(geometry, distance)
error = scale * (center - model_y) + config.offset_m
raw = 2.0 * error / distance ** 2
target = clip(raw, -BOUNDS["raw_correction"], BOUNDS["raw_correction"]) * config.gain * authority
alpha = 1.0 - math.exp(-BOUNDS["period_s"] / BOUNDS["tau_s"])
filtered = alpha * target + (1.0 - alpha) * self.correction
self.correction = clip(filtered, self.correction - BOUNDS["correction_delta"], self.correction + BOUNDS["correction_delta"])
return requested + self.correction
except (AttributeError, IndexError, TypeError, ValueError, OverflowError):
self.reset()
return requested
def lane_blend(self, geometry, distance):
try:
left = sample(geometry.left_x, geometry.left_y, distance)
right = sample(geometry.right_x, geometry.right_y, distance)
probs, stds = geometry.probabilities, geometry.standard_deviations
if (left is None or right is None or not isinstance(probs, tuple) or not isinstance(stds, tuple) or
len(probs) != 2 or len(stds) != 2 or not all(math.isfinite(p) and 0.0 <= p <= 1.0 for p in probs) or
not all(math.isfinite(s) and s >= 0.0 for s in stds)):
return 0.0, 0.0
confidence = min(*probs, interp(right - left, BOUNDS["width_m"], BOUNDS["width_tolerance"]),
interp(max(stds), BOUNDS["std"], BOUNDS["std_tolerance"]))
scale = clip(interp(confidence, BOUNDS["confidence"], (0.0, 1.0)), 0.0, 1.0)
return scale, 0.5 * (left + right)
except (AttributeError, IndexError, TypeError, ValueError, OverflowError):
return 0.0, 0.0
@@ -0,0 +1,156 @@
from ...car import structs
from ...car.lateral import AngleSteeringLimits
from .. import host_types
from ..params.manifest import PER_FRAME_KEYS, startup_value
class FordSafetyFlagsSP:
"""BluePilot's Ford safety flags: the bits of the second safety param a host sends the panda
(current_safety_param_sp, USB control 0xdf), not the main safetyConfigs[].safetyParam (HOSTS.md).
ford_bp_init reads it with GET_FLAG(current_safety_param_sp, ...), same pattern as Subaru
STOP_AND_GO (subaru_common.h). Plain int constants, not IntFlag: the param must stay a plain int
through the host's serialization.
"""
# Bit layout of the SP safety param (mirrored in ford_bp_init, bluepilot_lateral/safety/ford.h; pinned by
# tests/test_safety_param_bits.py):
# bit 0 STEER_ANGLE_CURVATURE (pinion-sourced curvature measurement)
# bits 1-4 pinion geometry index (FORD_PINION_GEOMETRY_SHIFT / FORD_PINION_GEOMETRY_INDEX)
# bit 5 BP_LATERAL: the panda runs the 4-signal checks (ford_lmc_checks) instead of
# upstream's stock curvature-only checks. Clear = upstream safety, verbatim.
STEER_ANGLE_CURVATURE = 1
BP_LATERAL = 1 << 5
# Geometry-table index for the steering-angle curvature measurement, packed into
# the startup safety bits 1-4 when STEER_ANGLE_CURVATURE is set. Must match the
# ford_pinion_geometry table in bluepilot_lateral/safety/ford.h row for row.
# Index 0 is reserved as invalid: the firmware treats flag-set-but-no-index as feature
# off, so a half-configured param can never select the wrong geometry silently.
# Platforms with no row are in FORD_NO_PINION_PLATFORMS. Indices stay aligned with the C table.
# BluePilot: the per-platform tables key on the platform name (CP.carFingerprint), not the CAR enum,
# so a row for a platform some host lacks never breaks import there; it just never matches.
# FORD_HOST_ONLY_PLATFORMS lists the names with rows here that upstream opendbc's CAR doesn't have; any
# name a host's CAR lacks must be on it (tests/test_platform_tables.py).
FORD_HOST_ONLY_PLATFORMS: frozenset[str] = frozenset({
"FORD_EDGE_MK2", # StarPilot
"FORD_MONDEO_MK5", # BP7, StarPilot
"FORD_TRANSIT_MK5", # StarPilot
})
# No pinion geometry row, so no STEER_ANGLE_CURVATURE (the setting no-ops and the yaw source is kept):
FORD_NO_PINION_PLATFORMS: frozenset[str] = frozenset({
# ALT_STEER_ANGLE: reads a RELATIVE pinion angle (SteeringPinion_Data_Alt + learned offset) and lacks the
# absolute measurement this needs
"FORD_EDGE_MK2",
# LKA steering through Lane_Assist_Data1 alone: no BluePilot lateral at all
"FORD_TRANSIT_MK5",
})
FORD_PINION_GEOMETRY_SHIFT = 1
FORD_PINION_GEOMETRY_INDEX = {
"FORD_BRONCO_SPORT_MK1": 1,
"FORD_ESCAPE_MK4": 2,
"FORD_ESCAPE_MK4_5": 3,
"FORD_EXPEDITION_MK4": 4,
"FORD_EXPLORER_MK6": 5,
"FORD_FOCUS_MK4": 6,
"FORD_F_150_LIGHTNING_MK1": 7,
"FORD_F_150_MK14": 8,
"FORD_MAVERICK_MK1": 9,
"FORD_MONDEO_MK5": 10,
"FORD_MUSTANG_MACH_E_MK1": 11,
"FORD_RANGER_MK2": 12,
}
def ford_safety_param(CP: structs.CarParams, params_dict: dict[str, str]) -> int:
"""The startup safety bits for this car, from the startup settings (HOSTS.md). A host ORs them into the
second safety param it sends the panda (current_safety_param_sp, USB 0xdf) at car init."""
# Steering-angle curvature measurement (bad-yaw-sensor workaround): the STEER_ANGLE_CURVATURE flag +
# the platform geometry-table index; the control side mirrors the same flag
# (carcontroller_ext.InputTracker.get_current_curvature). Platforms without a geometry row silently
# keep stock yaw behavior -- the toggle no-ops rather than half-configuring.
bits = 0
if CP.brand == 'ford':
# BluePilot 4-signal lateral: tell the panda to run the BP checks. Read once here, at init;
# the carcontroller sends 4-signal messages only when this bit is set. Changing the setting
# while driving does not change the panda: stock messages are then judged by the BP
# envelope (which is not identical to the stock one), so the setting takes full effect at
# the next init. Same property as openpilot longitudinal.
bp_lateral = not startup_value(params_dict, "disable_BP_lat_UI")
if bp_lateral:
bits |= FordSafetyFlagsSP.BP_LATERAL
# The pinion-sourced measurement is a BP lateral feature: its wider error band and the
# Python side's yaw-vs-pinion handling only line up on the BP path, so it is off whenever
# BP lateral is off (ford_bp_init enforces the same on the panda).
steer_angle_curvature = bp_lateral and startup_value(params_dict, "FordPrefSteerAngleCurvature")
if steer_angle_curvature:
geometry_index = FORD_PINION_GEOMETRY_INDEX.get(CP.carFingerprint)
if geometry_index is not None:
bits |= FordSafetyFlagsSP.STEER_ANGLE_CURVATURE | (geometry_index << FORD_PINION_GEOMETRY_SHIFT)
return bits
# Pinion geometry wheelbase (m) where BluePilot deliberately differs from CarSpecs. Used by both the
# C table (ford_pinion_geometry in bluepilot_lateral/safety/ford.h) and the Python pinion path
# (carcontroller_ext.pinion_vehicle_model), so the two layers convert pinion angle the same way.
# FORD_F_150_MK14: the trucks with lane centering come in 3.68 and 3.99 m wheelbases and nothing on
# the car tells them apart; the average keeps either within about 4% (#21).
FORD_PINION_WHEELBASE = {
"FORD_F_150_MK14": 3.84,
}
# BluePilot: Max curvature for steering command (m^-1), from DBC file limits
CURVATURE_MAX = 0.02
# BluePilot: Curvature rate limits — 3-point breakpoints for smoother lateral control.
# Upstream opendbc uses 2-point ([5, 25]) with more conservative values.
# These allow higher rates at low speed for responsiveness, lower rates at mid-speed
# for comfort, and very low rates at highway speed for stability.
#
# Control (Python) uses stricter windup than unwind so OP stays inside panda when apply_std
# picks the wrong table vs ford_bp_curvature_cmd_checks. Safety firmware is looser than both: its
# up table is the control's down table, and its down table is 1% looser again — see
# bluepilot_lateral/safety/ford.h FORD_LIMITS.
_BP_ANGLE_RATE_UP = ([5, 16, 25], [0.0025, 0.0012, 0.00008])
_BP_ANGLE_RATE_DOWN = ([5, 16, 25], [0.0025, 0.0014, 0.00018])
BP_ANGLE_LIMITS = AngleSteeringLimits(
0.02, # Max curvature for steering command, m^-1
_BP_ANGLE_RATE_UP,
_BP_ANGLE_RATE_DOWN,
)
# The model's prediction times, part of the host contract (host_types.py); the strategies read it from here.
MODEL_T_IDXS = host_types.MODEL_T_IDXS
# The per-frame settings a host publishes every frame (HOSTS.md; hosts import this name from here). The
# settings, their defaults and how they are read are in params/manifest.py.
BP_LATERAL_PARAMS = PER_FRAME_KEYS
# Lane change smoothing, both modes: speed breakpoints (m/s) and the low-speed factor
LANE_CHANGE_FACTOR_BP = (4.4, 40.23)
LANE_CHANGE_FACTOR_LOW = 0.95
# Angle mode's per-platform high-speed gain defaults (low curvature, high curvature); any other
# platform gets ANGLE_GAIN_CAN. Read by lateral_angle_ext.update_angle_params.
# CAN vehicles (Escape MK4, Bronco Sport, Explorer, Maverick, Edge)
ANGLE_GAIN_CAN = (1.00, 1.15)
# CAN-FD body-on-frame trucks (F-150, Lightning, Expedition, Ranger)
ANGLE_GAIN_CANFD_BOF = (0.95, 0.95)
# CAN-FD unibody SUVs (Mustang Mach-E, Escape MK4.5)
ANGLE_GAIN_CANFD_SUV = (1.00, 1.05)
ANGLE_GAIN_CANFD_BOF_CARS = frozenset({
"FORD_F_150_MK14",
"FORD_F_150_LIGHTNING_MK1",
"FORD_EXPEDITION_MK4",
"FORD_RANGER_MK2",
})
ANGLE_GAIN_CANFD_SUV_CARS = frozenset({
"FORD_MUSTANG_MACH_E_MK1",
"FORD_ESCAPE_MK4_5",
})
@@ -0,0 +1,48 @@
"""BluePilot: the types a host hands the library and reads back from it (HOSTS.md).
Standard library only: opendbc/car/structs.py imports LateralInputs for CarControlSP, so an import of
anything in opendbc here would be an import cycle."""
from dataclasses import dataclass, field
# The model's prediction grid: every path array in LateralInputs has MODEL_PATH_POINTS entries at these
# times, openpilot's ModelConstants.T_IDXS (t = 10 * (i / 32)^2). The core interpolates over it.
MODEL_PATH_POINTS = 33
MODEL_T_IDXS = [10.0 * (i / 32) ** 2 for i in range(MODEL_PATH_POINTS)]
@dataclass
class LateralInputs:
"""Per-frame model / vehicle inputs for the Ford 4-signal lateral control. The host fills them from
modelV2 / vehicleParameters / lateralDelay, so opendbc never subscribes to messaging."""
valid: bool = False # producer saw model + vehicle params and filled every field below
pathYawRate: list[float] = field(default_factory=list) # modelV2.orientationRate.z, 33 points over T_IDXS
pathX: list[float] = field(default_factory=list) # modelV2.position.x
pathY: list[float] = field(default_factory=list) # modelV2.position.y
laneLineLeftX: list[float] = field(default_factory=list) # modelV2.laneLines[1]
laneLineLeftY: list[float] = field(default_factory=list)
laneLineRightX: list[float] = field(default_factory=list) # modelV2.laneLines[2]
laneLineRightY: list[float] = field(default_factory=list)
laneLineProbs: list[float] = field(default_factory=list) # 4 entries
laneLineStds: list[float] = field(default_factory=list)
laneChangeState: int = 0 # modelV2.meta.laneChangeState
laneChangeDirection: int = 0 # modelV2.meta.laneChangeDirection
steerRatio: float = 0.0 # vehicleParameters
stiffnessFactor: float = 0.0
roll: float = 0.0
angleOffsetDeg: float = 0.0
lateralDelay: float = 0.0 # lateralDelay.lateralDelay
@dataclass
class LateralStatus:
"""This frame's values for a BluePilot UI, from BluePilotLateral.status(). The names are the ones
BP7's controllerStateBP publisher reads off its car controller; the four angle-mode values read
False outside angle mode, as BP7 sets them."""
lateralUncertainty: float = 0.0 # from whichever strategy ran last
angleRateLimited: bool = False # angle mode: the path_angle soft-ROC clip bit this frame
curvatureRateLimited: bool = False # angle mode: the equivalent curvature would have been rate-limited
curvatureDeviationLimited: bool = False # either mode: the current-curvature error clip constrained the command
humanTurnLateralPaused: bool = False # angle mode: lateral sent inactive during a manual turn
stallBlipActive: bool = False # angle mode: the brief mode-0 pulse after a post-override stall
disable_BP_lat_UI: bool = False # BluePilot lateral off: the UI toggle, or the panda bit not set at init
primary_lateral_control: int = 0 # 0 = curvature, 1 = angle
@@ -0,0 +1,270 @@
diff --git a/iqdbc/car/ford/carcontroller.py b/iqdbc/car/ford/carcontroller.py
index e00869f..cf8e4ba 100644
--- a/iqdbc/car/ford/carcontroller.py
+++ b/iqdbc/car/ford/carcontroller.py
@@ -5,6 +5,7 @@ from iqdbc.car import ACCELERATION_DUE_TO_GRAVITY, Bus, DT_CTRL, apply_hysteresi
from iqdbc.car.ford import fordcan
from iqdbc.car.ford.values import CarControllerParams, FordFlags, CAR
from iqdbc.car.interfaces import CarControllerBase, V_CRUISE_MAX
+from iqdbc.bluepilot_lateral.hosts.iqpilot.lateral import BluePilotLateral # BluePilot
LongCtrlState = structs.CarControl.Actuators.LongControlState
VisualAlert = structs.CarControl.HUDControl.VisualAlert
@@ -38,6 +39,7 @@ class CarController(CarControllerBase):
super().__init__(dbc_names, CP, CP_IQ)
self.packer = CANPacker(dbc_names[Bus.pt])
self.CAN = fordcan.CanBus(CP)
+ self.bp = BluePilotLateral(CP, CP_IQ, self.packer, self.CAN) # BluePilot
self.apply_curvature_last = 0
self.anti_overshoot_curvature_last = 0
@@ -52,6 +54,7 @@ class CarController(CarControllerBase):
def update(self, CC, CC_IQ, CS, now_nanos):
can_sends = []
+ bp_active = self.bp.update(CC_IQ, self.frame) # BluePilot
actuators = CC.actuators
hud_control = CC.hudControl
@@ -74,7 +77,10 @@ class CarController(CarControllerBase):
### lateral control ###
# send steer msg at 20Hz
- if (self.frame % CarControllerParams.STEER_STEP) == 0:
+ if bp_active and (self.frame % CarControllerParams.STEER_STEP) == 0: # BluePilot
+ lat_sends, self.apply_curvature_last = self.bp.step_lateral(CC, CS, actuators, self.apply_curvature_last)
+ can_sends.extend(lat_sends)
+ elif (self.frame % CarControllerParams.STEER_STEP) == 0:
# Bronco and some other cars consistently overshoot curv requests
# Apply some deadzone + smoothing convergence to avoid oscillations
if self.CP.carFingerprint in (CAR.FORD_BRONCO_SPORT_MK1, CAR.FORD_F_150_MK14):
@@ -108,7 +114,9 @@ class CarController(CarControllerBase):
can_sends.append(fordcan.create_lat_ctl_msg(self.packer, self.CAN, CC.latActive, 0., 0., -self.apply_curvature_last, 0.))
# send lka msg at 33Hz
- if (self.frame % CarControllerParams.LKA_STEP) == 0:
+ if bp_active and (self.frame % CarControllerParams.LKA_STEP) == 0: # BluePilot
+ can_sends.append(self.bp.step_lka(CC, hud_control))
+ elif (self.frame % CarControllerParams.LKA_STEP) == 0:
can_sends.append(fordcan.create_lka_msg(self.packer, self.CAN))
### longitudinal control ###
diff --git a/iqdbc/car/structs.py b/iqdbc/car/structs.py
index 520c7fc..5f95bbd 100644
--- a/iqdbc/car/structs.py
+++ b/iqdbc/car/structs.py
@@ -4,6 +4,7 @@ from typing import dataclass_transform, get_origin
import os
import capnp
from iqdbc.car.common.basedir import BASEDIR
+from iqdbc.bluepilot_lateral.host_types import LateralInputs # BluePilot: the per-frame inputs controlsd fills
try:
from iqpilot.cereal import car
except ImportError:
@@ -54,6 +55,7 @@ class IQCarParams:
longitudinalStoppingSpeedOverride: float = auto_field()
stoppingDecelRateOverride: float = auto_field()
longActiveWithGasOverride: bool = auto_field()
+ lateralInputsRequired: bool = auto_field() # BluePilot: controlsd fills IQCarControl.lateralInputs and params
iqLateralNet: 'IQCarParams.LateralNet' = field(default_factory=lambda: IQCarParams.LateralNet())
@@ -120,6 +122,7 @@ class IQCarControl:
useRadarAccel: bool = auto_field()
radarSetSpeedKph: float = auto_field()
radarGapBars: int = auto_field()
+ lateralInputs: 'LateralInputs' = field(default_factory=lambda: LateralInputs()) # BluePilot
@auto_dataclass
class Param:
diff --git a/iqdbc/lvbs/car/interfaces.py b/iqdbc/lvbs/car/interfaces.py
index 5ca575a..aafd822 100644
--- a/iqdbc/lvbs/car/interfaces.py
+++ b/iqdbc/lvbs/car/interfaces.py
@@ -8,6 +8,7 @@ from collections.abc import Callable
from iqdbc.car import structs
from iqdbc.car.can_definitions import CanRecvCallable, CanSendCallable
+from iqdbc.bluepilot_lateral.hosts.iqpilot.safety_param import init_ford_safety_param_iq # BluePilot
from iqdbc.car.subaru.values import SubaruFlags
from iqdbc.car.tesla.values import LEGACY_CARS
from iqdbc.lvbs.car.subaru.iq_values import SubaruFlagsIQ, SubaruSafetyFlagsIQ
@@ -80,6 +81,7 @@ def apply_iq_car_config(CI, CP: structs.CarParams, CP_IQ: structs.IQCarParams,
_apply_tesla_options(CP, CP_IQ, params_dict)
_apply_creep_assist(CP, CP_IQ, params_dict)
_apply_toyota_options(CP, CP_IQ, params_dict)
+ init_ford_safety_param_iq(CP, CP_IQ, params_dict) # BluePilot
def _apply_long_tuning(CI, CP: structs.CarParams, CP_IQ: structs.IQCarParams,
diff --git a/iqdbc/safety/modes/ford.h b/iqdbc/safety/modes/ford.h
index 26ec0af..5e5c0c4 100644
--- a/iqdbc/safety/modes/ford.h
+++ b/iqdbc/safety/modes/ford.h
@@ -9,6 +9,7 @@
#define FORD_BrakeSysFeatures 0x415U // RX from ABS, for vehicle speed
#define FORD_EngVehicleSpThrottle2 0x202U // RX from PCM, for second vehicle speed
#define FORD_Yaw_Data_FD1 0x91U // RX from RCM, for yaw rate
+#define FORD_SteeringPinion_Data 0x7EU // RX from PSCM, optional angle_meas source (BluePilot STEER_ANGLE_CURVATURE)
#define FORD_Steering_Data_FD1 0x083U // TX by OP, various driver switches and LKAS/CC buttons
#define FORD_ACCDATA 0x186U // TX by OP, ACC controls
#define FORD_ACCDATA_3 0x18AU // TX by OP, ACC/TJA user interface
@@ -29,6 +30,9 @@ static uint8_t ford_get_counter(const CANPacket_t *msg) {
} else if (msg->addr == FORD_Yaw_Data_FD1) {
// Signal: VehRollYaw_No_Cnt
cnt = msg->data[5];
+ } else if (msg->addr == FORD_SteeringPinion_Data) {
+ // Signal: StePinAn_No_Cnt (47|4@0+)
+ cnt = (msg->data[5] >> 4) & 0xFU;
} else {
}
return cnt;
@@ -74,6 +78,8 @@ static bool ford_get_quality_flag_valid(const CANPacket_t *msg) {
valid = ((msg->data[4] >> 5) & 0x3U) == 0x3U; // VehVActlEng_D_Qf
} else if (msg->addr == FORD_Yaw_Data_FD1) {
valid = ((msg->data[6] >> 4) & 0x3U) == 0x3U; // VehYawWActl_D_Qf
+ } else if (msg->addr == FORD_SteeringPinion_Data) {
+ valid = ((msg->data[5] >> 2) & 0x3U) == 0x3U; // StePinCompAnEst_D_Qf (3=OK)
} else {
}
return valid;
@@ -94,6 +100,16 @@ static const NativeCurvatureSteeringLimits FORD_STEERING_LIMITS = {
.curvature_error_min_speed = 10.0,
};
+// BluePilot: the Ford 4-signal lateral checks, pinion angle_meas source and angle-mode state, from the
+// bluepilot_lateral subtree. The bindings point the library at IQ's native-curvature names.
+#define BP_HOST_CURVATURE_LIMITS NativeCurvatureSteeringLimits
+#define BP_HOST_CURVATURE_STATE native_curvature_state
+#define BP_HOST_RT_CURVATURE_RATE_CHECK rt_native_curvature_rate_limit_check
+#define BP_HOST_LAT_ALLOWED() is_lat_active()
+#define BP_HOST_SPEED_MISMATCH_CHECK() ((void)0) // ford_rx_hook runs it on FORD_EngVehicleSpThrottle2
+// cppcheck-suppress misra-c2012-20.1; needs the Ford constants defined above
+#include "iqdbc/bluepilot_lateral/safety/ford.h"
+
static void ford_rx_hook(const CANPacket_t *msg) {
if (msg->bus == FORD_MAIN_BUS) {
// Update in motion state from standstill signal
@@ -117,7 +133,7 @@ static void ford_rx_hook(const CANPacket_t *msg) {
}
// Update vehicle yaw rate
- if (msg->addr == FORD_Yaw_Data_FD1) {
+ if ((msg->addr == FORD_Yaw_Data_FD1) && !ford_bp_pinion_curvature) { // BluePilot: pinion source replaces yaw
// Signal: VehYaw_W_Actl
// TODO: we should use the speed which results in the closest angle measurement to the desired angle
float ford_yaw_rate = (((msg->data[2] << 8U) | msg->data[3]) * 0.0002) - 6.5;
@@ -149,6 +165,8 @@ static void ford_rx_hook(const CANPacket_t *msg) {
aol_button_press = GET_BIT(msg, 40U) ? AOL_BUTTON_PRESSED : AOL_BUTTON_NOT_PRESSED;
}
}
+
+ ford_bp_rx_hook(msg); // BluePilot: pinion angle_meas source
}
static bool ford_tx_hook(const CANPacket_t *msg) {
@@ -223,6 +241,8 @@ static bool ford_tx_hook(const CANPacket_t *msg) {
if (action != 0U) {
tx = false;
}
+
+ ford_bp_lka_tx(msg); // BluePilot: angle-mode state from the frame's unused bits
}
// Safety check for LateralMotionControl action
@@ -234,12 +254,17 @@ static bool ford_tx_hook(const CANPacket_t *msg) {
unsigned int raw_path_angle = (msg->data[3] << 3) | (msg->data[4] >> 5);
unsigned int raw_path_offset = (msg->data[5] << 2) | (msg->data[6] >> 6);
- // These signals are not yet tested with the current safety limits
- bool violation = (raw_curvature_rate != FORD_INACTIVE_CURVATURE_RATE) || (raw_path_angle != FORD_INACTIVE_PATH_ANGLE) || (raw_path_offset != FORD_INACTIVE_PATH_OFFSET);
-
- // Check angle error and steer_control_enabled
- int desired_curvature = raw_curvature - FORD_INACTIVE_CURVATURE; // /FORD_STEERING_LIMITS.curvature_to_can to get real curvature
- violation |= steer_native_curvature_cmd_checks(desired_curvature, steer_control_enabled, FORD_STEERING_LIMITS);
+ bool violation = false;
+ if (ford_bp_lateral) { // BluePilot: 4-signal lateral
+ violation = ford_bp_lmc_tx(false, raw_curvature, raw_curvature_rate, raw_path_offset, raw_path_angle, steer_control_enabled);
+ } else {
+ // These signals are not yet tested with the current safety limits
+ violation = (raw_curvature_rate != FORD_INACTIVE_CURVATURE_RATE) || (raw_path_angle != FORD_INACTIVE_PATH_ANGLE) || (raw_path_offset != FORD_INACTIVE_PATH_OFFSET);
+
+ // Check angle error and steer_control_enabled
+ int desired_curvature = raw_curvature - FORD_INACTIVE_CURVATURE; // /FORD_STEERING_LIMITS.curvature_to_can to get real curvature
+ violation |= steer_native_curvature_cmd_checks(desired_curvature, steer_control_enabled, FORD_STEERING_LIMITS);
+ }
if (violation) {
tx = false;
@@ -255,12 +280,17 @@ static bool ford_tx_hook(const CANPacket_t *msg) {
unsigned int raw_path_angle = ((msg->data[3] & 0x1FU) << 6) | (msg->data[4] >> 2);
unsigned int raw_path_offset = ((msg->data[4] & 0x3U) << 8) | msg->data[5];
- // These signals are not yet tested with the current safety limits
- bool violation = (raw_curvature_rate != FORD_CANFD_INACTIVE_CURVATURE_RATE) || (raw_path_angle != FORD_INACTIVE_PATH_ANGLE) || (raw_path_offset != FORD_INACTIVE_PATH_OFFSET);
-
- // Check angle error and steer_control_enabled
- int desired_curvature = raw_curvature - FORD_INACTIVE_CURVATURE; // /FORD_STEERING_LIMITS.curvature_to_can to get real curvature
- violation |= steer_native_curvature_cmd_checks(desired_curvature, steer_control_enabled, FORD_STEERING_LIMITS);
+ bool violation = false;
+ if (ford_bp_lateral) { // BluePilot: 4-signal lateral
+ violation = ford_bp_lmc_tx(true, raw_curvature, raw_curvature_rate, raw_path_offset, raw_path_angle, steer_control_enabled);
+ } else {
+ // These signals are not yet tested with the current safety limits
+ violation = (raw_curvature_rate != FORD_CANFD_INACTIVE_CURVATURE_RATE) || (raw_path_angle != FORD_INACTIVE_PATH_ANGLE) || (raw_path_offset != FORD_INACTIVE_PATH_OFFSET);
+
+ // Check angle error and steer_control_enabled
+ int desired_curvature = raw_curvature - FORD_INACTIVE_CURVATURE; // /FORD_STEERING_LIMITS.curvature_to_can to get real curvature
+ violation |= steer_native_curvature_cmd_checks(desired_curvature, steer_control_enabled, FORD_STEERING_LIMITS);
+ }
if (violation) {
tx = false;
@@ -287,6 +317,20 @@ static safety_config ford_init(uint16_t param) {
{.msg = {{FORD_Steering_Data_FD1, 0, 8, 10U, .ignore_checksum = true, .ignore_counter = true, .ignore_quality_flag = true}, { 0 }, { 0 }}},
};
+ // BluePilot: ford_rx_checks plus the pinion message, enforced only while the pinion angle_meas source is
+ // on (ford_bp_init). Written out rather than shared through a macro: cppcheck's MISRA 5.8 check misreads one.
+ static RxCheck ford_rx_checks_pinion[] = {
+ {.msg = {{FORD_BrakeSysFeatures, 0, 8, 50U, .max_counter = 15U}, { 0 }, { 0 }}},
+ {.msg = {{FORD_EngVehicleSpThrottle2, 0, 8, 50U, .ignore_checksum = true, .ignore_counter = true}, { 0 }, { 0 }}},
+ {.msg = {{FORD_Yaw_Data_FD1, 0, 8, 100U, .max_counter = 255U}, { 0 }, { 0 }}},
+ {.msg = {{FORD_EngBrakeData, 0, 8, 10U, .ignore_checksum = true, .ignore_counter = true, .ignore_quality_flag = true}, { 0 }, { 0 }}},
+ {.msg = {{FORD_EngVehicleSpThrottle, 0, 8, 100U, .ignore_checksum = true, .ignore_counter = true, .ignore_quality_flag = true}, { 0 }, { 0 }}},
+ {.msg = {{FORD_DesiredTorqBrk, 0, 8, 50U, .ignore_checksum = true, .ignore_counter = true, .ignore_quality_flag = true}, { 0 }, { 0 }}},
+ {.msg = {{FORD_Steering_Data_FD1, 0, 8, 10U, .ignore_checksum = true, .ignore_counter = true, .ignore_quality_flag = true}, { 0 }, { 0 }}},
+ // StePinAn_No_Cs matches no Ford checksum pattern on real frames: integrity via counter (0-15) + quality flag + 100 Hz
+ {.msg = {{FORD_SteeringPinion_Data, 0, 8, 100U, .max_counter = 15U, .ignore_checksum = true}, { 0 }, { 0 }}},
+ };
+
#define FORD_COMMON_TX_MSGS \
{FORD_Steering_Data_FD1, 0, 8, .check_relay = false}, \
{FORD_Steering_Data_FD1, 2, 8, .check_relay = false}, \
@@ -331,6 +375,11 @@ static safety_config ford_init(uint16_t param) {
} else {
ret = BUILD_SAFETY_CFG(ford_rx_checks, FORD_LONG_TX_MSGS);
}
+
+ // BluePilot: IQ safety flags (BP lateral, pinion source); the pinion RX check applies only when it is consumed
+ if (ford_bp_init(current_safety_param_iq)) {
+ SET_RX_CHECKS(ford_rx_checks_pinion, ret);
+ }
return ret;
}
diff --git a/iqdbc/safety/tests/test.sh b/iqdbc/safety/tests/test.sh
index 1ff4ee8..9c8b425 100755
--- a/iqdbc/safety/tests/test.sh
+++ b/iqdbc/safety/tests/test.sh
@@ -10,8 +10,8 @@ source ../../../setup.sh
rm -f ./libsafety/*.gcda
scons -j$(nproc) -D
-# run safety tests and generate coverage data
-pytest -n8 --ignore-glob=misra/*
+# run safety tests and generate coverage data (and the BluePilot ones, from the bluepilot_lateral subtree)
+pytest -n8 --ignore-glob=misra/* . ../../bluepilot_lateral/safety/tests
if [ "$(uname)" = "Darwin" ]; then
GCOV_EXEC="/opt/homebrew/opt/llvm@18/bin/llvm-cov gcov"
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# BluePilot: IQPilot as a test host for this library (#18). Its opendbc, iqdbc, comes from IQ.Lvbs's own repo at the
# commit pinned below, with IQPilot's hooks (hooks.patch, the files they change outside the library) and this
# library at iqdbc/bluepilot_lateral/.
# host.sh tree <dir> [ref] <dir>/iq: the IQPilot folders iqdbc's code imports; <dir>/host: iqdbc on its own, a git
# repo with IQPilot as committed (first commit) and the hooks plus this library, placed by
# tools/vendor.sh (second). Writes <dir>/state: "not adopted" (the patch applied), "adopted"
# (IQPilot already carries it, or there's no patch), or "drifted: ..." (and fails): the hooks
# or IQPilot's copy of the library differ from what this repo has
# host.sh test <dir> build <dir>/host and run the library tests, IQPilot's safety suite with the BluePilot
# safety tests, and 100% line coverage of modes/ford.h and the library's safety headers
# host.sh make <dir> write hooks.patch from <dir>/host: every change outside the library since IQPilot's
# commit, committed or not. Change a hook: tree, edit <dir>/host, test, make.
set -euo pipefail
REPO=https://gitlvb.teallvbs.xyz/IQ.Lvbs/IQ.Pilot.git
PIN=cb6a1c1145b18e36bf5180ab13a4e7beda39c40a # beta, 2026-10-29
PROJECT=artifacts/package_sources/iqdbc # iqdbc's own project: setup.sh, pyproject.toml, uv.lock
IMPORTED="iqpilot/common iqpilot/cereal iqpilot/system iqpilot/selfdrive" # imported by iqdbc's code and tests
EXTRA_PACKAGES="pyserial setproctitle" # imported too, but only in IQPilot's top-level lock
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB="$(cd "$HERE/../.." && pwd)"
DIR="$(mkdir -p "$2" && cd "$2" && pwd)"
case "$1" in
tree)
REF=${3:-$PIN}
rm -rf "$DIR/iq" "$DIR/host"
GIT_LFS_SKIP_SMUDGE=1 git clone -q --filter=blob:none --no-checkout "$REPO" "$DIR/iq"
# shellcheck disable=SC2086 # a list of paths
git -C "$DIR/iq" sparse-checkout set $PROJECT $IMPORTED
GIT_LFS_SKIP_SMUDGE=1 git -C "$DIR/iq" checkout -q "$REF"
# iqdbc on its own: under IQPilot's root, uv takes it for a member of IQPilot's workspace
cp -R "$DIR/iq/$PROJECT" "$DIR/host"
cd "$DIR/host"
git init -q
git add -A
git -c user.name=ci -c user.email=ci@localhost commit -q -m "IQPilot $(git -C "$DIR/iq" rev-parse --short HEAD)"
state="adopted"
if [ -f "$HERE/hooks.patch" ]; then
if git apply --check "$HERE/hooks.patch" 2>/dev/null; then
git apply "$HERE/hooks.patch"
state="not adopted"
elif ! git apply --check --reverse "$HERE/hooks.patch" 2>/dev/null; then
state="drifted: hooks.patch neither applies nor is already in IQPilot (update it to what IQPilot carries: make)"
fi
fi
# IQPilot's own copy, once it carries one, has to be a release as vendored, before this library replaces it
if [ -f iqdbc/bluepilot_lateral/VERSION ] && ! "$LIB/tools/check_vendored.sh" iqdbc/bluepilot_lateral; then
state="drifted: IQPilot's copy of the library differs from the release in its VERSION"
fi
"$LIB/tools/vendor.sh" HEAD iqdbc/bluepilot_lateral
git add -A
git -c user.name=ci -c user.email=ci@localhost commit -q -m "hooks and library"
echo "$state" > "$DIR/state"
echo "IQPilot $(git -C "$DIR/iq" rev-parse --short HEAD), hooks: $state, library $(git -C "$LIB" rev-parse --short HEAD)"
[[ "$state" != drifted* ]]
;;
test)
cd "$DIR/host"
set +u
# shellcheck disable=SC1091 # IQPilot's: tools (clang-18 and mull on Linux, for the coverage build) and the venv
source setup.sh
set -u
export PYTHONPATH="$DIR/host:$DIR/iq" # setup.sh sets it to iqdbc alone; its code imports iqpilot too
# shellcheck disable=SC2086 # a list of packages
uv pip install -q $EXTRA_PACKAGES
scons -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)"
python -m unittest discover -s iqdbc/bluepilot_lateral/tests -t .
# IQPilot's safety suite as its test.sh runs it (pytest: unittest also collects its base classes), the BluePilot
# tests in the same run (its cross-mode TX sweep reads every Test* class it collects), minus IQPilot's own failures
deselect=()
# pytest names a test from its rootdir (iqdbc's project), not from the directory it runs in
while read -r t; do deselect+=("--deselect=iqdbc/safety/tests/$t"); done < <(grep -v -e '^#' -e '^$' "$HERE/known_failures.txt")
cd iqdbc/safety/tests
rm -f libsafety/*.gcda
pytest -q -n8 --ignore-glob='misra/*' "${deselect[@]}" . ../../bluepilot_lateral/safety/tests
if [ "$(uname)" = "Darwin" ]; then GCOV_EXEC="/opt/homebrew/opt/llvm@18/bin/llvm-cov gcov"; else GCOV_EXEC="llvm-cov-18 gcov"; fi
# two reports: modes/ford.h and the library's safety/ford.h share a file name (see bluepilot_coverage.sh)
gcovr -r .. --gcov-executable "$GCOV_EXEC" --fail-under-line=100 -f "$DIR/host/iqdbc/safety/modes/ford.h"
gcovr -r "$DIR/host" --gcov-executable "$GCOV_EXEC" -d --fail-under-line=100 -f "$DIR/host/iqdbc/bluepilot_lateral/safety/"
;;
make)
git -C "$DIR/host" diff HEAD~1 -- . ':(exclude)iqdbc/bluepilot_lateral' > "$HERE/hooks.patch"
echo "hooks.patch: $(grep -c '^diff --git' "$HERE/hooks.patch") files"
;;
*)
echo "usage: $0 tree|test|make <dir> [ref]" >&2
exit 2
;;
esac
@@ -0,0 +1,4 @@
# IQPilot's own failing tests at the pinned commit, skipped by host.sh test so they don't fail this library's CI.
# Each one fails on IQPilot without the hooks or the library too. Remove a line once IQPilot fixes it.
test_tesla_legacy.py::TestTeslaHW2ExternalPandaSafety::test_enable_control_allowed_with_manual_aol_button_state
test_tesla_legacy.py::TestTeslaHW3ExternalPandaSafety::test_enable_control_allowed_with_manual_aol_button_state
@@ -0,0 +1,21 @@
"""
BluePilot: the IQ.Pilot host adapter for the lateral (HOSTS.md). The Ford CarController builds this
with IQ.Pilot's types; it hands the core the library's types and nothing IQ-specific.
"""
from ...core import carcontroller_ext
from ...params.param_store import ParamStore
def params_from_cc_iq(CC_IQ) -> ParamStore:
"""The settings IQ.Pilot published this frame (IQCarControl.params entries) as (key, value) pairs."""
return ParamStore((p.key, p.value) for p in CC_IQ.params)
class BluePilotLateral(carcontroller_ext.BluePilotLateral):
def __init__(self, CP, CP_IQ, packer, CAN):
super().__init__(CP, CP_IQ.iqSafetyFlags, packer, CAN)
def update(self, CC_IQ, frame):
"""Every frame: the inputs and settings IQ.Pilot's controlsd put in IQCarControl. Returns whether
BluePilot lateral is active."""
return super().update(CC_IQ.lateralInputs, params_from_cc_iq(CC_IQ), frame)
@@ -0,0 +1,14 @@
"""
BluePilot: the startup safety bits onto IQ.Pilot's IQCarParams (HOSTS.md). Its own module, so car
init imports only values_ext, not the whole lateral.
"""
from ...core.values_ext import ford_safety_param
def init_ford_safety_param_iq(CP, CP_IQ, params_dict: dict[str, str]) -> None:
"""Called from IQ.Pilot's apply_iq_car_config at car init. CP_IQ.iqSafetyFlags reaches the panda as
current_safety_param_iq (USB 0xdf); lateralInputsRequired tells the host to fill
IQCarControl.lateralInputs and the per-frame settings, for every Ford (BluePilot lateral on or off)."""
CP_IQ.iqSafetyFlags |= ford_safety_param(CP, params_dict)
if CP.brand == 'ford':
CP_IQ.lateralInputsRequired = True
@@ -1,138 +0,0 @@
"""StarPilot's CP adapter; model transport and saved Params stay in the host.
Only exact final CP words derived from the existing five classic/six CANFD
extended profiles can select angle. Combined AOL retains its original Ford profile identity.
"""
from opendbc.car import structs
from opendbc.car.ford.pinion import base_word, configured, MASK as PINION_MASK
from opendbc.car.ford.values import CAR, FordFlags
from opendbc.bluepilot_lateral.core.angle import AngleController, Inputs
from opendbc.bluepilot_lateral.core.trim import LaneGeometry
from opendbc.bluepilot_lateral.params import limits as bounds
ANGLE_FLAG = 128
CLASSIC = frozenset({CAR.FORD_BRONCO_SPORT_MK1, CAR.FORD_ESCAPE_MK4, CAR.FORD_FOCUS_MK4,
CAR.FORD_MAVERICK_MK1, CAR.FORD_EXPLORER_MK6})
CANFD = frozenset({CAR.FORD_ESCAPE_MK4_5, CAR.FORD_EXPEDITION_MK4, CAR.FORD_F_150_MK14,
CAR.FORD_F_150_LIGHTNING_MK1, CAR.FORD_RANGER_MK2, CAR.FORD_MUSTANG_MACH_E_MK1})
TRUCKS = CANFD - {CAR.FORD_ESCAPE_MK4_5, CAR.FORD_MUSTANG_MACH_E_MK1}
def mode_word(cp, *, selected):
if (cp.brand != "ford" or cp.passive or cp.dashcamOnly or cp.notCar or cp.alternativeExperience not in (0, 32) or
cp.steerControlType != structs.CarParams.SteerControlType.angle or
not cp.pcmCruise or len(cp.safetyConfigs) not in (1, 2)):
return None
if len(cp.safetyConfigs) == 2 and (cp.safetyConfigs[0].safetyModel != structs.CarParams.SafetyModel.noOutput or
cp.safetyConfigs[0].safetyParam != 0):
return None
fd = cp.carFingerprint in CANFD
if ((fd and (not cp.flags & FordFlags.CANFD or cp.flags & ~int(FordFlags.CANFD | FordFlags.HAS_BSM))) or
(not fd and (cp.carFingerprint not in CLASSIC or cp.flags & ~int(FordFlags.HAS_BSM)))):
return None
safety = cp.safetyConfigs[-1]
long = int(cp.openpilotLongitudinalControl)
original = (18 if cp.carFingerprint == CAR.FORD_MUSTANG_MACH_E_MK1 else 66) + long if fd else 32 + long
manual = cp.transmissionType == structs.CarParams.TransmissionType.manual
if cp.transmissionType not in (structs.CarParams.TransmissionType.automatic, structs.CarParams.TransmissionType.manual):
return None
if cp.alternativeExperience == 32:
expected = original | (256 if manual else 0) | (ANGLE_FLAG if selected else 0)
else:
expected = ANGLE_FLAG + (2 if fd else 0) + long if selected else original
valid = (safety.safetyModel == structs.CarParams.SafetyModel.ford and base_word(safety.safetyParam) == expected and
(safety.safetyParam == expected or configured(cp)))
return expected if valid else None
def supported(cp):
return mode_word(cp, selected=False) is not None or mode_word(cp, selected=True) is not None
def qualified(cp):
return mode_word(cp, selected=True) is not None
def select(cp, raw):
# Absence/invalid bytes stay at current curvature. Never rewrite saved bytes.
chosen = (type(raw) is int and raw == 1) or (type(raw) in (bytes, str) and raw in (b"1", "1"))
if not chosen or mode_word(cp, selected=False) is None:
return False
if cp.alternativeExperience == 32:
cp.safetyConfigs[-1].safetyParam |= ANGLE_FLAG
else:
metadata = cp.safetyConfigs[-1].safetyParam & PINION_MASK
cp.safetyConfigs[-1].safetyParam = (ANGLE_FLAG + (2 if cp.flags & FordFlags.CANFD else 0) + int(cp.openpilotLongitudinalControl)) | metadata
cp.steerActuatorDelay = 0.22
return True
def model_geometry(model, *, fresh=False):
"""Project a caller-qualified snapshot to immutable core arrays; no model imports."""
if model is None or fresh is not True:
return None
try:
position_x, position_y = tuple(model.position.x), tuple(model.position.y)
except (AttributeError, TypeError, ValueError):
return None
try:
return LaneGeometry(position_x=position_x, position_y=position_y, fresh=True, left_x=tuple(model.laneLines[1].x), left_y=tuple(model.laneLines[1].y),
right_x=tuple(model.laneLines[2].x), right_y=tuple(model.laneLines[2].y),
probabilities=(model.laneLineProbs[1], model.laneLineProbs[2]),
standard_deviations=(model.laneLineStds[1], model.laneLineStds[2]))
except (AttributeError, IndexError, TypeError, ValueError):
return LaneGeometry(position_x=position_x, position_y=position_y, fresh=True)
class Host:
def __init__(self, cp):
if not qualified(cp):
raise ValueError("BluePilot angle requires an exact admitted Ford safety word")
low, high = (0.95, 0.95) if cp.carFingerprint in TRUCKS else ((1.0, 1.05) if cp.carFingerprint in CANFD else (1.0, 1.15))
self.cp = cp
from opendbc.car.ford.pinion import Measurement
self.measurement = Measurement(cp)
self.controller = AngleController(low_gain=low, high_gain=high)
self.inputs = None
self.steering_rejected = False
def update(self, CC, CS):
if self.inputs is not None:
self.inputs.update()
self.measurement.update_parameters(getattr(self.inputs, "vehicle_parameters", None))
snapshot = self.inputs.lateral_snapshot(CS.out.vEgoRaw, angle=True) if self.inputs is not None else None
model, times, delay, _ = snapshot if snapshot is not None else (None, (), 0.15, True)
aol = self.cp.alternativeExperience == 32
# The host is constructed before AOL marks the final CarParams.
# Retained AOL keeps EPS engaged through release and tracking error;
# physical human-turn pauses and fault recovery remain independent.
self.controller.recovery_blips = not aol
ordinary = CC.enabled and CS.out.cruiseState.enabled and not CS.out.gasPressed and not CS.out.brakePressed
ready = (self.inputs is not None and self.inputs.native_permission(self.cp, max_age_ns=bounds.PANDA_AGE_NS) and
(aol and CS.out.cruiseState.available or ordinary) and CC.latActive and CS.out.canValid and
not CS.out.canTimeout and
(not aol or CS.out.gearShifter in (structs.CarState.GearShifter.drive,
structs.CarState.GearShifter.sport, structs.CarState.GearShifter.low)) and
not CS.out.steerFaultTemporary and not CS.out.steerFaultPermanent and not CS.out.vehicleSensorsInvalid)
# Only an actual steering/shadow rejection invalidates its accepted path
# and counter history. Unrelated ACC/UI rejections do not reset steering.
rejected, self.steering_rejected = self.steering_rejected, False
ready = ready and not rejected
speed_min = speed_max = None
speed_window = getattr(CS, "bluepilot_speed_window", None)
if speed_window is not None:
low, high, step = speed_window
# Allow source progress across the existing 100 ms CAN freshness window.
margin = max(0.05, 5 * step)
speed_min = max(0.1, min(low, CS.out.vEgoRaw) - margin)
speed_max = max(high, CS.out.vEgoRaw, 0.1) + margin
meta = model.meta if model is not None else None
return self.controller.step(Inputs(
active=ready, speed=CS.out.vEgoRaw, measured_curvature=self.measurement.curvature(CS.out),
desired_curvature=CC.actuators.curvature, steering_pressed=CS.out.steeringPressed,
steering_angle=CS.out.steeringAngleDeg, yaw_predictions=tuple(model.orientationRate.z) if model is not None else (),
time_indices=tuple(times), lateral_delay=delay,
lane_change=int(getattr(meta.laneChangeState, "raw", meta.laneChangeState)) in (1, 2, 3) if meta else False,
lane_direction=int(getattr(meta.laneChangeDirection, "raw", meta.laneChangeDirection)) if meta else 0,
limit_status=getattr(CS, "lat_ctl_lim_stat", 0),
geometry=model_geometry(model, fresh=snapshot is not None), speed_min=speed_min, speed_max=speed_max))
@@ -0,0 +1,40 @@
"""
BluePilot: the StarPilot host adapter for the lateral (HOSTS.md). StarPilot's Ford CarController builds this
from CarParams alone (the startup bits ride its Ford safetyParam, safety_param.py). StarPilot has no
CarControl extension: its openpilot side hands the CarController a FrameInputs each frame instead, carrying
what sunnypilot and IQ.Pilot carry in theirs.
"""
from dataclasses import dataclass, field
from ...core import carcontroller_ext
from ...host_types import LateralInputs
from ...params.param_store import ParamStore
from .safety_param import bp_safety_param
@dataclass
class FrameInputs:
"""This frame's model / vehicle-parameter inputs and settings, filled by StarPilot's openpilot side."""
@dataclass
class Param:
key: str = ""
value: bytes = b""
lateralInputs: LateralInputs = field(default_factory=LateralInputs)
params: list = field(default_factory=list)
def params_from_frame(frame_inputs: FrameInputs) -> ParamStore:
"""The settings StarPilot published this frame (FrameInputs.params entries) as (key, value) pairs."""
return ParamStore((p.key, p.value) for p in frame_inputs.params)
class BluePilotLateral(carcontroller_ext.BluePilotLateral):
def __init__(self, CP, packer, CAN):
super().__init__(CP, bp_safety_param(CP), packer, CAN)
def update(self, frame_inputs: FrameInputs, frame):
"""Every frame: the inputs and settings StarPilot's openpilot side put in frame_inputs. Returns whether
BluePilot lateral is active."""
return super().update(frame_inputs.lateralInputs, params_from_frame(frame_inputs), frame)
@@ -0,0 +1,37 @@
"""
BluePilot: the startup safety bits onto StarPilot's Ford safetyParam (HOSTS.md). StarPilot has no second
safety param, so the bits ride its one Ford param at bits 9-14, clear of the bits StarPilot's Ford safety
reads (0-3, and 8 for its AOL's manual transmission). Its own module, so car init imports only values_ext,
not the whole lateral.
"""
from ....car import structs
from ....car.ford.values import FordFlags
from ...core.values_ext import ford_safety_param
BP_SAFETY_PARAM_SHIFT = 9
BP_SAFETY_PARAM_MASK = 0x3F # ford_safety_param's six bits, before the shift
# StarPilot's Transit steers through Lane_Assist_Data1 alone: no BluePilot lateral
LKA_STEERING = getattr(FordFlags, "LKA_STEERING", 0)
def init_ford_safety_param_starpilot(CP: structs.CarParams, params_dict: dict[str, str]) -> None:
"""Called from StarPilot's Ford interface at car init, after its own safetyParam bits are set. The
shifted bits reach the panda with the Ford safety mode; StarPilot's ford_init passes them to ford_bp_init."""
if CP.brand != 'ford' or CP.flags & LKA_STEERING:
return
bits = ford_safety_param(CP, params_dict)
if bits:
CP.safetyConfigs[-1].safetyParam |= bits << BP_SAFETY_PARAM_SHIFT
def bp_safety_param(CP: structs.CarParams) -> int:
"""The startup bits as ford_safety_param returned them: what BluePilotLateral and ford_bp_init take."""
if not CP.safetyConfigs:
return 0
return (CP.safetyConfigs[-1].safetyParam >> BP_SAFETY_PARAM_SHIFT) & BP_SAFETY_PARAM_MASK
def lateral_inputs_required(CP: structs.CarParams) -> bool:
"""Whether StarPilot's openpilot side fills the per-frame inputs and settings: every Ford the lateral can
run on, BluePilot lateral on or off."""
return CP.brand == 'ford' and not CP.flags & LKA_STEERING
@@ -0,0 +1,21 @@
"""
BluePilot: the sunnypilot host adapter for the lateral (HOSTS.md). The Ford CarController builds this
with sunnypilot's types; it hands the core the library's types and nothing sunnypilot-specific.
"""
from ...core import carcontroller_ext
from ...params.param_store import ParamStore
def params_from_cc_sp(CC_SP) -> ParamStore:
"""The settings sunnypilot published this frame (CarControlSP.params entries) as (key, value) pairs."""
return ParamStore((p.key, p.value) for p in CC_SP.params)
class BluePilotLateral(carcontroller_ext.BluePilotLateral):
def __init__(self, CP, CP_SP, packer, CAN):
super().__init__(CP, CP_SP.safetyParam, packer, CAN)
def update(self, CC_SP, frame):
"""Every frame: the inputs and settings sunnypilot's controlsd put in CarControlSP. Returns whether
BluePilot lateral is active."""
return super().update(CC_SP.lateralInputs, params_from_cc_sp(CC_SP), frame)
@@ -0,0 +1,15 @@
"""
BluePilot: the startup safety bits onto sunnypilot's CarParamsSP (HOSTS.md). Its own module, so car
init imports only values_ext, not the whole lateral.
"""
from ....car import structs
from ...core.values_ext import ford_safety_param
def init_ford_safety_param_sp(CP: structs.CarParams, CP_SP: structs.CarParamsSP, params_dict: dict[str, str]) -> None:
"""Called from sunnypilot's setup_interfaces at car init. CP_SP.safetyParam reaches the panda as
current_safety_param_sp (USB 0xdf); lateralInputsRequired tells the host to fill
CarControlSP.lateralInputs and the per-frame settings, for every Ford (BluePilot lateral on or off)."""
CP_SP.safetyParam |= ford_safety_param(CP, params_dict)
if CP.brand == 'ford':
CP_SP.lateralInputsRequired = True
@@ -1,41 +0,0 @@
"""Generate protocol constants without loading a host or vehicle module."""
import json
from pathlib import Path
root = Path(__file__).parent
spec = json.loads((root / "protocol.json").read_text())
(root / "protocol.py").write_text('"""Generated from protocol.json; layout preserves the existing curvature flag."""\n' +
"".join(f"{key.upper()} = {value!r}\n" for key, value in spec.items()))
(root / "protocol.h").write_text("#pragma once\n// Generated from protocol.json; do not allocate bit 1 to angle metadata.\n" +
"".join(f"#define FORD_BP_{key.upper()} {int(value)}U\n" for key, value in spec.items() if key != "shadow_scale"))
manifest = json.loads((root / "manifest.json").read_text())
defaults, bounds = manifest["defaults"], manifest["bounds"]
limits = {"BLEND": defaults["blend"], "VLT_EXTRA": defaults["vlt_extra_max"],
"PATH_MIN": bounds["path_angle_wire"][0], "PATH_MAX": bounds["path_angle_wire"][1],
"GAIN_MIN": bounds["gain"][0], "GAIN_MAX": bounds["gain"][1],
"CURVATURE_MAX": bounds["curvature"], "CURVATURE_ERROR": bounds["curvature_error"],
"LATERAL_ACCEL": bounds["lateral_accel"], "RX_AGE_US": bounds["rx_age_us"],
"SHADOW_AGE_US": bounds["shadow_age_us"], "PATH_ROUNDING_MARGIN": bounds["path_rounding_margin"],
"SHADOW_ROUNDING_MARGIN": bounds["shadow_rounding_margin"], "LATERAL_JERK": bounds["lateral_jerk"],
"RT_INTERVAL_US": bounds["rt_interval_us"], "STEER_PERIOD_US": bounds["steer_period_us"],
"CORE_STEER_PERIOD_S": bounds["core_steer_period_s"], "PANDA_AGE_NS": bounds["panda_age_ns"]}
(root / "limits.py").write_text('"""Generated from manifest.json; host and core use this manifest contract."""\n' +
"".join(f"{key} = {value!r}\n" for key, value in limits.items()))
(root / "limits.h").write_text("#pragma once\n// Generated from manifest.json; native and Python bounds share a source.\n" +
"".join(f"#define FORD_BP_{key} {str(value) + 'F' if isinstance(value, float) else str(value) + 'U'}\n"
for key, value in limits.items()))
# Optional angle trim settings are Python-only; native safety limits stay unchanged.
trim = manifest["angle_lane_trim"]
types = {"enabled": "bool", "offset_m": "float", "gain": "float"}
defaults_fields = "".join(f" {key}: {types[key]}\n" for key in trim["defaults"])
bounds_fields = "".join(f" {key}: {'list[float]' if isinstance(value, list) else 'float'}\n" for key, value in trim["bounds"].items())
(root / "trim.py").write_text('"""Generated Python-only angle trim defaults; no native bound or registry changes."""\n' +
"from typing import TypedDict\n\n\n" +
"class TrimDefaults(TypedDict):\n" + defaults_fields + "\n\n" +
"class TrimBounds(TypedDict):\n" + bounds_fields + "\n\n" +
f"SOURCE_REVISION = {trim['source_revision']!r}\n" +
f"DEFAULTS: TrimDefaults = {trim['defaults']!r}\n" +
"BOUNDS: TrimBounds = {\n" +
"".join(f" {key!r}: {value!r},\n" for key, value in trim["bounds"].items()) + "}\n")
@@ -1,20 +0,0 @@
#pragma once
// Generated from manifest.json; native and Python bounds share a source.
#define FORD_BP_BLEND 0.5F
#define FORD_BP_VLT_EXTRA 0.1F
#define FORD_BP_PATH_MIN -0.5F
#define FORD_BP_PATH_MAX 0.5235F
#define FORD_BP_GAIN_MIN 0.95F
#define FORD_BP_GAIN_MAX 1.3F
#define FORD_BP_CURVATURE_MAX 0.02F
#define FORD_BP_CURVATURE_ERROR 0.002F
#define FORD_BP_LATERAL_ACCEL 2.4114F
#define FORD_BP_RX_AGE_US 100000U
#define FORD_BP_SHADOW_AGE_US 60000U
#define FORD_BP_PATH_ROUNDING_MARGIN 0.0005F
#define FORD_BP_SHADOW_ROUNDING_MARGIN 1e-06F
#define FORD_BP_LATERAL_JERK 3.5886F
#define FORD_BP_RT_INTERVAL_US 250000U
#define FORD_BP_STEER_PERIOD_US 50000U
#define FORD_BP_CORE_STEER_PERIOD_S 0.045F
#define FORD_BP_PANDA_AGE_NS 300000000U
@@ -1,19 +0,0 @@
"""Generated from manifest.json; host and core use this manifest contract."""
BLEND = 0.5
VLT_EXTRA = 0.1
PATH_MIN = -0.5
PATH_MAX = 0.5235
GAIN_MIN = 0.95
GAIN_MAX = 1.3
CURVATURE_MAX = 0.02
CURVATURE_ERROR = 0.002
LATERAL_ACCEL = 2.4114
RX_AGE_US = 100000
SHADOW_AGE_US = 60000
PATH_ROUNDING_MARGIN = 0.0005
SHADOW_ROUNDING_MARGIN = 1e-06
LATERAL_JERK = 3.5886
RT_INTERVAL_US = 250000
STEER_PERIOD_US = 50000
CORE_STEER_PERIOD_S = 0.045
PANDA_AGE_NS = 300000000
@@ -1,109 +0,0 @@
{
"source_revision": "e1d051d7ba270261b4455068bd68f1a58db15a4a",
"settings": [
{
"key": "FordLateralMode",
"type": "enum",
"default": 0,
"values": {
"0": "Current curvature",
"1": "BluePilot angle"
},
"label": "Ford steering controller",
"description": "Select the steering controller for supported Ford profiles. Takes effect on the next drive.",
"modes": [
"angle"
]
}
],
"defaults": {
"blend": 0.5,
"vlt_extra_max": 0.1,
"low_speed_factor": 1.0,
"high_speed_factor": 1.0,
"dampening": 1.0,
"lane_change_factor": 1.0
},
"bounds": {
"path_angle_wire": [
-0.5,
0.5235
],
"gain": [
0.95,
1.3
],
"curvature_error": 0.002,
"lateral_accel": 2.4114,
"rx_age_us": 100000,
"shadow_age_us": 60000,
"steer_period_us": 50000,
"curvature": 0.02,
"path_rounding_margin": 0.0005,
"shadow_rounding_margin": 1e-06,
"lateral_jerk": 3.5886,
"rt_interval_us": 250000,
"core_steer_period_s": 0.045,
"panda_age_ns": 300000000
},
"angle_lane_trim": {
"source_revision": "e22afa6be9b881fa784c92ebb316db47728a3d81",
"defaults": {
"enabled": false,
"offset_m": 0.0,
"gain": 0.25
},
"bounds": {
"offset_m": [
-0.5,
0.5
],
"gain": [
0.0,
1.0
],
"width_m": [
2.4,
2.8,
3.75,
4.25
],
"width_tolerance": [
0.0,
0.81,
0.81,
0.59
],
"std": [
0.3,
0.5
],
"std_tolerance": [
0.81,
0.0
],
"confidence": [
0.6,
0.8
],
"speed_mps": [
0.0,
9.0,
15.0
],
"speed_authority": [
0.0,
0.0,
1.0
],
"lookahead_m": [
8.0,
35.0
],
"raw_correction": 0.004,
"tau_s": 0.4,
"period_s": 0.05,
"correction_delta": 0.00015
}
}
}
@@ -0,0 +1,137 @@
"""
BluePilot: every setting the lateral library reads, in one place.
Each Setting says what the host stores (key, type, default: its params_keys.h line), what the user
sees (label, description, range: the BluePilot UI's text and limits), and how the library reads it
(at startup, every frame or both; clamped to its range or not). The library reads every setting
through `value` (per frame, from the CarControlSP.params ParamStore) or `startup_value` (get_car's
params dict), with one rule: a published, parseable value is used; missing, empty or unparsable
gives the default.
`python -m <pkg>.bluepilot_lateral.params.manifest` prints the lines for a host's params_keys.h (<pkg>: the host's opendbc package).
"""
from dataclasses import dataclass
from typing import Literal
STARTUP, PER_FRAME, BOTH = "startup", "per-frame", "both"
@dataclass(frozen=True)
class Setting:
key: str
type: type
default: bool | int | float
read: Literal["startup", "per-frame", "both"]
label: str
description: str
range: tuple[float, float] | None = None # the UI's limits
clamp: bool = False # the library clamps to range (the angle-mode factors only, as BluePilot does)
SETTINGS: tuple[Setting, ...] = (
Setting("disable_BP_lat_UI", bool, False, BOTH, "Disable BP Lateral Control",
"Disable BluePilot lateral control."),
Setting("enable_human_turn_detection_curv", bool, True, PER_FRAME, "Enable Human Turn Detection",
"Enable detection of human-initiated turns."),
Setting("enable_lane_positioning_curv", bool, False, PER_FRAME, "Enable Lane Positioning",
"Enable custom lane positioning controls."),
Setting("enable_lane_full_mode_curv", bool, False, PER_FRAME, "Enable Lanefull Mode",
"Enable lanefull mode for lane positioning."),
Setting("enable_lane_positioning_ang", bool, False, PER_FRAME, "Enable Lane Positioning",
"Nudge the vehicle toward true lane-line center (plus optional bias) while in angle-primary control."),
Setting("FordPrefLateralControl", int, 0, PER_FRAME, "Primary Control Variable",
"Curvature matches the existing strategy. Angle uses path_angle as the main actuator (in development).",
range=(0, 1)),
# an on/off toggle in the UI, stored as INT
Setting("custom_profile_curv", int, 0, PER_FRAME, "Use Custom Tuning Profile",
"Enable custom tuning profile settings.", range=(0, 1)),
Setting("lane_change_factor_high_curv", float, 0.85, PER_FRAME, "Lane Change Factor High",
"Scales steering during a lane change (curvature control). Values <1.0 reduce it.", range=(0.5, 1.0)),
Setting("pc_blend_ratio_high_C_UI_curv", float, 0.4, PER_FRAME, "Predicted Curvature Blend Ratio High",
"Adjust the high curvature blend ratio (0.0-1.0).", range=(0.0, 1.0)),
Setting("pc_blend_ratio_low_C_UI_curv", float, 0.4, PER_FRAME, "Predicted Curvature Blend Ratio Low",
"Adjust the low curvature blend ratio (0.0-1.0).", range=(0.0, 1.0)),
Setting("custom_path_offset_curv", float, 0.0, PER_FRAME, "In-Lane Offset",
"Adjust the in-lane offset (-0.5 to 0.5).", range=(-0.5, 0.5)),
Setting("LC_PID_gain_UI_curv", float, 1.0, PER_FRAME, "Centering PID gain",
"PID gain for the curvature-mode centering controller. Applies whenever lane positioning is on.",
range=(0.0, 50.0)),
Setting("FordLowSpeedFactor_ang", float, 1.0, PER_FRAME, "Low Speed Adjustment Factor",
"Scales the low-speed steering response in angle mode. Adjust for personal feel. Default 1.0.",
range=(0.5, 1.5), clamp=True),
Setting("FordHighSpeedFactor_ang", float, 1.0, PER_FRAME, "High Speed Adjustment Factor",
"Scales the high-speed steering response in angle mode. Adjust for personal feel. Default 1.0.",
range=(0.5, 1.5), clamp=True),
Setting("FordHighSpeedDampening_ang", float, 1.0, PER_FRAME, "High Speed Low Curve Adjustment Factor",
"Tune adjustment factor for low curve straightaways (highways) at high speeds. If oversteering, reduce. " +
"If understeering, increase", range=(0.25, 1.25), clamp=True),
Setting("lane_change_factor_high_ang", float, 1.0, PER_FRAME, "Lane Change Factor High",
"Scales steering during a lane change (angle control). Values >1.0 boost it.", range=(0.85, 1.5), clamp=True),
Setting("custom_path_offset_ang", float, 0.0, PER_FRAME, "In-Lane Offset",
"Adjust the in-lane offset (-0.5 to 0.5).", range=(-0.5, 0.5), clamp=True),
Setting("lane_centering_strength_ang", float, 0.25, PER_FRAME, "Lane Centering Strength",
"How much authority the lane centering trim has vs. the model's own path (0.0-1.0).",
range=(0.0, 1.0), clamp=True),
Setting("FordPrefSteerAngleCurvature", bool, False, STARTUP, "Use Pinion Yaw Sensor",
"Measures how the car is turning from the steering pinion angle sensor instead of a faulty RCM yaw sensor " +
'(symptoms: "Turn Exceeds Steering Limit" warnings, weak curve tracking, "Service AdvanceTrac"). Check with ' +
"tools/ford_yaw_health_check.py. Applies the next time the car starts. Not available on the Edge."),
)
_BY_KEY = {s.key: s for s in SETTINGS}
# what a host publishes in CarControlSP.params every frame (values_ext.BP_LATERAL_PARAMS)
PER_FRAME_KEYS = tuple(s.key for s in SETTINGS if s.read != STARTUP)
def _parse(setting: Setting, raw):
"""The setting's value from what the host gave, or None if there is nothing usable."""
if isinstance(raw, (bytes, bytearray)):
raw = raw.decode("utf-8", errors="replace")
if raw is None or raw == "":
return None
if setting.type is bool:
if isinstance(raw, (bool, int)):
return bool(raw)
text = str(raw).strip().lower()
return True if text in ("1", "true") else False if text in ("0", "false") else None
try:
v = setting.type(raw)
except (TypeError, ValueError):
return None
if setting.clamp and setting.range is not None:
v = setting.type(min(max(v, setting.range[0]), setting.range[1]))
return v
def default(key: str):
return _BY_KEY[key].default
def value(params, key: str):
"""A per-frame setting from the host's ParamStore (built from this frame's (key, value) pairs)."""
setting = _BY_KEY[key]
assert setting.read != STARTUP, f"{key} is read at startup only: hosts don't publish it per frame"
v = _parse(setting, params.get(key))
return setting.default if v is None else v
def startup_value(params_dict: dict, key: str):
"""A startup setting from get_car's params dict (values as Params returned them)."""
setting = _BY_KEY[key]
assert setting.read != PER_FRAME, f"{key} is read per frame only: hosts don't pass it at startup"
v = _parse(setting, params_dict.get(key))
return setting.default if v is None else v
def params_keys_lines() -> list[str]:
"""A host's params_keys.h entries for every setting."""
types = {bool: "BOOL", int: "INT", float: "FLOAT"}
def text(s):
return ("1" if s.default else "0") if s.type is bool else str(s.default)
return [f'{{"{s.key}", {{PERSISTENT | BACKUP, {types[s.type]}, "{text(s)}"}}}},' for s in SETTINGS]
if __name__ == "__main__":
print("\n".join(params_keys_lines()))
@@ -0,0 +1,17 @@
"""
BluePilot: the settings a host published this frame, by key (HOSTS.md). The lateral code reads every
setting through params/manifest.py's value(), which parses the published text and takes the manifest
default for a key that is missing, empty or unparsable. A host publishes every per-frame key anyway,
using its own registry default for one never written.
"""
class ParamStore:
def __init__(self, pairs):
"""pairs: the (key, value) settings a host published this frame, value as bytes or text."""
self._values = dict(pairs)
def get(self, key):
"""The published value as text, or None if the host didn't publish the key."""
v = self._values.get(key)
return v.decode("utf-8", errors="replace") if isinstance(v, (bytes, bytearray)) else v
@@ -1,10 +0,0 @@
#pragma once
// Generated from protocol.json; do not allocate bit 1 to angle metadata.
#define FORD_BP_VERSION 1U
#define FORD_BP_ANGLE_MASK 1U
#define FORD_BP_EXTENDED_CURVATURE_MASK 2U
#define FORD_BP_COUNTER_SHIFT 2U
#define FORD_BP_COUNTER_MASK 28U
#define FORD_BP_COUNTER_MODULUS 8U
#define FORD_BP_VERSION_MASK 15U
#define FORD_BP_CHECKSUM_SHIFT 4U
@@ -1,11 +0,0 @@
{
"version": 1,
"angle_mask": 1,
"extended_curvature_mask": 2,
"counter_shift": 2,
"counter_mask": 28,
"counter_modulus": 8,
"shadow_scale": 1e-06,
"version_mask": 15,
"checksum_shift": 4
}
@@ -1,10 +0,0 @@
"""Generated from protocol.json; layout preserves the existing curvature flag."""
VERSION = 1
ANGLE_MASK = 1
EXTENDED_CURVATURE_MASK = 2
COUNTER_SHIFT = 2
COUNTER_MASK = 28
COUNTER_MODULUS = 8
SHADOW_SCALE = 1e-06
VERSION_MASK = 15
CHECKSUM_SHIFT = 4
@@ -1,45 +0,0 @@
"""Generated Python-only angle trim defaults; no native bound or registry changes."""
from typing import TypedDict
class TrimDefaults(TypedDict):
enabled: bool
offset_m: float
gain: float
class TrimBounds(TypedDict):
offset_m: list[float]
gain: list[float]
width_m: list[float]
width_tolerance: list[float]
std: list[float]
std_tolerance: list[float]
confidence: list[float]
speed_mps: list[float]
speed_authority: list[float]
lookahead_m: list[float]
raw_correction: float
tau_s: float
period_s: float
correction_delta: float
SOURCE_REVISION = 'e22afa6be9b881fa784c92ebb316db47728a3d81'
DEFAULTS: TrimDefaults = {'enabled': False, 'offset_m': 0.0, 'gain': 0.25}
BOUNDS: TrimBounds = {
'offset_m': [-0.5, 0.5],
'gain': [0.0, 1.0],
'width_m': [2.4, 2.8, 3.75, 4.25],
'width_tolerance': [0.0, 0.81, 0.81, 0.59],
'std': [0.3, 0.5],
'std_tolerance': [0.81, 0.0],
'confidence': [0.6, 0.8],
'speed_mps': [0.0, 9.0, 15.0],
'speed_authority': [0.0, 0.0, 1.0],
'lookahead_m': [8.0, 35.0],
'raw_correction': 0.004,
'tau_s': 0.4,
'period_s': 0.05,
'correction_delta': 0.00015,
}
@@ -0,0 +1,383 @@
/**
* BluePilot Ford lateral-control extensions: state and implementations.
* See ford_declarations.h in this directory for the declarations.
*/
#pragma once
#include "../safety/ford_declarations.h"
#include "../safety/lateral.h"
// ===============================
// Global Variables
// ===============================
bool ford_bp_lateral = false; // SP safety-param bit 5: BP 4-signal lateral checks instead of stock
bool ford_bp_pinion_curvature = false;
const AngleSteeringParams *ford_bp_pinion_params = &ford_pinion_geometry[0];
// BluePilot: per-platform geometry for pinion-angle -> curvature conversion (the optional
// angle_meas source), selected by the 4-bit geometry index in current_safety_param_sp
// bits 1-4. Row order and values must match FORD_PINION_GEOMETRY_INDEX in
// bluepilot_lateral/core/values_ext.py (enforced by test_ford_bluepilot_pinion.py's
// geometry test against CarSpecs + calc_slip_factor(VehicleModel(CP)), with the wheelbase
// overrides in FORD_PINION_WHEELBASE applied).
// Index 0 is reserved as invalid; ford_bp_init disables the feature outright on a zero or
// out-of-range index so a half-configured param can never select the wrong geometry.
// FORD_EDGE_MK2 (ALT_STEER_ANGLE: relative pinion angle + learned offset) is unsupported
// and deliberately absent.
const AngleSteeringParams ford_pinion_geometry[FORD_PINION_GEOMETRY_ROWS] = {
{.slip_factor = 0.0f, .steer_ratio = 1.0f, .wheelbase = 1.0f}, // 0: invalid
{.slip_factor = -0.00062819555f, .steer_ratio = 17.7f, .wheelbase = 2.670f}, // 1: FORD_BRONCO_SPORT_MK1
{.slip_factor = -0.00061892325f, .steer_ratio = 16.7f, .wheelbase = 2.710f}, // 2: FORD_ESCAPE_MK4
{.slip_factor = -0.00061892325f, .steer_ratio = 16.7f, .wheelbase = 2.710f}, // 3: FORD_ESCAPE_MK4_5
{.slip_factor = -0.00045454798f, .steer_ratio = 17.0f, .wheelbase = 3.690f}, // 4: FORD_EXPEDITION_MK4
{.slip_factor = -0.00055447339f, .steer_ratio = 16.8f, .wheelbase = 3.025f}, // 5: FORD_EXPLORER_MK6
{.slip_factor = -0.00062121569f, .steer_ratio = 15.0f, .wheelbase = 2.700f}, // 6: FORD_FOCUS_MK4
{.slip_factor = -0.00045331952f, .steer_ratio = 16.9f, .wheelbase = 3.700f}, // 7: FORD_F_150_LIGHTNING_MK1
{.slip_factor = -0.00043679222f, .steer_ratio = 17.0f, .wheelbase = 3.840f}, // 8: FORD_F_150_MK14 (average wheelbase, #21)
{.slip_factor = -0.00054528036f, .steer_ratio = 17.0f, .wheelbase = 3.076f}, // 9: FORD_MAVERICK_MK1
{.slip_factor = -0.00058852001f, .steer_ratio = 14.8f, .wheelbase = 2.850f}, // 10: FORD_MONDEO_MK5
{.slip_factor = -0.00056209187f, .steer_ratio = 17.0f, .wheelbase = 2.984f}, // 11: FORD_MUSTANG_MACH_E_MK1
{.slip_factor = -0.00051293030f, .steer_ratio = 17.0f, .wheelbase = 3.270f}, // 12: FORD_RANGER_MK2
};
bool ford_bp_angle_mode_engaged = false;
int16_t ford_bp_shadow_curvature_raw = 0; // wire units, scale 1e-6 1/m (LANE_ASSIST_DATA1.md)
int desired_path_angle_last = 0;
int desired_path_offset_last = 0;
// ===============================
// Function Implementations
// ===============================
static inline bool path_angle_cmd_checks(int desired_path_angle, bool steer_control_enabled, const AngleSteeringLimits limits) {
bool violation = false;
if (steer_control_enabled) {
float speed = ((float)vehicle_speed.min / VEHICLE_SPEED_FACTOR) - 1.;
int delta_path_angle_roc = (safety_interpolate(limits.angle_rate_up_lookup, speed) * limits.angle_deg_to_can) + 1.;
int highest_desired_path_angle = desired_path_angle_last + delta_path_angle_roc;
int lowest_desired_path_angle = desired_path_angle_last - delta_path_angle_roc;
violation |= safety_max_limit_check(desired_path_angle, highest_desired_path_angle, lowest_desired_path_angle);
FORD_BP_DBG("path_angle_cmd_checks 1: desired_path_angle: %d desired_path_angle_last: %d highest_desired_path_angle: %d lowest_desired_path_angle: %d violation: %d \n",
desired_path_angle, desired_path_angle_last, highest_desired_path_angle, lowest_desired_path_angle, (int)violation);
}
desired_path_angle_last = desired_path_angle;
if (!steer_control_enabled) {
violation |= (desired_path_angle != 0);
}
FORD_BP_DBG("path_angle_cmd_checks 2: violation: %d \n", (int)violation);
return violation;
}
static inline bool path_offset_cmd_checks(int desired_path_offset, bool steer_control_enabled, const AngleSteeringLimits limits) {
bool violation = false;
if (steer_control_enabled) {
float speed = ((float)vehicle_speed.min / VEHICLE_SPEED_FACTOR) - 1.;
int delta_path_offset_roc = (safety_interpolate(limits.angle_rate_up_lookup, speed) * limits.angle_deg_to_can) + 1.;
int highest_desired_path_offset = desired_path_offset_last + delta_path_offset_roc;
int lowest_desired_path_offset = desired_path_offset_last - delta_path_offset_roc;
violation |= safety_max_limit_check(desired_path_offset, highest_desired_path_offset, lowest_desired_path_offset);
FORD_BP_DBG("path_offset_cmd_checks 1: desired_path_offset: %d desired_path_offset_last: %d highest_desired_path_offset: %d lowest_desired_path_offset: %d violation: %d \n",
desired_path_offset, desired_path_offset_last, highest_desired_path_offset, lowest_desired_path_offset, (int)violation);
}
desired_path_offset_last = desired_path_offset;
if (!steer_control_enabled) {
violation |= (desired_path_offset != 0);
}
FORD_BP_DBG("path_offset_cmd_checks 2: violation: %d \n", (int)violation);
return violation;
}
// BluePilot: angle mode has no "current path_angle" measurement to check the command against,
// unlike curvature mode, which compares desired_curvature against angle_meas (measured curvature,
// from yaw rate). Without this, a large deviation between commanded path_angle and the car's ACTUAL
// curvature -- e.g. a pothole or driver override kicking the wheel -- would go unchecked: path_angle's
// own ROC only bounds how fast the *command* changes, not how far it may sit from reality.
//
// Deliberately narrower than steer_angle_cmd_checks: no rate-of-change enforcement here, and no
// shared state (desired_angle_last) with curvature mode. path_angle already has its own dedicated,
// tuned ROC (path_angle_cmd_checks / FORD_PATH_ANGLE_LIMITS); imposing a second, curvature-tuned ROC
// on shadow_curvature -- which isn't an actuator, just a cross-check value -- would risk spurious
// blocks unrelated to path_angle's actual behavior (confirmed on real hardware 2026-07-10: doing
// this via steer_angle_cmd_checks caused blocks at low speed from shadow_curvature jumping frame to
// frame with nothing driving it toward path_angle's own smooth ROC). This is a pure per-frame
// proximity check: does this frame's steering intent make physical sense given where the car is.
// BluePilot: enforce_angle_error is gone from the struct; the check is unconditional now because
// its only caller, ford_lmc_checks, passes the base of a BluePilot limit set (FORD_LIMITS, above
// ford_bp_lmc_tx), all of which set it true pre-sync.
static inline bool ford_shadow_curvature_error_check(int desired_curvature, bool steer_control_enabled,
const BP_HOST_CURVATURE_LIMITS limits) {
bool violation = false;
if (steer_control_enabled &&
((vehicle_speed.values[0] / VEHICLE_SPEED_FACTOR) > limits.curvature_error_min_speed)) {
int lowest_allowed = BP_HOST_CURVATURE_STATE.meas.min - limits.max_curvature_error - 1;
int highest_allowed = BP_HOST_CURVATURE_STATE.meas.max + limits.max_curvature_error + 1;
violation = safety_max_limit_check(desired_curvature, highest_allowed, lowest_allowed);
}
return violation;
}
// BluePilot: shared curvature/curvature_rate/path_offset/path_angle command checks for
// LateralMotionControl (CAN) and LateralMotionControl2 (CAN FD), in signed CAN units. limits is the
// curvature limit set ford_bp_lmc_tx picked for the message and the angle_meas source.
// dbg_prefix labels the FORD_BP_DBG output ("CAN Out" / "CANFD Out").
static inline bool ford_lmc_checks(int desired_curvature, int desired_curvature_rate, int desired_path_offset, int desired_path_angle,
bool steer_control_enabled, const FordBpCurvatureLimits *limits, const char *dbg_prefix) {
// dbg_prefix is only read inside FORD_BP_DBG, which expands to a no-op unless FORD_BP_DEBUG is defined for the
// debug build, making the parameter otherwise unused.
SAFETY_UNUSED(dbg_prefix);
// PathAngle rate limits
static const AngleSteeringLimits FORD_PATH_ANGLE_LIMITS = {
.max_angle = 1000,
// 0.0005
.angle_deg_to_can = 2000, // 1 / (2e-5) rad to can
// Mirror lateral_angle_ext.py _soft_roc: interp(v_ego, [9,10,15,25], [0.055,0.055,0.0425,0.009])
// rad/call, scaled x1.02 so panda is 2% LOOSER than the Python control and never blocks LMC2.
// lookup_t is fixed at 3 points; Python's 9 & 10 m/s nodes are both 0.055 (flat top), so {10,15,25}
// reproduces the curve exactly and speeds <10 clamp to the first point. The +1 CAN unit and the
// speed-1 fudge in path_angle_cmd_checks add extra headroom on top of the 2%.
// BluePilot: LMC2 is only sent once per CarControllerParams.STEER_STEP (5) = 20Hz, not 100Hz --
// _soft_roc's y-values (and this mirror) are per-call, not per-100Hz-tick; see lateral_angle_ext.py.
.angle_rate_up_lookup = {
.x = {10., 15., 25.},
.y = {0.0561, 0.04335, 0.00918}
},
.angle_rate_down_lookup = {
.x = {10., 15., 25.},
.y = {0.0561, 0.04335, 0.00918}
},
.frequency = 20U, // Hz -- LateralMotionControl/LateralMotionControl2 @ 20Hz (matches
// actual STEER_STEP=5 cadence; was 100U, a stale leftover from an
// abandoned 100Hz-cadence experiment. Currently unread by
// path_angle_cmd_checks (only angle_rate_up/down_lookup matter),
// but corrected for consistency/documentation and in case a future
// rt_angle_rate_limit_check() wiring starts consuming it.
};
// PathOffset rate limits
static const AngleSteeringLimits FORD_PATH_OFFSET_LIMITS = {
.max_angle = 100, // 1.0 meter in CAN units (100 * 0.01)
.angle_deg_to_can = 100, // 1 / (0.01) meter to can
.angle_rate_up_lookup = {
.x = {5., 15., 25.},
.y = {0.05, 0.025, 0.01} // Slower rate limits for path offset
},
.angle_rate_down_lookup = {
.x = {5., 15., 25.},
.y = {0.05, 0.025, 0.01} // Slower rate limits for path offset
},
.frequency = 20U, // Hz - 20Hz message rate
};
const BP_HOST_CURVATURE_LIMITS *base = &limits->base;
bool violation = false;
// Check curvature value limits (already converted to signed CAN units by the caller). Note:
// curvature's wire scale (curvature_to_can) is the same for CAN and CAN FD, so this is safe to
// read off the (possibly pinion-widened) limits struct regardless of bus.
int curvature_min_can = (int)(FORD_CURVATURE_MIN * base->curvature_to_can);
int curvature_max_can = (int)(FORD_CURVATURE_MAX * base->curvature_to_can);
violation |= (desired_curvature < curvature_min_can) || (desired_curvature > curvature_max_can);
FORD_BP_DBG("%s: desired_curvature: %d, curvature_min_can: %d, curvature_max_can: %d, violation: %d\n",
dbg_prefix, desired_curvature, curvature_min_can, curvature_max_can, (int)violation);
// Curvature rate must be 0, like stock Ford: BluePilot always sends 0 (#22)
violation |= (desired_curvature_rate != 0);
FORD_BP_DBG("%s: desired_curvature_rate: %d, violation: %d\n", dbg_prefix, desired_curvature_rate, (int)violation);
// Check path offset value limits
int path_offset_min_can = (int)(FORD_PATH_OFFSET_MIN * FORD_PATH_OFFSET_LIMITS.angle_deg_to_can);
int path_offset_max_can = (int)(FORD_PATH_OFFSET_MAX * FORD_PATH_OFFSET_LIMITS.angle_deg_to_can);
violation |= (desired_path_offset < path_offset_min_can) || (desired_path_offset > path_offset_max_can);
FORD_BP_DBG("%s: desired_path_offset: %d, path_offset_min_can: %d, path_offset_max_can: %d, violation: %d\n",
dbg_prefix, desired_path_offset, path_offset_min_can, path_offset_max_can, (int)violation);
// Check path angle value limits. Angle mode uses path_angle as the actuator and may swing to
// the full DBC range, corroborated by ford_bp_angle_mode_engaged so a frame can't unlock this
// wider range by merely setting curvature to 0. Curvature mode always keeps the tight cap,
// including at curvature == 0 (straight driving, or the reset/human-turn frame) -- path_angle
// there only trims and amplifies wound-up curvature, never needs the wide range.
float path_angle_min_phys = ford_bp_angle_mode_engaged ? FORD_DBC_PATH_ANGLE_MIN : FORD_PATH_ANGLE_MIN;
float path_angle_max_phys = ford_bp_angle_mode_engaged ? FORD_DBC_PATH_ANGLE_MAX : FORD_PATH_ANGLE_MAX;
const float path_angle_min_scaled = path_angle_min_phys * FORD_PATH_ANGLE_LIMITS.angle_deg_to_can;
const float path_angle_max_scaled = path_angle_max_phys * FORD_PATH_ANGLE_LIMITS.angle_deg_to_can;
int path_angle_min_can = (int)path_angle_min_scaled;
int path_angle_max_can = (int)path_angle_max_scaled;
violation |= (desired_path_angle < path_angle_min_can) || (desired_path_angle > path_angle_max_can);
FORD_BP_DBG("%s: desired_path_angle: %d, path_angle_min_can: %d, path_angle_max_can: %d, violation: %d\n",
dbg_prefix, desired_path_angle, path_angle_min_can, path_angle_max_can, (int)violation);
// Check the curvature command and steer_control_enabled. At curvature 0 (angle mode, where
// path_angle does the steering, and curvature mode's reset frame) ford_bp_curvature_cmd_checks skips
// its value checks itself and keeps the speed cross-check, message-rate check and controls gate;
// path_angle keeps its own checks regardless.
violation |= ford_bp_curvature_cmd_checks(desired_curvature, steer_control_enabled, limits);
FORD_BP_DBG("%s: 1. desired_curvature violation: %d\n", dbg_prefix, (int)violation);
// Angle mode's own deviation-only check against shadow_curvature, once angle mode is confirmed
// engaged. If desired_curvature == 0 but angle mode is NOT confirmed, this is skipped -- that's
// ordinary curvature mode at zero (straight driving or the reset/human-turn frame), which needs
// no shadow-curvature check; it's still bounded by the tight path_angle range above and
// steer_control_enabled's own checks.
if ((desired_curvature == 0) && ford_bp_angle_mode_engaged) {
const float shadow_curvature_scaled = (float)ford_bp_shadow_curvature_raw * FORD_BP_LKA_SHADOW_RAW_TO_CAN;
int shadow_curvature_can = (int)shadow_curvature_scaled;
violation |= ford_shadow_curvature_error_check(shadow_curvature_can, steer_control_enabled, *base);
}
// Check path angle rate of change limits
violation |= path_angle_cmd_checks(desired_path_angle, steer_control_enabled, FORD_PATH_ANGLE_LIMITS);
FORD_BP_DBG("%s: 2. desired_path_angle violation: %d\n", dbg_prefix, (int)violation);
// Check path offset rate of change limits
violation |= path_offset_cmd_checks(desired_path_offset, steer_control_enabled, FORD_PATH_OFFSET_LIMITS);
FORD_BP_DBG("%s: 3. desired_path_offset violation: %d\n", dbg_prefix, (int)violation);
return violation;
}
// BluePilot: the curvature limit sets ford_bp_lmc_tx picks from: the host's BP_HOST_CURVATURE_LIMITS as
// .base plus the measured rate tables, checked by ford_bp_curvature_cmd_checks instead of upstream's
// ISO-jerk-derived delta. The values are the pre-sync macro's. max_curv_err is 100 (0.002) on the stock
// yaw-sourced angle_meas path and 150 (0.003) on the pinion-sourced path (STEER_ANGLE_CURVATURE):
// firmware uses the raw pinion angle, with no roll/alignment-offset compensation (the Python layer
// compensates via liveParameters). ford_bp_curvature_cmd_checks assumes max_steer_power 0 and
// inactive_curvature_is_zero true rather than reading them, and base sets only the fields the library
// reads, so it initializes upstream's CurvatureSteeringLimits and a host's narrower struct alike.
#define FORD_LIMITS(limit_lateral_accel, max_curv_err) { \
.base = { \
.max_curvature = 1000, /* 0.02 curvature */ \
.curvature_to_can = 50000, /* 1 / (2e-5) rad to can */ \
.frequency = 20U, /* LateralMotionControl / LateralMotionControl2 @ 20 Hz */ \
.max_curvature_error = (max_curv_err), \
/* no blending at low speed due to lack of torque wind-up and inaccurate current curvature */\
.curvature_error_min_speed = 10.0, /* m/s */ \
}, \
/* Down table 1% looser than up (#20); both >= the Python control's tables (values_ext) */ \
.curvature_rate_up_lookup = { \
{5., 16., 25.}, \
{0.0025f, 0.0014f, 0.00018f} \
}, \
.curvature_rate_down_lookup = { \
{5., 16., 25.}, \
{0.002525f, 0.001414f, 0.0001818f} \
}, \
.limit_lateral_acceleration = (limit_lateral_accel), \
}
// BluePilot: LateralMotionControl (CAN) or LateralMotionControl2 (CAN FD) with BP lateral on. Both
// messages carry the same four signals; ford_tx_hook decodes the raw values, and this converts them
// to signed CAN units (physical = raw * scale - offset), picks the limit set for the message and the
// angle_meas source, and runs ford_lmc_checks.
static inline bool ford_bp_lmc_tx(bool canfd, unsigned int raw_curvature, unsigned int raw_curvature_rate,
unsigned int raw_path_offset, unsigned int raw_path_angle, bool steer_control_enabled) {
// static inside the one function that reads them (MISRA 8.9)
static const FordBpCurvatureLimits FORD_BP_STEERING_LIMITS = FORD_LIMITS(false, 100);
static const FordBpCurvatureLimits FORD_BP_STEERING_LIMITS_PINION = FORD_LIMITS(false, 150);
static const FordBpCurvatureLimits FORD_CANFD_STEERING_LIMITS = FORD_LIMITS(true, 100);
static const FordBpCurvatureLimits FORD_CANFD_STEERING_LIMITS_PINION = FORD_LIMITS(true, 150);
const FordBpCurvatureLimits *limits;
unsigned int inactive_curvature_rate;
const char *dbg_prefix;
if (canfd) {
limits = ford_bp_pinion_curvature ? &FORD_CANFD_STEERING_LIMITS_PINION : &FORD_CANFD_STEERING_LIMITS;
inactive_curvature_rate = FORD_CANFD_INACTIVE_CURVATURE_RATE;
dbg_prefix = "CANFD Out";
} else {
limits = ford_bp_pinion_curvature ? &FORD_BP_STEERING_LIMITS_PINION : &FORD_BP_STEERING_LIMITS;
inactive_curvature_rate = FORD_INACTIVE_CURVATURE_RATE;
dbg_prefix = "CAN Out";
}
int desired_curvature = raw_curvature - FORD_INACTIVE_CURVATURE;
int desired_curvature_rate = raw_curvature_rate - inactive_curvature_rate;
int desired_path_offset = raw_path_offset - FORD_INACTIVE_PATH_OFFSET;
int desired_path_angle = raw_path_angle - FORD_INACTIVE_PATH_ANGLE;
return ford_lmc_checks(desired_curvature, desired_curvature_rate, desired_path_offset, desired_path_angle,
steer_control_enabled, limits, dbg_prefix);
}
// BluePilot: angle_mode_engaged and shadow_curvature ride in Lane_Assist_Data1 bits with no DBC
// signal mapped to them (layout: bluepilot_lateral/can/LANE_ASSIST_DATA1.md), read out of
// the frame being sent. A separate CAN message (0x5F0)
// was tried first: the panda never receives its own TX, and an RX check for it tripped
// safety_tick's lagging check and blocked controls car-wide (2026-07-09).
static inline void ford_bp_lka_tx(const CANPacket_t *msg) {
ford_bp_angle_mode_engaged = (msg->data[FORD_BP_LKA_ANGLE_MODE_BYTE] & FORD_BP_LKA_ANGLE_MODE_MASK) != 0U;
unsigned int shadow_curvature_raw = (msg->data[FORD_BP_LKA_SHADOW_MSB_BYTE] << 8) | msg->data[FORD_BP_LKA_SHADOW_LSB_BYTE];
ford_bp_shadow_curvature_raw = (int16_t)shadow_curvature_raw;
}
// BluePilot: optional angle_meas source -- measured curvature from the steering pinion angle (PSCM)
// via the vehicle model, for vehicles whose RCM broadcasts implausible yaw (sign-inverted vs
// IMU/steering geometry) while its quality flag still reads OK. The pinion angle was validated
// against the comma IMU (corr +0.99 on real routes); the Python control layer measures from the
// same source when this is enabled (carcontroller_ext.InputTracker.get_current_curvature), so the layers always
// agree. ford_rx_hook skips its yaw block while this source is on.
static inline void ford_bp_rx_hook(const CANPacket_t *msg) {
// addr and bus compared directly, not with upstream's msg_matches, which not every host has
if ((msg->addr == FORD_SteeringPinion_Data) && (msg->bus == FORD_MAIN_BUS) && ford_bp_pinion_curvature) {
// Signal: StePinComp_An_Est, 22|15@0+ (0.1,-1600) deg
int angle_raw = ((msg->data[2] & 0x7FU) << 8) | msg->data[3];
float pinion_angle_deg = ((float)angle_raw * 0.1f) - 1600.0f;
float pinion_angle_rad = pinion_angle_deg * 0.017453292519943295f; // DEG_TO_RAD
// angle -> curvature via vehicle model (matches VehicleModel.curvature_factor);
// sign: firmware angle_meas is Ford wire convention (the yaw block uses +yaw/v), and
// pinion angle correlates +0.97 with wire desired curvature on real frames -> positive.
float speed = SAFETY_MAX(vehicle_speed.values[0] / VEHICLE_SPEED_FACTOR, 0.1);
float curvature_factor = get_curvature_factor(speed, *ford_bp_pinion_params);
float current_curvature = pinion_angle_rad * curvature_factor / ford_bp_pinion_params->steer_ratio;
// convert current curvature into units on CAN for comparison with desired curvature
update_sample(&BP_HOST_CURVATURE_STATE.meas, ROUND(current_curvature * FORD_STEERING_LIMITS.curvature_to_can));
}
}
// BluePilot: read the SP safety param and reset the BP lateral state. param_sp is
// current_safety_param_sp, delivered via USB 0xdf before the safety model is set (a separate uint16
// from ford_init's param; pattern: subaru_common.h). Bits: 0 pinion-curvature enable, 1-4 pinion
// geometry index, 5 BP 4-signal lateral (clear = upstream's stock lateral safety, verbatim). A zero
// or out-of-range index disables the pinion source, so a half-configured param can never select the
// wrong geometry: the stock yaw path is kept. Returns whether the pinion source is on, i.e. whether
// ford_init applies the pinion RX check (ford_rx_checks_pinion). That check stays out of the stock
// config, so a pinion hiccup can't disable controls for users who never consume the message.
static inline bool ford_bp_init(uint16_t param_sp) {
const uint16_t FORD_PARAM_SP_BP_LATERAL = 32;
ford_bp_lateral = GET_FLAG(param_sp, FORD_PARAM_SP_BP_LATERAL);
const uint16_t FORD_PARAM_SP_STEER_ANGLE_CURVATURE = 1;
// Pinion measurement is only valid with the BP checks: the stock error band (100) was tuned for the
// yaw source and the stock Python path clips against yaw-derived curvature.
bool pinion_enabled = ford_bp_lateral && GET_FLAG(param_sp, FORD_PARAM_SP_STEER_ANGLE_CURVATURE);
const uint16_t pinion_geometry_index = (param_sp >> 1) & 0xFU;
if ((pinion_geometry_index == 0U) || (pinion_geometry_index > FORD_PINION_GEOMETRY_COUNT)) {
pinion_enabled = false;
}
ford_bp_pinion_curvature = pinion_enabled;
ford_bp_pinion_params = pinion_enabled ? &ford_pinion_geometry[pinion_geometry_index] : &ford_pinion_geometry[0];
// start from a clean BP lateral state, as set_safety_hooks does for upstream's (desired_angle_last
// etc.), so a re-init never checks against a previous session's commands. The angle-mode flag is
// set again by the next Lane_Assist_Data1 frame.
desired_path_angle_last = 0;
desired_path_offset_last = 0;
ford_bp_angle_mode_engaged = false;
return ford_bp_pinion_curvature;
}
@@ -1,249 +0,0 @@
#pragma once
// Angle-primary Ford extension adapted from Alan Polk's bp-7.0 e1d051d7.
// See ../CREDITS.md, ../LICENSE and ../LICENSE.md. No reset bypass.
#include "opendbc/bluepilot_lateral/params/protocol.h"
#include "opendbc/bluepilot_lateral/params/limits.h"
// Shared Ford stock-switch state is declared before the angle hooks below.
static bool ford_stock_switch = false;
static bool ford_bp_enabled = false;
static bool ford_bp_canfd = false;
static bool ford_bp_shadow_seen = false;
static bool ford_bp_lka_counter_seen = false;
static uint8_t ford_bp_lka_counter = 0U;
static int ford_bp_shadow_raw = 0;
static uint32_t ford_bp_shadow_us = 0U;
static int ford_bp_path_last = 0;
static bool ford_bp_control_counter_seen = false;
static uint8_t ford_bp_control_counter = 0U;
static bool ford_bp_control_seen = false;
static uint32_t ford_bp_control_us = 0U;
static float ford_bp_equivalent_last = 0.0F;
static unsigned int ford_bp_rt_msgs = 0U;
static unsigned int ford_bp_rt_msgs_prev = 0U;
static uint32_t ford_bp_rt_us = 0U;
static bool ford_bp_rx_seen[7];
static uint32_t ford_bp_rx_us[7];
static void ford_bp_invalidate_shadow(void) {
ford_bp_shadow_seen = false;
ford_bp_shadow_raw = 0;
}
static void ford_bp_withdraw(void) {
ford_bp_invalidate_shadow();
ford_bp_path_last = 0;
ford_bp_equivalent_last = 0.0F;
}
static bool ford_bp_configure(uint16_t param) {
ford_bp_enabled = ((param == 128U) || (param == 129U) || (param == 130U)) && (alternative_experience == 0);
#ifdef ALLOW_DEBUG
ford_bp_enabled |= (param == 131U) && (alternative_experience == 0);
#endif
const bool joint = ((unsigned int)alternative_experience == 32U) &&
((param == 146U) || (param == 147U) || (param == 160U) || (param == 161U) || (param == 194U) || (param == 195U));
ford_bp_enabled |= joint;
ford_bp_canfd = (param & 2U) != 0U;
ford_bp_lka_counter_seen = false;
ford_bp_control_counter_seen = false;
ford_bp_control_seen = false;
ford_bp_lka_counter = 0U;
ford_bp_control_counter = 0U;
ford_bp_shadow_us = 0U;
ford_bp_control_us = 0U;
ford_bp_rt_msgs = 0U;
ford_bp_rt_msgs_prev = 0U;
ford_bp_rt_us = microsecond_timer_get();
ford_bp_withdraw();
for (unsigned int i = 0U; i < 7U; i++) {
ford_bp_rx_seen[i] = false;
ford_bp_rx_us[i] = 0U;
}
return ((param & 128U) == 0U) || ford_bp_enabled;
}
static bool ford_bp_health(uint32_t now);
static bool ford_bp_permission(void) {
bool permitted = false;
if (ford_aol_enabled) {
permitted = (ford_aol_permission_mask() & 1U) != 0U;
} else {
permitted = lateral_controls_allowed() && controls_allowed && !brake_pressed && !gas_pressed;
}
return permitted;
}
static void ford_bp_rx(const CANPacket_t *msg) {
if (ford_bp_enabled && (msg->bus == 0U) && (GET_LEN(msg) == 8U)) {
const unsigned int ids[7] = {0x415U, 0x202U, 0x91U, 0x165U, 0x204U, 0x213U, 0x83U};
for (unsigned int i = 0U; i < 7U; i++) {
if (msg->addr == ids[i]) {
ford_bp_rx_seen[i] = true;
ford_bp_rx_us[i] = microsecond_timer_get();
}
}
// Angle-specific source freshness is stricter than the generic1s RX tick.
// Expose that withdrawal through the ordinary public controls acknowledgment.
if (controls_allowed && !ford_bp_health(microsecond_timer_get())) {
controls_allowed = false;
}
// Permission losses remove the old actuator history before any later arm.
if (!ford_bp_permission() || !ford_bp_health(microsecond_timer_get())) {
ford_bp_withdraw();
}
}
}
static bool ford_bp_health(uint32_t now) {
bool valid = !safety_rx_checks_invalid && !relay_malfunction;
for (int i = 0; i < current_safety_config.rx_checks_len; i++) {
const RxStatus *status = &current_safety_config.rx_checks[i].status;
valid &= status->msg_seen && !status->lagging && status->valid_checksum &&
status->valid_quality_flag && (status->wrong_counters < MAX_WRONG_COUNTERS);
}
const unsigned int count = ford_stock_switch ? 7U : 6U;
for (unsigned int i = 0U; i < count; i++) {
const uint32_t age = ((i == 3U) || (i == 6U)) ? (2U * FORD_BP_RX_AGE_US) : FORD_BP_RX_AGE_US;
valid &= ford_bp_rx_seen[i] && (safety_get_ts_elapsed(now, ford_bp_rx_us[i]) <= age);
}
return valid;
}
static bool ford_bp_lka(const CANPacket_t *msg) {
const uint32_t now = microsecond_timer_get();
const unsigned int counter = (msg->data[4] & FORD_BP_COUNTER_MASK) >> FORD_BP_COUNTER_SHIFT;
unsigned int sum = 0U;
for (unsigned int i = 0U; i < 7U; i++) { sum += msg->data[i]; }
const unsigned int checksum = (15U - (sum & 15U)) & 15U;
// Preserve every DBC-owned bit of the packer's neutral LKA body.
const unsigned int angle = ((msg->data[2] & 0xFU) << 8) | msg->data[3];
const unsigned int curvature = (msg->data[1] << 4) | (msg->data[2] >> 4);
bool valid = !relay_malfunction && (msg->bus == 0U) && (GET_LEN(msg) == 8U) && (msg->data[0] == 0U) &&
(angle == 2048U) && (curvature == 2048U) &&
((msg->data[4] & 0xE3U) == FORD_BP_ANGLE_MASK) &&
((msg->data[7] & FORD_BP_VERSION_MASK) == FORD_BP_VERSION) &&
((msg->data[7] >> FORD_BP_CHECKSUM_SHIFT) == checksum);
const int shadow = to_signed((msg->data[5] << 8) | msg->data[6], 16);
const bool resync = (shadow == 0) && !ford_bp_permission();
if (ford_bp_lka_counter_seen && !resync) {
valid &= counter == ((ford_bp_lka_counter + 1U) % FORD_BP_COUNTER_MODULUS);
valid &= safety_get_ts_elapsed(now, ford_bp_shadow_us) >= 20000U;
}
valid &= SAFETY_ABS(shadow) <= 20000;
if (valid) {
ford_bp_lka_counter = (uint8_t)counter;
ford_bp_lka_counter_seen = true;
ford_bp_shadow_raw = shadow;
ford_bp_shadow_seen = !resync;
ford_bp_shadow_us = now;
} else {
ford_bp_invalidate_shadow();
}
return valid;
}
static bool ford_bp_lateral(const CANPacket_t *msg) {
const bool fd = msg->addr == 0x3D6U;
const unsigned int mode = fd ? ((msg->data[0] >> 4) & 7U) : ((msg->data[4] >> 2) & 7U);
const unsigned int curvature = fd ? ((msg->data[2] << 3) | (msg->data[3] >> 5)) :
((msg->data[0] << 3) | (msg->data[1] >> 5));
const unsigned int rate = fd ? (((unsigned int)msg->data[6] << 3) | (msg->data[7] >> 5)) :
(((msg->data[1] & 31U) << 8) | msg->data[2]);
const unsigned int path_raw = fd ? (((msg->data[3] & 31U) << 6) | (msg->data[4] >> 2)) :
(((unsigned int)msg->data[3] << 3) | (msg->data[4] >> 5));
const unsigned int offset = fd ? (((msg->data[4] & 3U) << 8) | msg->data[5]) :
(((unsigned int)msg->data[5] << 2) | (msg->data[6] >> 6));
const unsigned int precision = fd ? ((msg->data[0] >> 2) & 3U) : (msg->data[4] & 3U);
const unsigned int ramp = fd ? (msg->data[0] & 3U) : ((msg->data[6] >> 4) & 3U);
const bool active = mode == 1U;
const int path = (int)path_raw - 1000;
const uint32_t now = microsecond_timer_get();
bool valid = !relay_malfunction && (msg->bus == 0U) && (GET_LEN(msg) == 8U) && (fd == ford_bp_canfd) && (mode <= 1U) &&
(curvature == 1000U) && (rate == (fd ? 1024U : 4096U)) && (offset == 512U) &&
(precision <= 1U) && (ramp == (active ? 2U : 0U));
const unsigned int counter = (msg->data[7] >> 1) & 15U;
if (fd) {
unsigned int checksum = mode + counter;
const unsigned int fields[4] = {curvature, rate, path_raw, offset};
for (unsigned int i = 0U; i < 4U; i++) { checksum += fields[i] + (fields[i] >> 8); }
valid &= msg->data[1] == (0xFFU - (checksum & 0xFFU));
valid &= ((msg->data[0] & 0x80U) == 0U) && ((msg->data[7] & 1U) == 0U);
if (ford_bp_control_counter_seen && active) { valid &= counter == ((ford_bp_control_counter + 1U) % 16U); }
} else {
valid &= ((msg->data[6] & 0xFU) == 0U) && (msg->data[7] == 0U);
}
const float speed_for_equivalent = SAFETY_MAX(vehicle_speed.min / VEHICLE_SPEED_FACTOR, 0.1F);
const float equivalent = (float)path / (2000.0F * speed_for_equivalent);
// Time-window expiration is independent of packet acceptance: denied floods
// cannot latch the old count forever or erase accepted actuator history.
const uint32_t rt_elapsed = safety_get_ts_elapsed(now, ford_bp_rt_us);
if (rt_elapsed >= FORD_BP_RT_INTERVAL_US) {
ford_bp_rt_msgs = 0U;
ford_bp_rt_msgs_prev = 0U;
ford_bp_rt_us = now;
} else if (rt_elapsed >= (FORD_BP_RT_INTERVAL_US / 2U)) {
ford_bp_rt_msgs_prev = ford_bp_rt_msgs;
ford_bp_rt_msgs = 0U;
ford_bp_rt_us = now;
} else {
// Keep the current history while neither expiration boundary has elapsed.
}
const unsigned int rt_count = ford_bp_rt_msgs + ford_bp_rt_msgs_prev;
valid &= rt_count <= 7U; // Current20Hz curvature RT allowance; accepted frames own this count.
if (active) {
static const struct lookup_t roc = {{10.0F, 15.0F, 25.0F}, {0.0561F, 0.04335F, 0.00918F}};
const float speed_min = SAFETY_MAX(vehicle_speed.min / VEHICLE_SPEED_FACTOR, 0.1F);
const float speed_max = SAFETY_MAX(vehicle_speed.max / VEHICLE_SPEED_FACTOR, 0.1F);
const float delta_float = safety_interpolate(roc, speed_min - 1.0F) * 2000.0F;
const int delta = (int)delta_float + 1;
const float shadow = (float)ford_bp_shadow_raw * 0.000001F;
const float shadow_can = shadow * 50000.0F;
const float path_abs = SAFETY_ABS(path) / 2000.0F;
const float path_curvature_low = path_abs / (speed_max * FORD_BP_GAIN_MAX);
const float path_curvature_high = path_abs / (speed_min * FORD_BP_GAIN_MIN);
const float physical_cap = SAFETY_MIN(FORD_BP_CURVATURE_MAX, FORD_BP_LATERAL_ACCEL / (SAFETY_MAX(speed_max, 1.0F) * SAFETY_MAX(speed_max, 1.0F)));
const float relation_low = SAFETY_ABS(shadow) * speed_min * FORD_BP_GAIN_MIN * 2000.0F;
const float relation_high = SAFETY_ABS(shadow) * speed_max * FORD_BP_GAIN_MAX * 2000.0F;
valid &= ford_bp_permission() && ford_bp_health(now) && ford_bp_shadow_seen &&
(safety_get_ts_elapsed(now, ford_bp_shadow_us) <= FORD_BP_SHADOW_AGE_US) &&
(path >= -1000) && (path <= 1047) && (SAFETY_ABS(path - ford_bp_path_last) <= delta) &&
((path == 0) || ((ford_bp_shadow_raw != 0) && ((path > 0) == (ford_bp_shadow_raw > 0)))) &&
(SAFETY_ABS(path) >= (relation_low - 2.0F)) && (SAFETY_ABS(path) <= (relation_high + 2.0F)) &&
(SAFETY_ABS(shadow) <= (physical_cap + FORD_BP_SHADOW_ROUNDING_MARGIN)) && (path_curvature_high <= (physical_cap + FORD_BP_SHADOW_ROUNDING_MARGIN));
if (speed_max > 10.0F) {
const int measured_min = curvature_state.meas.min - (ford_pinion_enabled ? 150 : 100);
const int measured_max = curvature_state.meas.max + (ford_pinion_enabled ? 150 : 100);
const float measured_min_float = (float)measured_min;
const float measured_max_float = (float)measured_max;
valid &= (shadow_can >= measured_min_float) && (shadow_can <= measured_max_float);
// Independent actual-actuator check; an invented shadow cannot launder path angle.
const float signed_low = (path < 0) ? (-path_curvature_high) : path_curvature_low;
const float signed_high = (path < 0) ? (-path_curvature_low) : path_curvature_high;
valid &= ((signed_low * 50000.0F) >= measured_min_float) &&
((signed_high * 50000.0F) <= measured_max_float);
}
const uint32_t elapsed = ford_bp_control_seen ? safety_get_ts_elapsed(now, ford_bp_control_us) : FORD_BP_STEER_PERIOD_US;
const float jerk_speed = SAFETY_MAX(speed_min - 1.0F, 1.0F);
const float jerk_delta = FORD_BP_LATERAL_JERK / (jerk_speed * jerk_speed) *
(SAFETY_MIN(elapsed, FORD_BP_STEER_PERIOD_US) * 0.000001F) * FORD_BP_GAIN_MIN;
valid &= SAFETY_ABS(equivalent - ford_bp_equivalent_last) <= (jerk_delta + FORD_BP_SHADOW_ROUNDING_MARGIN);
if (ford_bp_control_seen) { valid &= elapsed >= 40000U; }
} else {
valid &= path == 0;
}
if (valid) {
ford_bp_rt_msgs += 1U;
ford_bp_equivalent_last = active ? equivalent : 0.0F;
ford_bp_path_last = active ? path : 0;
ford_bp_control_counter = (uint8_t)counter;
ford_bp_control_counter_seen = true;
ford_bp_control_seen = true;
ford_bp_control_us = now;
if (!active) { ford_bp_shadow_seen = false; }
} else {
ford_bp_invalidate_shadow();
}
return valid;
}
@@ -0,0 +1,99 @@
/**
* BluePilot Ford lateral-control extensions: type declarations, state, and prototypes.
* See ford.h in this directory for the implementations.
*/
#pragma once
#include "../../safety/declarations.h"
#include "../safety/lateral_declarations.h"
// BluePilot: Ford safety debug prints. Compile-time switch: build with -DFORD_BP_DEBUG (bench /
// libsafety only) to turn every FORD_BP_DBG into a printf; otherwise it expands to nothing, so CI
// coverage and mutation see no code and the panda build carries no runtime flag. The printf
// branch is bench-only and is not part of the MISRA-checked configuration (test_misra.sh runs
// without FORD_BP_DEBUG). To use it on the bench, compile libsafety with
// cc ... -DALLOW_DEBUG -DFORD_BP_DEBUG -I . -c <pkg>/safety/tests/libsafety/safety.c
// (the same flags <pkg>/safety/tests/libsafety/libsafety_py.py uses, plus FORD_BP_DEBUG; <pkg> is the
// host's opendbc package: opendbc, iqdbc).
#ifdef FORD_BP_DEBUG
#include <stdio.h>
#define FORD_BP_DBG(...) printf(__VA_ARGS__)
#else
#define FORD_BP_DBG(...) ((void)0)
#endif
// ===============================
// Constants and Defines
// ===============================
// BluePilot: steering-angle curvature measurement (bad-yaw-sensor workaround). Index 0 of
// ford_pinion_geometry is reserved as invalid; see ford_bp_init in ford.h.
#define FORD_PINION_GEOMETRY_COUNT 12U
// Row count including the reserved index-0 entry. Kept as a plain literal: the mutation
// harness rewrites arithmetic in array sizes into runtime expressions, which cannot compile.
#define FORD_PINION_GEOMETRY_ROWS 13U
// Control signal limits -- curvature magnitude must match MAX_CURVATURE; rate tables must match
// bluepilot_lateral/core/values_ext.py BP_ANGLE_LIMITS.
#define FORD_CURVATURE_MIN -0.02f
#define FORD_CURVATURE_MAX 0.02f
#define FORD_PATH_OFFSET_MIN -1.0f
#define FORD_PATH_OFFSET_MAX 1.0f
#define FORD_PATH_ANGLE_MIN -0.25f
#define FORD_PATH_ANGLE_MAX 0.25f
// BluePilot: full DBC signal range for path_angle (LatCtlPath_An, 0.0005 scale). Used as the
// value limit in ANGLE mode, where path_angle is the steering actuator (see ford_lmc_checks).
// Matches lateral_angle_ext.py FORD_DBC_PATH_ANGLE_MIN/MAX. In curvature mode the tight ±0.25
// cap above applies instead (path_angle there only trims and amplifies wound-up curvature).
#define FORD_DBC_PATH_ANGLE_MIN -0.5f
#define FORD_DBC_PATH_ANGLE_MAX 0.5235f
// ===============================
// Global Variables
// ===============================
// BluePilot: optional angle_meas source, measured curvature from the steering pinion angle
// (PSCM) via the vehicle model, selected by the geometry index in current_safety_param_sp
// bits 1-4. Default off = stock yaw-sourced angle_meas. See ford_bp_init in ford.h.
extern bool ford_bp_lateral;
extern bool ford_bp_pinion_curvature;
extern const AngleSteeringParams *ford_bp_pinion_params;
extern const AngleSteeringParams ford_pinion_geometry[FORD_PINION_GEOMETRY_ROWS];
// BluePilot: angle_mode_engaged + shadow_curvature, read out of Lane_Assist_Data1's unused
// bits by ford_bp_lka_tx (no separate CAN message, no RX). The layout is in
// bluepilot_lateral/can/LANE_ASSIST_DATA1.md; fordcan_ext.py packs it with the same values
// (tests/test_lane_assist_data1.py).
#define FORD_BP_LKA_ANGLE_MODE_BYTE 4U
#define FORD_BP_LKA_ANGLE_MODE_MASK 0x1U
#define FORD_BP_LKA_SHADOW_MSB_BYTE 5U // shadow curvature: int16, big-endian, bytes 5-6
#define FORD_BP_LKA_SHADOW_LSB_BYTE 6U
// shadow curvature raw units (1e-6 1/m) to curvature CAN units (2e-5 1/m)
#define FORD_BP_LKA_SHADOW_RAW_TO_CAN 0.05f
extern bool ford_bp_angle_mode_engaged;
extern int16_t ford_bp_shadow_curvature_raw; // wire units, scale 1e-6 1/m (LANE_ASSIST_DATA1.md)
// last commanded values for the path_angle / path_offset rate-of-change checks
extern int desired_path_angle_last;
extern int desired_path_offset_last;
// ===============================
// Function Declarations
// ===============================
static inline bool path_angle_cmd_checks(int desired_path_angle, bool steer_control_enabled, AngleSteeringLimits limits);
static inline bool path_offset_cmd_checks(int desired_path_offset, bool steer_control_enabled, AngleSteeringLimits limits);
static inline bool ford_shadow_curvature_error_check(int desired_curvature, bool steer_control_enabled, BP_HOST_CURVATURE_LIMITS limits);
// Shared curvature/curvature_rate/path_offset/path_angle command checks for LateralMotionControl
// (CAN) and LateralMotionControl2 (CAN FD) -- see ford_lmc_checks in ford.h.
static inline bool ford_lmc_checks(int desired_curvature, int desired_curvature_rate, int desired_path_offset, int desired_path_angle,
bool steer_control_enabled, const FordBpCurvatureLimits *limits, const char *dbg_prefix);
// Called from modes/ford.h's hooks and ford_init -- see bluepilot_lateral/safety/ford.h.
static inline bool ford_bp_lmc_tx(bool canfd, unsigned int raw_curvature, unsigned int raw_curvature_rate,
unsigned int raw_path_offset, unsigned int raw_path_angle, bool steer_control_enabled);
static inline void ford_bp_lka_tx(const CANPacket_t *msg);
static inline void ford_bp_rx_hook(const CANPacket_t *msg);
static inline bool ford_bp_init(uint16_t param_sp);
@@ -0,0 +1,121 @@
/**
* BluePilot lateral-control extensions shared across safety modes: implementation.
* See lateral_declarations.h in this directory for the declarations.
*/
#pragma once
#include "../safety/lateral_declarations.h"
// BluePilot: measured-table rate limiting. This is the pre-sync steer_angle_cmd_checks logic,
// carried over verbatim in the curvature domain so Ford lateral behavior is unchanged by the
// move off AngleSteeringLimits. Differences from upstream's ISO-only path (steer_curvature_cmd_checks
// in opendbc/safety/lateral.h) that matter:
// - asymmetric up/down deltas selected by the sign of the last command
// - the error boundary uses relaxed deltas and CONVERGES toward measured, rather than
// hard-clamping, so a wound-up command unwinds instead of instantly faulting
// - the ISO lateral accel cap is applied only on buses that need it (Q4/CAN FD)
static inline bool ford_bp_curvature_rate_lookup_check(int desired_curvature, const FordBpCurvatureLimits *limits) {
const BP_HOST_CURVATURE_LIMITS *base = &limits->base;
bool violation = false;
const float fudged_speed_lookup = (vehicle_speed.min / VEHICLE_SPEED_FACTOR) - 1.;
const int delta_up = (safety_interpolate(limits->curvature_rate_up_lookup, fudged_speed_lookup) * base->curvature_to_can) + 1.;
const int delta_down = (safety_interpolate(limits->curvature_rate_down_lookup, fudged_speed_lookup) * base->curvature_to_can) + 1.;
// allow down limits at zero since small floats from openpilot will be rounded to 0
int highest_desired_curvature = BP_HOST_CURVATURE_STATE.desired_last + ((BP_HOST_CURVATURE_STATE.desired_last > 0) ? delta_up : delta_down);
int lowest_desired_curvature = BP_HOST_CURVATURE_STATE.desired_last - ((BP_HOST_CURVATURE_STATE.desired_last >= 0) ? delta_down : delta_up);
if (base->max_curvature_error && ((vehicle_speed.values[0] / VEHICLE_SPEED_FACTOR) > base->curvature_error_min_speed)) {
// flipped fudge to avoid false positives
const float fudged_speed_error = (vehicle_speed.max / VEHICLE_SPEED_FACTOR) + 1.;
const int delta_up_relaxed = (safety_interpolate(limits->curvature_rate_up_lookup, fudged_speed_error) * base->curvature_to_can) - 1.;
const int delta_down_relaxed = (safety_interpolate(limits->curvature_rate_down_lookup, fudged_speed_error) * base->curvature_to_can) - 1.;
const int lowest_desired_curvature_error = BP_HOST_CURVATURE_STATE.meas.min - base->max_curvature_error - 1;
const int highest_desired_curvature_error = BP_HOST_CURVATURE_STATE.meas.max + base->max_curvature_error + 1;
// the MAX/MIN allow the desired value to reach the edge of the bounds without going under
if (BP_HOST_CURVATURE_STATE.desired_last > highest_desired_curvature_error) {
const int delta = (BP_HOST_CURVATURE_STATE.desired_last >= 0) ? delta_down_relaxed : delta_up_relaxed;
highest_desired_curvature = SAFETY_MAX(BP_HOST_CURVATURE_STATE.desired_last - delta, highest_desired_curvature_error);
} else if (BP_HOST_CURVATURE_STATE.desired_last < lowest_desired_curvature_error) {
const int delta = (BP_HOST_CURVATURE_STATE.desired_last <= 0) ? delta_down_relaxed : delta_up_relaxed;
lowest_desired_curvature = SAFETY_MIN(BP_HOST_CURVATURE_STATE.desired_last + delta, lowest_desired_curvature_error);
} else {
// already inside error boundary, don't allow commanding outside it
highest_desired_curvature = SAFETY_MIN(highest_desired_curvature, highest_desired_curvature_error);
lowest_desired_curvature = SAFETY_MAX(lowest_desired_curvature, lowest_desired_curvature_error);
}
// don't enforce above the max curvature
lowest_desired_curvature = SAFETY_CLAMP(lowest_desired_curvature, -base->max_curvature, base->max_curvature);
highest_desired_curvature = SAFETY_CLAMP(highest_desired_curvature, -base->max_curvature, base->max_curvature);
}
violation |= safety_max_limit_check(desired_curvature, highest_desired_curvature, lowest_desired_curvature);
if (limits->limit_lateral_acceleration) {
// Limit to average banked road since safety doesn't have the roll. NOTE: this is the
// pre-sync constant (ISO_LATERAL_ACCEL MINUS the roll term, ~2.4 m/s^2), not the
// MAX_LATERAL_ACCEL used by the ISO path in steer_curvature_cmd_checks (~3.6 m/s^2). Do not
// merge the two.
static const float BP_MAX_LATERAL_ACCEL = ISO_LATERAL_ACCEL - (EARTH_G * AVERAGE_ROAD_ROLL);
const float fudged_speed = SAFETY_MAX((vehicle_speed.min / VEHICLE_SPEED_FACTOR) - 1.0, 1.0);
const float max_curvature_accel = BP_MAX_LATERAL_ACCEL / (fudged_speed * fudged_speed);
const int max_curvature_accel_can = (max_curvature_accel * base->curvature_to_can) + 1.;
violation |= safety_max_limit_check(desired_curvature, max_curvature_accel_can, -max_curvature_accel_can);
}
return violation;
}
// BluePilot: upstream's steer_curvature_cmd_checks (opendbc/safety/lateral.h) with
// ford_bp_curvature_rate_lookup_check in place of its ISO lateral accel and jerk checks. Ford has no
// steer power signal (max_steer_power 0) and resets to zero on a violation
// (inactive_curvature_is_zero), so only those sides of upstream's branches are kept.
// The other difference from upstream: curvature 0 (no curvature command: angle mode, curvature
// mode's reset frame) skips the cap and rate-table checks, keeping the message-rate check and the
// gates. Keep it when re-syncing against upstream (#60).
static inline bool ford_bp_curvature_cmd_checks(int desired_curvature, bool steer_control_enabled,
const FordBpCurvatureLimits *limits) {
bool violation = false;
BP_HOST_SPEED_MISMATCH_CHECK();
if (BP_HOST_LAT_ALLOWED() && steer_control_enabled) {
// BluePilot: curvature 0 is the no-curvature-command sentinel (angle mode steers with path_angle
// and holds curvature at 0; curvature mode's reset frame sends 0), so the value checks don't
// apply to it: the error band would trip as the car turns while the command stays at 0. The
// message-rate check and the gates below still run.
if (desired_curvature != 0) {
// *** absolute curvature cap ***
violation |= safety_max_limit_check(desired_curvature, limits->base.max_curvature, -limits->base.max_curvature);
// *** measured-table rate limits, error band and (CAN FD) lateral accel cap ***
violation |= ford_bp_curvature_rate_lookup_check(desired_curvature, limits);
}
// *** real time rate limit check ***
violation |= BP_HOST_RT_CURVATURE_RATE_CHECK(limits->base);
}
BP_HOST_CURVATURE_STATE.desired_last = desired_curvature;
// Curvature must be 0 while not steering
if (!steer_control_enabled) {
violation |= desired_curvature != 0;
}
// No curvature control allowed when controls are not allowed
violation |= !BP_HOST_LAT_ALLOWED() && steer_control_enabled;
// reset to zero (inactive_curvature_is_zero)
if (violation || !BP_HOST_LAT_ALLOWED()) {
BP_HOST_CURVATURE_STATE.desired_last = 0;
}
return violation;
}
@@ -0,0 +1,60 @@
/**
* BluePilot lateral-control extensions shared across safety modes: declarations.
* See lateral.h in this directory for the implementation.
*/
#pragma once
#include "../../safety/declarations.h"
// BluePilot: the host bindings. The library is written against comma's opendbc safety names; a host
// whose names differ defines these before including safety/ford.h (HOSTS.md, "Host bindings"). The
// defaults are upstream opendbc's (sunnypilot/opendbc), so a host on those names defines nothing.
// Type of FordBpCurvatureLimits.base: the host's curvature limit struct. The library reads only its
// max_curvature, curvature_to_can, frequency, max_curvature_error and curvature_error_min_speed.
#ifndef BP_HOST_CURVATURE_LIMITS
#define BP_HOST_CURVATURE_LIMITS CurvatureSteeringLimits
#endif
// The host's curvature state (desired_last, meas), the one its curvature RX hook fills.
#ifndef BP_HOST_CURVATURE_STATE
#define BP_HOST_CURVATURE_STATE curvature_state
#endif
// The host's real-time message-rate check, taking a BP_HOST_CURVATURE_LIMITS by value.
#ifndef BP_HOST_RT_CURVATURE_RATE_CHECK
#define BP_HOST_RT_CURVATURE_RATE_CHECK rt_curvature_rate_limit_check
#endif
// Whether lateral control is allowed: controls_allowed or the host's lateral-only engagement (MADS).
#ifndef BP_HOST_LAT_ALLOWED
#define BP_HOST_LAT_ALLOWED() (controls_allowed || controls_allowed_lateral)
#endif
// The second-speed cross-check upstream runs inside its curvature check. A host that runs it in its
// RX hook instead defines this as ((void)0).
#ifndef BP_HOST_SPEED_MISMATCH_CHECK
#define BP_HOST_SPEED_MISMATCH_CHECK() speed_mismatch_check((float)vehicle_speed_2.values[0] / VEHICLE_SPEED_FACTOR)
#endif
// BluePilot: curvature limits for the measured-table check. base holds upstream's fields
// (max_curvature, curvature_to_can, frequency, error band, ...); the rest are BluePilot's.
typedef struct {
BP_HOST_CURVATURE_LIMITS base;
// Explicit rate-of-change tables, mirrored from values_ext.py BP_ANGLE_LIMITS. Upstream derives
// the per-tick delta from the ISO lateral jerk limit alone, which is looser than Ford's EPS
// tolerates at low speed and tighter than it tolerates at high speed.
struct lookup_t curvature_rate_up_lookup;
struct lookup_t curvature_rate_down_lookup;
// Ford CAN (Q3) is NOT lateral-accel limited in safety -- the EPS already caps it, and applying
// the ISO cap here would clip commands the car accepts today. Ford CAN FD (Q4) has more torque
// available and IS limited.
bool limit_lateral_acceleration;
} FordBpCurvatureLimits;
// BluePilot: measured-table curvature rate-of-change + error-band check, used by
// ford_bp_curvature_cmd_checks below.
static inline bool ford_bp_curvature_rate_lookup_check(int desired_curvature, const FordBpCurvatureLimits *limits);
// BluePilot: curvature command checks for the BluePilot lateral (Ford only today, see the
// FORD_LIMITS macro in bluepilot_lateral/safety/ford.h). Upstream's steer_curvature_cmd_checks with
// the measured-table check in place of its ISO accel and jerk checks, and no value checks at
// curvature 0, the no-curvature-command sentinel (only the message-rate check and the gates).
static inline bool ford_bp_curvature_cmd_checks(int desired_curvature, bool steer_control_enabled,
const FordBpCurvatureLimits *limits);
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# BluePilot: 100% line coverage of the BluePilot safety headers (#53). Upstream's test.sh only measures
# files under <pkg>/safety/, and the headers live in <pkg>/bluepilot_lateral/safety/. <pkg> is the host's
# opendbc package, whatever it's named (opendbc, iqdbc): the one the library is mounted in.
set -e
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)"
ROOT="$(cd "$DIR/../../../.." && pwd)"
PKG="$(basename "$(cd "$DIR/../../.." && pwd)")"
cd "$ROOT/$PKG/safety/tests"
source "$ROOT/setup.sh"
# reset coverage data, then run upstream's safety tests. The BluePilot ones come in through the host's
# loader (test_bluepilot_lateral.py) where it has one; otherwise they run here on their own.
rm -f ./libsafety/*.gcda ./libsafety/*.gcno
SAFETY_COVERAGE=1 python -m unittest discover -s .
if [ ! -f test_bluepilot_lateral.py ]; then
SAFETY_COVERAGE=1 python -m unittest discover -s "$ROOT/$PKG/bluepilot_lateral/safety/tests" -t "$ROOT"
fi
if [ "$(uname)" = "Darwin" ]; then
GCOV_EXEC="llvm-cov gcov"
else
GCOV_EXEC="gcov"
fi
# Two reports: the library's safety/ford.h and the host's modes/ford.h share a file name, and one gcovr run
# over both can credit one's lines to the other. modes/ford.h is measured from <pkg>/safety, as the host's
# test.sh does; the second run deletes the coverage data.
if ! OUT="$(gcovr -r "$ROOT/$PKG/safety" --gcov-executable "$GCOV_EXEC" --fail-under-line=100 \
-f "$ROOT/$PKG/safety/modes/ford.h")"; then
echo -e "FAILED:\n$OUT"
exit 1
fi
echo "$OUT"
if ! OUT="$(gcovr -r "$ROOT" --gcov-executable "$GCOV_EXEC" -d --fail-under-line=100 \
-f "$ROOT/$PKG/bluepilot_lateral/safety/")"; then
echo -e "FAILED:\n$OUT"
exit 1
fi
echo "$OUT"
echo "SUCCESS: the BluePilot safety headers and modes/ford.h have 100% coverage!"
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""BluePilot: run upstream's mutation.py on the BluePilot safety headers (#53).
Upstream's run only mutates code from files under <pkg>/safety/, and the BluePilot headers live in
the library (<pkg>/bluepilot_lateral/safety/). This points the same run at them; the killer tests
are the ones mutation.py already discovers in <pkg>/safety/tests/, the BluePilot ones through the
loader there (test_bluepilot_lateral.py). Same options as mutation.py. <pkg> is the host's opendbc
package, whatever it's named (opendbc, iqdbc): the one the library is mounted in."""
import importlib
import sys
from pathlib import Path
PKG = Path(__file__).resolve().parents[3].name
mutation = importlib.import_module(f"{PKG}.safety.tests.mutation")
mutation.SAFETY_DIR = mutation.ROOT / PKG / "bluepilot_lateral" / "safety"
# Equivalent mutants: no test can kill them, so they are skipped (mutation.py keeps its own list inside
# main()). ford.h's +-0.02 range check in ford_lmc_checks is the same +-1000 CAN cap as lateral.h's
# max_curvature check, which blocks every frame it would (its steering-off and controls-not-allowed
# checks cover the rest), and the range check writes no state (only ford_bp_curvature_cmd_checks
# resets desired_last). It stays as the backstop if a platform's max_curvature ever differs, and a
# test can reach it then.
KNOWN_SURVIVORS = {
(f"{PKG}/bluepilot_lateral/safety/ford.h", 197, "|=", "&="),
}
_enumerate_sites = mutation.enumerate_sites
def key(site):
return str(site.origin_file.relative_to(mutation.ROOT)), site.origin_line, site.original_op, site.mutated_op
def enumerate_sites(input_source, preprocessed_file):
sites, counts, build_incompatible_ids, source = _enumerate_sites(input_source, preprocessed_file)
skipped = [s for s in sites if key(s) in KNOWN_SURVIVORS]
assert len(skipped) == len(KNOWN_SURVIVORS), f"an equivalent mutant moved or is gone: {sorted(KNOWN_SURVIVORS - {key(s) for s in skipped})}"
for s in skipped:
print(f"skipping equivalent mutant: {key(s)[0]}:{s.origin_line} [{s.mutator}] {s.original_op}->{s.mutated_op}", flush=True)
return [s for s in sites if key(s) not in KNOWN_SURVIVORS], counts, build_incompatible_ids, source
mutation.enumerate_sites = enumerate_sites # main() looks it up by name
if __name__ == "__main__":
sys.exit(mutation.main())
@@ -0,0 +1,268 @@
"""
BluePilot: shared harness for the Ford 4-signal lateral safety tests.
Not a test module (no test_ prefix). Provides:
* BPFordLateralModel: a Python mirror of the BluePilot curvature checks in
opendbc/bluepilot_lateral/safety/lateral.h (ford_bp_curvature_rate_lookup_check) and the lateral
acceleration cap, computed with the same float32 arithmetic and rounding as the C, so the
tests can assert exact CAN-unit boundaries on both x86 and ARM runners.
* BPFordTestCase: unittest base that borrows upstream's Ford test class for its message
builders (composition, so upstream's tests are not inherited) and sets the BP_LATERAL safety
param bit before ford_init.
"""
import re
import unittest
from pathlib import Path
import numpy as np
from ....car.structs import CarParams
from ....car.ford.fordcan import CanBus
from ....car.ford.values import FordSafetyFlags
from ...can import fordcan_ext
from ...core.values_ext import FordSafetyFlagsSP
from ....safety.tests.libsafety import libsafety_py
from ....safety.tests.common import CANPackerSafety
from ....safety.tests import test_ford # module import keeps its test classes out of discovery here
from ...tests import host
f32 = np.float32
def c_int(v) -> int:
"""C (int) cast: truncation toward zero."""
return int(np.trunc(float(v)))
# --- constants mirrored from the C (opendbc/safety/modes/ford.h, opendbc/bluepilot_lateral/safety/*.h, lateral.h) ---
CURVATURE_TO_CAN = 50000.0 # FORD_LIMITS .curvature_to_can
FORD_CURVATURE_MAX = 0.02 # rad/m, FORD_CURVATURE_MAX in ford_declarations.h
MAX_CURVATURE_CAN = c_int(f32(FORD_CURVATURE_MAX) * f32(CURVATURE_TO_CAN)) # FORD_LIMITS .max_curvature, 1000
MAX_CURVATURE_ERROR_CAN = 100 # FORD_LIMITS(_, 100)
MAX_CURVATURE_ERROR_CAN_PINION = 150 # FORD_BP_STEERING_LIMITS_PINION / FORD_CANFD_STEERING_LIMITS_PINION
CURVATURE_ERROR_MIN_SPEED = 10.0 # m/s
VEHICLE_SPEED_FACTOR = 1000.0
RATE_LOOKUP_X = (5., 16., 25.) # m/s, FORD_LIMITS curvature_rate_{up,down}_lookup
RATE_LOOKUP_UP_Y = (0.0025, 0.0014, 0.00018) # rad/m per frame, curvature_rate_up_lookup
RATE_LOOKUP_DOWN_Y = (0.002525, 0.001414, 0.0001818) # curvature_rate_down_lookup: 1% looser than up (#20)
ISO_LATERAL_ACCEL = 3.0
EARTH_G = 9.81
AVERAGE_ROAD_ROLL = 0.06
BP_MAX_LATERAL_ACCEL = float(f32(ISO_LATERAL_ACCEL) - f32(EARTH_G) * f32(AVERAGE_ROAD_ROLL)) # lateral.h, ~2.41 m/s^2
PATH_ANGLE_TO_CAN = 2000.0 # FORD_PATH_ANGLE_LIMITS .angle_deg_to_can (1 / 5e-4 rad)
FORD_PATH_ANGLE_MAX = 0.25 # rad, curvature mode
FORD_DBC_PATH_ANGLE_MIN = -0.5 # rad, angle mode: the DBC's own range
FORD_DBC_PATH_ANGLE_MAX = 0.5235
PATH_ANGLE_MAX_CAN = c_int(f32(FORD_PATH_ANGLE_MAX) * f32(PATH_ANGLE_TO_CAN)) # 500
PATH_ANGLE_DBC_MIN_CAN = c_int(f32(FORD_DBC_PATH_ANGLE_MIN) * f32(PATH_ANGLE_TO_CAN)) # -1000
PATH_ANGLE_DBC_MAX_CAN = c_int(f32(FORD_DBC_PATH_ANGLE_MAX) * f32(PATH_ANGLE_TO_CAN)) # 1047, truncated like the C's (int) cast
PATH_ANGLE_LOOKUP_X = (10., 15., 25.)
PATH_ANGLE_LOOKUP_Y = (0.0561, 0.04335, 0.00918) # rad per frame
PATH_OFFSET_TO_CAN = 100.0 # FORD_PATH_OFFSET_LIMITS .angle_deg_to_can (1 / 0.01 m)
FORD_PATH_OFFSET_MAX = 1.0 # m
PATH_OFFSET_MAX_CAN = c_int(f32(FORD_PATH_OFFSET_MAX) * f32(PATH_OFFSET_TO_CAN)) # 100
PATH_OFFSET_LOOKUP_X = (5., 15., 25.)
PATH_OFFSET_LOOKUP_Y = (0.05, 0.025, 0.01) # m per frame
CURVATURE_RATE_TO_CAN_CANFD = 1000000.0 # LatCtlCrv_NoRate2_Actl wire resolution, 1e-6 1/m^2
CURVATURE_RATE_TO_CAN_CAN = 4000000.0 # LatCtlCurv_NoRate_Actl wire resolution, 2.5e-7 1/m^2
LATCTL_CURVATURE_SIGNAL_MAX = 0.02094 # rad/m, LatCtlCurv_No_Actl range [-0.02|0.02094] in the DBC
CURVATURE_SIGNAL_MAX_CAN = c_int(f32(LATCTL_CURVATURE_SIGNAL_MAX) * f32(CURVATURE_TO_CAN)) # 1047: the packer cannot go further
FORMER_LATCH_WINDOW_FRAMES = 70 # > the 60 frames the removed reset-bypass latch covered (#9)
SMALL_ANGLE = 0.01 # rad: a small non-zero path_angle; probes hold it constant, so its ROC never decides
def c_interpolate(xs, ys, x) -> np.float32:
"""safety_interpolate() from opendbc/safety/helpers.h, in float32."""
x = f32(x)
xs = [f32(v) for v in xs]
ys = [f32(v) for v in ys]
ret = ys[-1]
if x <= xs[0]:
ret = ys[0]
else:
for i in range(len(xs) - 1):
if x < xs[i + 1]:
x0, y0 = xs[i], ys[i]
dx = f32(max(xs[i + 1] - x0, f32(0.0001)))
dy = f32(ys[i + 1] - y0)
ret = f32(f32(dy * f32(x - x0)) / dx) + y0
break
return f32(ret)
def c_fudged_speed(speed_ms: float, offset: float) -> np.float32:
"""(vehicle_speed.x / VEHICLE_SPEED_FACTOR) + offset, as the C computes it: the sample is an
int in mm/s, the division and the offset happen in double, and the result is rounded to
float once when passed to safety_interpolate. speed_ms comes from libsafety's getters,
which are the sample / 1000 rounded to float32; the integer is recovered exactly."""
sample = round(float(speed_ms) * VEHICLE_SPEED_FACTOR)
return f32(sample / VEHICLE_SPEED_FACTOR + offset)
class BPFordLateralModel:
"""Mirror of the BP curvature path in ford_bp_curvature_cmd_checks + ford_bp_curvature_rate_lookup_check.
Speeds are what the C sees: vehicle_speed.min / max / values[0] in m/s (read back from
libsafety after the rx messages so packer rounding is included).
"""
def __init__(self, canfd: bool, max_curvature_error: int = MAX_CURVATURE_ERROR_CAN):
self.limit_lateral_accel = canfd # FORD_LIMITS(true, …) only for CAN FD
self.max_curvature_error = max_curvature_error
@staticmethod
def rate_delta(speed_min: float, up: bool = True) -> int:
# (safety_interpolate(lookup, (vehicle_speed.min / F) - 1.) * curvature_to_can) + 1. -> int
fudged = c_fudged_speed(speed_min, -1.0)
table = RATE_LOOKUP_UP_Y if up else RATE_LOOKUP_DOWN_Y
return c_int(float(f32(c_interpolate(RATE_LOOKUP_X, table, fudged) * f32(CURVATURE_TO_CAN))) + 1.0)
@staticmethod
def rate_delta_relaxed(speed_max: float, up: bool = True) -> int:
fudged = c_fudged_speed(speed_max, 1.0)
table = RATE_LOOKUP_UP_Y if up else RATE_LOOKUP_DOWN_Y
return c_int(float(f32(c_interpolate(RATE_LOOKUP_X, table, fudged) * f32(CURVATURE_TO_CAN))) - 1.0)
@staticmethod
def accel_cap(speed_min: float) -> int:
# BP_MAX_LATERAL_ACCEL / (fudged_speed^2) * curvature_to_can + 1, fudged = max(v.min - 1, 1)
fudged = f32(max(float(c_fudged_speed(speed_min, -1.0)), 1.0))
max_curv = f32(f32(BP_MAX_LATERAL_ACCEL) / f32(fudged * fudged))
return c_int(float(f32(max_curv * f32(CURVATURE_TO_CAN))) + 1.0)
def bounds(self, desired_last: int, meas_min: int, meas_max: int,
speed_min: float, speed_max: float, speed_now: float) -> tuple[int, int]:
"""(lowest, highest) allowed desired curvature in CAN units for the next frame."""
delta_up = self.rate_delta(speed_min, up=True)
delta_down = self.rate_delta(speed_min, up=False)
highest = desired_last + (delta_up if desired_last > 0 else delta_down)
lowest = desired_last - (delta_down if desired_last >= 0 else delta_up)
if self.max_curvature_error and speed_now > CURVATURE_ERROR_MIN_SPEED:
relaxed_up = self.rate_delta_relaxed(speed_max, up=True)
relaxed_down = self.rate_delta_relaxed(speed_max, up=False)
lowest_err = meas_min - self.max_curvature_error - 1
highest_err = meas_max + self.max_curvature_error + 1
if desired_last > highest_err:
delta = relaxed_down if desired_last >= 0 else relaxed_up
highest = max(desired_last - delta, highest_err)
elif desired_last < lowest_err:
delta = relaxed_down if desired_last <= 0 else relaxed_up
lowest = min(desired_last + delta, lowest_err)
else:
highest = min(highest, highest_err)
lowest = max(lowest, lowest_err)
lowest = max(-MAX_CURVATURE_CAN, min(MAX_CURVATURE_CAN, lowest))
highest = max(-MAX_CURVATURE_CAN, min(MAX_CURVATURE_CAN, highest))
return lowest, highest
def cap(self, speed_min: float) -> int:
"""The largest |curvature| the checks accept at this speed: the lateral-accel cap on CAN FD, the
0.02 hard limit otherwise (FORD_LIMITS .limit_lateral_acceleration)."""
return min(self.accel_cap(speed_min), MAX_CURVATURE_CAN) if self.limit_lateral_accel else MAX_CURVATURE_CAN
def allowed(self, desired: int, desired_last: int, meas_min: int, meas_max: int,
speed_min: float, speed_max: float, speed_now: float) -> bool:
"""Whether a non-zero desired curvature passes the BP curvature checks (controls allowed, steer enabled)."""
if abs(desired) > MAX_CURVATURE_CAN:
return False
lowest, highest = self.bounds(desired_last, meas_min, meas_max, speed_min, speed_max, speed_now)
if desired > highest or desired < lowest:
return False
if self.limit_lateral_accel:
cap = self.accel_cap(speed_min)
if desired > cap or desired < -cap:
return False
return True
_NUM = r"[-+0-9.eE]+"
_GEOMETRY_ROW = re.compile(rf"\{{\.slip_factor = (?P<slip>{_NUM})f, \.steer_ratio = (?P<sr>{_NUM})f, \.wheelbase = (?P<wb>{_NUM})f\}},\s*// (?P<idx>\d+):")
def pinion_geometry_table() -> dict[int, tuple[float, float, float]]:
"""ford_pinion_geometry from opendbc/bluepilot_lateral/safety/ford.h as {index: (slip_factor, steer_ratio,
wheelbase)}. Read from source: libsafety exposes no getters for it and adding some would touch
upstream's harness."""
bp = Path(__file__).resolve().parents[1]
src = (bp / "ford.h").read_text()
body = src[src.index("ford_pinion_geometry[FORD_PINION_GEOMETRY_ROWS] = {"):]
body = body[:body.index("};")]
matches = list(_GEOMETRY_ROW.finditer(body))
# every initializer must parse (a row in a spelling the regex misses would otherwise vanish),
# the comment index must be the row's position, and the count must be the declared size
assert len(matches) == body.count("{.slip_factor"), "a geometry row did not parse"
rows = {}
for pos, m in enumerate(matches):
assert int(m["idx"]) == pos, f"row {pos} is labeled {m['idx']}"
rows[pos] = (float(m["slip"]), float(m["sr"]), float(m["wb"]))
declared = re.search(r"#define FORD_PINION_GEOMETRY_ROWS (\d+)U", (bp / "ford_declarations.h").read_text())
assert declared and int(declared[1]) == len(rows), "FORD_PINION_GEOMETRY_ROWS does not match the table"
return rows
def angle_roc_delta(xs, ys, scale: float, speed_min: float) -> int:
"""path_angle / path_offset per-frame ROC: interpolate(lookup, v.min - 1) * scale + 1."""
fudged = c_fudged_speed(speed_min, -1.0)
return c_int(float(f32(c_interpolate(xs, ys, fudged) * f32(scale))) + 1.0)
class BPFordTestCase(unittest.TestCase):
"""Base for BluePilot Ford lateral tests. Subclasses set CANFD and may set PARAM_SP."""
CANFD = True
PARAM_SP = FordSafetyFlagsSP.BP_LATERAL
def setUp(self):
cls = test_ford.TestFordCANFDStockSafety if self.CANFD else test_ford.TestFordLongitudinalSafety
self.stock = cls("test_steer_allowed") # upstream's Ford TestCase, for its message builders only
self.stock.packer = CANPackerSafety("ford_lincoln_base_pt")
self.stock.safety = libsafety_py.libsafety
self.safety = self.stock.safety
self.reinit()
self.safety.init_tests()
self.safety.set_controls_allowed(True)
self.model = BPFordLateralModel(self.CANFD)
self.set_meas(0, 15.0)
def tearDown(self):
# stock classes in the same process must not inherit the bit
host.set_safety_param(self.safety, 0)
super().tearDown() # the host's own resets (IQ.Pilot's AOL state)
def reinit(self):
"""A safety-mode init with the BP bit set (ford_init reads the bit)."""
host.set_safety_param(self.safety, self.PARAM_SP)
self.safety.set_safety_hooks(CarParams.SafetyModel.ford, host.ford_param(FordSafetyFlags.CANFD if self.CANFD else 0, self.PARAM_SP))
# --- wrappers over upstream's builders ---
def tx(self, msg) -> bool:
return self.stock._tx(msg)
def rx(self, msg) -> bool:
return self.stock._rx(msg)
def lat(self, enabled: bool, path_offset=0.0, path_angle=0.0, curvature=0.0, curvature_rate=0.0, increment_timer=True):
return self.stock._lat_ctl_msg(enabled, path_offset, path_angle, curvature, curvature_rate, increment_timer=increment_timer)
def set_meas(self, curvature: float, speed: float):
self.stock._reset_curvature_measurement(curvature, speed)
def set_prev_curvature_can(self, can: int):
self.safety.set_desired_curvature_last(can)
def speeds(self) -> tuple[float, float]:
return float(self.safety.get_vehicle_speed_min()), float(self.safety.get_vehicle_speed_max())
def meas(self) -> tuple[int, int]:
return int(self.safety.get_curvature_meas_min()), int(self.safety.get_curvature_meas_max())
def lka_bp_status_msg(self, angle_mode_engaged: bool, shadow_curvature: float):
"""Lane_Assist_Data1 with BluePilot's angle-mode side channel (can/LANE_ASSIST_DATA1.md), built by
the real packer, so every angle-mode test runs Python's packing through the panda's decode."""
addr, dat, bus = fordcan_ext.create_lka_msg(self.stock.packer, CanBus(fingerprint={}), False, None, angle_mode_engaged,
shadow_curvature)
return libsafety_py.make_CANPacket(addr, bus, bytes(dat))
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""
BluePilot: the BP_LATERAL safety-param bit selects which Ford lateral checks the panda runs.
This pins the switch itself, not the BluePilot limits: with the bit set a modest non-zero
path_angle is accepted, and with it clear (upstream's stock checks) the same frame is rejected.
The BluePilot limits are covered separately.
"""
import unittest
from ....car.structs import CarParams
from ....car.ford.values import FordSafetyFlags
from ...core.values_ext import FordSafetyFlagsSP
from ....safety.tests.libsafety import libsafety_py
from ....safety.tests.common import CANPackerSafety
from ....safety.tests import test_ford # module import: keeps its test classes out of this module's discovery
from ...tests import host
SPEED = 15.0 # m/s, above the curvature-error gate, inside the path_angle ROC table
SMALL_PATH_ANGLE = 0.01 # rad, well inside the per-call ROC at this speed (~0.043)
LARGE_PATH_ANGLE = 0.30 # rad, outside both the ROC step and the ±0.25 curvature-mode range
class TestFordBPLateralBit(unittest.TestCase):
"""Uses upstream's CAN FD stock test class only for its message helpers; its tests are not inherited."""
def setUp(self):
self.h = test_ford.TestFordCANFDStockSafety("test_steer_allowed")
self.h.packer = CANPackerSafety("ford_lincoln_base_pt")
self.h.safety = libsafety_py.libsafety
def tearDown(self):
host.set_safety_param(self.h.safety, 0)
super().tearDown()
def _init(self, param_sp: int):
h = self.h
host.set_safety_param(h.safety, param_sp)
h.safety.set_safety_hooks(CarParams.SafetyModel.ford, host.ford_param(FordSafetyFlags.CANFD, param_sp))
h.safety.init_tests()
h.safety.set_controls_allowed(True)
h._reset_curvature_measurement(0, SPEED)
h._set_prev_desired_angle(0.001)
def test_bit_set_runs_bluepilot_checks(self):
self._init(FordSafetyFlagsSP.BP_LATERAL)
self.assertTrue(self.h._tx(self.h._lat_ctl_msg(True, 0, SMALL_PATH_ANGLE, 0.001, 0)))
self.assertFalse(self.h._tx(self.h._lat_ctl_msg(True, 0, LARGE_PATH_ANGLE, 0.001, 0)))
def test_bit_clear_runs_stock_checks(self):
self._init(0)
# upstream: any non-zero path_angle is a violation
self.assertFalse(self.h._tx(self.h._lat_ctl_msg(True, 0, SMALL_PATH_ANGLE, 0.001, 0)))
self.assertTrue(self.h._tx(self.h._lat_ctl_msg(True, 0, 0, 0.001, 0)))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""
BluePilot Ford lateral, angle mode: the shadow-curvature plausibility check.
In angle mode the LatCtl curvature signal is held at 0 and the command rides on path_angle.
Lane_Assist_Data1 carries a side channel (angle mode engaged, and the curvature the controller
would have commanded, "shadow curvature"; layout in opendbc/bluepilot_lateral/can/LANE_ASSIST_DATA1.md)
that the panda checks against the yaw-measured curvature with the same error window as curvature mode
(ford_shadow_curvature_error_check, ford.h). Ported from BluePilot's earlier vendored tests.
"""
import unittest
from .ford_bluepilot_common import (
BPFordTestCase, SMALL_ANGLE, FORMER_LATCH_WINDOW_FRAMES, CURVATURE_TO_CAN, MAX_CURVATURE_ERROR_CAN, CURVATURE_ERROR_MIN_SPEED,
)
class TestFordBPAngleModeCANFD(BPFordTestCase):
CANFD = True
def _angle_frame(self) -> bool:
return self.tx(self.lat(True, 0, SMALL_ANGLE, 0, 0))
def test_shadow_curvature_error_window(self):
"""Above the speed gate the shadow curvature must be within max_curvature_error of measured;
below it the check is off. Covers ford_shadow_curvature_error_check and its call site."""
measured_can = 250 # 0.005 rad/m
for speed in (CURVATURE_ERROR_MIN_SPEED - 1, CURVATURE_ERROR_MIN_SPEED + 1, 20.0):
enforced = speed > CURVATURE_ERROR_MIN_SPEED
self.set_meas(measured_can / CURVATURE_TO_CAN, speed)
m_min, m_max = self.meas()
hi_edge = m_max + MAX_CURVATURE_ERROR_CAN + 1 # highest_allowed in the C
lo_edge = m_min - MAX_CURVATURE_ERROR_CAN - 1
# shadow arrives as int16 at 1e-6 rad/m; the C scales by 0.05 to CAN units and truncates,
# so pick shadow values that land exactly on the CAN-unit boundaries
for shadow_can, expect in ((m_max, True), (hi_edge, True), (hi_edge + 1, not enforced),
(lo_edge, True), (lo_edge - 1, not enforced), (0, not enforced)):
self.tx(self.lka_bp_status_msg(True, shadow_can / CURVATURE_TO_CAN))
with self.subTest(speed=speed, shadow_can=shadow_can):
self.assertEqual(expect, self._angle_frame())
def test_shadow_curvature_has_no_rate_limit(self):
"""Only the path_angle ROC applies in angle mode: a large frame-to-frame jump in the shadow
curvature that stays inside the window of a correspondingly moved measurement must pass."""
speed = CURVATURE_ERROR_MIN_SPEED + 1
for curvature in (0.015, -0.015, 0.018, -0.018, 0.001, -0.019):
self.set_meas(curvature, speed)
self.tx(self.lka_bp_status_msg(True, curvature))
with self.subTest(curvature=curvature):
self.assertTrue(self._angle_frame())
def test_flag_clear_skips_shadow_check(self):
"""Without the angle-mode bit a curvature-0 frame is not checked against the shadow value."""
speed = CURVATURE_ERROR_MIN_SPEED + 5
self.set_meas(0.005, speed)
self.tx(self.lka_bp_status_msg(False, 0.02)) # would fail the window if it were applied
self.assertTrue(self._angle_frame())
def test_curvature_mode_unaffected_by_angle_mode_flag(self):
"""A real (non-zero) curvature command is curvature mode by its own content; the flag and the
shadow value must not gate it either way."""
speed = CURVATURE_ERROR_MIN_SPEED + 5
curvature_can = 500
self.set_meas(curvature_can / CURVATURE_TO_CAN, speed)
for flag in (True, False):
self.tx(self.lka_bp_status_msg(flag, 0.02))
self.set_prev_curvature_can(curvature_can)
with self.subTest(flag=flag):
self.assertTrue(self.tx(self.lat(True, 0, 0, curvature_can / CURVATURE_TO_CAN, 0)))
def test_shadow_out_of_window_blocks_while_enabled(self):
"""An out-of-window shadow value blocks the enabled frame; the disabled all-zero frame (mode 0)
is not checked against it."""
speed = CURVATURE_ERROR_MIN_SPEED + 5
self.set_meas(0.005, speed)
self.tx(self.lka_bp_status_msg(True, 0.02))
self.assertFalse(self._angle_frame())
self.assertTrue(self.tx(self.lat(False, 0, 0, 0, 0)))
def test_mode0_frame_leaves_shadow_check_active(self):
"""Angle mode's human-turn override sends mode 0 with every signal zero. The frames after it
are checked: an out-of-window shadow curvature blocks on every frame of the 60-frame window
the removed bypass latch used to cover (#9)."""
speed = CURVATURE_ERROR_MIN_SPEED + 5
self.set_meas(0.005, speed)
self.tx(self.lka_bp_status_msg(True, 0.005))
self.assertTrue(self.tx(self.lat(False, 0, 0, 0, 0)))
self.tx(self.lka_bp_status_msg(True, 0.02))
for frame in range(FORMER_LATCH_WINDOW_FRAMES):
with self.subTest(frame=frame):
self.assertFalse(self._angle_frame())
class TestFordBPAngleModeCAN(TestFordBPAngleModeCANFD):
CANFD = False
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,463 @@
#!/usr/bin/env python3
"""
BluePilot Ford lateral: the 4-signal limits with the BP_LATERAL bit set.
Every expectation comes from ford_bluepilot_common.BPFordLateralModel, a float32 mirror of the
C, so boundaries are asserted at the exact CAN unit on both x86 and ARM.
"""
import random
import unittest
from ....safety.tests import common
from ....can.dbc import DBC
from .ford_bluepilot_common import (
BPFordTestCase, angle_roc_delta, SMALL_ANGLE, FORMER_LATCH_WINDOW_FRAMES, CURVATURE_SIGNAL_MAX_CAN,
CURVATURE_TO_CAN, MAX_CURVATURE_CAN, MAX_CURVATURE_ERROR_CAN, CURVATURE_ERROR_MIN_SPEED,
PATH_ANGLE_TO_CAN, PATH_ANGLE_MAX_CAN, PATH_ANGLE_DBC_MIN_CAN, PATH_ANGLE_DBC_MAX_CAN,
PATH_ANGLE_LOOKUP_X, PATH_ANGLE_LOOKUP_Y,
PATH_OFFSET_TO_CAN, PATH_OFFSET_MAX_CAN, PATH_OFFSET_LOOKUP_X, PATH_OFFSET_LOOKUP_Y,
CURVATURE_RATE_TO_CAN_CANFD, CURVATURE_RATE_TO_CAN_CAN,
)
from ...tests import host
# speeds chosen to hit both sides of the error-band gate (10 m/s) and every segment of the
# {5, 16, 25} rate table, plus its clamped ends
SPEEDS = (3.0, 5.0, 8.0, 10.5, 12.0, 16.0, 20.0, 25.0, 30.0)
class TestFordBPLimitsCANFD(BPFordTestCase):
CANFD = True
def _probe(self, desired_can: int, last_can: int) -> bool:
self.set_prev_curvature_can(last_can)
return self.tx(self.lat(True, 0, SMALL_ANGLE, desired_can / CURVATURE_TO_CAN, 0))
def _assert_matches_model(self, last_can: int, probes):
v_min, v_max = self.speeds()
m_min, m_max = self.meas()
for desired in probes:
if desired == 0 or abs(desired) > CURVATURE_SIGNAL_MAX_CAN: # 0 skips the value checks the model mirrors
continue
expected = self.model.allowed(desired, last_can, m_min, m_max, v_min, v_max, v_max)
with self.subTest(speed=v_max, meas=(m_min, m_max), last=last_can, desired=desired):
self.assertEqual(expected, self._probe(desired, last_can))
def test_curvature_rate_lookup_boundaries(self):
"""Per-frame delta from the {5,16,25} m/s table (ford_bp_curvature_rate_lookup_check), both
directions, both signs, exact boundary: the speed fudge and the delta arithmetic."""
for speed in SPEEDS:
self.set_meas(0, speed)
v_min, v_max = self.speeds()
delta = self.model.rate_delta(v_min)
for last in (0, 40, -40, 250, -250):
lowest, highest = self.model.bounds(last, *self.meas(), v_min, v_max, v_max)
self._assert_matches_model(last, (highest - 1, highest, highest + 1, lowest - 1, lowest, lowest + 1,
last + delta, last + delta + 1, last - delta, last - delta - 1))
def test_curvature_error_band_convergence(self):
"""Above 10 m/s the command must stay within max_curvature_error of measured; when the last
command is already outside the band it must move back toward it by at least the relaxed
delta, and may not move further away. Below 10 m/s the band is off (the relaxed branches of
ford_bp_curvature_rate_lookup_check). Seen in the car: a 4-frame block at 26 mph with the driver
steering harder than the command, sitting exactly on this window's edge (#12)."""
# measured both signs, and last commands on both sides of zero, so both relaxed branches run
# with last commands of either sign. The C picks the up or down table by that sign, and the
# down table is 1% looser (#20), so each relaxed probe below uses the table the C picks.
for meas_can in (200, -200, 600, -600):
for speed in (8.0, 12.0, 16.0, 20.0):
self.set_meas(meas_can / CURVATURE_TO_CAN, speed)
v_min, v_max = self.speeds()
m_min, m_max = self.meas()
band_on = v_max > CURVATURE_ERROR_MIN_SPEED
delta = self.model.rate_delta(v_min)
lowest_err = m_min - MAX_CURVATURE_ERROR_CAN - 1
highest_err = m_max + MAX_CURVATURE_ERROR_CAN + 1
cap = self.model.cap(v_min) # the explicit asserts below stay under it
# last command below the band (on either side of zero): must climb toward it
for last in (lowest_err - 150, 0, -1, 1):
if last >= lowest_err:
continue
relaxed = self.model.rate_delta_relaxed(v_max, up=last > 0) # climbing: down table while last <= 0
self._assert_matches_model(last, (last + relaxed - 1, last + relaxed, last + delta, last + delta + 1,
last - 1, lowest_err))
if band_on:
ctx = f"meas={meas_can} speed={speed} last={last} relaxed={relaxed} band=({lowest_err},{highest_err})"
self.assertFalse(self._probe(last + relaxed - 1, last), "must move toward the band " + ctx)
if abs(min(last + relaxed, lowest_err)) <= cap:
self.assertTrue(self._probe(min(last + relaxed, lowest_err), last), ctx)
if last - 1 != 0: # curvature 0 is angle mode's sentinel and skips the curvature checks
self.assertFalse(self._probe(last - 1, last), "may not move away from the band " + ctx)
# last command above the band (on either side of zero): must descend toward it
for last in (highest_err + 150, 0, -1, 1):
if last <= highest_err:
continue
relaxed = self.model.rate_delta_relaxed(v_max, up=last < 0) # descending: down table while last >= 0
self._assert_matches_model(last, (last - relaxed + 1, last - relaxed, last - delta, last - delta - 1,
last + 1, highest_err))
if band_on:
ctx = f"meas={meas_can} speed={speed} last={last} relaxed={relaxed} band=({lowest_err},{highest_err})"
self.assertFalse(self._probe(last - relaxed + 1, last), "must move toward the band " + ctx)
if abs(max(last - relaxed, highest_err)) <= cap:
self.assertTrue(self._probe(max(last - relaxed, highest_err), last), ctx)
if last + 1 != 0:
self.assertFalse(self._probe(last + 1, last), "may not move away from the band " + ctx)
# last command inside the band: may not leave it
last = highest_err - 1
self._assert_matches_model(last, (highest_err, highest_err + 1, lowest_err, lowest_err - 1))
if band_on and delta > 1 and abs(highest_err) <= cap:
self.assertTrue(self._probe(highest_err, last))
self.assertFalse(self._probe(highest_err + 1, last), "inside the band, may not command outside it")
def test_lateral_accel_cap(self):
"""CAN FD only: |curvature| <= (ISO_LATERAL_ACCEL - g*roll) / v^2, with the speed fudged down 1 m/s."""
for speed in (15.0, 20.0, 25.0, 30.0):
self.set_meas(0, speed)
v_min, _ = self.speeds()
cap = self.model.accel_cap(v_min)
self.assertLess(cap, MAX_CURVATURE_CAN, "cap only means something below the 0.02 hard limit")
for sign in (1, -1):
# measured curvature at the cap so the error band and rate limits allow it; last at the cap too
self.set_meas(sign * cap / CURVATURE_TO_CAN, speed)
with self.subTest(speed=speed, sign=sign):
self.assertTrue(self._probe(sign * cap, sign * cap))
self.assertFalse(self._probe(sign * (cap + 1), sign * cap))
def test_max_curvature_hard_limit(self):
"""+-0.02 rad/m (1000 CAN) regardless of speed. At 3 m/s the accel cap and the band are off. Only the
positive side has an over-cap frame: LatCtlCurv_No_Actl is 11 bits unsigned with offset -0.02, so its
minimum is exactly -0.02, and -1001 CAN units can't be sent (it wraps to +1047 on the wire)."""
self.set_meas(0.02, 3.0)
for sign in (1, -1):
self.assertTrue(self._probe(sign * MAX_CURVATURE_CAN, sign * MAX_CURVATURE_CAN))
self.assertFalse(self._probe(MAX_CURVATURE_CAN + 1, MAX_CURVATURE_CAN))
name = "LateralMotionControl2" if self.CANFD else "LateralMotionControl"
sig = next(m for m in DBC("ford_lincoln_base_pt").msgs.values() if m.name == name).sigs["LatCtlCurv_No_Actl"]
self.assertFalse(sig.is_signed)
self.assertEqual(round(sig.offset * CURVATURE_TO_CAN), -MAX_CURVATURE_CAN)
def test_over_cap_frame_resets_last(self):
"""A frame past the 0.02 cap resets desired_last to 0 (ford_bp_curvature_cmd_checks' own cap), so
the next frame at the cap is a jump from 0 past the rate limit. ford_lmc_checks' range check blocks
the over-cap frame either way; only the cap in ford_bp_curvature_cmd_checks resets. At 5 m/s the
band is off and the CAN FD accel cap is far above 0.02. Positive only: the signal's minimum is
-0.02 exactly (11 bits unsigned, offset -0.02), so -1001 wraps to +1047 on the wire."""
self.set_meas(0.02, 5.0)
self.assertLess(self.model.rate_delta(self.speeds()[0]), MAX_CURVATURE_CAN)
self.assertFalse(self._probe(MAX_CURVATURE_CAN + 1, MAX_CURVATURE_CAN))
self.assertFalse(self.tx(self.lat(True, 0, SMALL_ANGLE, MAX_CURVATURE_CAN / CURVATURE_TO_CAN, 0)))
def test_signals_zero_when_steer_disabled(self):
"""With LatCtl_D2_Rq = 0 every signal must be zero (path_angle/path_offset_cmd_checks, the
curvature-rate check and ford_bp_curvature_cmd_checks), each on its own and in combination."""
self.set_meas(0, 15.0)
self.assertFalse(self.tx(self.lat(False, 0.1, 0, 0, 0)))
self.assertFalse(self.tx(self.lat(False, 0, 0, 0, 0.0001)))
self.assertFalse(self.tx(self.lat(False, 0, 0.01, 0, 0)))
self.assertFalse(self.tx(self.lat(False, 0.1, 0.01, 0, 0)))
self.assertFalse(self.tx(self.lat(False, 0, 0.01, 0, 0.0001)))
self.assertFalse(self.tx(self.lat(False, 0, 0, 0.001, 0)))
self.assertFalse(self.tx(self.lat(False, 0.1, 0, 0.001, 0)))
self.assertFalse(self.tx(self.lat(False, 0, 0, 0.001, 0.0001)))
# one CAN unit of curvature: the curvature-must-be-0 check is the only one that blocks it
for sign in (1, -1):
self.assertFalse(self.tx(self.lat(False, 0, 0, sign / CURVATURE_TO_CAN, 0)))
# the all-zero disabled frame is the one thing allowed
self.assertTrue(self.tx(self.lat(False, 0, 0, 0, 0)))
def test_controls_not_allowed_blocks_steering(self):
"""steer enabled with controls not allowed is blocked whatever the signals carry: a real
curvature command (ford_bp_curvature_cmd_checks), in curvature mode and with the angle-mode
flag set. Upstream's test_steer_allowed asserts this for the stock path; this is the BP path."""
self.set_meas(0, 15.0)
for angle_mode in (False, True):
self.tx(self.lka_bp_status_msg(angle_mode, 0.0))
self.safety.set_controls_allowed(True)
self.assertTrue(self._probe(20, 10))
self.safety.set_controls_allowed(False)
host.set_controls_allowed_lat(self.safety, False)
with self.subTest(angle_mode=angle_mode):
self.assertFalse(self._probe(20, 10))
self.assertFalse(self.tx(self.lat(True, 0, SMALL_ANGLE, 0, 0)))
self.safety.set_controls_allowed(True)
self.tx(self.lka_bp_status_msg(False, 0.0))
def test_newest_speed_sample_gates_the_band(self):
"""The error band switches on vehicle_speed.values[0], the newest sample, not the window's
min or max, and only strictly above 10 m/s. Six samples at 9 m/s then one at 11: band on;
the reverse: band off; a newest sample of exactly 10.0: band off."""
meas_can = 200
last = meas_can + MAX_CURVATURE_ERROR_CAN # inside the band, so only the band decides
below, above, gate = CURVATURE_ERROR_MIN_SPEED - 1, CURVATURE_ERROR_MIN_SPEED + 1, CURVATURE_ERROR_MIN_SPEED
# outside the band (its edge is meas + band + 1), inside the rate delta from 'last' at the slowest window
out_of_band = last + self.model.rate_delta(below) // 2
self.assertGreater(out_of_band, meas_can + MAX_CURVATURE_ERROR_CAN + 1)
for window, newest, expect_blocked in ((below, above, True), (above, below, False), (above, gate, False), (below, gate, False)):
self.set_meas(meas_can / CURVATURE_TO_CAN, window)
self.rx(self.stock._speed_msg(newest))
v_min, v_max = self.speeds()
m_min, m_max = self.meas()
with self.subTest(window=window, newest=newest):
self.assertEqual(not expect_blocked, self.model.allowed(out_of_band, last, m_min, m_max, v_min, v_max, newest))
self.assertEqual(not expect_blocked, self._probe(out_of_band, last))
def test_newest_speed_sample_gates_the_shadow_check(self):
"""Angle mode's shadow-curvature check (ford_shadow_curvature_error_check) also switches on
vehicle_speed.values[0], the newest sample: older samples at 9.5 m/s and the newest at 10.5: on;
the reverse: off. 0.5 m/s either side keeps clear of speed_mismatch_check's 2 m/s limit."""
below, above = CURVATURE_ERROR_MIN_SPEED - 0.5, CURVATURE_ERROR_MIN_SPEED + 0.5
for window, newest, expect_blocked in ((below, above, True), (above, below, False)):
self.set_meas(0, window)
self.rx(self.stock._speed_msg(newest))
_, m_max = self.meas()
out_of_band = m_max + MAX_CURVATURE_ERROR_CAN + 2 # one past highest_allowed in the C
self.tx(self.lka_bp_status_msg(True, out_of_band / CURVATURE_TO_CAN))
with self.subTest(window=window, newest=newest):
self.assertEqual(not expect_blocked, self.tx(self.lat(True, 0, SMALL_ANGLE, 0, 0)))
self.tx(self.lka_bp_status_msg(False, 0.0))
def test_zero_curvature_skips_curvature_checks(self):
"""Curvature == 0 (angle mode's sentinel) skips the curvature value checks (cap, rate tables,
error band); the controls-allowed gate and the message-rate check remain
(ford_bp_curvature_cmd_checks). Pins current behavior, see #10 and #60."""
self.set_meas(0.01, 15.0) # measured 500 CAN: a real curvature command of 0 would be far outside the band
self.assertTrue(self.tx(self.lat(True, 0, SMALL_ANGLE, 0, 0)))
self.safety.set_controls_allowed(False)
host.set_controls_allowed_lat(self.safety, False)
self.assertFalse(self.tx(self.lat(True, 0, SMALL_ANGLE, 0, 0)))
self.safety.set_controls_allowed(True)
# --- the reset frame: curvature 0 and path_angle 0 (#9) ---
def test_reset_frame_accepted_mid_curve(self):
"""Curvature mode's human-turn reset: steering enabled, curvature 0 and path_angle 0 while the
car is mid-curve. Curvature 0 skips the error band, so the frame is accepted; the
controller depends on this."""
self.set_meas(0.01, 20.0) # measured 500 CAN: a real curvature command of 0 would be far outside the band
self.set_prev_curvature_can(500)
self.assertTrue(self.tx(self.lat(True, 0, 0, 0, 0)))
def test_reset_frame_then_jump_blocked(self):
"""The repro in #9, steps A-C: at 25 m/s a jump from 0.001 to 0.02 rad/m is blocked (A); a
curvature 0 / path_angle 0 frame is accepted (B); the same jump right after it is still
blocked (C). With the latch, C passed."""
self.set_meas(0, 25.0)
jump, last = 0.02, int(0.001 * CURVATURE_TO_CAN)
self.set_prev_curvature_can(last)
self.assertFalse(self.tx(self.lat(True, 0, 0, jump, 0))) # A
self.assertTrue(self.tx(self.lat(True, 0, 0, 0, 0))) # B
self.set_prev_curvature_can(last)
self.assertFalse(self.tx(self.lat(True, 0, 0, jump, 0))) # C
def _walk(self, reset_frame) -> list[bool]:
"""Re-init, send reset_frame (if any), then a seeded random walk of curvature, path_angle and
path_offset steps up to 1.3 ROC steps each, so some frames pass and some are blocked by the
rate limits and the error band. Returns each frame's tx result. curvature_rate stays 0: its ROC
check can never fire (#22), so varying it would show nothing."""
self.reinit()
self.safety.set_controls_allowed(True)
self.set_meas(0, 12.0) # band on around 0
if reset_frame is not None:
self.assertTrue(self.tx(reset_frame))
v_min, _ = self.speeds()
steps = (self.model.rate_delta(v_min),
angle_roc_delta(PATH_ANGLE_LOOKUP_X, PATH_ANGLE_LOOKUP_Y, PATH_ANGLE_TO_CAN, v_min),
angle_roc_delta(PATH_OFFSET_LOOKUP_X, PATH_OFFSET_LOOKUP_Y, PATH_OFFSET_TO_CAN, v_min))
bounds = (150, 300, 80) # CAN units: past the band (101), inside the path_angle / path_offset ranges
rng = random.Random(9)
pos = [self.TINY_CURVATURE_CAN, 0, 0]
results = []
for _ in range(FORMER_LATCH_WINDOW_FRAMES):
pos = [max(-b, min(b, p + round(rng.uniform(-1.3, 1.3) * s))) for p, s, b in zip(pos, steps, bounds, strict=True)]
pos[0] = pos[0] or self.TINY_CURVATURE_CAN # curvature 0 would skip the value checks
curvature, angle, offset = pos
results.append(self.tx(self.lat(True, offset / PATH_OFFSET_TO_CAN, angle / PATH_ANGLE_TO_CAN, curvature / CURVATURE_TO_CAN, 0)))
return results
def test_reset_frame_leaves_no_state(self):
"""After a reset frame, enabled (curvature mode's human-turn reset) or disabled (angle mode's
mode 0), every check gives the same result, frame for frame, as without it, across the whole
window the removed bypass latch used to cover (#9)."""
baseline = self._walk(None)
self.assertTrue(any(baseline)) # some frames pass
self.assertFalse(all(baseline)) # and some are blocked
for enabled in (True, False):
with self.subTest(enabled=enabled):
self.assertEqual(baseline, self._walk(self.lat(enabled, 0, 0, 0, 0)))
def test_init_resets_bp_state(self):
"""ford_bp_init (from ford_init) clears the BP path_angle / path_offset last values and the angle-mode flag, the
way set_safety_hooks clears upstream's lateral state: after a re-init the first frame is
checked against 0, not against the previous session. (The curvature_rate last value is reset
too, but its ROC can never fire, so that is not observable; see #22.)"""
speed = CURVATURE_ERROR_MIN_SPEED + 5
for stale in ("path_angle", "path_offset", "angle_mode"):
with self.subTest(stale=stale):
self.set_meas(0, speed)
v_min, _ = self.speeds()
angle_delta = angle_roc_delta(PATH_ANGLE_LOOKUP_X, PATH_ANGLE_LOOKUP_Y, PATH_ANGLE_TO_CAN, v_min)
offset_delta = angle_roc_delta(PATH_OFFSET_LOOKUP_X, PATH_OFFSET_LOOKUP_Y, PATH_OFFSET_TO_CAN, v_min)
# leave the one piece of state 3 ROC steps away from 0 (or angle mode engaged, with a
# shadow value that fails the window), then re-init
for step in (1, 2, 3):
angle = step * angle_delta if stale == "path_angle" else 0
offset = step * offset_delta if stale == "path_offset" else 0
self.set_prev_curvature_can(self.TINY_CURVATURE_CAN)
self.assertTrue(self.tx(self.lat(True, offset / PATH_OFFSET_TO_CAN, angle / PATH_ANGLE_TO_CAN,
self.TINY_CURVATURE_CAN / CURVATURE_TO_CAN, 0)))
if stale == "angle_mode":
self.tx(self.lka_bp_status_msg(True, 0.02))
self.reinit()
self.safety.set_controls_allowed(True)
self.set_meas(0.005 if stale == "angle_mode" else 0, speed)
self.set_prev_curvature_can(self.TINY_CURVATURE_CAN)
if stale == "angle_mode":
# the flag is cleared, so a curvature-0 frame is not checked against the shadow value
# (ford_bp_init leaves the raw shadow value alone; the flag gates its use)
self.assertTrue(self.tx(self.lat(True, 0, SMALL_ANGLE, 0, 0)))
else:
# one full ROC step below 0: within the ROC only if the last value is exactly 0 (from the
# stale value it is 4 steps, from 1 it is one unit too many)
angle = -angle_delta if stale == "path_angle" else 0
offset = -offset_delta if stale == "path_offset" else 0
self.assertTrue(self.tx(self.lat(True, offset / PATH_OFFSET_TO_CAN, angle / PATH_ANGLE_TO_CAN,
self.TINY_CURVATURE_CAN / CURVATURE_TO_CAN, 0)))
# --- the other three signals ---
# the three non-curvature probes carry 1 CAN unit of curvature (measured 0, last 1), which
# passes the curvature checks, so only the signal under test decides
TINY_CURVATURE_CAN = 1
def _angle_probe(self, angle_can: int) -> bool:
self.set_prev_curvature_can(self.TINY_CURVATURE_CAN)
return self.tx(self.lat(True, 0, angle_can / PATH_ANGLE_TO_CAN, self.TINY_CURVATURE_CAN / CURVATURE_TO_CAN, 0))
def test_path_angle_range_and_roc(self):
"""Per-frame ROC from the {10,15,25} table (path_angle_cmd_checks) and the +-0.25 rad range in curvature
mode. A frame always becomes the new 'last' even when blocked, so each probe re-baselines."""
for speed in (8.0, 12.0, 20.0, 25.0):
self.set_meas(0, speed)
v_min, _ = self.speeds()
delta = angle_roc_delta(PATH_ANGLE_LOOKUP_X, PATH_ANGLE_LOOKUP_Y, PATH_ANGLE_TO_CAN, v_min)
for base in (0, 200, -200):
for sign in (1, -1):
with self.subTest(speed=speed, base=base, sign=sign):
self._angle_probe(base)
self.assertTrue(self._angle_probe(base + sign * delta))
self._angle_probe(base)
self.assertFalse(self._angle_probe(base + sign * (delta + 1)))
# range: step from just inside so the ROC allows it
if delta > 2:
for sign in (1, -1):
self._angle_probe(sign * (PATH_ANGLE_MAX_CAN - 1))
self.assertTrue(self._angle_probe(sign * PATH_ANGLE_MAX_CAN))
self.assertFalse(self._angle_probe(sign * (PATH_ANGLE_MAX_CAN + 1)))
def test_path_angle_dbc_range_in_angle_mode(self):
"""With angle mode engaged (Lane_Assist_Data1 bit) the range widens to the DBC's [-0.5, 0.5235]."""
self.set_meas(0, 8.0) # below the shadow-check speed gate so only the range matters
self.tx(self.lka_bp_status_msg(True, 0.0))
v_min, _ = self.speeds()
delta = angle_roc_delta(PATH_ANGLE_LOOKUP_X, PATH_ANGLE_LOOKUP_Y, PATH_ANGLE_TO_CAN, v_min)
self.assertGreater(delta, 60)
for target, over in ((PATH_ANGLE_DBC_MAX_CAN, PATH_ANGLE_DBC_MAX_CAN + 1), (PATH_ANGLE_DBC_MIN_CAN, PATH_ANGLE_DBC_MIN_CAN - 1)):
# walk up in ROC-sized steps (the signal can pack these values)
pos = 0
while abs(target - pos) > delta:
pos += delta if target > pos else -delta
self._angle_probe(pos)
self.assertTrue(self._angle_probe(target))
self.assertFalse(self._angle_probe(over))
self._angle_probe(pos)
self.tx(self.lka_bp_status_msg(False, 0.0))
def _offset_probe(self, offset_can: int) -> bool:
self.set_prev_curvature_can(self.TINY_CURVATURE_CAN)
return self.tx(self.lat(True, offset_can / PATH_OFFSET_TO_CAN, SMALL_ANGLE, self.TINY_CURVATURE_CAN / CURVATURE_TO_CAN, 0))
def test_path_offset_range_and_roc(self):
"""Per-frame ROC from the {5,15,25} table (path_offset_cmd_checks) and the +-1.0 m range.
At 9 m/s the speed fudge's sign decides the limit (5 with v - 1, 4 with v + 1)."""
for speed in (3.0, 8.0, 9.0, 12.0, 20.0, 25.0):
self.set_meas(0, speed)
v_min, _ = self.speeds()
delta = angle_roc_delta(PATH_OFFSET_LOOKUP_X, PATH_OFFSET_LOOKUP_Y, PATH_OFFSET_TO_CAN, v_min)
for base in (0, 50, -50):
for sign in (1, -1):
with self.subTest(speed=speed, base=base, sign=sign):
self._offset_probe(base)
self.assertTrue(self._offset_probe(base + sign * delta))
self._offset_probe(base)
self.assertFalse(self._offset_probe(base + sign * (delta + 1)))
for sign in (1, -1):
self._offset_probe(sign * (PATH_OFFSET_MAX_CAN - 1))
self.assertTrue(self._offset_probe(sign * PATH_OFFSET_MAX_CAN))
self.assertFalse(self._offset_probe(sign * (PATH_OFFSET_MAX_CAN + 1)))
RATE_TO_CAN = CURVATURE_RATE_TO_CAN_CANFD
def _rate_probe(self, rate_can: int) -> bool:
self.set_prev_curvature_can(self.TINY_CURVATURE_CAN)
return self.tx(self.lat(True, 0, SMALL_ANGLE, self.TINY_CURVATURE_CAN / CURVATURE_TO_CAN, rate_can / self.RATE_TO_CAN))
def test_curvature_rate_must_be_zero(self):
"""Curvature rate must be 0, the rule stock Ford applies (#22): one wire unit either way is
blocked at any speed, steer enabled or disabled. The disabled frame carries only the rate, so
the rate check is the one deciding (the all-zero disabled frame passes)."""
for speed in (3.0, 12.0, 30.0):
self.set_meas(0, speed)
for rate_can in (1, -1, 1023, -1024):
with self.subTest(speed=speed, rate_can=rate_can):
self.assertTrue(self._rate_probe(0))
self.assertFalse(self._rate_probe(rate_can))
# disabled frames last: their path_angle of 0 would make the next enabled probe's path_angle step decide
for speed in (3.0, 12.0, 30.0):
self.set_meas(0, speed)
for rate_can in (1, -1, 1023, -1024):
with self.subTest(speed=speed, rate_can=rate_can, enabled=False):
self.assertTrue(self.tx(self.lat(False, 0, 0, 0, 0)))
self.assertFalse(self.tx(self.lat(False, 0, 0, 0, rate_can / self.RATE_TO_CAN)))
def test_curvature_zero_frames_are_rate_limited(self):
"""#24: angle mode sends curvature 0 on every frame. Those frames are held to the same message-rate
window as the rest (upstream's rolling 250 ms window, split in two halves): sent faster than 20 Hz,
every frame past the limit is blocked until the window rolls over. Path angle carries the command,
so these frames never arm the reset latch."""
self.set_meas(0, 15.0)
max_rt_msgs = int(20 * common.RT_INTERVAL / 1e6 * 1.2 + 1)
half = common.RT_INTERVAL // 2
def send():
return self.tx(self.lat(True, 0, SMALL_ANGLE, 0, 0, increment_timer=False))
self.safety.set_timer(0)
for i in range(max_rt_msgs * 2):
self.assertEqual(i <= max_rt_msgs, send(), i)
self.safety.set_timer(half)
self.assertFalse(send()) # the overflow moves into the previous half
self.safety.set_timer(half + common.RT_INTERVAL)
self.assertFalse(send())
self.safety.set_timer(half + 2 * common.RT_INTERVAL)
for _ in range(max_rt_msgs):
self.assertTrue(send())
class TestFordBPLimitsCAN(TestFordBPLimitsCANFD):
"""Same checks on the CAN (non-FD) LateralMotionControl message: no lateral-accel cap, and the
curvature-rate signal has 4x the wire resolution."""
CANFD = False
RATE_TO_CAN = CURVATURE_RATE_TO_CAN_CAN
def test_lateral_accel_cap(self):
"""CAN: no cap. A curvature well above the CAN FD cap is accepted up to the 0.02 hard limit."""
self.set_meas(MAX_CURVATURE_CAN / CURVATURE_TO_CAN, 25.0)
v_min, _ = self.speeds()
self.assertLess(self.model.accel_cap(v_min), MAX_CURVATURE_CAN) # the FD cap would bite here
self.assertTrue(self._probe(MAX_CURVATURE_CAN, MAX_CURVATURE_CAN))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
BluePilot Ford lateral: upstream's whole Ford safety matrix with the BP_LATERAL bit set.
Subclasses the three stock test classes so relay-malfunction, forwarding, button, longitudinal,
rx-hook and every other upstream test runs against the BluePilot tx hook. Only the tests whose
stock assertions the 4-signal lateral changes on purpose are replaced:
* test_steer_allowed, test_curvature_rate_limits: assert upstream's single-signal limits; the
BluePilot equivalents are in test_ford_bluepilot_limits.py (every signal, exact boundaries).
* test_max_lateral_acceleration: BluePilot caps at ISO_LATERAL_ACCEL - g*roll (~2.41 m/s^2)
with a speed fudge, not upstream's ~3.6 m/s^2; CAN FD only.
* test_rt_limits runs unchanged: it sends curvature-0 frames, which still get the message-rate
check in ford_bp_curvature_cmd_checks (#24, #60). test_rt_limits_nonzero_curvature pins it for
curvature mode.
"""
import unittest
from ....safety.tests import common
from ...core.values_ext import FordSafetyFlagsSP
from ....safety.tests import test_ford
from ....safety.tests.libsafety import libsafety_py
from .ford_bluepilot_common import (
BPFordLateralModel, CURVATURE_TO_CAN, MAX_CURVATURE_ERROR_CAN, CURVATURE_SIGNAL_MAX_CAN,
)
from ...tests import host
SKIP_REASON = "BluePilot 4-signal limits are asserted in test_ford_bluepilot_limits.py"
class BPLateralMatrixMixin:
"""Set the bit before ford_init runs, clear it after, and swap the host's stock lateral tests
(skipped below, or replaced).
Class names must start with 'TestFord': common.py's cross-mode TX sweep only skips
Ford-vs-Ford comparisons by that prefix."""
CANFD = True
PARAM_SP = int(FordSafetyFlagsSP.BP_LATERAL)
MAX_CURVATURE_ERROR_CAN_BP = MAX_CURVATURE_ERROR_CAN # FORD_LIMITS(_, 100); the pinion classes use MAX_CURVATURE_ERROR_CAN_PINION
def setUp(self):
self.safety = libsafety_py.libsafety
host.set_safety_param(self.safety, self.PARAM_SP)
# StarPilot: the bits ride the Ford param. Only the upstream bases that define SAFETY_PARAM
# (sunnypilot, StarPilot) need this; IQ.Pilot's iqdbc base doesn't define it, and ford_param
# is a no-op for non-StarPilot hosts anyway.
if hasattr(self, "SAFETY_PARAM"):
self.SAFETY_PARAM = host.ford_param(self.SAFETY_PARAM, self.PARAM_SP)
super().setUp() # stock setUp: packer, set_safety_hooks, init_tests
self.model = BPFordLateralModel(self.CANFD, self.MAX_CURVATURE_ERROR_CAN_BP)
def tearDown(self):
host.set_safety_param(self.safety, 0)
super().tearDown() # the host's own resets (IQ.Pilot's AOL state)
@unittest.skip(SKIP_REASON)
def test_steer_allowed(self):
pass
@unittest.skip(SKIP_REASON)
def test_curvature_rate_limits(self):
pass
# IQ.Pilot's test_ford: its stock controller (CURVATURE_LIMITS.apply_limits) against the stock checks.
# With BP_LATERAL set the car controller sends the BluePilot commands instead, never these.
@unittest.skip(SKIP_REASON)
def test_controller_limits_pass_safety(self):
pass
def test_max_lateral_acceleration(self):
"""BluePilot's cap (CAN FD only) at ISO_LATERAL_ACCEL - g*roll over the fudged speed squared."""
for speed in (12.0, 15.0, 20.0, 25.0, 30.0, 35.0):
self.safety.set_controls_allowed(True)
self._reset_curvature_measurement(0, speed)
v_min = float(self.safety.get_vehicle_speed_min())
cap = self.model.cap(v_min)
for sign in (1, -1):
for offset in (-5, -1, 0, 1, 5):
curvature_can = sign * (cap + offset)
if abs(curvature_can) > CURVATURE_SIGNAL_MAX_CAN:
continue
self._reset_curvature_measurement(curvature_can / CURVATURE_TO_CAN, speed)
self._set_prev_desired_angle(curvature_can / CURVATURE_TO_CAN)
should_tx = abs(curvature_can) <= cap
with self.subTest(speed=speed, curvature_can=curvature_can):
self.assertEqual(should_tx, self._tx(self._lat_ctl_msg(True, 0, 0, curvature_can / CURVATURE_TO_CAN, 0)))
def test_rt_limits_nonzero_curvature(self):
"""Upstream's rolling-window message-rate check, driven with a non-zero curvature (curvature mode)."""
self.safety.set_controls_allowed(True)
self._reset_curvature_measurement(0, 0)
curvature = 1 / CURVATURE_TO_CAN
max_rt_msgs = int(self.LATERAL_FREQUENCY * common.RT_INTERVAL / 1e6 * 1.2 + 1)
half = common.RT_INTERVAL // 2
def send():
self._set_prev_desired_angle(curvature)
return self._tx(self._lat_ctl_msg(True, 0, 0, curvature, 0, increment_timer=False))
self.safety.set_timer(0)
for i in range(max_rt_msgs * 2):
self.assertEqual(i <= max_rt_msgs, send())
self.safety.set_timer(half)
self.assertFalse(send())
self.safety.set_timer(half + 2 * common.RT_INTERVAL // 5)
self.assertFalse(send())
self.assertFalse(send())
self.safety.set_timer(half + common.RT_INTERVAL)
self.assertFalse(send())
self.safety.set_timer(half + 2 * common.RT_INTERVAL)
for _ in range(max_rt_msgs):
self.assertTrue(send())
class TestFordBPCANFDStockSafety(BPLateralMatrixMixin, test_ford.TestFordCANFDStockSafety):
CANFD = True
class TestFordBPCANFDLongitudinalSafety(BPLateralMatrixMixin, test_ford.TestFordCANFDLongitudinalSafety):
CANFD = True
class TestFordBPLongitudinalSafety(BPLateralMatrixMixin, test_ford.TestFordLongitudinalSafety):
CANFD = False
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""
BluePilot Ford lateral: the pinion-sourced curvature measurement (STEER_ANGLE_CURVATURE).
With bit 0 of the SP safety param set (and BP_LATERAL, which it requires), the panda measures
curvature from the PSCM steering pinion angle through the per-platform geometry table in
bluepilot_lateral/safety/ford.h instead of the RCM yaw rate, and widens the error band to 150 CAN units.
The geometry index rides bits 1-4. Ported from BluePilot's earlier vendored tests onto the
BP matrix classes; the geometry-table check reads the C table from source (no libsafety
getters), so no upstream harness file is touched.
"""
import unittest
from types import SimpleNamespace
import numpy as np
from ....car.ford.values import CAR
from ....car.vehicle_model import VehicleModel, calc_slip_factor
from ...core.values_ext import (
FordSafetyFlagsSP, FORD_HOST_ONLY_PLATFORMS, FORD_NO_PINION_PLATFORMS, FORD_PINION_GEOMETRY_INDEX, FORD_PINION_GEOMETRY_SHIFT,
FORD_PINION_WHEELBASE,
)
from ...core.carcontroller_ext import pinion_vehicle_model, scale_tire_stiffness_per_mass
from ....car.ford.interface import CarInterface
from .ford_bluepilot_common import (
CURVATURE_TO_CAN, CURVATURE_ERROR_MIN_SPEED, MAX_CURVATURE_ERROR_CAN_PINION, pinion_geometry_table,
)
from . import test_ford_bluepilot_matrix as matrix
def pinion_param_sp(geometry_index: int) -> int:
return int(FordSafetyFlagsSP.BP_LATERAL) | int(FordSafetyFlagsSP.STEER_ANGLE_CURVATURE) | (geometry_index << FORD_PINION_GEOMETRY_SHIFT)
GEOMETRY = pinion_geometry_table() # the C table, so the platform mixins can never drift from it
class BPPinionMixin:
"""Pinion-angle measurement source. The geometry comes from the C table row for GEOMETRY_INDEX;
TestFordBPPinionGeometryTable checks that row against CarSpecs."""
MAX_CURVATURE_ERROR_CAN_BP = MAX_CURVATURE_ERROR_CAN_PINION
MAX_CURVATURE_ERROR = MAX_CURVATURE_ERROR_CAN_PINION / CURVATURE_TO_CAN # the host's own error-band tests (IQ.Pilot's test_ford)
GEOMETRY_INDEX = 0
PINION_SLIP_FACTOR, PINION_STEER_RATIO, PINION_WHEELBASE = GEOMETRY[0]
cnt_pinion = 0
def __init_subclass__(cls, **kwargs):
# a platform sets GEOMETRY_INDEX only; the geometry and the safety param follow from it
super().__init_subclass__(**kwargs)
cls.PINION_SLIP_FACTOR, cls.PINION_STEER_RATIO, cls.PINION_WHEELBASE = GEOMETRY[cls.GEOMETRY_INDEX]
cls.PARAM_SP = pinion_param_sp(cls.GEOMETRY_INDEX)
def _curvature_factor(self, speed: float) -> float:
speed = max(speed, 0.1)
return 1. / (1. - (self.PINION_SLIP_FACTOR * (speed ** 2))) / self.PINION_WHEELBASE
def _curvature_to_pinion_angle_deg(self, curvature: float, speed: float) -> float:
# inverse of ford_bp_rx_hook: curvature = angle_rad * curvature_factor(speed) / steer_ratio
return float(np.degrees(curvature * self.PINION_STEER_RATIO / self._curvature_factor(speed)))
def _pinion_quant_tol(self, speed: float) -> int:
# 0.1 deg DBC quantization in curvature CAN units at this speed, +2 for float rounding
return int(np.radians(0.1) * self._curvature_factor(speed) / self.PINION_STEER_RATIO * CURVATURE_TO_CAN) + 2
def _pinion_msg(self, curvature: float, speed: float, quality_flag=True, bus=0):
values = {"StePinComp_An_Est": self._curvature_to_pinion_angle_deg(curvature, speed),
"StePinCompAnEst_D_Qf": 3 if quality_flag else 0,
"StePinAn_No_Cnt": self.cnt_pinion % 16}
self.__class__.cnt_pinion += 1
return self.packer.make_can_msg_safety("SteeringPinion_Data", bus, values)
def _reset_curvature_measurement(self, curvature, speed):
# 14 frames, not 6: after a counter discontinuity (rejected bad-QF frames still advance the
# python-side counter) the rx counter check drops frames until it re-syncs
for _ in range(14):
self._rx(self._speed_msg(speed))
self._rx(self._speed_msg_2(speed)) # ford_bp_curvature_cmd_checks cross-checks the two speed sources
self._rx(self._pinion_msg(curvature, speed))
def test_rx_hook(self):
# checksum, counter and quality flag checks: the stock matrix plus the pinion message
for quality_flag in (True, False):
for msg_type in ("speed", "speed_2", "yaw", "pinion"):
self.safety.set_controls_allowed(True)
for _ in range(10):
if msg_type == "speed":
msg = self._speed_msg(0, quality_flag=quality_flag)
elif msg_type == "speed_2":
msg = self._speed_msg_2(0, quality_flag=quality_flag)
elif msg_type == "yaw":
msg = self._yaw_rate_msg(0, 0, quality_flag=quality_flag)
else:
msg = self._pinion_msg(0, 0, quality_flag=quality_flag)
self.assertEqual(quality_flag, self._rx(msg))
self.assertEqual(quality_flag, self.safety.get_controls_allowed())
# corrupt the checksum byte; not checked on the 2nd speed source or the pinion message
# (unknown OEM checksum: integrity is counter + quality flag)
msg[0].data[3] = 0
should_rx = msg_type in ("speed_2", "pinion") and quality_flag
self.assertEqual(should_rx, self._rx(msg))
self.assertEqual(should_rx, self.safety.get_controls_allowed())
def test_angle_measurements(self):
"""The rx hook converts pinion angle to curvature through the geometry row, within the
signal's 0.1 deg quantization."""
for speed in np.arange(0.5, 40, 0.5):
for curvature in np.arange(0, 0.02 * 2, 2e-3):
self._rx(self._speed_msg(speed))
for c in (curvature, -curvature, 0, 0, 0, 0):
self._rx(self._pinion_msg(c, speed))
tol = self._pinion_quant_tol(speed)
self.assertAlmostEqual(self.safety.get_curvature_meas_min(), round(-curvature * CURVATURE_TO_CAN), delta=tol)
self.assertAlmostEqual(self.safety.get_curvature_meas_max(), round(curvature * CURVATURE_TO_CAN), delta=tol)
self._rx(self._pinion_msg(0, speed))
self.assertAlmostEqual(self.safety.get_curvature_meas_min(), round(-curvature * CURVATURE_TO_CAN), delta=tol)
self.assertAlmostEqual(self.safety.get_curvature_meas_max(), 0, delta=tol)
self._rx(self._pinion_msg(0, speed))
self.assertAlmostEqual(self.safety.get_curvature_meas_min(), 0, delta=tol)
self.assertAlmostEqual(self.safety.get_curvature_meas_max(), 0, delta=tol)
def test_pinion_quality_flag_gates_measurement(self):
speed = CURVATURE_ERROR_MIN_SPEED + 5
self._reset_curvature_measurement(0.005, speed)
before = self.safety.get_curvature_meas_max()
self.assertGreater(before, 0)
for _ in range(6):
self.assertFalse(self._rx(self._pinion_msg(0, speed, quality_flag=False)))
self.assertEqual(self.safety.get_curvature_meas_max(), before)
def test_pinion_other_bus_ignored(self):
# only the main bus pinion frame feeds the measurement (its rx check entry is bus 0; ford_bp_rx_hook also matches the bus)
speed = CURVATURE_ERROR_MIN_SPEED + 5
self._reset_curvature_measurement(0.005, speed)
before = self.safety.get_curvature_meas_max()
for _ in range(6):
self._rx(self._pinion_msg(-0.005, speed, bus=2))
self.assertEqual(self.safety.get_curvature_meas_max(), before)
def test_pinion_sign_convention(self):
"""A command matching the measured sign passes the error band; a sign-inverted command (the
broken-yaw failure mode this source exists for) is blocked above the gate speed."""
speed = CURVATURE_ERROR_MIN_SPEED + 5
curvature = 0.005 # well above the 150-unit band
for sign in (1, -1):
with self.subTest(sign=sign):
self.safety.set_controls_allowed(True)
self._reset_curvature_measurement(sign * curvature, speed)
self._set_prev_desired_angle(sign * curvature)
self.assertTrue(self._tx(self._lat_ctl_msg(True, 0, 0, sign * curvature, 0)))
self._set_prev_desired_angle(-sign * curvature)
self.assertFalse(self._tx(self._lat_ctl_msg(True, 0, 0, -sign * curvature, 0)))
def test_pinion_check_inert_below_gate_speed(self):
self.safety.set_controls_allowed(True)
speed = CURVATURE_ERROR_MIN_SPEED - 2
self._reset_curvature_measurement(0.005, speed)
inverted = -0.005
self._set_prev_desired_angle(inverted)
self.assertTrue(self._tx(self._lat_ctl_msg(True, 0, 0, inverted, 0)))
class FordExplorerPinionGeometry: # GEOMETRY_INDEX only; BPPinionMixin.__init_subclass__ fills in the rest
"""FORD_EXPLORER_MK6, the on-road-validated primary platform."""
GEOMETRY_INDEX = 5
class FordBroncoSportPinionGeometry: # GEOMETRY_INDEX only; BPPinionMixin.__init_subclass__ fills in the rest
"""FORD_BRONCO_SPORT_MK1, smallest wheelbase in the table."""
GEOMETRY_INDEX = 1
class FordF150PinionGeometry: # GEOMETRY_INDEX only; BPPinionMixin.__init_subclass__ fills in the rest
"""FORD_F_150_MK14, largest wheelbase in the table."""
GEOMETRY_INDEX = 8
class TestFordBPPinionLongitudinalSafety(FordExplorerPinionGeometry, BPPinionMixin, matrix.TestFordBPLongitudinalSafety):
pass
class TestFordBPPinionCANFDStockSafety(FordExplorerPinionGeometry, BPPinionMixin, matrix.TestFordBPCANFDStockSafety):
pass
class TestFordBPPinionCANFDLongitudinalSafety(FordExplorerPinionGeometry, BPPinionMixin, matrix.TestFordBPCANFDLongitudinalSafety):
pass
class TestFordBPPinionBroncoSportSafety(FordBroncoSportPinionGeometry, BPPinionMixin, matrix.TestFordBPLongitudinalSafety):
pass
class TestFordBPPinionF150Safety(FordF150PinionGeometry, BPPinionMixin, matrix.TestFordBPCANFDLongitudinalSafety):
pass
class TestFordBPPinionGeometryTable(unittest.TestCase):
"""The C geometry table must match CarSpecs + calc_slip_factor(VehicleModel(CP)) for every
platform in FORD_PINION_GEOMETRY_INDEX, so it cannot rot as platforms change. Where BluePilot
deliberately uses another wheelbase (FORD_PINION_WHEELBASE), the row uses that one, and so does
the Python pinion path."""
def _assert_row_matches_carspecs(self, car, idx):
specs = car.config.specs
wheelbase = FORD_PINION_WHEELBASE.get(car, specs.wheelbase)
center_to_front = wheelbase * specs.centerToFrontRatio
front, rear = scale_tire_stiffness_per_mass(wheelbase, center_to_front, specs.tireStiffnessFactor)
# both VehicleModel signatures, as pinion_vehicle_model builds it (slip factor doesn't read inertia)
CP = SimpleNamespace(wheelbase=wheelbase, steerRatio=specs.steerRatio, centerToFront=center_to_front,
tireStiffnessFrontPerMass=front, tireStiffnessRearPerMass=rear, rotationalInertiaPerMass=1.0,
mass=1.0, tireStiffnessFront=front, tireStiffnessRear=rear, rotationalInertia=1.0, steerRatioRear=0.)
slip_factor = calc_slip_factor(VehicleModel(CP))
slip, sr, wb = GEOMETRY[idx]
self.assertAlmostEqual(sr, specs.steerRatio, places=3, msg=str(car))
self.assertAlmostEqual(wb, wheelbase, places=3, msg=str(car))
self.assertAlmostEqual(slip, slip_factor, delta=abs(slip_factor) * 1e-4, msg=str(car))
def test_geometry_matches_carspecs(self):
rows = GEOMETRY
# the index rides bits 1-4 of current_safety_param_sp; growing past 15 would silently
# disable the firmware side while the control side still enables
self.assertLessEqual(len(rows) - 1, 15)
# every supported platform has a row unless it can't take the pinion source (FORD_NO_PINION_PLATFORMS), every
# Python index entry points at a C row. A row for a platform this host's CAR doesn't have
# (FORD_HOST_ONLY_PLATFORMS) has no CarSpecs here
self.assertEqual({n for n in FORD_PINION_GEOMETRY_INDEX if n in set(CAR)}, set(CAR) - FORD_NO_PINION_PLATFORMS,
"every Ford platform needs a geometry row")
self.assertTrue(set(FORD_PINION_GEOMETRY_INDEX) - set(CAR) <= FORD_HOST_ONLY_PLATFORMS)
self.assertTrue(set(FORD_PINION_GEOMETRY_INDEX.values()) <= set(rows) - {0})
self.assertEqual(len(set(FORD_PINION_GEOMETRY_INDEX.values())), len(FORD_PINION_GEOMETRY_INDEX), "duplicate index")
for name, idx in FORD_PINION_GEOMETRY_INDEX.items():
if name in set(CAR):
self._assert_row_matches_carspecs(CAR(name), idx)
def test_f150_uses_the_average_wheelbase(self):
"""#21: F-150s with lane centering come in 3.68 and 3.99 m wheelbases and nothing tells them
apart, so pinion mode uses the average on both sides: the C row and the Python model."""
self.assertEqual(FORD_PINION_WHEELBASE[CAR.FORD_F_150_MK14], 3.84)
self.assertAlmostEqual(GEOMETRY[FORD_PINION_GEOMETRY_INDEX[CAR.FORD_F_150_MK14]][2], 3.84, places=3)
CP = CarInterface.get_non_essential_params(CAR.FORD_F_150_MK14)
VM = pinion_vehicle_model(CP)
self.assertAlmostEqual(VM.l, 3.84, places=3)
self.assertAlmostEqual(VM.aF / VM.l, CP.centerToFront / CP.wheelbase, places=6) # the platform's weight split is kept
def test_other_platforms_use_carspecs(self):
for car in FORD_PINION_GEOMETRY_INDEX:
if car in FORD_PINION_WHEELBASE or car not in set(CAR):
continue
CP = CarInterface.get_non_essential_params(car)
with self.subTest(car=car):
self.assertAlmostEqual(pinion_vehicle_model(CP).l, CP.wheelbase, places=6)
def test_invalid_index_row_is_inert(self):
self.assertEqual(GEOMETRY[0], (0.0, 1.0, 1.0))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,79 @@
"""The host's own names, for the tests that touch them: what carries the per-frame inputs and settings, where
its safety bits ride, its adapter (hosts/), and its libsafety setters. Detected from the host's opendbc, so
the same tests run on every host (HOSTS.md, "Running the tests")."""
import importlib.util
from ...car import structs
if hasattr(structs, "CarControlSP"): # sunnypilot
from ..hosts.sunnypilot.lateral import BluePilotLateral, params_from_cc_sp as params_from_cc
from ..hosts.sunnypilot.safety_param import init_ford_safety_param_sp
NAME = "sunnypilot"
CarControlX = structs.CarControlSP
SET_SAFETY_PARAM = "set_current_safety_param_sp"
SET_CONTROLS_ALLOWED_LAT = "set_controls_allowed_lateral"
def _init(CP, params_dict):
CP_SP = structs.CarParamsSP()
init_ford_safety_param_sp(CP, CP_SP, params_dict)
return CP_SP.safetyParam, CP_SP.lateralInputsRequired
elif hasattr(structs, "IQCarControl"): # IQ.Pilot
from ..hosts.iqpilot.lateral import BluePilotLateral, params_from_cc_iq as params_from_cc
from ..hosts.iqpilot.safety_param import init_ford_safety_param_iq
NAME = "iqpilot"
CarControlX = structs.IQCarControl
SET_SAFETY_PARAM = "set_current_safety_param_iq"
SET_CONTROLS_ALLOWED_LAT = "set_controls_allowed_lat"
def _init(CP, params_dict):
CP_IQ = structs.IQCarParams()
init_ford_safety_param_iq(CP, CP_IQ, params_dict)
return CP_IQ.iqSafetyFlags, CP_IQ.lateralInputsRequired
elif importlib.util.find_spec(__package__.rsplit(".", 2)[0] + ".car.ford.aol"): # StarPilot: its Ford AOL
from ..hosts.starpilot.lateral import BluePilotLateral, FrameInputs as CarControlX, params_from_frame as params_from_cc
from ..hosts.starpilot.safety_param import (BP_SAFETY_PARAM_SHIFT, bp_safety_param, init_ford_safety_param_starpilot,
lateral_inputs_required as lateral_inputs_required_starpilot)
NAME = "starpilot"
SET_SAFETY_PARAM = None # no second param: the bits ride the Ford param (ford_param)
SET_CONTROLS_ALLOWED_LAT = None # no lateral-only setter: AOL is off in these tests, so lateral follows controls_allowed
def _init(CP, params_dict):
CP.safetyConfigs = [structs.CarParams.SafetyConfig(safetyModel=structs.CarParams.SafetyModel.ford)]
init_ford_safety_param_starpilot(CP, params_dict)
return bp_safety_param(CP), lateral_inputs_required_starpilot(CP)
else:
raise ImportError("unknown host: add it to tests/host.py and hosts/")
__all__ = ["BluePilotLateral", "CarControlX", "params_from_cc"]
def startup_bits(CP, params_dict: dict[str, str]) -> int:
"""What the host's car-init hook gives ford_bp_init (values_ext.ford_safety_param's bits)."""
return _init(CP, params_dict)[0]
def lateral_inputs_required(CP, params_dict: dict[str, str]) -> bool:
"""Whether the host fills the per-frame inputs and settings for this car."""
return _init(CP, params_dict)[1]
def set_safety_param(safety, bits: int) -> None:
"""libsafety: the second safety param, the one ford_bp_init reads (current_safety_param_sp / _iq). A host
without one carries the bits in the Ford param instead (ford_param)."""
if SET_SAFETY_PARAM is not None:
getattr(safety, SET_SAFETY_PARAM)(bits)
def ford_param(base: int, bits: int) -> int:
"""The Ford safety param set_safety_hooks takes for these startup bits: unchanged on a host with a second
param, the bits shifted in on StarPilot."""
return base | (bits << BP_SAFETY_PARAM_SHIFT) if NAME == "starpilot" else base
def set_controls_allowed_lat(safety, allowed: bool) -> None:
"""libsafety: the host's lateral-only engagement (sunnypilot MADS, IQ.Pilot AOL)."""
if SET_CONTROLS_ALLOWED_LAT is not None:
getattr(safety, SET_CONTROLS_ALLOWED_LAT)(allowed)
@@ -1,101 +0,0 @@
"""Hash-bound upstream angle execution and current-curvature controller oracle.
Only imports are removed from the upstream angle/human module AST; every other
statement and function remains exact. Glue supplies current ABI types, source
fixed defaults and the source's no-op lazy-init/yaw-default adapter. No BluePilot
curvature strategy is loaded. Its diagnostic limiter cannot affect angle output.
"""
import ast
from collections import namedtuple
import hashlib
import json
from pathlib import Path
from types import SimpleNamespace
import numpy as np
from opendbc.car import structs, DT_CTRL
from opendbc.car.ford.values import CAR, CarControllerParams
from opendbc.car.ford.fordcan import CanBus
from opendbc.car.lateral import AngleSteeringLimits, apply_std_steer_angle_limits
from openpilot.selfdrive.modeld.constants import ModelConstants
ROOT = Path(__file__).with_name("reference")
MANIFEST = json.loads((ROOT / "manifest.json").read_text())
def source(name):
raw = (ROOT / (name + ".py.txt")).read_bytes()
if hashlib.sha256(raw).hexdigest() != MANIFEST[name]["sha256"]:
raise AssertionError("Reference source differs from its pinned manifest: " + name)
return ast.parse(raw.decode(), filename=MANIFEST[name]["origin"])
def execute(tree, namespace, *, names=None):
body = []
for node in tree.body:
if isinstance(node, (ast.Import, ast.ImportFrom)):
continue
if names is not None:
assigned = [target.id for target in node.targets if isinstance(target, ast.Name)] if isinstance(node, ast.Assign) else []
if getattr(node, "name", None) not in names and not any(name in names for name in assigned):
continue
body.append(node)
exec(compile(ast.Module(body=body, type_ignores=[]), "<pinned source>", "exec"), namespace)
def angle():
LateralResult = namedtuple("LateralResult", "apply_curvature curvature_rate path_offset path_angle ramp_type precision_type lateralUncertainty")
namespace = {"np": np, "clip": np.clip, "interp": np.interp, "DT_CTRL": DT_CTRL,
"CAR": CAR, "CarControllerParams": CarControllerParams, "ModelConstants": ModelConstants,
"LateralResult": LateralResult, "AngleSteeringLimits": AngleSteeringLimits,
"apply_std_steer_angle_limits": apply_std_steer_angle_limits}
execute(source("human"), namespace)
execute(source("values"), namespace, names={"_BP_ANGLE_RATE_UP", "_BP_ANGLE_RATE_DOWN", "BP_ANGLE_LIMITS"})
execute(source("angle"), namespace)
return namespace["LateralAngleExt"]
def builders():
namespace = {"CanBus": CanBus, "structs": structs}
execute(source("fordcan"), namespace, names={"calculate_lat_ctl2_checksum"})
execute(source("builders"), namespace, names={"create_lka_msg", "create_lat_ctl_msg", "create_lat_ctl2_msg", "_BP_LKA_SHADOW_CURVATURE_SCALE"})
return namespace
def curvature_controller():
# Full exact pre-angle production controller, with unchanged current imports.
namespace = {"__name__": "ford_curvature_pre_angle_reference"}
exec(compile(source("starpilot_curvature_controller"), "<current pre-angle controller>", "exec"), namespace)
return namespace["CarController"]
class Angle:
def __init__(self, identity):
self.controller = angle()()
self.controller.CP = SimpleNamespace(carFingerprint=identity)
self.controller._ensure_lateral_curv_initialized = lambda _cp: None # Exact source guard is a no-op.
self.controller.get_current_curvature = lambda cs: -cs.out.yawRate / max(cs.out.vEgoRaw, .1)
self.controller.path_angle_last = 0.
self.controller.lp = None # Read but unused by the source angle strategy.
self.controller.lane_change_factor_bp = [0., 50.]
self.controller.lane_change_factor_low = 1.
self.controller.model = None
self.controller.update_angle_params(None)
def step(self, inputs):
c = self.controller
c.sm = {"liveDelay": SimpleNamespace(lateralDelay=inputs.lateral_delay)}
c.model = SimpleNamespace(orientationRate=SimpleNamespace(z=list(inputs.yaw_predictions)),
meta=SimpleNamespace(laneChangeState=1 if inputs.lane_change else 0,
laneChangeDirection=inputs.lane_direction)) if inputs.yaw_predictions else None
cc = SimpleNamespace(latActive=inputs.active)
cs = SimpleNamespace(out=SimpleNamespace(vEgoRaw=inputs.speed, yawRate=-inputs.measured_curvature * inputs.speed,
steeringPressed=inputs.steering_pressed, steeringAngleDeg=inputs.steering_angle),
lat_ctl_lim_stat=inputs.limit_status)
return c.update_angle_strategy(cc, cs, SimpleNamespace(curvature=inputs.desired_curvature), c.CP)
def trim():
namespace = {"np": np, "interp": np.interp}
execute(source("bpdev_trim"), namespace)
return namespace["LaneCenterTrim"]
@@ -1,377 +0,0 @@
"""
BluePilot Ford CAN message builder extensions.
Provides extended versions of the stock fordcan.py message builders with additional
parameters for BluePilot features:
- Dynamic ramp_type and precision_type for lateral control
- Split brake_actuate / precharge_actuate for smoother deceleration
- BlueCruise cluster HUD status, TJA warnings, and hands-free messaging
- Independent left/right lane line logic for LKAS UI
- ICBM button injection for intelligent cruise button management
Each function mirrors the stock fordcan.py function it replaces, with an extended
parameter list. When stock carcontroller.py is refactored, it will import these
functions instead of the stock versions.
"""
from opendbc.car import structs
from opendbc.car.ford.fordcan import CanBus, calculate_lat_ctl2_checksum
HUDControl = structs.CarControl.HUDControl
_BP_LKA_SHADOW_CURVATURE_SCALE = 1e-6 # 1/meter per raw unit, matches ford.h's decode
def create_lka_msg(packer, CAN: CanBus, lat_active: bool, hud_control,
angle_mode_engaged: bool = False, shadow_curvature: float = 0.0):
"""
Creates a CAN message for the Ford LKA Command.
BluePilot extension: accepts lat_active and hud_control parameters for future
lane departure warning integration (unused so far).
Also carries angle_mode_engaged + shadow_curvature packed into bits with no DBC signal mapped to
them -- confirmed unused (always 0, no cabana signal) on real F-150 dashcam routes. This message
is one openpilot itself originates every cycle, and ford_tx_hook already reads other fields
(LkaActvStats_D2_Req) directly out of these same bytes synchronously, in the same tx_hook call --
no separate CAN ID, no RX round-trip needed (panda does not self-receive its own TX, confirmed
2026-07-09 investigating a controlsMismatch caused by an earlier dedicated-message design).
Byte layout (bits not covered by any Lane_Assist_Data1 DBC signal):
byte 4 bit 0: angle_mode_engaged
byte 4 bits 1-4: reserved (future bools)
byte 5-6: shadow_curvature (int16, scale 1e-6 1/m)
byte 7: reserved (future value)
Must match the decode in ford.h's FORD_Lane_Assist_Data1 tx_hook check exactly.
Frequency is 33Hz.
"""
addr, dat, bus = packer.make_can_msg("Lane_Assist_Data1", CAN.main, {})
dat = bytearray(dat)
shadow_curvature_raw = int(round(shadow_curvature / _BP_LKA_SHADOW_CURVATURE_SCALE))
shadow_curvature_raw = max(-32768, min(32767, shadow_curvature_raw)) & 0xFFFF
dat[4] |= 1 if angle_mode_engaged else 0
dat[5] = (shadow_curvature_raw >> 8) & 0xFF
dat[6] = shadow_curvature_raw & 0xFF
return addr, bytes(dat), bus
def create_lat_ctl_msg(packer, CAN: CanBus, lat_active: bool, ramp_type: int, precision_type: int,
path_offset: float, path_angle: float, curvature: float, curvature_rate: float):
"""
Creates a CAN message for the Ford TJA/LCA Command (non-CAN FD).
BluePilot extension: dynamic ramp_type and precision_type parameters.
Stock hardcodes ramp_type=0 (Slow) and precision_type=1 (Precise).
Ford lane centering uses a third-order polynomial to describe the road centerline:
c0 (path_offset): lateral offset between vehicle and centerline (positive is right)
c1 (path_angle): heading angle between vehicle and centerline (positive is right)
c2 (curvature): curvature of the centerline (positive is left)
c3 (curvature_rate): rate of change of curvature
Frequency is 20Hz.
"""
values = {
"LatCtlRng_L_Max": 0, # Unknown [0|126] meter
"HandsOffCnfm_B_Rq": 0, # Unknown: 0=Inactive, 1=Active [0|1]
"LatCtl_D_Rq": 1 if lat_active else 0, # Mode: 0=None, 1=ContinuousPathFollowing, 2=InterventionLeft,
# 3=InterventionRight, 4-7=NotUsed [0|7]
"LatCtlRampType_D_Rq": ramp_type, # Ramp speed: 0=Slow, 1=Medium, 2=Fast, 3=Immediate [0|3]
"LatCtlPrecision_D_Rq": precision_type, # Precision: 0=Comfortable, 1=Precise, 2/3=NotUsed [0|3]
"LatCtlPathOffst_L_Actl": path_offset, # Path offset [-5.12|5.11] meter
"LatCtlPath_An_Actl": path_angle, # Path angle [-0.5|0.5235] radians
"LatCtlCurv_NoRate_Actl": curvature_rate, # Curvature rate [-0.001024|0.00102375] 1/meter^2
"LatCtlCurv_No_Actl": curvature, # Curvature [-0.02|0.02094] 1/meter
}
return packer.make_can_msg("LateralMotionControl", CAN.main, values)
def create_lat_ctl2_msg(packer, CAN: CanBus, mode: int, ramp_type: int, precision_type: int,
path_offset: float, path_angle: float, curvature: float,
curvature_rate: float, counter: int):
"""
Creates a CAN message for the Ford Lane Centering command (CAN FD).
BluePilot extension: dynamic ramp_type and precision_type parameters.
Stock hardcodes ramp_type=0 (Slow) and precision_type=1 (Precise).
This message replaces LateralMotionControl on CAN FD platforms and includes
counter and checksum fields.
Frequency is 20Hz.
"""
values = {
"LatCtl_D2_Rq": mode, # Mode: 0=None, 1=PathFollowingLimitedMode, 2=PathFollowingExtendedMode,
# 3=SafeRampOut, 4-7=NotUsed [0|7]
"LatCtlRampType_D_Rq": ramp_type, # 0=Slow, 1=Medium, 2=Fast, 3=Immediate [0|3]
"LatCtlPrecision_D_Rq": precision_type, # 0=Comfortable, 1=Precise, 2/3=NotUsed [0|3]
"LatCtlPathOffst_L_Actl": path_offset, # [-5.12|5.11] meter
"LatCtlPath_An_Actl": path_angle, # [-0.5|0.5235] radians
"LatCtlCurv_No_Actl": curvature, # [-0.02|0.02094] 1/meter
"LatCtlCrv_NoRate2_Actl": curvature_rate, # [-0.001024|0.001023] 1/meter^2
"HandsOffCnfm_B_Rq": 0, # 0=Inactive, 1=Active [0|1]
"LatCtlPath_No_Cnt": counter, # [0|15]
"LatCtlPath_No_Cs": 0, # [0|255]
}
# Calculate checksum (reuse stock function)
dat = packer.make_can_msg("LateralMotionControl2", 0, values)[1]
values["LatCtlPath_No_Cs"] = calculate_lat_ctl2_checksum(mode, counter, dat)
return packer.make_can_msg("LateralMotionControl2", CAN.main, values)
def create_acc_msg(packer, CAN: CanBus, long_active: bool, gas: float, accel: float, accel_pred: float,
stopping: bool, brake_actuate: bool, precharge_actuate: bool, v_ego_kph: float):
"""
Creates a CAN message for the Ford ACC Command.
BluePilot extension: split brake control into brake_actuate and precharge_actuate
(each with independent hysteresis thresholds) for smoother deceleration. Also
accepts accel_pred as a parameter instead of hardcoding -5.0.
Precharge engages slightly before full brake for smoother initial decel feel.
Both use configurable hysteresis to avoid binary on/off feel.
Frequency is 50Hz.
"""
values = {
"AccBrkTot_A_Rq": accel, # Brake total accel request: [-20|11.9449] m/s^2
"Cmbb_B_Enbl": 1 if long_active else 0, # Enabled: 0=No, 1=Yes
"AccPrpl_A_Rq": gas, # Acceleration request: [-5|5.23] m/s^2
"AccPrpl_A_Pred": accel_pred, # Predicted accel (from carcontroller, not hardcoded)
"AccResumEnbl_B_Rq": 1 if long_active else 0,
"AccVeh_V_Trg": v_ego_kph, # Target speed: [0|255] km/h
"AccBrkPrchg_B_Rq": 1 if precharge_actuate else 0, # Pre-charge brake request (independent hysteresis)
"AccBrkDecel_B_Rq": 1 if brake_actuate else 0, # Deceleration request (independent hysteresis)
"AccStopStat_B_Rq": 1 if stopping else 0,
}
return packer.make_can_msg("ACCDATA", CAN.main, values)
def create_acc_ui_msg(packer, CAN: CanBus, CP, main_on: bool, enabled: bool, fcw_alert: bool,
standstill: bool, hud_control, stock_values: dict, send_hands_free_msg: bool,
send_ui: bool, send_bars: bool, tja_warn: int, tja_msg: int):
"""
Creates a CAN message for the Ford IPC adaptive cruise, FCW and TJA status.
BluePilot extension: replaces stock show_distance_bars with explicit send_ui,
send_bars, and TJA parameters. Adds BlueCruise status 7 for hands-free cluster
UI. TJA warn/msg are set from DM state computation rather than stock passthrough.
Stock functionality is maintained by passing through unmodified signals.
Frequency is 5Hz.
"""
# Tja_D_Stat: TJA status for cluster display
if enabled:
if hud_control.leftLaneDepart:
status = 3 # ActiveInterventionLeft
elif hud_control.rightLaneDepart:
status = 4 # ActiveInterventionRight
elif send_hands_free_msg:
status = 7 # BlueCruise UI in the cluster
else:
status = 2 # Active
elif main_on:
if hud_control.leftLaneDepart:
status = 5 # ActiveWarningLeft
elif hud_control.rightLaneDepart:
status = 6 # ActiveWarningRight
else:
status = 1 # Standby
elif standstill:
status = 0 # Off
else:
status = 1 # Standby
values = {s: stock_values[s] for s in [
"HaDsply_No_Cs",
"HaDsply_No_Cnt",
"AccStopStat_D_Dsply", # ACC stopped status message
"AccTrgDist2_D_Dsply", # ACC target distance
"AccStopRes_B_Dsply",
# TjaWarn_D_Rq and TjaMsgTxt_D_Dsply are set explicitly below, not passed through
"IaccLamp_D_Rq", # iACC status icon
"AccMsgTxt_D2_Rq", # ACC text
"FcwDeny_B_Dsply", # FCW disabled
"FcwMemStat_B_Actl", # FCW enabled setting
"AccTGap_B_Dsply", # ACC time gap display setting
"CadsAlignIncplt_B_Actl",
"AccFllwMde_B_Dsply", # ACC follow mode display setting
"CadsRadrBlck_B_Actl",
"CmbbPostEvnt_B_Dsply", # AEB event status
"AccStopMde_B_Dsply", # ACC stop mode display setting
"FcwMemSens_D_Actl", # FCW sensitivity setting
"FcwMsgTxt_D_Rq", # FCW text
"AccWarn_D_Dsply", # ACC warning
"FcwVisblWarn_B_Rq", # FCW visible alert
"FcwAudioWarn_B_Rq", # FCW audio alert
"AccTGap_D_Dsply", # ACC time gap
"AccMemEnbl_B_RqDrv", # ACC adaptive/normal setting
"FdaMem_B_Stat", # FDA enabled setting
]}
values.update({
"Tja_D_Stat": status, # TJA status
"TjaWarn_D_Rq": tja_warn, # TJA warning (from DM state, not stock passthrough)
"TjaMsgTxt_D_Dsply": tja_msg, # TJA text (from DM state, not stock passthrough)
})
if CP.openpilotLongitudinalControl:
values.update({
"AccStopStat_D_Dsply": 2 if standstill else 0, # Stopping status text
"AccMsgTxt_D2_Rq": 0, # ACC text
"AccTGap_B_Dsply": 1 if send_bars else 0, # Show time gap control UI
"AccFllwMde_B_Dsply": 1 if hud_control.leadVisible else 0, # Lead indicator
"AccStopMde_B_Dsply": 1 if standstill else 0,
"AccWarn_D_Dsply": 0, # ACC warning
"AccTGap_D_Dsply": hud_control.leadDistanceBars, # Time gap
})
# Forward FCW alert from IPMA
if fcw_alert:
values["FcwVisblWarn_B_Rq"] = 1 # FCW visible alert
values["FcwAudioWarn_B_Rq"] = 1 # FCW audio alert
return packer.make_can_msg("ACCDATA_3", CAN.main, values)
def create_lkas_ui_msg(packer, CAN: CanBus, main_on: bool, enabled: bool, hands: int,
hud_control, stock_values: dict):
"""
Creates a CAN message for the Ford IPC IPMA/LKAS status.
BluePilot extension: replaces stock steer_alert bool with hands int (0-3),
and uses independent left/right lane line status logic.
hands values:
0 = HandsOn
1 = Level1 (warning without chime)
2 = Level2 (warning with chime)
3 = Suppressed
LaActvStats_D_Dsply value table (left \\ right):
Right → | Intervene | Warning | Suppress | Available | None
Intervene | 24 | 19 | 14 | 9 | 4
Warning | 23 | 18 | 13 | 8 | 3
Suppress | 22 | 17 | 12 | 7 | 2
Available | 21 | 16 | 11 | 6 | 1
None | 20 | 15 | 10 | 5 | 0
Stock functionality is maintained by passing through unmodified signals.
Frequency is 1Hz.
"""
lines = 0
if hud_control is not None:
# Determine left lane status independently
if hud_control.leftLaneDepart:
left_status = 4 # Intervene (Yellow)
elif hud_control.leftLaneVisible:
left_status = 1 # Available
else:
left_status = 2 # Suppress
# Determine right lane status independently
if hud_control.rightLaneDepart:
right_status = 20 # Intervene (Yellow)
elif hud_control.rightLaneVisible:
right_status = 5 # Available
else:
right_status = 10 # Suppress
# Combine left and right lane status
lines = left_status + right_status
values = {s: stock_values[s] for s in [
"FeatConfigIpmaActl",
"FeatNoIpmaActl",
"PersIndexIpma_D_Actl",
"AhbcRampingV_D_Rq", # AHB ramping
"LaDenyStats_B_Dsply", # LKAS error
"CamraDefog_B_Req", # Windshield heater?
"CamraStats_D_Dsply", # Camera status
"DasAlrtLvl_D_Dsply", # DAS alert level
"DasStats_D_Dsply", # DAS status
"DasWarn_D_Dsply", # DAS warning
"AhbHiBeam_D_Rq", # AHB status
"Passthru_63",
"Passthru_48",
]}
values.update({
"LaActvStats_D_Dsply": lines, # LKAS status (lane lines) [0|31]
"LaHandsOff_D_Dsply": hands, # 0=HandsOn, 1=Level1 (w/o chime), 2=Level2 (w/ chime), 3=Suppressed
})
return packer.make_can_msg("IPMA_Data", CAN.main, values)
def create_button_msg(packer, bus: int, stock_values: dict, cancel=False, resume=False,
tja_toggle=False, icbm_button=None):
"""
Creates a CAN message for the Ford SCCM buttons/switches.
BluePilot extension: adds icbm_button parameter for Intelligent Cruise Button
Management. When set, the specified CAN signal is set to 1 in the outgoing
message, enabling openpilot to emulate cruise button presses for speed adjustment.
Args:
icbm_button: Optional string signal name (e.g., "CcAslButtnSetIncPress",
"CcAslButtnSetDecPress") for ICBM button injection.
Frequency is 10Hz.
"""
values = {s: stock_values[s] for s in [
"HeadLghtHiFlash_D_Stat", # SCCM Passthrough the remaining buttons
"TurnLghtSwtch_D_Stat", # SCCM Turn signal switch
"WiprFront_D_Stat",
"LghtAmb_D_Sns",
"AccButtnGapDecPress",
"AccButtnGapIncPress",
"AslButtnOnOffCnclPress",
"AslButtnOnOffPress",
"LaSwtchPos_D_Stat",
"CcAslButtnCnclResPress",
"CcAslButtnDeny_B_Actl",
"CcAslButtnIndxDecPress",
"CcAslButtnIndxIncPress",
"CcAslButtnOffCnclPress",
"CcAslButtnOnOffCncl",
"CcAslButtnOnPress",
"CcAslButtnResDecPress",
"CcAslButtnResIncPress",
"CcAslButtnSetDecPress",
"CcAslButtnSetIncPress",
"CcAslButtnSetPress",
"CcButtnOffPress",
"CcButtnOnOffCnclPress",
"CcButtnOnOffPress",
"CcButtnOnPress",
"HeadLghtHiFlash_D_Actl",
"HeadLghtHiOn_B_StatAhb",
"AhbStat_B_Dsply",
"AccButtnGapTogglePress",
"WiprFrontSwtch_D_Stat",
"HeadLghtHiCtrl_D_RqAhb",
]}
values.update({
"CcAslButtnCnclPress": 1 if cancel else 0, # CC cancel button
"CcAsllButtnResPress": 1 if resume else 0, # CC resume button
"TjaButtnOnOffPress": 1 if tja_toggle else 0, # LCA/TJA toggle button
})
# ICBM button support — set the specified button signal to 1
if icbm_button is not None:
values[icbm_button] = 1
return packer.make_can_msg("Steering_Data_FD1", bus, values)
@@ -1,342 +0,0 @@
from opendbc.car import CanBusBase, structs
HUDControl = structs.CarControl.HUDControl
class CanBus(CanBusBase):
def __init__(self, CP=None, fingerprint=None) -> None:
super().__init__(CP, fingerprint)
@property
def main(self) -> int:
return self.offset
@property
def radar(self) -> int:
return self.offset + 1
@property
def camera(self) -> int:
return self.offset + 2
def calculate_lat_ctl2_checksum(mode: int, counter: int, dat: bytearray) -> int:
curvature = (dat[2] << 3) | ((dat[3]) >> 5)
curvature_rate = (dat[6] << 3) | ((dat[7]) >> 5)
path_angle = ((dat[3] & 0x1F) << 6) | ((dat[4]) >> 2)
path_offset = ((dat[4] & 0x3) << 8) | dat[5]
checksum = mode + counter
for sig_val in (curvature, curvature_rate, path_angle, path_offset):
checksum += sig_val + (sig_val >> 8)
return 0xFF - (checksum & 0xFF)
def create_lka_msg(packer, CAN: CanBus):
"""
Creates an empty CAN message for the Ford LKA Command.
This command can apply "Lane Keeping Aid" maneuvers, which are subject to the PSCM lockout.
Frequency is 33Hz.
"""
return packer.make_can_msg("Lane_Assist_Data1", CAN.main, {})
def create_lat_ctl_msg(packer, CAN: CanBus, lat_active: bool, path_offset: float, path_angle: float, curvature: float,
curvature_rate: float):
"""
Creates a CAN message for the Ford TJA/LCA Command.
This command can apply "Lane Centering" maneuvers: continuous lane centering for traffic jam assist and highway
driving. It is not subject to the PSCM lockout.
Ford lane centering command uses a third order polynomial to describe the road centerline. The polynomial is defined
by the following coefficients:
c0: lateral offset between the vehicle and the centerline (positive is right)
c1: heading angle between the vehicle and the centerline (positive is right)
c2: curvature of the centerline (positive is left)
c3: rate of change of curvature of the centerline
As the PSCM combines this information with other sensor data, such as the vehicle's yaw rate and speed, the steering
angle cannot be easily controlled.
The PSCM should be configured to accept TJA/LCA commands before these commands will be processed. This can be done
using tools such as Forscan.
Frequency is 20Hz.
"""
values = {
"LatCtlRng_L_Max": 0, # Unknown [0|126] meter
"HandsOffCnfm_B_Rq": 0, # Unknown: 0=Inactive, 1=Active [0|1]
"LatCtl_D_Rq": 1 if lat_active else 0, # Mode: 0=None, 1=ContinuousPathFollowing, 2=InterventionLeft,
# 3=InterventionRight, 4-7=NotUsed [0|7]
"LatCtlRampType_D_Rq": 0, # Ramp speed: 0=Slow, 1=Medium, 2=Fast, 3=Immediate [0|3]
# Makes no difference with curvature control
"LatCtlPrecision_D_Rq": 1, # Precision: 0=Comfortable, 1=Precise, 2/3=NotUsed [0|3]
# The stock system always uses comfortable
"LatCtlPathOffst_L_Actl": path_offset, # Path offset [-5.12|5.11] meter
"LatCtlPath_An_Actl": path_angle, # Path angle [-0.5|0.5235] radians
"LatCtlCurv_NoRate_Actl": curvature_rate, # Curvature rate [-0.001024|0.00102375] 1/meter^2
"LatCtlCurv_No_Actl": curvature, # Curvature [-0.02|0.02094] 1/meter
}
return packer.make_can_msg("LateralMotionControl", CAN.main, values)
def create_lat_ctl2_msg(packer, CAN: CanBus, mode: int, path_offset: float, path_angle: float, curvature: float,
curvature_rate: float, counter: int):
"""
Create a CAN message for the new Ford Lane Centering command.
This message is used on the CAN FD platform and replaces the old LateralMotionControl message. It is similar but has
additional signals for a counter and checksum.
Frequency is 20Hz.
"""
values = {
"LatCtl_D2_Rq": mode, # Mode: 0=None, 1=PathFollowingLimitedMode, 2=PathFollowingExtendedMode,
# 3=SafeRampOut, 4-7=NotUsed [0|7]
"LatCtlRampType_D_Rq": 0, # 0=Slow, 1=Medium, 2=Fast, 3=Immediate [0|3]
"LatCtlPrecision_D_Rq": 1, # 0=Comfortable, 1=Precise, 2/3=NotUsed [0|3]
"LatCtlPathOffst_L_Actl": path_offset, # [-5.12|5.11] meter
"LatCtlPath_An_Actl": path_angle, # [-0.5|0.5235] radians
"LatCtlCurv_No_Actl": curvature, # [-0.02|0.02094] 1/meter
"LatCtlCrv_NoRate2_Actl": curvature_rate, # [-0.001024|0.001023] 1/meter^2
"HandsOffCnfm_B_Rq": 0, # 0=Inactive, 1=Active [0|1]
"LatCtlPath_No_Cnt": counter, # [0|15]
"LatCtlPath_No_Cs": 0, # [0|255]
}
# calculate checksum
dat = packer.make_can_msg("LateralMotionControl2", 0, values)[1]
values["LatCtlPath_No_Cs"] = calculate_lat_ctl2_checksum(mode, counter, dat)
return packer.make_can_msg("LateralMotionControl2", CAN.main, values)
def create_acc_msg(packer, CAN: CanBus, long_active: bool, gas: float, accel: float, stopping: bool, brake_request, v_ego_kph: float):
"""
Creates a CAN message for the Ford ACC Command.
This command can be used to enable ACC, to set the ACC gas/brake/decel values
and to disable ACC.
Frequency is 50Hz.
"""
values = {
"AccBrkTot_A_Rq": accel, # Brake total accel request: [-20|11.9449] m/s^2
"Cmbb_B_Enbl": 1 if long_active else 0, # Enabled: 0=No, 1=Yes
"AccPrpl_A_Rq": gas, # Acceleration request: [-5|5.23] m/s^2
# No observed acceleration seen from this signal alone. During stock system operation, it appears to
# be the raw acceleration request (AccPrpl_A_Rq when positive, AccBrkTot_A_Rq when negative)
"AccPrpl_A_Pred": -5.0, # Acceleration request: [-5|5.23] m/s^2
"AccResumEnbl_B_Rq": 1 if long_active else 0,
# No observed acceleration seen from this signal alone
"AccVeh_V_Trg": v_ego_kph, # Target speed: [0|255] km/h
# TODO: we may be able to improve braking response by utilizing pre-charging better
# When setting these two bits without AccBrkTot_A_Rq, an initial jerk is observed and car may be able to brake temporarily with AccPrpl_A_Rq
"AccBrkPrchg_B_Rq": 1 if brake_request else 0, # Pre-charge brake request: 0=No, 1=Yes
"AccBrkDecel_B_Rq": 1 if brake_request else 0, # Deceleration request: 0=Inactive, 1=Active
"AccStopStat_B_Rq": 1 if stopping else 0,
}
return packer.make_can_msg("ACCDATA", CAN.main, values)
def create_acc_ui_msg(packer, CAN: CanBus, CP, main_on: bool, enabled: bool, fcw_alert: bool, standstill: bool,
show_distance_bars: bool, hud_control, stock_values: dict):
"""
Creates a CAN message for the Ford IPC adaptive cruise, forward collision warning and traffic jam
assist status.
Stock functionality is maintained by passing through unmodified signals.
Frequency is 5Hz.
"""
# Tja_D_Stat
if enabled:
if hud_control.leftLaneDepart:
status = 3 # ActiveInterventionLeft
elif hud_control.rightLaneDepart:
status = 4 # ActiveInterventionRight
else:
status = 2 # Active
elif main_on:
if hud_control.leftLaneDepart:
status = 5 # ActiveWarningLeft
elif hud_control.rightLaneDepart:
status = 6 # ActiveWarningRight
else:
status = 1 # Standby
else:
status = 0 # Off
values = {s: stock_values[s] for s in [
"HaDsply_No_Cs",
"HaDsply_No_Cnt",
"AccStopStat_D_Dsply", # ACC stopped status message
"AccTrgDist2_D_Dsply", # ACC target distance
"AccStopRes_B_Dsply",
"TjaWarn_D_Rq", # TJA warning
"TjaMsgTxt_D_Dsply", # TJA text
"IaccLamp_D_Rq", # iACC status icon
"AccMsgTxt_D2_Rq", # ACC text
"FcwDeny_B_Dsply", # FCW disabled
"FcwMemStat_B_Actl", # FCW enabled setting
"AccTGap_B_Dsply", # ACC time gap display setting
"CadsAlignIncplt_B_Actl",
"AccFllwMde_B_Dsply", # ACC follow mode display setting
"CadsRadrBlck_B_Actl",
"CmbbPostEvnt_B_Dsply", # AEB event status
"AccStopMde_B_Dsply", # ACC stop mode display setting
"FcwMemSens_D_Actl", # FCW sensitivity setting
"FcwMsgTxt_D_Rq", # FCW text
"AccWarn_D_Dsply", # ACC warning
"FcwVisblWarn_B_Rq", # FCW visible alert
"FcwAudioWarn_B_Rq", # FCW audio alert
"AccTGap_D_Dsply", # ACC time gap
"AccMemEnbl_B_RqDrv", # ACC adaptive/normal setting
"FdaMem_B_Stat", # FDA enabled setting
]}
values.update({
"Tja_D_Stat": status, # TJA status
})
if CP.openpilotLongitudinalControl:
values.update({
"AccStopStat_D_Dsply": 2 if standstill else 0, # Stopping status text
"AccMsgTxt_D2_Rq": 0, # ACC text
"AccTGap_B_Dsply": 1 if show_distance_bars else 0, # Show time gap control UI
"AccFllwMde_B_Dsply": 1 if hud_control.leadVisible else 0, # Lead indicator
"AccStopMde_B_Dsply": 1 if standstill else 0,
"AccWarn_D_Dsply": 0, # ACC warning
"AccTGap_D_Dsply": hud_control.leadDistanceBars, # Time gap
})
# Forwards FCW alert from IPMA
if fcw_alert:
values["FcwVisblWarn_B_Rq"] = 1 # FCW visible alert
return packer.make_can_msg("ACCDATA_3", CAN.main, values)
def create_lkas_ui_msg(packer, CAN: CanBus, main_on: bool, enabled: bool, steer_alert: bool, hud_control,
stock_values: dict):
"""
Creates a CAN message for the Ford IPC IPMA/LKAS status.
Show the LKAS status with the "driver assist" lines in the IPC.
Stock functionality is maintained by passing through unmodified signals.
Frequency is 1Hz.
"""
# LaActvStats_D_Dsply
# R Intvn Warn Supprs Avail No
# L
# Intvn 24 19 14 9 4
# Warn 23 18 13 8 3
# Supprs 22 17 12 7 2
# Avail 21 16 11 6 1
# No 20 15 10 5 0
#
# TODO: test suppress state
if enabled:
lines = 0 # NoLeft_NoRight
if hud_control.leftLaneDepart:
lines += 4
elif hud_control.leftLaneVisible:
lines += 1
if hud_control.rightLaneDepart:
lines += 20
elif hud_control.rightLaneVisible:
lines += 5
elif main_on:
lines = 0
else:
if hud_control.leftLaneDepart:
lines = 3 # WarnLeft_NoRight
elif hud_control.rightLaneDepart:
lines = 15 # NoLeft_WarnRight
else:
lines = 30 # LA_Off
hands_on_wheel_dsply = 1 if steer_alert else 0
values = {s: stock_values[s] for s in [
"FeatConfigIpmaActl",
"FeatNoIpmaActl",
"PersIndexIpma_D_Actl",
"AhbcRampingV_D_Rq", # AHB ramping
"LaDenyStats_B_Dsply", # LKAS error
"CamraDefog_B_Req", # Windshield heater?
"CamraStats_D_Dsply", # Camera status
"DasAlrtLvl_D_Dsply", # DAS alert level
"DasStats_D_Dsply", # DAS status
"DasWarn_D_Dsply", # DAS warning
"AhbHiBeam_D_Rq", # AHB status
"Passthru_63",
"Passthru_48",
]}
values.update({
"LaActvStats_D_Dsply": lines, # LKAS status (lines) [0|31]
"LaHandsOff_D_Dsply": hands_on_wheel_dsply, # 0=HandsOn, 1=Level1 (w/o chime), 2=Level2 (w/ chime), 3=Suppressed
})
return packer.make_can_msg("IPMA_Data", CAN.main, values)
def create_button_msg(packer, bus: int, stock_values: dict, cancel=False, resume=False, tja_toggle=False):
"""
Creates a CAN message for the Ford SCCM buttons/switches.
Includes cruise control buttons, turn lights and more.
Frequency is 10Hz.
"""
values = {s: stock_values[s] for s in [
"HeadLghtHiFlash_D_Stat", # SCCM Passthrough the remaining buttons
"TurnLghtSwtch_D_Stat", # SCCM Turn signal switch
"WiprFront_D_Stat",
"LghtAmb_D_Sns",
"AccButtnGapDecPress",
"AccButtnGapIncPress",
"AslButtnOnOffCnclPress",
"AslButtnOnOffPress",
"LaSwtchPos_D_Stat",
"CcAslButtnCnclResPress",
"CcAslButtnDeny_B_Actl",
"CcAslButtnIndxDecPress",
"CcAslButtnIndxIncPress",
"CcAslButtnOffCnclPress",
"CcAslButtnOnOffCncl",
"CcAslButtnOnPress",
"CcAslButtnResDecPress",
"CcAslButtnResIncPress",
"CcAslButtnSetDecPress",
"CcAslButtnSetIncPress",
"CcAslButtnSetPress",
"CcButtnOffPress",
"CcButtnOnOffCnclPress",
"CcButtnOnOffPress",
"CcButtnOnPress",
"HeadLghtHiFlash_D_Actl",
"HeadLghtHiOn_B_StatAhb",
"AhbStat_B_Dsply",
"AccButtnGapTogglePress",
"WiprFrontSwtch_D_Stat",
"HeadLghtHiCtrl_D_RqAhb",
]}
values.update({
"CcAslButtnCnclPress": 1 if cancel else 0, # CC cancel button
"CcAsllButtnResPress": 1 if resume else 0, # CC resume button
"TjaButtnOnOffPress": 1 if tja_toggle else 0, # LCA/TJA toggle button
})
return packer.make_can_msg("Steering_Data_FD1", bus, values)
@@ -1,43 +0,0 @@
{
"angle": {
"origin": "opendbc_repo/opendbc/sunnypilot/car/ford/lateral_angle_ext.py",
"sha256": "18fa5290edade52137c7c6705a2eb2d42c94d39d1fa649eae06b7828c68207b3",
"revision": "e1d051d7ba270261b4455068bd68f1a58db15a4a"
},
"human": {
"origin": "opendbc_repo/opendbc/sunnypilot/car/ford/human_turn.py",
"sha256": "82735e348c5ce3e7b2c6a28ea3e298cca71ae8867433c270f7b54a9ee315ef37",
"revision": "e1d051d7ba270261b4455068bd68f1a58db15a4a"
},
"builders": {
"origin": "opendbc_repo/opendbc/sunnypilot/car/ford/fordcan_ext.py",
"sha256": "65ad2114db7210371fc5de06de962c0e29ce396a651c1dd2d58b07a80555a1bf",
"revision": "e1d051d7ba270261b4455068bd68f1a58db15a4a"
},
"fordcan": {
"origin": "opendbc_repo/opendbc/car/ford/fordcan.py",
"sha256": "0c35b8663afcaa3a58ab028a56f076c2c9f2f0296416c0abaf5905c674672956",
"revision": "e1d051d7ba270261b4455068bd68f1a58db15a4a"
},
"values": {
"origin": "opendbc_repo/opendbc/sunnypilot/car/ford/values_ext.py",
"sha256": "74802d82314eaa27eb610810d2815d3da77003c4a1c62e0437e57e785ba25f52",
"revision": "e1d051d7ba270261b4455068bd68f1a58db15a4a"
},
"starpilot_curvature_controller": {
"origin": "opendbc_repo/opendbc/car/ford/carcontroller.py",
"sha256": "9838f258b7a4eef48c8c1ed4fb40d180939b86e22ac9079a734d1eabf4c0060e",
"revision": "d2ae98eb3959c88011ed537e7cbc033d91fd3efa",
"intentional_changes": [
{
"commit": "d60f2d7ada6cf6aa0f32e881aa916809686a65a6",
"contract": "Generic CAN-FD stock-idle and same-batch mode-before-bounded-command handoff. Retains the pre-angle curvature oracle and all full-byte comparisons."
}
]
},
"bpdev_trim": {
"origin": "opendbc_repo/opendbc/sunnypilot/car/ford/lane_center_trim.py",
"sha256": "1affc0abeaeeabaefca4741b0d75d8f249590c157340c5a9760a395d28f0a621",
"revision": "e22afa6be9b881fa784c92ebb316db47728a3d81"
}
}
@@ -1,337 +0,0 @@
import math
import numpy as np
from opendbc.can import CANPacker
from opendbc.car import ACCELERATION_DUE_TO_GRAVITY, Bus, DT_CTRL, apply_hysteresis, structs
from opendbc.car.ford import fordcan
from opendbc.car.ford.manual_turn import ManualTurnLatch
from opendbc.car.ford.aol import qualified as qualified_aol
from opendbc.car.ford.stock_cruise import FordStockCruiseButton, qualified as stock_switch_qualified
from opendbc.car.ford.curvature_preview import blend_curvature
from opendbc.car.ford import mache_can
from opendbc.car.ford.extended_classic_can import create_extended_classic_lat_ctl_msg
from opendbc.car.ford.classic_lateral import create_controller as create_classic_controller, bounded_command as classic_bounded_command
from opendbc.car.ford.generic_canfd_can import create_extended_canfd_msg
from opendbc.car.ford.generic_canfd_lateral import (
GenericCanfdLateralController, qualified as generic_canfd_qualified, bounded_command as generic_canfd_bounded_command,
)
from opendbc.car.ford.mache_lateral import MachELateralController, FordLateralResult, bounded_command, qualified as mache_qualified
from opendbc.car.ford.values import CarControllerParams, FordFlags, FordSafetyFlags, CAR
from opendbc.car.interfaces import CarControllerBase, V_CRUISE_MAX
LongCtrlState = structs.CarControl.Actuators.LongControlState
VisualAlert = structs.CarControl.HUDControl.VisualAlert
def anti_overshoot(apply_curvature, apply_curvature_last, v_ego):
diff = 0.1
tau = 5 # 5s smooths over the overshoot
dt = DT_CTRL * CarControllerParams.STEER_STEP
alpha = 1 - np.exp(-dt / tau)
lataccel = apply_curvature * (v_ego ** 2)
last_lataccel = apply_curvature_last * (v_ego ** 2)
last_lataccel = apply_hysteresis(lataccel, last_lataccel, diff)
last_lataccel = alpha * lataccel + (1 - alpha) * last_lataccel
output_curvature = last_lataccel / (max(v_ego, 1) ** 2)
return float(np.interp(v_ego, [5, 10], [apply_curvature, output_curvature]))
def apply_creep_compensation(accel: float, v_ego: float, car_fingerprint: str, *, standstill: bool, stopping: bool) -> float:
if car_fingerprint == CAR.FORD_MUSTANG_MACH_E_MK1 and not (standstill and stopping):
return accel
creep_accel = np.interp(v_ego, [1., 3.], [0.6, 0.])
creep_accel = np.interp(accel, [0., 0.2], [creep_accel, 0.])
accel -= creep_accel
return float(accel)
def apply_brake_rate_limit(accel: float, gas: float, accel_last: float, v_ego: float, car_fingerprint: str,
*, stopping: bool, standstill: bool, fcw: bool) -> tuple[float, float]:
mild_mach_e_braking = (
car_fingerprint == CAR.FORD_MUSTANG_MACH_E_MK1 and
-1.0 < gas < 0.0 and v_ego >= 5.0 and not (stopping or standstill or fcw)
)
if mild_mach_e_braking:
accel = max(accel, min(accel_last, 0.0) - 2.0 * CarControllerParams.ACC_CONTROL_STEP * DT_CTRL)
gas = accel
else:
accel = max(accel, accel_last - 3.5 * CarControllerParams.ACC_CONTROL_STEP * DT_CTRL)
return float(accel), float(gas)
class CarController(CarControllerBase):
def __init__(self, dbc_names, CP):
super().__init__(dbc_names, CP)
self.packer = CANPacker(dbc_names[Bus.pt])
self.CAN = fordcan.CanBus(CP)
self.manual_turn = ManualTurnLatch() if CP.carFingerprint == CAR.FORD_MUSTANG_MACH_E_MK1 and not CP.flags & FordFlags.LKA_STEERING else None
self.stock_cruise_button = FordStockCruiseButton()
self.manual_turn_inputs = None
self.mache_lateral = MachELateralController(CP) if mache_qualified(CP) else None
self.mache_extended_announced = False
self.generic_canfd = generic_canfd_qualified(CP)
self.classic_lateral = GenericCanfdLateralController(CP) if self.generic_canfd else create_classic_controller(CP)
self.classic_extended_announced = False
self.classic_profile = any(s.safetyModel == structs.CarParams.SafetyModel.ford and
s.safetyParam & (FordSafetyFlags.CLASSIC_EXTENDED | FordSafetyFlags.GENERIC_CANFD_EXTENDED) for s in CP.safetyConfigs)
self.mache_profile = any(s.safetyModel == structs.CarParams.SafetyModel.ford and
s.safetyParam & FordSafetyFlags.MACH_E_EXTENDED for s in CP.safetyConfigs)
self.apply_curvature_last = 0
self.anti_overshoot_curvature_last = 0
self.accel = 0.0
self.gas = 0.0
self.brake_request = False
self.main_on_last = False
self.lkas_enabled_last = False
self.steer_alert_last = False
self.lead_distance_bars_last = None
self.distance_bar_frame = 0
self.apply_lka_angle_last = 0.
self.apply_lka_curvature_last = 0.
def update(self, CC, CS, now_nanos):
can_sends = []
if (self.mache_lateral is not None or self.classic_lateral is not None) and self.manual_turn_inputs is not None:
self.manual_turn_inputs.update()
self.manual_turn_inputs.apply_blend_settings(self)
actuators = CC.actuators
hud_control = CC.hudControl
main_on = CS.out.cruiseState.available
steer_alert = hud_control.visualAlert in (VisualAlert.steerRequired, VisualAlert.ldw)
fcw_alert = hud_control.visualAlert == VisualAlert.fcw
### acc buttons ###
stock_cancel = False
stock_resume = False
if stock_switch_qualified(self.CP):
stock_cancel, stock_resume = self.stock_cruise_button.update(
bool(CS.buttons_stock_values["CcAslButtnCnclResPress"]),
CS.out.cruiseState.available, CS.out.cruiseState.enabled)
if CC.cruiseControl.cancel:
can_sends.append(fordcan.create_button_msg(self.packer, self.CAN.camera, CS.buttons_stock_values, cancel=True))
can_sends.append(fordcan.create_button_msg(self.packer, self.CAN.main, CS.buttons_stock_values, cancel=True))
elif (stock_cancel or stock_resume) and (self.frame % CarControllerParams.BUTTONS_STEP) == 0:
can_sends.append(fordcan.create_button_msg(
self.packer, self.CAN.camera, CS.buttons_stock_values, cancel=stock_cancel, resume=stock_resume))
can_sends.append(fordcan.create_button_msg(
self.packer, self.CAN.main, CS.buttons_stock_values, cancel=stock_cancel, resume=stock_resume))
elif CC.cruiseControl.resume and (self.frame % CarControllerParams.BUTTONS_STEP) == 0:
can_sends.append(fordcan.create_button_msg(self.packer, self.CAN.camera, CS.buttons_stock_values, resume=True))
can_sends.append(fordcan.create_button_msg(self.packer, self.CAN.main, CS.buttons_stock_values, resume=True))
# if stock lane centering isn't off, send a button press to toggle it off
# the stock system checks for steering pressed, and eventually disengages cruise control
elif CS.acc_tja_status_stock_values["Tja_D_Stat"] != 0 and (self.frame % CarControllerParams.ACC_UI_STEP) == 0:
can_sends.append(fordcan.create_button_msg(self.packer, self.CAN.camera, CS.buttons_stock_values, tja_toggle=True))
### lateral control ###
# send steer msg at 20Hz
if (self.frame % CarControllerParams.STEER_STEP) == 0 and self.CP.flags & FordFlags.LKA_STEERING:
# Preserve the stock LMC heartbeat without requesting lateral action.
can_sends.append(fordcan.create_lat_ctl_msg(self.packer, self.CAN, False, 0., 0., 0., 0.,
stock_lmc=CS.lateral_motion_control))
elif (self.frame % CarControllerParams.STEER_STEP) == 0 and self.mache_lateral is not None:
permission = getattr(self.manual_turn_inputs, "assist_permission", None)
self.mache_lateral.set_assist_permission(*(permission() if permission is not None else ((), False)))
inputs = self.manual_turn_inputs.lateral_snapshot(CS.out.vEgoRaw) if self.manual_turn_inputs is not None else None
if inputs is not None:
self.mache_lateral.set_inputs(*inputs)
else:
# Withdraw stale preview while retaining current actuator/manual-turn control.
self.mache_lateral.set_inputs(None, (), 0.2, self.manual_turn_inputs.enabled if self.manual_turn_inputs is not None else True)
previous = self.mache_lateral.curvature_last
previous_path_angle = self.mache_lateral.path_angle_last
demanded = self.mache_lateral.update(CC, CS, actuators) if self.mache_extended_announced else FordLateralResult()
lateral = bounded_command(self.mache_lateral, demanded, previous, CS.out.vEgoRaw, previous_path_angle)
self.mache_lateral_demand = demanded
self.apply_curvature_last = lateral.curvature
counter = (self.frame // CarControllerParams.STEER_STEP) % 0x10
can_sends.append(mache_can.create_lat_ctl2_msg(
self.packer, self.CAN, int(lateral.active), lateral.ramp_type, lateral.precision_type,
-lateral.curvature, -lateral.curvature_rate, counter, -lateral.path_angle))
elif (self.frame % CarControllerParams.STEER_STEP) == 0 and self.classic_lateral is not None:
inputs = self.manual_turn_inputs.lateral_snapshot(CS.out.vEgoRaw) if self.manual_turn_inputs is not None else None
if inputs is not None:
self.classic_lateral.set_inputs(*inputs)
else:
self.classic_lateral.set_inputs(None, (), 0.2, self.manual_turn_inputs.enabled if self.manual_turn_inputs is not None else True)
previous = self.classic_lateral.curvature_last
demanded = self.classic_lateral.update(CC, CS, actuators) if self.classic_extended_announced or self.generic_canfd else FordLateralResult()
bound = generic_canfd_bounded_command if self.generic_canfd else classic_bounded_command
lateral = bound(self.classic_lateral, demanded, previous, CS.out.vEgoRaw,
-CS.out.yawRate / max(CS.out.vEgoRaw, 0.1))
self.classic_lateral_demand = demanded
self.apply_curvature_last = lateral.curvature
if self.generic_canfd:
if self.classic_extended_announced != bool(lateral.active):
can_sends.append(mache_can.create_lka_msg(self.packer, self.CAN) if lateral.active else
fordcan.create_lka_msg(self.packer, self.CAN))
self.classic_extended_announced = bool(lateral.active)
counter = (self.frame // CarControllerParams.STEER_STEP) % 0x10
can_sends.append(create_extended_canfd_msg(
self.packer, self.CAN, lateral.active, lateral.ramp_type, lateral.precision_type,
-lateral.curvature, -lateral.curvature_rate, counter))
else:
can_sends.append(create_extended_classic_lat_ctl_msg(
self.packer, self.CAN, lateral.active, lateral.ramp_type, lateral.precision_type,
-lateral.curvature, -lateral.curvature_rate))
elif (self.frame % CarControllerParams.STEER_STEP) == 0:
lateral_active = CC.latActive and not self.mache_profile and not self.classic_profile
manual_turn = False
if self.manual_turn is not None and self.manual_turn_inputs is not None:
detection_enabled, lane_change, model_ready = self.manual_turn_inputs.update()
manual_turn = self.manual_turn.update(CC, CS, float(actuators.curvature), detection_enabled, lane_change, model_ready)
if manual_turn:
lateral_active = False
self.apply_curvature_last = 0.
# Bronco and some other cars consistently overshoot curv requests
# Apply some deadzone + smoothing convergence to avoid oscillations
if self.CP.carFingerprint in (CAR.FORD_BRONCO_SPORT_MK1, CAR.FORD_F_150_MK14):
self.anti_overshoot_curvature_last = anti_overshoot(actuators.curvature, self.anti_overshoot_curvature_last, CS.out.vEgoRaw)
apply_curvature = self.anti_overshoot_curvature_last
else:
apply_curvature = actuators.curvature
# apply rate limits, curvature error limit, and clip to signal range
current_curvature = -CS.out.yawRate / max(CS.out.vEgoRaw, 0.1)
if self.manual_turn is not None and self.manual_turn_inputs is not None and lateral_active:
prediction = getattr(self.manual_turn_inputs, "preview_curvature", None)
preview = prediction(CS.out.vEgoRaw) if prediction is not None else None
if preview is not None:
apply_curvature = blend_curvature(apply_curvature, preview, current_curvature)
# No blending at low speed due to lack of torque wind-up and inaccurate current curvature
if CS.out.vEgoRaw > 9:
apply_curvature = float(np.clip(apply_curvature, current_curvature - CarControllerParams.CURVATURE_ERROR,
current_curvature + CarControllerParams.CURVATURE_ERROR))
apply_curvature = CarControllerParams.CURVATURE_LIMITS.apply_limits(apply_curvature, self.apply_curvature_last, CS.out.vEgoRaw,
0., lateral_active, CarControllerParams.STEER_STEP)
self.apply_curvature_last = 0. if manual_turn else apply_curvature
if self.CP.flags & FordFlags.CANFD:
# TODO: extended mode
# Ford uses four individual signals to dictate how to drive to the car. Curvature alone (limited to 0.02 m^-1)
# can actuate the steering for a large portion of any lateral movements. However, in order to get further control on
# steer actuation, the other three signals are necessary. Ford controls vehicles differently than most other makes.
# A detailed explanation on ford control can be found here:
# https://www.f150gen14.com/forum/threads/introducing-bluepilot-a-ford-specific-fork-for-comma3x-openpilot.24241/#post-457706
mode = 1 if lateral_active else 0
counter = (self.frame // CarControllerParams.STEER_STEP) % 0x10
can_sends.append(fordcan.create_lat_ctl2_msg(self.packer, self.CAN, mode, 0., 0., -self.apply_curvature_last, 0., counter))
else:
can_sends.append(fordcan.create_lat_ctl_msg(self.packer, self.CAN, lateral_active, 0., 0., -self.apply_curvature_last, 0.))
# send lka msg at 33Hz
if (self.frame % CarControllerParams.LKA_STEP) == 0 and not any(
address == 0x3CA for address, _, _ in can_sends):
if self.CP.flags & FordFlags.LKA_STEERING:
source_age_ns = now_nanos - CS.lkas_available_ts_nanos
lka_active = (CC.latActive and CS.lkas_available and 0 <= source_age_ns <= 100_000_000 and
(CS.out.cruiseState.enabled or qualified_aol(self.CP, marked_only=True)) and
not CS.out.steerFaultTemporary and
not CS.out.vehicleSensorsInvalid)
if lka_active:
desired_angle = float(np.clip(actuators.steeringAngleDeg - CS.out.steeringAngleDeg, -5.8, 5.8))
self.apply_lka_angle_last = float(np.clip(desired_angle, self.apply_lka_angle_last - 1.,
self.apply_lka_angle_last + 1.))
desired_curvature = -actuators.curvature
current_curvature = CS.out.yawRate / max(CS.out.vEgoRaw, 0.1)
if CS.out.vEgoRaw > 9.:
desired_curvature = float(np.clip(desired_curvature, current_curvature - CarControllerParams.CURVATURE_ERROR,
current_curvature + CarControllerParams.CURVATURE_ERROR))
self.apply_lka_curvature_last = CarControllerParams.LKA_CURVATURE_LIMITS.apply_limits(
desired_curvature, self.apply_lka_curvature_last, CS.out.vEgoRaw, 0., True, CarControllerParams.LKA_STEP)
else:
self.apply_lka_angle_last = 0.
self.apply_lka_curvature_last = 0.
self.apply_curvature_last = -self.apply_lka_curvature_last
direction = 2 if self.apply_lka_angle_last >= 0. else 4
can_sends.append(fordcan.create_lka_msg(self.packer, self.CAN, lka_active, self.apply_lka_angle_last,
direction, self.apply_lka_curvature_last, transit=True))
elif self.mache_lateral is not None:
can_sends.append(mache_can.create_lka_msg(self.packer, self.CAN))
self.mache_extended_announced = True
elif self.classic_lateral is not None:
if self.generic_canfd and not self.classic_extended_announced:
can_sends.append(fordcan.create_lka_msg(self.packer, self.CAN))
else:
can_sends.append(mache_can.create_lka_msg(self.packer, self.CAN))
self.classic_extended_announced = True
else:
can_sends.append(fordcan.create_lka_msg(self.packer, self.CAN))
### longitudinal control ###
# send acc msg at 50Hz
if self.CP.openpilotLongitudinalControl and (self.frame % CarControllerParams.ACC_CONTROL_STEP) == 0:
accel = actuators.accel
gas = accel
stopping = actuators.longControlState == LongCtrlState.stopping
if CC.longActive:
accel = apply_creep_compensation(accel, CS.out.vEgo, self.CP.carFingerprint,
standstill=CS.out.standstill, stopping=stopping)
accel, gas = apply_brake_rate_limit(
accel, gas, self.accel, CS.out.vEgo, self.CP.carFingerprint,
stopping=stopping, standstill=CS.out.standstill,
fcw=fcw_alert or CS.out.stockFcw or CS.out.stockAeb,
)
accel = float(np.clip(accel, CarControllerParams.ACCEL_MIN, CarControllerParams.ACCEL_MAX))
gas = float(np.clip(gas, CarControllerParams.ACCEL_MIN, CarControllerParams.ACCEL_MAX))
# Both gas and accel are in m/s^2, accel is used solely for braking
if not CC.longActive or gas < CarControllerParams.MIN_GAS:
gas = CarControllerParams.INACTIVE_GAS
# PCM applies pitch compensation to gas/accel, but we need to compensate for the brake/pre-charge bits
accel_due_to_pitch = 0.0
if len(CC.orientationNED) == 3:
accel_due_to_pitch = math.sin(CC.orientationNED[1]) * ACCELERATION_DUE_TO_GRAVITY
accel_pitch_compensated = accel + accel_due_to_pitch
if accel_pitch_compensated > 0.3 or not CC.longActive:
self.brake_request = False
elif accel_pitch_compensated < 0.0:
self.brake_request = True
# TODO: look into using the actuators packet to send the desired speed
can_sends.append(fordcan.create_acc_msg(self.packer, self.CAN, CC.longActive, gas, accel, stopping, self.brake_request, v_ego_kph=V_CRUISE_MAX))
self.accel = accel
self.gas = gas
### ui ###
send_ui = (self.main_on_last != main_on) or (self.lkas_enabled_last != CC.latActive) or (self.steer_alert_last != steer_alert)
# send lkas ui msg at 1Hz or if ui state changes
if (self.frame % CarControllerParams.LKAS_UI_STEP) == 0 or send_ui:
can_sends.append(fordcan.create_lkas_ui_msg(self.packer, self.CAN, main_on, CC.latActive, steer_alert, hud_control, CS.lkas_status_stock_values))
# send acc ui msg at 5Hz or if ui state changes
if hud_control.leadDistanceBars != self.lead_distance_bars_last:
send_ui = True
self.distance_bar_frame = self.frame
if (self.frame % CarControllerParams.ACC_UI_STEP) == 0 or send_ui:
show_distance_bars = self.frame - self.distance_bar_frame < 400
can_sends.append(fordcan.create_acc_ui_msg(self.packer, self.CAN, self.CP, main_on, CC.latActive,
fcw_alert, CS.out.cruiseState.standstill, show_distance_bars,
hud_control, CS.acc_tja_status_stock_values))
self.main_on_last = main_on
self.lkas_enabled_last = CC.latActive
self.steer_alert_last = steer_alert
self.lead_distance_bars_last = hud_control.leadDistanceBars
new_actuators = actuators.as_builder()
new_actuators.curvature = self.apply_curvature_last
if self.CP.flags & FordFlags.LKA_STEERING:
new_actuators.steeringAngleDeg = CS.out.steeringAngleDeg + self.apply_lka_angle_last
new_actuators.accel = self.accel
new_actuators.gas = self.gas
self.frame += 1
return new_actuators, can_sends
@@ -1,125 +0,0 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from collections import namedtuple
from opendbc.car import structs
from opendbc.car.docs_definitions import CarParts, Device
from opendbc.car.ford.values import CAR
from opendbc.car.lateral import AngleSteeringLimits
ButtonType = structs.CarState.ButtonEvent.Type
Button = namedtuple('Button', ['event_type', 'can_addr', 'can_msg', 'values'])
# Ford cruise control buttons are in the Steering_Data_FD1 message (CAN ID 131)
# These signals are 1-bit flags: 1 = pressed, 0 = not pressed
#
# Note: Some buttons are combo buttons that emit multiple ButtonEvent types:
# - CcAslButtnSetIncPress emits both accelCruise (type 3) and setCruise (type 9)
# - CcAslButtnCnclResPress emits both cancel (type 5) and resumeCruise (type 10)
#
# There is also a separate CcAslButtnSetPress signal for the standalone "Set" button,
# but based on user mapping, setCruise is mapped to the combo button instead.
BUTTONS = [
# Combo button: Set + Increase (emits accelCruise when enabled, setCruise when disabled)
Button(ButtonType.accelCruise, "Steering_Data_FD1", "CcAslButtnSetIncPress", [1]),
Button(ButtonType.setCruise, "Steering_Data_FD1", "CcAslButtnSetIncPress", [1]),
# Combo button: Set + Decrease (emits decelCruise when enabled, setCruise when disabled)
Button(ButtonType.decelCruise, "Steering_Data_FD1", "CcAslButtnSetDecPress", [1]),
Button(ButtonType.setCruise, "Steering_Data_FD1", "CcAslButtnSetDecPress", [1]),
# Combo button: Cancel/Resume (emits cancel when enabled, resumeCruise when disabled)
Button(ButtonType.cancel, "Steering_Data_FD1", "CcAslButtnCnclResPress", [1]),
Button(ButtonType.resumeCruise, "Steering_Data_FD1", "CcAslButtnCnclResPress", [1]),
# Main cruise button (on/off toggle)
Button(ButtonType.mainCruise, "Steering_Data_FD1", "CcButtnOnOffPress", [1]),
]
class FordSafetyFlagsSP:
"""Sunnypilot-level safety flags for Ford.
Carried in CP_SP.safetyParam and delivered to the safety firmware as
current_safety_param_sp (the separate SP uint16, USB control 0xdf) -- NOT the main
safetyConfigs[].safetyParam. ford_init reads it with GET_FLAG(current_safety_param_sp,
...), same pattern as Subaru STOP_AND_GO (subaru_common.h). Plain int constants, not
IntFlag: CP_SP.safetyParam must stay a plain int through capnp serialization in card.
"""
STEER_ANGLE_CURVATURE = 1
# Geometry-table index for the steering-angle curvature measurement, packed into
# CP_SP.safetyParam bits 1-4 when STEER_ANGLE_CURVATURE is set. Must match the
# ford_pinion_geometry table in safety/modes/ford.h row for row (enforced by
# test_ford.py's geometry-consistency test against CarSpecs + calc_slip_factor).
# Index 0 is reserved as invalid: the firmware treats flag-set-but-no-index as feature
# off, so a half-configured param can never select the wrong geometry silently.
# FORD_EDGE_MK2 is deliberately absent: ALT_STEER_ANGLE platforms read a RELATIVE pinion
# angle (SteeringPinion_Data_Alt + learned offset) and lack the absolute measurement
# this feature needs -- the toggle no-ops there and yaw behavior is kept.
FORD_PINION_GEOMETRY_SHIFT = 1
FORD_PINION_GEOMETRY_INDEX = {
CAR.FORD_BRONCO_SPORT_MK1: 1,
CAR.FORD_ESCAPE_MK4: 2,
CAR.FORD_ESCAPE_MK4_5: 3,
CAR.FORD_EXPEDITION_MK4: 4,
CAR.FORD_EXPLORER_MK6: 5,
CAR.FORD_FOCUS_MK4: 6,
CAR.FORD_F_150_LIGHTNING_MK1: 7,
CAR.FORD_F_150_MK14: 8,
CAR.FORD_MAVERICK_MK1: 9,
CAR.FORD_MONDEO_MK5: 10,
CAR.FORD_MUSTANG_MACH_E_MK1: 11,
CAR.FORD_RANGER_MK2: 12,
}
# BluePilot: Max curvature for steering command (m^-1), from DBC file limits
CURVATURE_MAX = 0.02
# BluePilot: Curvature rate limits — 3-point breakpoints for smoother lateral control.
# Upstream opendbc uses 2-point ([5, 25]) with more conservative values.
# These allow higher rates at low speed for responsiveness, lower rates at mid-speed
# for comfort, and very low rates at highway speed for stability.
#
# Control (Python) uses stricter windup than unwind so OP stays inside panda when apply_std
# picks the wrong table vs steer_angle_cmd_checks. Safety firmware uses looser symmetric ROCs
# (former “down” table for both up/down) — see ford.h FORD_LIMITS.
# Tests: test_ford.py ANGLE_RATE_* match ford.h, not the stricter BP_ANGLE_LIMITS up row.
_BP_ANGLE_RATE_UP = ([5, 16, 25], [0.0025, 0.0012, 0.00008])
_BP_ANGLE_RATE_DOWN = ([5, 16, 25], [0.0025, 0.0014, 0.00018])
BP_ANGLE_LIMITS = AngleSteeringLimits(
0.02, # Max curvature for steering command, m^-1
_BP_ANGLE_RATE_UP,
_BP_ANGLE_RATE_DOWN,
)
def apply_bp_device_mount(car_docs, CP):
"""BluePilot: Select comma3 mount type per vehicle.
Most Ford vehicles use the angled mount due to windshield angle.
Vehicles not in this list use the standard mount.
"""
from opendbc.car.ford.values import CAR, CarHarness, FordFlags
harness = CarHarness.ford_q4 if CP.flags & FordFlags.CANFD else CarHarness.ford_q3
if CP.carFingerprint in (
CAR.FORD_BRONCO_SPORT_MK1,
CAR.FORD_MAVERICK_MK1,
CAR.FORD_F_150_MK14,
CAR.FORD_F_150_LIGHTNING_MK1,
CAR.FORD_ESCAPE_MK4_5,
CAR.FORD_MUSTANG_MACH_E_MK1,
CAR.FORD_RANGER_MK2,
CAR.FORD_EDGE_MK2,
):
car_docs.car_parts = CarParts([Device.threex_angled_mount, harness])
else:
car_docs.car_parts = CarParts([Device.threex, harness])

Some files were not shown because too many files have changed in this diff Show More