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>
This commit is contained in:
alan-polk
2026-10-29 16:46:32 -05:00
parent 2fd0741f56
commit 8da0c9d916
143 changed files with 314 additions and 11644 deletions
+2 -12
View File
@@ -12,15 +12,9 @@ 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.
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.
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 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,10 +28,6 @@ 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
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 steering from Jacob Waller's fork
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.
+1 -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
@@ -1,35 +0,0 @@
# BluePilot angle source
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.
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.
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.
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.
@@ -1,7 +0,0 @@
Copyright (c) 2020, Comma.ai, Inc.
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
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.
@@ -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 +0,0 @@
# BluePilot Ford angle control
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.
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.
`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.
## Admission
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.
@@ -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]
@@ -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)
@@ -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)
@@ -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
@@ -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))
@@ -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
}
}
}
@@ -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,
}
@@ -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;
}
@@ -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,577 +0,0 @@
"""
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,
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.
**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.
"""
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,
})
# DBC ``LatCtlPath_An_Actl`` (rad) — panda safety uses the same in ``ford.h``; PSCM enforces in firmware.
FORD_DBC_PATH_ANGLE_MIN = -0.5
FORD_DBC_PATH_ANGLE_MAX = 0.5235
# PSCM d_ref (m) vs speed (m/s) — 6 points; above ~55.6 m/s use plateau + optional cap to 5 m.
_PSCM_DREF_SPEEDS_MS = (0.0, 4.17, 27.78, 41.67, 50.0, 55.56)
_PSCM_DREF_M = (0.5, 0.95, 1.4, 2.075, 2.75, 3.875)
# Default blend ratio validated on F-150 fleet data (0.5s lookup time).
_FORD_PATH_ANGLE_BLEND_RATIO_DEFAULT = 0.50
# Variable lookup time (VLT): curvature_lookup_time adapts to speed and curvature magnitude.
# t_lookup = t_base + t_extra_max × speed_factor(v) × kappa_factor(|κ|)
# t_base = liveDelay.lateralDelay + DT_MDL — always matches the planner's pre-compensation floor.
# Extra lookahead collapses toward zero at high speed (PSCM responds faster)
# and at large curvature (prevents blend importing a "start unwinding" signal too early).
_DT_MDL = 0.05 # model loop period (matches common/realtime.py)
_VLT_T_EXTRA_MAX = 0.10 # max extra lookahead above t_base
_VLT_V_LOW_MS = 25.0 * 0.44704 # 25 mph — full extra lookahead at or below this speed
_VLT_V_HIGH_MS = 55.0 * 0.44704 # 55 mph — no extra lookahead at or above this speed
_VLT_KAPPA_FULL = 0.005 # 1/m — full extra lookahead below this curvature (200m+ radius)
_VLT_KAPPA_TAPER = 0.020 # 1/m — no extra lookahead above this curvature (50m radius)
# Rate cap on path_angle magnitude DECREASE during PSCM LimitReached (rad/call = 0.40 rad/s).
# Both model and planner naturally drop path_angle ~0.36 rad/s at a sharp 90° apex, while the PSCM is
# physically pinned and cannot execute the rapidly falling desired angle. The resulting actual-vs-desired
# gap (up to 47° observed) causes a snap correction the moment the PSCM is released. This cap limits
# the desired-angle drop rate to what the PSCM can reasonably track, at the cost of holding the car
# slightly more in the curve during saturation.
# BluePilot: this strategy runs once per STEER_STEP (CarControllerParams.STEER_STEP=5), i.e. once
# every 5th 100Hz control tick = 20Hz, not every tick. The original 0.004 rad/call value (and its
# "50Hz" comment, corrected above) was authored 2026-05-11 on bp-sid-simple, which had already
# switched STEER_STEP 5->1 (true 100Hz) on 2026-04-22 -- so it was tuned at 100Hz real cadence
# even though its own comment mistakenly said 50Hz. Scaled x5 here to restore the same real-world
# 0.40 rad/s (23 deg/s) unwind rate on this branch's actual 20Hz cadence.
_PSCM_SAT_UNWIND_RATE = 0.02 # rad/call (0.02 * 20Hz = 0.40 rad/s)
# Post-override stall blip. Road test 2026-07-14 (route 886240741b067740/000000bd--feb980680f)
# showed that after driver-touch episodes the Mach-E PSCM keeps reporting InProgress but honors
# path_angle at only ~0.56x (healthy hands-free delivery on the same route: ~0.95 median). The
# current-curvature deviation clip below then pins kappa_cmd at measured + CURVATURE_ERROR, so the
# command can never lead the car enough to overcome the attenuation -- a stall equilibrium the
# driver reads as "not engaging" (wire path_angle flat at ~4 deg for 4.5 s while desired kappa
# climbed to 3x measured, EPS motor current ~0 A). A short mode-0 pulse -- the identical
# panda-clean wire pattern the human-turn override sends, no ford.h involvement -- resets the
# PSCM's authority, after which path_angle ramps back in from zero through the soft ROC.
_STEER_DT = CarControllerParams.STEER_STEP * DT_CTRL # 20 Hz lateral tick (matches human_turn.py)
_STALL_GAP_MIN = 2.0 * CarControllerParams.CURVATURE_ERROR # desired must lead measured by 2x the clip tolerance
_STALL_HOLD_S = 0.5 # accumulated clip-binding time before a pulse fires
_STALL_BLIP_FRAMES = 6 # mode-0 pulse length (6 frames @ 20 Hz = 300 ms; PSCM acked mode 0 in ~150 ms on-road)
_STALL_COOLDOWN_S = 2.0 # re-arm delay after a pulse (release ramp + PSCM response time)
_STALL_MAX_BLIPS = 3 # give up on a stuck episode; devLim telemetry keeps recording the stall
# Proactive hand-off blip: any sustained driver press attenuates the PSCM (route 000000be seg 4:
# 3 s of sub-45-deg circle-exit steering left it at ~0x delivery, and the reactive detector's
# fire-after-the-stall-develops timing meant 2.4 s of dead-straight running into the next curve
# before the pulse landed). Firing the same pulse on the falling edge of a sustained press resets
# the PSCM while the car is straight and the command is small -- a 300 ms lateral gap right at
# hand-off, imperceptible, instead of a missed curve. The reactive detector above stays as backstop.
_PRESS_BLIP_MIN_S = 0.5 # press must last this long before its release earns a pulse
# The pulse releases steering for 300 ms; never fire it in a curve.
_BLIP_MAX_PATH_ANGLE = 0.10 # rad
def pscm_d_ref_m(v_ego_ms: float) -> float:
v = max(float(v_ego_ms), 0.0)
d = float(np.interp(v, _PSCM_DREF_SPEEDS_MS, _PSCM_DREF_M))
if v > _PSCM_DREF_SPEEDS_MS[-1]:
# Doc: d_ref table ends at 3.875 m; contribution saturates for high speed — cap at 5 m.
d = min(5.0, d)
return d
class LateralAngleExt:
def __init__(self, CP=None, CP_SP=None):
# Predicted-curvature blend for path_angle: pred * b + desired * (1-b); b from ``FordPathAngleBlendRatio``
self.path_angle_blend_ratio = _FORD_PATH_ANGLE_BLEND_RATIO_DEFAULT
# Max extra VLT above t_base; from ``FordVLTExtraMax`` param
self.vlt_extra_max = _VLT_T_EXTRA_MAX
# Telemetry: final path_angle (rad) after limits (see 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
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
# 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
# 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)
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
# 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.
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
self.stall_blip_count = 0 # pulses fired this stall episode
self.angle_stall_blip_active = False
self.press_timer_s = 0.0 # continuous steeringPressed time, for the hand-off blip
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
else:
low, high = _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
def update_angle_strategy(self, CC, CS, actuators, CP):
"""
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.
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)
curvature_rate = 0.0
path_offset = 0.0
path_angle = 0.0
ramp_type = 0
lateral_uncertainty = 0.0
precision = 1
if not CC.latActive:
self.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.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
# latActive), and ford.h latches the shadow from every LKA frame -- so the latched
# value must track reality here, not sit at a stale zero. Otherwise the first enabled
# LMC frame after (re-)engage races LKA's 33Hz latch against LMC's 20Hz enable bit and
# 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.angle_human_turn_active = False
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
return LateralResult(
apply_curvature=0.0,
curvature_rate=0.0,
path_offset=0.0,
path_angle=0.0,
ramp_type=0,
precision_type=1,
lateralUncertainty=0.0,
)
# 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
# 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(
True, CS.out.steeringPressed, CS.out.steeringAngleDeg)
if self.angle_human_turn_active:
self.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.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)
# Keep exit detection current so resume doesn't compare against a stale pre-turn value.
self._desired_curvature_last = float(actuators.curvature)
# 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.
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
return LateralResult(
apply_curvature=0.0,
curvature_rate=0.0,
path_offset=0.0,
path_angle=0.0,
ramp_type=0,
precision_type=1,
lateralUncertainty=0.0,
)
# Proactive hand-off blip: the falling edge of a sustained press earns an immediate mode-0
# pulse (see _PRESS_BLIP_MIN_S) -- resets the PSCM's press-induced attenuation right at
# hand-off, while the car is straight and the command small, instead of waiting for the
# reactive stall detector below to watch the car miss the next curve first.
if CS.out.steeringPressed:
self.press_timer_s += _STEER_DT
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):
self.stall_blip_frames_left = _STALL_BLIP_FRAMES
self.press_timer_s = 0.0
# Stall-blip pulse in progress: hold lateral inactive (mode 0, all-zero signals -- the same
# wire pattern as the human-turn override, no ford.h involvement) for _STALL_BLIP_FRAMES so the
# PSCM drops its post-override attenuation, then release; path_angle ramps back in from zero
# through the soft ROC exactly like a human-turn release. Detection lives at the end of the
# normal flow below.
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.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.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._desired_curvature_last = float(actuators.curvature)
self.precision_type = 1
if self.stall_blip_frames_left <= 0:
self.stall_blip_cooldown_s = _STALL_COOLDOWN_S
return LateralResult(
apply_curvature=0.0,
curvature_rate=0.0,
path_offset=0.0,
path_angle=0.0,
ramp_type=0,
precision_type=1,
lateralUncertainty=0.0,
)
self.angle_stall_blip_active = False
self.precision_type = 1
precision = 1
LP = self.lp
desired_curvature = float(actuators.curvature)
# Variable lookup time: t_base tracks planner pre-compensation; extra tapers on high speed and large curves.
# Cap liveDelay at 0.15s for VLT purposes. liveDelay can calibrate up to ~420ms on some runs, which inflates
# VLT to 0.6s and pushes the model lookahead 5m into the curve. At that depth the model sees full peak
# 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
_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)))
_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
else:
_kappa_factor = float(interp(abs(desired_curvature), [_VLT_KAPPA_FULL, _VLT_KAPPA_TAPER], [1.0, 0.0]))
curvature_lookup_time = _t_base + self.vlt_extra_max * _speed_factor * _kappa_factor
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)
predicted_curvature = float(
interp(curvature_lookup_time, ModelConstants.T_IDXS, curvatures)
)
b = float(self.path_angle_blend_ratio)
b = float(clip(b, 0.0, 1.0))
# Exit-biased blend: near the PSCM authority limit or while the planner is actively
# reducing curvature (exit detected), drop model prediction weight from 60% → ~15%.
# This lets the planner's natural unwind dominate instead of being diluted by a model
# prediction that still sees the curve (→ seg-14 slow unwind) or that snaps when its
# lookahead window crosses the curve exit (→ seg-17 snap + reverse PSCM hit).
# Normal gentle curves are unaffected: no PSCM limit, no falling desired → full b=0.60.
_pscm_lim = getattr(CS, 'lat_ctl_lim_stat', 0)
# In angle mode, LatCtlLim_D_Stat (→ lat_ctl_lim_stat) does not fire.
# Previously used angleState.saturated (CtrSat) as a proxy, but CtrSat fires whenever the car
# lags the commanded path_angle by > 2.5° — which happens during any normal curve entry.
# 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)
_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
# was tuned to trigger on planner unwind faster than 0.2 (1/m)/s. Scaled x5 here to restore
# that same real-world trigger rate on this branch's actual 20Hz cadence; unscaled it fired at
# 0.04 (1/m)/s, collapsing the model blend on mild straightening instead of genuine exits.
# Same bug class and fix as _PSCM_SAT_UNWIND_RATE and _soft_roc above.
_desired_falling = abs(desired_curvature) < abs(self._desired_curvature_last) - 0.010
_on_exit_near_limit = not _kappa_entering and (_pscm_lim >= 1 or _in_hard_sat or _desired_falling)
b_blend = float(clip(b * 0.25, 0.0, 1.0)) if _on_exit_near_limit else b
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)
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]
)
if self.lane_change and self.model is not None:
if self.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:
requested_curvature *= lane_change_factor
precision = 0
self.precision_type = precision
# Use planner / predicted κ directly for the κ → path_angle map; we are not sending κ on CAN.
kappa_cmd = float(requested_curvature)
# 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
# (and therefore path_angle, and the shadow_curvature sent to ford.h) can legitimately lead the
# measured curvature by more than ford.h's angle-error tolerance during normal curve entry/exit
# -- the shadow-curvature deviation check (ford_shadow_curvature_error_check) would then block
# 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
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)
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)]
)
self.high_gain_calc = interp(v_ego, [13.5, 26.82], [(1.30 * self.low_speed_curv_factor), (self.path_angle_gain_highC_highV * self.high_speed_curv_factor)])
# As the curve gets bigger, we will need a little boost to the signal to to not understeer
self.curvature_factor = interp(abs(kappa_cmd), [0.0007, 0.001], [self.low_gain_calc, self.high_gain_calc])
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).
# LimitClose (_pscm_lim >= 1 only): block magnitude increases — exit-biased blend provides unwind.
# Hard saturation (_in_hard_sat): block increases AND rate-limit decreases to _PSCM_SAT_UNWIND_RATE.
# 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_mag = abs(_last)
_curr_mag = abs(path_angle)
if _curr_mag > _last_mag: # magnitude growing — block
path_angle = _last
elif _last_mag - _curr_mag > _PSCM_SAT_UNWIND_RATE: # decreasing too fast — rate-limit
_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 = min(FORD_DBC_PATH_ANGLE_MAX, max(FORD_DBC_PATH_ANGLE_MIN, path_angle))
# Soft ROC limit — unconditional, slightly tighter than ford.h, applied before the
# hardware bypass in ford.h is re-enabled. Lets us observe whether the limit would
# suppress control and tune it, while the PSCM still receives the clipped value.
# BluePilot: this strategy runs once per STEER_STEP (CarControllerParams.STEER_STEP=5), i.e.
# once every 5th 100Hz control tick = 20Hz, not every tick -- ported "verbatim from bp-sid-simple"
# (2026-06-13), which runs STEER_STEP=1 (true 100Hz, switched 2026-04-22). The y-values below are
# scaled x5 from the original [0.011, 0.011, 0.0085, 0.0018] to restore the same real-world rate
# (63/63/49/10 deg/s at v=9-10/15/25) on this branch's actual 20Hz cadence. See ford.h's
# FORD_PATH_ANGLE_LIMITS, which must mirror this scaling (x1.02 looser) to stay a true backstop.
_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))
# 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
# Telemetry / state
self.bp_path_angle_gain_lowC_highV = self.path_angle_gain_lowC_highV
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.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
# 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
# kappa can't follow the wheel: the driver moves the measured curvature faster than the
# deviation clip tracks it, so the shadow can exit ford.h's error band mid-curve -- the one
# 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
# 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
# curvature mode applies), so only the rate-of-change portion remains to simulate here.
_equiv_curv_rl = apply_std_steer_angle_limits(kappa_cmd, self.sim_curvature_last, v_ego,
CS.out.steeringAngleDeg, CC.latActive, BP_ANGLE_LIMITS)
self.bp_curvature_rate_limited = bool(abs(_equiv_curv_rl - kappa_cmd) > 1e-9)
self.sim_curvature_last = float(_equiv_curv_rl)
# Post-override stall detection (mechanism in the module constants' comment). Fires the mode-0
# blip when, hands-free, desired curvature has led measured by more than 2x the deviation
# clip's tolerance while the clip was actually binding for _STALL_HOLD_S accumulated seconds.
# devLim flickers mid-stall (~63% duty on the diagnosis route), so off frames hold the
# accumulator rather than resetting it; a closed gap or driver press ends the episode.
self.stall_blip_cooldown_s = max(0.0, self.stall_blip_cooldown_s - _STEER_DT)
_stall_gap = desired_curvature - current_curvature
_stalled = (not CS.out.steeringPressed and not self.lane_change and v_ego > 9.0
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:
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):
self.stall_blip_frames_left = _STALL_BLIP_FRAMES
self.stall_blip_hold_s = 0.0
self.stall_blip_count += 1
else:
self.stall_blip_hold_s = 0.0
if CS.out.steeringPressed or abs(_stall_gap) < 0.5 * _STALL_GAP_MIN:
self.stall_blip_count = 0 # episode over: the car is tracking again or the driver took it
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,
lateralUncertainty=lateral_uncertainty,
)
@@ -1,203 +0,0 @@
"""
BluePilot: angle-mode "advanced lane positioning" -- a curvature-domain trim toward true
lane-line center (plus an optional user left/right bias), for use with path_angle-primary
control (``lateral_angle_ext.py``).
Under curvature-primary control, lane positioning rode on a second DBC signal (path_angle as
a heading trim, c3) layered on top of curvature (c1, the primary). In angle mode, path_angle
IS the primary signal -- there is no separate trim slot on the wire. An earlier attempt ported
the old PID controller as an additive delta on the *final* path_angle value and it never tracked
lane center correctly: path_angle there 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 bypasses every limiter angle mode already applies to
``kappa_cmd`` (deviation clip, PSCM-saturation clamp, DBC clip, soft ROC).
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
(``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.
**Model-position fallback (blend, not hard gate)**: mirrors ``lateral_curv_ext.py``'s
``path_offset`` blend of ``path_offset_position`` (the model's own predicted path -- a "no
correction" baseline) vs. ``path_offset_lanelines`` (the geometric lane center), weighted by a
laneline confidence score. The *target* we correct toward is that same blend here, so:
- Good lane lines (confidence -> 1): target -> lane-line center + offset (full centering + bias).
- No/poor lane lines (confidence -> 0): target -> the model's own current path + offset, i.e. the
correction reduces to *just* the user's left/right bias riding on top of whatever the model
already wants to do -- it never goes to zero just because lane lines dropped out. This is the
case that matters on center-stripe-only roads with a curbed edge: there's no reliable lane line
to center between, but the user's bias should still nudge the car off the curb.
Confidence uses the exact formula and breakpoints ``lateral_curv_ext.py`` already uses for
``laneline_confidence`` / ``min_laneline_confidence_bp`` (smooth width-tolerance + laneline probs,
not a hard cutoff) -- reusing an already-tuned, production formula instead of inventing a second one.
"""
import numpy as np
from numpy import interp
# Laneline confidence blend -- ported verbatim from lateral_curv_ext.py's path_offset blend
# (laneline_width_tolerance / min_laneline_confidence_bp) so both control schemes agree on what
# "good lane lines" means.
_WIDTH_TOLERANCE_BP = (2.4, 2.8, 3.75, 4.25) # m - the floor is added from StarPilot
_WIDTH_TOLERANCE_V = (0.0, 0.81, 0.81, 0.59) # Adds StarPilot's low edge
_STD_TOLERANCE_BP = (0.3, 0.5) #0.3 is from StarPilot to define bad references
_STD_TOLERANCE_V = (0.81, 0.0) #0.81 is a known good value and allows it to fade to zero as std increases
_CONFIDENCE_BP = (0.6, 0.8)
_CONFIDENCE_V = (0.0, 1.0)
# Speed ramp: reuses curvature-mode's already-tuned centering-authority envelope
# (LC_PID_speed_bp/v in lateral_curv_ext.py) instead of inventing a new one.
_SPEED_RAMP_BP = (0.0, 9.0, 15.0) # m/s
_SPEED_RAMP_V = (0.0, 0.0, 1.0)
# Lookahead distance for the position-error sample, clipped to a sane range (m).
_LOOKAHEAD_MIN_M = 8.0
_LOOKAHEAD_MAX_M = 35.0
# Hard safety ceiling on the raw correction magnitude (1/m) -- fixed, not a "feel" knob, same
# treatment as _PSCM_SAT_UNWIND_RATE / _soft_roc in lateral_angle_ext.py.
_MAX_RAW_CORRECTION = 0.004
# First-order smoothing time constant (s) -- avoids abrupt jumps in the trim.
_SMOOTH_TAU_S = 0.4
# Rate-of-change limit on the applied correction (1/m per 20 Hz tick), independent of the
# smoothing filter above. The filter alone still has its *fastest* slew immediately after a
# target jump -- its per-tick step is proportional to how far the target moved, so a large,
# sudden confidence swing (e.g. crossing into/out of a too-wide merge lane, or lane lines
# dropping out entirely while the model's own path and the laneline center disagree) can still
# produce a big single-tick correction change even though it's technically "smoothed". This caps
# that step explicitly, the same way path_angle's own soft ROC (lateral_angle_ext.py) and
# curvature mode's LC_path_angle_ROC (lateral_curv_ext.py) rate-limit their outputs. At this
# rate, crossing the full -_MAX_RAW_CORRECTION..+_MAX_RAW_CORRECTION span takes ~2.7s; a more
# typical confidence-transition jump (a fraction of that) resolves proportionally faster.
_CORRECTION_ROC_PER_TICK = 0.00015
class LaneCenterTrim:
def __init__(self):
self._correction = 0.0
def reset(self) -> None:
self._correction = 0.0
def update(self, kappa_cmd: float, model, v_ego: float, enabled: bool, offset: float,
gain: float, lat_active: bool, lane_change: bool) -> float:
"""Returns ``kappa_cmd``, nudged toward (lane-blend target + ``offset``) when active.
``offset`` (m): positive shifts the target right, negative left (same sign convention as
curvature mode's ``custom_path_offset_curv``). Applied whether or not lane lines are usable
-- see module docstring.
``gain`` (0.0-1.0): user-tunable authority -- how much of the (already magnitude-clipped)
raw correction is actually applied. 0 disables the trim's effect without disabling detection.
The applied correction is both exponentially smoothed (_SMOOTH_TAU_S) and rate-limited
(_CORRECTION_ROC_PER_TICK) -- see the constants above -- so a lane-line-confidence
transition (e.g. a merge lane too wide to be trusted, then narrowing back into range) eases
into its new target instead of snapping.
"""
if not enabled or not lat_active or lane_change or model is None:
self.reset()
return kappa_cmd
speed_factor = float(interp(v_ego, _SPEED_RAMP_BP, _SPEED_RAMP_V))
if speed_factor <= 0.0:
self.reset()
return kappa_cmd
if not (np.isfinite(offset) and np.isfinite(gain)): #stops not a number from getting to steering command; validation of number
self.reset()
return kappa_cmd
valid, raw = self._raw_correction(model, v_ego, offset)
if not valid:
# Only true failure case now: model.position itself is unusable, so there's no baseline
# to offset from at all (see _raw_correction). Lane-line-only failures fall back to the
# model-position blend instead of landing here.
self.reset()
return kappa_cmd
target = float(np.clip(raw, -_MAX_RAW_CORRECTION, _MAX_RAW_CORRECTION)) * float(np.clip(gain, 0.0, 1.0)) * speed_factor
alpha = 1.0 - np.exp(-0.05 / _SMOOTH_TAU_S) # BluePilot lateral tick is 20 Hz (dt=0.05s)
filtered = float(alpha * target + (1.0 - alpha) * self._correction)
# Rate-of-change limit -- see _CORRECTION_ROC_PER_TICK. Bounds the correction's own per-tick
# delta on top of the exponential filter above, so a lane-line-confidence transition can't
# snap the trim toward its new target in one or two frames.
self._correction = float(np.clip(filtered, self._correction - _CORRECTION_ROC_PER_TICK,
self._correction + _CORRECTION_ROC_PER_TICK))
return kappa_cmd + self._correction
@property
def correction(self) -> float:
"""Telemetry: last applied correction (1/m)."""
return self._correction
def _raw_correction(self, model, v_ego: float, offset: float) -> tuple[bool, float]:
try:
pos_x = np.asarray(model.position.x, dtype=float)
pos_y = np.asarray(model.position.y, dtype=float)
if pos_x.size < 2 or pos_x.size != pos_y.size:
return False, 0.0
if not (np.isfinite(pos_x).all() and np.isfinite(pos_y).all() and np.all(np.diff(pos_x) > 0)):
return False, 0.0
lookahead = float(np.clip(v_ego, _LOOKAHEAD_MIN_M, _LOOKAHEAD_MAX_M))
model_y = float(np.interp(lookahead, pos_x, pos_y))
except (AttributeError, IndexError, TypeError, ValueError):
return False, 0.0
# Laneline contribution, blended in by confidence. scale=0 (model-position-only, i.e. just
# the user's offset bias) whenever lines are missing, low-confidence, or structurally bad --
# see _laneline_blend for the confidence formula (ported from lateral_curv_ext.py).
scale, laneline_center_y = self._laneline_blend(model, lookahead)
target_y = model_y * (1.0 - scale) + laneline_center_y * scale
error = (target_y + offset) - model_y
raw = 2.0 * error / (lookahead ** 2)
return True, float(raw)
def _laneline_blend(self, model, lookahead: float) -> tuple[float, float]:
"""Returns (scale, laneline_center_y). scale=0 whenever lanelines can't be trusted (missing,
low-probability, structurally invalid) -- center_y is unused/meaningless in that case since
it's weighted out by scale in the caller's blend."""
try:
lane_lines = model.laneLines
probs = model.laneLineProbs
stds = model.laneLineStds # a line could exist but this verifies how sure it is of where it is
if len(lane_lines) < 3 or len(probs) < 3 or len(stds) < 3:
return 0.0, 0.0
left_x = np.asarray(lane_lines[1].x, dtype=float)
left_y = np.asarray(lane_lines[1].y, dtype=float)
right_x = np.asarray(lane_lines[2].x, dtype=float)
right_y = np.asarray(lane_lines[2].y, dtype=float)
if (left_x.size < 2 or left_x.size != left_y.size or
right_x.size < 2 or right_x.size != right_y.size):
return 0.0, 0.0
if not (np.isfinite(left_x).all() and np.isfinite(left_y).all() and
np.isfinite(right_x).all() and np.isfinite(right_y).all()):
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
left = float(np.interp(lookahead, left_x, left_y))
right = float(np.interp(lookahead, right_x, right_y))
width = right - left
# Same confidence formula as lateral_curv_ext.py's path_offset blend: width-tolerance
# (penalizes implausibly wide/merging-looking detections) combined with per-line
# 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
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)
return scale, center_y
except (AttributeError, IndexError, TypeError, ValueError):
return 0.0, 0.0
@@ -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,71 +0,0 @@
"""
BluePilot: shared manual-steering-override ("human turn") detection for Ford lateral control.
Both Ford lateral strategies hand control back to the driver on a sustained manual turn, and the
detection is identical, so it lives here as one class instead of being copied into each mode. What
each mode DOES with the signal differs: curvature-primary (``lateral_curv_ext``) zeroes its command
(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
# Require sustained hands-on AND a large angle (avoids resetting on small wheel nudges in a curve).
HUMAN_TURN_ANGLE_DEG = 45.0
HUMAN_TURN_HOLD_S = 1.5
# When the wheel was ALREADY past HUMAN_TURN_ANGLE_DEG at first contact -- lateral control had it
# turned mid-curve -- the angle condition is pre-satisfied, so a brief corrective nudge would latch
# after only HUMAN_TURN_HOLD_S of light contact and kill steering mid-curve. Require a longer hold
# there before reading it as an intentional takeover. Route 000000bd (2026-07-14) showed the
# discriminator holds on-road: all 7 deliberate turns began with the wheel below the threshold
# (driver wound it up through 45 deg); only mid-maneuver grabs/nudges began beyond it.
HUMAN_TURN_HOLD_PRETURNED_S = 3.0
_STEER_DT = CarControllerParams.STEER_STEP * DT_CTRL # 20 Hz lateral tick
class HumanTurnDetector:
"""Latches ``active`` once the driver holds real steering pressure AND ``|wheel angle|`` >
``HUMAN_TURN_ANGLE_DEG`` continuously for ``HUMAN_TURN_HOLD_S`` — long enough to tell an
intentional turn from a brief nudge. ``just_released`` pulses True on the first frame after the
override clears, so a mode can re-seed its command as it re-engages.
Call ``update`` once per lateral tick while control is active. Modes that want the timer to zero
on disengage call ``reset`` on their inactive path; modes that want it to persist simply stop
calling ``update`` (the timer holds its value).
"""
def __init__(self):
self.hold_timer_s = 0.0
self.active = False
self._active_last = False
self._pressed_last = False
self._press_started_preturned = False
def update(self, enabled: bool, steering_pressed: bool, steering_angle_deg: float) -> bool:
self._active_last = self.active
# Was the wheel already past the angle threshold when this press began? If so the driver is
# touching a wheel that lateral control turned (mid-curve nudge), not driving a turn -- hold
# the longer HUMAN_TURN_HOLD_PRETURNED_S before latching.
if steering_pressed and not self._pressed_last:
self._press_started_preturned = abs(steering_angle_deg) > HUMAN_TURN_ANGLE_DEG
self._pressed_last = steering_pressed
if not enabled:
self.hold_timer_s = 0.0
elif steering_pressed and abs(steering_angle_deg) > HUMAN_TURN_ANGLE_DEG:
self.hold_timer_s += _STEER_DT
else:
self.hold_timer_s = 0.0
hold_req = HUMAN_TURN_HOLD_PRETURNED_S if self._press_started_preturned else HUMAN_TURN_HOLD_S
self.active = self.hold_timer_s >= hold_req
return self.active
@property
def just_released(self) -> bool:
return self._active_last and not self.active
def reset(self) -> None:
self.hold_timer_s = 0.0
self.active = False
self._active_last = False
self._pressed_last = False
self._press_started_preturned = False
@@ -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])
@@ -1,250 +0,0 @@
"""Pure angle host-independent finite inputs and explicit override/blip reset."""
import math
import unittest
from opendbc.bluepilot_lateral.core.angle import AngleController, Inputs, Command
from opendbc.bluepilot_lateral.params import limits
from opendbc.bluepilot_lateral.tests import reference
from opendbc.bluepilot_lateral.hosts.starpilot import CLASSIC, CANFD, TRUCKS
from opendbc.bluepilot_lateral.can.ford import lateral, lka
from opendbc.can import CANPacker
from types import SimpleNamespace
from dataclasses import replace
from opendbc.bluepilot_lateral.core.trim import LaneCenterTrim, LaneGeometry, TrimConfig
from opendbc.bluepilot_lateral.hosts.starpilot import model_geometry
from openpilot.selfdrive.modeld.constants import ModelConstants
class TestAngleCore(unittest.TestCase):
def test_finite_permission_and_inactive_state_clear_pending_history(self):
for value in (math.nan, math.inf, -math.inf):
for field in ("speed", "desired_curvature", "measured_curvature", "steering_angle", "lateral_delay"):
core = AngleController()
inputs = Inputs(active=True, speed=15., measured_curvature=0., desired_curvature=.001)
self.assertEqual(core.step(replace(inputs, **{field: value})), Command())
core = AngleController()
for _ in range(10):
self.assertTrue(core.step(Inputs(True, 15., 0., .001)).active)
self.assertEqual(core.step(Inputs(False, 15., 0., .001)), Command())
self.assertEqual(core.path_last, 0.)
def test_actual_equivalent_jerk_and_cap_both_signs_and_speeds(self):
for speed in (1., 9., 10., 15., 25., 40., 55.):
for direction in (-1, 1):
core = AngleController(low_gain=.95, high_gain=.95)
previous = 0.
for _ in range(60):
command = core.step(Inputs(True, speed, 0., direction * .002))
if command.active:
equivalent = command.path_angle / speed / limits.GAIN_MIN
self.assertLessEqual(abs(equivalent), min(limits.CURVATURE_MAX, limits.LATERAL_ACCEL / max(speed, 1.) ** 2))
self.assertLessEqual(abs(equivalent - previous), limits.LATERAL_JERK / max(speed - 1., 1.) ** 2 * .05)
previous = equivalent
else:
previous = 0.
def test_human_turn_and_release_blips_are_neutral_then_bounded(self):
core = AngleController()
for _ in range(61):
result = core.step(Inputs(True, 15., 0., .001, steering_pressed=True, steering_angle=60.))
self.assertFalse(result.active)
self.assertEqual(result.path_angle, 0.)
core = AngleController()
for _ in range(12):
core.step(Inputs(True, 15., 0., .001, steering_pressed=True))
for _ in range(6):
self.assertEqual(core.step(Inputs(True, 15., 0., .001)), Command())
resumed = core.step(Inputs(True, 15., 0., .001))
self.assertTrue(resumed.active)
self.assertLess(abs(resumed.path_angle), .02)
def test_retained_aol_keeps_active_after_release_and_tracking_error(self):
for stalled in (False, True):
core = AngleController(recovery_blips=False)
for tick in range(100):
inputs = Inputs(True, 15., 0., .006 if stalled else .001,
steering_pressed=not stalled and tick < 20)
result = core.step(inputs)
self.assertTrue(result.active, (stalled, tick))
self.assertLessEqual(abs(result.path_angle / (15. * limits.GAIN_MIN)), limits.LATERAL_ACCEL / 15. ** 2)
self.assertEqual(core.blip_frames, 0)
def test_retained_aol_preserves_human_turn_and_invalid_input_neutral(self):
core = AngleController(recovery_blips=False)
for _ in range(61):
result = core.step(Inputs(True, 15., 0., .001, steering_pressed=True, steering_angle=60.))
self.assertEqual(result, Command())
self.assertEqual(core.step(Inputs(True, 15., 0., math.nan)), Command())
self.assertEqual(core.step(Inputs(False, 15., 0., .001)), Command())
# Infeasible measured-curvature and actuator envelopes must still go neutral.
self.assertEqual(core.step(Inputs(True, 15., .1, .001)), Command())
def test_pinned_bp7_pure_angle_and_actual_control_bytes_with_documented_limits(self):
builders = reference.builders()
bus = SimpleNamespace(main=0)
differences = set()
packer = CANPacker("ford_lincoln_base_pt")
for identity in sorted(CLASSIC | CANFD):
gains = (.95, .95) if identity in TRUCKS else ((1., 1.05) if identity in CANFD else (1., 1.15))
for speed in (5., 15., 25., 40.):
for direction in (-1, 1):
adapted, upstream = AngleController(low_gain=gains[0], high_gain=gains[1]), reference.Angle(identity)
for tick in range(60):
# Tiny tracking curves stay clear of all intentional envelope changes.
desired = direction * .00005 * (1. + .2 * math.sin(tick / 8.))
inputs = Inputs(True, speed, desired, desired,
yaw_predictions=tuple(desired * speed for _ in ModelConstants.T_IDXS),
time_indices=tuple(ModelConstants.T_IDXS), lateral_delay=.15,
lane_change=tick >= 30, lane_direction=1 if direction < 0 else 2)
result = upstream.step(inputs)
command = adapted.step(inputs)
self.assertTrue(command.active)
self.assertAlmostEqual(command.path_angle, result.path_angle, places=12)
self.assertEqual(command.precision, result.precision_type)
fd = identity in CANFD
actual = lateral(packer, 0, command, canfd=fd, counter=tick % 16)
expected = (builders["create_lat_ctl2_msg"](packer, bus, 1, result.ramp_type, result.precision_type,
0., -result.path_angle, 0., 0., tick % 16) if fd else
builders["create_lat_ctl_msg"](packer, bus, True, result.ramp_type, result.precision_type,
0., -result.path_angle, 0., 0.))
self.assertEqual(actual, expected)
old_shadow = builders["create_lka_msg"](packer, bus, True, SimpleNamespace(), True, upstream.controller.bp_kappa_cmd)
new_shadow = lka(packer, 0, command.shadow_curvature, tick % 8)
# bp7 packs an empty body as raw zeros; the strict native contract requires physical zeros.
self.assertEqual(old_shadow[1][:4], b"\x00\x00\x00\x00")
self.assertEqual(new_shadow[1][:4], b"\x00\x80\x08\x00")
differences.update(("physical_neutral_dbc_offsets", "versioned_side_channel"))
# Aggressive entry demonstrates the required stricter native-equivalent derivative/cap.
adapted, upstream = AngleController(low_gain=gains[0], high_gain=gains[1]), reference.Angle(identity)
previous = 0.
for _tick in range(8):
inputs = Inputs(True, speed, 0., direction * .02,
yaw_predictions=tuple(direction * .02 * speed for _ in ModelConstants.T_IDXS),
time_indices=tuple(ModelConstants.T_IDXS), lateral_delay=.15)
result, command = upstream.step(inputs), adapted.step(inputs)
if command.active:
equivalent = command.path_angle / max(speed, .1) / limits.GAIN_MIN
self.assertLessEqual(abs(equivalent), min(limits.CURVATURE_MAX, limits.LATERAL_ACCEL / max(speed, 1.) ** 2))
self.assertLessEqual(abs(equivalent - previous), limits.LATERAL_JERK / max(speed - 1., 1.) ** 2 * .05)
previous = equivalent
if abs(command.path_angle - result.path_angle) > 1e-7:
differences.add("native_cap_jerk_deviation_packing")
else:
previous = 0.
self.assertEqual(differences, {"physical_neutral_dbc_offsets", "versioned_side_channel", "native_cap_jerk_deviation_packing"})
class TestAngleTrim(unittest.TestCase):
@staticmethod
def geometry(direction=1., *, width=3.3, probability=.9):
xs = (0., 10., 40.)
return LaneGeometry(xs, (direction * .3,) * 3, xs, (-width / 2,) * 3,
xs, (width / 2,) * 3, (probability,) * 2, (.1,) * 2, True)
@staticmethod
def model(g):
return SimpleNamespace(position=SimpleNamespace(x=g.position_x, y=g.position_y),
laneLines=[SimpleNamespace(x=(), y=()), SimpleNamespace(x=g.left_x, y=g.left_y),
SimpleNamespace(x=g.right_x, y=g.right_y)],
laneLineProbs=[0., *g.probabilities], laneLineStds=[0., *g.standard_deviations])
def test_disabled_trim_preserves_all_eleven_default_commands_and_wire_bytes(self):
packer = CANPacker("ford_lincoln_base_pt")
for identity in sorted(CLASSIC | CANFD):
low, high = (.95, .95) if identity in TRUCKS else ((1., 1.05) if identity in CANFD else (1., 1.15))
for speed in (5., 15., 25., 40.):
for direction in (-1, 1):
baseline, supplied = AngleController(low_gain=low, high_gain=high), AngleController(low_gain=low, high_gain=high)
self.assertFalse(supplied.trim_config.enabled)
for tick in range(30):
i = Inputs(True, speed, 0., direction * .0002)
a, b = baseline.step(i), supplied.step(replace(i, geometry=self.geometry(direction)))
self.assertEqual(a, b)
self.assertEqual(lateral(packer, 0, a, canfd=identity in CANFD, counter=tick % 16),
lateral(packer, 0, b, canfd=identity in CANFD, counter=tick % 16))
self.assertEqual(lka(packer, 0, a.shadow_curvature, tick % 8), lka(packer, 0, b.shadow_curvature, tick % 8))
self.assertEqual(supplied.trim.correction, 0.)
def test_fresh_geometry_matches_exact_currentdev_trim_and_model_fallback(self):
for speed in (5., 9., 12., 15., 25., 40.):
for direction in (-1, 1):
adapted, upstream = LaneCenterTrim(), reference.trim()()
config = TrimConfig(True, direction * .1, .25)
for tick in range(90):
g = self.geometry(direction, width=3.3 if tick < 30 or tick >= 60 else 1.5,
probability=.9 if tick < 60 else .3)
actual = adapted.update(.0001, speed, g, config, active=True, lane_change=False)
expected = upstream.update(.0001, self.model(g), speed, True, config.offset_m, config.gain, True, False)
self.assertAlmostEqual(actual, expected, places=14)
self.assertAlmostEqual(adapted.correction, upstream.correction, places=14)
projected = model_geometry(self.model(g), fresh=True)
self.assertEqual(projected, g)
self.assertIsNone(model_geometry(self.model(g), fresh=False))
def test_invalid_stale_driver_and_lane_change_suppress_trim_and_reset_history(self):
original = self.geometry()
config = TrimConfig(True, 0., .25)
for geometry in (replace(original, fresh=False), replace(original, position_x=(0., 0., 40.)),
replace(original, position_y=(0., math.nan, 0.)), replace(original, position_y=(0.,)), None):
trim = LaneCenterTrim()
self.assertNotEqual(trim.update(0., 15., original, config, active=True, lane_change=False), 0.)
self.assertEqual(trim.update(.001, 15., geometry, config, active=True, lane_change=False), .001)
self.assertEqual(trim.correction, 0.)
for changes in ({"active": False}, {"lane_change": True}, {"steering_pressed": True}):
trim = LaneCenterTrim()
trim.update(0., 15., original, config, active=True, lane_change=False)
flags = {"active": True, "lane_change": False, "steering_pressed": False}
flags.update(changes)
self.assertEqual(trim.update(.001, 15., original, config, **flags), .001)
self.assertEqual(trim.correction, 0.)
for invalid in (TrimConfig(True, math.nan, .25), TrimConfig(True, 0., math.inf), TrimConfig(True, .6, .25)):
self.assertEqual(LaneCenterTrim().update(.001, 15., original, invalid, active=True, lane_change=False), .001)
# Bad lane-only references fall back to the model+offset, not a stale lane correction.
bad = replace(original, probabilities=(math.nan, .9))
without_lines = replace(original, left_x=(), right_x=())
a, b = LaneCenterTrim(), LaneCenterTrim()
self.assertEqual(a.update(0., 15., bad, TrimConfig(True, .1, .25), active=True, lane_change=False),
b.update(0., 15., without_lines, TrimConfig(True, .1, .25), active=True, lane_change=False))
core = AngleController(trim_config=config)
core.step(Inputs(True, 15., 0., 0., geometry=original))
self.assertNotEqual(core.trim.correction, 0.)
self.assertEqual(core.step(Inputs(False, 15., 0., 0., geometry=original)), Command())
self.assertEqual(core.trim.correction, 0.)
def test_enabled_trim_remains_inside_final_curvature_jerk_and_shadow_bounds(self):
for speed in (9., 10., 15., 25., 40.):
for direction in (-1, 1):
core = AngleController(low_gain=.95, high_gain=.95, trim_config=TrimConfig(True, direction * .5, 1.))
previous, previous_trim, previous_active = 0., 0., False
for _tick in range(100):
command = core.step(Inputs(True, speed, 0., direction * .02, geometry=self.geometry(-direction)))
if command.active and previous_active:
self.assertLessEqual(abs(core.trim.correction - previous_trim), .00015 + 1e-12)
previous_trim, previous_active = core.trim.correction, command.active
if command.active:
equivalent = command.path_angle / speed / limits.GAIN_MIN
self.assertLessEqual(abs(equivalent), min(limits.CURVATURE_MAX, limits.LATERAL_ACCEL / max(speed, 1.) ** 2))
self.assertLessEqual(abs(equivalent - previous), limits.LATERAL_JERK / max(speed - 1., 1.) ** 2 * .05)
self.assertLessEqual(abs(command.shadow_curvature), min(limits.CURVATURE_MAX, limits.LATERAL_ACCEL / max(speed, 1.) ** 2))
# Shadow comes from the final path; no independent trim/shadow command can bypass bounds.
ratio = command.path_angle / (speed * command.shadow_curvature) if command.shadow_curvature else 1.
self.assertTrue(limits.GAIN_MIN - 1e-12 <= ratio <= limits.GAIN_MAX + 1e-12)
previous = equivalent
else:
previous = 0.
self.assertEqual(core.trim.correction, 0.)
class TestAngleInputParity(unittest.TestCase):
def test_missing_model_fallback_matches_pinned_bp7(self):
for identity in sorted(CLASSIC | CANFD):
gains = (.95, .95) if identity in TRUCKS else ((1., 1.05) if identity in CANFD else (1., 1.15))
for speed in (5., 8.):
for direction in (-1, 1):
adapted = AngleController(low_gain=gains[0], high_gain=gains[1])
upstream = reference.Angle(identity)
for _ in range(20):
inputs = Inputs(True, speed, direction * .001, direction * .001)
command, expected = adapted.step(inputs), upstream.step(inputs)
self.assertTrue(command.active)
self.assertAlmostEqual(command.path_angle, expected.path_angle, places=12)
self.assertEqual(command.precision, expected.precision_type)
@@ -1,307 +0,0 @@
"""Public packed angle/native boundaries; uses the runner-selected safety library."""
from opendbc.bluepilot_lateral.core.angle import AngleController, Inputs, Command
from opendbc.bluepilot_lateral.can.ford import lateral, lka, checksum4
from opendbc.safety.tests import common
from opendbc.safety.tests.libsafety import libsafety_py
from opendbc.car.structs import CarParams
from opendbc.can import CANPacker
class TestFordAngleNative(common.SafetyTestBase):
DBC = "ford_lincoln_base_pt"
SAFETY_MODEL = CarParams.SafetyModel.ford
SAFETY_PARAM = 128
def setUp(self):
super().setUp()
self.debug = self.safety.set_safety_hooks(CarParams.SafetyModel.allOutput, 0) == 0
self.safety.set_safety_hooks(self.SAFETY_MODEL, self.SAFETY_PARAM)
self.safety.init_tests()
self.now = 1_000_000
self.lka_count = 0
self.control_count = 0
self.word = self.SAFETY_PARAM
def packet(self, msg):
addr, dat, bus = msg
return libsafety_py.make_CANPacket(addr, bus, dat)
def rx(self, name, values, *, bus=0):
self.assertTrue(self._rx(self.packer.make_can_msg_safety(name, bus, values)))
def warm(self, speed=15.0, curvature=0.0):
self.safety.set_timer(self.now)
for _ in range(6):
self.rx("BrakeSysFeatures", {"Veh_V_ActlBrk": speed * 3.6, "VehVActlBrk_D_Qf": 3})
self.rx("EngVehicleSpThrottle2", {"Veh_V_ActlEng": speed * 3.6, "VehVActlEng_D_Qf": 3})
self.rx("Yaw_Data_FD1", {"VehYaw_W_Actl": curvature * speed, "VehYawWActl_D_Qf": 3})
self.rx("EngVehicleSpThrottle", {"ApedPos_Pc_ActlArb": 0})
self.rx("DesiredTorqBrk", {"VehStop_D_Stat": 0})
self.rx("Steering_Data_FD1", {"CcAslButtnCnclResPress": 0})
if not self.safety.get_controls_allowed():
self.rx("EngBrakeData", {"BpedDrvAppl_D_Actl": 1, "CcStat_D_Actl": 0})
self.rx("EngBrakeData", {"BpedDrvAppl_D_Actl": 1, "CcStat_D_Actl": 5})
self.assertTrue(self.safety.get_controls_allowed())
def reset_word(self, word):
self.safety.set_alternative_experience(0)
self.safety.set_safety_hooks(self.SAFETY_MODEL, word)
self.word = word
self.lka_count = self.control_count = 0
self.now += 1_000_000
self.warm()
def shadow(self, command):
msg = lka(self.packer, 0, command.shadow_curvature, self.lka_count)
self.lka_count = (self.lka_count + 1) % 8
return msg
def control(self, command):
msg = lateral(self.packer, 0, command, canfd=bool(self.word & 2), counter=self.control_count)
self.control_count = (self.control_count + 1) % 16
return msg
def send(self, msg, expected=True):
self.assertEqual(bool(self._tx(self.packet(msg))), expected)
def tick(self, command):
self.now += 50_000
self.warm()
self.send(self.shadow(command))
self.send(self.control(command))
def test_real_packer_physical_neutral_shadow_and_corruptions(self):
packer = CANPacker(self.DBC)
for word in (128, 130):
for direction in (-1, 1):
for fault in ("angle", "curvature", "checksum", "version", "counter", "replay"):
with self.subTest(word=word, direction=direction, fault=fault):
self.reset_word(word)
command = Command(True, direction * 0.005, direction * 0.005 / 15.0)
# Real RX and PCM edges in warm() establish permission, with no allowance setter.
self.assertTrue(self.safety.get_controls_allowed())
good = lka(packer, 0, command.shadow_curvature, 0)
addr, data, bus = good
self.assertEqual((addr, bus), (0x3CA, 0))
self.assertEqual(data[:4], b"\x00\x80\x08\x00")
self.assertEqual(((data[2] & 15) << 8) | data[3], 2048)
self.assertEqual((data[1] << 4) | (data[2] >> 4), 2048)
self.assertEqual(data[7] & 15, 1)
self.assertEqual(data[7] >> 4, checksum4(data))
self.assertEqual(int.from_bytes(data[5:7], "big", signed=True), round(-command.shadow_curvature * 1e6))
self.send(good)
self.send(lateral(packer, 0, command, canfd=bool(word & 2), counter=0))
self.now += 50_000
self.warm()
next_good = lka(packer, 0, command.shadow_curvature, 1)
mutated = bytearray(next_good[1])
if fault == "angle":
mutated[3] ^= 1
elif fault == "curvature":
mutated[1] ^= 1
elif fault == "checksum":
mutated[7] ^= 16
elif fault == "version":
mutated[7] ^= 1
elif fault == "counter":
mutated[4] ^= 4
if fault in ("angle", "curvature", "counter"):
mutated[7] = 1 | (checksum4(mutated) << 4)
bad = good if fault == "replay" else (addr, bytes(mutated), bus)
self.send(bad, False)
self.assertTrue(self.safety.get_controls_allowed())
self.send(lateral(packer, 0, command, canfd=bool(word & 2), counter=1), False)
# A new valid announcement restores the same bounded path without relaxing native checks.
self.now += 50_000
self.warm()
self.send(next_good)
self.send(lateral(packer, 0, command, canfd=bool(word & 2), counter=1))
def test_actual_core_packer_boundary_all_namespaces(self):
words = [128, 129, 130]
if self.debug:
words.append(131)
for word in words:
for direction in (-1, 1):
self.reset_word(word)
core = AngleController(low_gain=0.95, high_gain=0.95)
active_seen = False
for _ in range(40):
command = core.step(Inputs(True, 15.0, 0.0, direction * 0.02))
active_seen |= command.active
self.tick(command)
self.assertTrue(active_seen)
self.tick(Command())
def test_cold_and_stale_shadow_require_new_valid_announcement(self):
command = Command(True, 0.005, 0.005 / 15.0)
self.warm()
self.send(self.control(command), False)
self.send(self.shadow(command))
self.send(self.control(command))
self.now += 60_001
self.warm()
self.send(self.control(command), False)
self.now += 50_000
self.warm()
self.send(self.shadow(command))
self.send(self.control(command))
def test_invalid_shadow_version_checksum_and_dbc_bits_withdraw(self):
command = Command(True, 0.005, 0.005 / 15.0)
for index, mask in ((7, 1), (7, 16), (0, 32), (4, 2), (4, 32)):
self.reset_word(128)
valid = self.shadow(command)
addr, data, bus = valid
mutated = bytearray(data)
mutated[index] ^= mask
if index != 7:
mutated[7] = 1 | (checksum4(mutated) << 4)
self.send((addr, bytes(mutated), bus), False)
self.send(self.control(command), False)
self.send(valid)
self.send(self.control(command))
def test_replay_wrong_bus_and_length_cannot_refresh_shadow(self):
command = Command(True, 0.005, 0.005 / 15.0)
self.warm()
msg = self.shadow(command)
self.send(msg)
self.send(msg, False)
self.send(self.control(command), False)
for bus, size in ((2, 8), (0, 7), (0, 16)):
addr, dat, _ = self.shadow(command)
self.send((addr, (dat + b"\x00" * 8)[:size], bus), False)
self.now += 30_000
self.rx("EngBrakeData", {"BpedDrvAppl_D_Actl": 1, "CcStat_D_Actl": 0})
self.send(self.shadow(Command()))
self.now += 30_000
self.warm()
self.send(self.shadow(command))
self.send(self.control(command))
def test_native_actual_path_cannot_be_laundered_by_shadow(self):
for command in (Command(True, 0.0005, 0.0), Command(True, -0.0005, 0.0), Command(True, 0.5, 0.0), Command(True, -0.01, 0.001),
Command(True, 0.04, 0.002), Command(True, 0.10, 0.005)):
self.reset_word(128)
self.send(self.shadow(command))
self.send(self.control(command), False)
# A rejected command does not seed an excessive subsequent ROC.
self.reset_word(128)
large = Command(True, 0.20, 0.01)
self.send(self.shadow(large))
self.send(self.control(large), False)
self.now += 50_000
self.warm()
small = Command(True, 0.005, 0.005 / 15.0)
self.send(self.shadow(small))
self.send(self.control(small))
def test_permission_and_rx_withdrawals(self):
command = Command(True, 0.005, 0.005 / 15.0)
for source in ("brake", "gas", "cruise", "speed_quality", "yaw_checksum", "stale"):
self.reset_word(128)
self.send(self.shadow(command))
self.send(self.control(command))
self.now += 50_000
self.warm()
self.send(self.shadow(command))
if source == "brake":
self.rx("EngBrakeData", {"BpedDrvAppl_D_Actl": 2, "CcStat_D_Actl": 5})
elif source == "gas":
self.rx("EngVehicleSpThrottle", {"ApedPos_Pc_ActlArb": 50})
elif source == "cruise":
self.rx("EngBrakeData", {"BpedDrvAppl_D_Actl": 1, "CcStat_D_Actl": 3})
elif source == "speed_quality":
self.assertFalse(self._rx(self.packer.make_can_msg_safety("BrakeSysFeatures", 0, {"VehVActlBrk_D_Qf": 0})))
elif source == "yaw_checksum":
packet = self.packer.make_can_msg_safety("Yaw_Data_FD1", 0, {"VehYawWActl_D_Qf": 3})
packet[0].data[4] ^= 1
self.assertFalse(self._rx(packet))
else:
self.now += 200_001
self.safety.set_timer(self.now)
self.send(self.control(command), False)
self.send(self.control(Command()))
def test_fd_bad_crc_counter_and_mode_neutral_recovery(self):
self.reset_word(130)
command = Command(True, 0.005, 0.005 / 15.0)
self.send(self.shadow(command))
good = self.control(command)
addr, dat, bus = good
bad = bytearray(dat)
bad[1] ^= 1
self.send((addr, bytes(bad), bus), False)
self.send(good, False) # bad CRC withdrew shadow
self.now += 50_000
self.warm()
self.send(self.shadow(command))
self.send(self.control(Command())) # valid mode0 resynchronizes skipped counters
self.now += 50_000
self.warm()
self.send(self.shadow(command))
self.send(self.control(command))
self.now += 50_000
self.warm()
self.send(self.shadow(command))
self.control_count = (self.control_count + 1) % 16
self.send(self.control(command), False)
def test_adjacent_profile_version_and_release_long_denials(self):
command = Command(True, 0.005, 0.005 / 15.0)
for word in (0, 2, 4, 8, 10, 12, 18, 32, 66, 132, 134, 136, 160, 194, 256):
self.reset_word(word)
announcement = self.shadow(command)
if word & 128:
self.send(announcement, False)
else:
self._tx(self.packet(announcement)) # Existing neutral metadata is not angle authority.
self.send(self.control(command), False)
self.reset_word(131)
expected = bool(self.debug)
self.send(self.shadow(command), expected)
self.send(self.control(command), expected)
def test_rejected_metadata_does_not_erase_last_accepted_path_and_neutral_resync(self):
self.reset_word(128)
# At25m/s ROC is tight enough that erasing the accepted path would reject a same-path retry.
core = AngleController(low_gain=.95, high_gain=.95)
for _ in range(30):
self.now += 50_000
self.warm(speed=25., curvature=.001)
command = core.step(Inputs(True, 25., -.001, -.001))
self.send(self.shadow(command))
self.send(self.control(command))
self.assertGreater(abs(command.path_angle), .01)
self.now += 50_000
self.warm(speed=25., curvature=.001)
good = self.shadow(command)
addr, data, bus = good
bad = bytearray(data)
bad[7] ^= 16
self.send((addr, bytes(bad), bus), False)
self.send(good)
self.send(self.control(command))
self.now += 50_000
self.warm(speed=25., curvature=.001)
self.send(self.control(Command()))
self.now += 50_000
self.warm(speed=25., curvature=.001)
recovery = AngleController(low_gain=.95, high_gain=.95).step(Inputs(True, 25., -.001, -.001))
self.send(self.shadow(recovery))
self.send(self.control(recovery))
def test_rt_rate_denial_expires_and_neutral_can_recover(self):
self.reset_word(130)
for _ in range(8):
self.send(self.control(Command()))
self.send(self.control(Command()), False)
self.now += 250_000
self.warm()
self.send(self.control(Command()))
self.now += 50_000
self.warm()
command = Command(True, .005, .005 / 15.)
self.send(self.shadow(command))
self.send(self.control(command))
@@ -1,179 +0,0 @@
"""Mach-E route 0000000d: refresh before publication; neutral restarts from zero."""
import pytest
from collections import defaultdict
from types import SimpleNamespace
from opendbc.car import structs, Bus
from opendbc.car.ford.values import CAR
from opendbc.car.ford.carcontroller import CarController
from opendbc.bluepilot_lateral.core.angle import AngleController, Inputs, Command
class CoreHost:
def __init__(self):
self.controller = AngleController(low_gain=1., high_gain=1.05, recovery_blips=False)
self.steering_rejected = False
self.steering_pressed = False
self.steering_angle = 0.
def update(self, cc, cs):
active = cc.latActive and not self.steering_rejected
self.steering_rejected = False
return self.controller.step(Inputs(active, 32.6, 0., .0012, steering_pressed=self.steering_pressed, steering_angle=self.steering_angle))
def setup(canfd=True):
cp = structs.CarParams(brand='ford', carFingerprint=CAR.FORD_MUSTANG_MACH_E_MK1 if canfd else CAR.FORD_FOCUS_MK4,
flags=3 if canfd else 2, pcmCruise=True, alternativeExperience=32,
transmissionType=structs.CarParams.TransmissionType.automatic,
steerControlType=structs.CarParams.SteerControlType.angle,
openpilotLongitudinalControl=True)
cp.safetyConfigs = [structs.CarParams.SafetyConfig(safetyModel=structs.CarParams.SafetyModel.ford, safetyParam=147 if canfd else 161)]
owner = CarController({Bus.pt:'ford_lincoln_base_pt'}, cp)
owner.bp_lat.host = CoreHost()
cc = structs.CarControl(latActive=True)
state = structs.CarState(vEgo=32.6, vEgoRaw=32.6, canValid=True,
gearShifter=structs.CarState.GearShifter.drive)
state.cruiseState.available = True
cs = SimpleNamespace(out=state.as_reader(), buttons_stock_values=defaultdict(int),
acc_tja_status_stock_values=defaultdict(int), lkas_status_stock_values=defaultdict(int))
return owner, cc.as_reader(), cs
def control_active(message):
return ((message[1][0] >> 4) if message[0] == 0x3D6 else (message[1][4] >> 2)) & 7
def prime(owner, cc, cs):
for now in range(1_000_000_000, 2_000_000_000, 10_000_000):
owner.update(cc, cs, now)
assert owner.bp_lat.pending.active
assert abs(owner.bp_lat.host.controller.path_last) > .015
def test_due_shadow_precedes_control_after_recorded_72ms_gap():
owner, cc, cs = setup()
prime(owner, cc, cs)
bp = owner.bp_lat
now = max(bp.shadow_sent_ns, bp.control_sent_ns) + 72_000_000
_, messages = owner.update(cc, cs, now)
angle = [m for m in messages if m[0] in (0x3CA, 0x3D6)]
assert [m[0] for m in angle] == [0x3CA, 0x3D6]
assert control_active(angle[-1]) == 1
assert bp.announced_ns == now
def test_unrefreshable_stale_lease_neutral_clears_pending_actuator_history():
owner, cc, cs = setup()
prime(owner, cc, cs)
bp = owner.bp_lat
now = max(bp.shadow_sent_ns, bp.control_sent_ns) + 72_000_000
# A newly consumed late shadow echo still enforces its pacing delay.
bp.shadow_echo_pending = True
_, messages = owner.update(cc, cs, now)
angle = [m for m in messages if m[0] in (0x3CA, 0x3D6)]
assert [m[0] for m in angle] == [0x3D6]
assert control_active(angle[0]) == 0
assert bp.pending == Command()
assert bp.host.controller.path_last == 0.
# A subsequent announcement cannot revive the old nonzero actuator.
seen = []
for t in range(now + 10_000_000, now + 300_000_000, 10_000_000):
_, messages = owner.update(cc, cs, t)
seen.extend(m for m in messages if m[0] == 0x3D6 and control_active(m))
assert seen
sig = owner.packer.dbc.name_to_msg['LateralMotionControl2'].sigs['LatCtlPath_An_Actl']
first_path = sig.get_raw_value(seen[0][1]) * sig.factor + sig.offset
assert abs(first_path) <= .005
@pytest.mark.parametrize('canfd, word', [(True, 147), (False, 161)])
def test_aol_native_accepts_due_refresh_and_stale_restart_without_relaxation(canfd, word):
from opendbc.safety.tests.test_ford_aol import TestFordAolDriverIntent
from opendbc.safety.tests.libsafety import libsafety_py
native = TestFordAolDriverIntent()
native.setUp()
native.reset(word)
native.arm()
native.pump(30, speed=32.6, mask=1)
owner, cc, cs = setup(canfd)
active = 0
try:
for tick in range(200):
native.pump(1, speed=32.6, mask=1)
now = (1_000_000 + native.tick * 10_000) * 1000
if tick in (110, 150):
# No host update for 70ms, but native receives fresh vehicle sources.
native.pump(7, speed=32.6, mask=1)
now = (1_000_000 + native.tick * 10_000) * 1000
if tick == 150:
owner.bp_lat.shadow_echo_pending = True
_, messages = owner.update(cc, cs, now)
for address, data, bus in messages:
if address not in (0x3CA, 0x3D6, 0x3D3):
continue
assert native.safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus, data)), (tick, hex(address), data.hex())
active += address in (0x3D6, 0x3D3) and bool(control_active((address, data, bus)))
assert active > 20
finally:
native.tearDown()
def test_rejected_shadow_is_not_an_ack_and_withdraws_next_control():
owner, cc, cs = setup()
prime(owner, cc, cs)
bp = owner.bp_lat
now = max(bp.shadow_sent_ns, bp.control_sent_ns) + 72_000_000
payload = bp.shadow_return_payload
bp.observe_can([(now, [(0x3CA, payload, 0xC0)])], 0)
assert bp.host.steering_rejected
assert not bp.shadow_echo_pending
_, messages = owner.update(cc, cs, now)
controls = [m for m in messages if m[0] == 0x3D6]
assert controls and control_active(controls[0]) == 0
assert bp.host.controller.path_last == 0.
def test_unrelated_or_out_of_order_returns_do_not_refresh_shadow_time():
owner, cc, cs = setup()
prime(owner, cc, cs)
bp = owner.bp_lat
sent = bp.shadow_sent_ns
payload = bp.shadow_return_payload
bp.shadow_returned_ns = sent + 10_000_000
bp.observe_can([(sent, [(0x3CA, payload, 0x80)])], 0)
bp.observe_can([(sent + 20_000_000, [(0x3CA, bytes(8), 0x80)])], 0)
assert not bp.shadow_echo_pending
assert bp.announced_ns == sent
def test_permission_withdrawal_does_not_publish_active_even_with_due_shadow():
owner, cc, cs = setup()
prime(owner, cc, cs)
now = max(owner.bp_lat.shadow_sent_ns, owner.bp_lat.control_sent_ns) + 72_000_000
_, messages = owner.update(structs.CarControl().as_reader(), cs, now)
controls = [m for m in messages if m[0] == 0x3D6]
assert controls and control_active(controls[0]) == 0
assert owner.bp_lat.pending == Command()
def test_stale_transport_reset_preserves_continuous_manual_turn_pause():
owner, cc, cs = setup()
prime(owner, cc, cs)
host = owner.bp_lat.host
host.steering_pressed = True
host.steering_angle = 60.
for _ in range(70):
command = host.update(cc, cs)
assert not command.active
assert host.controller.human_timer >= 3.
now = max(owner.bp_lat.shadow_sent_ns, owner.bp_lat.control_sent_ns) + 72_000_000
owner.bp_lat.shadow_echo_pending = True
_, messages = owner.update(cc, cs, now)
assert not any(control_active(m) for m in messages if m[0] == 0x3D6)
assert host.controller.human_timer >= 3.
assert host.controller.press_last and host.controller.preturned
for t in range(now + 10_000_000, now + 500_000_000, 10_000_000):
_, messages = owner.update(cc, cs, t)
assert not any(control_active(m) for m in messages if m[0] == 0x3D6)
@@ -1,7 +0,0 @@
Copyright (c) 2018, Comma.ai, Inc.
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
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.
@@ -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
+16 -34
View File
@@ -1,20 +1,18 @@
from opendbc.car import structs
from opendbc.car.ford.values import CAR, FordFlags, FordSafetyFlags
from opendbc.car.ford.values import CAR, FORD_CANFD_CARS, FORD_CLASSIC_CARS, FordFlags, FordSafetyFlags
AOL_EXPERIENCE = 32
CLASSIC = frozenset((CAR.FORD_BRONCO_SPORT_MK1, CAR.FORD_ESCAPE_MK4, CAR.FORD_FOCUS_MK4,
CAR.FORD_MAVERICK_MK1, CAR.FORD_EXPLORER_MK6))
GENERIC_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))
MANUAL = int(FordSafetyFlags.MANUAL)
BASE_WORDS = frozenset((8, 9, 10, 11, 12, 13, 18, 19, 32, 33, 66, 67))
ANGLE = 128
ANGLE_BASE_WORDS = frozenset((18, 19, 32, 33, 66, 67))
AOL_WORDS = (BASE_WORDS | frozenset(word | ANGLE for word in ANGLE_BASE_WORDS))
AOL_WORDS |= frozenset(word | MANUAL for word in AOL_WORDS)
from opendbc.car.ford.pinion import ENABLE, SHIFT, PROFILES, base_word, configured
AOL_WORDS |= frozenset(word | ENABLE | index << SHIFT for index, words in PROFILES.items()
for word in tuple(AOL_WORDS) if (word & ~MANUAL) in words)
# bits 9-14 carry the BluePilot lateral library's startup bits (safety/modes/ford_param.h); bit 14 is its
# BluePilot lateral bit, set when the panda runs the library's steering checks
BP_PARAM_MASK = 0x7E00
BP_LATERAL = 0x4000
BASE_WORDS = frozenset((0, 1, 2, 3, 8, 9, 10, 11, 12, 13))
AOL_WORDS = BASE_WORDS | frozenset(word | MANUAL for word in BASE_WORDS)
def profile_word(word):
return int(word) & ~BP_PARAM_MASK
def aol_safety_config(cp):
@@ -35,23 +33,9 @@ def base_safety_param(cp):
manual = cp.transmissionType == structs.CarParams.TransmissionType.manual
if cp.transmissionType not in (structs.CarParams.TransmissionType.automatic, structs.CarParams.TransmissionType.manual):
return -1
word = int(owner.safetyParam)
normalized = base_word(word)
if normalized != word and not configured(cp):
return -1
word = normalized
word = profile_word(owner.safetyParam)
if (word & MANUAL and not manual) or (manual and cp.alternativeExperience == AOL_EXPERIENCE and not word & MANUAL):
return -1
if word & ANGLE:
if cp.alternativeExperience == 0:
from opendbc.bluepilot_lateral.hosts.starpilot import qualified as angle_qualified
if not angle_qualified(cp):
return -1
base = 18 if cp.carFingerprint == CAR.FORD_MUSTANG_MACH_E_MK1 else (66 if cp.carFingerprint in GENERIC_CANFD else 32)
return base | int(cp.openpilotLongitudinalControl)
if word not in AOL_WORDS:
return -1
word &= ~ANGLE
return word & ~MANUAL
@@ -61,12 +45,10 @@ def qualified(cp, *, marked_only=False):
cp.alternativeExperience not in ((32,) if marked_only else (0, 32))):
return False
flags = int(cp.flags) & ~int(FordFlags.HAS_BSM)
if cp.carFingerprint in CLASSIC and flags == 0:
base = 32
elif cp.carFingerprint == CAR.FORD_MUSTANG_MACH_E_MK1 and flags == int(FordFlags.CANFD):
base = 18
elif cp.carFingerprint in GENERIC_CANFD and flags == int(FordFlags.CANFD):
base = 66
if cp.carFingerprint in FORD_CLASSIC_CARS and flags == 0:
base = 0
elif cp.carFingerprint in FORD_CANFD_CARS and flags == int(FordFlags.CANFD):
base = 2
elif cp.carFingerprint == CAR.FORD_EDGE_MK2 and flags == int(FordFlags.NEW_PORT | FordFlags.ALT_STEER_ANGLE):
base = 8
elif cp.carFingerprint == CAR.FORD_MONDEO_MK5 and flags == int(FordFlags.NEW_PORT | FordFlags.CANFD):
+17 -140
View File
@@ -1,24 +1,12 @@
import math
import numpy as np
from opendbc.can import CANPacker
from opendbc.bluepilot_lateral import BluePilotLateral
from opendbc.bluepilot_lateral.hosts.starpilot import qualified as angle_qualified
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, base_safety_param
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, path_speed_window_supported, qualified as mache_qualified
from opendbc.car.ford.new_port_curvature import create_controller as create_new_port_curvature, bounded_command as new_port_bounded_command
from opendbc.car.ford.values import CarControllerParams, FordFlags, FordSafetyFlags, CAR
from opendbc.car.ford.aol import profile_word
from opendbc.car.ford.values import CarControllerParams, FordFlags, CAR
from opendbc.car.interfaces import CarControllerBase, V_CRUISE_MAX
LongCtrlState = structs.CarControl.Actuators.LongControlState
@@ -69,23 +57,10 @@ class CarController(CarControllerBase):
self.packer = CANPacker(dbc_names[Bus.pt])
self.CAN = fordcan.CanBus(CP)
self.bp_lat = BluePilotLateral(CP) if angle_qualified(CP) else None
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.mache_ui_owned = False
self.lat_ui_owned = False
self.hands_free_cluster_input = None
self.cluster_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.new_port_curvature = create_new_port_curvature(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
@@ -104,10 +79,7 @@ class CarController(CarControllerBase):
if self.hands_free_cluster_input is not None:
self.hands_free_cluster_input.update()
if not CC.latActive:
self.mache_ui_owned = False
if (self.mache_lateral is not None or self.classic_lateral is not None or self.new_port_curvature is not None) and self.manual_turn_inputs is not None:
self.manual_turn_inputs.update()
self.manual_turn_inputs.apply_blend_settings(self)
self.lat_ui_owned = False
actuators = CC.actuators
hud_control = CC.hudControl
@@ -143,92 +115,11 @@ class CarController(CarControllerBase):
### lateral control ###
# send steer msg at 20Hz
if self.bp_lat is not None:
# Publish a due shadow before its dependent actuator, not after a stale-lease withdrawal.
can_sends += self.bp_lat.step_lka(self.packer, self.CAN, now_nanos)
lateral_sends = self.bp_lat.step_lateral(CC, CS, self.packer, self.CAN, self.frame, now_nanos)
can_sends += lateral_sends
self.apply_curvature_last = self.bp_lat.apply_curvature_last
self.mache_ui_owned |= bool(lateral_sends and self.bp_lat.announced.active and self.bp_lat.pending.active)
elif (self.frame % CarControllerParams.STEER_STEP) == 0 and self.CP.flags & FordFlags.LKA_STEERING:
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,
getattr(CS, "bluepilot_speed_window", None) if path_speed_window_supported(self.CP) else None)
self.mache_lateral_demand = demanded
self.mache_ui_owned |= bool(lateral.active)
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,
self.classic_lateral._current_curvature(CS))
self.classic_lateral_demand = demanded
self.apply_curvature_last = lateral.curvature
if self.generic_canfd:
# Stay in the stock interface while inactive. Announce a mode change in
# the same batch, before the corresponding bounded steering command.
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)
self.mache_ui_owned |= 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 and self.new_port_curvature is not None:
inputs = self.manual_turn_inputs.lateral_snapshot(CS.out.vEgoRaw) if self.manual_turn_inputs is not None else None
self.new_port_curvature.set_inputs(*(inputs if inputs is not None else
(None, (), 0.2, self.manual_turn_inputs.enabled if self.manual_turn_inputs is not None else True)))
previous = self.new_port_curvature.curvature_last
demanded = self.new_port_curvature.update(CC, CS, actuators)
lateral = new_port_bounded_command(self.new_port_curvature, demanded, previous, CS.out.vEgoRaw,
self.new_port_curvature._current_curvature(CS))
self.new_port_curvature_demand = demanded
self.apply_curvature_last = lateral.curvature
if self.CP.flags & FordFlags.CANFD:
counter = (self.frame // CarControllerParams.STEER_STEP) % 0x10
can_sends.append(fordcan.create_lat_ctl2_msg(self.packer, self.CAN, int(lateral.active), 0., 0.,
-lateral.curvature, 0., counter))
else:
can_sends.append(fordcan.create_lat_ctl_msg(self.packer, self.CAN, lateral.active, 0., 0., -lateral.curvature, 0.))
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):
@@ -239,18 +130,14 @@ class CarController(CarControllerBase):
# 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
0., CC.latActive, CarControllerParams.STEER_STEP)
self.apply_curvature_last = apply_curvature
self.lat_ui_owned |= CC.latActive
if self.CP.flags & FordFlags.CANFD:
# TODO: extended mode
@@ -259,18 +146,15 @@ class CarController(CarControllerBase):
# 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
mode = 1 if CC.latActive 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.))
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.bp_lat is not None or (self.frame % CarControllerParams.LKA_STEP) == 0) and not any(
address == 0x3CA for address, _, _ in can_sends):
if self.bp_lat is not None:
can_sends += self.bp_lat.step_lka(self.packer, self.CAN, now_nanos)
elif self.CP.flags & FordFlags.LKA_STEERING:
if (self.frame % CarControllerParams.LKA_STEP) == 0:
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
@@ -294,15 +178,6 @@ class CarController(CarControllerBase):
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))
@@ -354,8 +229,10 @@ class CarController(CarControllerBase):
### ui ###
# Acquire the display after the first active replacement, then retain it while
# AOL stays active, including a manual-turn steering handoff (as on Dom).
aol_owned_ui = qualified_aol(self.CP, marked_only=True) and base_safety_param(self.CP) in (18, 19, 66, 67)
ui_enabled = CC.latActive and (not aol_owned_ui or self.mache_ui_owned)
aol_owned_ui = qualified_aol(self.CP, marked_only=True) and any(
s.safetyModel == structs.CarParams.SafetyModel.ford and (profile_word(s.safetyParam) & ~(1 | 256)) == 2
for s in self.CP.safetyConfigs)
ui_enabled = CC.latActive and (not aol_owned_ui or self.lat_ui_owned)
neutral_ui = (aol_owned_ui and self.CP.openpilotLongitudinalControl and not CC.latActive and not steer_alert and
CS.out.canValid and not CS.out.canTimeout and not CS.out.vehicleSensorsInvalid and
not CS.out.steerFaultTemporary and not CS.out.steerFaultPermanent and not CS.out.accFaulted and
-12
View File
@@ -6,7 +6,6 @@ from opendbc.can.dbc import DBC as CANDBC
from opendbc.car import Bus, create_button_events, structs
from opendbc.car.common.conversions import Conversions as CV
from opendbc.car.ford.fordcan import CanBus
from opendbc.car.ford.mache_lateral import path_speed_window_supported
from opendbc.car.ford.stock_cruise import FordStockCruiseButton
from opendbc.car.ford.values import CAR, DBC, CarControllerParams, FordFlags
from opendbc.car.interfaces import CarStateBase
@@ -41,9 +40,6 @@ class CarState(CarStateBase):
self.lkas_available = False
self.lkas_available_ts_nanos = 0
self.lateral_motion_control = None
self.bluepilot_speed_samples = [0] * 6 if (CP.safetyConfigs and CP.safetyConfigs[-1].safetyParam & 128 or
path_speed_window_supported(CP)) else None
self.bluepilot_speed_window = None
def get_car_gps(self):
return self.car_gps_tracker.get()
@@ -67,14 +63,6 @@ class CarState(CarStateBase):
# car speed
ret.vEgoRaw = cp.vl["BrakeSysFeatures"]["Veh_V_ActlBrk"] * CV.KPH_TO_MS
if self.bluepilot_speed_samples is not None:
# Match Panda's six raw ABS speed samples, including its millimetre/second rounding.
for value in cp.vl_all["BrakeSysFeatures"]["Veh_V_ActlBrk"]:
self.bluepilot_speed_samples.append(math.floor(value * CV.KPH_TO_MS * 1000 + .5))
self.bluepilot_speed_samples = self.bluepilot_speed_samples[-6:]
samples = self.bluepilot_speed_samples
step = max(abs(b - a) for a, b in zip(samples, samples[1:], strict=False)) / 1000
self.bluepilot_speed_window = (min(samples) / 1000, max(samples) / 1000, step)
ret.vEgo, ret.aEgo = self.update_speed_kf(ret.vEgoRaw)
ret.yawRate = cp.vl["Yaw_Data_FD1"]["VehYaw_W_Actl"]
ret.standstill = cp.vl["DesiredTorqBrk"]["VehStop_D_Stat"] == 1
@@ -1,51 +0,0 @@
"""Shared source-derived classic Ford family; model transport stays outside opendbc.
The reused extended strategy derives from BluePilot Ford work by Alan Polk and
contributors. Its original attribution and licenses remain in lateral_strategy.py,
CREDITS.md and THIRD_PARTY_NOTICES.md; this module only selects admitted callers.
"""
from opendbc.car import structs
from opendbc.car.ford.aol import base_safety_param
from opendbc.car.ford.explorer_lateral import ExplorerLateralController, bounded_command as bounded_command, qualified as explorer_qualified
from opendbc.car.ford.lateral_strategy import FordLateralController
from opendbc.car.ford.values import CAR, FordFlags, FordSafetyFlags
GENERIC_CLASSIC_CARS = frozenset({CAR.FORD_BRONCO_SPORT_MK1, CAR.FORD_ESCAPE_MK4,
CAR.FORD_FOCUS_MK4, CAR.FORD_MAVERICK_MK1})
CLASSIC_EXTENDED_CARS = GENERIC_CLASSIC_CARS | {CAR.FORD_EXPLORER_MK6}
def qualified(cp):
if cp.carFingerprint == CAR.FORD_EXPLORER_MK6:
return explorer_qualified(cp)
if (cp.brand != "ford" or cp.carFingerprint not in GENERIC_CLASSIC_CARS or
cp.flags & ~int(FordFlags.HAS_BSM) or cp.passive or cp.dashcamOnly or cp.notCar or
cp.alternativeExperience not in (0, 32) or len(cp.safetyConfigs) not in (1, 2)):
return False
if len(cp.safetyConfigs) == 2 and (cp.safetyConfigs[0].safetyModel != structs.CarParams.SafetyModel.noOutput or
cp.safetyConfigs[0].safetyParam != 0):
return False
safety = cp.safetyConfigs[-1]
expected = int(FordSafetyFlags.CLASSIC_EXTENDED | (FordSafetyFlags.LONG_CONTROL if cp.openpilotLongitudinalControl else 0))
return safety.safetyModel == structs.CarParams.SafetyModel.ford and base_safety_param(cp) == expected
class GenericClassicLateralController(FordLateralController):
"""Original generic liveDelay preview; no Explorer fixed-delay or Mach-E latch."""
def __init__(self, cp):
if not qualified(cp) or cp.carFingerprint not in GENERIC_CLASSIC_CARS:
raise ValueError("Generic classic controller requires the exact admitted family")
super().__init__(cp)
self.manual_turn_detected = False
def _manual_turn(self, CC, CS, desired, driver_assisting):
self.manual_turn_detected = super()._manual_turn(CC, CS, desired, driver_assisting)
return self.manual_turn_detected
def create_controller(cp):
if not qualified(cp):
return None
return ExplorerLateralController(cp) if cp.carFingerprint == CAR.FORD_EXPLORER_MK6 else GenericClassicLateralController(cp)
@@ -1,14 +0,0 @@
"""Mach-E default model preview, before the upstream actuator limits."""
import math
def blend_curvature(desired: float, predicted: float, current: float) -> float:
if not all(math.isfinite(value) for value in (desired, predicted, current)):
return desired
blend = 0.4
if desired * predicted <= 0.0:
blend = 0.0
elif current * predicted > 0.0 and abs(current) > abs(desired) and abs(predicted) > abs(desired):
blend *= abs(desired) / abs(predicted)
return predicted * blend + desired * (1.0 - blend)
@@ -1,91 +0,0 @@
"""Explorer/Aviator source-derived demand and exact native profile contract.
The inherited strategy is adapted from BluePilot Ford work. Attribution and
license notices are preserved in lateral_strategy.py, CREDITS.md and
THIRD_PARTY_NOTICES.md.
"""
from dataclasses import replace
from typing import Protocol
import numpy as np
from opendbc.car import structs
from opendbc.car.ford.aol import base_safety_param
from opendbc.car.ford.lateral_strategy import FordLateralController, FordLateralResult, FORD_CURVATURE_LIMITS, STEER_DT
from opendbc.car.ford.values import CAR, CarControllerParams, FordFlags, FordSafetyFlags
class ExplorerLateralController(FordLateralController):
"""Exact classic Explorer family, including the Aviator identity alias."""
def __init__(self, CP):
if CP.carFingerprint != CAR.FORD_EXPLORER_MK6 or CP.flags & ~int(FordFlags.HAS_BSM):
raise ValueError("Explorer demand strategy requires the exact classic family")
super().__init__(CP)
self.manual_turn_detected = False
def _manual_turn(self, CC, CS, desired: float, driver_assisting: bool = False) -> bool:
self.manual_turn_detected = super()._manual_turn(CC, CS, desired, driver_assisting)
return self.manual_turn_detected
def _curvature_lookahead(self) -> float:
# Original reached family override precedes liveDelay inspection.
return 0.20
def qualified(CP) -> bool:
if (CP.brand != "ford" or CP.carFingerprint != CAR.FORD_EXPLORER_MK6 or
CP.flags & ~int(FordFlags.HAS_BSM) or CP.passive or CP.dashcamOnly or CP.notCar or
CP.alternativeExperience not in (0, 32) or len(CP.safetyConfigs) not in (1, 2)):
return False
if len(CP.safetyConfigs) == 2 and (CP.safetyConfigs[0].safetyModel != structs.CarParams.SafetyModel.noOutput or
CP.safetyConfigs[0].safetyParam != 0):
return False
safety = CP.safetyConfigs[-1]
expected = int(FordSafetyFlags.EXPLORER_EXTENDED | (FordSafetyFlags.LONG_CONTROL if CP.openpilotLongitudinalControl else 0))
return safety.safetyModel == structs.CarParams.SafetyModel.ford and base_safety_param(CP) == expected
class CurvatureOwner(Protocol):
manual_turn_detected: bool
curvature_last: float
path_angle_last: float
def bounded_command(owner: CurvatureOwner, demanded: FordLateralResult,
previous: float, speed: float, measured: float, *, absolute_cap: float | None = None) -> FordLateralResult:
"""Keep classic demand inside the modern common ISO/jerk envelope."""
limits = CarControllerParams.CURVATURE_LIMITS
cap = min(limits.CURVATURE_MAX, limits.MAX_LATERAL_ACCEL / max(speed, 1.0) ** 2)
if absolute_cap is not None:
cap = min(cap, absolute_cap)
modern_delta = limits.MAX_LATERAL_JERK / max(speed, 1.0) ** 2 * STEER_DT
source_up = FORD_CURVATURE_LIMITS.ANGLE_RATE_LIMIT_UP
source_down = FORD_CURVATURE_LIMITS.ANGLE_RATE_LIMIT_DOWN
rise = float(np.interp(speed, source_up[0], source_up[1]))
fall = float(np.interp(speed, source_down[0], source_down[1]))
# Same sign plus increasing magnitude uses rise; unwind/opposite uses fall.
source_low = previous - (fall if previous > 0.0 else rise)
source_high = previous + (fall if previous < 0.0 else rise)
low = max(-cap, previous - modern_delta, source_low)
high = min(cap, previous + modern_delta, source_high)
# Match modern recovery toward the actual measured-curvature error window.
# Outside the window, require directional progress without jumping into it.
if speed > 10.0:
error_low = measured - CarControllerParams.CURVATURE_ERROR
error_high = measured + CarControllerParams.CURVATURE_ERROR
if previous < error_low:
low = max(low, min(previous + modern_delta, error_low, cap))
elif previous > error_high:
high = min(high, max(previous - modern_delta, error_high, -cap))
else:
low = max(low, error_low)
high = min(high, error_high)
if not demanded.active or owner.manual_turn_detected or low > high:
command = FordLateralResult()
else:
command = replace(demanded, curvature=float(np.clip(demanded.curvature, low, high)), path_angle=0.0)
owner.curvature_last = command.curvature
owner.path_angle_last = 0.0
return command
@@ -1,25 +0,0 @@
"""Source-derived classic Ford extended lateral constructor.
Adapted from BluePilot fordcan_ext.py; see CREDITS.md and
THIRD_PARTY_NOTICES.md. This codec does not grant native authority.
"""
from opendbc.car.ford.fordcan import CanBus
def create_extended_classic_lat_ctl_msg(packer, CAN: CanBus, active: bool, ramp_type: int, precision_type: int,
curvature: float, curvature_rate: float):
# 13-bit DBC endpoint: positive .001024 would wrap to negative .001024.
curvature_rate = max(-0.001024, min(curvature_rate, 0.00102375))
values = {
"LatCtlRng_L_Max": 0,
"HandsOffCnfm_B_Rq": 0,
"LatCtl_D_Rq": 1 if active else 0,
"LatCtlRampType_D_Rq": ramp_type,
"LatCtlPrecision_D_Rq": precision_type,
"LatCtlPathOffst_L_Actl": 0.0,
"LatCtlPath_An_Actl": 0.0,
"LatCtlCurv_NoRate_Actl": curvature_rate,
"LatCtlCurv_No_Actl": curvature,
}
return packer.make_can_msg("LateralMotionControl", CAN.main, values)
@@ -1,9 +0,0 @@
"""Generic extended CAN FD wire endpoint, using the attributed shared codec."""
from opendbc.car.ford.mache_can import create_lat_ctl2_msg
def create_extended_canfd_msg(packer, CAN, active, ramp, precision, curvature, rate, counter):
# Generic original lower rate negates to +.001024, outside the 11-bit field.
# Clamp the physical wire endpoint before packing instead of wrapping sign.
rate = max(-.001024, min(.001023, rate))
return create_lat_ctl2_msg(packer, CAN, int(active), ramp, precision, curvature, rate, counter, 0.)
@@ -1,46 +0,0 @@
"""Exact generic CAN FD caller; no Mach-E error, path assist, or latch.
The reused BluePilot-derived strategy retains its original attribution in
lateral_strategy.py, CREDITS.md and THIRD_PARTY_NOTICES.md.
"""
from opendbc.car import structs
from opendbc.car.ford.aol import base_safety_param
from opendbc.car.ford.explorer_lateral import bounded_command as shared_bound
from opendbc.car.ford.lateral_strategy import FordLateralController
from opendbc.car.ford.values import CAR, FordFlags, FordSafetyFlags
GENERIC_CANFD_CARS = 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})
SOURCE_ACCEL_CAP = 3.0 - 9.81 * .06
def qualified(cp):
if (cp.brand != "ford" or cp.carFingerprint not in GENERIC_CANFD_CARS or
not cp.flags & FordFlags.CANFD or cp.flags & ~int(FordFlags.CANFD | FordFlags.HAS_BSM) or
cp.passive or cp.dashcamOnly or cp.notCar or cp.alternativeExperience not in (0, 32) or
len(cp.safetyConfigs) not in (1, 2)):
return False
if len(cp.safetyConfigs) == 2 and (cp.safetyConfigs[0].safetyModel != structs.CarParams.SafetyModel.noOutput or
cp.safetyConfigs[0].safetyParam != 0):
return False
s = cp.safetyConfigs[-1]
expected = int(FordSafetyFlags.GENERIC_CANFD_EXTENDED | FordSafetyFlags.CANFD |
(FordSafetyFlags.LONG_CONTROL if cp.openpilotLongitudinalControl else 0))
return s.safetyModel == structs.CarParams.SafetyModel.ford and base_safety_param(cp) == expected
class GenericCanfdLateralController(FordLateralController):
def __init__(self, cp):
if not qualified(cp):
raise ValueError("Generic CAN FD controller requires its exact source-owned profile")
super().__init__(cp)
self.manual_turn_detected = False
def _manual_turn(self, CC, CS, desired, driver_assisting):
self.manual_turn_detected = super()._manual_turn(CC, CS, desired, driver_assisting)
return self.manual_turn_detected
def bounded_command(owner, demanded, previous, speed, measured):
return shared_bound(owner, demanded, previous, speed, measured,
absolute_cap=SOURCE_ACCEL_CAP / max(speed, 1.) ** 2)
+3 -43
View File
@@ -4,11 +4,9 @@ from opendbc.car.carlog import carlog
from opendbc.car.common.conversions import Conversions as CV
from opendbc.car.ford.carcontroller import CarController
from opendbc.car.ford.carstate import CarState
from opendbc.car.ford.classic_lateral import CLASSIC_EXTENDED_CARS
from opendbc.car.ford.generic_canfd_lateral import GENERIC_CANFD_CARS
from opendbc.car.ford.fordcan import CanBus
from opendbc.car.ford.radar_interface import RadarInterface
from opendbc.car.ford.values import CarControllerParams, DBC, Ecu, FordFlags, RADAR, FordSafetyFlags, CAR
from opendbc.car.ford.values import CarControllerParams, DBC, Ecu, FordFlags, RADAR, FordSafetyFlags, CAR, FORD_CANFD_CARS, FORD_CLASSIC_CARS
from opendbc.car.interfaces import CarInterfaceBase
TransmissionType = structs.CarParams.TransmissionType
@@ -21,11 +19,6 @@ class CarInterface(CarInterfaceBase):
DRIVABLE_GEARS = (structs.CarState.GearShifter.low, structs.CarState.GearShifter.manumatic)
def update(self, can_packets):
if self.CC is not None and self.CC.bp_lat is not None:
self.CC.bp_lat.observe_can(can_packets, self.CC.CAN.main)
return super().update(can_packets)
@staticmethod
def get_pid_accel_limits(CP, current_speed, cruise_speed):
# PCM doesn't allow acceleration near cruise_speed,
@@ -59,18 +52,9 @@ class CarInterface(CarInterfaceBase):
ret.safetyConfigs = cfgs
new_port = bool(ret.flags & FordFlags.NEW_PORT)
if candidate in GENERIC_CANFD_CARS:
if candidate in FORD_CANFD_CARS | FORD_CLASSIC_CARS:
# Explicit stock/OP-long selection on these families
ret.alphaLongitudinalAvailable = True
ret.steerActuatorDelay = 0.22
longitudinal_requested = bool(alpha_long)
elif candidate == CAR.FORD_MUSTANG_MACH_E_MK1:
ret.alphaLongitudinalAvailable = True
longitudinal_requested = bool(alpha_long)
elif candidate in CLASSIC_EXTENDED_CARS:
# Original shared classic ownership is explicit stock/OP-long selection.
# Actuator delay is live; modern LongControl already supplies the original P=0.
ret.alphaLongitudinalAvailable = True
ret.steerActuatorDelay = 0.22
longitudinal_requested = bool(alpha_long)
else:
ret.alphaLongitudinalAvailable = True if new_port else ret.radarUnavailable
@@ -126,28 +110,4 @@ class CarInterface(CarInterfaceBase):
ret.autoResumeSng = ret.minEnableSpeed == -1.
ret.centerToFront = ret.wheelbase * 0.44
classic_extended = (
candidate in CLASSIC_EXTENDED_CARS and not (ret.flags & ~int(FordFlags.HAS_BSM))
and not ret.dashcamOnly and ret.alternativeExperience == 0
and ret.safetyConfigs[-1].safetyParam in (0, 1)
)
if classic_extended:
ret.safetyConfigs[-1].safetyParam |= FordSafetyFlags.CLASSIC_EXTENDED.value
# The source-derived Mach-E owner has a separate native namespace. SecOC,
# unrelated static flags and alternative experiences retain no extension.
mach_e_extended = (
candidate == CAR.FORD_MUSTANG_MACH_E_MK1
and bool(ret.flags & FordFlags.CANFD)
and not (ret.flags & ~int(FordFlags.CANFD | FordFlags.HAS_BSM))
and not ret.dashcamOnly and ret.alternativeExperience == 0
and ret.safetyConfigs[-1].safetyParam in (2, 3)
)
if mach_e_extended:
ret.safetyConfigs[-1].safetyParam |= FordSafetyFlags.MACH_E_EXTENDED.value
if (candidate in GENERIC_CANFD_CARS and ret.flags & FordFlags.CANFD and
not ret.flags & ~int(FordFlags.CANFD | FordFlags.HAS_BSM) and
not ret.dashcamOnly and not ret.passive and not ret.notCar and ret.alternativeExperience == 0):
ret.safetyConfigs[-1].safetyParam |= FordSafetyFlags.GENERIC_CANFD_EXTENDED.value
return ret
@@ -1,758 +0,0 @@
"""Ford lateral-control extensions.
The extended curvature strategy, manual-turn detector, and related safety protocol are substantially
adapted from BluePilot's Ford work, principally by Alan Polk and additional contributors. The audited
bp-7.0 reference is e1d051d7ba270261b4455068bd68f1a58db15a4a; source attribution is retained in
CREDITS.md and THIRD_PARTY_NOTICES.md. StarPilot reorganized that work for its own architecture and has since
changed its tuning and lookahead behavior.
See CREDITS.md for feature-level authorship and upstream commits, and THIRD_PARTY_NOTICES.md for the
published upstream license notices. Upstream contributors do not maintain this adaptation.
"""
from collections import deque
from dataclasses import dataclass
import numpy as np
from opendbc.car import ACCELERATION_DUE_TO_GRAVITY, DT_CTRL, structs
from opendbc.car.ford.values import CAR, CarControllerParams, FordFlags, FordSafetyFlags
from opendbc.car.lateral import AngleSteeringLimits, ISO_LATERAL_ACCEL, apply_std_steer_angle_limits
# These rate-limit values descend from BluePilot's ``values_ext.py``, which carries the Haibin Wen
# and sunnypilot contributors copyright notice reproduced in THIRD_PARTY_NOTICES.md.
FORD_CURVATURE_LIMITS = AngleSteeringLimits(
0.02,
([5, 16, 25], [0.0025, 0.0012, 0.00008]),
([5, 16, 25], [0.0025, 0.0014, 0.00018]),
)
MAX_LATERAL_ACCEL = ISO_LATERAL_ACCEL - ACCELERATION_DUE_TO_GRAVITY * 0.06
STEER_DT = CarControllerParams.STEER_STEP * DT_CTRL
CURVATURE_LOOKAHEAD_MIN = 0.20
CURVATURE_LOOKAHEAD_MAX = 0.40
MACH_E_HIGH_SPEED_LOOKAHEAD_EXTRA = 0.40
MACH_E_HIGH_SPEED_LOOKAHEAD_START_SPEED = 16.0
MACH_E_HIGH_SPEED_LOOKAHEAD_FULL_SPEED = 23.0
MACH_E_TURN_IN_LOOKAHEAD_EXTRA = 0.80
MACH_E_LOW_SPEED_TURN_IN_LOOKAHEAD_EXTRA = 1.60
MACH_E_LOW_SPEED_TURN_IN_START_SPEED = 2.0
MACH_E_LOW_SPEED_TURN_IN_FULL_SPEED = 3.0
MACH_E_LOW_SPEED_TURN_IN_MAX_SPEED = 12.0
MACH_E_LOW_SPEED_TURN_IN_FADE_SPEED = 15.0
MACH_E_TURN_IN_MIN_CURVATURE = 0.002
MACH_E_TURN_IN_FULL_CURVATURE = 0.008
MACH_E_TURN_IN_LAG_CURVATURE = 0.006
MACH_E_TURN_IN_PREVIEW_HOLD_SECONDS = 0.10
MACH_E_TURN_IN_PREVIEW_HOLD_MIN_LAG = 0.0005
MACH_E_TURN_IN_RATE_MIN_SPEED = 3.0
MACH_E_TURN_IN_RATE_MIN_DEFICIT = 0.002
MACH_E_UNWIND_LOOKAHEAD_EXTRA = 0.80
MACH_E_UNWIND_FULL_LAG_CURVATURE = 0.0005
MACH_E_UNWIND_PREVIEW_LAG_CURVATURE = 0.002
MACH_E_UNWIND_PREVIEW_HOLD_SECONDS = 0.10
MACH_E_UNWIND_PREVIEW_RISE_TOLERANCE = 0.0002
MACH_E_UNWIND_RELEASE_GAIN = 0.40
MACH_E_DIRECTION_CHANGE_MIN_SPEED = 9.0
MACH_E_DIRECTION_CHANGE_LOOKAHEAD_RAMP_SPEED = 10.0
MACH_E_DIRECTION_CHANGE_LOOKAHEAD_FULL_SPEED = 12.0
MACH_E_DIRECTION_CHANGE_LOOKAHEAD_FADE_SPEED = 15.0
MACH_E_DIRECTION_CHANGE_LOOKAHEAD_EXTRA = 2.40
MACH_E_DIRECTION_CHANGE_MIN_PREVIEW_CURVATURE = 0.0005
MACH_E_DIRECTION_CHANGE_FULL_PREVIEW_CURVATURE = 0.002
MACH_E_DIRECTION_CHANGE_MIN_LAG_CURVATURE = 0.0008
MACH_E_DIRECTION_CHANGE_FULL_LAG_CURVATURE = 0.0015
MACH_E_DIRECTION_CHANGE_EARLY_MIN_LAG_CURVATURE = -0.001
MACH_E_DIRECTION_CHANGE_EARLY_FULL_LAG_CURVATURE = 0.0008
MACH_E_DIRECTION_CHANGE_EARLY_MIN_CURVATURE = 0.002
MACH_E_DIRECTION_CHANGE_EARLY_FULL_CURVATURE = 0.004
MACH_E_LOW_SPEED_DIRECTION_CHANGE_START_SPEED = 1.8
MACH_E_LOW_SPEED_DIRECTION_CHANGE_FULL_SPEED = 2.0
MACH_E_LOW_SPEED_DIRECTION_CHANGE_HOLD_SPEED = 2.8
MACH_E_LOW_SPEED_DIRECTION_CHANGE_FADE_SPEED = 3.5
MACH_E_LOW_SPEED_DIRECTION_CHANGE_MIN_CURVATURE = 0.0004
MACH_E_LOW_SPEED_DIRECTION_CHANGE_FULL_CURVATURE = 0.0006
MACH_E_LOW_SPEED_DIRECTION_CHANGE_MAX_CURVATURE = 0.0015
MACH_E_SHARP_DIRECTION_CHANGE_START_SPEED = 1.5
MACH_E_SHARP_DIRECTION_CHANGE_FULL_SPEED = 1.8
MACH_E_SHARP_DIRECTION_CHANGE_HOLD_SPEED = 3.0
MACH_E_SHARP_DIRECTION_CHANGE_FADE_SPEED = 4.0
MACH_E_SHARP_DIRECTION_CHANGE_MIN_CURVATURE = 0.0002
MACH_E_SHARP_DIRECTION_CHANGE_FULL_CURVATURE = 0.0005
MACH_E_SHARP_DIRECTION_CHANGE_MIN_PREVIEW_CURVATURE = 0.008
MACH_E_SHARP_DIRECTION_CHANGE_FULL_PREVIEW_CURVATURE = 0.012
MACH_E_SHARP_DIRECTION_CHANGE_MIN_ACCEL = 1.8
MACH_E_SHARP_DIRECTION_CHANGE_FULL_ACCEL = 2.2
MACH_E_SHARP_DIRECTION_CHANGE_MIN_LAG_CURVATURE = -0.0005
MACH_E_SHARP_DIRECTION_CHANGE_FULL_LAG_CURVATURE = 0.0008
MACH_E_CURVATURE_ERROR_MAX = 0.006
MACH_E_UNDERSTEER_ERROR_MIN_DEFICIT = 0.002
MACH_E_UNDERSTEER_ERROR_FULL_DEFICIT = 0.004
MACH_E_PATH_ANGLE_MAX = 0.16
MACH_E_PATH_ANGLE_STEP = 0.055
MACH_E_PATH_ANGLE_FADE_START_SPEED = 8.0
MACH_E_PATH_ANGLE_MAX_SPEED = 8.8
MACH_E_PATH_ANGLE_TRACKING_FACTOR = 0.75
MACH_E_PATH_ANGLE_DRIVER_COOLDOWN = 0.75
MACH_E_DRIVER_ASSIST_MIN_SPEED = 2.0
MACH_E_DRIVER_ASSIST_MAX_SPEED = 15.0
MACH_E_DRIVER_ASSIST_MAX_TORQUE = 3.5
FORD_CURVATURE_LOOKAHEAD = {
CAR.FORD_EXPLORER_MK6: 0.20,
}
FORD_CONSERVATIVE_PREVIEW_CARS = frozenset({
CAR.FORD_MUSTANG_MACH_E_MK1,
})
FORD_SHARP_DIRECTION_CHANGE_CARS = frozenset({
CAR.FORD_MUSTANG_MACH_E_MK1,
})
FORD_MANUAL_TURN_LATCH_CARS = frozenset({
CAR.FORD_MUSTANG_MACH_E_MK1,
})
MANUAL_TURN_ENTRY_ANGLE_DEG = 12.0
MANUAL_TURN_RELEASE_ANGLE_DEG = 12.0
MANUAL_TURN_RECOVERY_SECONDS = 0.25
@dataclass(frozen=True)
class FordLateralResult:
curvature: float = 0.0
curvature_rate: float = 0.0
path_angle: float = 0.0
ramp_type: int = 0
precision_type: int = 1
active: bool = False
# Adapted from BluePilot HumanTurnDetector (Alan Polk, 97867c1eb57b7472f6fc3de62f0fef576e5a5497).
class HumanTurnDetector:
ANGLE_DEG = 45.0
HOLD_SECONDS = 1.5
PRETURNED_HOLD_SECONDS = 3.0
def __init__(self):
self.timer = 0.0
self.active = False
self._pressed_last = False
self._press_started_preturned = False
def update(self, enabled: bool, steering_pressed: bool, steering_angle_deg: float) -> bool:
if steering_pressed and not self._pressed_last:
self._press_started_preturned = abs(steering_angle_deg) > self.ANGLE_DEG
self._pressed_last = steering_pressed
if enabled and steering_pressed and abs(steering_angle_deg) > self.ANGLE_DEG:
self.timer += STEER_DT
else:
self.timer = 0.0
hold_time = self.PRETURNED_HOLD_SECONDS if self._press_started_preturned else self.HOLD_SECONDS
self.active = self.timer + 1e-9 >= hold_time
return self.active
def reset(self):
self.timer = 0.0
self.active = False
self._pressed_last = False
self._press_started_preturned = False
class FordLateralController:
def __init__(self, CP):
self.CP = CP
from opendbc.car.ford.pinion import Measurement
self.measurement = Measurement(CP)
self.model = None
self.time_indices = ()
self.lookahead = CURVATURE_LOOKAHEAD_MIN
self.human_turn_enabled = True
self.curvature_blend_low = 0.4
self.curvature_blend_high = 0.4
self.curvature_lane_change_factor = 0.85
self.human_turn = HumanTurnDetector()
self.manual_turn_latched = False
self.manual_turn_recovery_timer = 0.0
self.manual_turn_direction = 0.0
self.curvature_samples = deque(maxlen=max(2, round(0.3 / STEER_DT)))
self.curvature_last = 0.0
self.path_angle_last = 0.0
self.path_angle_driver_cooldown = 0.0
self.desired_curvature_last = 0.0
self.turn_in_preview_hold_timer = 0.0
self.unwind_preview_hold_timer = 0.0
self.assist_engagement_retained = False
def set_assist_permission(self, panda_states, healthy: bool):
ford_pandas = [p for p in panda_states if p.safetyModel == structs.CarParams.SafetyModel.ford] if healthy else []
assist_flags = FordSafetyFlags.CANFD | FordSafetyFlags.MACH_E_EXTENDED
self.assist_engagement_retained = bool(ford_pandas) and all(
p.controlsAllowed and p.safetyParam & assist_flags == assist_flags and
not p.safetyParam & FordSafetyFlags.LKA_STEERING for p in ford_pandas
)
def _path_angle_assist_permitted(self, CC, CS) -> bool:
if not CC.enabled or CS.out.brakePressed:
return False
return not CS.out.gasPressed or (CS.out.cruiseState.enabled and self.assist_engagement_retained)
def set_blend_settings(self, low: float, high: float, lane_change: float):
self.curvature_blend_low = float(np.clip(low if np.isfinite(low) else 0.4, 0.0, 1.0))
self.curvature_blend_high = float(np.clip(high if np.isfinite(high) else 0.4, 0.0, 1.0))
self.curvature_lane_change_factor = float(np.clip(lane_change if np.isfinite(lane_change) else 0.85, 0.5, 1.25))
def set_inputs(self, model, time_indices, lookahead: float, human_turn_enabled: bool):
self.model = model
self.time_indices = time_indices
self.lookahead = float(np.clip(lookahead, CURVATURE_LOOKAHEAD_MIN, CURVATURE_LOOKAHEAD_MAX))
self.human_turn_enabled = human_turn_enabled
def _predicted_curvature(self, v_ego: float, lookup_time: float) -> float:
if self.model is None or len(self.model.orientationRate.z) < 17:
return 0.0
curvatures = np.asarray(self.model.orientationRate.z) / max(v_ego, 0.01)
return float(np.interp(lookup_time, self.time_indices, curvatures))
def _curvature_lookahead(self) -> float:
return self.lookahead
def _lane_change(self) -> tuple[bool, int]:
if self.model is None:
return False, 0
state = int(getattr(self.model.meta.laneChangeState, "raw", self.model.meta.laneChangeState))
direction = int(getattr(self.model.meta.laneChangeDirection, "raw", self.model.meta.laneChangeDirection))
return state in (1, 2, 3), direction
def _high_speed_lookahead_extra(self, v_ego: float, steering_pressed: bool, lane_change: bool) -> float:
if (self.CP.carFingerprint != CAR.FORD_MUSTANG_MACH_E_MK1 or not self.CP.flags & FordFlags.CANFD or
steering_pressed or lane_change):
return 0.0
return MACH_E_HIGH_SPEED_LOOKAHEAD_EXTRA * float(np.interp(
v_ego, [MACH_E_HIGH_SPEED_LOOKAHEAD_START_SPEED, MACH_E_HIGH_SPEED_LOOKAHEAD_FULL_SPEED], [0.0, 1.0]))
def _current_curvature(self, CS) -> float:
return self.measurement.curvature(CS.out)
def _driver_assisting_curve(self, CS, desired: float) -> bool:
v_ego = float(CS.out.vEgoRaw)
if (self.CP.carFingerprint != CAR.FORD_MUSTANG_MACH_E_MK1 or not self.CP.flags & FordFlags.CANFD or
not CS.out.steeringPressed or not MACH_E_DRIVER_ASSIST_MIN_SPEED <= v_ego < MACH_E_DRIVER_ASSIST_MAX_SPEED or
self._lane_change()[0] or abs(desired) < MACH_E_TURN_IN_MIN_CURVATURE or
desired * CS.out.steeringTorque >= 0.0 or abs(CS.out.steeringTorque) > MACH_E_DRIVER_ASSIST_MAX_TORQUE):
return False
current = self._current_curvature(CS)
preview = self._predicted_curvature(v_ego, self._curvature_lookahead() + MACH_E_TURN_IN_LOOKAHEAD_EXTRA)
return bool(desired * current > 0.0 and desired * preview > 0.0 and
abs(preview) >= MACH_E_TURN_IN_FULL_CURVATURE and
np.sign(desired) * (current - desired) <= CarControllerParams.CURVATURE_ERROR)
def _curvature_error_limit(self, requested: float, desired: float, current: float, v_ego: float,
steering_pressed: bool, lane_change: bool, predicted: float = 0.0) -> float:
base = CarControllerParams.CURVATURE_ERROR
if (self.CP.carFingerprint != CAR.FORD_MUSTANG_MACH_E_MK1 or not self.CP.flags & FordFlags.CANFD or
steering_pressed or lane_change):
return base
speed_weight = float(np.interp(v_ego, [8.0, 9.0, 14.0, 16.0], [0.0, 1.0, 1.0, 0.0]))
deficit_weight = 0.0
planned_curve = abs(desired) >= 0.003 or (abs(requested) >= 0.003 and requested * predicted > 0.0)
if requested * desired > 0.0 and planned_curve:
deficit = np.sign(desired) * (requested - current)
deficit_weight = float(np.interp(
deficit, [MACH_E_UNDERSTEER_ERROR_MIN_DEFICIT, MACH_E_UNDERSTEER_ERROR_FULL_DEFICIT], [0.0, 1.0]))
if current * desired < 0.0:
predicted = self._predicted_curvature(v_ego, self._curvature_lookahead() + MACH_E_TURN_IN_LOOKAHEAD_EXTRA)
reversal_weight = 0.0
if current * predicted < 0.0 and current * (requested - current) < 0.0:
reversal_weight = float(np.interp(
abs(predicted),
[MACH_E_DIRECTION_CHANGE_MIN_PREVIEW_CURVATURE, MACH_E_DIRECTION_CHANGE_FULL_PREVIEW_CURVATURE],
[0.0, 1.0],
))
unwind_weight = 0.0
if (self.model is not None and len(self.model.orientationRate.z) >= 17 and
requested * current >= 0.0 and desired * current >= 0.0 and abs(desired) < abs(current)):
unwind_deficit = np.sign(current) * (current - requested)
preview_deficit = np.sign(current) * (current - predicted)
unwind_weight = float(np.interp(
min(unwind_deficit, preview_deficit),
[MACH_E_UNDERSTEER_ERROR_MIN_DEFICIT, MACH_E_UNDERSTEER_ERROR_FULL_DEFICIT],
[0.0, 1.0],
))
return base + (MACH_E_CURVATURE_ERROR_MAX - base) * speed_weight * max(deficit_weight, reversal_weight, unwind_weight)
def _path_angle_assist(self, requested: float, desired: float, applied: float, current: float, v_ego: float,
steering_pressed: bool, lane_change: bool) -> float:
if steering_pressed:
self.path_angle_driver_cooldown = MACH_E_PATH_ANGLE_DRIVER_COOLDOWN
else:
self.path_angle_driver_cooldown = max(0.0, self.path_angle_driver_cooldown - STEER_DT)
target = 0.0
if (self.CP.carFingerprint == CAR.FORD_MUSTANG_MACH_E_MK1 and self.CP.flags & FordFlags.CANFD and
not steering_pressed and self.path_angle_driver_cooldown == 0.0 and not lane_change and
3.0 <= v_ego < MACH_E_PATH_ANGLE_MAX_SPEED and
requested * desired > 0.0 and requested * applied > 0.0 and
abs(requested) > 0.0198 and abs(desired) > MACH_E_TURN_IN_FULL_CURVATURE and abs(applied) >= 0.0195 and
np.sign(desired) * (desired - current) > 0.002):
max_curvature = MAX_LATERAL_ACCEL / v_ego ** 2
residual = max(0.0, min(max(abs(requested), abs(desired)), max_curvature) - abs(applied))
tracking_deficit = max(0.0, np.sign(applied) * (applied - current))
acceleration_headroom = max(0.0, (max_curvature - abs(applied)) * v_ego)
speed_weight = float(np.interp(
v_ego, [MACH_E_PATH_ANGLE_FADE_START_SPEED, MACH_E_PATH_ANGLE_MAX_SPEED], [1.0, 0.0]))
target = float(np.sign(applied) * min(
(residual + MACH_E_PATH_ANGLE_TRACKING_FACTOR * tracking_deficit) * v_ego,
acceleration_headroom, MACH_E_PATH_ANGLE_MAX) * speed_weight)
if target == 0.0 or target * self.path_angle_last < 0.0:
self.path_angle_last = 0.0
else:
self.path_angle_last = float(np.clip(
target, self.path_angle_last - MACH_E_PATH_ANGLE_STEP, self.path_angle_last + MACH_E_PATH_ANGLE_STEP))
return self.path_angle_last
def _blend_and_scale(self, desired: float, predicted: float, v_ego: float, current: float = 0.0,
allow_opposite_preview: bool = False) -> tuple[float, int]:
blend = float(np.interp(abs(desired), [0.0, 0.001], [self.curvature_blend_low, self.curvature_blend_high]))
if self.CP.carFingerprint in FORD_CONSERVATIVE_PREVIEW_CARS:
if desired * predicted <= 0.0 and not allow_opposite_preview:
blend = 0.0
elif current * predicted > 0.0 and abs(current) > abs(desired) and abs(predicted) > abs(desired):
blend *= abs(desired) / abs(predicted)
requested = predicted * blend + desired * (1.0 - blend)
lane_change, direction = self._lane_change()
precision = 1
if lane_change:
factor = float(np.interp(v_ego, [4.4, 40.23], [0.95, self.curvature_lane_change_factor]))
if (direction == 1 and requested < 0.0) or (direction == 2 and requested > 0.0):
requested *= factor
precision = 0
return requested, precision
def _unwind_preview(self, desired: float, predicted: float, current: float, v_ego: float,
allow_plateau: bool = False) -> float:
if self.CP.carFingerprint != CAR.FORD_MUSTANG_MACH_E_MK1:
return predicted
if desired * current <= 0.0 or desired * predicted <= 0.0 or desired * self.desired_curvature_last <= 0.0:
return predicted
if abs(desired) >= abs(self.desired_curvature_last) and not allow_plateau:
return predicted
preview = self._predicted_curvature(v_ego, self._curvature_lookahead() + MACH_E_UNWIND_LOOKAHEAD_EXTRA)
if desired * preview <= 0.0 or abs(preview) >= min(abs(desired), abs(predicted), abs(current)):
return predicted
speed_weight = float(np.interp(v_ego, [5.0, 7.0, 12.0, 15.0], [0.0, 1.0, 1.0, 0.0]))
curvature_weight = float(np.interp(abs(desired), [0.002, 0.008], [0.0, 1.0]))
lag_weight = float(np.interp(abs(current) - abs(desired), [0.0, MACH_E_UNWIND_FULL_LAG_CURVATURE], [0.0, 1.0]))
preview_lag_weight = float(np.interp(
abs(current) - abs(preview), [0.0, MACH_E_UNWIND_PREVIEW_LAG_CURVATURE], [0.0, 1.0]))
lag_weight = max(lag_weight, preview_lag_weight)
return predicted + speed_weight * curvature_weight * lag_weight * (preview - predicted)
def _turn_in_preview_plateau(self, desired: float, current: float, v_ego: float,
steering_pressed: bool, lane_change: bool) -> float:
if (self.CP.carFingerprint != CAR.FORD_MUSTANG_MACH_E_MK1 or not self.CP.flags & FordFlags.CANFD or
not MACH_E_LOW_SPEED_TURN_IN_START_SPEED <= v_ego < MACH_E_LOW_SPEED_TURN_IN_FADE_SPEED or
steering_pressed or lane_change or desired * self.desired_curvature_last < 0.0 or
abs(desired) < abs(self.desired_curvature_last)):
self.turn_in_preview_hold_timer = 0.0
return 0.0
if abs(desired) > abs(self.desired_curvature_last):
self.turn_in_preview_hold_timer = MACH_E_TURN_IN_PREVIEW_HOLD_SECONDS
return 0.0
self.turn_in_preview_hold_timer = max(0.0, self.turn_in_preview_hold_timer - STEER_DT)
if (self.turn_in_preview_hold_timer <= 1e-9 or
np.sign(desired) * (desired - current) <= MACH_E_TURN_IN_PREVIEW_HOLD_MIN_LAG):
return 0.0
return float(np.interp(v_ego, [2.0, 3.0, 14.0, 15.0], [0.0, 1.0, 1.0, 0.0]))
def _unwind_preview_plateau(self, desired: float, v_ego: float, steering_pressed: bool, lane_change: bool) -> bool:
if (self.CP.carFingerprint != CAR.FORD_MUSTANG_MACH_E_MK1 or not self.CP.flags & FordFlags.CANFD or
not 5.0 < v_ego < 15.0 or steering_pressed or lane_change or
desired * self.desired_curvature_last <= 0.0 or
abs(desired) - abs(self.desired_curvature_last) > MACH_E_UNWIND_PREVIEW_RISE_TOLERANCE):
self.unwind_preview_hold_timer = 0.0
return False
if abs(desired) < abs(self.desired_curvature_last):
self.unwind_preview_hold_timer = MACH_E_UNWIND_PREVIEW_HOLD_SECONDS
return False
self.unwind_preview_hold_timer = max(0.0, self.unwind_preview_hold_timer - STEER_DT)
return self.unwind_preview_hold_timer > 1e-9
def _unwind_request(self, desired: float, predicted: float, command_predicted: float, requested: float) -> float:
delta = command_predicted - predicted
if (self.CP.carFingerprint != CAR.FORD_MUSTANG_MACH_E_MK1 or not self.CP.flags & FordFlags.CANFD or
not np.isfinite([desired, predicted, command_predicted, requested]).all() or
desired * command_predicted <= 0.0 or desired * delta >= 0.0):
return requested
release_weight = float(np.interp(abs(delta), [0.002, 0.006], [0.0, 1.0]))
blend = float(np.interp(abs(desired), [0.0, 0.001], [self.curvature_blend_low, self.curvature_blend_high]))
preview_authority = float(np.clip(blend / 0.4, 0.0, 1.0))
return float(np.clip(requested + MACH_E_UNWIND_RELEASE_GAIN * preview_authority * release_weight * delta,
min(desired, command_predicted), max(desired, command_predicted)))
def _reversal_followthrough_preview(self, desired: float, predicted: float, current: float, v_ego: float,
steering_pressed: bool, lane_change: bool) -> float:
if (self.CP.carFingerprint != CAR.FORD_MUSTANG_MACH_E_MK1 or not self.CP.flags & FordFlags.CANFD or
steering_pressed or lane_change or desired * current >= 0.0):
return predicted
speed_weight = float(np.interp(v_ego, [8.0, 9.0, 14.0, 16.0], [0.0, 1.0, 1.0, 0.0]))
if speed_weight == 0.0:
return predicted
preview = self._predicted_curvature(v_ego, self._curvature_lookahead() + MACH_E_TURN_IN_LOOKAHEAD_EXTRA)
if not np.isfinite(preview) or desired * preview <= 0.0 or np.sign(desired) * (preview - predicted) <= 0.0:
return predicted
preview_weight = float(np.interp(
abs(preview), [MACH_E_DIRECTION_CHANGE_MIN_PREVIEW_CURVATURE, MACH_E_DIRECTION_CHANGE_FULL_PREVIEW_CURVATURE],
[0.0, 1.0]))
lag_weight = float(np.interp(
abs(current), [MACH_E_DIRECTION_CHANGE_MIN_LAG_CURVATURE, MACH_E_DIRECTION_CHANGE_FULL_LAG_CURVATURE],
[0.0, 1.0]))
return predicted + speed_weight * preview_weight * lag_weight * (preview - predicted)
def _turn_in_preview_weight(self, desired: float, preview: float, current: float,
allow_plateau: float = 0.0) -> float:
if self.CP.carFingerprint not in FORD_CONSERVATIVE_PREVIEW_CARS:
return 0.0
if desired * preview <= 0.0 or desired * self.desired_curvature_last < 0.0:
return 0.0
if (abs(desired) < abs(self.desired_curvature_last) or
(abs(desired) == abs(self.desired_curvature_last) and not allow_plateau)):
return 0.0
target = max(abs(desired), abs(preview))
curvature_weight = float(np.interp(
target,
[MACH_E_TURN_IN_MIN_CURVATURE, MACH_E_TURN_IN_FULL_CURVATURE],
[0.0, 1.0],
))
direction = float(np.sign(desired))
lag_weight = float(np.clip(
(target - direction * current) / MACH_E_TURN_IN_LAG_CURVATURE,
0.0, 1.0,
))
plateau_weight = allow_plateau if abs(desired) == abs(self.desired_curvature_last) else 1.0
return curvature_weight * lag_weight * plateau_weight
def _turn_in_curvature_rate(self, desired: float, current: float, curvature_rate: float,
v_ego: float, lookahead: float, steering_pressed: bool, lane_change: bool) -> float:
if (self.CP.carFingerprint != CAR.FORD_MUSTANG_MACH_E_MK1 or not self.CP.flags & FordFlags.CANFD or
not MACH_E_TURN_IN_RATE_MIN_SPEED <= v_ego < MACH_E_LOW_SPEED_TURN_IN_FADE_SPEED or
steering_pressed or lane_change or desired * self.desired_curvature_last <= 0.0 or
abs(desired) < abs(self.desired_curvature_last) or desired * curvature_rate >= 0.0 or
np.sign(desired) * (desired - current) <= MACH_E_TURN_IN_RATE_MIN_DEFICIT):
return curvature_rate
preview = self._predicted_curvature(v_ego, lookahead + MACH_E_TURN_IN_LOOKAHEAD_EXTRA)
if (desired * preview > 0.0 and abs(preview) >= abs(desired) and
np.sign(desired) * (preview - current) > MACH_E_TURN_IN_RATE_MIN_DEFICIT):
return 0.0
return curvature_rate
@staticmethod
def _turn_in_lookahead_extra(v_ego: float) -> float:
return float(np.interp(
v_ego,
[MACH_E_LOW_SPEED_TURN_IN_START_SPEED, MACH_E_LOW_SPEED_TURN_IN_FULL_SPEED,
MACH_E_LOW_SPEED_TURN_IN_MAX_SPEED, MACH_E_LOW_SPEED_TURN_IN_FADE_SPEED],
[MACH_E_TURN_IN_LOOKAHEAD_EXTRA, MACH_E_LOW_SPEED_TURN_IN_LOOKAHEAD_EXTRA,
MACH_E_LOW_SPEED_TURN_IN_LOOKAHEAD_EXTRA, MACH_E_TURN_IN_LOOKAHEAD_EXTRA],
))
@staticmethod
def _direction_change_lookahead_extra(v_ego: float) -> float:
return float(np.interp(
v_ego,
[MACH_E_DIRECTION_CHANGE_MIN_SPEED, MACH_E_DIRECTION_CHANGE_LOOKAHEAD_RAMP_SPEED,
MACH_E_DIRECTION_CHANGE_LOOKAHEAD_FULL_SPEED, MACH_E_DIRECTION_CHANGE_LOOKAHEAD_FADE_SPEED],
[MACH_E_TURN_IN_LOOKAHEAD_EXTRA, MACH_E_DIRECTION_CHANGE_LOOKAHEAD_EXTRA,
MACH_E_DIRECTION_CHANGE_LOOKAHEAD_EXTRA, MACH_E_TURN_IN_LOOKAHEAD_EXTRA],
))
def _direction_change_preview_weight(self, desired: float, preview: float, current: float,
allow_rising_desired: bool = False, early_handoff_weight: float = 0.0,
sharp_handoff: bool = False) -> float:
if self.CP.carFingerprint not in FORD_CONSERVATIVE_PREVIEW_CARS:
return 0.0
if desired * preview >= 0.0 or desired * self.desired_curvature_last <= 0.0 or desired * current <= 0.0:
return 0.0
early_handoff_weight = float(np.clip(early_handoff_weight, 0.0, 1.0))
lag = abs(current) - abs(desired)
if sharp_handoff:
lag_min = MACH_E_SHARP_DIRECTION_CHANGE_MIN_LAG_CURVATURE
lag_full = MACH_E_SHARP_DIRECTION_CHANGE_FULL_LAG_CURVATURE
else:
lag_min = float(np.interp(
early_handoff_weight, [0.0, 1.0],
[MACH_E_DIRECTION_CHANGE_MIN_LAG_CURVATURE, MACH_E_DIRECTION_CHANGE_EARLY_MIN_LAG_CURVATURE],
))
lag_full = float(np.interp(
early_handoff_weight, [0.0, 1.0],
[MACH_E_DIRECTION_CHANGE_FULL_LAG_CURVATURE, MACH_E_DIRECTION_CHANGE_EARLY_FULL_LAG_CURVATURE],
))
desired_rising = abs(desired) >= abs(self.desired_curvature_last)
rising_handoff = (allow_rising_desired and abs(desired) > abs(self.desired_curvature_last) and
abs(desired) <= MACH_E_LOW_SPEED_DIRECTION_CHANGE_MAX_CURVATURE)
early_rising_handoff = early_handoff_weight > 0.0 and lag > lag_min
if desired_rising and not rising_handoff and not early_rising_handoff:
return 0.0
preview_weight = float(np.interp(
abs(preview),
[MACH_E_DIRECTION_CHANGE_MIN_PREVIEW_CURVATURE, MACH_E_DIRECTION_CHANGE_FULL_PREVIEW_CURVATURE],
[0.0, 1.0],
))
if sharp_handoff:
preview_weight *= float(np.interp(
abs(preview),
[MACH_E_SHARP_DIRECTION_CHANGE_MIN_PREVIEW_CURVATURE, MACH_E_SHARP_DIRECTION_CHANGE_FULL_PREVIEW_CURVATURE],
[0.0, 1.0],
))
lag_weight = float(np.interp(
lag,
[lag_min, lag_full],
[0.0, 1.0],
))
return preview_weight * lag_weight
@staticmethod
def _low_speed_direction_change_weight(v_ego: float, desired: float) -> float:
speed_weight = float(np.interp(
v_ego,
[MACH_E_LOW_SPEED_DIRECTION_CHANGE_START_SPEED, MACH_E_LOW_SPEED_DIRECTION_CHANGE_FULL_SPEED,
MACH_E_LOW_SPEED_DIRECTION_CHANGE_HOLD_SPEED, MACH_E_LOW_SPEED_DIRECTION_CHANGE_FADE_SPEED],
[0.0, 1.0, 1.0, 0.0],
))
curvature_weight = float(np.interp(
abs(desired),
[MACH_E_LOW_SPEED_DIRECTION_CHANGE_MIN_CURVATURE, MACH_E_LOW_SPEED_DIRECTION_CHANGE_FULL_CURVATURE],
[0.0, 1.0],
))
return speed_weight * curvature_weight
@staticmethod
def _sharp_direction_change_weight(v_ego: float, a_ego: float, desired: float, preview: float) -> float:
speed_weight = float(np.interp(
v_ego,
[MACH_E_SHARP_DIRECTION_CHANGE_START_SPEED, MACH_E_SHARP_DIRECTION_CHANGE_FULL_SPEED,
MACH_E_SHARP_DIRECTION_CHANGE_HOLD_SPEED, MACH_E_SHARP_DIRECTION_CHANGE_FADE_SPEED],
[0.0, 1.0, 1.0, 0.0],
))
curvature_weight = float(np.interp(
abs(desired),
[MACH_E_SHARP_DIRECTION_CHANGE_MIN_CURVATURE, MACH_E_SHARP_DIRECTION_CHANGE_FULL_CURVATURE],
[0.0, 1.0],
))
preview_weight = float(np.interp(
abs(preview),
[MACH_E_SHARP_DIRECTION_CHANGE_MIN_PREVIEW_CURVATURE, MACH_E_SHARP_DIRECTION_CHANGE_FULL_PREVIEW_CURVATURE],
[0.0, 1.0],
))
acceleration_weight = float(np.interp(
a_ego,
[MACH_E_SHARP_DIRECTION_CHANGE_MIN_ACCEL, MACH_E_SHARP_DIRECTION_CHANGE_FULL_ACCEL],
[0.0, 1.0],
))
return speed_weight * curvature_weight * preview_weight * acceleration_weight
def _manual_turn(self, CC, CS, desired: float, driver_assisting: bool = False) -> bool:
if not CC.latActive:
self.human_turn.reset()
self.manual_turn_latched = False
self.manual_turn_recovery_timer = 0.0
self.manual_turn_direction = 0.0
return False
detected = self.human_turn.update(
self.human_turn_enabled and not driver_assisting, CS.out.steeringPressed, CS.out.steeringAngleDeg)
if self.CP.carFingerprint not in FORD_MANUAL_TURN_LATCH_CARS:
return detected
if not self.human_turn_enabled:
self.manual_turn_latched = False
self.manual_turn_recovery_timer = 0.0
self.manual_turn_direction = 0.0
return False
blinker_direction = float(CS.out.rightBlinker) - float(CS.out.leftBlinker)
driver_turning_with_signal = (
CS.out.steeringPressed and not driver_assisting and abs(CS.out.steeringAngleDeg) >= MANUAL_TURN_ENTRY_ANGLE_DEG and
blinker_direction != 0.0 and not self._lane_change()[0] and
CS.out.steeringTorque * blinker_direction < 0.0
)
if self.model is not None and (detected or driver_turning_with_signal):
self.manual_turn_latched = True
if blinker_direction != 0.0:
self.manual_turn_direction = blinker_direction
elif self.manual_turn_direction == 0.0:
self.manual_turn_direction = -float(np.sign(CS.out.steeringAngleDeg))
if not self.manual_turn_latched:
self.manual_turn_recovery_timer = 0.0
self.manual_turn_direction = 0.0
return False
following_next_curve = (
driver_assisting and not CS.out.leftBlinker and not CS.out.rightBlinker and
CS.out.vEgoRaw >= MACH_E_DIRECTION_CHANGE_MIN_SPEED and self.manual_turn_direction * desired < 0.0
)
if not following_next_curve and (CS.out.steeringPressed or blinker_direction != 0.0 or
abs(CS.out.steeringAngleDeg) > MANUAL_TURN_RELEASE_ANGLE_DEG):
self.manual_turn_recovery_timer = 0.0
else:
self.manual_turn_recovery_timer += STEER_DT
if self.manual_turn_recovery_timer + 1e-9 >= MANUAL_TURN_RECOVERY_SECONDS:
current = self._current_curvature(CS)
if (self.manual_turn_direction * desired > 0.0 and
self.manual_turn_direction * (desired - current) > CarControllerParams.CURVATURE_ERROR):
self.manual_turn_recovery_timer = MANUAL_TURN_RECOVERY_SECONDS
else:
self.manual_turn_latched = False
self.manual_turn_recovery_timer = 0.0
self.manual_turn_direction = 0.0
return self.manual_turn_latched
def update(self, CC, CS, actuators) -> FordLateralResult:
current = self._current_curvature(CS)
if not CC.latActive:
self.human_turn.reset()
self.manual_turn_latched = False
self.manual_turn_recovery_timer = 0.0
self.manual_turn_direction = 0.0
self.curvature_samples.clear()
self.curvature_last = 0.0
self.path_angle_last = 0.0
self.path_angle_driver_cooldown = 0.0
self.desired_curvature_last = 0.0
self.turn_in_preview_hold_timer = 0.0
self.unwind_preview_hold_timer = 0.0
return FordLateralResult()
desired = float(actuators.curvature)
driver_assisting = self._driver_assisting_curve(CS, desired)
driver_override = bool(CS.out.steeringPressed) and not driver_assisting
manual_turn = self._manual_turn(CC, CS, desired, driver_assisting)
if manual_turn or CS.out.vEgoRaw < 0.1:
if CS.out.steeringPressed:
self.path_angle_driver_cooldown = MACH_E_PATH_ANGLE_DRIVER_COOLDOWN
self.curvature_samples.clear()
self.curvature_last = 0.0
self.path_angle_last = 0.0
self.desired_curvature_last = 0.0
self.turn_in_preview_hold_timer = 0.0
self.unwind_preview_hold_timer = 0.0
return FordLateralResult(active=not (
manual_turn and self.CP.carFingerprint in FORD_MANUAL_TURN_LATCH_CARS))
v_ego = float(CS.out.vEgoRaw)
lookahead = self._curvature_lookahead()
lookahead += self._high_speed_lookahead_extra(v_ego, bool(CS.out.steeringPressed), self._lane_change()[0])
predicted = self._predicted_curvature(v_ego, lookahead)
allow_opposite_preview = False
if self.CP.carFingerprint in FORD_CONSERVATIVE_PREVIEW_CARS:
turn_in_plateau = self._turn_in_preview_plateau(
desired, current, v_ego, bool(CS.out.steeringPressed), self._lane_change()[0])
turn_in_predicted = self._predicted_curvature(v_ego, lookahead + MACH_E_TURN_IN_LOOKAHEAD_EXTRA)
direction_change_predicted = turn_in_predicted
direction_change_weight = 0.0
sharp_direction_change_weight = 0.0
direction_change_speed_weight = float(v_ego > MACH_E_DIRECTION_CHANGE_MIN_SPEED)
low_speed_direction_change = direction_change_speed_weight == 0.0
if direction_change_speed_weight == 0.0:
direction_change_speed_weight = self._low_speed_direction_change_weight(v_ego, desired)
if self.CP.carFingerprint in FORD_SHARP_DIRECTION_CHANGE_CARS:
sharp_direction_change_weight = self._sharp_direction_change_weight(
v_ego, float(CS.out.aEgo), desired, turn_in_predicted)
direction_change_speed_weight = max(direction_change_speed_weight, sharp_direction_change_weight)
if direction_change_speed_weight > 0.0 and not CS.out.steeringPressed and not self._lane_change()[0]:
direction_change_lookahead_extra = self._direction_change_lookahead_extra(v_ego)
early_handoff_weight = float(np.interp(
direction_change_lookahead_extra,
[MACH_E_TURN_IN_LOOKAHEAD_EXTRA, MACH_E_DIRECTION_CHANGE_LOOKAHEAD_EXTRA],
[0.0, 1.0],
))
early_handoff_weight *= float(np.interp(
abs(desired),
[MACH_E_DIRECTION_CHANGE_EARLY_MIN_CURVATURE, MACH_E_DIRECTION_CHANGE_EARLY_FULL_CURVATURE],
[0.0, 1.0],
))
if direction_change_lookahead_extra > MACH_E_TURN_IN_LOOKAHEAD_EXTRA:
direction_change_predicted = self._predicted_curvature(v_ego, lookahead + direction_change_lookahead_extra)
direction_change_weight = self._direction_change_preview_weight(
desired, direction_change_predicted, current, allow_rising_desired=low_speed_direction_change,
early_handoff_weight=early_handoff_weight, sharp_handoff=sharp_direction_change_weight > 0.0)
direction_change_weight *= direction_change_speed_weight
if direction_change_weight > 0.0:
predicted = float(np.interp(direction_change_weight, [0.0, 1.0], [predicted, direction_change_predicted]))
allow_opposite_preview = True
else:
turn_in_lookahead_extra = self._turn_in_lookahead_extra(v_ego)
if (turn_in_lookahead_extra > MACH_E_TURN_IN_LOOKAHEAD_EXTRA and
desired * self.desired_curvature_last >= 0.0 and
(abs(desired) > abs(self.desired_curvature_last) or turn_in_plateau)):
low_speed_turn_in_predicted = self._predicted_curvature(v_ego, lookahead + turn_in_lookahead_extra)
if (desired * low_speed_turn_in_predicted > 0.0 and
abs(low_speed_turn_in_predicted) > abs(turn_in_predicted)):
turn_in_predicted = low_speed_turn_in_predicted
turn_in_weight = self._turn_in_preview_weight(desired, turn_in_predicted, current, turn_in_plateau)
if turn_in_weight > 0.0:
turn_in_target = float(np.copysign(max(abs(desired), abs(turn_in_predicted)), desired))
predicted = float(np.interp(turn_in_weight, [0.0, 1.0], [predicted, turn_in_target]))
unwind_plateau = self._unwind_preview_plateau(
desired, v_ego, bool(CS.out.steeringPressed), self._lane_change()[0])
command_predicted = predicted
canfd_mach_e = self.CP.carFingerprint == CAR.FORD_MUSTANG_MACH_E_MK1 and self.CP.flags & FordFlags.CANFD
if (not allow_opposite_preview or canfd_mach_e) and not CS.out.steeringPressed and not self._lane_change()[0]:
command_predicted = self._unwind_preview(desired, predicted, current, v_ego, unwind_plateau)
unwind_selected = command_predicted != predicted
command_predicted = self._reversal_followthrough_preview(
desired, command_predicted, current, v_ego, bool(CS.out.steeringPressed), self._lane_change()[0])
requested, precision = self._blend_and_scale(desired, command_predicted, v_ego, current, allow_opposite_preview)
if unwind_selected:
requested = self._unwind_request(desired, predicted, command_predicted, requested)
if v_ego > 9.0:
error_limit = self._curvature_error_limit(
requested, desired, current, v_ego, driver_override, self._lane_change()[0], command_predicted)
requested = float(np.clip(requested, current - error_limit, current + error_limit))
applied = float(apply_std_steer_angle_limits(
requested, self.curvature_last, v_ego, CS.out.steeringAngleDeg, True, FORD_CURVATURE_LIMITS))
if self.CP.flags & FordFlags.CANFD:
max_curvature = MAX_LATERAL_ACCEL / max(v_ego, 1.0) ** 2
applied = float(np.clip(applied, -max_curvature, max_curvature))
path_angle = self._path_angle_assist(
requested, desired, applied, current, v_ego, driver_override, self._lane_change()[0])
if path_angle != 0.0 and not self._path_angle_assist_permitted(CC, CS):
self.path_angle_last = 0.0
path_angle = 0.0
self.curvature_samples.append(predicted)
curvature_rate = 0.0
if len(self.curvature_samples) > 1:
sample_time = (len(self.curvature_samples) - 1) * STEER_DT
curvature_rate = (self.curvature_samples[-1] - self.curvature_samples[0]) / max(sample_time * v_ego, 0.01)
curvature_rate *= float(np.interp(abs(predicted), [0.0, 0.008, 0.01], [0.0, 0.0, 1.0]))
curvature_rate *= float(np.interp(v_ego, [0.0, 14.5, 15.5], [1.0, 1.0, 0.0]))
if self._lane_change()[0]:
curvature_rate = 0.0
if (self.CP.carFingerprint == CAR.FORD_MUSTANG_MACH_E_MK1 and self.CP.flags & FordFlags.CANFD and
unwind_selected and command_predicted * curvature_rate > 0.0):
curvature_rate = 0.0
curvature_rate = self._turn_in_curvature_rate(
desired, current, curvature_rate, v_ego, lookahead, bool(CS.out.steeringPressed), self._lane_change()[0])
self.desired_curvature_last = desired
self.curvature_last = float(np.clip(applied, -0.02, 0.02))
min_curvature_rate = -0.001024
if self.CP.carFingerprint == CAR.FORD_MUSTANG_MACH_E_MK1 and self.CP.flags & FordFlags.CANFD:
min_curvature_rate = -0.001023
curvature_rate = float(np.clip(curvature_rate, min_curvature_rate, 0.001023))
return FordLateralResult(
curvature=self.curvature_last,
curvature_rate=curvature_rate,
path_angle=path_angle,
ramp_type=2,
precision_type=precision,
active=True,
)
@@ -1,50 +0,0 @@
"""Ford extended-lateral CAN constructors.
Adapted from BluePilot's ``fordcan_ext.py`` and extended-lateral protocol at bp-7.0 commit
e1d051d7ba270261b4455068bd68f1a58db15a4a, including panda integration developed principally by
Alan Polk. See CREDITS.md and THIRD_PARTY_NOTICES.md for detailed provenance and terms.
"""
from opendbc.car.ford.fordcan import CanBus
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):
addr, dat, bus = packer.make_can_msg("Lane_Assist_Data1", CAN.main, {})
dat = bytearray(dat)
dat[4] |= 0x2
return addr, bytes(dat), bus
def create_lat_ctl2_msg(packer, CAN: CanBus, mode: int, ramp_type: int, precision_type: int,
curvature: float, curvature_rate: float, counter: int, path_angle: float = 0.0):
values = {
"LatCtl_D2_Rq": mode,
"LatCtlRampType_D_Rq": ramp_type,
"LatCtlPrecision_D_Rq": precision_type,
"LatCtlPathOffst_L_Actl": 0.0,
"LatCtlPath_An_Actl": path_angle,
"LatCtlCurv_No_Actl": curvature,
"LatCtlCrv_NoRate2_Actl": curvature_rate,
"HandsOffCnfm_B_Rq": 0,
"LatCtlPath_No_Cnt": counter,
"LatCtlPath_No_Cs": 0,
}
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)
@@ -1,155 +0,0 @@
"""Ford lateral-control extensions.
The extended curvature strategy, manual-turn detector, and related safety protocol are substantially
adapted from BluePilot's Ford work, principally by Alan Polk and additional contributors. The audited
bp-7.0 reference is e1d051d7ba270261b4455068bd68f1a58db15a4a; source attribution is retained in
CREDITS.md and THIRD_PARTY_NOTICES.md. StarPilot reorganized that work for its own architecture and has since
changed its tuning and lookahead behavior.
See CREDITS.md for feature-level authorship and upstream commits, and THIRD_PARTY_NOTICES.md for the
published upstream license notices. Upstream contributors do not maintain this adaptation.
Mach-E admission and modern command envelope; pure strategy is shared separately.
"""
from dataclasses import replace
import numpy as np
from opendbc.car import structs
from opendbc.car.ford.aol import base_safety_param
from opendbc.car.ford.values import CAR, FordFlags, FordSafetyFlags
from opendbc.car.lateral import MAX_LATERAL_JERK
from opendbc.car.ford.lateral_strategy import (
FordLateralController,
FORD_CURVATURE_LIMITS as FORD_CURVATURE_LIMITS,
MAX_LATERAL_ACCEL as MAX_LATERAL_ACCEL,
STEER_DT as STEER_DT,
CURVATURE_LOOKAHEAD_MIN as CURVATURE_LOOKAHEAD_MIN,
CURVATURE_LOOKAHEAD_MAX as CURVATURE_LOOKAHEAD_MAX,
MACH_E_TURN_IN_LOOKAHEAD_EXTRA as MACH_E_TURN_IN_LOOKAHEAD_EXTRA,
MACH_E_LOW_SPEED_TURN_IN_LOOKAHEAD_EXTRA as MACH_E_LOW_SPEED_TURN_IN_LOOKAHEAD_EXTRA,
MACH_E_LOW_SPEED_TURN_IN_START_SPEED as MACH_E_LOW_SPEED_TURN_IN_START_SPEED,
MACH_E_LOW_SPEED_TURN_IN_FULL_SPEED as MACH_E_LOW_SPEED_TURN_IN_FULL_SPEED,
MACH_E_LOW_SPEED_TURN_IN_MAX_SPEED as MACH_E_LOW_SPEED_TURN_IN_MAX_SPEED,
MACH_E_LOW_SPEED_TURN_IN_FADE_SPEED as MACH_E_LOW_SPEED_TURN_IN_FADE_SPEED,
MACH_E_TURN_IN_MIN_CURVATURE as MACH_E_TURN_IN_MIN_CURVATURE,
MACH_E_TURN_IN_FULL_CURVATURE as MACH_E_TURN_IN_FULL_CURVATURE,
MACH_E_TURN_IN_LAG_CURVATURE as MACH_E_TURN_IN_LAG_CURVATURE,
MACH_E_UNWIND_LOOKAHEAD_EXTRA as MACH_E_UNWIND_LOOKAHEAD_EXTRA,
MACH_E_UNWIND_FULL_LAG_CURVATURE as MACH_E_UNWIND_FULL_LAG_CURVATURE,
MACH_E_UNWIND_PREVIEW_LAG_CURVATURE as MACH_E_UNWIND_PREVIEW_LAG_CURVATURE,
MACH_E_DIRECTION_CHANGE_MIN_SPEED as MACH_E_DIRECTION_CHANGE_MIN_SPEED,
MACH_E_DIRECTION_CHANGE_LOOKAHEAD_RAMP_SPEED as MACH_E_DIRECTION_CHANGE_LOOKAHEAD_RAMP_SPEED,
MACH_E_DIRECTION_CHANGE_LOOKAHEAD_FULL_SPEED as MACH_E_DIRECTION_CHANGE_LOOKAHEAD_FULL_SPEED,
MACH_E_DIRECTION_CHANGE_LOOKAHEAD_FADE_SPEED as MACH_E_DIRECTION_CHANGE_LOOKAHEAD_FADE_SPEED,
MACH_E_DIRECTION_CHANGE_LOOKAHEAD_EXTRA as MACH_E_DIRECTION_CHANGE_LOOKAHEAD_EXTRA,
MACH_E_DIRECTION_CHANGE_MIN_PREVIEW_CURVATURE as MACH_E_DIRECTION_CHANGE_MIN_PREVIEW_CURVATURE,
MACH_E_DIRECTION_CHANGE_FULL_PREVIEW_CURVATURE as MACH_E_DIRECTION_CHANGE_FULL_PREVIEW_CURVATURE,
MACH_E_DIRECTION_CHANGE_MIN_LAG_CURVATURE as MACH_E_DIRECTION_CHANGE_MIN_LAG_CURVATURE,
MACH_E_DIRECTION_CHANGE_FULL_LAG_CURVATURE as MACH_E_DIRECTION_CHANGE_FULL_LAG_CURVATURE,
MACH_E_DIRECTION_CHANGE_EARLY_MIN_LAG_CURVATURE as MACH_E_DIRECTION_CHANGE_EARLY_MIN_LAG_CURVATURE,
MACH_E_DIRECTION_CHANGE_EARLY_FULL_LAG_CURVATURE as MACH_E_DIRECTION_CHANGE_EARLY_FULL_LAG_CURVATURE,
MACH_E_DIRECTION_CHANGE_EARLY_MIN_CURVATURE as MACH_E_DIRECTION_CHANGE_EARLY_MIN_CURVATURE,
MACH_E_DIRECTION_CHANGE_EARLY_FULL_CURVATURE as MACH_E_DIRECTION_CHANGE_EARLY_FULL_CURVATURE,
MACH_E_LOW_SPEED_DIRECTION_CHANGE_START_SPEED as MACH_E_LOW_SPEED_DIRECTION_CHANGE_START_SPEED,
MACH_E_LOW_SPEED_DIRECTION_CHANGE_FULL_SPEED as MACH_E_LOW_SPEED_DIRECTION_CHANGE_FULL_SPEED,
MACH_E_LOW_SPEED_DIRECTION_CHANGE_HOLD_SPEED as MACH_E_LOW_SPEED_DIRECTION_CHANGE_HOLD_SPEED,
MACH_E_LOW_SPEED_DIRECTION_CHANGE_FADE_SPEED as MACH_E_LOW_SPEED_DIRECTION_CHANGE_FADE_SPEED,
MACH_E_LOW_SPEED_DIRECTION_CHANGE_MIN_CURVATURE as MACH_E_LOW_SPEED_DIRECTION_CHANGE_MIN_CURVATURE,
MACH_E_LOW_SPEED_DIRECTION_CHANGE_FULL_CURVATURE as MACH_E_LOW_SPEED_DIRECTION_CHANGE_FULL_CURVATURE,
MACH_E_LOW_SPEED_DIRECTION_CHANGE_MAX_CURVATURE as MACH_E_LOW_SPEED_DIRECTION_CHANGE_MAX_CURVATURE,
MACH_E_SHARP_DIRECTION_CHANGE_START_SPEED as MACH_E_SHARP_DIRECTION_CHANGE_START_SPEED,
MACH_E_SHARP_DIRECTION_CHANGE_FULL_SPEED as MACH_E_SHARP_DIRECTION_CHANGE_FULL_SPEED,
MACH_E_SHARP_DIRECTION_CHANGE_HOLD_SPEED as MACH_E_SHARP_DIRECTION_CHANGE_HOLD_SPEED,
MACH_E_SHARP_DIRECTION_CHANGE_FADE_SPEED as MACH_E_SHARP_DIRECTION_CHANGE_FADE_SPEED,
MACH_E_SHARP_DIRECTION_CHANGE_MIN_CURVATURE as MACH_E_SHARP_DIRECTION_CHANGE_MIN_CURVATURE,
MACH_E_SHARP_DIRECTION_CHANGE_FULL_CURVATURE as MACH_E_SHARP_DIRECTION_CHANGE_FULL_CURVATURE,
MACH_E_SHARP_DIRECTION_CHANGE_MIN_PREVIEW_CURVATURE as MACH_E_SHARP_DIRECTION_CHANGE_MIN_PREVIEW_CURVATURE,
MACH_E_SHARP_DIRECTION_CHANGE_FULL_PREVIEW_CURVATURE as MACH_E_SHARP_DIRECTION_CHANGE_FULL_PREVIEW_CURVATURE,
MACH_E_SHARP_DIRECTION_CHANGE_MIN_ACCEL as MACH_E_SHARP_DIRECTION_CHANGE_MIN_ACCEL,
MACH_E_SHARP_DIRECTION_CHANGE_FULL_ACCEL as MACH_E_SHARP_DIRECTION_CHANGE_FULL_ACCEL,
MACH_E_SHARP_DIRECTION_CHANGE_MIN_LAG_CURVATURE as MACH_E_SHARP_DIRECTION_CHANGE_MIN_LAG_CURVATURE,
MACH_E_SHARP_DIRECTION_CHANGE_FULL_LAG_CURVATURE as MACH_E_SHARP_DIRECTION_CHANGE_FULL_LAG_CURVATURE,
MACH_E_CURVATURE_ERROR_MAX as MACH_E_CURVATURE_ERROR_MAX,
MACH_E_UNDERSTEER_ERROR_MIN_DEFICIT as MACH_E_UNDERSTEER_ERROR_MIN_DEFICIT,
MACH_E_UNDERSTEER_ERROR_FULL_DEFICIT as MACH_E_UNDERSTEER_ERROR_FULL_DEFICIT,
MACH_E_PATH_ANGLE_MAX as MACH_E_PATH_ANGLE_MAX,
MACH_E_PATH_ANGLE_STEP as MACH_E_PATH_ANGLE_STEP,
MACH_E_PATH_ANGLE_FADE_START_SPEED as MACH_E_PATH_ANGLE_FADE_START_SPEED,
MACH_E_PATH_ANGLE_MAX_SPEED as MACH_E_PATH_ANGLE_MAX_SPEED,
MACH_E_PATH_ANGLE_TRACKING_FACTOR as MACH_E_PATH_ANGLE_TRACKING_FACTOR,
MACH_E_PATH_ANGLE_DRIVER_COOLDOWN as MACH_E_PATH_ANGLE_DRIVER_COOLDOWN,
MACH_E_DRIVER_ASSIST_MIN_SPEED as MACH_E_DRIVER_ASSIST_MIN_SPEED,
MACH_E_DRIVER_ASSIST_MAX_SPEED as MACH_E_DRIVER_ASSIST_MAX_SPEED,
MACH_E_DRIVER_ASSIST_MAX_TORQUE as MACH_E_DRIVER_ASSIST_MAX_TORQUE,
FORD_CURVATURE_LOOKAHEAD as FORD_CURVATURE_LOOKAHEAD,
FORD_CONSERVATIVE_PREVIEW_CARS as FORD_CONSERVATIVE_PREVIEW_CARS,
FORD_SHARP_DIRECTION_CHANGE_CARS as FORD_SHARP_DIRECTION_CHANGE_CARS,
FORD_MANUAL_TURN_LATCH_CARS as FORD_MANUAL_TURN_LATCH_CARS,
MANUAL_TURN_ENTRY_ANGLE_DEG as MANUAL_TURN_ENTRY_ANGLE_DEG,
MANUAL_TURN_RELEASE_ANGLE_DEG as MANUAL_TURN_RELEASE_ANGLE_DEG,
MANUAL_TURN_RECOVERY_SECONDS as MANUAL_TURN_RECOVERY_SECONDS,
FordLateralResult as FordLateralResult,
HumanTurnDetector as HumanTurnDetector,
)
class MachELateralController(FordLateralController):
"""Compatibility owner for the exact admitted Mach-E profile."""
def qualified(CP) -> bool:
if (CP.brand != "ford" or CP.carFingerprint != CAR.FORD_MUSTANG_MACH_E_MK1 or
not CP.flags & FordFlags.CANFD or CP.flags & ~int(FordFlags.CANFD | FordFlags.HAS_BSM) or
CP.passive or CP.dashcamOnly or CP.notCar or CP.alternativeExperience not in (0, 32) or
len(CP.safetyConfigs) not in (1, 2)):
return False
if len(CP.safetyConfigs) == 2 and (CP.safetyConfigs[0].safetyModel != structs.CarParams.SafetyModel.noOutput or
CP.safetyConfigs[0].safetyParam != 0):
return False
safety = CP.safetyConfigs[-1]
return (safety.safetyModel == structs.CarParams.SafetyModel.ford and
base_safety_param(CP) == int(FordSafetyFlags.CANFD | FordSafetyFlags.MACH_E_EXTENDED |
(FordSafetyFlags.LONG_CONTROL if CP.openpilotLongitudinalControl else 0)))
def path_speed_window_supported(CP) -> bool:
return (qualified(CP) and CP.alternativeExperience == 32 and
len(CP.safetyConfigs) == 1 and CP.safetyConfigs[0].safetyParam ==
int(FordSafetyFlags.CANFD | FordSafetyFlags.MACH_E_EXTENDED | FordSafetyFlags.LONG_CONTROL))
def bounded_command(owner: MachELateralController, demanded: FordLateralResult, previous: float, speed: float,
previous_path_angle: float = 0.0, speed_window=None) -> FordLateralResult:
"""Intersect original demand with modern transient limits and old absolute cap.
A discontinuous speed change may leave no legal active interval. Withdraw the
request then, rather than clipping outside the jerk interval. Histories always
follow emitted fields; the original demand remains observable separately.
"""
cap = min(0.02, MAX_LATERAL_ACCEL / max(speed, 1.0) ** 2)
jerk_delta = MAX_LATERAL_JERK / max(speed, 1.0) ** 2 * STEER_DT
low, high = max(-cap, previous - jerk_delta), min(cap, previous + jerk_delta)
if not demanded.active or low > high:
command = FordLateralResult()
else:
curvature = float(np.clip(demanded.curvature, low, high))
path_angle = 0.0
# Native path assistance checks the maximum of six raw ABS speed samples.
# Do not emit an assist that only the newest (lower) speed sample permits.
path_speed = max(speed, speed_window[1]) if speed_window is not None else speed
if (3.0 <= speed and path_speed < MACH_E_PATH_ANGLE_MAX_SPEED and abs(curvature) >= 0.0195 and
curvature * demanded.path_angle > 0.0):
headroom = max(0.0, (MAX_LATERAL_ACCEL / path_speed ** 2 - abs(curvature)) * path_speed)
path_cap = min(MACH_E_PATH_ANGLE_MAX, headroom)
path_low = max(-path_cap, previous_path_angle - MACH_E_PATH_ANGLE_STEP)
path_high = min(path_cap, previous_path_angle + MACH_E_PATH_ANGLE_STEP)
if path_low <= path_high:
path_angle = float(np.clip(demanded.path_angle, path_low, path_high))
if path_angle * curvature <= 0.0:
path_angle = 0.0
command = replace(demanded, curvature=curvature, path_angle=path_angle)
owner.curvature_last = command.curvature
owner.path_angle_last = command.path_angle
return command
@@ -1,101 +0,0 @@
import numpy as np
from opendbc.car import DT_CTRL
from opendbc.car.ford.values import CarControllerParams
STEER_DT = CarControllerParams.STEER_STEP * DT_CTRL
MANUAL_TURN_ENTRY_ANGLE_DEG = 12.0
MANUAL_TURN_RELEASE_ANGLE_DEG = 12.0
MANUAL_TURN_RECOVERY_SECONDS = 0.25
class HumanTurnDetector:
ANGLE_DEG = 45.0
HOLD_SECONDS = 1.5
PRETURNED_HOLD_SECONDS = 3.0
def __init__(self):
self.timer = 0.0
self.active = False
self._pressed_last = False
self._press_started_preturned = False
def update(self, enabled: bool, steering_pressed: bool, steering_angle_deg: float) -> bool:
if steering_pressed and not self._pressed_last:
self._press_started_preturned = abs(steering_angle_deg) > self.ANGLE_DEG
self._pressed_last = steering_pressed
if enabled and steering_pressed and abs(steering_angle_deg) > self.ANGLE_DEG:
self.timer += STEER_DT
else:
self.timer = 0.0
hold_time = self.PRETURNED_HOLD_SECONDS if self._press_started_preturned else self.HOLD_SECONDS
self.active = self.timer + 1e-9 >= hold_time
return self.active
def reset(self):
self.timer = 0.0
self.active = False
self._pressed_last = False
self._press_started_preturned = False
class ManualTurnLatch:
def __init__(self):
self.human_turn = HumanTurnDetector()
self.manual_turn_latched = False
self.manual_turn_recovery_timer = 0.0
self.manual_turn_direction = 0.0
self.human_turn_enabled = True
def update(self, CC, CS, desired: float, enabled: bool, lane_change: bool, entry_allowed: bool = True) -> bool:
if not CC.latActive:
self.human_turn.reset()
self.manual_turn_latched = False
self.manual_turn_recovery_timer = 0.0
self.manual_turn_direction = 0.0
return False
self.human_turn_enabled = enabled
detected = self.human_turn.update(
self.human_turn_enabled, CS.out.steeringPressed, CS.out.steeringAngleDeg)
if not self.human_turn_enabled:
self.manual_turn_latched = False
self.manual_turn_recovery_timer = 0.0
self.manual_turn_direction = 0.0
return False
blinker_direction = float(CS.out.rightBlinker) - float(CS.out.leftBlinker)
driver_turning_with_signal = (
CS.out.steeringPressed and abs(CS.out.steeringAngleDeg) >= MANUAL_TURN_ENTRY_ANGLE_DEG and
blinker_direction != 0.0 and not lane_change and
CS.out.steeringTorque * blinker_direction < 0.0
)
if entry_allowed and (detected or driver_turning_with_signal):
self.manual_turn_latched = True
if blinker_direction != 0.0:
self.manual_turn_direction = blinker_direction
elif self.manual_turn_direction == 0.0:
self.manual_turn_direction = -float(np.sign(CS.out.steeringAngleDeg))
if not self.manual_turn_latched:
self.manual_turn_recovery_timer = 0.0
self.manual_turn_direction = 0.0
return False
if (CS.out.steeringPressed or blinker_direction != 0.0 or
abs(CS.out.steeringAngleDeg) > MANUAL_TURN_RELEASE_ANGLE_DEG):
self.manual_turn_recovery_timer = 0.0
else:
self.manual_turn_recovery_timer += STEER_DT
if self.manual_turn_recovery_timer + 1e-9 >= MANUAL_TURN_RECOVERY_SECONDS:
current = -CS.out.yawRate / max(CS.out.vEgoRaw, 0.1)
if (self.manual_turn_direction * desired > 0.0 and
self.manual_turn_direction * (desired - current) > CarControllerParams.CURVATURE_ERROR):
self.manual_turn_recovery_timer = MANUAL_TURN_RECOVERY_SECONDS
else:
self.manual_turn_latched = False
self.manual_turn_recovery_timer = 0.0
self.manual_turn_direction = 0.0
return self.manual_turn_latched
@@ -1,70 +0,0 @@
"""Reuse the current Ford curvature strategy for exact Edge/Mondeo callers.
Only scalar curvature crosses the existing native boundary; no protocol extension.
The reused strategy retains its required provenance and notices in lateral_strategy.py.
"""
from dataclasses import replace
import math
from opendbc.car import structs
from opendbc.car.ford.aol import base_safety_param
from opendbc.car.ford.explorer_lateral import bounded_command as shared_bound
from opendbc.car.ford.lateral_strategy import FordLateralController, FordLateralResult
from opendbc.car.ford.values import CAR, FordFlags
PROFILES = {
CAR.FORD_EDGE_MK2: (int(FordFlags.NEW_PORT | FordFlags.ALT_STEER_ANGLE), 8),
CAR.FORD_MONDEO_MK5: (int(FordFlags.NEW_PORT | FordFlags.CANFD), 10),
}
def qualified(cp):
profile = PROFILES.get(cp.carFingerprint)
if (profile is None or cp.brand != "ford" or cp.passive or cp.dashcamOnly or cp.notCar or
cp.alternativeExperience not in (0, 32) or not cp.pcmCruise or
cp.steerControlType != structs.CarParams.SteerControlType.angle or
len(cp.safetyConfigs) not in (1, 2)):
return False
flags, word = profile
if (int(cp.flags) & ~int(FordFlags.HAS_BSM)) != flags:
return False
if len(cp.safetyConfigs) == 2 and (cp.safetyConfigs[0].safetyModel != structs.CarParams.SafetyModel.noOutput or
cp.safetyConfigs[0].safetyParam != 0):
return False
safety = cp.safetyConfigs[-1]
return safety.safetyModel == structs.CarParams.SafetyModel.ford and base_safety_param(cp) == word + int(cp.openpilotLongitudinalControl)
class NewPortCurvatureController(FordLateralController):
def __init__(self, cp):
if not qualified(cp):
raise ValueError("Edge/Mondeo curvature requires its exact current native profile")
super().__init__(cp)
self.manual_turn_detected = False
def _manual_turn(self, CC, CS, desired: float, driver_assisting: bool = False) -> bool:
self.manual_turn_detected = super()._manual_turn(CC, CS, desired, driver_assisting)
return self.manual_turn_detected
def update(self, CC, CS, actuators):
out = CS.out
if (not CC.latActive or not out.canValid or out.canTimeout or out.steerFaultTemporary or
out.steerFaultPermanent or out.vehicleSensorsInvalid or
not all(math.isfinite(value) for value in (out.vEgoRaw, out.yawRate, out.steeringAngleDeg, actuators.curvature))):
self.human_turn.reset()
self.curvature_samples.clear()
self.curvature_last = self.path_angle_last = 0.0
self.manual_turn_detected = False
return FordLateralResult()
return super().update(CC, CS, actuators)
def create_controller(cp):
return NewPortCurvatureController(cp) if qualified(cp) else None
def bounded_command(owner, demanded, previous, speed, measured):
cap = (3.0 - 9.81 * .06) / max(speed, 1.) ** 2 if owner.CP.flags & FordFlags.CANFD else None
command = shared_bound(owner, demanded, previous, speed, measured, absolute_cap=cap)
# These profiles keep their current curvature-only native/codec contract.
return replace(command, curvature_rate=0.0, path_angle=0.0, ramp_type=0, precision_type=1)
-84
View File
@@ -1,84 +0,0 @@
"""Init-only pinion measurement selection adapted from BluePilot PR #145.
See CREDITS.md and THIRD_PARTY_NOTICES.md for upstream attribution and license.
The geometry is carried in the existing exact Panda safety word, not live Params.
"""
from opendbc.car.ford.values import CAR
KEY = "FordPrefSteerAngleCurvature"
ENABLE = 0x200
SHIFT = 10
MASK = 0x3E00
GEOMETRY = {
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,
}
CLASSIC = frozenset((32, 33, 128, 129, 160, 161))
GENERIC = frozenset((66, 67, 130, 131, 194, 195))
MACH_E = frozenset((18, 19, 130, 131, 146, 147))
PROFILES = {i: CLASSIC if i in (1, 2, 5, 6, 9) else MACH_E if i == 11 else frozenset((10, 11)) if i == 10 else GENERIC
for i in range(1, 13)}
def decode(word):
"""Return (original profile, geometry), or None for malformed metadata."""
if not word & MASK:
return word, 0
index = (word >> SHIFT) & 15
base = word & ~MASK
if not word & ENABLE or index not in PROFILES or (base & ~256) not in PROFILES[index]:
return None
return base, index
def base_word(word):
result = decode(int(word))
return result[0] if result is not None else -1
def configured(cp):
if cp.brand != "ford" or not cp.safetyConfigs:
return False
result = decode(int(cp.safetyConfigs[-1].safetyParam))
return bool(result is not None and result[1] and result[1] == GEOMETRY.get(cp.carFingerprint))
def select(cp, enabled):
if not enabled or cp.brand != "ford" or cp.passive or cp.dashcamOnly or cp.notCar:
return False
from opendbc.car.ford.aol import qualified
if not qualified(cp):
return False
index = GEOMETRY.get(cp.carFingerprint)
if index is None:
return False
cp.safetyConfigs[-1].safetyParam |= ENABLE | index << SHIFT
return True
class Measurement:
"""Same init-time family as native; live model correction never changes the source."""
def __init__(self, cp):
self.enabled = configured(cp)
self.offset = self.roll = 0.0
self.model = None
if self.enabled:
from opendbc.car.vehicle_model import VehicleModel
self.model = VehicleModel(cp)
def update_parameters(self, parameters):
import math
if self.model is None or parameters is None:
return
values = (parameters.angleOffsetDeg, parameters.roll, parameters.stiffnessFactor, parameters.steerRatio)
if parameters.valid and all(math.isfinite(v) for v in values):
self.offset, self.roll = values[:2]
self.model.update_params(max(values[2], 0.1), max(values[3], 0.1))
def curvature(self, state):
if not self.enabled:
return -state.yawRate / max(state.vEgoRaw, 0.1)
import math
return -self.model.calc_curvature(math.radians(state.steeringAngleDeg - self.offset), state.vEgoRaw, self.roll)
@@ -7,9 +7,7 @@ from opendbc.can import CANParser
from opendbc.car import Bus, structs
from opendbc.car.common.conversions import Conversions as CV
from opendbc.car.ford.fordcan import CanBus
from opendbc.car.ford.values import DBC, RADAR
from opendbc.car.ford.generic_canfd_lateral import qualified as generic_canfd_qualified
from opendbc.car.ford.mache_lateral import qualified as mache_qualified
from opendbc.car.ford.values import DBC, FORD_CANFD_CARS, FordFlags, RADAR
from opendbc.car.interfaces import RadarInterfaceBase
DELPHI_ESR_RADAR_MSGS = list(range(0x500, 0x540))
@@ -102,7 +100,8 @@ class RadarInterface(RadarInterfaceBase):
self.points: list[list[float]] = []
self.clusters: list[Cluster] = []
self.generic_canfd_lead = generic_canfd_qualified(CP) or mache_qualified(CP)
self.generic_canfd_lead = (CP.carFingerprint in FORD_CANFD_CARS and bool(CP.flags & FordFlags.CANFD) and
not CP.flags & ~int(FordFlags.CANFD | FordFlags.HAS_BSM))
self.v_rel_history = deque(maxlen=20)
self.updated_messages = set()
+1 -14
View File
@@ -3,8 +3,6 @@
from opendbc.car import structs
from opendbc.car.ford.aol import base_safety_param
from opendbc.car.ford.values import CAR, FordFlags, FordSafetyFlags
from opendbc.car.ford.classic_lateral import CLASSIC_EXTENDED_CARS
from opendbc.car.ford.generic_canfd_lateral import qualified as generic_canfd_qualified
class FordStockCruiseButton:
@@ -28,9 +26,6 @@ class FordStockCruiseButton:
def qualified(CP) -> bool:
from opendbc.bluepilot_lateral.hosts.starpilot import qualified as angle_qualified
if angle_qualified(CP):
return not CP.openpilotLongitudinalControl
"""Existing stock profiles with the paired physical-driver button contract."""
if (CP.brand != "ford" or CP.carFingerprint not in tuple(CAR) or CP.openpilotLongitudinalControl or CP.alternativeExperience not in (0, 32) or
CP.passive or CP.dashcamOnly or CP.notCar or len(CP.safetyConfigs) not in (1, 2) or
@@ -40,17 +35,9 @@ def qualified(CP) -> bool:
CP.safetyConfigs[0].safetyParam != 0):
return False
safety = CP.safetyConfigs[-1]
if safety.safetyModel != structs.CarParams.SafetyModel.ford or base_safety_param(CP) not in (2, 8, 10, 12, 18, 32, 66):
if safety.safetyModel != structs.CarParams.SafetyModel.ford or base_safety_param(CP) not in (0, 2, 8, 10, 12):
return False
if safety.safetyModel == structs.CarParams.SafetyModel.ford and base_safety_param(CP) == 32:
return CP.carFingerprint in CLASSIC_EXTENDED_CARS and not CP.flags & ~int(FordFlags.HAS_BSM)
if base_safety_param(CP) == 66:
return generic_canfd_qualified(CP)
expected = (int(FordSafetyFlags.CANFD) if CP.flags & FordFlags.CANFD else 0)
expected |= int(FordSafetyFlags.NEW_PORT) if CP.flags & FordFlags.NEW_PORT else 0
expected |= int(FordSafetyFlags.LKA_STEERING) if CP.flags & FordFlags.LKA_STEERING else 0
if base_safety_param(CP) == 18:
if CP.carFingerprint != CAR.FORD_MUSTANG_MACH_E_MK1 or expected != int(FordSafetyFlags.CANFD):
return False
expected |= int(FordSafetyFlags.MACH_E_EXTENDED)
return base_safety_param(CP) == expected
@@ -9,11 +9,11 @@ from opendbc.car.ford.values import CAR
PROFILES = (
(CAR.FORD_BRONCO_SPORT_MK1, 32), (CAR.FORD_ESCAPE_MK4, 32), (CAR.FORD_FOCUS_MK4, 32),
(CAR.FORD_MAVERICK_MK1, 32), (CAR.FORD_EXPLORER_MK6, 32),
(CAR.FORD_MUSTANG_MACH_E_MK1, 18), (CAR.FORD_ESCAPE_MK4_5, 66),
(CAR.FORD_EXPEDITION_MK4, 66), (CAR.FORD_F_150_MK14, 66),
(CAR.FORD_F_150_LIGHTNING_MK1, 66), (CAR.FORD_RANGER_MK2, 66),
(CAR.FORD_BRONCO_SPORT_MK1, 0), (CAR.FORD_ESCAPE_MK4, 0), (CAR.FORD_FOCUS_MK4, 0),
(CAR.FORD_MAVERICK_MK1, 0), (CAR.FORD_EXPLORER_MK6, 0),
(CAR.FORD_MUSTANG_MACH_E_MK1, 2), (CAR.FORD_ESCAPE_MK4_5, 2),
(CAR.FORD_EXPEDITION_MK4, 2), (CAR.FORD_F_150_MK14, 2),
(CAR.FORD_F_150_LIGHTNING_MK1, 2), (CAR.FORD_RANGER_MK2, 2),
(CAR.FORD_EDGE_MK2, 8), (CAR.FORD_MONDEO_MK5, 10), (CAR.FORD_TRANSIT_MK5, 12),
)
@@ -62,7 +62,6 @@ def run_startup(identity, base_word, alpha, release, topology, monkeypatch):
from openpilot.selfdrive.car.card import Car
from openpilot.selfdrive.controls.controlsd import Controls
from openpilot.starpilot.aol.vehicle import policy_for
from openpilot.starpilot.controller_extensions import ManualTurnInputs
from openpilot.starpilot.lateral.tests.test_lane_runtime import feed
monkeypatch.setenv("SIMULATION", "1")
@@ -111,12 +110,6 @@ def run_startup(identity, base_word, alpha, release, topology, monkeypatch):
assert policy.normal_runtime_supported == admitted
assert ci.CP is card.CP
assert ci.CC is not None
if base_word in (18, 32, 66):
owner = ci.CC.mache_lateral if base_word == 18 else ci.CC.classic_lateral
assert owner is not None
assert owner.CP is card.CP
assert isinstance(ci.CC.manual_turn_inputs, ManualTurnInputs)
assert not ci.CC.manual_turn_inputs.enabled
if admitted:
assert card.aol_card_intent is not None
healthy = structs.CarState(canValid=True, canTimeout=False, gearShifter="drive", buttonEvents=[])
@@ -178,8 +171,6 @@ def run_startup(identity, base_word, alpha, release, topology, monkeypatch):
assert active_command.enabled
assert active_command.latActive == (acknowledged and not stale)
assert not active_command.longActive
if base_word in (18, 32, 66):
assert not owner.human_turn_enabled
finally:
del controls, card, ci, subscriber
gc.collect()
@@ -1,81 +0,0 @@
from pathlib import Path
from types import SimpleNamespace
import pytest
from opendbc.car.ford.interface import CarInterface
from opendbc.car.ford.lateral_strategy import FordLateralController
from opendbc.car.ford.values import CAR
@pytest.mark.parametrize("values,expected", ((None, (0.4, 0.4, 0.85)),
((0.25, 0.65, 1.1), (0.25, 0.65, 1.1)),
((-1.0, 2.0, 4.0), (0.0, 1.0, 1.25)),
((2.0, -1.0, -4.0), (1.0, 0.0, 0.5))))
def test_typed_saved_blends_reach_owner_without_model(values, expected, monkeypatch):
from openpilot.common.params import Params
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.starpilot import controller_extensions
model = SimpleNamespace(meta=SimpleNamespace(laneChangeState=0))
sm = SimpleNamespace(update=lambda timeout: None, alive={"modelV2": False}, valid={"modelV2": False})
class Transport:
alive = sm.alive
valid = sm.valid
def update(self, timeout):
return None
def __getitem__(self, key):
return model
monkeypatch.setattr(controller_extensions.messaging, "SubMaster", lambda names: Transport())
with OpenpilotPrefix():
saved = Params()
keys = ("FordCurvatureBlendLow", "FordCurvatureBlendHigh", "FordCurvatureLaneChangeFactor")
if values is not None:
for key, value in zip(keys, values, strict=True):
saved.put(key, value, block=True)
inputs = controller_extensions.ManualTurnInputs(saved)
owner = FordLateralController(CarInterface.get_non_essential_params(CAR.FORD_RANGER_MK2))
calls = []
setter = owner.set_blend_settings
def counted_setter(*values):
calls.append(values)
setter(*values)
monkeypatch.setattr(owner, "set_blend_settings", counted_setter)
controller = SimpleNamespace(classic_lateral=owner)
inputs.apply_blend_settings(controller)
assert (owner.curvature_blend_low, owner.curvature_blend_high, owner.curvature_lane_change_factor) == expected
inputs.update()
inputs.apply_blend_settings(controller)
assert inputs.blend_settings == expected
assert len(calls) == 1
for key, raw in zip(keys, (b"nan", b"not-a-float", b"inf"), strict=True):
Path(saved.get_param_path(key)).write_bytes(raw)
for _ in range(99):
inputs.update()
assert inputs.blend_settings == expected
inputs.update()
inputs.apply_blend_settings(controller)
assert inputs.blend_settings == (0.4, 0.4, 0.85)
assert len(calls) == (1 if expected == (0.4, 0.4, 0.85) else 2)
assert (owner.curvature_blend_low, owner.curvature_blend_high, owner.curvature_lane_change_factor) == (0.4, 0.4, 0.85)
@pytest.mark.parametrize("desired,predicted,direction", ((-0.0005, -0.002, 1), (0.0005, 0.002, 2),
(-0.001, -0.003, 2), (0.001, 0.003, 1)))
def test_saved_blends_preserve_source_units_and_lane_direction(desired, predicted, direction):
owner = FordLateralController(CarInterface.get_non_essential_params(CAR.FORD_RANGER_MK2))
owner.set_blend_settings(0.25, 0.65, 1.1)
owner.model = SimpleNamespace(meta=SimpleNamespace(laneChangeState=2, laneChangeDirection=direction))
blend = 0.25 + min(abs(desired) / 0.001, 1.0) * 0.4
expected = predicted * blend + desired * (1.0 - blend)
precision = 1
if (direction == 1 and expected < 0.0) or (direction == 2 and expected > 0.0):
expected *= 0.95 + (20.0 - 4.4) / (40.23 - 4.4) * 0.15
precision = 0
result, actual_precision = owner._blend_and_scale(desired, predicted, v_ego=20.0, current=0.0)
assert result == pytest.approx(expected)
assert actual_precision == precision
@@ -1,91 +0,0 @@
import gc
import time
import pytest
from opendbc.car import structs
from opendbc.car.ford.generic_canfd_lateral import GENERIC_CANFD_CARS
from opendbc.car.ford.tests.test_three_ports import params
@pytest.mark.parametrize("identity", sorted(GENERIC_CANFD_CARS))
def test_generic_actual_card_publication_reaches_blend_settings(identity, monkeypatch):
alpha = release = disable = False
from opendbc.car import car_helpers, gen_empty_fingerprint
from opendbc.car.ford.generic_canfd_lateral import qualified as generic_qualified
from openpilot.selfdrive.car import card as card_module
from openpilot.cereal import messaging
from openpilot.common.params import Params
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.car.card import Car
from openpilot.selfdrive.controls.controlsd import Controls
from openpilot.starpilot.controller_extensions import ManualTurnInputs
from openpilot.starpilot.lateral.tests.test_lane_runtime import feed
monkeypatch.setenv('SIMULATION', '1')
monkeypatch.setenv('REPLAY', '1')
with OpenpilotPrefix():
saved = Params()
saved.put_bool('OpenpilotEnabledToggle', True, block=True)
saved.put_bool('AlwaysOnLateral', False, block=True)
saved.put_bool('AlphaLongitudinalEnabled', alpha, block=True)
saved.put_bool('IsReleaseBranch', release, block=True)
saved.put_bool('DisableOpenpilotLongitudinal', disable, block=True)
saved.put_bool('FordHumanTurnDetection', False, block=True)
for key, value in (("FordCurvatureBlendLow", 0.25), ("FordCurvatureBlendHigh", 0.65), ("FordCurvatureLaneChangeFactor", 1.1)):
saved.put(key, value, block=True)
cp = params(identity, alpha, release)
cp.carVin = '0' * 17
cp.carFw = []
expected = cp.to_dict()
fingerprint = gen_empty_fingerprint()
fingerprint[0][0x5A] = 8
fingerprint[2][0x3D6] = 8
fingerprint[2][0x186] = 8
observed = (identity, fingerprint, cp.carVin, [], cp.fingerprintSource, True)
subscriber = messaging.sub_sock('carParams', timeout=100, conflate=True)
ci = card = controls = None
try:
with monkeypatch.context() as discovery:
discovery.setattr(car_helpers, 'fingerprint', lambda *args: observed)
discovery.setattr(card_module, 'can_comm_callbacks', lambda *args: (lambda *args: [], lambda frames: None))
initial_can = messaging.new_message('can', 1)
discovery.setattr(card_module.messaging, 'recv_one_retry', lambda socket: initial_can)
card = Car()
ci = card.CI
assert card.CP.to_dict() == expected
assert generic_qualified(card.CP)
owner = ci.CC.classic_lateral
assert owner is not None
assert (owner.curvature_blend_low, owner.curvature_blend_high, owner.curvature_lane_change_factor) == (0.25, 0.65, 1.1)
assert isinstance(ci.CC.manual_turn_inputs, ManualTurnInputs)
assert not ci.CC.manual_turn_inputs.enabled
assert not ci.CC.mache_extended_announced and not ci.CC.classic_extended_announced
with structs.CarParams.from_bytes(saved.get('CarParams')) as published:
assert published.to_dict() == expected
event = None
for _ in range(10):
card.car_params_published = False
card.state_publish(ci.update([]), None)
event = messaging.recv_one(subscriber)
if event is not None:
break
time.sleep(.01)
assert event is not None and event.valid and event.carParams.to_dict() == expected
controls = Controls()
assert controls.CP.to_dict() == expected
feed(controls, 1_000_000_000, 0, active=False, enabled=False, can_valid=False, can_timeout=True)
command, lateral_log = controls.state_control()
assert not command.enabled and not command.latActive and not command.longActive
controls.publish(command, lateral_log)
_, packets = ci.apply(command.as_reader(), 1_000_000_000)
assert packets
assert not ci.CC.classic_extended_announced
heartbeat = [data for address, data, _ in packets if address == 0x3CA]
assert heartbeat and all(not (data[4] & 2) for data in heartbeat)
assert all(((data[0] >> 4) & 7) == 0 for address, data, _ in packets if address == 0x3D6)
assert not owner.human_turn_enabled
assert (owner.curvature_blend_low, owner.curvature_blend_high, owner.curvature_lane_change_factor) == (0.25, 0.65, 1.1)
finally:
del controls, card, ci, subscriber
gc.collect()
@@ -1,166 +0,0 @@
"""One classic contract, distinct preview and equipment identities."""
from types import SimpleNamespace
import pytest
from opendbc.car import gen_empty_fingerprint
from opendbc.car.ford.classic_lateral import GENERIC_CLASSIC_CARS, create_controller, qualified
from opendbc.car.ford.interface import CarInterface
from opendbc.car.ford.values import CAR, FordFlags
@pytest.mark.parametrize("car", sorted(GENERIC_CLASSIC_CARS))
@pytest.mark.parametrize("alpha,release", ((False, False), (True, False), (False, True), (True, True)))
def test_actual_classic_identity_and_explicit_long_owner(car, alpha, release):
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
cp = CarInterface.get_params(car, fp, [], alpha, release, False)
assert cp.alphaLongitudinalAvailable
assert cp.openpilotLongitudinalControl == alpha
assert cp.safetyConfigs[-1].safetyParam == (33 if alpha else 32)
assert cp.steerActuatorDelay == pytest.approx(.22)
assert qualified(cp)
from openpilot.selfdrive.controls.lib.longcontrol import LongControl
control = LongControl(cp)
assert control.pid.k_p == 0.
assert control.pid.k_i == .5
@pytest.mark.parametrize("car", sorted(GENERIC_CLASSIC_CARS))
@pytest.mark.parametrize("delay,expected", ((.1, .2), (.3, .3), (.8, .4)))
def test_generic_model_preview_remains_live_delay(car, delay, expected):
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
cp = CarInterface.get_params(car, fp, [], False, False, False)
owner = create_controller(cp)
model = SimpleNamespace(orientationRate=SimpleNamespace(z=[i * .01 for i in range(33)]))
owner.set_inputs(model, tuple(i * .1 for i in range(33)), delay, True)
assert owner._curvature_lookahead() == expected
assert owner._predicted_curvature(10., expected) == pytest.approx(expected * .01)
def test_explorer_fixed_preview_is_not_generalized():
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
cp = CarInterface.get_params(CAR.FORD_EXPLORER_MK6, fp, [], False, False, False)
owner = create_controller(cp)
owner.set_inputs(None, (), .4, True)
assert owner._curvature_lookahead() == .2
cp.flags = int(cp.flags) | int(FordFlags.CANFD)
assert not qualified(cp)
assert create_controller(cp) is None
@pytest.mark.parametrize("car", sorted(GENERIC_CLASSIC_CARS))
@pytest.mark.parametrize("automatic,bsm", ((False, False), (True, False), (False, True), (True, True)))
def test_equipment_does_not_change_shared_profile(car, automatic, bsm):
from opendbc.car import structs
fp = gen_empty_fingerprint()
if automatic:
fp[0][0x5A] = 8
if bsm:
fp[0][0x3A6] = fp[0][0x3A7] = 8
cp = CarInterface.get_params(car, fp, [], False, False, False)
assert bool(cp.flags & FordFlags.HAS_BSM) == bsm
assert cp.transmissionType == (structs.CarParams.TransmissionType.automatic if automatic else structs.CarParams.TransmissionType.manual)
assert cp.minEnableSpeed == pytest.approx(-1. if automatic else 20. * .44704)
assert cp.safetyConfigs[-1].safetyParam == 32
assert qualified(cp)
@pytest.mark.parametrize("car", sorted(GENERIC_CLASSIC_CARS))
def test_namespace_and_topology_fail_closed(car):
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
cp = CarInterface.get_params(car, fp, [], False, False, False)
for word in (0, 1, 2, 18, 34, 35, 65535):
cp.safetyConfigs[-1].safetyParam = word
assert not qualified(cp)
cp.safetyConfigs[-1].safetyParam = 32
cp.alternativeExperience = 32
assert qualified(cp)
cp.safetyConfigs = [cp.safetyConfigs[0], cp.safetyConfigs[0]]
assert not qualified(cp)
cp.safetyConfigs = [cp.safetyConfigs[0]]
for experience in (1, 64, 96):
cp.alternativeExperience = experience
assert not qualified(cp)
cp.alternativeExperience = 0
for flag in (FordFlags.CANFD, FordFlags.NEW_PORT, FordFlags.LKA_STEERING, FordFlags.ALT_STEER_ANGLE):
cp.flags = int(flag)
assert not qualified(cp)
cp.flags = 0
cp.passive = True
assert create_controller(cp) is None
@pytest.mark.parametrize("car", sorted(GENERIC_CLASSIC_CARS))
@pytest.mark.parametrize("valid", (False, True))
def test_actual_eps_capability_gate(car, valid):
from opendbc.car import structs
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
fw = structs.CarParams.CarFw.new_message()
fw.ecu = structs.CarParams.Ecu.eps
fw.request = [b"\x22\xde\x01"]
payload = bytearray(24)
payload[7] = payload[8] = 255 if valid else 0
fw.fwVersion = bytes(payload)
cp = CarInterface.get_params(car, fp, [fw.as_reader()], False, False, False)
assert cp.dashcamOnly == (not valid)
assert qualified(cp) == valid
def test_actual_explorer_factory_provider_controller_keeps_fixed_preview():
from unittest.mock import patch
from opendbc.car import structs
from opendbc.car.ford.explorer_lateral import ExplorerLateralController
from openpilot.cereal import messaging
from openpilot.common.params import Params
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.modeld.constants import ModelConstants
from openpilot.starpilot.controller_extensions import configure_controller
with OpenpilotPrefix():
params = Params()
params.put_bool("FordHumanTurnDetection", True, block=True)
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
cp = CarInterface.get_params(CAR.FORD_EXPLORER_MK6, fp, [], False, False, False)
ci = CarInterface(cp)
configure_controller(ci, params)
assert isinstance(ci.CC.classic_lateral, ExplorerLateralController)
import time
now = time.monotonic_ns()
ci.update([(now - 10_000_000, [])])
assert not ci.CS.out.canValid # This is dispatch/transport, not healthy raw-CAN qualification.
from opendbc.can import CANPacker
from opendbc.car import Bus
from opendbc.car.ford.values import DBC
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
speed_frame = packer.make_can_msg("BrakeSysFeatures", 0, {"Veh_V_ActlBrk":36., "VehVActlBrk_D_Qf":3, "VehVActlBrk_No_Cnt":1})
out = ci.update([(now, [speed_frame])])
assert out.vEgoRaw == pytest.approx(10., abs=.03)
assert not out.canValid # The deliberately partial raw fixture makes no complete health claim.
model = messaging.new_message("modelV2")
model.valid = True
model.logMonoTime = now
model.modelV2.orientationRate.z = [float(t) * .1 for t in ModelConstants.T_IDXS]
delay = messaging.new_message("lateralDelay")
delay.valid = True
delay.logMonoTime = now
delay.lateralDelay.lateralDelay = .4
owner = ci.CC.manual_turn_inputs
try:
with patch("openpilot.starpilot.controller_extensions.time.monotonic_ns", return_value=now), \
patch.object(owner.sm, "update", side_effect=lambda timeout=0: owner.sm.update_msgs(now / 1e9, [model.as_reader(), delay.as_reader()])):
# An inactive actual controller tick must still transport inputs into the selected owner.
ci.apply(structs.CarControl().as_reader(), now)
assert ci.CC.classic_lateral._curvature_lookahead() == .2
assert ci.CC.classic_lateral._predicted_curvature(10., .2) == pytest.approx(.002)
assert owner.lateral_snapshot(10.)[2] == pytest.approx(.4)
finally:
owner.sm.sock.clear()
owner.sm.poller = None
owner.sm = None
owner.params = None
params = None
@@ -1,69 +0,0 @@
import unittest
from types import SimpleNamespace
from opendbc.can import CANParser
from opendbc.car import Bus, structs
from opendbc.car.ford.curvature_preview import blend_curvature
from opendbc.car.ford.carcontroller import CarController
from opendbc.car.ford.carstate import CarState
from opendbc.car.ford.tests.test_three_ports import params
from opendbc.car.ford.values import CAR, DBC
class TestCurvaturePreview(unittest.TestCase):
def test_original_default_signed_preview_and_conservative_overshoot(self):
for sign in (-1, 1):
self.assertAlmostEqual(blend_curvature(sign * .001, sign * .002, 0.), sign * .0014)
self.assertAlmostEqual(blend_curvature(sign * .001, sign * .002, sign * .003), sign * .0012)
self.assertEqual(blend_curvature(sign * .001, -sign * .002, 0.), sign * .001)
self.assertEqual(blend_curvature(0., sign * .002, 0.), 0.)
for bad in (float('nan'), float('inf'), -float('inf')):
self.assertEqual(blend_curvature(.001, bad, 0.), .001)
def test_actual_controller_blends_before_unchanged_envelope_and_yields(self):
cp = params(CAR.FORD_MUSTANG_MACH_E_MK1)
cp.safetyConfigs[0].safetyParam = 3 if cp.openpilotLongitudinalControl else 2
controller = CarController(DBC[cp.carFingerprint], cp)
state = CarState(cp)
state.update(state.get_can_parsers(cp))
out = structs.CarState()
out.vEgoRaw = 20.
state.out = out.as_reader()
command = structs.CarControl()
command.latActive = True
command.actuators.curvature = .001
preview = SimpleNamespace(update=lambda: (True, False, True), preview_curvature=lambda speed: .002)
controller.manual_turn_inputs = preview
controller.apply_curvature_last = .0014
from opendbc.car.ford.values import CarControllerParams
expected = CarControllerParams.CURVATURE_LIMITS.apply_limits(.0014, .0014, 20., 0., True, CarControllerParams.STEER_STEP)
actual, frames = controller.update(command.as_reader(), state, 0)
self.assertAlmostEqual(actual.curvature, expected)
parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('LateralMotionControl2', 20)], controller.CAN.main)
parser.update([(50_000_000, frames)])
self.assertAlmostEqual(parser.vl['LateralMotionControl2']['LatCtlCurv_No_Actl'], -.0014, places=5)
self.assertEqual(parser.vl['LateralMotionControl2']['LatCtl_D2_Rq'], 1)
controller.frame = 5
out.steeringPressed = True
out.steeringAngleDeg = -12.
out.steeringTorque = -1.
out.rightBlinker = True
state.out = out.as_reader()
actual, _ = controller.update(command.as_reader(), state, 50_000_000)
self.assertEqual(actual.curvature, 0.)
def test_neighbor_controller_does_not_query_preview(self):
cp = params(CAR.FORD_F_150_MK14)
controller = CarController(DBC[cp.carFingerprint], cp)
state = CarState(cp)
state.update(state.get_can_parsers(cp))
state.out = structs.CarState(vEgoRaw=20.).as_reader()
command = structs.CarControl(latActive=True)
command.actuators.curvature = .001
def unexpected(speed):
raise AssertionError('neighbor queried Mach-E preview')
controller.manual_turn_inputs = SimpleNamespace(update=lambda: None, apply_blend_settings=lambda owner: None,
lateral_snapshot=lambda speed: None, enabled=True,
preview_curvature=unexpected)
controller.update(command.as_reader(), state, 0)
@@ -1,185 +0,0 @@
"""Explorer fixed preview must not broaden the admitted Mach-E strategy."""
from types import SimpleNamespace
from opendbc.car.ford.interface import CarInterface
import pytest
from opendbc.car.ford.explorer_lateral import ExplorerLateralController
from opendbc.car.ford.mache_lateral import MachELateralController
from opendbc.car.ford.values import CAR, CarControllerParams, FordFlags
def controller_params(car, flags):
cp = CarInterface.get_non_essential_params(car)
return SimpleNamespace(brand=cp.brand, safetyConfigs=cp.safetyConfigs, carFingerprint=car, flags=flags)
def model():
return SimpleNamespace(orientationRate=SimpleNamespace(z=[i * 0.01 for i in range(33)]))
@pytest.mark.parametrize("delay", (0.20, 0.30, 0.40))
def test_explorer_fixed_lookahead_selects_original_model_sample(delay):
owner = ExplorerLateralController(controller_params(CAR.FORD_EXPLORER_MK6, 0))
owner.set_inputs(model(), tuple(i * 0.1 for i in range(33)), delay, True)
assert owner._curvature_lookahead() == 0.20
assert owner._predicted_curvature(10.0, owner._curvature_lookahead()) == pytest.approx(0.002)
def test_mach_e_compatibility_keeps_current_provider_lookahead():
owner = MachELateralController(controller_params(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD))
owner.set_inputs(model(), tuple(i * 0.1 for i in range(33)), 0.40, True)
assert owner._curvature_lookahead() == 0.40
assert owner._predicted_curvature(10.0, owner._curvature_lookahead()) == pytest.approx(0.004)
def test_explorer_strategy_rejects_unrelated_topology():
for car, flags in ((CAR.FORD_EXPLORER_MK6, FordFlags.CANFD),
(CAR.FORD_EDGE_MK2, FordFlags.ALT_STEER_ANGLE | FordFlags.NEW_PORT)):
with pytest.raises(ValueError):
ExplorerLateralController(controller_params(car, flags))
@pytest.mark.parametrize("alpha,release", ((False, False), (True, False), (False, True), (True, True)))
def test_actual_interface_restores_explicit_longitudinal_ownership(alpha, release):
from opendbc.car import gen_empty_fingerprint
from opendbc.car.ford.interface import CarInterface
from opendbc.car.ford.explorer_lateral import qualified
from opendbc.car.ford.stock_cruise import qualified as stock_switch_qualified
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
cp = CarInterface.get_params(CAR.FORD_EXPLORER_MK6, fp, [], alpha, release, False)
assert cp.alphaLongitudinalAvailable
assert cp.openpilotLongitudinalControl == alpha
assert cp.safetyConfigs[-1].safetyParam == (33 if alpha else 32)
assert cp.steerActuatorDelay == pytest.approx(.22)
from openpilot.selfdrive.controls.lib.longcontrol import LongControl
control = LongControl(cp)
assert control.pid.k_p == 0.
assert control.pid.k_i == .5
assert list(cp.longitudinalTuning.kiV) == [.5]
assert qualified(cp)
assert stock_switch_qualified(cp) == (not alpha)
def test_classic_modern_envelope_withdraws_infeasible_speed_transition():
from opendbc.car.ford.explorer_lateral import bounded_command
from opendbc.car.ford.lateral_strategy import FordLateralResult
owner = ExplorerLateralController(controller_params(CAR.FORD_EXPLORER_MK6, 0))
result = bounded_command(owner, FordLateralResult(curvature=.02, active=True), .02, 20., .02)
assert not result.active
assert result.curvature == owner.curvature_last == 0.
assert result.path_angle == owner.path_angle_last == 0.
def test_manual_turn_withdraws_without_mach_e_latch_or_gain_compensation():
from opendbc.car.ford.explorer_lateral import bounded_command
from opendbc.car.ford.lateral_strategy import FordLateralResult
owner = ExplorerLateralController(controller_params(CAR.FORD_EXPLORER_MK6, 0))
owner.manual_turn_detected = True
result = bounded_command(owner, FordLateralResult(active=True), .002, 10., .002)
assert not result.active
assert result.curvature == 0.
assert not owner.manual_turn_latched
def test_speed_change_cap_cannot_escape_original_rate_interval():
from opendbc.car.ford.explorer_lateral import bounded_command
from opendbc.car.ford.lateral_strategy import FordLateralResult
owner = ExplorerLateralController(controller_params(CAR.FORD_EXPLORER_MK6, 0))
# At26m/s modernjerk admits~.000265, originalnative lookup only.000180.
# Modern bank-tolerant cap~.005309 requires.000211 fromprevious.005520.
result = bounded_command(owner, FordLateralResult(curvature=.00552, active=True), .00552, 26., .00552)
assert not result.active
assert result.curvature == owner.curvature_last == 0.
def test_actual_long_control_uses_original_zero_p_half_i_and_physical_units():
from opendbc.car import gen_empty_fingerprint
from opendbc.car.ford.interface import CarInterface
from openpilot.selfdrive.controls.lib.longcontrol import LongControl
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
cp = CarInterface.get_params(CAR.FORD_EXPLORER_MK6, fp, [], True, False, False)
owner = LongControl(cp)
assert owner.extension is None
assert owner.pid.k_p == 0.
assert owner.pid.k_i == .5
limits = CarInterface.get_pid_accel_limits(cp, 10., 20.)
assert limits == pytest.approx((-3.5, 2.))
assert owner.pid.update(0., speed=10., feedforward=.2) == pytest.approx(.2)
def test_typed_offset_eps_and_namespace_guards():
from opendbc.car import gen_empty_fingerprint, structs
from opendbc.car.ford.interface import CarInterface
from opendbc.car.ford.explorer_lateral import qualified
fp = gen_empty_fingerprint()
fp[4][0x5A] = 8
cp = CarInterface.get_params(CAR.FORD_EXPLORER_MK6, fp, [], False, False, False)
assert len(cp.safetyConfigs) == 2
assert cp.safetyConfigs[0].safetyModel == structs.CarParams.SafetyModel.noOutput
assert cp.safetyConfigs[-1].safetyParam == 32
assert qualified(cp)
cp.alternativeExperience = 1
assert not qualified(cp)
cp.alternativeExperience = 0
cp.safetyConfigs[0].safetyParam = 1
assert not qualified(cp)
invalid = structs.CarParams.CarFw(ecu=structs.CarParams.Ecu.eps, request=[b"\x22\xde\x01"], fwVersion=b"bad")
bad = CarInterface.get_params(CAR.FORD_EXPLORER_MK6, fp, [invalid], False, False, False)
assert bad.dashcamOnly
assert bad.safetyConfigs[-1].safetyParam == 0
assert not qualified(bad)
@pytest.mark.parametrize("sign", (-1., 1.))
def test_final_intersection_preserves_source_rise_and_unwind(sign):
from opendbc.car.ford.explorer_lateral import bounded_command
from opendbc.car.ford.lateral_strategy import FordLateralResult
owner = ExplorerLateralController(controller_params(CAR.FORD_EXPLORER_MK6, 0))
rise = bounded_command(owner, FordLateralResult(curvature=sign*.0015, active=True), sign*.001, 26., sign*.001)
assert rise.curvature == pytest.approx(sign*.00108)
fall = bounded_command(owner, FordLateralResult(curvature=0., active=True), sign*.001, 26., sign*.001)
assert fall.curvature == pytest.approx(sign*.00082)
@pytest.mark.parametrize("rate,expected", ((.001024, .00102375), (-.001024, -.001024),
(.00102375, .00102375), (-.00102425, -.001024)))
def test_classic_rate_endpoint_has_correct_decoded_sign(rate, expected):
from opendbc.can import CANPacker, CANParser
from opendbc.car import Bus
from opendbc.car.ford.values import DBC
from opendbc.car.ford.extended_classic_can import create_extended_classic_lat_ctl_msg
dbc = DBC[CAR.FORD_EXPLORER_MK6][Bus.pt]
frame = create_extended_classic_lat_ctl_msg(CANPacker(dbc), SimpleNamespace(main=0), True, 2, 1, 0., rate)
parser = CANParser(dbc, [("LateralMotionControl", 20)], 0)
parser.update([(50_000_000, [frame])])
actual = parser.vl["LateralMotionControl"]["LatCtlCurv_NoRate_Actl"]
assert actual == pytest.approx(expected, abs=1e-10)
assert actual * rate > 0.
@pytest.mark.parametrize("sign", (-1., 1.))
def test_measured_recovery_with_empty_source_interval_withdraws(sign):
from opendbc.car.ford.explorer_lateral import bounded_command
from opendbc.car.ford.lateral_strategy import FordLateralResult
owner = ExplorerLateralController(controller_params(CAR.FORD_EXPLORER_MK6, 0))
command = bounded_command(owner, FordLateralResult(curvature=sign*.008, active=True), 0., 35., sign*.008)
assert not command.active and command.curvature == 0.
assert owner.curvature_last == owner.path_angle_last == 0.
@pytest.mark.parametrize("sign", (-1., 1.))
def test_measured_window_requires_recovery_and_bounds_unwind(sign):
from opendbc.car.ford.explorer_lateral import bounded_command
from opendbc.car.ford.lateral_strategy import FordLateralResult
owner = ExplorerLateralController(controller_params(CAR.FORD_EXPLORER_MK6, 0))
# Recovery outside the error window may proceed when source fall permits it.
recovery = bounded_command(owner, FordLateralResult(curvature=sign*.001, active=True), sign*.003, 35., 0.)
delta = CarControllerParams.CURVATURE_LIMITS.MAX_LATERAL_JERK / 35.**2 * .05
assert recovery.active
assert recovery.curvature == pytest.approx(sign*(.003-delta))
# Once inside, even an unwind demand cannot leave the measured error window.
unwind = bounded_command(owner, FordLateralResult(curvature=0., active=True), sign*.00195, 20., sign*.00395)
assert unwind.active and unwind.curvature == pytest.approx(sign*.00195)
@@ -1,219 +0,0 @@
"""Exact source-owned CAN FD identity/release and physical wire contracts."""
from types import SimpleNamespace
import pytest
from opendbc.car import gen_empty_fingerprint
from opendbc.car.ford.interface import CarInterface
from opendbc.car.ford.generic_canfd_lateral import GENERIC_CANFD_CARS, qualified, bounded_command, SOURCE_ACCEL_CAP
from opendbc.car.ford.lateral_strategy import FordLateralResult
from opendbc.car.ford.values import CAR, FordFlags
@pytest.mark.parametrize("car", sorted(GENERIC_CANFD_CARS))
@pytest.mark.parametrize("alpha,release", ((False,False),(True,False),(False,True),(True,True)))
def test_actual_profile_and_release_long_support(car, alpha, release):
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
fp[2].update({0x3D6:8,0x186:8})
cp = CarInterface.get_params(car, fp, [], alpha, release, False)
assert cp.alphaLongitudinalAvailable
assert cp.openpilotLongitudinalControl == alpha
assert cp.safetyConfigs[-1].safetyParam == (67 if alpha else 66)
assert cp.steerActuatorDelay == pytest.approx(.22)
assert qualified(cp)
def test_unknown_identity_flags_and_secoctopology_denied():
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
fp[2].update({0x3D6:8,0x186:8})
cp = CarInterface.get_params(CAR.FORD_RANGER_MK2, fp, [], False, False, False)
for flag in (FordFlags.NEW_PORT, FordFlags.LKA_STEERING, FordFlags.ALT_STEER_ANGLE):
bad = cp.as_reader().as_builder()
bad.flags = int(bad.flags) | int(flag)
assert not qualified(bad)
bad = cp.as_reader().as_builder()
bad.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
assert not qualified(bad)
fp[2][0x3D6] = 16
bad = CarInterface.get_params(CAR.FORD_RANGER_MK2, fp, [], False, False, False)
assert bad.dashcamOnly and not qualified(bad)
def test_old_canfd_cap_intersection_withdraws_without_history_leak():
owner = SimpleNamespace(manual_turn_detected=False, curvature_last=.006, path_angle_last=0.)
command = bounded_command(owner, FordLateralResult(active=True,curvature=.006), .006, 25., 0.)
assert not command.active and owner.curvature_last == 0. and owner.path_angle_last == 0.
owner = SimpleNamespace(manual_turn_detected=False, curvature_last=0., path_angle_last=0.)
command = bounded_command(owner, FordLateralResult(active=True,curvature=.001), 0., 5., 0.)
assert command.active and abs(command.curvature) <= SOURCE_ACCEL_CAP / 25.
def test_f150_source_metadata_reaches_dynamic_consumers():
from opendbc.car import STD_CARGO_KG
from opendbc.car.interfaces import scale_rot_inertia, scale_tire_stiffness
from opendbc.car.vehicle_model import VehicleModel
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
cp = CarInterface.get_params(CAR.FORD_F_150_MK14, fp, [], False, False, False)
assert cp.mass == pytest.approx(3334 + STD_CARGO_KG)
assert cp.wheelbase == pytest.approx(3.99)
assert cp.rotationalInertia == pytest.approx(scale_rot_inertia(cp.mass, cp.wheelbase))
front,rear = scale_tire_stiffness(cp.mass, cp.wheelbase, cp.centerToFront, cp.tireStiffnessFactor)
assert cp.tireStiffnessFront == pytest.approx(front)
assert cp.tireStiffnessRear == pytest.approx(rear)
vm = VehicleModel(cp)
assert vm.m == cp.mass and vm.l == cp.wheelbase
@pytest.mark.parametrize("delay,expected", ((.1,.2),(.3,.3),(.8,.4)))
def test_live_preview_is_not_mache_or_explorer_override(delay, expected):
from opendbc.car.ford.generic_canfd_lateral import GenericCanfdLateralController
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
cp = CarInterface.get_params(CAR.FORD_RANGER_MK2, fp, [], False, False, False)
owner = GenericCanfdLateralController(cp)
model = SimpleNamespace(orientationRate=SimpleNamespace(z=[i*.01 for i in range(33)]))
owner.set_inputs(model, tuple(i*.1 for i in range(33)), delay, True)
assert owner._curvature_lookahead() == expected
assert owner._predicted_curvature(10.,expected) == pytest.approx(expected*.01)
def test_wire_rate_endpoint_does_not_wrap_sign():
from opendbc.can import CANPacker
from opendbc.car import Bus
from opendbc.car.ford.values import DBC
from opendbc.car.ford.fordcan import CanBus
from opendbc.car.ford.generic_canfd_can import create_extended_canfd_msg
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
cp = CarInterface.get_params(CAR.FORD_RANGER_MK2, fp, [], False, False, False)
packer = CANPacker(DBC[cp.carFingerprint][Bus.pt])
for rate,raw in ((-.001024,0),(.001023,2047),(.001024,2047)):
address,data,bus = create_extended_canfd_msg(packer,CanBus(cp),True,2,1,0.,rate,3)
assert address == 0x3D6 and bus == 0
assert ((data[6] << 3) | (data[7] >> 5)) == raw
# Independent field-based checksum includes actual packed endpoint.
mode = (data[0] >> 4) & 7
counter = (data[7] >> 1) & 15
fields = ((data[2] << 3) | (data[3] >> 5), raw,
((data[3] & 31) << 6) | (data[4] >> 2), ((data[4] & 3) << 8) | data[5])
assert data[1] == 255-((mode+counter+sum(v+(v >> 8) for v in fields)) & 255)
@pytest.mark.parametrize("car", sorted(GENERIC_CANFD_CARS) + [CAR.FORD_MUSTANG_MACH_E_MK1])
def test_actual_camera_lead_parser_and_source_window(car):
from opendbc.can import CANPacker
from opendbc.car import Bus
from opendbc.car.ford.values import DBC, RADAR
from opendbc.car.ford.radar_interface import RadarInterface
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
cp = CarInterface.get_params(car, fp, [], False, False, False)
assert not cp.radarUnavailable and DBC[car][Bus.radar] == RADAR.STEER_ASSIST_DATA
radar = RadarInterface(cp)
assert radar.rcp is not None and radar.rcp.bus == 2
packer = CANPacker(RADAR.STEER_ASSIST_DATA)
tick = 0
def feed(distance=40., velocity=-1., confidence=3):
nonlocal tick
tick += 1
msg = packer.make_can_msg("Steer_Assist_Data",2,{
"CmbbObjConfdnc_D_Stat":confidence,"CmbbObjDistLong_L_Actl":distance,
"CmbbObjRelLong_V_Actl":velocity,"CmbbObjDistLat_L_Actl":1.2})
return radar.update([(1_000_000_000+tick*50_000_000,[msg])])
for _ in range(30):
result = feed()
assert radar.rcp.can_valid and len(result.points) == 1
point = result.points[0]
assert point.dRel == pytest.approx(40.) and point.yRel == pytest.approx(1.2)
assert point.vRel == pytest.approx(-1.)
# Exercise the real radard point consumer and typed radarState lead carrier.
from openpilot.selfdrive.controls.radard import Track, KalmanParams, get_lead, RADAR_TO_CAMERA
from openpilot.cereal import messaging
model = messaging.new_message('modelV2')
model.modelV2.init('leadsV3', 2)
vision = model.modelV2.leadsV3[0]
vision.x = [point.dRel + RADAR_TO_CAMERA]
vision.y = [-point.yRel]
vision.v = [10. + point.vRel]
vision.xStd = [1.]
vision.yStd = [1.]
vision.vStd = [1.]
consumer = Track(point.trackId, 10.+point.vRel, KalmanParams(.05))
consumer.update(point.dRel, point.yRel, point.vRel, 10.+point.vRel)
lead = get_lead(10., True, {point.trackId:consumer}, vision.as_reader(), 10., .9)
state = messaging.new_message('radarState')
state.radarState.leadOne = lead
decoded = state.as_reader().radarState.leadOne
assert decoded.present and decoded.radar and decoded.radarTrackId == point.trackId
assert decoded.dRel == pytest.approx(point.dRel) and decoded.yRel == pytest.approx(point.yRel)
assert decoded.vRel == pytest.approx(point.vRel)
track = point.trackId
expected_window = []
previous = 40.
for i in range(1,24):
distance = 40.-i*.1
result = feed(distance,0.)
expected_window.append(distance-previous)
expected_window = expected_window[-20:]
assert result.points[0].vRel == pytest.approx(sum(expected_window),abs=1e-5)
assert result.points[0].trackId == track
previous = distance
result = feed(30.,-4.)
assert result.points[0].trackId != track and not radar.v_rel_history
assert not feed(confidence=0).points and not radar.v_rel_history
assert feed(20.,-2.).points[0].vRel == pytest.approx(-2.)
assert not feed(distance=0).points
from opendbc.can.parser import CAN_INVALID_CNT
for tick in range(CAN_INVALID_CNT):
radar.rcp.update([(10_000_000_000 + tick * 50_000_000, [])])
radar._update_steer_assist()
assert not radar.rcp.can_valid and not radar.pts and not radar.v_rel_history
def test_camera_mapping_is_explicit_for_mache():
from opendbc.car import Bus
from opendbc.car.ford.values import DBC
assert DBC[CAR.FORD_MUSTANG_MACH_E_MK1][Bus.radar] == "ford_lincoln_base_pt"
def test_f150_original_wheelbase_changes_real_plant_and_turn_gate():
from opendbc.car import STD_CARGO_KG
from opendbc.car.vehicle_model import VehicleModel
from openpilot.selfdrive.controls.lib.longitudinal_planner import get_cruise_accel
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
cp = CarInterface.get_params(CAR.FORD_F_150_MK14, fp, [], False, False, False)
assert cp.mass == pytest.approx(3334 + STD_CARGO_KG)
assert cp.wheelbase == pytest.approx(3.99)
vm = VehicleModel(cp)
assert vm.calc_curvature(.1, 0., 0.) == pytest.approx(.1 / (17. * 3.99))
assert vm.get_steer_from_curvature(.001, 0., 0.) == pytest.approx(.001 * 17. * 3.99)
# Compare the actual reached turn gate to the prior upstream wheelbase,
# not a speculative trim or a tuned controller gain. Only this consumer's
# wheelbase is changed; no alternate source/plant is imported.
prior = cp.as_reader().as_builder()
prior.wheelbase = 3.69
restored = get_cruise_accel(False, 35., 25., 0., 12.4, cp, 1., 0., True)
upstream = get_cruise_accel(False, 35., 25., 0., 12.4, prior, 1., 0., True)
assert upstream == pytest.approx(0.)
assert 0. < restored <= .8
@pytest.mark.parametrize("alpha,release", ((False, False), (True, False), (False, True), (True, True)))
@pytest.mark.parametrize("bsm", (False, True))
def test_mache_camera_source_preserves_exact_factory_words(alpha, release, bsm):
from opendbc.car.ford.radar_interface import RadarInterface
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
fp[2].update({0x3D6: 8, 0x186: 8})
if bsm:
fp[0].update({0x3A6: 8, 0x3A7: 8})
cp = CarInterface.get_params(CAR.FORD_MUSTANG_MACH_E_MK1, fp, [], alpha, release, False)
assert cp.safetyConfigs[-1].safetyParam == (19 if alpha else 18)
assert cp.openpilotLongitudinalControl == alpha
assert not cp.radarUnavailable
radar = RadarInterface(cp)
assert radar.rcp.bus == 2 and radar.generic_canfd_lead
@@ -1,52 +0,0 @@
import unittest
from opendbc.car import gen_empty_fingerprint, structs
from opendbc.car.ford.interface import CarInterface
from opendbc.car.ford.values import CAR, FordSafetyFlags
def fingerprint(offset=0, secoc=False):
fp = gen_empty_fingerprint()
fp[offset][0x5A] = 8
fp[offset + 2][0x3D6] = 16 if secoc else 8
fp[offset + 2][0x186] = 16 if secoc else 8
return fp
class TestMachEProfile(unittest.TestCase):
def test_actual_params_stock_debug_long_and_release_long(self):
for alpha, release, word in ((False, False, 18), (True, False, 19), (False, True, 18), (True, True, 19)):
with self.subTest(alpha=alpha, release=release):
cp = CarInterface.get_params(CAR.FORD_MUSTANG_MACH_E_MK1, fingerprint(), [], alpha, release, False)
self.assertEqual(cp.safetyConfigs[-1].safetyParam, word)
self.assertEqual(cp.openpilotLongitudinalControl, word == 19)
self.assertEqual(cp.alternativeExperience, 0)
if release:
self.assertTrue(cp.alphaLongitudinalAvailable)
def test_secoc_unrelated_flags_and_ae_do_not_select_extension(self):
cp = CarInterface.get_params(CAR.FORD_MUSTANG_MACH_E_MK1, fingerprint(secoc=True), [], False, False, False)
self.assertTrue(cp.dashcamOnly)
self.assertFalse(cp.safetyConfigs[-1].safetyParam & FordSafetyFlags.MACH_E_EXTENDED)
for added_flag, ae in ((4, 0), (8, 0), (16, 0), (0, 32)):
with self.subTest(added_flag=added_flag, ae=ae):
cp = CarInterface.get_params(CAR.FORD_MUSTANG_MACH_E_MK1, fingerprint(), [], False, False, False)
cp.flags |= added_flag
cp.alternativeExperience = ae
cp = CarInterface._get_params(cp, CAR.FORD_MUSTANG_MACH_E_MK1, fingerprint(), [], False, False, False)
self.assertFalse(cp.safetyConfigs[-1].safetyParam & FordSafetyFlags.MACH_E_EXTENDED)
def test_sibling_canfd_uses_separate_generic_profile(self):
for candidate in (CAR.FORD_F_150_MK14, CAR.FORD_ESCAPE_MK4_5, CAR.FORD_F_150_LIGHTNING_MK1):
for alpha, word in ((False, 66), (True, 67)):
with self.subTest(candidate=candidate, alpha=alpha):
cp = CarInterface.get_params(candidate, fingerprint(), [], alpha, False, False)
self.assertEqual(cp.safetyConfigs[-1].safetyParam, word)
self.assertFalse(cp.safetyConfigs[-1].safetyParam & FordSafetyFlags.MACH_E_EXTENDED)
def test_bus_offset_keeps_no_output_first(self):
cp = CarInterface.get_params(CAR.FORD_MUSTANG_MACH_E_MK1, fingerprint(offset=4), [], False, False, False)
self.assertEqual(len(cp.safetyConfigs), 2)
self.assertEqual(cp.safetyConfigs[0].safetyModel, structs.CarParams.SafetyModel.noOutput)
self.assertEqual(cp.safetyConfigs[0].safetyParam, 0)
self.assertEqual(cp.safetyConfigs[1].safetyParam, 18)
@@ -1,184 +0,0 @@
"""Recorded Mach-E turn inputs retain Dom display state across steering handoff."""
from pathlib import Path
from types import SimpleNamespace
import pytest
from opendbc.can import CANParser
from opendbc.car import gen_empty_fingerprint, structs
from opendbc.car.ford.carcontroller import CarController
from opendbc.car.ford.carstate import CarState
from opendbc.car.ford.interface import CarInterface
from opendbc.car.ford.values import CAR, DBC
from opendbc.safety.tests.libsafety import libsafety_py
from opendbc.safety.tests import test_ford_aol
from openpilot.common.params import Params, ParamKeyFlag, ParamKeyType
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.modeld.constants import ModelConstants
from openpilot.starpilot.car.ford.cluster_preferences import FordClusterPreference, KEY
class RecordedPreview:
def __init__(self):
self.model = SimpleNamespace(orientationRate=SimpleNamespace(z=[0.] * 33),
meta=SimpleNamespace(laneChangeState=0, laneChangeDirection=0))
def update(self):
pass
def apply_blend_settings(self, controller):
lateral = controller.mache_lateral or controller.classic_lateral
lateral.set_blend_settings(.4, .4, .85)
def lateral_snapshot(self, speed):
return self.model, ModelConstants.T_IDXS, .2, True
def assist_permission(self):
return (), False
def run_turn(*, hands_free, angle, torque, desired, speed, yaw, identity=CAR.FORD_MUSTANG_MACH_E_MK1):
native = test_ford_aol.TestFordAolDriverIntent()
native.setUp()
native.reset(18 if identity == CAR.FORD_MUSTANG_MACH_E_MK1 else 66)
native.arm()
with OpenpilotPrefix():
saved = Params()
Path(saved.get_param_path(KEY)).write_bytes(b"1" if hands_free else b"0")
fingerprint = gen_empty_fingerprint()
fingerprint[0].update({0x5A: 8, 0x3A6: 8, 0x3A7: 8})
fingerprint[2].update({0x3D6: 8, 0x186: 8})
cp = CarInterface.get_params(identity, fingerprint, [], False, False, False)
cp.alternativeExperience = 32
assert cp.safetyConfigs[-1].safetyParam == (18 if identity == CAR.FORD_MUSTANG_MACH_E_MK1 else 66)
assert cp.flags == 3
controller = CarController(DBC[cp.carFingerprint], cp)
controller.hands_free_cluster_input = FordClusterPreference(saved)
controller.manual_turn_inputs = RecordedPreview()
state = CarState(cp)
state.update(state.get_can_parsers(cp))
cs = structs.CarState(canValid=True, vEgo=speed, vEgoRaw=speed, yawRate=yaw,
gearShifter=structs.CarState.GearShifter.drive)
cs.cruiseState.available = True
command = structs.CarControl(latActive=False)
command.actuators.curvature = desired
state.acc_tja_status_stock_values['FcwVisblWarn_B_Rq'] = 1
state.acc_tja_status_stock_values['FcwAudioWarn_B_Rq'] = 1
parser = CANParser('ford_lincoln_base_pt', [('ACCDATA_3', 5)], 0)
steering = []
displays = []
def step():
native.pump(1, mask=int(command.latActive), speed=speed, curvature=yaw / speed)
state.out = cs.as_reader()
_, frames = controller.update(command.as_reader(), state, (1_000_000 + native.tick * 10_000) * 1000)
if identity != CAR.FORD_MUSTANG_MACH_E_MK1:
lateral_frames = [(index, data) for index, (address, data, _) in enumerate(frames) if address == 0x3D6]
announcements = [(index, data) for index, (address, data, _) in enumerate(frames) if address == 0x3CA]
assert len(announcements) <= 1
for index, data in lateral_frames:
active = ((data[0] >> 4) & 7) != 0
previous_active = bool(steering and ((steering[-1][0] >> 4) & 7))
if active != previous_active:
assert announcements and announcements[0][0] < index
assert bool(announcements[0][1][4] & 2) == active
if not command.latActive:
assert all(not (data[4] & 2) for _, data in announcements)
for address, data, bus in frames:
assert native.safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus, data))
if address == 0x3D6:
steering.append(bytes(data))
if address == 0x18A:
parser.update([(native.tick * 10_000_000, [(address, data, bus)])])
displays.append(dict(parser.vl['ACCDATA_3']))
return frames
# Startup remains stock until an active replacement is actually accepted.
for _ in range(25):
assert not {address for address, _, _ in step()} & {0x18A, 0x3D8}
assert native.safety.safety_fwd_hook(2, 0x18A) == 0
native.arm()
command.latActive = True
for _ in range(60):
step()
assert displays and displays[-1]['Tja_D_Stat'] == (7 if hands_free else 2)
assert native.safety.aol_get_permission_mask() == 1
assert native.safety.safety_fwd_hook(2, 0x18A) == -1
# Both original onset tuples have driver input, a right signal and >12deg.
cs.steeringPressed = True
cs.steeringAngleDeg = angle
cs.steeringTorque = torque
cs.rightBlinker = True
if identity != CAR.FORD_MUSTANG_MACH_E_MK1:
# Generic curvature retains its existing 45deg/3s preturned detector.
for _ in range(310):
step()
count = len(displays)
for _ in range(40):
frames = step()
if any(address == 0x3D6 for address, _, _ in frames):
assert ((steering[-1][0] >> 4) & 7) == 0
assert native.safety.safety_fwd_hook(2, 0x3D6) == 0
assert native.safety.safety_fwd_hook(2, 0x18A) == -1
assert native.safety.safety_fwd_hook(2, 0x3D8) == -1
assert native.safety.aol_get_permission_mask() == 1
assert len(displays) > count
for display in displays[count:]:
assert display['Tja_D_Stat'] == (7 if hands_free else 2)
assert display['FcwVisblWarn_B_Rq'] == 1
assert display['FcwAudioWarn_B_Rq'] == 1
cs.steeringPressed = False
cs.steeringAngleDeg = 0.
cs.steeringTorque = 0.
cs.rightBlinker = False
for _ in range(50):
step()
assert ((steering[-1][0] >> 4) & 7) == 1
assert displays[-1]['Tja_D_Stat'] == (7 if hands_free else 2)
command.latActive = False
for _ in range(25):
assert not {address for address, _, _ in step()} & {0x18A, 0x3D8}
assert native.safety.safety_fwd_hook(2, 0x18A) == 0
assert native.safety.safety_fwd_hook(2, 0x3D8) == 0
assert native.safety.aol_get_permission_mask() == 0
return steering
@pytest.mark.parametrize('angle,torque,desired,speed,yaw', [
(-12.2, -1.6875, .000041086183, 2.55, -.006),
(-38.7, -2., -.000324735534, 2.563888788, -.033),
])
def test_recorded_turn_keeps_dom_display_and_hands_free_does_not_change_steering(angle, torque, desired, speed, yaw):
hands_free = run_turn(hands_free=True, angle=angle, torque=torque, desired=desired, speed=speed, yaw=yaw)
plain = run_turn(hands_free=False, angle=angle, torque=torque, desired=desired, speed=speed, yaw=yaw)
assert plain == hands_free
@pytest.mark.parametrize('saved', [False, True])
@pytest.mark.parametrize('flag', [ParamKeyFlag.CLEAR_ON_MANAGER_START, ParamKeyFlag.CLEAR_ON_ONROAD_TRANSITION,
ParamKeyFlag.CLEAR_ON_OFFROAD_TRANSITION, ParamKeyFlag.CLEAR_ON_IGNITION_ON])
def test_cluster_preference_survives_native_cleanup(tmp_path, saved, flag):
params = Params(str(tmp_path))
key = 'FordHandsFreeCluster'
assert params.check_key(key) and params.get_type(key) == ParamKeyType.BOOL
params.put(key, saved, block=True)
sentinel = Path(params.get_param_path('FordUnregisteredSentinel'))
sentinel.write_bytes(b'1')
params.clear_all(flag)
assert params.get(key) == saved and not sentinel.exists()
reopened = Params(str(tmp_path))
assert reopened.get(key) == saved
assert Path(reopened.get_param_path(key)).read_bytes() == (b'1' if saved else b'0')
assert FordClusterPreference(reopened).enabled == saved
@pytest.mark.parametrize('identity', [CAR.FORD_ESCAPE_MK4_5, CAR.FORD_EXPEDITION_MK4, CAR.FORD_F_150_MK14,
CAR.FORD_F_150_LIGHTNING_MK1, CAR.FORD_RANGER_MK2])
def test_generic_curvature_native_display_handoff_retains_hands_free_during_manual_turn(identity):
# Nonzero demand exercises the bounded first-active command, not just UI.
inputs = dict(identity=identity, angle=-55., torque=-2., desired=.000041086183, speed=2.55, yaw=-.006)
hands_free = run_turn(hands_free=True, **inputs)
plain = run_turn(hands_free=False, **inputs)
assert plain == hands_free
@@ -1,893 +0,0 @@
"""Original-derived Mach-E demand and driver handoff regressions."""
from types import SimpleNamespace
from opendbc.car.ford.interface import CarInterface
import pytest
from opendbc.car import Bus, structs
from opendbc.car.lateral import MAX_LATERAL_JERK
from opendbc.can import CANParser
from opendbc.car.ford.values import CAR, DBC, FordFlags, FordSafetyFlags
from opendbc.car.ford.mache_lateral import MachELateralController, FordLateralResult, bounded_command, STEER_DT, MAX_LATERAL_ACCEL, qualified
from opendbc.car.ford import mache_can as fordcan
from opendbc.car.ford.tests.test_three_ports import params
from opendbc.car.ford.carcontroller import CarController
from opendbc.car.ford.carstate import CarState
def controller_params(car, flags):
cp = CarInterface.get_non_essential_params(car)
return SimpleNamespace(brand=cp.brand, safetyConfigs=cp.safetyConfigs, carFingerprint=car, flags=flags)
@pytest.fixture
def controller():
owner = MachELateralController(controller_params(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD))
model = SimpleNamespace(orientationRate=SimpleNamespace(z=[0.]*33),
meta=SimpleNamespace(laneChangeState=0, laneChangeDirection=0))
owner.set_inputs(model, tuple(i*.1 for i in range(33)), .2, True)
return owner
def car_state(speed=15.0, accel=0.0, curvature=0.0, steering_pressed=False, steering_angle=0.0,
steering_torque=0.0, left_blinker=False, right_blinker=False, gas_pressed=False, brake_pressed=False,
cruise_enabled=False):
return SimpleNamespace(out=SimpleNamespace(vEgoRaw=speed, aEgo=accel, yawRate=-curvature*speed,
steeringPressed=steering_pressed, steeringAngleDeg=steering_angle, steeringTorque=steering_torque,
leftBlinker=left_blinker, rightBlinker=right_blinker, gasPressed=gas_pressed, brakePressed=brake_pressed,
cruiseState=SimpleNamespace(enabled=cruise_enabled)))
@pytest.mark.parametrize("sign", (-1, 1))
@pytest.mark.parametrize("speed,weight", ((4.0, 0.0), (5.0, 0.0), (6.0, 0.5), (7.0, 1.0),
(12.0, 1.0), (13.5, 0.5), (15.0, 0.0), (20.0, 0.0)))
def test_mach_e_unwind_preview_speed_and_direction(controller, monkeypatch, sign, speed, weight):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.desired_curvature_last = sign * 0.011
monkeypatch.setattr(controller, "_predicted_curvature", lambda *_: sign * 0.004)
result = controller._unwind_preview(sign * 0.010, sign * 0.009, sign * 0.011, speed)
assert result == pytest.approx(sign * (0.009 - 0.005 * weight))
@pytest.mark.parametrize("sign", (-1, 1))
def test_mach_e_turn_in_preview_holds_one_repeated_sample(controller, sign):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.desired_curvature_last = sign * 0.003
assert not controller._turn_in_preview_plateau(sign * 0.004, sign * 0.001, 6.0, False, False)
controller.desired_curvature_last = sign * 0.004
assert controller._turn_in_preview_plateau(sign * 0.004, sign * 0.001, 6.0, False, False)
assert controller._turn_in_preview_weight(sign * 0.004, sign * 0.03, sign * 0.001, True) == 1.0
assert not controller._turn_in_preview_plateau(sign * 0.004, sign * 0.001, 6.0, False, False)
assert controller._turn_in_preview_weight(sign * 0.004, sign * 0.03, sign * 0.001) == 0.0
@pytest.mark.parametrize("sign", (-1, 1))
@pytest.mark.parametrize("current", (0.0036, 0.004, 0.005))
def test_mach_e_turn_in_plateau_requires_tracking_lag(controller, sign, current):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.desired_curvature_last = sign * 0.004
controller.turn_in_preview_hold_timer = 0.1
assert not controller._turn_in_preview_plateau(sign * 0.004, sign * current, 6.0, False, False)
@pytest.mark.parametrize("desired", (0.003, -0.004, 0.0))
def test_mach_e_turn_in_plateau_resets_on_unwind_and_reversal(controller, desired):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.desired_curvature_last = 0.004
controller.turn_in_preview_hold_timer = 0.1
assert not controller._turn_in_preview_plateau(desired, 0.001, 6.0, False, False)
assert controller.turn_in_preview_hold_timer == 0.0
@pytest.mark.parametrize("fingerprint,flags,speed,driver,lane_change", (
(CAR.FORD_EDGE_MK2, FordFlags.CANFD, 6.0, False, False),
(CAR.FORD_EXPLORER_MK6, FordFlags.CANFD, 6.0, False, False),
(CAR.FORD_F_150_MK14, FordFlags.CANFD, 6.0, False, False),
(CAR.FORD_MUSTANG_MACH_E_MK1, 0, 6.0, False, False),
(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD, 1.99, False, False),
(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD, 15.0, False, False),
(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD, 6.0, True, False),
(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD, 6.0, False, True),
))
def test_turn_in_plateau_preserves_other_fords_and_handoffs(controller, fingerprint, flags, speed, driver, lane_change):
controller.CP.carFingerprint = fingerprint
controller.CP.flags = flags
controller.desired_curvature_last = 0.004
controller.turn_in_preview_hold_timer = 0.1
assert not controller._turn_in_preview_plateau(0.004, 0.001, speed, driver, lane_change)
assert controller.turn_in_preview_hold_timer == 0.0
@pytest.mark.parametrize("sign", (-1, 1))
def test_mach_e_repeated_turn_in_sample_preserves_extended_preview(controller, monkeypatch, sign):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.set_inputs(controller.model, controller.time_indices, .4, True)
controller.desired_curvature_last = sign * 0.003
controller.curvature_last = sign * 0.012
monkeypatch.setattr(controller, "_predicted_curvature",
lambda _v, t: sign * (0.002 if t < 1.0 else 0.01 if t < 1.5 else 0.03))
state = car_state(speed=6.0, curvature=sign * 0.001)
cc = SimpleNamespace(latActive=True, enabled=False)
actuators = SimpleNamespace(curvature=sign * 0.004)
first = controller.update(cc, state, actuators)
repeated = controller.update(cc, state, actuators)
assert sign * repeated.curvature >= sign * first.curvature
assert sign * repeated.curvature == pytest.approx(0.0144)
expired = controller.update(cc, state, actuators)
assert sign * expired.curvature < sign * repeated.curvature
assert repeated.path_angle == 0.0
controller.update(SimpleNamespace(latActive=False), state, actuators)
assert controller.turn_in_preview_hold_timer == 0.0
controller.update(cc, state, actuators)
assert controller.turn_in_preview_hold_timer > 0.0
controller.update(cc, car_state(speed=0.0), actuators)
assert controller.turn_in_preview_hold_timer == 0.0
@pytest.mark.parametrize("speed,weight", ((1.99, 0.0), (2.0, 0.0), (2.5, 0.5), (3.0, 1.0),
(14.0, 1.0), (14.5, 0.5), (15.0, 0.0), (16.0, 0.0)))
def test_mach_e_turn_in_plateau_speed_boundaries_are_continuous(controller, speed, weight):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.desired_curvature_last = 0.004
controller.turn_in_preview_hold_timer = 0.1
actual_weight = controller._turn_in_preview_plateau(0.004, 0.001, speed, False, False)
assert actual_weight == pytest.approx(weight)
assert controller._turn_in_preview_weight(0.004, 0.03, 0.001, actual_weight) == pytest.approx(weight)
@pytest.mark.parametrize("speed,desired,requested,current,driver,lane_change,expected", (
(12.0, 0.012, 0.012, 0.004, False, False, 0.006),
(12.0, -0.012, -0.012, 0.004, False, False, 0.006),
(12.0, 0.012, 0.012, 0.010, False, False, 0.002),
(12.0, 0.012, 0.012, 0.014, False, False, 0.002),
(9.5, 0.012, 0.012, 0.004, False, False, 0.006),
(15.0, 0.012, 0.012, 0.004, False, False, 0.004),
(16.0, 0.012, 0.012, 0.004, False, False, 0.002),
(12.0, 0.012, 0.012, 0.004, True, False, 0.002),
(12.0, 0.012, 0.012, 0.004, False, True, 0.002),
(12.0, 0.012, -0.004, 0.004, False, False, 0.002),
))
def test_mach_e_understeer_error_scope(controller, speed, desired, requested, current,
driver, lane_change, expected):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
assert controller._curvature_error_limit(
requested, desired, current, speed, driver, lane_change) == pytest.approx(expected)
@pytest.mark.parametrize("sign", (-1, 1))
@pytest.mark.parametrize("speed,expected", ((8.0, 0.002), (8.5, 0.004), (9.0, 0.006), (12.0, 0.006),
(14.0, 0.006), (15.0, 0.004), (16.0, 0.002)))
def test_mach_e_unwind_error_tracks_opening_path_before_direction_changes(controller, sign, speed, expected):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.model = SimpleNamespace(orientationRate=SimpleNamespace(z=[0.0] * 33))
assert controller._curvature_error_limit(
sign * 0.001, sign * 0.002, sign * 0.005, speed, False, False, sign * 0.001) == pytest.approx(expected)
@pytest.mark.parametrize("sign", (-1, 1))
@pytest.mark.parametrize("requested,preview,expected", ((0.003, 0.001, 0.002), (0.002, 0.001, 0.004),
(0.001, 0.001, 0.006), (0.001, 0.004, 0.002),
(0.001, 0.003, 0.002), (0.001, 0.002, 0.004),
(0.001, -0.0002, 0.006), (0.001, 0.0, 0.006),
(0.0, 0.0, 0.006)))
def test_mach_e_unwind_error_requires_measured_lag_and_opening_preview(controller, sign, requested, preview, expected):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.model = SimpleNamespace(orientationRate=SimpleNamespace(z=[0.0] * 33))
assert controller._curvature_error_limit(
sign * requested, sign * 0.002, sign * 0.005, 12.0, False, False, sign * preview) == pytest.approx(expected)
@pytest.mark.parametrize("fingerprint,flags,driver,lane_change", (
(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD, True, False),
(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD, False, True),
(CAR.FORD_MUSTANG_MACH_E_MK1, 0, False, False),
(CAR.FORD_EDGE_MK2, FordFlags.CANFD, False, False),
(CAR.FORD_EXPLORER_MK6, FordFlags.CANFD, False, False),
(CAR.FORD_F_150_MK14, FordFlags.CANFD, False, False),
))
def test_unwind_error_preserves_takeover_lane_changes_and_other_fords(controller, fingerprint, flags, driver, lane_change):
controller.CP.carFingerprint = fingerprint
controller.CP.flags = flags
controller.model = SimpleNamespace(orientationRate=SimpleNamespace(z=[0.0] * 33))
assert controller._curvature_error_limit(0.001, 0.002, 0.005, 12.0, driver, lane_change, 0.001) == 0.002
@pytest.mark.parametrize("model", (None, SimpleNamespace(orientationRate=SimpleNamespace(z=[0.0] * 16))))
def test_mach_e_unwind_error_requires_model_preview(controller, model):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.model = model
assert controller._curvature_error_limit(0.001, 0.002, 0.005, 12.0, False, False, 0.001) == 0.002
@pytest.mark.parametrize("sign", (-1, 1))
def test_mach_e_curve_exit_releases_without_waiting_for_left_right_reversal(controller, monkeypatch, sign):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.model = SimpleNamespace(orientationRate=SimpleNamespace(z=[0.0] * 33),
meta=SimpleNamespace(laneChangeState=0, laneChangeDirection=0))
controller.curvature_last = sign * 0.004
controller.desired_curvature_last = sign * 0.003
monkeypatch.setattr(controller, "_predicted_curvature", lambda *_: sign * 0.001)
commands = []
for _ in range(4):
result = controller.update(SimpleNamespace(latActive=True), car_state(speed=12.0, curvature=sign * 0.005),
SimpleNamespace(curvature=sign * 0.002))
assert result.active
assert result.path_angle == 0.0
commands.append(sign * result.curvature)
assert commands[0] == pytest.approx(0.004 - 0.0018)
assert commands[-1] == pytest.approx(0.0016)
assert commands[-1] < 0.005 - 0.002
@pytest.mark.parametrize("sign", (-1, 1))
def test_mach_e_reversal_does_not_reapply_old_direction_at_desired_zero_crossing(controller, monkeypatch, sign):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.set_inputs(controller.model, controller.time_indices, 0.4, True)
controller.desired_curvature_last = sign * 0.00155
controller.curvature_last = -sign * 0.00065
monkeypatch.setattr(controller, "_predicted_curvature",
lambda _v, t: sign * 0.0007 if t < 1.0 else -sign * 0.005)
result = controller.update(SimpleNamespace(latActive=True), car_state(speed=12.0, curvature=sign * 0.00462),
SimpleNamespace(curvature=-sign * 0.00034))
assert result.active
# Reversal followthrough selects -sign*.005; the existing .006 error
# envelope around sign*.00462 bounds the opposite demand to -sign*.00138.
assert result.curvature == pytest.approx(-sign * 0.00138)
assert result.path_angle == 0.0
@pytest.mark.parametrize("sign", (-1, 1))
def test_mach_e_path_angle_assist_starts_at_saturation_and_releases_after_driver(controller, sign):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
request = (sign * 0.0205, sign * 0.019, sign * 0.02, sign * 0.007, 7.0)
assert controller._path_angle_assist(*request, False, False) == pytest.approx(sign * 0.055)
assert controller._path_angle_assist(*request, True, False) == 0.0
for _ in range(round(0.75 / STEER_DT) - 1):
assert controller._path_angle_assist(*request, False, False) == 0.0
assert controller._path_angle_assist(*request, False, False) == pytest.approx(sign * 0.055)
assert controller._path_angle_assist(
sign * 0.0205, sign * 0.019, sign * 0.02, sign * 0.021, 7.0, False, False) == 0.0
@pytest.mark.parametrize("sign", (-1, 1))
def test_mach_e_driver_assistance_handoff_and_takeover(controller, monkeypatch, sign):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.curvature_last = sign * 0.020
monkeypatch.setattr(controller, "_predicted_curvature", lambda *_: sign * 0.030)
CC = SimpleNamespace(latActive=True, enabled=True)
actuators = SimpleNamespace(curvature=sign * 0.022)
helping = car_state(speed=7.0, curvature=sign * 0.008, steering_pressed=True,
steering_angle=-sign * 50.0, steering_torque=-sign * 2.0,
left_blinker=sign < 0, right_blinker=sign > 0)
for _ in range(round(3.5 / STEER_DT)):
result = controller.update(CC, helping, actuators)
assert result.active
assert result.curvature == pytest.approx(sign * 0.020)
assert sign * result.path_angle > 0.0
assert not controller.manual_turn_latched
helping.out.steeringPressed = False
helping.out.steeringTorque = 0.0
result = controller.update(CC, helping, actuators)
assert result.active
assert sign * result.path_angle > 0.0
assert controller.path_angle_driver_cooldown == 0.0
helping.out.steeringPressed = True
helping.out.steeringTorque = sign * 2.0
result = controller.update(CC, helping, actuators)
assert result.path_angle == 0.0
assert controller.path_angle_driver_cooldown > 0.0
helping.out.steeringTorque = -sign * 3.6
result = controller.update(CC, helping, actuators)
assert not result.active
assert result.curvature == result.path_angle == 0.0
assert controller.manual_turn_latched
helping.out.steeringTorque = -sign * 2.0
assert not controller.update(CC, helping, actuators).active
controller.update(SimpleNamespace(latActive=False), helping, actuators)
helping.out.steeringTorque = -sign * 2.0
helping.out.yawRate = -sign * 0.025 * helping.out.vEgoRaw
result = controller.update(CC, helping, actuators)
assert not result.active
assert result.curvature == result.path_angle == 0.0
assert controller.manual_turn_latched
controller.update(SimpleNamespace(latActive=False), helping, actuators)
assert not controller.manual_turn_latched
assert controller.path_angle_driver_cooldown == 0.0
@pytest.mark.parametrize("speed,expected", (
(1.0, 0.80),
(2.0, 0.80),
(2.5, 1.20),
(3.0, 1.60),
(8.0, 1.60),
(9.0, 1.60),
(10.5, 1.60),
(11.0, 1.60),
(12.0, 1.60),
(13.0, 4.0 / 3.0),
(14.0, 16.0 / 15.0),
(15.0, 0.80),
(16.0, 0.80),
))
def test_mach_e_turn_in_lookahead_extra_fades_by_speed(controller, speed, expected):
assert controller._turn_in_lookahead_extra(speed) == pytest.approx(expected)
def test_exact_profile_rejects_collision_and_siblings():
cp = params(CAR.FORD_MUSTANG_MACH_E_MK1)
for word in (18, 19):
cp.openpilotLongitudinalControl = word == 19
cp.safetyConfigs[0].safetyParam = word
assert qualified(cp)
for word in (2, 3, 10, 11, 22, 26, 34):
cp.safetyConfigs[0].safetyParam = word
assert not qualified(cp)
cp.safetyConfigs[0].safetyParam = 19
cp.passive = True
assert not qualified(cp)
neighbor = params(CAR.FORD_F_150_MK14)
neighbor.safetyConfigs[0].safetyParam = 18
assert not qualified(neighbor)
def test_actual_controller_announces_before_full_physical_frame_and_resets_off():
cp = params(CAR.FORD_MUSTANG_MACH_E_MK1)
cp.openpilotLongitudinalControl = False
cp.safetyConfigs[0].safetyParam = 18
cc = CarController(DBC[cp.carFingerprint], cp)
cs = CarState(cp)
cs.update(cs.get_can_parsers(cp))
cs.out = structs.CarState(vEgoRaw=7.).as_reader()
command = structs.CarControl(latActive=True)
command.actuators.curvature = .008
model = SimpleNamespace(orientationRate=SimpleNamespace(z=[.07]*33),
meta=SimpleNamespace(laneChangeState=0, laneChangeDirection=0))
times = tuple(i*.1 for i in range(33))
cc.manual_turn_inputs = SimpleNamespace(update=lambda: None, apply_blend_settings=lambda owner: None, enabled=True,
lateral_snapshot=lambda speed: (model, times, .2, True))
_, frames = cc.update(command.as_reader(), cs, 0)
first = next(data for addr, data, _ in frames if addr == 0x3d6)
assert (first[0] >> 4) & 7 == 0
announcement = next(data for addr, data, _ in frames if addr == 0x3ca)
assert announcement[4] & 2
cc.frame = 5
actual, frames = cc.update(command.as_reader(), cs, 50_000_000)
frame = next(data for addr, data, _ in frames if addr == 0x3d6)
assert (frame[0] >> 4) & 7 == 1
assert frame[1] == fordcan.calculate_lat_ctl2_checksum(1, 1, frame)
parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('LateralMotionControl2', 20)], cc.CAN.main)
parser.update([(50_000_000, frames)])
assert parser.vl['LateralMotionControl2']['LatCtlCurv_No_Actl'] == pytest.approx(-actual.curvature, abs=1.01e-5)
command.latActive = False
cc.frame = 10
actual, frames = cc.update(command.as_reader(), cs, 100_000_000)
assert actual.curvature == 0
assert cc.mache_lateral.path_angle_last == 0
assert cc.mache_lateral.desired_curvature_last == 0
def test_modern_transient_bound_updates_emitted_history_without_hidden_windup():
owner = MachELateralController(controller_params(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD))
raw = FordLateralResult(curvature=.006, path_angle=.16, active=True)
command = bounded_command(owner, raw, .001, 15.)
assert command.curvature < raw.curvature
assert abs(command.curvature-.001) <= MAX_LATERAL_JERK / 15.**2 * STEER_DT + 1e-12
assert command.path_angle == 0.
assert owner.curvature_last == command.curvature
assert owner.path_angle_last == command.path_angle
assert raw.curvature == .006 and raw.path_angle == .16
slow = bounded_command(owner, FordLateralResult(curvature=.02, active=True), .02, 15.)
assert not slow.active and slow.curvature == slow.path_angle == slow.curvature_rate == 0.
assert owner.curvature_last == 0.
@pytest.mark.parametrize("sign", (-1, 1))
def test_speed_changes_intersect_bounds_or_withdraw_and_preserve_path_headroom(sign):
owner = MachELateralController(controller_params(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD))
previous, path = sign * .0198, sign * .055
for speed in (5., 5.1, 5.2, 5.3, 8., 15., 5.):
demand = FordLateralResult(curvature=sign*.02, path_angle=sign*.16, active=True)
emitted = bounded_command(owner, demand, previous, speed, path)
if emitted.active:
assert abs(emitted.curvature-previous) <= MAX_LATERAL_JERK / speed**2 * STEER_DT + 1e-12
assert abs(emitted.curvature) <= MAX_LATERAL_ACCEL / speed**2 + 1e-12
if emitted.path_angle:
assert abs(emitted.path_angle-path) <= .055+1e-12
assert (abs(emitted.curvature)+abs(emitted.path_angle)/speed)*speed**2 <= MAX_LATERAL_ACCEL+1e-12
else:
assert emitted.curvature == emitted.path_angle == emitted.curvature_rate == 0.
previous, path = emitted.curvature, emitted.path_angle
def test_final_typed_cp_binds_real_provider_and_freshness():
import tempfile
from unittest.mock import patch
import openpilot.cereal.messaging as messaging
from openpilot.common.params import Params
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.starpilot.controller_extensions import configure_controller, ManualTurnInputs
from openpilot.selfdrive.modeld.constants import ModelConstants
from opendbc.car.ford.interface import CarInterface
cp = params(CAR.FORD_MUSTANG_MACH_E_MK1)
assert cp.safetyConfigs[0].safetyParam == (19 if cp.openpilotLongitudinalControl else 18)
ci = CarInterface(cp)
# Native qualification may run with an unwritable home. Keep the real
# messaging prefix and its filesystem lifecycle inside this fixture.
with tempfile.TemporaryDirectory() as directory, \
patch("openpilot.common.prefix.Paths.comma_home", return_value=directory + "/comma"), \
patch("openpilot.common.prefix.Paths.log_root", return_value=directory + "/logs"), OpenpilotPrefix():
preferences = Params(directory)
preferences.put_bool('FordHumanTurnDetection', True, block=True)
configure_controller(ci, preferences)
provider = ci.CC.manual_turn_inputs
assert isinstance(provider, ManualTurnInputs)
try:
event = messaging.new_message('modelV2')
event.valid = True
event.logMonoTime = 1_000_000_000
event.modelV2.orientationRate.z = [.07] * len(ModelConstants.T_IDXS)
event.modelV2.meta.laneChangeState = 0
event.modelV2.meta.laneChangeDirection = 0
provider.sm.update_msgs(1., [event.as_reader()])
with patch('openpilot.starpilot.controller_extensions.time.monotonic_ns', return_value=1_000_000_000):
sample = provider.lateral_snapshot(7.)
assert sample is not None
assert sample[0].orientationRate.z[0] == pytest.approx(.07)
with patch('openpilot.starpilot.controller_extensions.time.monotonic_ns', return_value=1_200_000_000):
assert provider.lateral_snapshot(7.) is None
finally:
ci.CC.manual_turn_inputs = None
del provider
def test_real_offset_cp_keeps_owner_and_invalid_extended_cp_never_falls_back_active():
from opendbc.car import gen_empty_fingerprint
from opendbc.car.ford.interface import CarInterface
fingerprint = gen_empty_fingerprint()
fingerprint[4][0x5a] = 8
fingerprint[6][0x3d6] = 8
fingerprint[6][0x186] = 8
cp = CarInterface.get_params(CAR.FORD_MUSTANG_MACH_E_MK1, fingerprint, [], False, False, False)
assert len(cp.safetyConfigs) == 2
assert cp.safetyConfigs[0].safetyModel == structs.CarParams.SafetyModel.noOutput
assert cp.safetyConfigs[-1].safetyParam == 18
assert qualified(cp)
controller = CarController(DBC[cp.carFingerprint], cp)
assert controller.mache_lateral is not None and controller.CAN.main == 4
cp.passive = True
controller = CarController(DBC[cp.carFingerprint], cp)
assert controller.mache_lateral is None
state = CarState(cp)
state.update(state.get_can_parsers(cp))
state.out = structs.CarState(vEgoRaw=10.).as_reader()
command = structs.CarControl(latActive=True)
command.actuators.curvature = .008
actual, frames = controller.update(command.as_reader(), state, 0)
frame = next(data for addr, data, _ in frames if addr == 0x3d6)
assert frame[0] >> 4 & 7 == 0
assert actual.curvature == 0
@pytest.mark.parametrize("sign", (-1, 1))
def test_real_controller_gas_permission_loss_withdraws_path_only(sign):
cp = params(CAR.FORD_MUSTANG_MACH_E_MK1)
owner = CarController(DBC[cp.carFingerprint], cp)
state = CarState(cp)
state.update(state.get_can_parsers(cp))
control = structs.CarControl(latActive=True, enabled=True)
control.actuators.curvature = sign * .03
states, healthy = [SimpleNamespace(safetyModel=structs.CarParams.SafetyModel.ford, controlsAllowed=True,
safetyParam=FordSafetyFlags.CANFD | FordSafetyFlags.MACH_E_EXTENDED)], True
model = SimpleNamespace(orientationRate=SimpleNamespace(z=[sign * .28] * 33),
meta=SimpleNamespace(laneChangeState=0, laneChangeDirection=0))
owner.manual_turn_inputs = SimpleNamespace(update=lambda: None, apply_blend_settings=lambda _: None, enabled=True,
lateral_snapshot=lambda _: (model, tuple(i*.1 for i in range(33)), .2, True),
assist_permission=lambda: (states, healthy))
parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('LateralMotionControl2', 20)], owner.CAN.main)
out = structs.CarState(vEgoRaw=7., yawRate=-sign * .049)
out.cruiseState.enabled = True
state.out = out.as_reader()
# First production frame announces the extension; subsequent commands run the actual demand and envelope.
owner.update(control.as_reader(), state, 0)
assert owner.mache_lateral is not None
owner.mache_lateral.curvature_last = sign * .02
for tick, (gas, allowed, valid, enabled, brake, cruise) in enumerate((
(False, True, True, True, False, True), (True, True, True, True, False, True),
(False, True, True, True, False, True), (True, False, True, True, False, True),
(True, True, True, True, False, True), (True, True, False, True, False, True),
(True, True, True, True, False, False),
(False, True, True, False, False, True), (False, True, True, True, True, True),
), 1):
out.gasPressed, out.brakePressed = gas, brake
out.cruiseState.enabled = cruise
state.out = out.as_reader()
control.enabled = enabled
states[0].controlsAllowed, healthy = allowed, valid
owner.frame = tick * 5
_, frames = owner.update(control.as_reader(), state, tick * 50_000_000)
parser.update([(tick * 50_000_000, frames)])
fields = parser.vl['LateralMotionControl2']
permitted = enabled and not brake and (not gas or (cruise and allowed and valid))
assert fields['LatCtl_D2_Rq'] == 1
assert fields['LatCtlCurv_No_Actl'] == pytest.approx(-sign * .02, abs=1.01e-5)
assert (fields['LatCtlPath_An_Actl'] != 0.) == permitted
assert (owner.mache_lateral.path_angle_last != 0.) == permitted
owner.manual_turn_inputs = None
out.gasPressed, out.brakePressed, control.enabled = True, False, True
state.out = out.as_reader()
owner.frame = 50
owner.update(control.as_reader(), state, 500_000_000)
assert not owner.mache_lateral.assist_engagement_retained
assert owner.mache_lateral.path_angle_last == 0.
@pytest.mark.parametrize("sign", (-1, 1))
def test_real_controller_recovers_opposite_curve_after_continuous_agreement(sign):
cp = params(CAR.FORD_MUSTANG_MACH_E_MK1)
owner = CarController(DBC[cp.carFingerprint], cp)
state = CarState(cp)
state.update(state.get_can_parsers(cp))
control = structs.CarControl(latActive=True, enabled=True)
control.actuators.curvature = sign * .006
model = SimpleNamespace(orientationRate=SimpleNamespace(z=[0.] * 33),
meta=SimpleNamespace(laneChangeState=0, laneChangeDirection=0))
owner.manual_turn_inputs = SimpleNamespace(update=lambda: None, apply_blend_settings=lambda _: None, enabled=True,
lateral_snapshot=lambda _: (model, tuple(i*.1 for i in range(33)), .2, True))
out = structs.CarState(vEgoRaw=9.5, yawRate=-sign * .038, steeringPressed=True,
steeringAngleDeg=-sign * 30., steeringTorque=-sign * 2.,
leftBlinker=sign < 0, rightBlinker=sign > 0)
state.out = out.as_reader()
owner.update(control.as_reader(), state, 0)
owner.frame = 5
owner.update(control.as_reader(), state, 50_000_000)
assert owner.mache_lateral.manual_turn_latched
out.yawRate, out.steeringAngleDeg, out.steeringTorque = sign * .038, sign * 20., sign * 2.
out.leftBlinker = out.rightBlinker = False
state.out = out.as_reader()
control.actuators.curvature = -sign * .006
model.orientationRate.z = [-sign * .095] * 33
# An opposing driver torque after four agreeing frames must restart the full dwell.
for tick, (torque, recovered) in enumerate([(2., False)] * 4 + [(-2., False)] + [(2., False)] * 4 + [(2., True)], 1):
out.steeringTorque = sign * torque
state.out = out.as_reader()
owner.frame = (tick + 1) * 5
actual, frames = owner.update(control.as_reader(), state, (tick + 1) * 50_000_000)
active = next(data for address, data, _ in frames if address == 0x3d6)[0] >> 4 & 7
assert bool(active) == recovered
assert owner.mache_lateral.path_angle_last == 0.
if recovered:
assert -sign * actual.curvature > 0.
assert not owner.mache_lateral.manual_turn_latched
@pytest.mark.parametrize("speed,expected", ((0.0, 0.0), (15.0, 0.0), (16.0, 0.0), (19.5, 0.2),
(23.0, 0.4), (30.0, 0.4), (40.0, 0.4)))
def test_mach_e_high_speed_preview_ramps_continuously(controller, speed, expected):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
assert controller._high_speed_lookahead_extra(speed, False, False) == pytest.approx(expected)
@pytest.mark.parametrize("fingerprint,flags,driver,lane_change", (
(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD, True, False),
(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD, False, True),
(CAR.FORD_MUSTANG_MACH_E_MK1, 0, False, False),
(CAR.FORD_EXPLORER_MK6, FordFlags.CANFD, False, False),
(CAR.FORD_F_150_MK14, FordFlags.CANFD, False, False),
(CAR.FORD_EDGE_MK2, 0, False, False),
))
def test_high_speed_preview_preserves_takeover_lane_changes_and_other_fords(controller, fingerprint, flags, driver, lane_change):
controller.CP.carFingerprint = fingerprint
controller.CP.flags = flags
assert controller._high_speed_lookahead_extra(30.0, driver, lane_change) == 0.0
@pytest.mark.parametrize("sign", (-1, 1))
@pytest.mark.parametrize("speed", (16.0, 19.5, 23.0, 30.0))
def test_mach_e_high_speed_preview_preserves_constant_curve_authority(controller, monkeypatch, sign, speed):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
curvature = sign * 0.001
controller.curvature_last = curvature
controller.desired_curvature_last = curvature
monkeypatch.setattr(controller, "_predicted_curvature", lambda *_: curvature)
result = controller.update(SimpleNamespace(latActive=True), car_state(speed=speed, curvature=curvature),
SimpleNamespace(curvature=curvature))
assert result.curvature == pytest.approx(curvature)
assert result.curvature_rate == 0.0
assert result.path_angle == 0.0
@pytest.mark.parametrize("driver,lane_change", ((False, False), (True, False), (False, True)))
def test_mach_e_high_speed_preview_update_uses_bounded_horizon(controller, monkeypatch, driver, lane_change):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.set_inputs(controller.model, controller.time_indices, 0.38, True)
monkeypatch.setattr(controller, "_lane_change", lambda: (lane_change, 0))
lookaheads = []
monkeypatch.setattr(controller, "_predicted_curvature", lambda v, t: lookaheads.append(t) or 0.0)
controller.update(SimpleNamespace(latActive=True), car_state(speed=30.0, steering_pressed=driver),
SimpleNamespace(curvature=0.0001))
assert lookaheads[0] == pytest.approx(0.38 if driver or lane_change else 0.78)
assert controller._curvature_lookahead() == pytest.approx(0.38)
@pytest.mark.parametrize("sign", (-1, 1))
@pytest.mark.parametrize("conflicting_rate", (False, True))
@pytest.mark.parametrize("fingerprint,flags,driver,lane_change", (
(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD, False, False),
(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD, True, False),
(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD, False, True),
(CAR.FORD_MUSTANG_MACH_E_MK1, 0, False, False),
(CAR.FORD_EXPLORER_MK6, FordFlags.CANFD, False, False),
(CAR.FORD_EDGE_MK2, 0, False, False),
))
def test_mach_e_unwind_rate_does_not_fight_selected_release(controller, monkeypatch, sign, conflicting_rate,
fingerprint, flags, driver, lane_change):
controller.CP.carFingerprint = fingerprint
controller.CP.flags = flags
controller.desired_curvature_last = sign * 0.013
controller.curvature_last = sign * 0.012
predicted = sign * 0.011
rate = sign * 0.0002 * (1 if conflicting_rate else -1)
controller.curvature_samples.append(predicted - rate * STEER_DT * 8.0)
monkeypatch.setattr(controller, "_predicted_curvature", lambda v, t: predicted if t < 0.5 else sign * 0.006)
monkeypatch.setattr(controller, "_lane_change", lambda: (lane_change, 0))
result = controller.update(SimpleNamespace(latActive=True),
car_state(speed=8.0, curvature=sign * 0.012, steering_pressed=driver),
SimpleNamespace(curvature=sign * 0.012))
suppress = fingerprint == CAR.FORD_MUSTANG_MACH_E_MK1 and flags & FordFlags.CANFD and not driver and not lane_change
assert result.curvature_rate == pytest.approx(0.0 if lane_change or suppress and conflicting_rate else rate)
assert result.path_angle == 0.0
@pytest.mark.parametrize("sign", (-1, 1))
@pytest.mark.parametrize("last,desired,current,preview,rate,suppress", (
(0.011, 0.012, 0.004, 0.014, -0.001023, True),
(0.012, 0.012, 0.004, 0.014, -0.001023, True),
(0.011, 0.012, -0.004, 0.014, -0.001023, True),
(0.013, 0.012, 0.004, 0.014, -0.001023, False),
(-0.011, 0.012, 0.004, 0.014, -0.001023, False),
(0.011, 0.012, 0.011, 0.014, -0.001023, False),
(0.011, 0.012, 0.004, 0.005, -0.001023, False),
(0.011, 0.012, 0.004, 0.010, -0.001023, False),
(0.011, 0.012, 0.004, -0.014, -0.001023, False),
(0.011, 0.012, 0.004, 0.000, -0.001023, False),
(0.011, 0.012, 0.004, 0.014, 0.001023, False),
(0.011, 0.012, 0.004, 0.014, 0.000000, False),
))
def test_mach_e_turn_in_rate_preserves_entry_and_opening_phases(controller, monkeypatch, sign,
last, desired, current, preview, rate, suppress):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.desired_curvature_last = sign * last
monkeypatch.setattr(controller, "_predicted_curvature", lambda *_: sign * preview)
result = controller._turn_in_curvature_rate(sign * desired, sign * current, sign * rate, 8.0, 0.4, False, False)
assert result == pytest.approx(0.0 if suppress else sign * rate)
@pytest.mark.parametrize("fingerprint,flags,driver,lane_change", (
(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD, True, False),
(CAR.FORD_MUSTANG_MACH_E_MK1, FordFlags.CANFD, False, True),
(CAR.FORD_MUSTANG_MACH_E_MK1, 0, False, False),
(CAR.FORD_EXPLORER_MK6, FordFlags.CANFD, False, False),
(CAR.FORD_EDGE_MK2, 0, False, False),
))
@pytest.mark.parametrize("sign", (-1, 1))
def test_turn_in_rate_preserves_driver_lane_change_and_other_fords(controller, monkeypatch, sign,
fingerprint, flags, driver, lane_change):
controller.CP.carFingerprint = fingerprint
controller.CP.flags = flags
controller.desired_curvature_last = sign * 0.011
monkeypatch.setattr(controller, "_predicted_curvature", lambda *_: sign * 0.014)
rate = -sign * 0.001023
assert controller._turn_in_curvature_rate(sign * 0.012, sign * 0.004, rate, 8.0, 0.4, driver, lane_change) == rate
@pytest.mark.parametrize("speed,suppress", ((0.3, False), (2.9, False), (3.0, True), (14.9, True), (15.0, False), (25.0, False)))
@pytest.mark.parametrize("sign", (-1, 1))
def test_mach_e_turn_in_rate_does_not_use_near_zero_speed_yaw_or_change_highway_behavior(controller, monkeypatch,
sign, speed, suppress):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.desired_curvature_last = sign * 0.011
monkeypatch.setattr(controller, "_predicted_curvature", lambda *_: sign * 0.014)
rate = -sign * 0.001023
result = controller._turn_in_curvature_rate(sign * 0.012, sign * 0.004, rate, speed, 0.4, False, False)
assert result == pytest.approx(0.0 if suppress else rate)
@pytest.mark.parametrize("sign", (-1, 1))
def test_mach_e_turn_in_rate_update_uses_previous_desired_and_preserves_unwind(controller, monkeypatch, sign):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.desired_curvature_last = sign * 0.011
controller.curvature_last = sign * 0.012
monkeypatch.setattr(controller, "_predicted_curvature", lambda *_: sign * 0.014)
rate = -sign * 0.0002
for desired in (0.012, 0.012, 0.011):
controller.curvature_samples.clear()
controller.curvature_samples.append(sign * 0.014 - rate * STEER_DT * 8.0)
result = controller.update(SimpleNamespace(latActive=True), car_state(speed=8.0, curvature=sign * 0.004),
SimpleNamespace(curvature=sign * desired))
assert result.curvature_rate == pytest.approx(rate if desired == 0.011 else 0.0)
assert controller.desired_curvature_last == sign * desired
assert result.active and result.path_angle == 0.0
def test_mach_e_mild_braking_actual_wire_and_driver_override():
from opendbc.car.ford.interface import CarInterface
cp = params(CAR.FORD_MUSTANG_MACH_E_MK1, alpha=True)
assert list(cp.longitudinalTuning.kiV) == pytest.approx([0.3])
assert cp.longitudinalActuatorDelay == pytest.approx(0.15)
assert CarInterface.get_pid_accel_limits(cp, 11., 20.)[0] == -3.5
cc = CarController(DBC[cp.carFingerprint], cp)
cs = CarState(cp)
cs.update(cs.get_can_parsers(cp))
cs.out = structs.CarState(vEgo=11., vEgoRaw=11.).as_reader()
command = structs.CarControl(enabled=True)
command.actuators.longControlState = structs.CarControl.Actuators.LongControlState.pid
parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('ACCDATA', 50)], 0)
for frame, active, request, expected in ((2, True, -.5, -.04), (4, False, 0., 0.),
(8, True, -.5, -.04), (14, True, -3.5, -.11)):
cc.frame = frame
command.longActive = active
command.actuators.accel = request
actual, frames = cc.update(command.as_reader(), cs, frame * 10_000_000)
parser.update([(frame * 10_000_000, [msg for msg in frames if msg[0] == 0x186])])
values = parser.vl['ACCDATA']
assert actual.accel == pytest.approx(expected)
assert values['AccBrkTot_A_Rq'] == pytest.approx(expected, abs=.01)
assert values['AccPrpl_A_Rq'] == pytest.approx(expected if active and request > -1. else -5., abs=.01)
assert values['Cmbb_B_Enbl'] == int(active)
assert values['AccResumEnbl_B_Rq'] == int(active)
# Dom Green Garden / Sea Turtles / Waffles actual demand update regressions.
@pytest.mark.parametrize("sign", (-1, 1))
def test_mach_e_unwind_plateau_sequence_and_reset(controller, monkeypatch, sign):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.model = SimpleNamespace(orientationRate=SimpleNamespace(z=[0.0] * 33),
meta=SimpleNamespace(laneChangeState=0, laneChangeDirection=0))
monkeypatch.setattr(controller, "_predicted_curvature", lambda v, t: sign * (0.009 if t < 0.5 else 0.004))
controller.desired_curvature_last = sign * 0.012
controller.curvature_last = sign * 0.009
state = car_state(speed=10.0, curvature=sign * 0.009)
CC = SimpleNamespace(latActive=True)
commands = [controller.update(CC, state, SimpleNamespace(curvature=sign * x)).curvature for x in (0.011, 0.011)]
assert commands[1] == pytest.approx(commands[0])
for _ in range(6):
result = controller.update(CC, state, SimpleNamespace(curvature=sign * 0.011))
assert sign * result.curvature > sign * commands[0]
assert controller.unwind_preview_hold_timer == 0.0
controller.unwind_preview_hold_timer = 0.10
state.out.steeringPressed = True
controller.update(CC, state, SimpleNamespace(curvature=sign * 0.011))
assert controller.unwind_preview_hold_timer == 0.0
controller.unwind_preview_hold_timer = 0.10
assert not controller.update(SimpleNamespace(latActive=False), state, SimpleNamespace(curvature=0.0)).active
assert controller.unwind_preview_hold_timer == 0.0
state.out.steeringPressed = False
controller.update(CC, state, SimpleNamespace(curvature=sign * 0.011))
assert controller.unwind_preview_hold_timer == 0.0
@pytest.mark.parametrize("rate_sign", (-1, 1))
def test_mach_e_unwind_plateau_rate_consistency(controller, monkeypatch, rate_sign):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.model = SimpleNamespace(orientationRate=SimpleNamespace(z=[0.0] * 33),
meta=SimpleNamespace(laneChangeState=0, laneChangeDirection=0))
controller.desired_curvature_last = 0.010
controller.curvature_last = 0.010
controller.unwind_preview_hold_timer = 0.10
rate = rate_sign * 0.0001
controller.curvature_samples.append(0.010 - rate * STEER_DT * 10.0)
monkeypatch.setattr(controller, "_predicted_curvature", lambda v, t: 0.010 if t < 0.5 else 0.004)
result = controller.update(SimpleNamespace(latActive=True), car_state(speed=10.0, curvature=0.011),
SimpleNamespace(curvature=0.010))
assert result.curvature_rate == pytest.approx(0.0 if rate_sign > 0 else rate)
@pytest.mark.parametrize("manual_turn", (False, True))
def test_mach_e_unwind_plateau_resets_when_stopped_or_manual_turn_latched(controller, manual_turn):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.unwind_preview_hold_timer = 0.10
controller.manual_turn_latched = manual_turn
state = car_state(speed=10.0 if manual_turn else 0.0, steering_angle=20.0 if manual_turn else 0.0)
controller.update(SimpleNamespace(latActive=True), state, SimpleNamespace(curvature=0.010))
assert controller.unwind_preview_hold_timer == 0.0
@pytest.mark.parametrize("sign", (-1, 1))
def test_mach_e_unwind_release_update_yields_and_reverses(controller, monkeypatch, sign):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.model = SimpleNamespace(orientationRate=SimpleNamespace(z=[0.0] * 33),
meta=SimpleNamespace(laneChangeState=0, laneChangeDirection=0))
monkeypatch.setattr(controller, "_predicted_curvature", lambda v, t: sign * (0.014 if t < 0.5 else 0.004))
controller.desired_curvature_last = sign * 0.016
controller.curvature_last = sign * 0.012
CC = SimpleNamespace(latActive=True)
state = car_state(speed=10.0, curvature=sign * 0.010)
result = controller.update(CC, state, SimpleNamespace(curvature=sign * 0.012))
assert result.curvature == pytest.approx(sign * 0.010)
assert result.path_angle == 0.0
state.out.steeringPressed = True
controller.update(CC, state, SimpleNamespace(curvature=sign * 0.012))
assert controller.unwind_preview_hold_timer == 0.0
state.out.steeringPressed = False
state.out.yawRate = -sign * 0.004 * state.out.vEgoRaw
for _ in range(20):
result = controller.update(CC, state, SimpleNamespace(curvature=-sign * 0.008))
assert sign * result.curvature < 0.0
assert controller.unwind_preview_hold_timer == 0.0
assert not controller.update(SimpleNamespace(latActive=False), state, SimpleNamespace(curvature=0.0)).active
@pytest.mark.parametrize("sign", (-1, 1))
def test_mach_e_reversal_zero_crossing_after_preview_sequence(controller, monkeypatch, sign):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.CP.flags = FordFlags.CANFD
controller.lookahead = 0.4
controller.desired_curvature_last = sign * 0.00155
controller.curvature_last = -sign * 0.00065
monkeypatch.setattr(controller, "_predicted_curvature",
lambda _v, t: sign * 0.0007 if t < 1.0 else -sign * 0.005)
result = controller.update(SimpleNamespace(latActive=True), car_state(speed=12.0, curvature=sign * 0.00462),
SimpleNamespace(curvature=-sign * 0.00034))
assert result.active
assert result.curvature == pytest.approx(-sign * 0.00138)
assert result.path_angle == 0.0
@pytest.mark.parametrize("sign", (-1, 1))
def test_dom_unwind_release_actual_caller_keeps_modern_envelope_and_wire(sign):
import struct
cp = params(CAR.FORD_MUSTANG_MACH_E_MK1)
cc = CarController(DBC[cp.carFingerprint], cp)
cs = CarState(cp)
cs.update(cs.get_can_parsers(cp))
cs.out = structs.CarState(vEgoRaw=10., yawRate=-sign * .10).as_reader()
control = structs.CarControl(latActive=True, enabled=True)
control.actuators.curvature = sign * .012
model = SimpleNamespace(orientationRate=SimpleNamespace(z=[sign * (.14 if i < 5 else .04) for i in range(33)]),
meta=SimpleNamespace(laneChangeState=0, laneChangeDirection=0))
cc.manual_turn_inputs = SimpleNamespace(update=lambda: None, apply_blend_settings=lambda _: None, enabled=True,
lateral_snapshot=lambda _: (model, tuple(i*.1 for i in range(33)), .2, True))
cc.update(control.as_reader(), cs, 0)
cc.mache_lateral.desired_curvature_last = sign * .016
cc.mache_lateral.curvature_last = sign * .012
cc.frame = 5
actual, frames = cc.update(control.as_reader(), cs, 50_000_000)
assert cc.mache_lateral_demand.curvature == pytest.approx(sign * .010)
assert abs(actual.curvature - sign * .012) <= MAX_LATERAL_JERK / 100. * STEER_DT + 1e-12
assert abs(actual.curvature) <= min(.02, MAX_LATERAL_ACCEL / 100.)
assert cc.mache_lateral.curvature_last == cc.apply_curvature_last
# Actuators.curvature is Float32; internal emitted histories are doubles.
assert actual.curvature == struct.unpack("f", struct.pack("f", cc.apply_curvature_last))[0]
parser = CANParser(DBC[cp.carFingerprint][Bus.pt], [('LateralMotionControl2', 20)], cc.CAN.main)
parser.update([(50_000_000, frames)])
fields = parser.vl['LateralMotionControl2']
assert fields['LatCtl_D2_Rq'] == 1
assert fields['LatCtlCurv_No_Actl'] == pytest.approx(-actual.curvature, abs=1.01e-5)
assert fields['LatCtlPath_An_Actl'] == 0.
control.latActive = False
cc.frame = 10
actual, _ = cc.update(control.as_reader(), cs, 100_000_000)
assert actual.curvature == 0.
assert cc.mache_lateral.unwind_preview_hold_timer == 0.
@@ -1,140 +0,0 @@
import pytest
from opendbc.car.ford import mache_lateral as original
from opendbc.car import Bus
from opendbc.can import CANPacker
candidate = original
def cp():
from opendbc.car import gen_empty_fingerprint
from opendbc.car.ford.interface import CarInterface
from opendbc.car.ford.values import CAR
fingerprint = gen_empty_fingerprint()
fingerprint[0] = {0x5A: 8, 0x3A6: 8, 0x3A7: 8}
result = CarInterface.get_params(CAR.FORD_MUSTANG_MACH_E_MK1, fingerprint, [], True, False, False)
result.alternativeExperience = 32
assert result.safetyConfigs[-1].safetyParam == 19 and result.flags == 3
return result
@pytest.mark.parametrize("sign", [-1, 1])
def test_recorded_native_window_prevents_first_rejection_and_cascade(sign):
from opendbc.safety.tests.test_ford_mach_e_extended import TestFordMachEExtended
from opendbc.safety.tests.test_ford_aol import TestFordAolDriverIntent
f = TestFordMachEExtended()
f.SAFETY_PARAM = 19
f.setUp()
a = TestFordAolDriverIntent()
a.setUp()
def setup():
a.reset(19)
a.arm()
a.pump(12, mask=3, speed=8.805556, curvature=sign * 0.02)
f._rx(f._pcm_status_msg(True))
a.safety.aol_set_host_request(3)
assert a.safety.aol_get_permission_mask() == 3
assert f.announce()
a.pump(2, mask=3, main=4, speed=8.775, curvature=sign * 0.02)
f._set_prev_desired_angle(sign * 0.02)
demand = original.FordLateralResult(active=True, curvature=sign * 0.02, path_angle=sign * 0.0035, curvature_rate=-0.001023)
setup()
old = original.bounded_command(candidate.MachELateralController(cp()), demand, sign * 0.02, 8.775)
assert not f._tx(f.command(curvature=old.curvature, angle=old.path_angle, rate=old.curvature_rate, increment_timer=False))
assert not f._tx(f.command(curvature=sign * 0.02, angle=0, rate=0, increment_timer=False))
setup()
new = candidate.bounded_command(candidate.MachELateralController(cp()), demand, sign * 0.02, 8.775, speed_window=(8.775, 8.806, 0.031))
assert new.curvature == old.curvature and new.path_angle == 0
assert f._tx(f.command(curvature=new.curvature, angle=new.path_angle, rate=new.curvature_rate, increment_timer=False))
assert f._tx(f.command(curvature=sign * 0.02, angle=0, rate=0, increment_timer=False))
a.tearDown()
@pytest.mark.parametrize("speed", [0, 2.99, 3, 5, 8.7, 8.8, 12, 30])
@pytest.mark.parametrize("active", [False, True])
def test_zero_span_window_preserves_default_command(speed, active):
d = original.FordLateralResult(active=active, curvature=0.02, path_angle=0.1)
assert candidate.bounded_command(candidate.MachELateralController(cp()), d, 0.019, speed) == candidate.bounded_command(
candidate.MachELateralController(cp()), d, 0.019, speed, speed_window=(speed, speed, 0.0)
)
@pytest.mark.parametrize("param,ae,supported", [(19, 32, True), (18, 32, False), (19, 0, False), (147, 32, False), (67, 32, False)])
def test_exact_profile(param, ae, supported):
c = cp()
c.safetyConfigs[0].safetyParam = param
c.alternativeExperience = ae
assert candidate.path_speed_window_supported(c) == supported
def test_actual_carstate_tracks_six_raw_samples():
from opendbc.car.ford.carstate import CarState
c = cp()
cs = CarState(c)
parsers = cs.get_can_parsers(c)
p = CANPacker("ford_lincoln_base_pt")
values = [8.7, 8.805556, 8.79, 8.78, 8.775, 8.77, 8.76]
for i, v in enumerate(values):
parsers[Bus.pt].update(
[[(i + 1) * 20_000_000, [p.make_can_msg("BrakeSysFeatures", 0, {"Veh_V_ActlBrk": v * 3.6, "VehVActlBrk_D_Qf": 3, "VehVActlBrk_No_Cnt": i + 1})]]]
)
cs.update(parsers)
assert cs.bluepilot_speed_window is not None
assert cs.bluepilot_speed_samples is not None
assert cs.bluepilot_speed_window[1] >= 8.8
assert len(cs.bluepilot_speed_samples) == 6
for i in range(7, 13):
parsers[Bus.pt].update(
[[(i + 1) * 20_000_000, [p.make_can_msg("BrakeSysFeatures", 0, {"Veh_V_ActlBrk": 8.7 * 3.6, "VehVActlBrk_D_Qf": 3, "VehVActlBrk_No_Cnt": i + 1})]]]
)
cs.update(parsers)
assert cs.bluepilot_speed_window is not None
assert cs.bluepilot_speed_window[1] < 8.8
@pytest.mark.parametrize("high", [8.79, 8.8, 8.81, 9.0])
def test_rising_window_previous_nonzero_path(high):
demand = original.FordLateralResult(active=True, curvature=0.02, path_angle=0.05)
out = candidate.bounded_command(candidate.MachELateralController(cp()), demand, 0.02, 8.775, 0.05, speed_window=(8.7, high, 0.1))
if high >= 8.8:
assert out.path_angle == 0
else:
assert 0 <= out.path_angle <= 0.05
assert out.curvature == 0.02
def test_controller_uses_native_speed_window():
from opendbc.can import CANParser
from opendbc.car import structs
from opendbc.car.ford.carcontroller import CarController
from opendbc.car.ford.carstate import CarState
from opendbc.car.ford.values import DBC
from openpilot.common.prefix import OpenpilotPrefix
with OpenpilotPrefix():
c = cp()
controller = CarController(DBC[c.carFingerprint], c)
controller.manual_turn_inputs = None
controller.mache_extended_announced = True
assert controller.mache_lateral is not None
controller.mache_lateral.curvature_last = 0.02
controller.mache_lateral.update = lambda *_: candidate.FordLateralResult(active=True, curvature=0.02, path_angle=0.0035)
state = CarState(c)
state.update(state.get_can_parsers(c))
state.bluepilot_speed_window = (8.775, 8.806, 0.031)
cs = structs.CarState(canValid=True, vEgo=8.775, vEgoRaw=8.775, gearShifter=structs.CarState.GearShifter.drive)
cs.cruiseState.available = True
state.out = cs.as_reader()
command = structs.CarControl(latActive=True, longActive=True)
_, frames = controller.update(command.as_reader(), state, 1_000_000_000)
parser = CANParser("ford_lincoln_base_pt", [("LateralMotionControl2", 50)], 0)
parser.update([[1_000_000_000, frames]])
assert any(address == 0x3D6 for address, _, _ in frames)
assert abs(parser.vl["LateralMotionControl2"]["LatCtlPath_An_Actl"]) < 0.00001
assert abs(abs(parser.vl["LateralMotionControl2"]["LatCtlCurv_No_Actl"]) - 0.02) < 0.00001
@@ -1,144 +0,0 @@
import unittest
from types import SimpleNamespace
from opendbc.can import CANParser
from opendbc.car import Bus, structs
from opendbc.car.ford import fordcan
from opendbc.car.ford.carcontroller import CarController
from opendbc.car.ford.carstate import CarState
from opendbc.car.ford.manual_turn import HumanTurnDetector, ManualTurnLatch
from opendbc.car.ford.tests.test_three_ports import params as _params
from opendbc.car.ford.values import CAR, DBC
def turn_state(direction=1):
return SimpleNamespace(out=SimpleNamespace(steeringPressed=True, steeringAngleDeg=-direction * 12.,
steeringTorque=-direction, rightBlinker=direction == 1,
leftBlinker=direction == -1, yawRate=0., vEgoRaw=20.))
def params(candidate):
cp = _params(candidate)
if candidate == CAR.FORD_MUSTANG_MACH_E_MK1:
cp.safetyConfigs[0].safetyParam = 3 if cp.openpilotLongitudinalControl else 2
return cp
class TestManualTurn(unittest.TestCase):
def test_detector_preturned_and_new_turn_hold(self):
for preturned, frames in ((True, 60), (False, 30)):
detector = HumanTurnDetector()
if not preturned:
self.assertFalse(detector.update(True, True, 0.))
for _ in range(frames - 1):
self.assertFalse(detector.update(True, True, 46.))
self.assertTrue(detector.update(True, True, 46.))
self.assertFalse(detector.update(True, False, 46.))
self.assertFalse(detector.update(False, True, 46.))
def test_signed_directional_recovery_and_resets(self):
command = SimpleNamespace(latActive=True)
for direction in (-1, 1):
for desired, current, release in ((.006, .003, False), (.005, .003, True), (0., .003, True), (-.006, .003, True)):
with self.subTest(direction=direction, desired=desired):
latch = ManualTurnLatch()
state = turn_state(direction)
self.assertTrue(latch.update(command, state, direction * desired, True, False))
state.out.steeringPressed = False
state.out.steeringAngleDeg = 0.
state.out.rightBlinker = state.out.leftBlinker = False
state.out.yawRate = -direction * current * 20.
for _ in range(4):
self.assertTrue(latch.update(command, state, direction * desired, True, False))
self.assertEqual(latch.update(command, state, direction * desired, True, False), not release)
self.assertFalse(latch.update(command, state, direction * desired, False, False))
self.assertEqual(latch.manual_turn_direction, 0.)
state = turn_state(direction)
self.assertFalse(latch.update(command, state, .006, True, True))
self.assertTrue(latch.update(command, state, .006, True, False))
command.latActive = False
self.assertFalse(latch.update(command, state, .006, True, False))
command.latActive = True
def test_no_signal_direction_and_recovery_interruptions(self):
latch = ManualTurnLatch()
command = SimpleNamespace(latActive=True)
state = turn_state(-1)
state.out.leftBlinker = False
state.out.steeringAngleDeg = 46.
for _ in range(60):
latched = latch.update(command, state, -.006, True, False)
self.assertTrue(latched)
self.assertEqual(latch.manual_turn_direction, -1.)
for attribute, value in (('steeringPressed', True), ('rightBlinker', True), ('steeringAngleDeg', 13.)):
state.out.steeringPressed = False
state.out.rightBlinker = False
state.out.steeringAngleDeg = 0.
latch.update(command, state, -.006, True, False)
setattr(state.out, attribute, value)
self.assertTrue(latch.update(command, state, -.006, True, False))
self.assertEqual(latch.manual_turn_recovery_timer, 0.)
def test_missing_model_blocks_entry_but_preserves_recovery(self):
command = SimpleNamespace(latActive=True)
state = turn_state()
latch = ManualTurnLatch()
self.assertFalse(latch.update(command, state, .006, True, False, False))
self.assertTrue(latch.update(command, state, .006, True, False, True))
state.out.steeringPressed = False
state.out.rightBlinker = False
state.out.steeringAngleDeg = 0.
for _ in range(4):
self.assertTrue(latch.update(command, state, 0., True, False, False))
self.assertFalse(latch.update(command, state, 0., True, False, False))
def test_controller_real_can_yield_counter_and_recovery(self):
candidate = CAR.FORD_MUSTANG_MACH_E_MK1
cp = params(candidate)
state = CarState(cp)
state.update(state.get_can_parsers(cp))
out = structs.CarState()
out.vEgoRaw = 20.
out.steeringPressed = True
out.steeringAngleDeg = -12.
out.steeringTorque = -1.
out.rightBlinker = True
state.out = out.as_reader()
command = structs.CarControl()
command.latActive = True
command.actuators.curvature = .006
controller = CarController(DBC[candidate], cp)
controller.manual_turn_inputs = SimpleNamespace(update=lambda: (True, False, True))
parser = CANParser(DBC[candidate][Bus.pt], [('LateralMotionControl2', 20)], controller.CAN.main)
for index in range(10):
controller.frame = index * 5
if index == 1:
out.steeringPressed = False
out.steeringAngleDeg = 0.
out.rightBlinker = False
if index == 7:
command.actuators.curvature = -.001
state.out = out.as_reader()
_, messages = controller.update(command.as_reader(), state, (index + 1) * 50_000_000)
wire = next(message for message in messages if message[0] == 0x3D6)
expected = fordcan.create_lat_ctl2_msg(controller.packer, controller.CAN, int(index >= 7), 0., 0.,
-controller.apply_curvature_last, 0., index)
self.assertEqual(wire, expected)
parser.update([((index + 1) * 50_000_000, messages)])
values = parser.vl['LateralMotionControl2']
self.assertEqual(values['LatCtlPath_No_Cnt'], index)
self.assertEqual(values['LatCtl_D2_Rq'], int(index >= 7))
if index < 7:
self.assertAlmostEqual(values['LatCtlCurv_No_Actl'], 0., places=5)
self.assertEqual(controller.apply_curvature_last, 0.)
self.assertTrue(parser.can_valid)
def test_other_platform_and_lka_do_not_create_inputs(self):
for candidate in (CAR.FORD_BRONCO_SPORT_MK1, CAR.FORD_TRANSIT_MK5):
cp = params(candidate)
controller = CarController(DBC[candidate], cp)
self.assertIsNone(controller.manual_turn)
state = CarState(cp)
state.update(state.get_can_parsers(cp))
state.out = structs.CarState().as_reader()
controller.update(structs.CarControl().as_reader(), state, 0)
@@ -1,207 +0,0 @@
"""Current-strategy Edge/Mondeo wiring and current native scalar-curvature proof.
Source-only authored cases; runtime must bind actual DEBUG/RELEASE libraries.
"""
import os
from pathlib import Path
import unittest
from unittest.mock import patch
from openpilot.cereal import messaging
from openpilot.common.params import Params
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.controls.controlsd import Controls
from openpilot.starpilot.tests import test_vehicle_preferences as startup
from openpilot.starpilot.lateral.tests import test_lane_runtime as lane
from opendbc.car import gen_empty_fingerprint, structs
from opendbc.car.ford.new_port_curvature import qualified, create_controller
from opendbc.car.ford.tests.test_three_ports import params as factory
from opendbc.car.ford.values import CAR, FordFlags
from opendbc.safety.tests import test_ford_aol as physical
from opendbc.safety.tests.libsafety import libsafety_py
PROFILES = (CAR.FORD_EDGE_MK2, CAR.FORD_MONDEO_MK5)
class TestNewPortCurvature(unittest.TestCase):
def test_exact_existing_factory_profiles_and_no_namespace_or_topology_inference(self):
for identity in PROFILES:
for alpha, release in ((False, False), (True, False), (False, True), (True, True)):
cp = factory(identity, alpha, release)
expected_long = bool(alpha)
self.assertTrue(cp.pcmCruise)
self.assertEqual(cp.openpilotLongitudinalControl, expected_long)
self.assertEqual(cp.safetyConfigs[-1].safetyParam, (8 if identity == CAR.FORD_EDGE_MK2 else 10) + int(expected_long))
self.assertTrue(qualified(cp))
self.assertIsNotNone(create_controller(cp))
original = cp.to_bytes()
cp.clear_write_flag()
create_controller(cp)
self.assertEqual(cp.to_bytes(), original)
cp.clear_write_flag()
for field, value in (("passive", True), ("dashcamOnly", True), ("notCar", True), ("alternativeExperience", 64),
("pcmCruise", False), ("openpilotLongitudinalControl", not expected_long)):
with structs.CarParams.from_bytes(original) as prior:
changed = prior.as_builder()
setattr(changed, field, value)
self.assertFalse(qualified(changed))
for word in (0, 2, 12, 18, 32, 66, 128, 130, 65535):
with structs.CarParams.from_bytes(original) as prior:
changed = prior.as_builder()
changed.safetyConfigs[-1].safetyParam = word
self.assertIsNone(create_controller(changed))
cp.alternativeExperience = 32
self.assertTrue(qualified(cp))
cp.safetyConfigs = [cp.safetyConfigs[-1], cp.safetyConfigs[-1]]
self.assertFalse(qualified(cp))
self.assertIsNone(create_controller(factory(CAR.FORD_TRANSIT_MK5)))
self.assertIsNone(create_controller(factory(CAR.FORD_BRONCO_SPORT_MK1)))
def test_saved_card_controls_actual_parser_provider_sender_and_native_both_signs(self):
safety = libsafety_py.libsafety
for identity in PROFILES:
for direction in (-1, 1):
with self.subTest(identity=identity, direction=direction), OpenpilotPrefix(), \
patch.dict(os.environ, {"REPLAY": "1", "SIMULATION": "1"}), \
patch("openpilot.selfdrive.controls.controlsd.messaging.PubMaster"):
saved = Params()
helper = startup.TestVehicleStartupPreferences()
helper.setUp()
helper.params = saved
native = physical.TestFordAolDriverIntent()
try:
for key, value in (("OpenpilotEnabledToggle", True), ("SafeMode", False), ("AlwaysOnLateral", False),
("AlphaLongitudinalEnabled", False), ("IsReleaseBranch", False)):
saved.put_bool(key, value, block=True)
observed = gen_empty_fingerprint()
observed[0][0x5A] = 8
observed[2].update({0x3D6: 8, 0x186: 8})
host, constructed, cp = helper.start(identity, key="FordHumanTurnDetection", observed=observed,
capture=lambda ci: (ci.CP.safetyConfigs[-1].safetyParam, ci.CC.new_port_curvature is not None))
self.assertEqual(constructed, [(8 if identity == CAR.FORD_EDGE_MK2 else 10, True)])
self.assertEqual(host.CI.CP.to_dict(), cp.to_dict())
self.assertTrue(qualified(cp))
owner = host.CI.CC.new_port_curvature
inputs = host.CI.CC.manual_turn_inputs
self.assertIsNotNone(inputs)
inputs.sm = messaging.SubMaster(["modelV2", "lateralDelay"])
host.sm = messaging.SubMaster(["carControl"])
controls = Controls()
native.setUp()
native.word, native.tick = cp.safetyConfigs[-1].safetyParam, 0
safety.set_alternative_experience(0)
self.assertEqual(safety.set_safety_hooks(structs.CarParams.SafetyModel.ford, native.word), 0)
safety.init_tests()
received, sent, active_curvatures = [], [], []
def rx(name, values, *, native=native, received=received):
self.assertLessEqual(set(values), set(native.packer.dbc.name_to_msg[name].sigs))
frame = native.packer.make_can_msg(name, 0, values)
addr, data, bus = frame
self.assertTrue(safety.safety_rx_hook(libsafety_py.make_CANPacket(addr, bus, data)))
received.append(frame)
host.publish_sendcan = lambda frames, valid=True, sent=sent: sent.extend(frames) if valid else self.fail("Invalid Card trace")
host.ci_initialized = True
host.CI.update([])
saw_human_zero = saw_fault_zero = saw_stale_model = saw_rate_neutral = False
for tick in range(600):
received.clear()
with patch.object(native, "rx", rx):
native.pump(1, main=0 if tick < 30 else 4, speed=15.)
now = (1_000_000 + native.tick * 10_000) * 1000
pressed = 100 <= tick < 430
fault = 460 <= tick < 480
extra: list[tuple[str, int, dict[str, float]]] = [("Cluster_Info1_FD1", 0, {"AccEnbl_B_RqDrv": 1}),
("BodyInfo_3_FD1", 0, {}), ("RCMStatusMessage2_FD1", 0, {"FirstRowBuckleDriver": 1}),
("INSTRUMENT_PANEL", 0, {}), ("IPMA_Data", 2, {}), ("ACCDATA", 2, {}),
("ACCDATA_2", 2, {}), ("ACCDATA_3", 2, {}),
("EPAS_INFO", 0, {"SteeringColumnTorque": 2 if pressed else 0, "EPAS_Failure": 2 if fault else 0})]
if cp.flags & FordFlags.ALT_STEER_ANGLE:
extra += [("ParkAid_Data", 0, {"ExtSteeringAngleReq2": 60 if pressed else 0, "EPASExtAngleStatReq": 0, "ApaSys_D_Stat": 0}),
("SteeringPinion_Data_Alt", 0, {"StePinRelInit_An_Sns": 60 if pressed else 0})]
else:
extra += [("SteeringPinion_Data", 0, {"StePinComp_An_Est": 60 if pressed else 0, "StePinCompAnEst_D_Qf": 3})]
host_sources = [native.packer.make_can_msg(name, bus, values) for name, bus, values in extra]
for addr, data, bus in host_sources:
self.assertTrue(safety.safety_rx_hook(libsafety_py.make_CANPacket(addr, bus, data)))
received.extend(host_sources)
state = host.CI.update([(now, list(received))])
if tick < 40:
continue
self.assertTrue(state.canValid)
self.assertFalse(state.canTimeout)
self.assertTrue(safety.get_controls_allowed())
with patch("openpilot.starpilot.controller_extensions.time.monotonic_ns", return_value=now), \
patch("openpilot.selfdrive.car.card.time.monotonic", return_value=now / 1e9), \
patch.object(inputs.sm, "update", return_value=None):
feed_events = []
with patch.object(controls.sm, "update_msgs", side_effect=lambda _, events, collector=feed_events: collector.extend(events)):
lane.feed(controls, now, tick, speed=15.)
car_state = messaging.new_message("carState", valid=True, logMonoTime=now)
car_state.carState = state
preview = messaging.new_message("modelV2", valid=True, logMonoTime=now)
preview.modelV2 = lane.model()
preview.modelV2.action.desiredCurvature = direction * .0002
predicted = .012 + (tick - 430) * .0002 if 430 <= tick < 460 else .0012
preview.modelV2.orientationRate.z = [direction * predicted * 15.] * 33
delay = messaging.new_message("lateralDelay", valid=True, logMonoTime=now)
delay.lateralDelay.lateralDelay = .38
replacements = {"carState": car_state.as_reader(), "modelV2": preview.as_reader(), "lateralDelay": delay.as_reader()}
controls.sm.update_msgs(now / 1e9, [event for event in feed_events
if event.which() not in replacements] + list(replacements.values()))
if tick < 490:
inputs.sm.update_msgs(now / 1e9, [preview.as_reader(), delay.as_reader()])
command, controller_log = controls.state_control()
self.assertTrue(command.enabled)
control_events = []
def capture_control(service, event, collector=control_events):
if service == "carControl":
collector.append(messaging.log_from_bytes(event.to_bytes()))
with patch.object(controls.pm, "send", side_effect=capture_control):
controls.publish(command, controller_log)
self.assertEqual(len(control_events), 1)
self.assertEqual(control_events[0].carControl.to_dict(), command.to_dict())
self.assertEqual(control_events[0].valid, state.canValid)
host.sm.update_msgs(now / 1e9, control_events)
command = host.sm["carControl"]
host.can_log_mono_time = now
sent.clear()
host.controls_update(state, command)
if tick > 510:
self.assertIsNone(owner.model)
saw_stale_model = True
for addr, data, bus in sent:
if addr not in (0x3CA, 0x3D3, 0x3D6):
continue
self.assertTrue(safety.safety_tx_hook(libsafety_py.make_CANPacket(addr, bus, data)), (identity, tick, hex(addr)))
if addr == 0x3CA:
self.assertEqual(data[4] & 3, 0) # Existing neutral body, no extension announcement.
elif addr in (0x3D3, 0x3D6):
fd = addr == 0x3D6
mode = (data[0] >> 4) & 7 if fd else (data[4] >> 2) & 7
curvature = ((data[2] << 3) | (data[3] >> 5)) - 1000 if fd else ((data[0] << 3) | (data[1] >> 5)) - 1000
rate = (data[6] << 3) | (data[7] >> 5) if fd else ((data[1] & 31) << 8) | data[2]
self.assertEqual(rate, 1024 if fd else 4096)
if 430 <= tick < 460 and host.CI.CC.new_port_curvature_demand.curvature_rate != 0:
saw_rate_neutral = True
if 400 <= tick < 425:
self.assertEqual((mode, curvature), (0, 0))
saw_human_zero = True
if fault:
self.assertEqual((mode, curvature), (0, 0))
saw_fault_zero = True
if 70 <= tick < 100 and mode and curvature:
active_curvatures.append(curvature)
self.assertAlmostEqual(owner._curvature_lookahead(), .38, places=6)
self.assertGreater(direction * host.CI.CC.new_port_curvature_demand.curvature, .0002)
self.assertTrue(active_curvatures)
self.assertTrue(all(value * direction < 0 for value in active_curvatures))
self.assertTrue(saw_human_zero and saw_fault_zero and saw_stale_model and saw_rate_neutral)
self.assertEqual(Path(saved.get_param_path("FordHumanTurnDetection")).read_bytes(), b"1")
finally:
helper.doCleanups()
safety.set_alternative_experience(0)
safety.set_safety_hooks(structs.CarParams.SafetyModel.noOutput, 0)
@@ -1,177 +0,0 @@
"""Closed, init-only measurement selection; disabled behavior stays unchanged."""
from types import SimpleNamespace
import pytest
from opendbc.car import gen_empty_fingerprint, structs
from opendbc.car.ford.interface import CarInterface
from opendbc.car.ford.pinion import GEOMETRY, ENABLE, SHIFT, MASK, decode, select, configured, Measurement
from opendbc.car.ford.aol import qualified
from opendbc.car.vehicle_model import VehicleModel, calc_slip_factor
from opendbc.can.packer import CANPacker
from opendbc.safety.tests.libsafety import libsafety_py
def params(car, long=False):
fp = gen_empty_fingerprint()
fp[0][0x5A] = 8
fp[2].update({0x3D6: 8, 0x186: 8})
return CarInterface.get_params(car, fp, [], long, False, False)
def test_closed_decoder_matches_native_exhaustively():
n = libsafety_py.libsafety
for word in range(65536):
decoded = decode(word)
assert n.get_ford_pinion_base(word) == (decoded[0] if decoded is not None else 65535)
@pytest.mark.parametrize('car', list(GEOMETRY))
@pytest.mark.parametrize('long', [False, True])
def test_default_off_and_exact_geometry_selection(car, long):
cp = params(car, long)
before = cp.to_dict()
assert not select(cp, False)
assert cp.to_dict() == before
assert select(cp, True)
assert configured(cp) and qualified(cp)
assert decode(cp.safetyConfigs[-1].safetyParam)[1] == GEOMETRY[car]
# Cross-family/identity corruption is not accepted by the host.
cp.safetyConfigs[-1].safetyParam = (cp.safetyConfigs[-1].safetyParam & ~MASK) | ENABLE | ((GEOMETRY[car] % 12 + 1) << SHIFT)
assert not configured(cp) and not qualified(cp)
@pytest.mark.parametrize('car', list(GEOMETRY))
def test_geometry_matches_actual_vehicle_model_and_native_sign(car):
cp = params(car)
index = GEOMETRY[car]
n = libsafety_py.libsafety
vm = VehicleModel(cp)
assert n.get_ford_pinion_slip(index) == pytest.approx(calc_slip_factor(vm), abs=2e-10)
assert n.get_ford_pinion_ratio(index) == pytest.approx(cp.steerRatio)
assert n.get_ford_pinion_wheelbase(index) == pytest.approx(cp.wheelbase)
assert select(cp, True)
host = Measurement(cp)
packer = CANPacker('ford_lincoln_base_pt')
for speed in (0., 5., 15., 30.):
for angle in (-100., -10., 0., 10., 100.):
n.set_alternative_experience(0)
n.set_safety_hooks(structs.CarParams.SafetyModel.ford, cp.safetyConfigs[-1].safetyParam)
assert n.get_ford_pinion_enabled()
for counter in range(6):
a, d, b = packer.make_can_msg('BrakeSysFeatures', 0, {'Veh_V_ActlBrk': speed * 3.6, 'VehVActlBrk_D_Qf': 3})
assert n.safety_rx_hook(libsafety_py.make_CANPacket(a, b, d))
a, d, b = packer.make_can_msg('SteeringPinion_Data', 0, {'StePinComp_An_Est': angle, 'StePinCompAnEst_D_Qf': 3, 'StePinAn_No_Cnt': counter})
assert n.safety_rx_hook(libsafety_py.make_CANPacket(a, b, d))
state = SimpleNamespace(vEgoRaw=max(speed, .1), yawRate=99., steeringAngleDeg=angle)
expected = -host.curvature(state) * 50000
assert n.get_curvature_meas_min() == pytest.approx(expected, abs=1.)
assert n.get_curvature_meas_max() == pytest.approx(expected, abs=1.)
# A valid but contradictory yaw packet never changes selected pinion measurement.
a, d, b = packer.make_can_msg('Yaw_Data_FD1', 0, {'VehYaw_W_Actl': 1., 'VehYawWActl_D_Qf': 3})
assert n.safety_rx_hook(libsafety_py.make_CANPacket(a, b, d))
assert n.get_curvature_meas_min() == pytest.approx(expected, abs=1.)
def test_default_measurement_is_yaw_even_with_conflicting_pinion():
cp = params(next(iter(GEOMETRY)))
m = Measurement(cp)
state = SimpleNamespace(vEgoRaw=15., yawRate=.3, steeringAngleDeg=100.)
assert m.curvature(state) == -.3 / 15.
assert not m.enabled
@pytest.mark.parametrize('index,base', [(1, 32), (3, 66), (10, 10), (11, 18)])
@pytest.mark.parametrize('long', [False, True])
@pytest.mark.parametrize('manual', [False, True])
def test_pinion_inventory_and_invalid_quality_withdraw_authority(index, base, long, manual):
from opendbc.safety.tests.test_ford_aol import TestFordAolDriverIntent
n = TestFordAolDriverIntent()
n.setUp()
base |= int(long) | (256 if manual else 0)
n.reset(base)
count = n.safety.get_ford_pinion_rx_count()
n.safety.set_safety_hooks(structs.CarParams.SafetyModel.ford, base | ENABLE | index << SHIFT)
n.safety.set_aol_test_heartbeat(True)
assert n.safety.get_ford_pinion_rx_count() == count + 1
original_rx = n.rx
def rx(name, values):
if name == 'Yaw_Data_FD1':
original_rx('SteeringPinion_Data', {'StePinComp_An_Est': 0., 'StePinCompAnEst_D_Qf': 3, 'StePinAn_No_Cnt': n.tick % 16})
original_rx(name, values)
n.rx = rx
try:
n.arm()
assert n.safety.aol_get_permission_mask() == 1
a, d, b = n.packer.make_can_msg('SteeringPinion_Data', 0,
{'StePinComp_An_Est': 100., 'StePinCompAnEst_D_Qf': 0, 'StePinAn_No_Cnt': (n.tick + 1) % 16})
assert not n.safety.safety_rx_hook(libsafety_py.make_CANPacket(a, b, d))
assert n.safety.aol_get_permission_mask() == 0
assert n.safety.get_curvature_meas_max() == 0
finally:
n.tearDown()
@pytest.mark.parametrize('failure', ['missing', 'wrong_bus', 'short', 'counter'])
def test_pinion_integrity_is_required_not_substituted_by_healthy_yaw(failure):
from opendbc.safety.tests.test_ford_aol import TestFordAolDriverIntent
n = TestFordAolDriverIntent()
n.setUp()
n.reset(66)
n.safety.set_safety_hooks(structs.CarParams.SafetyModel.ford, 66 | ENABLE | 7 << SHIFT)
n.safety.set_aol_test_heartbeat(True)
original_rx = n.rx
inject = True
def rx(name, values):
if name == 'Yaw_Data_FD1':
if inject:
original_rx('SteeringPinion_Data', {'StePinComp_An_Est': 0., 'StePinCompAnEst_D_Qf': 3, 'StePinAn_No_Cnt': n.tick % 16})
elif failure != 'missing':
a, d, b = n.packer.make_can_msg('SteeringPinion_Data', 0,
{'StePinComp_An_Est': 100., 'StePinCompAnEst_D_Qf': 3, 'StePinAn_No_Cnt': 2})
n.safety.safety_rx_hook(libsafety_py.make_CANPacket(a, 1 if failure == 'wrong_bus' else b, d[:7] if failure == 'short' else d))
original_rx(name, values)
n.rx = rx
try:
n.arm()
inject = False
n.pump(120, mask=1)
assert n.safety.aol_get_permission_mask() == 0
finally:
n.tearDown()
@pytest.mark.parametrize('car', [c for c in GEOMETRY if 'MONDEO' not in c])
@pytest.mark.parametrize('long', [False, True])
def test_angle_selection_preserves_pinion_and_aol_exact_profile(car, long):
from opendbc.bluepilot_lateral.hosts.starpilot import select as angle_select, qualified as angle_qualified
from opendbc.car.ford.aol import base_safety_param, AOL_WORDS
cp = params(car, long)
assert select(cp, True)
assert angle_select(cp, 1)
assert configured(cp) and angle_qualified(cp)
cp.safetyConfigs[-1].safetyParam |= base_safety_param(cp)
cp.alternativeExperience = 32
assert cp.safetyConfigs[-1].safetyParam in AOL_WORDS
assert configured(cp) and angle_qualified(cp) and qualified(cp, marked_only=True)
@pytest.mark.parametrize('word', [32 | ENABLE, 32 | (1 << SHIFT), 32 | ENABLE | (13 << SHIFT),
66 | ENABLE | (1 << SHIFT), 32 | ENABLE | (1 << SHIFT) | 0x4000,
32 | ENABLE | (1 << SHIFT) | 0x8000])
def test_invalid_pinion_native_profile_denies_all_commands(word):
n = libsafety_py.libsafety
n.set_alternative_experience(32)
n.set_safety_hooks(structs.CarParams.SafetyModel.ford, word)
n.set_controls_allowed(True)
assert not n.get_ford_pinion_enabled()
for address in (0x3D3, 0x3D6, 0x3CA, 0x186, 0x18A, 0x3D8):
assert not n.safety_tx_hook(libsafety_py.make_CANPacket(address, 0, bytes(8)))
def test_pinion_rx_capacity_guard_withdraws_authority_without_overwrite():
n = libsafety_py.libsafety
n.set_alternative_experience(32)
n.set_safety_hooks(structs.CarParams.SafetyModel.ford, 66 | ENABLE | (7 << SHIFT))
assert n.test_ford_pinion_rx_overflow()
@@ -1,121 +0,0 @@
import gc
import time
import pytest
from opendbc.car import structs
from opendbc.car.ford.classic_lateral import CLASSIC_EXTENDED_CARS
from opendbc.car.ford.tests.test_three_ports import params
from opendbc.car.ford.values import CAR, CarControllerParams
@pytest.mark.parametrize('identity', sorted(CLASSIC_EXTENDED_CARS | {CAR.FORD_MUSTANG_MACH_E_MK1}))
@pytest.mark.parametrize('disable', (False, True))
@pytest.mark.parametrize('alpha,release', ((False, False), (True, False), (False, True), (True, True)))
def test_actual_card_publication_reaches_ford_extensions(identity, alpha, release, disable, monkeypatch):
from opendbc.car import car_helpers, gen_empty_fingerprint
from opendbc.car.ford.classic_lateral import qualified as classic_qualified
from opendbc.car.ford.mache_lateral import qualified as mache_qualified
from openpilot.selfdrive.car import card as card_module
from openpilot.cereal import messaging
from openpilot.common.params import Params
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.car.card import Car
from openpilot.selfdrive.controls.controlsd import Controls
from openpilot.starpilot.controller_extensions import ManualTurnInputs
from openpilot.starpilot.lateral.tests.test_lane_runtime import feed
monkeypatch.setenv('SIMULATION', '1')
monkeypatch.setenv('REPLAY', '1')
monkeypatch.setenv('AOL_REPLAY_RUNTIME', '0')
with OpenpilotPrefix():
saved = Params()
saved.put_bool('OpenpilotEnabledToggle', True, block=True)
saved.put_bool('AlwaysOnLateral', False, block=True)
saved.put_bool('AlphaLongitudinalEnabled', alpha, block=True)
saved.put_bool('IsReleaseBranch', release, block=True)
saved.put_bool('DisableOpenpilotLongitudinal', disable, block=True)
saved.put_bool('FordHumanTurnDetection', False, block=True)
cp = params(identity, alpha, release)
cp.carVin = '0' * 17
cp.carFw = []
expected = cp.to_dict()
fingerprint = gen_empty_fingerprint()
fingerprint[0][0x5A] = 8
fingerprint[2][0x3D6] = 8
fingerprint[2][0x186] = 8
observed = (identity, fingerprint, cp.carVin, [], cp.fingerprintSource, True)
subscriber = messaging.sub_sock('carParams', timeout=100, conflate=True)
ci = card = controls = None
try:
with monkeypatch.context() as discovery:
discovery.setattr(car_helpers, 'fingerprint', lambda *args: observed)
discovery.setattr(card_module, 'can_comm_callbacks', lambda *args: (lambda *args: [], lambda frames: None))
initial_can = messaging.new_message('can', 1)
discovery.setattr(card_module.messaging, 'recv_one_retry', lambda socket: initial_can)
card = Car()
ci = card.CI
assert card.CP.to_dict() == expected
assert mache_qualified(card.CP) if identity == CAR.FORD_MUSTANG_MACH_E_MK1 else classic_qualified(card.CP)
owner = ci.CC.mache_lateral if identity == CAR.FORD_MUSTANG_MACH_E_MK1 else ci.CC.classic_lateral
assert owner is not None
assert isinstance(ci.CC.manual_turn_inputs, ManualTurnInputs)
assert not ci.CC.manual_turn_inputs.enabled
assert not ci.CC.mache_extended_announced and not ci.CC.classic_extended_announced
with structs.CarParams.from_bytes(saved.get('CarParams')) as published:
assert published.to_dict() == expected
event = None
for _ in range(10):
card.car_params_published = False
card.state_publish(ci.update([]), None)
event = messaging.recv_one(subscriber)
if event is not None:
break
time.sleep(.01)
assert event is not None and event.valid and event.carParams.to_dict() == expected
controls = Controls()
assert controls.CP.to_dict() == expected
assert not controls.aol_replay and not controls.ordinary_axis_ack_required
feed(controls, 1_000_000_000, 0, active=False, enabled=False, can_valid=False, can_timeout=True)
command, lateral_log = controls.state_control()
assert not command.enabled and not command.latActive and not command.longActive
controls.publish(command, lateral_log)
_, packets = ci.apply(command.as_reader(), 1_000_000_000)
assert packets
assert ci.CC.mache_extended_announced if identity == CAR.FORD_MUSTANG_MACH_E_MK1 else ci.CC.classic_extended_announced
assert not owner.human_turn_enabled
feed(controls, 1_010_000_000, 1, active=True, enabled=True, can_valid=True, can_timeout=False)
command, _ = controls.state_control()
assert command.enabled and command.latActive
if identity == CAR.FORD_BRONCO_SPORT_MK1 and not release and not disable:
from opendbc.safety.tests.test_ford_explorer_extended import TestFordExplorerExtended
from opendbc.safety.tests.libsafety import libsafety_py
native = TestFordExplorerExtended()
native.setUp()
try:
native.select(card.CP.safetyConfigs[-1].safetyParam, card.CP.alternativeExperience)
for address, data, bus in packets:
if address == 0x3CA:
assert native.safety.safety_tx_hook(libsafety_py.make_CANPacket(address, bus, data))
native._reset_curvature_measurement(0., 20.)
assert native._rx(native._pcm_status_msg(True))
assert native.safety.get_controls_allowed()
ci.CS.out = controls.sm['carState']
active_packets = 0
for frame in range(CarControllerParams.STEER_STEP):
_, outgoing = ci.apply(command.as_reader(), 1_020_000_000 + frame * 10_000_000)
for address, data, bus in outgoing:
if address == 0x3D3:
active_packets += 1
packet = libsafety_py.make_CANPacket(address, bus, data)
assert native.safety.safety_tx_hook(packet)
assert native._rx(native._pcm_status_msg(False))
assert not native.safety.get_controls_allowed()
assert not native.safety.safety_tx_hook(packet)
assert active_packets > 0
finally:
native.tearDown()
finally:
del controls, card, ci, subscriber
gc.collect()
@@ -91,13 +91,13 @@ def test_reached_controller_preserves_precedence_cadence_and_stock_bits():
def test_unknown_ae_longitudinal_and_unpaired_profiles_deny_translation():
# Actual factory results, not fabricated safety words. Classic stock0 is unreached.
# Actual factory results, not fabricated safety words.
fingerprint = gen_empty_fingerprint()
fingerprint[0][0x5a] = 8
fingerprint[2][0x3d6] = fingerprint[2][0x186] = 8
matrix = ((CAR.FORD_F_150_MK14, 66), (CAR.FORD_EDGE_MK2, 8),
matrix = ((CAR.FORD_F_150_MK14, 2), (CAR.FORD_EDGE_MK2, 8),
(CAR.FORD_MONDEO_MK5, 10), (CAR.FORD_TRANSIT_MK5, 12),
(CAR.FORD_MUSTANG_MACH_E_MK1, 18))
(CAR.FORD_MUSTANG_MACH_E_MK1, 2), (CAR.FORD_BRONCO_SPORT_MK1, 0))
for vehicle, word in matrix:
actual = CarInterface.get_params(vehicle, fingerprint, [], False, False, False)
assert actual.safetyConfigs[-1].safetyParam == word, vehicle
@@ -119,7 +119,7 @@ def test_unknown_ae_longitudinal_and_unpaired_profiles_deny_translation():
cp.openpilotLongitudinalControl = True
assert not qualified(cp)
cp.openpilotLongitudinalControl = False
cp.safetyConfigs[-1].safetyParam = 19
cp.safetyConfigs[-1].safetyParam = 3
assert not qualified(cp)
for word in (0, 4, 34):
cp.safetyConfigs[-1].safetyParam = word
+7 -5
View File
@@ -45,12 +45,7 @@ class FordSafetyFlags(IntFlag):
CANFD = 2
LKA_STEERING = 4
NEW_PORT = 8
MACH_E_EXTENDED = 16
CLASSIC_EXTENDED = 32
EXPLORER_EXTENDED = CLASSIC_EXTENDED # Existing public profile spelling.
MANUAL = 256
BLUEPILOT_ANGLE = 128
GENERIC_CANFD_EXTENDED = 64
class FordFlags(IntFlag):
@@ -354,3 +349,10 @@ FW_QUERY_CONFIG = FwQueryConfig(
)
DBC = CAR.create_dbc_map()
# StarPilot's Ford families: openpilot longitudinal is an explicit choice on these, and AOL, the stock
# cruise switch and the cluster/radar handling key off the family rather than a safety-param profile
FORD_CLASSIC_CARS = frozenset({CAR.FORD_BRONCO_SPORT_MK1, CAR.FORD_ESCAPE_MK4, CAR.FORD_FOCUS_MK4,
CAR.FORD_MAVERICK_MK1, CAR.FORD_EXPLORER_MK6})
FORD_CANFD_CARS = 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})
+33 -236
View File
@@ -1,14 +1,8 @@
#pragma once
#include "opendbc/safety/declarations.h"
#include "opendbc/safety/modes/ford_param.h"
#include "opendbc/safety/modes/ford_aol.h"
#include "opendbc/safety/modes/ford_pinion_measurement.h"
#include "opendbc/bluepilot_lateral/safety/ford_bp.h"
// StarPilot's extended Ford curvature enforcement below is substantially adapted from
// BluePilot bp-7.0 panda work, principally Alan Polk's 8f8d6d15f0a590f42b78de964ffb0d0af7f5d63d
// See /CREDITS.md and /THIRD_PARTY_NOTICES.md. This comment does not attribute the surrounding
// upstream openpilot code.
// Safety-relevant CAN messages for Ford vehicles.
@@ -17,7 +11,6 @@
#define FORD_DesiredTorqBrk 0x213U // RX from ABS, for standstill state
#define FORD_BrakeSysFeatures 0x415U // RX from ABS, for vehicle speed
#define FORD_EngVehicleSpThrottle2 0x202U // RX from PCM, for second vehicle speed
#define FORD_SteeringPinion_Data 0x7EU
#define FORD_Yaw_Data_FD1 0x91U // RX from RCM, for yaw rate
#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
@@ -42,9 +35,6 @@ static uint8_t ford_get_counter(const CANPacket_t *msg) {
// Signal: VehRollYaw_No_Cnt
cnt = msg->data[5];
}
if (msg->addr == FORD_SteeringPinion_Data) {
cnt = (msg->data[5] >> 4) & 0xFU;
}
return cnt;
}
@@ -91,9 +81,6 @@ static bool ford_get_quality_flag_valid(const CANPacket_t *msg) {
if (msg->addr == FORD_Yaw_Data_FD1) {
valid = ((msg->data[6] >> 4) & 0x3U) == 0x3U; // VehYawWActl_D_Qf
}
if (msg->addr == FORD_SteeringPinion_Data) {
valid = ((msg->data[5] >> 2) & 0x3U) == 0x3U;
}
return valid;
}
@@ -113,18 +100,6 @@ static const CurvatureSteeringLimits FORD_STEERING_LIMITS = {
.max_steer_power = 0, // disabled, Ford has no steed power signal
};
static CurvatureSteeringLimits ford_measurement_limits(CurvatureSteeringLimits limits) {
const int max_curvature_error = (ford_pinion_enabled && (limits.max_curvature_error < 150)) ? 150 : limits.max_curvature_error;
return (CurvatureSteeringLimits){
.max_curvature = limits.max_curvature,
.curvature_to_can = limits.curvature_to_can,
.frequency = limits.frequency,
.max_curvature_error = max_curvature_error,
.curvature_error_min_speed = limits.curvature_error_min_speed,
.max_steer_power = limits.max_steer_power,
};
}
// Transit 0x3CA has 0.000005 rad/m per raw step and runs at 33 Hz. Keep its
// command history separate from the inactive 0x3D3 heartbeat at 20 Hz.
static const CurvatureSteeringLimits FORD_LKA_STEERING_LIMITS = {
@@ -136,18 +111,10 @@ static const CurvatureSteeringLimits FORD_LKA_STEERING_LIMITS = {
.max_steer_power = 0,
};
// The physical cancel/resume switch passes through on stock-ACC profiles
static bool ford_stock_switch = false;
static bool ford_cancel_resume_button = false;
static bool ford_generic_canfd_extended = false;
static bool ford_generic_canfd_announced = false;
static bool ford_explorer_extended = false;
static bool ford_explorer_announced = false;
static bool ford_mach_e_extended = false;
static bool ford_mach_e_announced = false;
static int ford_mach_e_path_angle_last = 0;
static bool ford_lka_steering = false;
static bool ford_lka_available = false;
static uint32_t ford_lka_last_us = 0U;
@@ -198,7 +165,7 @@ static void ford_rx_hook(const CANPacket_t *msg) {
}
// Update vehicle yaw rate
if (msg_matches(msg, FORD_Yaw_Data_FD1, FORD_MAIN_BUS) && !ford_pinion_enabled) {
if (msg_matches(msg, FORD_Yaw_Data_FD1, FORD_MAIN_BUS)) {
// FIXME: safety can receive yaw before new vehicle speed, it should recompute meas on either received
// Signal: VehYaw_W_Actl
// TODO: we should use the speed which results in the closest angle measurement to the desired angle
@@ -213,14 +180,6 @@ static void ford_rx_hook(const CANPacket_t *msg) {
}
}
if (ford_pinion_enabled && msg_matches(msg, FORD_SteeringPinion_Data, FORD_MAIN_BUS)) {
const int raw = ((msg->data[2] & 0x7FU) << 8) | msg->data[3];
const float radians = (((float)raw * 0.1F) - 1600.0F) * 0.017453292519943295F;
const float speed = SAFETY_MAX(vehicle_speed.values[0] / VEHICLE_SPEED_FACTOR, 0.1F);
const float measured = radians * get_curvature_factor(speed, *ford_pinion_params) / ford_pinion_params->steer_ratio;
update_sample(&curvature_state.meas, ROUND(measured * FORD_STEERING_LIMITS.curvature_to_can));
}
// Update gas pedal
if (msg_matches(msg, FORD_EngVehicleSpThrottle, FORD_MAIN_BUS)) {
// Pedal position: (0.1 * val) in percent
@@ -247,7 +206,6 @@ static void ford_rx_hook(const CANPacket_t *msg) {
// Physical combined cancel/resume switch, not the outgoing resume signal.
ford_cancel_resume_button = (msg->data[2] & 0x20U) != 0U;
}
ford_bp_rx(msg);
}
static bool ford_tx_hook(const CANPacket_t *msg) {
@@ -319,10 +277,7 @@ static bool ford_tx_hook(const CANPacket_t *msg) {
}
// Safety check for Lane_Assist_Data1 action
if ((msg->addr == FORD_Lane_Assist_Data1) && !ford_bp_enabled) {
if (ford_mach_e_extended || ford_explorer_extended) {
tx &= (msg->data[4] & 0x1U) == 0U;
}
if (msg->addr == FORD_Lane_Assist_Data1) {
// Do not allow steering using Lane_Assist_Data1 (Lane-Departure Aid).
// This message must be sent for Lane Centering to work, and can include
// values such as the steering angle or lane curvature for debugging,
@@ -370,20 +325,8 @@ static bool ford_tx_hook(const CANPacket_t *msg) {
}
}
if (ford_mach_e_extended && tx && (msg->addr == FORD_Lane_Assist_Data1)) {
ford_mach_e_announced = (msg->data[4] & 0x2U) != 0U;
}
if (ford_generic_canfd_extended && tx && (msg->addr == FORD_Lane_Assist_Data1)) {
ford_generic_canfd_announced = (msg->data[4] & 0x2U) != 0U;
}
if (ford_explorer_extended && tx && (msg->addr == FORD_Lane_Assist_Data1)) {
ford_explorer_announced = (msg->data[4] & 0x2U) != 0U;
}
// Safety check for LateralMotionControl action
if ((msg->addr == FORD_LateralMotionControl) && !ford_bp_enabled) {
if (msg->addr == FORD_LateralMotionControl) {
// Signal: LatCtl_D_Rq
bool steer_control_enabled = ((msg->data[4] >> 2) & 0x7U) != 0U;
unsigned int raw_curvature = (msg->data[0] << 3) | (msg->data[1] >> 5);
@@ -391,39 +334,22 @@ 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);
// Explorer restores only the classic source-derived curvature-rate field.
// Every command still intersects the modern common curvature envelope.
bool violation = (raw_path_angle != FORD_INACTIVE_PATH_ANGLE) ||
(raw_path_offset != FORD_INACTIVE_PATH_OFFSET);
const unsigned int curvature_bits = raw_curvature;
const int desired_curvature = (int)curvature_bits - 1000;
if (ford_explorer_extended) {
static const struct lookup_t source_rate = {{5.0F, 16.0F, 25.0F}, {0.0025F, 0.0014F, 0.00018F}};
if (steer_control_enabled && lateral_controls_allowed()) {
const float source_speed = (vehicle_speed.min / VEHICLE_SPEED_FACTOR) - 1.0F;
const float source_delta_float = (safety_interpolate(source_rate, source_speed) * 50000.0F) + 1.0F;
const int source_delta = (int)source_delta_float;
violation |= SAFETY_ABS(desired_curvature - curvature_state.desired_last) > source_delta;
}
violation |= steer_control_enabled && !ford_explorer_announced;
violation |= !steer_control_enabled && ((desired_curvature != 0) ||
(raw_curvature_rate != FORD_INACTIVE_CURVATURE_RATE));
} else {
violation |= raw_curvature_rate != FORD_INACTIVE_CURVATURE_RATE;
}
violation |= steer_curvature_cmd_checks(desired_curvature, 0, steer_control_enabled, ford_measurement_limits(FORD_STEERING_LIMITS));
// 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_curvature_cmd_checks(desired_curvature, 0, steer_control_enabled, FORD_STEERING_LIMITS);
// The Transit steers through Lane_Assist_Data1; its LateralMotionControl is an inactive heartbeat
violation |= ford_lka_steering && steer_control_enabled;
if (violation) {
tx = false;
if (ford_explorer_extended) {
curvature_state.desired_last = 0;
}
}
}
// Safety check for LateralMotionControl2 action
if ((msg->addr == FORD_LateralMotionControl2) && !ford_bp_enabled) {
if (msg->addr == FORD_LateralMotionControl2) {
// Signal: LatCtl_D2_Rq
bool steer_control_enabled = ((msg->data[0] >> 4) & 0x7U) != 0U;
unsigned int raw_curvature = (msg->data[2] << 3) | (msg->data[3] >> 5);
@@ -431,86 +357,18 @@ 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];
const unsigned int curvature_bits = raw_curvature;
const int desired_curvature = (int)curvature_bits - (int)FORD_INACTIVE_CURVATURE;
const int desired_path_angle = (int)raw_path_angle - (int)FORD_INACTIVE_PATH_ANGLE;
bool violation = raw_path_offset != FORD_INACTIVE_PATH_OFFSET;
if (ford_mach_e_extended) {
// Exact Mach-E extension retains the source envelope and the modern common
// ISO/RT checks. This is an intersection, not generic Ford limit widening.
static const CurvatureSteeringLimits FORD_MACH_E_STEERING_LIMITS = {
.max_curvature = 1000,
.curvature_to_can = 50000,
.frequency = 20,
.max_curvature_error = 300,
.curvature_error_min_speed = 10.0,
.max_steer_power = 0,
};
const struct lookup_t source_rate = {{5.0F, 16.0F, 25.0F}, {0.0025F, 0.0014F, 0.00018F}};
const float speed = vehicle_speed.max / VEHICLE_SPEED_FACTOR;
const float speed_min = SAFETY_MAX(vehicle_speed.min / VEHICLE_SPEED_FACTOR, 1.0F);
const int source_delta = (safety_interpolate(source_rate, speed_min - 1.0F) * 50000.0F) + 1.0F;
const float source_max_float = ((3.0F - (9.81F * 0.06F)) / (speed_min * speed_min) * 50000.0F) + 1.0F;
const int source_max = (int)source_max_float;
if (!ford_mach_e_announced) {
violation |= steer_control_enabled || (desired_path_angle != 0) || (raw_curvature_rate != FORD_CANFD_INACTIVE_CURVATURE_RATE);
}
if (steer_control_enabled) {
violation |= SAFETY_ABS(desired_curvature - curvature_state.desired_last) > source_delta;
violation |= SAFETY_ABS(desired_curvature) > source_max;
} else {
violation |= (desired_curvature != 0) || (raw_curvature_rate != FORD_CANFD_INACTIVE_CURVATURE_RATE);
}
if (desired_path_angle != 0) {
const float curvature = SAFETY_ABS(desired_curvature) / 50000.0F;
const float path_angle = SAFETY_ABS(desired_path_angle) / 2000.0F;
const float combined_accel = (curvature + (path_angle / SAFETY_MAX(speed, 1.0F))) * speed * speed;
violation |= !steer_control_enabled || !controls_allowed;
violation |= (speed < 3.0F) || (speed >= 8.8F);
violation |= (SAFETY_ABS(desired_curvature) < 975) || (SAFETY_ABS(desired_path_angle) > 320);
violation |= ((desired_curvature * desired_path_angle) <= 0) || (combined_accel > 2.5F);
violation |= SAFETY_ABS(desired_path_angle - ford_mach_e_path_angle_last) > 110;
}
violation |= steer_curvature_cmd_checks(desired_curvature, 0, steer_control_enabled, ford_measurement_limits(FORD_MACH_E_STEERING_LIMITS));
} else if (ford_generic_canfd_extended) {
// One generic CAN FD owner; Mach-E's wider error/path assist stays separate.
static const struct lookup_t source_rate = {{5.0F, 16.0F, 25.0F}, {0.0025F, 0.0014F, 0.00018F}};
const float source_speed = SAFETY_MAX(vehicle_speed.min / VEHICLE_SPEED_FACTOR, 1.0F);
const float source_delta_float = (safety_interpolate(source_rate, source_speed - 1.0F) * 50000.0F) + 1.0F;
const int source_delta = (int)source_delta_float;
const float source_max_float = ((3.0F - (9.81F * 0.06F)) / (source_speed * source_speed) * 50000.0F) + 1.0F;
const int source_max = (int)source_max_float;
violation |= raw_path_angle != FORD_INACTIVE_PATH_ANGLE;
violation |= steer_control_enabled && !ford_generic_canfd_announced;
if (steer_control_enabled) {
violation |= SAFETY_ABS(desired_curvature - curvature_state.desired_last) > source_delta;
violation |= SAFETY_ABS(desired_curvature) > source_max;
} else {
violation |= (desired_curvature != 0) || (raw_curvature_rate != FORD_CANFD_INACTIVE_CURVATURE_RATE);
}
violation |= steer_curvature_cmd_checks(desired_curvature, 0, steer_control_enabled, ford_measurement_limits(FORD_STEERING_LIMITS));
} else {
violation |= (raw_curvature_rate != FORD_CANFD_INACTIVE_CURVATURE_RATE) || (raw_path_angle != FORD_INACTIVE_PATH_ANGLE);
violation |= steer_curvature_cmd_checks(desired_curvature, 0, steer_control_enabled, ford_measurement_limits(FORD_STEERING_LIMITS));
}
tx &= !violation;
if (ford_generic_canfd_extended && !tx) {
curvature_state.desired_last = 0;
}
if (ford_mach_e_extended && !tx) {
// External path/announcement checks must not retain a rejected command.
curvature_state.desired_last = 0;
ford_mach_e_path_angle_last = 0;
}
if (ford_mach_e_extended && tx) {
ford_mach_e_path_angle_last = desired_path_angle;
// 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_curvature_cmd_checks(desired_curvature, 0, steer_control_enabled, FORD_STEERING_LIMITS);
if (violation) {
tx = false;
}
}
if (ford_bp_enabled) {
if (msg->addr == FORD_Lane_Assist_Data1) { tx &= ford_bp_lka(msg); }
if ((msg->addr == FORD_LateralMotionControl) || (msg->addr == FORD_LateralMotionControl2)) { tx &= ford_bp_lateral(msg); }
}
ford_aol_tx(msg, tx);
return tx;
}
@@ -533,35 +391,9 @@ static void ford_aol_bind_tx(safety_config *ret, CanMsg workspace[7]) {
}
}
static void ford_append_pinion_rx(safety_config *config, RxCheck workspace[11]) {
if (config->rx_checks_len <= 10) {
const int count = config->rx_checks_len;
for (int i = 0; i < count; i++) {
workspace[i] = config->rx_checks[i];
}
workspace[count] = (RxCheck){.msg = {{FORD_SteeringPinion_Data, 0, 8, 100U,
.max_counter = 15U, .ignore_checksum = true}, {0}, {0}}};
config->rx_checks = workspace;
config->rx_checks_len = count + 1;
} else {
config->tx_msgs = NULL;
config->tx_msgs_len = 0;
ford_aol_reset();
}
}
static safety_config ford_init(uint16_t configured_param) {
static RxCheck ford_rx_workspace[11];
const bool pinion_valid = ford_pinion_word_valid(configured_param);
const uint16_t profile_param = ford_pinion_base_word(configured_param);
const uint16_t geometry = (configured_param >> 10U) & 15U;
ford_pinion_enabled = pinion_valid && ((configured_param & FORD_PINION_ENABLE) != 0U);
ford_pinion_params = ford_pinion_geometry_at(ford_pinion_enabled ? geometry : 0U);
const uint16_t bp_param = profile_param & ~256U;
const bool joint_angle = ((unsigned int)alternative_experience == 32U) &&
((bp_param == 146U) || (bp_param == 147U) || (bp_param == 160U) || (bp_param == 161U) || (bp_param == 194U) || (bp_param == 195U));
const uint16_t param = joint_angle ? (bp_param & ~128U) : bp_param;
static RxCheck ford_rx_workspace[10];
const uint16_t param = ford_profile_word(configured_param) & ~256U;
const bool manual = (configured_param & 256U) != 0U;
// Manual variants are only defined for the exact registered Ford profiles.
const bool manual_valid = !manual || ford_aol_param_valid(configured_param);
@@ -621,30 +453,9 @@ static safety_config ford_init(uint16_t configured_param) {
const uint16_t FORD_PARAM_CANFD = 2;
const uint16_t FORD_PARAM_LKA_STEERING = 4;
const uint16_t FORD_PARAM_NEW_PORT = 8;
const uint16_t FORD_PARAM_MACH_E_EXTENDED = 16U;
const uint16_t FORD_PARAM_EXPLORER_EXTENDED = 32U;
const uint16_t FORD_PARAM_GENERIC_CANFD_EXTENDED = 64U;
const bool generic_canfd_namespace = GET_FLAG(param, FORD_PARAM_GENERIC_CANFD_EXTENDED);
ford_generic_canfd_extended = (param == 66U) && (((unsigned int)alternative_experience == 0U) || (((unsigned int)alternative_experience == 32U) && ford_aol_param_valid(param)));
ford_generic_canfd_extended |= (param == 67U) && (((unsigned int)alternative_experience == 0U) || (((unsigned int)alternative_experience == 32U) && ford_aol_param_valid(param)));
ford_generic_canfd_announced = false;
const bool explorer_namespace = GET_FLAG(param, FORD_PARAM_EXPLORER_EXTENDED);
ford_explorer_extended = ((param == 32U) || (param == 33U)) &&
(((unsigned int)alternative_experience == 0U) || (((unsigned int)alternative_experience == 32U) && ford_aol_param_valid(param)));
ford_explorer_announced = false;
const bool mach_e_namespace = GET_FLAG(param, FORD_PARAM_MACH_E_EXTENDED);
ford_mach_e_extended = (param == 18U) && (((unsigned int)alternative_experience == 0U) || (((unsigned int)alternative_experience == 32U) && ford_aol_param_valid(param)));
ford_mach_e_extended |= (param == 19U) && (((unsigned int)alternative_experience == 0U) || (((unsigned int)alternative_experience == 32U) && ford_aol_param_valid(param)));
const bool bp_config_valid = ford_bp_configure(bp_param);
ford_stock_switch = ((((unsigned int)alternative_experience == 0U) || (((unsigned int)alternative_experience == 32U) && ford_aol_param_valid(param))) &&
((param == 2U) || (param == 8U) || (param == 10U) || (param == 12U) ||
((param == 18U) && ford_mach_e_extended) ||
((param == 32U) && ford_explorer_extended) ||
((param == 66U) && ford_generic_canfd_extended))) ||
(ford_bp_enabled && ((param & 1U) == 0U));
ford_stock_switch = (((unsigned int)alternative_experience == 0U) || (((unsigned int)alternative_experience == 32U) && ford_aol_param_valid(param))) &&
((param == 0U) || (param == 2U) || (param == 8U) || (param == 10U) || (param == 12U));
ford_cancel_resume_button = false;
ford_mach_e_path_angle_last = 0;
ford_mach_e_announced = false;
const bool ford_canfd = GET_FLAG(param, FORD_PARAM_CANFD);
ford_lka_steering = GET_FLAG(param, FORD_PARAM_LKA_STEERING);
const bool ford_new_port = GET_FLAG(param, FORD_PARAM_NEW_PORT);
@@ -662,7 +473,7 @@ static safety_config ford_init(uint16_t configured_param) {
safety_config ret;
if (ford_canfd) {
ret = BUILD_SAFETY_CFG(ford_rx_checks, FORD_CANFD_STOCK_TX_MSGS);
bool long_profile = (param == 11U) || (param == 19U) || (param == 67U);
bool long_profile = (param == 3U) || (param == 11U);
#ifdef ALLOW_DEBUG
long_profile |= GET_FLAG(param, 1U);
#endif
@@ -670,8 +481,9 @@ static safety_config ford_init(uint16_t configured_param) {
ret = BUILD_SAFETY_CFG(ford_rx_checks, FORD_CANFD_LONG_TX_MSGS);
}
} else {
ret = ford_new_port ? BUILD_SAFETY_CFG(ford_rx_checks, FORD_STOCK_TX_MSGS) :
BUILD_SAFETY_CFG(ford_rx_checks, FORD_LONG_TX_MSGS);
// Classic stock ACC (0) and the new ports send no ACCDATA unless openpilot long is set
ret = (ford_new_port || (param == 0U)) ? BUILD_SAFETY_CFG(ford_rx_checks, FORD_STOCK_TX_MSGS) :
BUILD_SAFETY_CFG(ford_rx_checks, FORD_LONG_TX_MSGS);
if (ford_lka_steering) {
SET_RX_CHECKS(ford_lka_rx_checks, ret);
}
@@ -679,10 +491,6 @@ static safety_config ford_init(uint16_t configured_param) {
SET_TX_MSGS(FORD_LONG_TX_MSGS, ret);
}
}
if (ford_explorer_extended && (param == 32U)) {
SET_TX_MSGS(FORD_STOCK_TX_MSGS, ret);
}
if (ford_bp_enabled && (param == 128U)) { SET_TX_MSGS(FORD_STOCK_TX_MSGS, ret); }
if (ford_stock_switch) {
if (ford_lka_steering) {
SET_RX_CHECKS(ford_stock_lka_rx_checks, ret);
@@ -690,9 +498,7 @@ static safety_config ford_init(uint16_t configured_param) {
SET_RX_CHECKS(ford_stock_rx_checks, ret);
}
}
if (!manual_valid || !bp_config_valid || (mach_e_namespace && !ford_mach_e_extended) ||
(explorer_namespace && !ford_explorer_extended) ||
(generic_canfd_namespace && !ford_generic_canfd_extended)) {
if (!manual_valid) {
ret.tx_msgs = NULL;
ret.tx_msgs_len = 0;
}
@@ -736,7 +542,7 @@ static safety_config ford_init(uint16_t configured_param) {
} else if (base == 10U) {
selected_rx = ford_aol_mondeo_rx;
selected_len = 10;
} else if ((base == 12U) || (base == 18U) || (base == 66U)) {
} else if ((base == 12U) || (base == 2U)) {
selected_rx = ford_aol_transit_rx;
selected_len = 10;
} else {
@@ -762,15 +568,6 @@ static safety_config ford_init(uint16_t configured_param) {
_Static_assert(sizeof(FORD_CANFD_LONG_TX_MSGS) <= sizeof(ford_aol_tx_msgs), "Ford CAN FD long TX capacity");
ford_aol_bind_tx(&ret, ford_aol_tx_msgs);
}
if (ford_pinion_enabled) {
// Append after profile-specific RX selection so AOL retains every existing check.
ford_append_pinion_rx(&ret, ford_rx_workspace);
}
if (!pinion_valid) {
ret.tx_msgs = NULL;
ret.tx_msgs_len = 0;
ford_aol_reset();
}
return ret;
}
+10 -12
View File
@@ -1,5 +1,5 @@
#pragma once
#include "opendbc/safety/modes/ford_pinion.h"
#include "opendbc/safety/modes/ford_param.h"
static bool ford_aol_manual = false;
static bool ford_aol_enabled = false;
@@ -16,9 +16,10 @@ static bool ford_aol_session = false;
static bool ford_aol_request_seen = false;
static uint16_t ford_aol_param = 0U;
// CAN FD (not the Mondeo's new port) keeps the cluster display while AOL owns steering
static bool ford_aol_retained_display_profile(void) {
const uint16_t base = ford_aol_param & 0xFFFEU;
return (base == 18U) || (base == 66U);
return base == 2U;
}
static bool ford_aol_drive = false;
static uint8_t ford_aol_eps = 0U;
@@ -173,7 +174,7 @@ static uint8_t ford_aol_permission_mask(void) {
ford_aol_clear();
}
const uint16_t base = ford_aol_param & 0xFFFEU;
const bool needs_lka = (base == 10U) || (base == 12U) || (base == 18U) || (base == 66U);
const bool needs_lka = (base == 2U) || (base == 10U) || (base == 12U);
const uint8_t requested = ford_aol_request_mask();
if (((requested & 1U) != 0U) && (controls_allowed || ford_aol_token) &&
ford_aol_main_available && ford_aol_drive && (ford_aol_eps == 0U) && (!needs_lka || ford_aol_lka_ready)) {
@@ -184,7 +185,7 @@ static uint8_t ford_aol_permission_mask(void) {
!brake_pressed && !gas_pressed && !gas_pressed_prev) {
result |= 2U;
}
if ((base == 18U) && ((result & 1U) == 0U)) {
if ((base == 2U) && ((result & 1U) == 0U)) {
ford_aol_owned &= FORD_AOL_STEERING_OWNED;
}
return result;
@@ -271,12 +272,9 @@ static void ford_aol_full_engagement(bool cruise_rising) {
static bool ford_aol_param_valid(uint16_t param) {
bool result = false;
switch (ford_pinion_base_word(param) & ~256U) {
case 8U: case 9U: case 10U: case 12U: case 13U: case 18U: case 32U: case 33U: case 66U:
result = true;
break;
case 146U: case 147U: case 160U: case 161U: case 194U: case 195U:
case 11U: case 19U: case 67U:
switch (ford_profile_word(param) & ~256U) {
case 0U: case 1U: case 2U: case 3U:
case 8U: case 9U: case 10U: case 11U: case 12U: case 13U:
result = true;
break;
default:
@@ -289,7 +287,7 @@ static void ford_aol_configure(uint16_t param) {
ford_aol_reset();
ford_aol_enabled = ford_aol_param_valid(param) && ((unsigned int)alternative_experience == 32U);
ford_aol_manual = (param & 256U) != 0U;
ford_aol_param = ford_pinion_base_word(param) & ~(256U | 128U);
ford_aol_param = ford_profile_word(param) & ~256U;
if (ford_aol_enabled) {
static const AolSafetyPolicy policy = {
.reset = ford_aol_reset,
@@ -304,7 +302,7 @@ static void ford_aol_configure(uint16_t param) {
static unsigned int ford_aol_replacement(void) {
const uint16_t base = ford_aol_param & 0xFFFEU;
return (base == 12U) ? 0x3CAU : (((base == 10U) || (base == 18U) || (base == 66U)) ? 0x3D6U : 0x3D3U);
return (base == 12U) ? 0x3CAU : (((base == 2U) || (base == 10U)) ? 0x3D6U : 0x3D3U);
}
static void ford_aol_tx(const CANPacket_t *msg, bool accepted) {
@@ -0,0 +1,9 @@
#pragma once
// The Ford safety param: StarPilot's Ford profile in bits 0-8 (long, CAN FD, LKA steering, new port, and 256
// for AOL's manual transmission), the BluePilot lateral library's startup bits in 9-14.
#define FORD_BP_PARAM_SHIFT 9U
#define FORD_BP_PARAM_MASK 0x7E00U
static inline uint16_t ford_profile_word(uint16_t word) {
return word & (uint16_t)~FORD_BP_PARAM_MASK;
}
@@ -1,31 +0,0 @@
#pragma once
// Pinion measurement selection adapted from BluePilot PR #145 (fe5b0195).
// See CREDITS.md and THIRD_PARTY_NOTICES.md. No live mode switching.
#define FORD_PINION_ENABLE 0x200U
#define FORD_PINION_MASK 0x3E00U
static bool ford_pinion_word_valid(uint16_t word) {
bool valid = true;
if ((word & FORD_PINION_MASK) != 0U) {
const uint16_t index = (word >> 10U) & 15U;
const uint16_t base = word & ~(FORD_PINION_MASK | 256U);
valid = (word & FORD_PINION_ENABLE) != 0U;
if ((index == 1U) || (index == 2U) || (index == 5U) || (index == 6U) || (index == 9U)) {
valid &= (base == 32U) || (base == 33U) || (base == 128U) || (base == 129U) || (base == 160U) || (base == 161U);
} else if ((index == 3U) || (index == 4U) || (index == 7U) || (index == 8U) || (index == 12U)) {
valid &= (base == 66U) || (base == 67U) || (base == 130U) || (base == 131U) || (base == 194U) || (base == 195U);
} else if (index == 11U) {
valid &= (base == 18U) || (base == 19U) || (base == 130U) || (base == 131U) || (base == 146U) || (base == 147U);
} else if (index == 10U) {
valid &= (base == 10U) || (base == 11U);
} else {
valid = false;
}
}
return valid;
}
static uint16_t ford_pinion_base_word(uint16_t word) {
return ford_pinion_word_valid(word) ? (word & ~FORD_PINION_MASK) : 0xFFFFU;
}
@@ -1,23 +0,0 @@
#pragma once
#define FORD_PINION_GEOMETRY_COUNT 12U
static const AngleSteeringParams *ford_pinion_geometry_at(uint16_t index) {
static const AngleSteeringParams ford_pinion_geometry[FORD_PINION_GEOMETRY_COUNT + 1U] = {
{.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.00042037149f, .steer_ratio = 17.0f, .wheelbase = 3.990f}, // 8: FORD_F_150_MK14
{.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
};
return &ford_pinion_geometry[index];
}
static bool ford_pinion_enabled = false;
static const AngleSteeringParams *ford_pinion_params = NULL;
@@ -8,11 +8,12 @@ from opendbc.safety.tests.libsafety import libsafety_py
libsafety_py.ffi.cdef('extern safety_config current_safety_config;')
STOCK_CANFD_TX = frozenset(((0x83, 0, 8), (0x83, 2, 8), (0x18A, 0, 8), (0x3CA, 0, 8), (0x3D8, 0, 8), (0x3D6, 0, 8)))
RELEASE_LEDGER = {3: (6, STOCK_CANFD_TX)}
# the retired Mach-E (19) and generic CAN FD (67) extended long words: stock CAN FD TX, no AOL or LONG
RELEASE_LEDGER = {19: (6, STOCK_CANFD_TX), 67: (6, STOCK_CANFD_TX)}
def assert_release_denial(native, word):
"""Prove absent AOL/LONG, preserving the existing stock fallback of legacy parameter3.
"""Prove absent AOL/LONG, preserving the existing stock fallback of a retired parameter.
A physical PCM edge can still set the ordinary boolean even with an empty TX
table. No assertion equates that boolean or hook return code with authority.
@@ -465,21 +465,3 @@ bool safety_test_hyundai_gv60_catalog_rejection(bool alternate) {
#endif
return rejected;
}
// Pinion protocol introspection for exact host/native geometry and namespace tests.
uint16_t get_ford_pinion_base(uint16_t word) { return ford_pinion_base_word(word); }
bool get_ford_pinion_enabled(void) { return ford_pinion_enabled; }
float get_ford_pinion_slip(int index) { return ford_pinion_geometry_at((uint16_t)index)->slip_factor; }
float get_ford_pinion_ratio(int index) { return ford_pinion_geometry_at((uint16_t)index)->steer_ratio; }
float get_ford_pinion_wheelbase(int index) { return ford_pinion_geometry_at((uint16_t)index)->wheelbase; }
int get_ford_pinion_rx_count(void) { return current_safety_config.rx_checks_len; }
bool test_ford_pinion_rx_overflow(void) {
RxCheck workspace[11] = {0};
safety_config config = current_safety_config;
config.rx_checks = workspace;
config.rx_checks_len = 11;
ford_append_pinion_rx(&config, workspace);
return (config.tx_msgs == NULL) && (config.tx_msgs_len == 0) &&
(config.rx_checks_len == 11) && !ford_aol_enabled;
}
@@ -529,8 +529,7 @@ def print_live_status(text, *, final=False):
def _discover_test_catalog():
loader = unittest.TestLoader()
catalog = {}
test_files = [*sorted(SAFETY_TESTS_DIR.glob("test_*.py")),
ROOT / "opendbc" / "bluepilot_lateral" / "tests" / "test_angle_native.py"]
test_files = sorted(SAFETY_TESTS_DIR.glob("test_*.py"))
for test_file in test_files:
module_name = ".".join(test_file.relative_to(ROOT).with_suffix("").parts)
suite = loader.loadTestsFromName(module_name)
+15 -2
View File
@@ -493,11 +493,24 @@ class TestFordLongitudinalSafetyBase(TestFordSafetyBase):
self.assertEqual(should_tx, self._tx(self._acc_command_msg(self.INACTIVE_GAS, brake, brake_actuation)))
class TestFordLongitudinalSafety(TestFordLongitudinalSafetyBase):
# Make sure we enforce long safety even without long flag for CAN
class TestFordStockSafety(TestFordSafetyBase):
# StarPilot: classic stock ACC sends no ACCDATA
SAFETY_PARAM = 0
STEER_MESSAGE = MSG_LateralMotionControl
TX_MSGS = [
[MSG_Steering_Data_FD1, 0], [MSG_Steering_Data_FD1, 2], [MSG_ACCDATA_3, 0], [MSG_Lane_Assist_Data1, 0],
[MSG_LateralMotionControl, 0], [MSG_IPMA_Data, 0],
]
RELAY_MALFUNCTION_ADDRS = {0: (MSG_ACCDATA_3, MSG_Lane_Assist_Data1, MSG_LateralMotionControl, MSG_IPMA_Data)}
FWD_BLACKLISTED_ADDRS = {2: [MSG_ACCDATA_3, MSG_Lane_Assist_Data1, MSG_LateralMotionControl, MSG_IPMA_Data]}
class TestFordLongitudinalSafety(TestFordLongitudinalSafetyBase):
SAFETY_PARAM = FordSafetyFlags.LONG_CONTROL
STEER_MESSAGE = MSG_LateralMotionControl
TX_MSGS = [
[MSG_Steering_Data_FD1, 0], [MSG_Steering_Data_FD1, 2], [MSG_ACCDATA, 0], [MSG_ACCDATA_3, 0], [MSG_Lane_Assist_Data1, 0],
[MSG_LateralMotionControl, 0], [MSG_IPMA_Data, 0],
@@ -7,8 +7,8 @@ from opendbc.car.ford.fordcan import CanBus, create_acc_msg
from opendbc.safety.tests.libsafety import libsafety_py
from opendbc.safety.tests.test_ford_full_engagement import ford_native as ford_native, select
LONG_WORDS = (9, 11, 13, 19, 33, 67)
STOCK_WORDS = (8, 10, 12, 18, 32, 66)
LONG_WORDS = (1, 3, 9, 11, 13)
STOCK_WORDS = (0, 2, 8, 10, 12)
def tx(native, *, active):
@@ -53,7 +53,7 @@ def test_stock_long_off_never_gains_a_long_axis_from_any_request(ford_native, wo
@pytest.mark.parametrize('fault', ('source', 'heartbeat', 'request-expired', 'eps-permanent'))
def test_native_long_recovery_requires_new_activation_after_real_revocation(ford_native, fault):
n = ford_native
select(n, 33)
select(n, 1)
n.pump(40, main=3)
n.pump(10, main=4, mask=3)
assert n.safety.aol_get_permission_mask() == 3
@@ -16,19 +16,8 @@ class TestFordAolAdmission(unittest.TestCase):
self.safety.set_alternative_experience(0)
self.safety.set_safety_hooks(structs.CarParams.SafetyModel.noOutput, 0)
def test_angle_profiles_reject_malformed_lateral_frames_without_healthy_sources(self):
for word in (128, 129, 130, 131):
with self.subTest(word=word):
self.safety.init_tests()
self.safety.set_alternative_experience(0)
self.assertEqual(self.safety.set_safety_hooks(structs.CarParams.SafetyModel.ford, word), 0)
self.safety.set_controls_allowed(False)
for address in (0x3CA, 0x3D3 if word & 2 else 0x3D6):
self.assertFalse(self.safety.safety_tx_hook(libsafety_py.make_CANPacket(address, 0, bytes(8))))
self.assertFalse(self.safety.get_controls_allowed())
def test_no_frames_no_lateral_or_longitudinal_authority(self):
for word in (8, 9, 10, 11, 12, 13, 18, 19, 32, 33, 66, 67):
for word in (0, 1, 2, 3, 8, 9, 10, 11, 12, 13):
for ae in (0, 32, 33):
with self.subTest(word=word, ae=ae):
self.safety.init_tests()
@@ -42,7 +31,7 @@ class TestFordAolAdmission(unittest.TestCase):
class TestFordAolDriverIntent(unittest.TestCase):
BASE_PROFILES = (8, 9, 10, 11, 12, 13, 18, 19, 32, 33, 66, 67)
BASE_PROFILES = (0, 1, 2, 3, 8, 9, 10, 11, 12, 13)
PROFILES = BASE_PROFILES + tuple(word | 256 for word in BASE_PROFILES)
def setUp(self):
@@ -68,7 +57,7 @@ class TestFordAolDriverIntent(unittest.TestCase):
self.assertTrue(self.safety.safety_rx_hook(libsafety_py.make_CANPacket(address, bus, data)))
def pump(self, count, *, main=3, tja=False, gear=3, eps=0, mask=0, heartbeat=True, speed=10., curvature=0.):
base = self.word & ~(257 | 128)
base = self.word & ~257
gear_name, gear_signal = ('TransGearData', 'GearLvrPos_D_Actl') if base == 8 else (
('Gear_Shift_by_Wire_FD1', 'TrnRng_D_RqGsm') if base == 10 else ('PowertrainData_10', 'TrnRng_D_Rq'))
if self.word & 256:
@@ -92,7 +81,7 @@ class TestFordAolDriverIntent(unittest.TestCase):
for name, hz, fields in values:
if self.tick * hz // 100 != (self.tick - 1) * hz // 100:
self.rx(name, fields)
if base in (10, 12, 18, 66) and self.tick * 30 // 100 != (self.tick - 1) * 30 // 100:
if base in (2, 10, 12) and self.tick * 30 // 100 != (self.tick - 1) * 30 // 100:
self.rx('Lane_Assist_Data3_FD1', {'LatCtlSte_D_Stat': 1, 'LaActAvail_D_Actl': 3, 'LaActDeny_B_Actl': 0})
self.safety.aol_set_host_request(mask)
self.safety.aol_get_permission_mask()
@@ -146,14 +135,13 @@ class TestFordAolDriverIntent(unittest.TestCase):
packer = CANPacker("ford_lincoln_base_pt")
self.safety.init_tests()
self.safety.set_alternative_experience(32)
profiles = ((32, "PowertrainData_10", "TrnRng_D_Rq", False),
profiles = ((0, "PowertrainData_10", "TrnRng_D_Rq", False),
(8, "TransGearData", "GearLvrPos_D_Actl", False),
(10, "Gear_Shift_by_Wire_FD1", "TrnRng_D_RqGsm", True),
(12, "PowertrainData_10", "TrnRng_D_Rq", True),
(18, "PowertrainData_10", "TrnRng_D_Rq", True),
(66, "PowertrainData_10", "TrnRng_D_Rq", True),
(2, "PowertrainData_10", "TrnRng_D_Rq", True),
(8, "TransGearData", "GearLvrPos_D_Actl", False),
(32, "PowertrainData_10", "TrnRng_D_Rq", False))
(0, "PowertrainData_10", "TrnRng_D_Rq", False))
for epoch, (word, gear_name, gear_signal, needs_lka) in enumerate(profiles):
with self.subTest(word=word, epoch=epoch):
self.assertEqual(self.safety.set_safety_hooks(structs.CarParams.SafetyModel.ford, word), 0)
@@ -193,12 +181,12 @@ class TestFordAolDriverIntent(unittest.TestCase):
def test_main_cancel_states_and_physical_cancel_buttons_withdraw(self):
for state in (1, 2):
self.reset(32)
self.reset(0)
self.arm()
self.pump(10, main=state, mask=1)
self.assertEqual(self.safety.aol_get_permission_mask(), 0)
for bit in (8, 24):
self.reset(32)
self.reset(0)
self.arm()
data = (1 << bit).to_bytes(8, 'little')
self.assertTrue(self.safety.safety_rx_hook(libsafety_py.make_CANPacket(0x83, 0, data)))
@@ -209,18 +197,13 @@ class TestFordAolDriverIntent(unittest.TestCase):
def test_accepted_lateral_owns_only_replacement_until_lease_or_rejection(self):
from opendbc.car.ford.fordcan import CanBus, create_lat_ctl_msg
from opendbc.car.ford.mache_can import create_lka_msg
from opendbc.car import gen_empty_fingerprint
self.reset(32)
self.reset(0)
self.arm()
bus = CanBus(fingerprint=gen_empty_fingerprint())
frame = create_lat_ctl_msg(self.packer, bus, True, 0., 0., 0., 0.)
command = libsafety_py.make_CANPacket(frame[0], frame[2], frame[1])
self.assertEqual(self.safety.safety_fwd_hook(2, 0x3D3), 0)
self.assertFalse(self.safety.safety_tx_hook(command))
announcement = create_lka_msg(self.packer, bus)
self.assertTrue(self.safety.safety_tx_hook(libsafety_py.make_CANPacket(announcement[0], announcement[2], announcement[1])))
self.assertEqual(self.safety.safety_fwd_hook(2, 0x3D3), 0)
self.assertTrue(self.safety.safety_tx_hook(command))
for address in (0x3D3, 0x3D8, 0x18A):
self.assertEqual(self.safety.safety_fwd_hook(2, address), -1)
@@ -233,7 +216,7 @@ class TestFordAolDriverIntent(unittest.TestCase):
def test_native_bounded_catalog_copy_denies_invalid_and_restores_registered_table(self):
for length, null_catalog in ((0, False), (1, False), (7, False), (8, False), (1, True)):
with self.subTest(length=length, null_catalog=null_catalog):
self.reset(32)
self.reset(0)
self.assertTrue(self.bounded_catalog_contract(length, null_catalog))
self.assertFalse(self.safety.get_controls_allowed())
self.assertEqual(self.safety.aol_get_permission_mask(), 0)
@@ -1,44 +0,0 @@
"""Combined angle authority retains the existing physical AOL lifecycle."""
import unittest
from opendbc.safety.tests import test_ford_aol as existing
from opendbc.bluepilot_lateral.core.angle import Command
from opendbc.bluepilot_lateral.can.ford import lateral, lka
from opendbc.safety.tests.libsafety import libsafety_py
class TestFordBluePilotAol(unittest.TestCase):
setUp = existing.TestFordAolDriverIntent.setUp
tearDown = existing.TestFordAolDriverIntent.tearDown
reset = existing.TestFordAolDriverIntent.reset
rx = existing.TestFordAolDriverIntent.rx
pump = existing.TestFordAolDriverIntent.pump
arm = existing.TestFordAolDriverIntent.arm
rearm = existing.TestFordAolDriverIntent.rearm
test_permanent_eps_then_clean_in_same_batch_revokes = existing.TestFordAolDriverIntent.test_permanent_eps_then_clean_in_same_batch_revokes
test_reverse_temporary_eps_retain_but_heartbeat_loss_rearms = (
existing.TestFordAolDriverIntent.test_reverse_temporary_eps_retain_but_heartbeat_loss_rearms)
BASE_PROFILES = (146,147,160,161,194,195)
PROFILES = BASE_PROFILES + tuple(word|256 for word in BASE_PROFILES)
def test_nonzero_angle_after_brake_requires_fresh_shadow_and_native_lat(self):
for word in self.PROFILES:
self.reset(word)
self.arm()
assert not self.safety.get_controls_allowed()
command = Command(True,0.005,0.005/10.)
def send(frame):
addr,data,bus=frame
return bool(self.safety.safety_tx_hook(libsafety_py.make_CANPacket(addr,bus,data)))
assert send(lka(self.packer,0,command.shadow_curvature,0))
assert send(lateral(self.packer,0,command,canfd=bool(word&2),counter=0))
self.rx('EngBrakeData',{'BpedDrvAppl_D_Actl':2,'CcStat_D_Actl':3})
self.pump(5,mask=1)
assert self.safety.aol_get_permission_mask()==1 and not self.safety.get_controls_allowed()
assert send(lka(self.packer,0,command.shadow_curvature,1))
assert send(lateral(self.packer,0,command,canfd=bool(word&2),counter=1))
self.pump(10,mask=1)
assert self.safety.aol_get_permission_mask()==1 and not self.safety.get_controls_allowed()
assert send(lka(self.packer,0,command.shadow_curvature,2))
assert send(lateral(self.packer,0,command,canfd=bool(word&2),counter=2))
self.safety.aol_set_host_request(0)
self.pump(5,mask=0)
assert not send(lateral(self.packer,0,command,canfd=bool(word&2),counter=2))
@@ -1,14 +1,14 @@
"""Display continuity never grants steering or survives loss of native authority."""
"""CAN FD display continuity never grants steering or survives loss of native authority."""
import pytest
from opendbc.car import gen_empty_fingerprint
from opendbc.car.ford import mache_can, fordcan
from opendbc.car.ford import fordcan
from opendbc.car.ford.fordcan import CanBus
from opendbc.safety.tests.libsafety import libsafety_py
from opendbc.safety.tests import test_ford_aol
@pytest.fixture(params=(18, 19, 274, 275, 66, 67, 322, 323))
@pytest.fixture(params=(2, 3, 258, 259))
def owner(request):
native = test_ford_aol.TestFordAolDriverIntent()
native.setUp()
@@ -22,8 +22,8 @@ def owner(request):
def command(owner, *, active, curvature=0., offset_byte=False):
frame = mache_can.create_lat_ctl2_msg(owner.packer, CanBus(fingerprint=gen_empty_fingerprint()),
int(active), 0, 1, curvature, 0., 0)
frame = fordcan.create_lat_ctl2_msg(owner.packer, CanBus(fingerprint=gen_empty_fingerprint()),
int(active), 0., 0., curvature, 0., 0)
data = bytearray(frame[1])
if offset_byte:
data[5] ^= 1
@@ -31,7 +31,7 @@ def command(owner, *, active, curvature=0., offset_byte=False):
def announce(owner):
frame = mache_can.create_lka_msg(owner.packer, CanBus(fingerprint=gen_empty_fingerprint()))
frame = fordcan.create_lka_msg(owner.packer, CanBus(fingerprint=gen_empty_fingerprint()))
assert owner.safety.safety_tx_hook(libsafety_py.make_CANPacket(frame[0], frame[2], frame[1]))
@@ -11,7 +11,7 @@ from opendbc.safety.tests.libsafety import libsafety_py
from opendbc.safety.tests.test_ford_full_engagement import ford_native as ford_native, select
from opendbc.safety.tests.test_ford_alpha_aol import tx
WORDS = (8, 9, 10, 11, 12, 13, 18, 19, 32, 33, 66, 67)
WORDS = (0, 1, 2, 3, 8, 9, 10, 11, 12, 13)
@pytest.mark.parametrize('base', WORDS)
@@ -133,7 +133,8 @@ def ford_release_native(tmp_path, record_property):
native.tearDown()
def test_release_keeps_unregistered_legacy_canfd_long_parameter_denied(ford_release_native):
@pytest.mark.parametrize('word', (19, 67))
def test_release_keeps_retired_extended_canfd_long_words_denied(ford_release_native, word):
from opendbc.safety.tests.ford_release_contract import assert_release_denial
select(ford_release_native, 3)
assert_release_denial(ford_release_native, 3)
select(ford_release_native, word)
assert_release_denial(ford_release_native, word)
@@ -1,102 +0,0 @@
"""Exact Explorer classic extension intersects original and modern limits."""
from opendbc.car.structs import CarParams
from opendbc.safety.tests.libsafety import libsafety_py
import unittest
from opendbc.safety.tests import test_ford as ford
from opendbc.safety.tests import test_ford_stock_switch as stock
class TestFordExplorerExtended(unittest.TestCase):
def setUp(self):
libsafety_py.libsafety.set_alternative_experience(0)
self.fixture = ford.TestFordLongitudinalSafety()
self.fixture.SAFETY_PARAM = 32
self.fixture.setUp()
def tearDown(self):
self.safety.set_alternative_experience(0)
self.safety.set_safety_hooks(CarParams.SafetyModel.noOutput, 0)
def __getattr__(self, name):
fixture = self.__dict__.get('fixture')
if fixture is None:
raise AttributeError(name)
return getattr(fixture, name)
def select(self, word=32, ae=0):
self.safety.set_alternative_experience(ae)
self.safety.set_safety_hooks(CarParams.SafetyModel.ford, word)
self.safety.set_timer(0)
def announce(self, active=True, forbidden=False):
msg = self._lkas_command_msg(0)
msg[0].data[4] = (2 if active else 0) | int(forbidden)
return self._tx(msg)
def prepare(self, speed=5.0):
self._reset_curvature_measurement(0.0, speed)
self._set_prev_desired_angle(0.0)
self.safety.set_controls_allowed(True)
def test_namespace_and_classic_long_both_variants(self):
for word in (32, 33):
self.select(word)
self.assertTrue(self.announce())
self.prepare()
self.assertTrue(self._tx(self._lat_ctl_msg(True, 0, 0, 0, 0.0002)))
self.assertEqual(word == 33, self._tx(self._acc_command_msg(self.INACTIVE_GAS, self.INACTIVE_ACCEL, False)))
for word in (32, 33):
self.select(word, 32)
self.assertTrue(self.announce())
self.assertTrue(self._tx(self._lat_ctl_msg(False, 0, 0, 0, 0)))
for word, ae in ((34, 0), (36, 0), (40, 0), (48, 0), (96, 0), (32, 1), (33, 1)):
self.select(word, ae)
self.assertFalse(self.announce())
self.assertFalse(self._tx(self._lat_ctl_msg(False, 0, 0, 0, 0)))
def test_accepted_announcement_and_inactive_fields(self):
self.prepare()
self.assertFalse(self._tx(self._lat_ctl_msg(True, 0, 0, 0, 0.0002)))
self.assertFalse(self.announce(forbidden=True))
self.prepare()
self.assertFalse(self._tx(self._lat_ctl_msg(True, 0, 0, 0, 0.0002)))
self.assertTrue(self.announce())
for rate in (-0.001024, 0.00102375):
self.prepare()
self.assertTrue(self._tx(self._lat_ctl_msg(True, 0, 0, 0, rate)))
for offset, angle, curvature, rate in ((0.01, 0, 0, 0), (0, 0.01, 0, 0), (0, 0, 0, 0.0002), (0, 0, 0.001, 0)):
self.prepare()
self.assertFalse(self._tx(self._lat_ctl_msg(False, offset, angle, curvature, rate)))
self.assertTrue(self._tx(self._lat_ctl_msg(False, 0, 0, 0, 0)))
self.assertTrue(self.announce(active=False))
self.prepare()
self.assertFalse(self._tx(self._lat_ctl_msg(True, 0, 0, 0, 0)))
def test_modern_jerk_and_original_lookup_intersect(self):
self.assertTrue(self.announce())
for speed, accepted_can, rejected_can in ((15.0, 40, 70), (26.0, 8, 12)):
self.prepare(speed)
self.assertTrue(self._tx(self._lat_ctl_msg(True, 0, 0, accepted_can / self.DEG_TO_CAN, 0)))
self.prepare(speed)
self.assertFalse(self._tx(self._lat_ctl_msg(True, 0, 0, rejected_can / self.DEG_TO_CAN, 0)))
self.assertEqual(self.safety.get_desired_curvature_last(), 0)
# A follow-up from zero must pass without resetting previous desire.
# Retaining the rejected positive command would make this reversal fail.
self.assertTrue(self._tx(self._lat_ctl_msg(True, 0, 0, -accepted_can / self.DEG_TO_CAN, 0)))
def cruise(self, state):
return stock.TestFordStockSwitch.cruise(self, state)
def physical_switch(self, pressed, bus=0):
return stock.TestFordStockSwitch.physical_switch(self, pressed, bus)
def test_stock_resume_does_not_grant_steering(self):
for word in (32, 33):
self.select(word)
stock.TestFordStockSwitch.healthy(self, word)
self.assertFalse(self.safety.get_controls_allowed())
self.assertEqual(word == 32, self._tx(self._acc_button_msg(ford.Buttons.RESUME, 0)))
self.assertFalse(self.safety.get_controls_allowed())
self.assertTrue(self._rx(self.physical_switch(False)))
self.assertFalse(self._tx(self._acc_button_msg(ford.Buttons.RESUME, 0)))
@@ -5,7 +5,7 @@ import pytest
from opendbc.car import structs
from opendbc.safety.tests.test_ford_aol import TestFordAolDriverIntent as _FordNative
WORDS = (8, 9, 10, 11, 12, 13, 18, 19, 32, 33, 66, 67)
WORDS = (0, 1, 2, 3, 8, 9, 10, 11, 12, 13)
@pytest.fixture
@@ -94,7 +94,7 @@ def test_full_engagement_never_bypasses_native_qualification(ford_native, word,
def test_full_engagement_token_dies_on_source_health_heartbeat_or_permanent_eps(ford_native):
n = ford_native
for fault in ('health', 'heartbeat', 'eps'):
select(n, 32)
select(n, 0)
n.pump(40, main=3)
n.pump(10, main=4, mask=1)
n.rx('EngBrakeData', {'BpedDrvAppl_D_Actl': 2, 'CcStat_D_Actl': 3})
@@ -1,89 +0,0 @@
"""Generic CAN FD extension; helpers composed without inheriting other suites."""
import unittest
from opendbc.car import structs
from opendbc.safety.tests import test_ford as ford
from opendbc.safety.tests.libsafety import libsafety_py
class TestFordGenericCanfdExtended(unittest.TestCase):
def setUp(self):
self.safety = libsafety_py.libsafety
self.safety.set_alternative_experience(0)
self.debug = self.safety.set_safety_hooks(structs.CarParams.SafetyModel.allOutput, 0) == 0
self.fixture = ford.TestFordCANFDLongitudinalSafety()
self.fixture.SAFETY_PARAM = 66
self.fixture.setUp()
def __getattr__(self, name):
fixture = self.__dict__.get("fixture")
if fixture is not None:
return getattr(fixture, name)
raise AttributeError(name)
def tearDown(self):
self.safety.set_alternative_experience(0)
self.safety.set_safety_hooks(structs.CarParams.SafetyModel.noOutput, 0)
def select(self, word, ae=0):
self.safety.set_alternative_experience(ae)
self.safety.set_safety_hooks(structs.CarParams.SafetyModel.ford, word)
self.safety.init_tests()
self.safety.set_timer(0)
def announce(self):
msg = self._lkas_command_msg(0)
msg[0].data[4] = 2
return self._tx(msg)
def prepare(self, speed=5.):
# Unit-limit setup only; actual caller packet proof uses physical RX instead.
self._reset_curvature_measurement(0., speed)
self._set_prev_desired_angle(0.)
self.safety.set_controls_allowed(True)
def test_exact_namespace_and_release_long_support(self):
for word in (66,67):
self.select(word)
admitted = True
self.assertEqual(admitted, self.announce())
self.prepare()
self.assertEqual(admitted, self._tx(self._lat_ctl_msg(True,0,0,0,.0002)))
self.assertEqual(word == 67,
self._tx(self._acc_command_msg(self.INACTIVE_GAS,self.INACTIVE_ACCEL,False)))
for word, ae in ((64,0),(65,0),(68,0),(70,0),(74,0),(82,0),(98,0),(194,0),(66,33),(67,33)):
self.select(word,ae)
self.assertFalse(self.announce())
self.assertFalse(self._tx(self._lat_ctl_msg(False,0,0,0,0)))
for word in (66, 67):
self.select(word, 32)
self.assertTrue(self.announce())
self.assertFalse(self._tx(self._lat_ctl_msg(True, 0, 0, .0002, 0)))
def test_announcement_path_and_inactive_contract(self):
self.prepare()
self.assertFalse(self._tx(self._lat_ctl_msg(True,0,0,0,.0002)))
self.assertTrue(self.announce())
for rate in (-.001024,.001023):
self.prepare()
self.assertTrue(self._tx(self._lat_ctl_msg(True,0,0,0,rate)))
for offset,angle,curvature,rate in ((.01,0,0,0),(0,.01,0,0),(0,0,.001,0),(0,0,0,.0002)):
self.prepare()
self.assertTrue(self._tx(self._lat_ctl_msg(True,0,0,.0002,0)))
self.assertEqual(10,self.safety.get_desired_curvature_last())
self.assertFalse(self._tx(self._lat_ctl_msg(False,offset,angle,curvature,rate)))
self.assertEqual(0,self.safety.get_desired_curvature_last())
self.assertTrue(self._tx(self._lat_ctl_msg(False,0,0,0,0)))
def test_modern_envelope_and_rejected_history(self):
self.assertTrue(self.announce())
for speed,accept,reject in ((15.,40,70),(26.,8,12)):
self.prepare(speed)
self.assertTrue(self._tx(self._lat_ctl_msg(True,0,0,accept/self.DEG_TO_CAN,0)))
self.assertEqual(accept,self.safety.get_desired_curvature_last())
# Keep the accepted previous command for the extension-only path rejection.
self.assertFalse(self._tx(self._lat_ctl_msg(True,0,.01,accept/self.DEG_TO_CAN,0)))
self.assertEqual(0,self.safety.get_desired_curvature_last())
self.prepare(speed)
self.assertFalse(self._tx(self._lat_ctl_msg(True,0,0,reject/self.DEG_TO_CAN,0)))
self.assertEqual(0,self.safety.get_desired_curvature_last())
self.assertTrue(self._tx(self._lat_ctl_msg(True,0,0,-accept/self.DEG_TO_CAN,0)))
@@ -1,179 +0,0 @@
"""Exact Mach-E extension intersects current Ford safety; variant-select methods."""
import unittest
from opendbc.safety.tests import test_ford as ford
from opendbc.car.structs import CarParams
from opendbc.safety.tests.libsafety import libsafety_py
from opendbc.safety.tests import test_ford_aol
class TestFordMachEExtended(unittest.TestCase):
SAFETY_PARAM = 18
REQUIRES_DEBUG = None
MAX_CURVATURE_ERROR = 0.006
def setUp(self):
safety = libsafety_py.libsafety
debug = safety.set_safety_hooks(CarParams.SafetyModel.allOutput, 0) == 0
safety.set_safety_hooks(CarParams.SafetyModel.noOutput, 0)
safety.set_alternative_experience(0)
if self.REQUIRES_DEBUG is not None and debug != self.REQUIRES_DEBUG:
self.skipTest("This case requires " + ("DEBUG" if self.REQUIRES_DEBUG else "RELEASE") + " safety")
self._message_fixture = ford.TestFordCANFDStockSafety()
self._message_fixture.SAFETY_PARAM = self.SAFETY_PARAM
self._message_fixture.setUp()
def __getattr__(self, name):
fixture = self.__dict__.get("_message_fixture")
if fixture is None:
raise AttributeError(name)
return getattr(fixture, name)
def announce(self, active=True, forbidden=False):
msg = self._lkas_command_msg(0)
msg[0].data[4] = (2 if active else 0) | int(forbidden)
return self._tx(msg)
def prepare(self, speed=5.0, curvature=0.0195):
self._reset_curvature_measurement(curvature, speed)
self._set_prev_desired_angle(curvature)
self.safety.set_controls_allowed(True)
def command(self, enabled=True, curvature=0.0195, rate=0.0, angle=0.02, offset=0.0, *, increment_timer=True):
return self._lat_ctl_msg(enabled, offset, angle, curvature, rate, increment_timer=increment_timer)
def test_accepted_announcement_required_rejected_frame_cannot_grant(self):
self.prepare()
self.assertFalse(self._tx(self.command()))
self.assertFalse(self.announce(forbidden=True))
self.prepare()
self.assertFalse(self._tx(self.command()))
self.assertTrue(self.announce())
self.prepare()
self.assertTrue(self._tx(self.command()))
self.assertTrue(self.announce(active=False))
self.prepare()
self.assertFalse(self._tx(self.command()))
def test_original_path_recipe_and_inactive_fields(self):
self.assertTrue(self.announce())
self.prepare()
self.assertTrue(self._tx(self.command()))
for kwargs in ({'angle': -0.02}, {'angle': 0.161}, {'angle': 0.08}, {'curvature': 0.0194}, {'offset': 0.01}):
with self.subTest(kwargs=kwargs):
self.prepare()
self.assertFalse(self._tx(self.command(**kwargs)))
self.prepare()
self.assertTrue(self._tx(self.command(enabled=False, curvature=0.0, angle=0.0)))
self.prepare()
self.assertFalse(self._tx(self.command(enabled=False, rate=0.000001, curvature=0.0, angle=0.0)))
def test_modern_jerk_rejects_original_rate_counterexample(self):
self.assertTrue(self.announce())
self.prepare(speed=15.0, curvature=0.0)
# Original15m/s lookup permits75CANunits; modernISOjerk permits46.
self.assertFalse(self._tx(self.command(curvature=0.0015, angle=0.0)))
self.prepare(speed=15.0, curvature=0.0)
self.assertTrue(self._tx(self.command(curvature=0.0009, angle=0.0)))
def test_speed_combined_accel_and_authority_denials(self):
self.assertTrue(self.announce())
for speed in (2.9, 8.8):
self.prepare(speed=speed)
self.assertFalse(self._tx(self.command()))
self.prepare()
self.assertTrue(self._tx(self.command(angle=0.05)))
self.prepare()
self.assertTrue(self._tx(self.command(angle=0.1)))
self.prepare(speed=8.7)
# 0.0195 * 8.7**2 + 0.12 * 8.7 > 2.5; path delta is only40CAN.
self.assertFalse(self._tx(self.command(angle=0.12)))
self.prepare()
self.safety.set_controls_allowed(False)
self.assertFalse(self._tx(self.command()))
def test_rejected_path_does_not_commit_curvature_history(self):
self.assertTrue(self.announce())
self.prepare()
self.assertFalse(self._tx(self.command(curvature=0.0175)))
self._reset_curvature_measurement(0.0, 5.0)
self.safety.set_controls_allowed(True)
# Reset history permits125CAN from zero; a leaked875CAN rejects this step.
self.assertTrue(self._tx(self.command(curvature=0.0025, angle=0.0)))
def test_namespace_unknown_words_and_siblings_cannot_acquire(self):
for word, ae in ((2, 0), (3, 0), (10, 0), (11, 0), (22, 0), (26, 0), (50, 0), (18, 32)):
with self.subTest(word=word, ae=ae):
self.safety.set_alternative_experience(ae)
self.safety.set_safety_hooks(CarParams.SafetyModel.ford, word)
self.announce()
self.prepare()
self.assertFalse(self._tx(self.command()))
def test_typed_aol_retains_curvature_without_path_angle(self):
for sign in (-1, 1):
with self.subTest(sign=sign):
self.setUp()
intent = test_ford_aol.TestFordAolDriverIntent()
intent.setUp()
intent.reset(self.SAFETY_PARAM)
intent.arm()
intent.pump(12, mask=1, speed=7.5, curvature=sign * .02)
self.assertTrue(self.safety.safety_config_valid())
self.assertEqual(self.safety.aol_get_permission_mask(), 1)
self.assertFalse(self.safety.get_controls_allowed())
self.assertTrue(self.announce())
self._set_prev_desired_angle(sign * .02)
self.assertFalse(self._tx(self.command(curvature=sign * .02, angle=sign * .055, increment_timer=False)))
self._set_prev_desired_angle(sign * .02)
self.assertTrue(self._tx(self.command(curvature=sign * .02, angle=0., increment_timer=False)))
def test_accelerator_assist_requires_retained_full_engagement(self):
for sign in (-1, 1):
with self.subTest(sign=sign):
self.setUp()
intent = test_ford_aol.TestFordAolDriverIntent()
intent.setUp()
intent.reset(self.SAFETY_PARAM)
intent.arm()
intent.pump(12, mask=1, speed=7.5, curvature=sign * .02)
self.assertTrue(self.safety.safety_config_valid())
self.assertEqual(self.safety.aol_get_permission_mask(), 1)
self._set_prev_desired_angle(sign * .02)
self._rx(self._pcm_status_msg(True))
self.assertTrue(self.announce())
for gas in (0., 25., 0.):
self._rx(self._user_gas_msg(gas))
self.assertTrue(self.safety.get_controls_allowed())
self.assertTrue(self._tx(self.command(curvature=sign * .02, angle=sign * .055, increment_timer=False)))
# Cruise returning to available revokes full engagement, while typed AOL keeps the lateral axis.
intent.rx('EngBrakeData', {'BpedDrvAppl_D_Actl': 1, 'CcStat_D_Actl': 3})
self.assertFalse(self.safety.get_controls_allowed())
self.assertEqual(self.safety.aol_get_permission_mask(), 1)
self.assertFalse(self._tx(self.command(curvature=sign * .02, angle=sign * .055, increment_timer=False)))
self._set_prev_desired_angle(sign * .02)
self.assertTrue(self._tx(self.command(curvature=sign * .02, angle=0., increment_timer=False)))
self._rx(self._pcm_status_msg(True))
self._rx(self._user_brake_msg(True))
self.assertFalse(self.safety.get_controls_allowed())
self.assertEqual(self.safety.aol_get_permission_mask(), 1)
self.assertFalse(self._tx(self.command(curvature=sign * .02, angle=sign * .055, increment_timer=False)))
self._set_prev_desired_angle(sign * .02)
self.assertTrue(self._tx(self.command(curvature=sign * .02, angle=0., increment_timer=False)))
class TestFordMachEDebugLongExtended(TestFordMachEExtended):
SAFETY_PARAM = 19
REQUIRES_DEBUG = True
class TestFordMachELongReleaseExtended(TestFordMachEExtended):
SAFETY_PARAM = 19
REQUIRES_DEBUG = False
# Compatibility for existing CI selector names: this runs the promoted profile's
# complete positive RELEASE contract, including the unchanged actuation bounds.
TestFordMachELongReleaseDenied = TestFordMachELongReleaseExtended
@@ -2,13 +2,13 @@
import pytest
from opendbc.car import gen_empty_fingerprint
from opendbc.car.ford import mache_can
from opendbc.car.ford import fordcan
from opendbc.car.ford.fordcan import CanBus
from opendbc.safety.tests.libsafety import libsafety_py
from opendbc.safety.tests import test_ford_aol
@pytest.fixture(params=(19, 67, 275, 323))
@pytest.fixture(params=(3, 259))
def owner(request):
native = test_ford_aol.TestFordAolDriverIntent()
native.setUp()
@@ -17,14 +17,14 @@ def owner(request):
native.safety.set_alternative_experience(32)
native.pump(40, main=0, mask=0, heartbeat=False)
bus = CanBus(fingerprint=gen_empty_fingerprint())
address, data, src = mache_can.create_lka_msg(native.packer, bus)
address, data, src = fordcan.create_lka_msg(native.packer, bus)
assert native.safety.safety_tx_hook(libsafety_py.make_CANPacket(address, src, data))
return native
def inactive(owner):
bus = CanBus(fingerprint=gen_empty_fingerprint())
address, data, src = mache_can.create_lat_ctl2_msg(owner.packer, bus, 0, 0, 1, 0., 0., 0)
address, data, src = fordcan.create_lat_ctl2_msg(owner.packer, bus, 0, 0., 0., 0., 0., 0)
assert owner.safety.safety_tx_hook(libsafety_py.make_CANPacket(address, src, data))
@@ -71,7 +71,7 @@ def test_main_off_continuity_has_no_steering_or_longitudinal_authority(owner):
assert owner.safety.aol_get_permission_mask() == 0
assert not owner.safety.get_controls_allowed()
bus = CanBus(fingerprint=gen_empty_fingerprint())
address, data, src = mache_can.create_lat_ctl2_msg(owner.packer, bus, 1, 0, 1, 0., 0., 1)
address, data, src = fordcan.create_lat_ctl2_msg(owner.packer, bus, 1, 0., 0., 0., 0., 1)
assert not owner.safety.safety_tx_hook(libsafety_py.make_CANPacket(address, src, data))
assert owner.safety.safety_fwd_hook(2, 0x18A) == 0
@@ -139,7 +139,7 @@ def test_neutral_continuity_across_real_microsecond_timer_wrap(owner):
owner.safety.set_alternative_experience(32)
owner.pump(40, main=0, mask=0, heartbeat=False)
bus = CanBus(fingerprint=gen_empty_fingerprint())
address, data, src = mache_can.create_lka_msg(owner.packer, bus)
address, data, src = fordcan.create_lka_msg(owner.packer, bus)
assert owner.safety.safety_tx_hook(libsafety_py.make_CANPacket(address, src, data))
acquire(owner)
for tick in range(40):
@@ -155,9 +155,9 @@ def test_neutral_continuity_across_real_microsecond_timer_wrap(owner):
def active_then_off(owner, full=False):
owner.arm()
bus = CanBus(fingerprint=gen_empty_fingerprint())
address, data, src = mache_can.create_lka_msg(owner.packer, bus)
address, data, src = fordcan.create_lka_msg(owner.packer, bus)
assert owner.safety.safety_tx_hook(libsafety_py.make_CANPacket(address, src, data))
address, data, src = mache_can.create_lat_ctl2_msg(owner.packer, bus, 1, 0, 1, 0., 0., 0)
address, data, src = fordcan.create_lat_ctl2_msg(owner.packer, bus, 1, 0., 0., 0., 0., 0)
assert owner.safety.safety_tx_hook(libsafety_py.make_CANPacket(address, src, data))
assert owner.safety.aol_get_permission_mask() == 1
if full:
@@ -46,7 +46,7 @@ class TestFordStockSwitch(unittest.TestCase):
self.assertTrue(self._rx(self.physical_switch(button)))
def test_physical_available_inactive_resume_does_not_enable_controls(self):
for word in (2, 8, 10, 12, 18):
for word in (0, 2, 8, 10, 12):
with self.subTest(word=word):
self.select(word)
self.healthy(word)
@@ -83,7 +83,7 @@ class TestFordStockSwitch(unittest.TestCase):
self.assertFalse(self._tx(self._acc_button_msg(Buttons.RESUME, 0)))
def test_long_unknown_ae_and_unreached_words_cannot_use_exception(self):
for word, ae in ((0, 0), (4, 0), (1, 0), (3, 0), (9, 0), (11, 0), (13, 0), (19, 0), (34, 0), (2, 32)):
for word, ae in ((4, 0), (1, 0), (3, 0), (9, 0), (11, 0), (13, 0), (34, 0), (4, 32)):
with self.subTest(word=word, ae=ae):
self.select(word, ae)
self.healthy(word)
-6
View File
@@ -43,13 +43,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"HondaBoschARadar", {PERSISTENT, BOOL, "1"}},
{"HondaGasFactorParams", {PERSISTENT, FLOAT, "1.0"}},
{"HondaWindFactorParams", {PERSISTENT, FLOAT, "1.0"}},
{"FordPrefSteerAngleCurvature", {PERSISTENT, BOOL, "0"}},
{"FordLateralMode", {PERSISTENT, INT, "0"}},
{"FordHandsFreeCluster", {PERSISTENT, BOOL, "0"}},
{"FordHumanTurnDetection", {PERSISTENT, BOOL, "1"}},
{"FordCurvatureBlendLow", {PERSISTENT, FLOAT, "0.4"}},
{"FordCurvatureBlendHigh", {PERSISTENT, FLOAT, "0.4"}},
{"FordCurvatureLaneChangeFactor", {PERSISTENT, FLOAT, "0.85"}},
{"OnroadCustomizations", {PERSISTENT | DONT_LOG, JSON}},
{"StarPilotFavoriteSlots", {PERSISTENT, JSON, R"([{"enabled":false,"show_onroad":false,"key":null,"label":""},{"enabled":false,"show_onroad":false,"key":null,"label":""},{"enabled":false,"show_onroad":false,"key":null,"label":""}])"}},
{"StarPilotControllerBindings", {PERSISTENT | DONT_LOG, JSON}},
@@ -249,22 +249,12 @@ int main() {
assert(aol_capable(preap, 39U, VEHICLE_REGISTRY) == preap_expected);
assert(!aol_capable(preap, 35U, VEHICLE_REGISTRY));
}
constexpr uint16_t ford_words[] = {8U, 9U, 10U, 11U, 12U, 13U, 18U, 19U, 32U, 33U, 66U, 67U, 146U, 147U, 160U, 161U, 194U, 195U};
constexpr uint16_t ford_words[] = {0U, 1U, 2U, 3U, 8U, 9U, 10U, 11U, 12U, 13U};
for (uint32_t word = 0U; word <= 65535U; ++word) {
bool expected = false;
for (const auto exact : ford_words) expected |= (word == exact) || (word == (exact | 256U));
// Exact opt-in geometry families, independently enumerated from the native decoder.
for (uint16_t geometry = 1U; geometry <= 12U; geometry++) {
const bool classic = (geometry == 1U) || (geometry == 2U) || (geometry == 5U) || (geometry == 6U) || (geometry == 9U);
const uint16_t curvature = classic ? 32U : ((geometry == 11U) ? 18U : ((geometry == 10U) ? 10U : 66U));
for (uint16_t longitudinal = 0U; longitudinal <= 1U; longitudinal++) {
for (uint16_t angle = 0U; angle <= 1U; angle++) {
if ((geometry == 10U) && angle) continue;
const uint16_t exact = 512U | (geometry << 10U) | curvature | longitudinal | (angle ? 128U : 0U);
expected |= (word == exact) || (word == (exact | 256U));
}
}
}
// Bits 9-14 carry the BluePilot lateral library's startup bits, whatever they hold.
const uint32_t profile = word & ~0x7E00U;
for (const auto exact : ford_words) expected |= (profile == exact) || (profile == (exact | 256U));
auto ford = status();
ford.safety_mode = 6U;
ford.safety_param = static_cast<uint16_t>(word);
@@ -145,7 +145,7 @@ void test_actual_config_slots_and_owner_handle(cereal::CarParams::SafetyModel mo
void test_registered_owners() {
test_actual_config_slots_and_owner_handle(cereal::CarParams::SafetyModel::HYUNDAI_CANFD, 0x809);
for (uint16_t word : {8U, 9U, 10U, 11U, 12U, 13U, 18U, 19U, 32U, 33U, 66U, 67U}) {
for (uint16_t word : {0U, 1U, 2U, 3U, 8U, 9U, 10U, 11U, 12U, 13U}) {
test_actual_config_slots_and_owner_handle(cereal::CarParams::SafetyModel::FORD, word);
test_actual_config_slots_and_owner_handle(cereal::CarParams::SafetyModel::FORD, word | 256U);
}
+7 -7
View File
@@ -817,11 +817,11 @@ class SelfdriveD:
return CS
def ford_source_deadline_supported(self):
from opendbc.car.ford.aol import qualified, base_safety_param
from opendbc.car.ford.aol import profile_word, qualified
from opendbc.car.ford.values import CAR
return (qualified(self.CP, marked_only=True) and
self.CP.carFingerprint == CAR.FORD_MUSTANG_MACH_E_MK1 and
len(self.CP.safetyConfigs) == 1 and base_safety_param(self.CP) == 19)
len(self.CP.safetyConfigs) == 1 and profile_word(self.CP.safetyConfigs[0].safetyParam) == 3)
def source_deadline_deferral_supported(self):
from openpilot.starpilot.car.hyundai.aol import source_deadline_supported
@@ -1780,13 +1780,14 @@ class SelfdriveD:
"""
from openpilot.starpilot.aol.transport_pause import independent_lateral_health
from opendbc.car.ford.values import CAR as FORD_CAR
from opendbc.car.ford.aol import profile_word
from openpilot.starpilot.car.ford.aol import native_accepts_cp as ford_native_accepts_cp
# Established ordinary Mach-E cruise does not require an independent AOL latch.
# Its native LONG pedal withdrawal may precede Card; the original combined
# publication, live LAT grant and every health/deadline check below still apply.
ordinary_ford = bool(self.CP.carFingerprint == FORD_CAR.FORD_MUSTANG_MACH_E_MK1 and self.CP.safetyConfigs and
ford_native_accepts_cp(self.CP, int(car.CarParams.SafetyModel.ford),
self.CP.safetyConfigs[-1].safetyParam))
ford_word = self.CP.safetyConfigs[-1].safetyParam
ordinary_ford = bool(self.CP.carFingerprint == FORD_CAR.FORD_MUSTANG_MACH_E_MK1 and profile_word(ford_word) == 3 and
ford_native_accepts_cp(self.CP, int(car.CarParams.SafetyModel.ford), ford_word))
proof = getattr(self, 'aol_published_input_proof', None)
prior = getattr(self, 'aol_published_input_native', None)
anchor = getattr(self, 'aol_ordinary_source_anchor', None)
@@ -1834,9 +1835,8 @@ class SelfdriveD:
# Ioniq6 also checks the physical brake before its separately published host sample.
# Retain only its already granted LAT; all proof, health and LONG withdrawal gates remain.
# The exact vehicle policies and all proof/health checks above remain required.
from openpilot.starpilot.car.ford.aol import early_native_long_withdrawal_supported as ford_early_long
return bool(ordinary_ford or gm_native_accepts_cp(self.CP, native.safetyModel, native.safetyParam) or
early_native_long_withdrawal_supported(self.CP) or ford_early_long(self.CP) or
early_native_long_withdrawal_supported(self.CP) or
toyota_aol_qualified(self.CP, marked_only=True) or
not native.pandaInventory[owners[0]].controlsAllowed and
not self.sm['pandaStates'][owners[0]].controlsAllowed)
@@ -8,8 +8,8 @@ from opendbc.car.ford.interface import CarInterface
from opendbc.car.ford.values import CAR
@pytest.fixture(params=[(False, False), (True, False), (False, True), (True, True)])
def caller(request):
@pytest.fixture
def caller():
case = fixture.IoniqAvailabilityTests(methodName='runTest')
with ExitStack() as stack:
stack.callback(case.doCleanups)
@@ -19,11 +19,6 @@ def caller(request):
cp = CarInterface.get_params(CAR.FORD_MUSTANG_MACH_E_MK1, fingerprint, [], True, False, False)
cp.alternativeExperience = 32
assert cp.safetyConfigs[-1].safetyParam == 19 and cp.flags == 3
angle, pinion = request.param
cp.safetyConfigs[0].safetyParam |= 128 if angle else 0
if pinion:
from opendbc.car.ford.pinion import select
assert select(cp, True)
sd, controls, step = case._authority_callers(True, cp=cp, genuine_events=True)
cs = fixture.car_state()
cs.cruiseState.enabled = True

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