mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-09-18 12:33:43 +08:00
Ford: add live C0 distance toggle on comma four
This commit is contained in:
@@ -1,16 +1,16 @@
|
|||||||
# Ford selected-action drive-test branch
|
# Ford selected-action drive-test branch
|
||||||
|
|
||||||
This v11 controller restores [curvature-derived C0](ford_curvature_c0_v8.md) and retains direct C0/C1 requests
|
This v12 controller restores [curvature-derived C0](ford_curvature_c0_v8.md) and retains direct C0/C1 requests
|
||||||
and [continuous C1 PI feedback](ford_c1_minimal_pi.md)
|
and [continuous C1 PI feedback](ford_c1_minimal_pi.md)
|
||||||
with **P=0.50 and I=0.25**.
|
with **P=0.50 and I=0.25**.
|
||||||
Only integrated tracking error accumulates correction; C0/C1 reflect the current bounded request. C0 is now a 7 m circular arc from selected desired curvature.
|
Only integrated tracking error accumulates correction; C0/C1 reflect the current bounded request. C0 defaults to a 7 m circular arc from selected desired curvature. An on-device toggle can instead use max(7 m, speed × 1 second).
|
||||||
[Base C1 overflow allocation to C0](ford_c1_overflow.md) remains.
|
[Base C1 overflow allocation to C0](ford_c1_overflow.md) remains.
|
||||||
It is selectable on **any Ford CAN FD vehicle**
|
It is selectable on **any Ford CAN FD vehicle**
|
||||||
through the existing persistent, default-off Sunnylink
|
through the existing persistent, default-off Sunnylink
|
||||||
toggle. Offline checks establish software behavior; physical tracking,
|
toggle. Offline checks establish software behavior; physical tracking,
|
||||||
turn-exit behavior and closed-loop stability remain unvalidated.
|
turn-exit behavior and closed-loop stability remain unvalidated.
|
||||||
|
|
||||||
V11 restores the v9 command law after the model-path C0 trial in `5db3e3c9a`.
|
V12 retains the v11/v9 command law by default after the model-path C0 trial in `5db3e3c9a`.
|
||||||
Both base commands use selected, upstream-limited desired curvature. The gains remain
|
Both base commands use selected, upstream-limited desired curvature. The gains remain
|
||||||
P=0.50 and I=0.25, and PSCM `LimitReached` handling is unchanged. The separate
|
P=0.50 and I=0.25, and PSCM `LimitReached` handling is unchanged. The separate
|
||||||
offline experiment that ignores the reached-limit integration block is not included.
|
offline experiment that ignores the reached-limit integration block is not included.
|
||||||
@@ -27,7 +27,7 @@ offline experiment that ignores the reached-limit integration block is not inclu
|
|||||||
|
|
||||||
The startup event `Ford path controller selected` should report
|
The startup event `Ford path controller selected` should report
|
||||||
`FordModelActionController`. Periodic `Ford C2-free path tracking` events
|
`FordModelActionController`. Periodic `Ford C2-free path tracking` events
|
||||||
identify **`hypothesis=model-action-curvature-c0-direct-pi-v11`**. They report desired and measured
|
identify **`hypothesis=model-action-curvature-c0-distance-pi-v12`**. They report desired and measured
|
||||||
curvature, base heading, proportional and accumulated correction, applied heading,
|
curvature, base heading, proportional and accumulated correction, applied heading,
|
||||||
feedback timing and driver/PSCM gating. `proportional_gain=0.5` and
|
feedback timing and driver/PSCM gating. `proportional_gain=0.5` and
|
||||||
`integral_gain=0.25` identify the trial. `offset_overflow` reports the extra C0
|
`integral_gain=0.25` identify the trial. `offset_overflow` reports the extra C0
|
||||||
@@ -42,6 +42,41 @@ a custom controller. The observer toggle is no longer exposed. The experiment
|
|||||||
only runs on Ford CAN FD vehicles; legacy Ford uses upstream control as well.
|
only runs on Ford CAN FD vehicles; legacy Ford uses upstream control as well.
|
||||||
See [toggle-off validation](ford_upstream_fallback.md).
|
See [toggle-off validation](ford_upstream_fallback.md).
|
||||||
|
|
||||||
|
## C0 distance toggle on comma four
|
||||||
|
|
||||||
|
With the experimental Ford controller enabled, open **Settings → toggles → C0: 1 second**.
|
||||||
|
The toggle is visible for Ford CAN FD vehicles and can be changed while disengaged.
|
||||||
|
|
||||||
|
- **Off (default):** C0 uses a fixed 7 m arc.
|
||||||
|
- **On:** C0 uses a distance of max(7 m, speed × 1 second), matching the base C1 distance.
|
||||||
|
|
||||||
|
Disengage assistance, change the toggle, and remain disengaged for at least three seconds
|
||||||
|
before reengaging. This setting uses the existing three-second runtime parameter refresh;
|
||||||
|
**no ignition cycle or controlsd restart is required**. Engaged or paused MADS and stale
|
||||||
|
engagement messages prevent applying a change. A mode change resets the PI correction and
|
||||||
|
adapter timestamps. Reapplying the same value does not reset anything.
|
||||||
|
|
||||||
|
The persistent parameter is `FordC0TimeBased`. It cannot enable the experimental controller
|
||||||
|
by itself. The existing Sunnylink controller-selection toggle still requires an onroad cycle.
|
||||||
|
C1, the gains, the 7 m heading-overflow allocation, the upstream reference limits and the CAN
|
||||||
|
field bounds are unchanged. Below 7 m/s (about 15.7 mph), both distance modes are identical.
|
||||||
|
At 20/30/60 mph the enabled distance is approximately 8.9/13.4/26.8 m, respectively; C0 can
|
||||||
|
therefore be substantially larger, especially at higher speeds. Its release still follows the
|
||||||
|
current selected curvature immediately, with no additional slew.
|
||||||
|
|
||||||
|
The `Ford C0 distance changed` event records an applied switch. Periodic tracking events
|
||||||
|
include `c0_time_based` and the actual `offset_distance` in meters, including the default mode.
|
||||||
|
Offline checks verify selection, runtime switching, resets, unchanged C1 and CAN encoding;
|
||||||
|
they do not establish which distance the PSCM follows better.
|
||||||
|
|
||||||
|
Validation on 2026-09-14: 410 tests and 25 subtests passed, plus Ruff and the local comma four
|
||||||
|
UI construction/write/refresh/visibility/render check. The 54,146-cycle maneuver-route replay
|
||||||
|
(`84865544361f55cb/0000011c--99f4537696`) matched `775012167` exactly with the new toggle off.
|
||||||
|
With it on, C0 changed in 35,668 cycles (maximum difference 0.74 m), while C1 and accumulated
|
||||||
|
correction remained identical on the same recorded motion. The two comparisons completed
|
||||||
|
216,584 controller updates and CAN round trips. No vehicle build, installation or road test
|
||||||
|
was performed for this change.
|
||||||
|
|
||||||
## Wiring and validation
|
## Wiring and validation
|
||||||
|
|
||||||
`controlsd` supplies the selected, upstream-limited desired curvature and the
|
`controlsd` supplies the selected, upstream-limited desired curvature and the
|
||||||
@@ -57,7 +92,7 @@ combined feedforward/P/I amplitude envelope. There is no C0 confirmation
|
|||||||
threshold or remembered turn direction. Zero error removes P and holds I; it
|
threshold or remembered turn direction. Zero error removes P and holds I; it
|
||||||
does not trigger a release. Final command limits still apply.
|
does not trigger a release. Final command limits still apply.
|
||||||
|
|
||||||
C0 starts with the 7 m circular arc of selected desired curvature. It does not
|
C0 starts with the selected-distance circular arc of selected desired curvature. It does not
|
||||||
add independent live model-path position or heading. Valid model geometry is
|
add independent live model-path position or heading. Valid model geometry is
|
||||||
still required as a health gate. When the raw base heading
|
still required as a health gate. When the raw base heading
|
||||||
exceeds ±0.5 rad, C0 additionally receives 7 m times the clipped-away heading.
|
exceeds ±0.5 rad, C0 additionally receives 7 m times the clipped-away heading.
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
|||||||
// sunnypilot car specific params
|
// sunnypilot car specific params
|
||||||
{"FordPscmObserver", {PERSISTENT | BACKUP, BOOL, "0"}},
|
{"FordPscmObserver", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||||
{"FordModelActionController", {PERSISTENT | BACKUP, BOOL, "0"}},
|
{"FordModelActionController", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||||
|
{"FordC0TimeBased", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||||
{"HyundaiLongitudinalTuning", {PERSISTENT | BACKUP, INT, "0"}},
|
{"HyundaiLongitudinalTuning", {PERSISTENT | BACKUP, INT, "0"}},
|
||||||
{"SubaruStopAndGo", {PERSISTENT | BACKUP, BOOL, "0"}},
|
{"SubaruStopAndGo", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||||
{"SubaruStopAndGoManualParkingBrake", {PERSISTENT | BACKUP, BOOL, "0"}},
|
{"SubaruStopAndGoManualParkingBrake", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ class Controls(ControlsExt):
|
|||||||
self.steer_limited_by_safety = False
|
self.steer_limited_by_safety = False
|
||||||
self.curvature = 0.0
|
self.curvature = 0.0
|
||||||
self.desired_curvature = 0.0
|
self.desired_curvature = 0.0
|
||||||
self.ford_path_controller = select_model_action_controller(self.CP, self.params.get_bool("FordModelActionController"))
|
self.ford_path_controller = select_model_action_controller(self.CP, self.params.get_bool("FordModelActionController"),
|
||||||
|
c0_time_based=self.params.get_bool("FordC0TimeBased"))
|
||||||
self.ford_model_action = isinstance(self.ford_path_controller, FordModelActionController)
|
self.ford_model_action = isinstance(self.ford_path_controller, FordModelActionController)
|
||||||
if self.CP.brand == "ford":
|
if self.CP.brand == "ford":
|
||||||
cloudlog.event("Ford path controller selected",
|
cloudlog.event("Ford path controller selected",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Opt-in Ford C2-free model mapping with measured-curvature PI feedback.
|
"""Opt-in Ford C2-free model mapping with measured-curvature PI feedback.
|
||||||
|
|
||||||
C0 samples a desired-curvature arc at 7 m, including base-heading overflow. C1
|
C0 samples a desired-curvature arc at 7 m, optionally max(7 m, v*1s),
|
||||||
|
including base-heading overflow. C1
|
||||||
combines the selected curvature's heading with proportional and integrated
|
combines the selected curvature's heading with proportional and integrated
|
||||||
tracking error. Reference distance and gains are explicit trial choices.
|
tracking error. Reference distance and gains are explicit trial choices.
|
||||||
Commands use the current bounded request without an additional C0/C1 slew.
|
Commands use the current bounded request without an additional C0/C1 slew.
|
||||||
@@ -34,8 +35,8 @@ def _finite(*values):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def encode_model_action(model, desired_curvature, speed):
|
def encode_model_action(model, desired_curvature, speed, *, c0_time_based=False):
|
||||||
"""Encode a 7 m circular-arc offset and max(7, v*1s)*selected curvature.
|
"""Encode a circular-arc offset and max(7, v*1s)*selected curvature.
|
||||||
|
|
||||||
The arc starts at zero lateral position and heading. Original model geometry
|
The arc starts at zero lateral position and heading. Original model geometry
|
||||||
remains a health gate; selected curvature supplies both path commands.
|
remains a health gate; selected curvature supplies both path commands.
|
||||||
@@ -49,9 +50,10 @@ def encode_model_action(model, desired_curvature, speed):
|
|||||||
if path is None or not all(_finite(*values) for values in path):
|
if path is None or not all(_finite(*values) for values in path):
|
||||||
return FordPath()
|
return FordPath()
|
||||||
# (1-cos(S*k))/k, using sinc to avoid cancellation near zero curvature.
|
# (1-cos(S*k))/k, using sinc to avoid cancellation near zero curvature.
|
||||||
half_heading = .5*OFFSET_STATION_M*desired_curvature
|
distance = max(OFFSET_STATION_M, speed*HEADING_TIME_S) if c0_time_based else OFFSET_STATION_M
|
||||||
|
half_heading = .5*distance*desired_curvature
|
||||||
sinc = math.sin(half_heading)/half_heading if half_heading else 1.
|
sinc = math.sin(half_heading)/half_heading if half_heading else 1.
|
||||||
c0 = .5*desired_curvature*OFFSET_STATION_M**2*sinc**2
|
c0 = .5*desired_curvature*distance**2*sinc**2
|
||||||
c1 = max(OFFSET_STATION_M, speed*HEADING_TIME_S)*desired_curvature
|
c1 = max(OFFSET_STATION_M, speed*HEADING_TIME_S)*desired_curvature
|
||||||
return FordPath(True, c0, c1, 0., 0.) if _finite(c0, c1) else FordPath()
|
return FordPath(True, c0, c1, 0., 0.) if _finite(c0, c1) else FordPath()
|
||||||
|
|
||||||
@@ -61,12 +63,13 @@ class ModelActionController:
|
|||||||
|
|
||||||
Freshness, measurement cadence and driver/PSCM arbitration belong to the caller.
|
Freshness, measurement cadence and driver/PSCM arbitration belong to the caller.
|
||||||
"""
|
"""
|
||||||
__slots__ = ('c0', 'c1', 'correction', 'proportional_gain', 'integral_gain', 'proportional', 'feedback_curvature')
|
__slots__ = ('c0', 'c1', 'correction', 'proportional_gain', 'integral_gain', 'proportional', 'feedback_curvature', 'c0_time_based')
|
||||||
|
|
||||||
def __init__(self, proportional_gain=C1_PROPORTIONAL_GAIN, integral_gain=C1_INTEGRAL_GAIN):
|
def __init__(self, proportional_gain=C1_PROPORTIONAL_GAIN, integral_gain=C1_INTEGRAL_GAIN, *, c0_time_based=False):
|
||||||
if not _finite(proportional_gain, integral_gain) or min(proportional_gain, integral_gain) < 0.:
|
if not _finite(proportional_gain, integral_gain) or min(proportional_gain, integral_gain) < 0.:
|
||||||
raise ValueError('PI gains must be finite and nonnegative')
|
raise ValueError('PI gains must be finite and nonnegative')
|
||||||
self.proportional_gain, self.integral_gain = float(proportional_gain), float(integral_gain)
|
self.proportional_gain, self.integral_gain = float(proportional_gain), float(integral_gain)
|
||||||
|
self.c0_time_based = bool(c0_time_based)
|
||||||
self.reset()
|
self.reset()
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
@@ -80,7 +83,7 @@ class ModelActionController:
|
|||||||
or not 0. <= feedback_dt <= .15 or abs(current_curvature) > 1. or abs(reference) > 1.):
|
or not 0. <= feedback_dt <= .15 or abs(current_curvature) > 1. or abs(reference) > 1.):
|
||||||
self.reset()
|
self.reset()
|
||||||
return FordPath()
|
return FordPath()
|
||||||
target = encode_model_action(model, desired_curvature, speed)
|
target = encode_model_action(model, desired_curvature, speed, c0_time_based=self.c0_time_based)
|
||||||
if not target.valid:
|
if not target.valid:
|
||||||
self.reset()
|
self.reset()
|
||||||
return FordPath()
|
return FordPath()
|
||||||
@@ -126,15 +129,24 @@ class FordModelActionController:
|
|||||||
clears the correction. Fresh PSCM limits only inhibit outward integration;
|
clears the correction. Fresh PSCM limits only inhibit outward integration;
|
||||||
neither a limit nor a repeated measurement freezes the model request.
|
neither a limit nor a repeated measurement freezes the model request.
|
||||||
"""
|
"""
|
||||||
def __init__(self, proportional_gain=C1_PROPORTIONAL_GAIN, integral_gain=C1_INTEGRAL_GAIN):
|
def __init__(self, proportional_gain=C1_PROPORTIONAL_GAIN, integral_gain=C1_INTEGRAL_GAIN, *, c0_time_based=False):
|
||||||
self.core = ModelActionController(proportional_gain=proportional_gain, integral_gain=integral_gain)
|
self.core = ModelActionController(proportional_gain=proportional_gain, integral_gain=integral_gain, c0_time_based=c0_time_based)
|
||||||
self.hypothesis = 'model-action-curvature-c0-direct-pi-v11'
|
self.hypothesis = 'model-action-curvature-c0-distance-pi-v12'
|
||||||
self.reset()
|
self.reset()
|
||||||
|
|
||||||
|
def set_c0_time_based(self, enabled, *, lateral_engaged):
|
||||||
|
"""Apply a distance change only after lateral assistance is disengaged."""
|
||||||
|
if lateral_engaged or self.core.c0_time_based == bool(enabled):
|
||||||
|
return False
|
||||||
|
self.core.c0_time_based = bool(enabled)
|
||||||
|
self.reset('c0_distance_changed')
|
||||||
|
return True
|
||||||
|
|
||||||
def reset(self, status='inactive'):
|
def reset(self, status='inactive'):
|
||||||
self.core.reset()
|
self.core.reset()
|
||||||
self.last_time = self.last_measurement_time = self.last_model_time = None
|
self.last_time = self.last_measurement_time = self.last_model_time = None
|
||||||
self.diagnostics = {'status': status, 'hypothesis': self.hypothesis,
|
self.diagnostics = {'status': status, 'hypothesis': self.hypothesis,
|
||||||
|
'c0_time_based': self.core.c0_time_based,
|
||||||
'calibration_approved': CALIBRATION_APPROVED, 'command': (0., 0., 0., 0.)}
|
'calibration_approved': CALIBRATION_APPROVED, 'command': (0., 0., 0., 0.)}
|
||||||
|
|
||||||
def update(self, model, desired_curvature, *, current_curvature, yaw_rate, speed, now, measurement_time, model_time,
|
def update(self, model, desired_curvature, *, current_curvature, yaw_rate, speed, now, measurement_time, model_time,
|
||||||
@@ -179,6 +191,8 @@ class FordModelActionController:
|
|||||||
raw_heading = max(OFFSET_STATION_M, speed*HEADING_TIME_S)*desired_curvature
|
raw_heading = max(OFFSET_STATION_M, speed*HEADING_TIME_S)*desired_curvature
|
||||||
base_heading = float(np.clip(raw_heading, -.5, .5))
|
base_heading = float(np.clip(raw_heading, -.5, .5))
|
||||||
self.diagnostics = {'status': 'active', 'hypothesis': self.hypothesis,
|
self.diagnostics = {'status': 'active', 'hypothesis': self.hypothesis,
|
||||||
|
'c0_time_based': self.core.c0_time_based,
|
||||||
|
'offset_distance': max(OFFSET_STATION_M, speed*HEADING_TIME_S) if self.core.c0_time_based else OFFSET_STATION_M,
|
||||||
'calibration_approved': CALIBRATION_APPROVED, 'desired_curvature': desired_curvature,
|
'calibration_approved': CALIBRATION_APPROVED, 'desired_curvature': desired_curvature,
|
||||||
'model_age': now - model_time, 'measurement_age': now - measurement_time, 'reference_age': now - reference_time,
|
'model_age': now - model_time, 'measurement_age': now - measurement_time, 'reference_age': now - reference_time,
|
||||||
'dt': dt, 'offset_request': self.core.c0, 'heading_request': self.core.c1,
|
'dt': dt, 'offset_request': self.core.c0, 'heading_request': self.core.c1,
|
||||||
@@ -194,9 +208,9 @@ class FordModelActionController:
|
|||||||
return command
|
return command
|
||||||
|
|
||||||
|
|
||||||
def select_model_action_controller(CP, enabled):
|
def select_model_action_controller(CP, enabled, *, c0_time_based=False):
|
||||||
"""Only opt-in Ford CAN FD vehicles override upstream curvature control."""
|
"""Only opt-in Ford CAN FD vehicles override upstream curvature control."""
|
||||||
compatible = CP.brand == 'ford' and CP.flags & FordFlags.CANFD
|
compatible = CP.brand == 'ford' and CP.flags & FordFlags.CANFD
|
||||||
if enabled and compatible:
|
if enabled and compatible:
|
||||||
return FordModelActionController(proportional_gain=C1_PROPORTIONAL_GAIN, integral_gain=C1_INTEGRAL_GAIN)
|
return FordModelActionController(proportional_gain=C1_PROPORTIONAL_GAIN, integral_gain=C1_INTEGRAL_GAIN, c0_time_based=c0_time_based)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""C0 distance mapping and live setting changes, without vehicle hardware."""
|
||||||
|
import ast
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from opendbc.can import CANPacker, CANParser
|
||||||
|
from opendbc.car.ford.fordcan import CanBus, create_lat_ctl2_msg
|
||||||
|
from openpilot.common.params import Params, ParamKeyFlag, ParamKeyType
|
||||||
|
from openpilot.selfdrive.controls.lib.ford_model_action import FordModelActionController, ModelActionController, encode_model_action
|
||||||
|
from openpilot.selfdrive.controls.lib.ford_path import FordPath
|
||||||
|
from openpilot.selfdrive.controls.tests.test_ford_model_action import straight
|
||||||
|
from openpilot.selfdrive.controls.tests.test_ford_model_action_adapter import _method, update
|
||||||
|
from openpilot.selfdrive.controls.tests.test_ford_model_action_selection import startup
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('speed', [.3, 3., 7., 8.94, 13.41, 26.82, 55.])
|
||||||
|
@pytest.mark.parametrize('curvature', [-.03, -1e-9, 0., 1e-9, .03])
|
||||||
|
def test_arc_distance_changes_only_c0_above_seven_meters_per_second(speed, curvature):
|
||||||
|
fixed = encode_model_action(straight(), curvature, speed)
|
||||||
|
timed = encode_model_action(straight(), curvature, speed, c0_time_based=True)
|
||||||
|
distance = max(7., speed)
|
||||||
|
# Independent small-angle expansion avoids cancellation at nearly zero k.
|
||||||
|
expected = (.5*curvature*distance**2 if abs(curvature) < 1e-6 else (1-math.cos(curvature*distance))/curvature)
|
||||||
|
assert timed.path_offset == pytest.approx(expected)
|
||||||
|
assert timed.path_angle == fixed.path_angle
|
||||||
|
assert timed.curvature == timed.curvature_rate == 0.
|
||||||
|
if speed <= 7.:
|
||||||
|
assert timed == fixed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('enabled', [False, True])
|
||||||
|
def test_reversal_and_zero_request_remain_immediate_on_the_wire(enabled):
|
||||||
|
core = ModelActionController(c0_time_based=enabled)
|
||||||
|
packer = CANPacker('ford_lincoln_base_pt')
|
||||||
|
parser = CANParser('ford_lincoln_base_pt', [('LateralMotionControl2', 100)], 0)
|
||||||
|
bus = CanBus(fingerprint={0: {}})
|
||||||
|
for i, k in enumerate([.01]*100+[-.01, 0.]):
|
||||||
|
command = core.update(straight(), k, current_curvature=k, speed=20., dt=.01)
|
||||||
|
packet = create_lat_ctl2_msg(packer, bus, 2, -command.path_offset, -command.path_angle, 0., 0., i % 16)
|
||||||
|
parser.update([i*10_000_000, [packet]])
|
||||||
|
wire = parser.vl['LateralMotionControl2']
|
||||||
|
assert wire['LatCtlPathOffst_L_Actl'] == pytest.approx(-command.path_offset)
|
||||||
|
assert wire['LatCtlPath_An_Actl'] == pytest.approx(-command.path_angle)
|
||||||
|
assert wire['LatCtlCurv_No_Actl'] == wire['LatCtlCrv_NoRate2_Actl'] == 0.
|
||||||
|
assert command.path_offset*k >= 0. and command.path_angle*k >= 0.
|
||||||
|
if k == 0.:
|
||||||
|
assert command == FordPath(True, 0., 0., 0., 0.)
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_feedback_produces_identical_c1_and_integral_in_both_modes():
|
||||||
|
cores = [ModelActionController(c0_time_based=mode) for mode in (False, True)]
|
||||||
|
model = straight()
|
||||||
|
for i in range(2000):
|
||||||
|
k = .03*math.sin(i*.03)
|
||||||
|
kwargs = {'current_curvature': .02*math.sin(i*.03-.5), 'speed': 20., 'dt': .01,
|
||||||
|
'feedback_enabled': i % 77 != 0, 'pscm_limited': i % 3 == 0}
|
||||||
|
outputs = [core.update(model, k, **kwargs) for core in cores]
|
||||||
|
assert outputs[0].path_angle == outputs[1].path_angle
|
||||||
|
assert cores[0].correction == cores[1].correction
|
||||||
|
assert cores[0].proportional == cores[1].proportional
|
||||||
|
for out in outputs:
|
||||||
|
assert abs(out.path_offset) <= 5.110001 and abs(out.path_angle) <= .500001
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('initial', [False, True])
|
||||||
|
def test_change_resets_feedback_and_timestamps_but_a_noop_does_not(initial):
|
||||||
|
controller = FordModelActionController(c0_time_based=initial)
|
||||||
|
for i in range(20):
|
||||||
|
update(controller, 1.+i*.01, current_curvature=0.)
|
||||||
|
assert controller.core.correction > 0.
|
||||||
|
before = controller.diagnostics.copy()
|
||||||
|
assert not controller.set_c0_time_based(not initial, lateral_engaged=True)
|
||||||
|
assert controller.diagnostics == before and controller.core.c0_time_based == initial
|
||||||
|
assert not controller.set_c0_time_based(initial, lateral_engaged=False)
|
||||||
|
assert controller.diagnostics == before
|
||||||
|
assert controller.set_c0_time_based(not initial, lateral_engaged=False)
|
||||||
|
assert controller.core.correction == controller.core.proportional == controller.core.c0 == controller.core.c1 == 0.
|
||||||
|
assert controller.last_time is controller.last_measurement_time is controller.last_model_time is None
|
||||||
|
assert controller.diagnostics['status'] == 'c0_distance_changed'
|
||||||
|
assert controller.diagnostics['c0_time_based'] == (not initial)
|
||||||
|
assert update(controller, 10.) == update(FordModelActionController(c0_time_based=not initial), 10.)
|
||||||
|
assert controller.diagnostics['offset_distance'] == (7. if initial else 20.)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def runtime(tmp_path):
|
||||||
|
params = Params(str(tmp_path))
|
||||||
|
params.put_bool('FordModelActionController', True, block=True)
|
||||||
|
controls = startup(params=params)
|
||||||
|
controls.CP.lateralTuning = SimpleNamespace(which=lambda: 'angle')
|
||||||
|
controls._param_update_time = 0.
|
||||||
|
controls.blinker_pause_lateral = SimpleNamespace(get_params=lambda: None)
|
||||||
|
clock = SimpleNamespace(now=4., monotonic=lambda: clock.now)
|
||||||
|
events = []
|
||||||
|
filename = Path(__file__).resolve().parents[3]/'sunnypilot/selfdrive/controls/controlsd_ext.py'
|
||||||
|
method = _method(filename, 'ControlsExt', 'get_params_sp')
|
||||||
|
env = {'time': clock, 'PARAMS_UPDATE_PERIOD': 3., 'messaging': SimpleNamespace(SubMaster=object),
|
||||||
|
'cloudlog': SimpleNamespace(event=lambda *args, **kwargs: events.append((args, kwargs)))}
|
||||||
|
exec(compile(ast.Module(body=[method], type_ignores=[]), str(filename), 'exec'), env)
|
||||||
|
controls.refresh = lambda sm: env['get_params_sp'](controls, sm)
|
||||||
|
return controls, params, clock, events
|
||||||
|
|
||||||
|
|
||||||
|
class EngagementMessages(dict):
|
||||||
|
healthy = True
|
||||||
|
|
||||||
|
def all_checks(self, services):
|
||||||
|
return self.healthy and all(service in self for service in services)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('mads_available', [False, True])
|
||||||
|
def test_running_process_defers_changes_until_disengaged_and_honors_poll_period(runtime, mads_available):
|
||||||
|
controls, params, clock, events = runtime
|
||||||
|
mads = SimpleNamespace(available=mads_available, enabled=True, active=False) # includes a paused MADS state
|
||||||
|
standard = SimpleNamespace(enabled=True, active=False)
|
||||||
|
sm = EngagementMessages(selfdriveStateSP=SimpleNamespace(mads=mads), selfdriveState=standard)
|
||||||
|
params.put_bool('FordC0TimeBased', True, block=True)
|
||||||
|
controller = controls.ford_path_controller
|
||||||
|
update(controller, current_curvature=0.)
|
||||||
|
controls.refresh(sm)
|
||||||
|
assert not controller.core.c0_time_based
|
||||||
|
mads.enabled = standard.enabled = False
|
||||||
|
clock.now = 5.
|
||||||
|
controls.refresh(sm)
|
||||||
|
assert not controller.core.c0_time_based # next scheduled refresh has not run yet
|
||||||
|
clock.now = 7.01
|
||||||
|
controls.refresh(sm)
|
||||||
|
assert controller.core.c0_time_based and controller.last_time is None
|
||||||
|
assert controls.ford_path_controller is controller # same controlsd/controller instance
|
||||||
|
assert len(events) == 1
|
||||||
|
params.put_bool('FordC0TimeBased', False, block=True)
|
||||||
|
sm.healthy = False
|
||||||
|
clock.now += 3.01
|
||||||
|
controls.refresh(sm)
|
||||||
|
assert controller.core.c0_time_based # stale engagement data cannot permit a swap
|
||||||
|
sm.healthy = True
|
||||||
|
clock.now += 3.01
|
||||||
|
controls.refresh(sm)
|
||||||
|
assert not controller.core.c0_time_based and len(events) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_setting_is_persistent_default_off_and_cannot_enable_custom_control(tmp_path):
|
||||||
|
params = Params(str(tmp_path))
|
||||||
|
assert params.get_default_value('FordC0TimeBased') is False
|
||||||
|
assert params.get_type('FordC0TimeBased') == ParamKeyType.BOOL
|
||||||
|
for flag in (ParamKeyFlag.PERSISTENT, ParamKeyFlag.BACKUP):
|
||||||
|
assert b'FordC0TimeBased' in params.all_keys(flag)
|
||||||
|
params.put_bool('FordC0TimeBased', True, block=True)
|
||||||
|
assert startup(params=params).ford_path_controller is None
|
||||||
|
params.put_bool('FordModelActionController', True, block=True)
|
||||||
|
assert startup(params=params).ford_path_controller.core.c0_time_based
|
||||||
|
params.clear_all(ParamKeyFlag.CLEAR_ON_MANAGER_START)
|
||||||
|
assert Params(str(tmp_path)).get_bool('FordC0TimeBased')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('speed', [3., 10., 20., 35., 55.])
|
||||||
|
def test_timed_mode_retains_bounds_at_extreme_and_nonfinite_requests(speed):
|
||||||
|
core = ModelActionController(c0_time_based=True)
|
||||||
|
for curvature in np.linspace(-1., 1., 101):
|
||||||
|
out = core.update(straight(), curvature, current_curvature=0., speed=speed, dt=.01)
|
||||||
|
assert out.valid and abs(out.path_offset) <= 5.110001 and abs(out.path_angle) <= .500001
|
||||||
|
for invalid in (math.nan, math.inf, -math.inf):
|
||||||
|
assert core.update(straight(), invalid, current_curvature=0., speed=speed, dt=.01) == FordPath()
|
||||||
@@ -53,7 +53,7 @@ class TestFordControlsLogging(unittest.TestCase):
|
|||||||
controls = SimpleNamespace(ford_path_controller=controller, desired_curvature=.03, curvature=.015,
|
controls = SimpleNamespace(ford_path_controller=controller, desired_curvature=.03, curvature=.015,
|
||||||
sm=SimpleNamespace(logMonoTime={'modelV2': 123456789, 'carState': 123450000}))
|
sm=SimpleNamespace(logMonoTime={'modelV2': 123456789, 'carState': 123450000}))
|
||||||
record = self.emit_controls_event('Ford C2-free path tracking', controls)
|
record = self.emit_controls_event('Ford C2-free path tracking', controls)
|
||||||
self.assertEqual(record['hypothesis'], 'model-action-curvature-c0-direct-pi-v11')
|
self.assertEqual(record['hypothesis'], 'model-action-curvature-c0-distance-pi-v12')
|
||||||
self.assertIs(record['calibration_approved'], False)
|
self.assertIs(record['calibration_approved'], False)
|
||||||
self.assertEqual(record['command'][2:], [0., 0.])
|
self.assertEqual(record['command'][2:], [0., 0.])
|
||||||
self.assertEqual(record['status'], controller.diagnostics['status'])
|
self.assertEqual(record['status'], controller.diagnostics['status'])
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ def test_continuous_pi_reversal_through_selected_limited_request_and_actual_can(
|
|||||||
assert wire['LatCtlPath_No_Cs'] == calculate_lat_ctl2_checksum(2, frame % 16, packet[1])
|
assert wire['LatCtlPath_No_Cs'] == calculate_lat_ctl2_checksum(2, frame % 16, packet[1])
|
||||||
if frame == 199:
|
if frame == 199:
|
||||||
assert sign*core.correction < 0. if same_turn else sign*core.correction > 0.
|
assert sign*core.correction < 0. if same_turn else sign*core.correction > 0.
|
||||||
assert controls.ford_path_controller.diagnostics['hypothesis'] == 'model-action-curvature-c0-direct-pi-v11'
|
assert controls.ford_path_controller.diagnostics['hypothesis'] == 'model-action-curvature-c0-distance-pi-v12'
|
||||||
if same_turn:
|
if same_turn:
|
||||||
assert controls.desired_curvature == pytest.approx(sign*.01)
|
assert controls.desired_curvature == pytest.approx(sign*.01)
|
||||||
assert sign*controls.ford_path.path_angle >= speed*.01 # No old unwind correction left below the new base.
|
assert sign*controls.ford_path.path_angle >= speed*.01 # No old unwind correction left below the new base.
|
||||||
@@ -473,7 +473,7 @@ def test_toggle_off_preserves_upstream_actuators_and_can(pipeline, fingerprint,
|
|||||||
call, publication = pipeline
|
call, publication = pipeline
|
||||||
settings = {'FordModelActionController': False, 'FordPscmObserver': observer}
|
settings = {'FordModelActionController': False, 'FordPscmObserver': observer}
|
||||||
flags = CAR(fingerprint).config.flags
|
flags = CAR(fingerprint).config.flags
|
||||||
controls = startup(car_params(carFingerprint=fingerprint, flags=flags), SimpleNamespace(get_bool=settings.__getitem__))
|
controls = startup(car_params(carFingerprint=fingerprint, flags=flags), SimpleNamespace(get_bool=lambda key: settings.get(key, False)))
|
||||||
assert controls.ford_path_controller is None
|
assert controls.ford_path_controller is None
|
||||||
sm = Subscriptions(False)
|
sm = Subscriptions(False)
|
||||||
controls.sm, controls.desired_curvature, controls.curvature = sm, .004, 0.
|
controls.sm, controls.desired_curvature, controls.curvature = sm, .004, 0.
|
||||||
|
|||||||
@@ -43,12 +43,12 @@ def startup(cp=None, params=None):
|
|||||||
@pytest.mark.parametrize('fingerprint', [*CANFD_CARS, 'FORD_FUTURE_CANFD'])
|
@pytest.mark.parametrize('fingerprint', [*CANFD_CARS, 'FORD_FUTURE_CANFD'])
|
||||||
def test_actual_startup_priority(candidate, observer, fingerprint):
|
def test_actual_startup_priority(candidate, observer, fingerprint):
|
||||||
settings = {'FordModelActionController': candidate, 'FordPscmObserver': observer}
|
settings = {'FordModelActionController': candidate, 'FordPscmObserver': observer}
|
||||||
selected = startup(car_params(carFingerprint=fingerprint), params=SimpleNamespace(get_bool=settings.__getitem__))
|
selected = startup(car_params(carFingerprint=fingerprint), params=SimpleNamespace(get_bool=lambda key: settings.get(key, False)))
|
||||||
if candidate:
|
if candidate:
|
||||||
assert type(selected.ford_path_controller) is FordModelActionController
|
assert type(selected.ford_path_controller) is FordModelActionController
|
||||||
assert selected.ford_path_controller.core.proportional_gain == C1_PROPORTIONAL_GAIN == .50
|
assert selected.ford_path_controller.core.proportional_gain == C1_PROPORTIONAL_GAIN == .50
|
||||||
assert selected.ford_path_controller.core.integral_gain == C1_INTEGRAL_GAIN == .25
|
assert selected.ford_path_controller.core.integral_gain == C1_INTEGRAL_GAIN == .25
|
||||||
assert selected.ford_path_controller.diagnostics['hypothesis'] == 'model-action-curvature-c0-direct-pi-v11'
|
assert selected.ford_path_controller.diagnostics['hypothesis'] == 'model-action-curvature-c0-distance-pi-v12'
|
||||||
else:
|
else:
|
||||||
assert selected.ford_path_controller is None
|
assert selected.ford_path_controller is None
|
||||||
assert selected.ford_model_action == candidate
|
assert selected.ford_model_action == candidate
|
||||||
@@ -59,7 +59,7 @@ def test_actual_startup_priority(candidate, observer, fingerprint):
|
|||||||
@pytest.mark.parametrize('observer', [False, True])
|
@pytest.mark.parametrize('observer', [False, True])
|
||||||
def test_other_vehicles_always_use_upstream(overrides, observer):
|
def test_other_vehicles_always_use_upstream(overrides, observer):
|
||||||
settings = {'FordModelActionController': False, 'FordPscmObserver': observer}
|
settings = {'FordModelActionController': False, 'FordPscmObserver': observer}
|
||||||
params = SimpleNamespace(get_bool=settings.__getitem__)
|
params = SimpleNamespace(get_bool=lambda key: settings.get(key, False))
|
||||||
before = startup(car_params(**overrides), params)
|
before = startup(car_params(**overrides), params)
|
||||||
settings['FordModelActionController'] = True
|
settings['FordModelActionController'] = True
|
||||||
after = startup(car_params(**overrides), params)
|
after = startup(car_params(**overrides), params)
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ class SettingsBigButton(BigButton):
|
|||||||
|
|
||||||
|
|
||||||
class SettingsLayout(NavScroller):
|
class SettingsLayout(NavScroller):
|
||||||
|
toggles_layout = TogglesLayoutMici
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._params = Params()
|
self._params = Params()
|
||||||
|
|
||||||
toggles_panel = TogglesLayoutMici()
|
toggles_panel = self.toggles_layout()
|
||||||
toggles_btn = SettingsBigButton("toggles", "", gui_app.texture("icons_mici/settings.png", 64, 64))
|
toggles_btn = SettingsBigButton("toggles", "", gui_app.texture("icons_mici/settings.png", 64, 64))
|
||||||
toggles_btn.set_click_callback(lambda: gui_app.push_widget(toggles_panel))
|
toggles_btn.set_click_callback(lambda: gui_app.push_widget(toggles_panel))
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from openpilot.selfdrive.ui.mici.widgets.button import BigCircleButton
|
|||||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog
|
from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog
|
||||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnylink import SunnylinkLayoutMici
|
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnylink import SunnylinkLayoutMici
|
||||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.models import ModelsLayoutMici
|
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.models import ModelsLayoutMici
|
||||||
|
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.toggles import TogglesLayoutMiciSP
|
||||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||||
from openpilot.system.ui.lib.multilang import tr
|
from openpilot.system.ui.lib.multilang import tr
|
||||||
@@ -30,6 +31,8 @@ class SunnylinkBigButton(SettingsBigButton):
|
|||||||
|
|
||||||
|
|
||||||
class SettingsLayoutSP(OP.SettingsLayout):
|
class SettingsLayoutSP(OP.SettingsLayout):
|
||||||
|
toggles_layout = TogglesLayoutMiciSP
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
OP.SettingsLayout.__init__(self)
|
OP.SettingsLayout.__init__(self)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from opendbc.car.ford.values import FordFlags
|
||||||
|
|
||||||
|
from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici
|
||||||
|
from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, GreyBigButton
|
||||||
|
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||||
|
from openpilot.system.ui.lib.multilang import tr
|
||||||
|
|
||||||
|
|
||||||
|
class TogglesLayoutMiciSP(TogglesLayoutMici):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._ford_c0_toggle = BigParamControl(tr('C0: 1 second'), 'FordC0TimeBased')
|
||||||
|
self._ford_c0_help = GreyBigButton('', tr('off: fixed 7 m\non: 1 second, min 7 m\ndisengage 3 s to apply\nno ignition cycle'))
|
||||||
|
self._ford_c0_toggle.set_enabled(lambda: not ui_state.engaged)
|
||||||
|
self._scroller.add_widgets([self._ford_c0_toggle, self._ford_c0_help])
|
||||||
|
self._refresh_toggles += (('FordC0TimeBased', self._ford_c0_toggle),)
|
||||||
|
self._ford_c0_toggle.set_visible(False)
|
||||||
|
self._ford_c0_help.set_visible(False)
|
||||||
|
|
||||||
|
def _update_toggles(self):
|
||||||
|
super()._update_toggles()
|
||||||
|
cp = ui_state.CP
|
||||||
|
visible = bool(cp is not None and cp.brand == 'ford' and cp.flags & FordFlags.CANFD
|
||||||
|
and ui_state.params.get_bool('FordModelActionController'))
|
||||||
|
self._ford_c0_toggle.set_visible(visible)
|
||||||
|
self._ford_c0_help.set_visible(visible)
|
||||||
@@ -51,6 +51,14 @@ class ControlsExt(ModelStateBase):
|
|||||||
if time.monotonic() - self._param_update_time > PARAMS_UPDATE_PERIOD:
|
if time.monotonic() - self._param_update_time > PARAMS_UPDATE_PERIOD:
|
||||||
self.blinker_pause_lateral.get_params()
|
self.blinker_pause_lateral.get_params()
|
||||||
|
|
||||||
|
if getattr(self, 'ford_model_action', False) and sm.all_checks(['selfdriveState', 'selfdriveStateSP']):
|
||||||
|
mads = sm['selfdriveStateSP'].mads
|
||||||
|
# Use the engagement state, not a temporary pause from blinkers or a
|
||||||
|
# standstill/fault gate, so a pause cannot swap the command mapping.
|
||||||
|
lateral_engaged = mads.enabled if mads.available else sm['selfdriveState'].enabled
|
||||||
|
if self.ford_path_controller.set_c0_time_based(self.params.get_bool('FordC0TimeBased'), lateral_engaged=lateral_engaged):
|
||||||
|
cloudlog.event('Ford C0 distance changed', c0_time_based=self.ford_path_controller.core.c0_time_based)
|
||||||
|
|
||||||
if self.CP.lateralTuning.which() == 'torque':
|
if self.CP.lateralTuning.which() == 'torque':
|
||||||
self.lat_delay = get_lat_delay(self.params, sm["lateralDelay"].lateralDelay)
|
self.lat_delay = get_lat_delay(self.params, sm["lateralDelay"].lateralDelay)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user