Files
IQ.Pilot/selfdrive/controls/lib/latcontrol_torque.py
T
2026-07-19 18:18:20 -05:00

620 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import math
import os
import tomllib
from collections import deque
from difflib import SequenceMatcher
import numpy as np
from cereal import log, custom # noqa: F401 (custom kept available for downstream imports)
from opendbc.car import structs
from opendbc.car.lateral import FRICTION_THRESHOLD, get_friction
from opendbc.iqpilot.car.interfaces import LatControlInputs
from opendbc.iqpilot.car.lateral_ext import get_friction as get_friction_in_torque_space
from openpilot.common.basedir import BASEDIR
from openpilot.common.constants import ACCELERATION_DUE_TO_GRAVITY
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.params import Params
from openpilot.common.pid import PIDController
from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N
from openpilot.selfdrive.controls.lib.latcontrol import LatControl
from openpilot.selfdrive.modeld.constants import ModelConstants
from openpilot.selfdrive.modeld.parse_model_outputs import safe_exp
from openpilot.iqpilot.selfdrive.controls.lib.helpers.nav_torque_pulse import NavTorquePulseBrain
# ===== locator =====
TORQUE_NN_MODEL_PATH = os.path.join(BASEDIR, "iqpilot", "iqpilot_iq_nnff_models", "neural_network_lateral_control")
TORQUE_NN_MODEL_SUBSTITUTE_PATH = os.path.join(BASEDIR, "opendbc", "car", "torque_data", "substitute.toml")
MOCK_MODEL_PATH = os.path.join(TORQUE_NN_MODEL_PATH, "MOCK.json")
# A candidate must reach this score for the fingerprint(+fw) match to count as exact.
_EXACT_THRESHOLD = 0.99
# Below this, we fall back to the next candidate ladder rung.
_ACCEPT_THRESHOLD = 0.9
def _score(a: str, b: str) -> float:
return SequenceMatcher(None, a, b).ratio()
def _best_model_for(candidate: str) -> tuple[str | None, float]:
"""Highest-scoring model file for a candidate string; (path, score)."""
best_path, best_score = None, -1.0
for entry in os.listdir(TORQUE_NN_MODEL_PATH):
if not entry.endswith(".json"):
continue
score = _score(os.path.splitext(entry)[0], candidate)
if score > best_score:
best_path, best_score = os.path.join(TORQUE_NN_MODEL_PATH, entry), score
return best_path, best_score
def _substitute_for(fingerprint: str) -> str:
with open(TORQUE_NN_MODEL_SUBSTITUTE_PATH, 'rb') as f:
table = tomllib.load(f)
return table.get(fingerprint, fingerprint)
def _eps_suffix(CP: structs.CarParams) -> str:
eps_fw = str(next((fw.fwVersion for fw in CP.carFw if fw.ecu == "eps"), ""))
return eps_fw.replace("\\", "") if len(eps_fw) > 3 else ""
def get_nn_model_path(CP: structs.CarParams) -> tuple[str, str, bool]:
"""Pick the closest NNFF model for this car.
Angle-steered cars always get MOCK. Otherwise walk a candidate ladder —
fingerprint+eps-fw, then fingerprint, then the substitute mapping — accepting
the first rung that clears the match threshold; the top rung landing at ~1.0
marks an exact (non-fuzzy) match.
"""
if CP.steerControlType == structs.CarParams.SteerControlType.angle:
return MOCK_MODEL_PATH, "MOCK", False
fingerprint = CP.carFingerprint
suffix = _eps_suffix(CP)
# rung 1: fingerprint + eps fw (when we have a usable fw string)
if suffix:
path, score = _best_model_for(f"{fingerprint} {suffix}")
if path is not None and fingerprint in path and score >= _ACCEPT_THRESHOLD:
name = os.path.splitext(os.path.basename(path))[0]
return path, name, score >= _EXACT_THRESHOLD
# rung 2: fingerprint alone
path, score = _best_model_for(fingerprint)
if path is not None and fingerprint in path and score >= _ACCEPT_THRESHOLD:
name = os.path.splitext(os.path.basename(path))[0]
return path, name, score >= _EXACT_THRESHOLD
# rung 3: substitute mapping — never exact
path, _ = _best_model_for(_substitute_for(fingerprint))
name = os.path.splitext(os.path.basename(path))[0] if path else "MOCK"
return (path or MOCK_MODEL_PATH), name, False
# ===== network =====
# The JSON model format (Twilsonco NNFF) is external: unicode 'σ' names the
# sigmoid activation, weights/biases live under keys suffixed _W/_b, and the
# input normalisation is (x - mean) / std. We translate names through a registry
# and keep the mean/std transposed once at load.
_MIN_INPUT_LEN = 2
_FRICTION_PROBE = (10.0, 0.0, 0.2)
_FRICTION_THRESHOLD = 0.1
_ACTIVATION_ALIASES = {"σ": "sigmoid"}
def _sigmoid(x):
return 1.0 / (1.0 + safe_exp(-x))
def _identity(x):
return x
_ACTIVATIONS = {"sigmoid": _sigmoid, "identity": _identity}
def _resolve_activation(name: str):
for symbol, canonical in _ACTIVATION_ALIASES.items():
name = name.replace(symbol, canonical)
fn = _ACTIVATIONS.get(name)
if fn is None:
raise ValueError(f"Unknown activation: {name}")
return fn
def _pick(layer: dict, suffix: str):
key = next(k for k in layer if k.endswith(suffix))
return np.array(layer[key], dtype=np.float32).T
class NNTorqueModel:
def __init__(self, params_file, zero_bias=False):
with open(params_file) as f:
params = json.load(f)
self.input_size = params["input_size"]
self.output_size = params["output_size"]
self.input_mean = np.array(params["input_mean"], dtype=np.float32).T
self.input_std = np.array(params["input_std"], dtype=np.float32).T
self._weights = []
self._biases = []
self._activations = []
for layer in params["layers"]:
weight = _pick(layer, "_W")
bias = np.zeros_like(_pick(layer, "_b")) if zero_bias else _pick(layer, "_b")
self._weights.append(weight)
self._biases.append(bias)
self._activations.append(_resolve_activation(layer["activation"]))
self.friction_override = self.evaluate(list(_FRICTION_PROBE)) < _FRICTION_THRESHOLD
def forward(self, x):
for weight, bias, activation in zip(self._weights, self._biases, self._activations, strict=True):
x = activation(x.dot(weight) + bias)
return x
def evaluate(self, input_array):
if len(input_array) != self.input_size:
if len(input_array) < _MIN_INPUT_LEN:
raise ValueError(f"Input array length {len(input_array)} must be length 2 or greater")
input_array = input_array + [0] * (self.input_size - len(input_array))
x = (np.array(input_array, dtype=np.float32) - self.input_mean) / self.input_std
return float(self.forward(x)[0, 0])
# names kept for callers/tests that introspected the old implementation
@staticmethod
def sigmoid(x):
return _sigmoid(x)
@staticmethod
def identity(x):
return _identity(x)
# ===== brain =====
PLAN_SAMPLE_START = 5
LAG_EXTRA_S = 0.0
BASE_P = 0.8
BASE_I = 0.15
PID_SPEED_BP = [1, 1.5, 2.0, 3.0, 5, 7.5, 10, 15, 30]
PID_P_GAIN = [250, 120, 65, 30, 11.5, 5.5, 3.5, 2.0, BASE_P]
_JERK_FALLBACK_IDX = 16 # T_IDXS index used when nothing exceeds the lookahead horizon
def sign(value: float) -> float:
if value > 0.0:
return 1.0
if value < 0.0:
return -1.0
return 0.0
polarity = sign
def _pointwise_jerk(accel_trace, dt_trace) -> list:
"""Finite-difference jerk from an acceleration trace over per-step dt."""
delta = np.diff(accel_trace)
span = min(len(delta), len(dt_trace))
if span <= 0:
return []
return (delta[:span] / np.array(dt_trace)[:span]).tolist()
def sign_locked_min(future_vals, seed_val):
"""Smallest-magnitude jerk over the horizon, but only if the whole horizon
agrees in sign with the seed; a sign disagreement collapses to 0."""
if not future_vals:
return seed_val
agreeing = [v for v in future_vals if sign(v) == sign(seed_val)]
if len(agreeing) < len(future_vals):
return 0.0
return min(agreeing + [seed_val], key=abs)
class PilotLateralBrain:
"""Shared lateral-control scaffolding: PID core, model snapshot, and the
forward-looking jerk/friction estimates the feed-forward controllers build on."""
def __init__(self, torque_ctrl, cp, cp_iq, car_if):
del cp_iq
self.lac_torque = torque_ctrl
self.torque_from_lateral_accel_in_torque_space = car_if.torque_from_lateral_accel_in_torque_space()
self.model_v2 = None
self.model_valid = False
self.jerk_now = 0.0
self.jerk_goal = 0.0
self.jerk_obs = 0.0
self.jerk_ahead = 0.0
# per-cycle control snapshot
self._ff = 0.0
self._pid = PIDController([PID_SPEED_BP, PID_P_GAIN], BASE_I)
self._pid_log = None
self._accel_goal = 0.0
self._accel_obs = 0.0
self._roll_g = 0.0
self._deadband = 0.0
self._want_la = 0.0
self._have_la = 0.0
self._want_cv = 0.0
self._have_cv = 0.0
self._grav_la = 0.0
self._capped = False
self._out_tq = 0.0
# friction-lookahead tuning
self.friction_look_ahead_v = [1.4, 2.0]
self.friction_look_ahead_bp = [9.0, 30.0]
self.lat_jerk_friction_factor = 0.4
self.lat_accel_friction_factor = 0.7
self.t_diffs = np.diff(ModelConstants.T_IDXS)
self.desired_lat_jerk_time = cp.steerActuatorDelay + LAG_EXTRA_S
def update_model_v2(self, model_packet):
self.model_v2 = model_packet
self.model_valid = model_packet is not None and len(model_packet.orientation.x) >= CONTROL_N
def update_lateral_lag(self, lag):
self.desired_lat_jerk_time = max(0.01, lag) + LAG_EXTRA_S
def update_friction_input(self, target_val, measured_val):
error = target_val - measured_val
return self.lat_accel_friction_factor * error + self.lat_jerk_friction_factor * self.jerk_ahead
def _measured_jerk(self, car_state, vehicle_model) -> float:
curvature_rate = -vehicle_model.calc_curvature(math.radians(car_state.steeringRateDeg), car_state.vEgo, 0.0)
return curvature_rate * car_state.vEgo ** 2
def _horizon_index(self, speed_mps: float) -> int:
lookahead = np.interp(speed_mps, self.friction_look_ahead_bp, self.friction_look_ahead_v)
return next((i for i, t in enumerate(ModelConstants.T_IDXS) if t > lookahead), _JERK_FALLBACK_IDX)
def _reset_jerk_estimates(self, car_state, vehicle_model):
self.jerk_now = self._measured_jerk(car_state, vehicle_model)
self.jerk_goal = 0.0
self.jerk_obs = 0.0
self.jerk_ahead = 0.0
def update_calculations(self, car_state, vehicle_model, desired_lat_accel):
self._reset_jerk_estimates(car_state, vehicle_model)
if not self.model_valid:
return
accel_y = self.model_v2.acceleration.y
horizon_accel = np.interp(self.desired_lat_jerk_time, ModelConstants.T_IDXS, accel_y)
desired_jerk = (horizon_accel - desired_lat_accel) / self.desired_lat_jerk_time
forecast = _pointwise_jerk(accel_y, self.t_diffs)
window = forecast[PLAN_SAMPLE_START:self._horizon_index(car_state.vEgo)]
self.jerk_ahead = sign_locked_min(window, desired_jerk)
if self.jerk_ahead == 0.0:
self.jerk_now = 0.0
self.lat_accel_friction_factor = 1.0
self.jerk_goal = self.lat_jerk_friction_factor * self.jerk_ahead
self.jerk_obs = self.lat_jerk_friction_factor * self.jerk_now
TorqueBrainCore = PilotLateralBrain
# ===== nnff =====
LOW_SPEED_X = [0, 10, 20, 30]
LOW_SPEED_Y = [12, 3, 1, 0]
# NNFF input layout expected by the trained models (dictated by the model data):
# 4 scalars (v_ego, target, jerk, roll) + past/future target repeats + past/future rolls.
_ERROR_BLEND_BP = [1.0, 2.0]
_ERROR_BLEND_V = [0.0, 1.0]
def roll_pitch_adjust(roll, pitch):
return roll * math.cos(pitch)
class _HistoryWindow:
"""Rolling past/future sample windows the NNFF vector is assembled from."""
def __init__(self, past_times, future_times, jerk_time):
self.past_times = past_times
self.future_times = future_times
self.jerk_time = jerk_time
self.nn_future_times = [t + jerk_time for t in future_times]
check_frames = [int(abs(t) * 100) for t in past_times]
self.frame_offsets = [check_frames[0] - f for f in check_frames]
maxlen = check_frames[0]
self.roll = deque(maxlen=maxlen)
self.lat_accel_desired = deque(maxlen=maxlen)
self.past_future_len = len(past_times) + len(self.nn_future_times)
def refresh_lag(self, jerk_time):
self.jerk_time = jerk_time
self.nn_future_times = [t + jerk_time for t in self.future_times]
def push(self, roll, lat_accel_desired):
self.roll.append(roll)
self.lat_accel_desired.append(lat_accel_desired)
def _sample(self, buf):
return [buf[min(len(buf) - 1, i)] for i in self.frame_offsets]
def past_rolls(self):
return self._sample(self.roll)
def past_lat_accels(self):
return self._sample(self.lat_accel_desired)
class NeuralNetworkFeedForward(PilotLateralBrain):
def __init__(self, lac_torque, CP, CP_IQ, CI):
super().__init__(lac_torque, CP, CP_IQ, CI)
self.params = Params()
self.enabled = self.params.get_bool("NeuralNetworkFeedForward")
self.has_nn_model = CP_IQ.iqLateralNet.model.path != MOCK_MODEL_PATH
self.model = NNTorqueModel(CP_IQ.iqLateralNet.model.path)
self.pitch = FirstOrderFilter(0.0, 0.5, 0.01)
self.pitch_last = 0.0
self.future_times = [0.3, 0.6, 1.0, 1.5]
self._window = _HistoryWindow([-0.3, -0.2, -0.1], self.future_times, self.desired_lat_jerk_time)
self.nav_torque_pulse = NavTorquePulseBrain(lac_torque)
# -- back-compat views onto the history window -------------------------------
@property
def nn_future_times(self):
return self._window.nn_future_times
@property
def past_future_len(self):
return self._window.past_future_len
@property
def _nnff_enabled(self):
return self.enabled and self.model_valid and self.has_nn_model
def update_limits(self):
if not self._nnff_enabled:
return
self._pid.set_limits(self.lac_torque.steer_max, -self.lac_torque.steer_max)
def update_lateral_lag(self, lag):
super().update_lateral_lag(lag)
self._window.refresh_lag(self.desired_lat_jerk_time)
# -- torque-space feedforward (non-NN path used for error scaling) -----------
def _torque_space(self, lateral_accel, CS, gravity_adjusted):
return self.torque_from_lateral_accel_in_torque_space(
LatControlInputs(lateral_accel, self._roll_g, CS.vEgo, CS.aEgo),
self.lac_torque.torque_params, gravity_adjusted=gravity_adjusted)
def update_feedforward_torque_space(self, CS):
torque_from_setpoint = self._torque_space(self._accel_goal, CS, gravity_adjusted=False)
torque_from_measurement = self._torque_space(self._accel_obs, CS, gravity_adjusted=False)
self._pid_log.error = float(torque_from_setpoint - torque_from_measurement)
self._ff = self._torque_space(self._grav_la, CS, gravity_adjusted=True)
self._ff += get_friction_in_torque_space(self._want_la - self._have_la,
self._deadband, FRICTION_THRESHOLD,
self.lac_torque.torque_params)
def update_output_torque(self, CS):
freeze_integrator = self._capped or CS.steeringPressed or CS.vEgo < 5
self._out_tq = self._pid.update(self._pid_log.error, feedforward=self._ff,
speed=CS.vEgo, freeze_integrator=freeze_integrator)
# -- NN input assembly -------------------------------------------------------
def _effective_roll(self, params, calibrated_pose):
roll = params.roll
if calibrated_pose is not None:
pitch = self.pitch.update(calibrated_pose.orientation.pitch)
roll = roll_pitch_adjust(roll, pitch)
self.pitch_last = pitch
return roll
def _future_rolls(self, roll, adjusted_future_times):
return [roll_pitch_adjust(np.interp(t, ModelConstants.T_IDXS, self.model_v2.orientation.x) + roll,
np.interp(t, ModelConstants.T_IDXS, self.model_v2.orientation.y) + self.pitch_last)
for t in adjusted_future_times]
def _future_lat_accels(self, adjusted_future_times):
return [np.interp(t, ModelConstants.T_IDXS, self.model_v2.acceleration.y) for t in adjusted_future_times]
def _query(self, lead_scalar, jerk_scalar, tail):
"""Build one model input from its 4 leading scalars + the shared tail, then
run the interpreter. `tail` is (repeat_value_or_None, extra_pairs...)."""
head = [self._v, lead_scalar, jerk_scalar, self._roll]
return self.model.evaluate(head + tail)
def update_neural_network_feedforward(self, CS, params, calibrated_pose) -> None:
if not self._nnff_enabled:
return
self.update_feedforward_torque_space(CS)
creep = float(np.interp(CS.vEgo, LOW_SPEED_X, LOW_SPEED_Y)) ** 2
self._accel_goal = self._want_la + creep * self._want_cv
self._accel_obs = self._have_la + creep * self._have_cv
# cache per-cycle scalars the query builder reads
self._v = CS.vEgo
self._roll = self._effective_roll(params, calibrated_pose)
self._window.push(self._roll, self._want_la)
horizon = [t + 0.5 * CS.aEgo * (t / max(CS.vEgo, 1.0)) for t in self.nn_future_times]
roll_ctx = self._window.past_rolls() + self._future_rolls(self._roll, horizon)
accel_ctx = self._window.past_lat_accels() + self._future_lat_accels(horizon)
goal_torque = self._query(self._accel_goal, self.jerk_goal, [self._accel_goal] * self.past_future_len + roll_ctx)
obs_torque = self._query(self._accel_obs, self.jerk_obs, [self._accel_obs] * self.past_future_len + roll_ctx)
self._pid_log.error = goal_torque - obs_torque
self._apply_error_blend()
friction_input = self.update_friction_input(self._accel_goal, self._accel_obs)
self._ff = self._query(self._want_la, friction_input, accel_ctx + roll_ctx)
if self.model.friction_override:
self._pid_log.error += get_friction(friction_input, self._deadband,
FRICTION_THRESHOLD, self.lac_torque.torque_params)
self.update_output_torque(CS)
def _apply_error_blend(self):
blend = float(np.interp(abs(self._want_la), _ERROR_BLEND_BP, _ERROR_BLEND_V))
if blend <= 0.0:
return
# error query carries a 0.0 roll slot (not the live roll), so build it directly
from_error = self.model.evaluate([self._v, self._accel_goal - self._accel_obs,
self.jerk_goal - self.jerk_obs, 0.0])
live = self._pid_log.error
if sign(live) == sign(from_error) and abs(live) < abs(from_error):
self._pid_log.error = live * (1.0 - blend) + from_error * blend
# -- per-cycle snapshot + entry point ----------------------------------------
def _snapshot_cycle(self, feedforward_seed, pid_core, pid_trace, torque_goal, torque_actual, roll_bias,
deadzone, lat_accel_goal, lat_accel_actual, curvature_goal, curvature_actual,
gravity_lat_accel, safety_limited, torque_output) -> None:
self._ff = feedforward_seed
self._pid = pid_core
self._pid_log = pid_trace
self._accel_goal = torque_goal
self._accel_obs = torque_actual
self._roll_g = roll_bias
self._deadband = deadzone
self._want_la = lat_accel_goal
self._have_la = lat_accel_actual
self._want_cv = curvature_goal
self._have_cv = curvature_actual
self._grav_la = gravity_lat_accel
self._capped = safety_limited
self._out_tq = torque_output
def update(self, car_state, vehicle_model, pid_core, calibrator, feedforward_seed, pid_trace,
torque_goal, torque_actual, calibrated_pose, roll_bias, lat_accel_goal, lat_accel_actual,
deadzone, gravity_lat_accel, curvature_goal, curvature_actual, safety_limited, torque_output):
self._snapshot_cycle(feedforward_seed, pid_core, pid_trace, torque_goal, torque_actual, roll_bias,
deadzone, lat_accel_goal, lat_accel_actual, curvature_goal, curvature_actual,
gravity_lat_accel, safety_limited, torque_output)
self.update_calculations(car_state, vehicle_model, lat_accel_goal)
self.update_neural_network_feedforward(car_state, calibrator, calibrated_pose)
self._out_tq = self.nav_torque_pulse.nudge_output_torque(True, car_state, self._out_tq)
return self._pid_log, self._out_tq
# At higher speeds (25+mph) we can assume:
# Lateral acceleration achieved by a specific car correlates to
# torque applied to the steering rack. It does not correlate to
# wheel slip, or to speed.
# This controller applies torque to achieve desired lateral
# accelerations. To compensate for the low speed effects the
# proportional gain is increased at low speeds by the PID controller.
# Additionally, there is friction in the steering wheel that needs
# to be overcome to move it at all, this is compensated for too.
KP = 0.8
KI = 0.15
INTERP_SPEEDS = [1, 1.5, 2.0, 3.0, 5, 7.5, 10, 15, 30]
KP_INTERP = [250, 120, 65, 30, 11.5, 5.5, 3.5, 2.0, KP]
LP_FILTER_CUTOFF_HZ = 1.2
JERK_LOOKAHEAD_SECONDS = 0.19
JERK_GAIN = 0.3
LAT_ACCEL_REQUEST_BUFFER_SECONDS = 1.0
VERSION = 1
class LatControlTorque(LatControl):
def __init__(self, CP, CP_IQ, CI, dt):
super().__init__(CP, CP_IQ, CI, dt)
self.torque_params = CP.lateralTuning.torque.as_builder()
self.torque_from_lateral_accel = CI.torque_from_lateral_accel()
self.lateral_accel_from_torque = CI.lateral_accel_from_torque()
self.pid = PIDController([INTERP_SPEEDS, KP_INTERP], KI, rate=1/self.dt)
self.update_limits()
self.steering_angle_deadzone_deg = self.torque_params.steeringAngleDeadzoneDeg
self.lat_accel_request_buffer_len = int(LAT_ACCEL_REQUEST_BUFFER_SECONDS / self.dt)
self.lat_accel_request_buffer = deque([0.] * self.lat_accel_request_buffer_len , maxlen=self.lat_accel_request_buffer_len)
self.lookahead_frames = int(JERK_LOOKAHEAD_SECONDS / self.dt)
self.jerk_filter = FirstOrderFilter(0.0, 1 / (2 * np.pi * LP_FILTER_CUTOFF_HZ), self.dt)
self.nnff_assist = NeuralNetworkFeedForward(self, CP, CP_IQ, CI)
def update_live_torque_params(self, latAccelFactor, latAccelOffset, friction):
self.torque_params.latAccelFactor = latAccelFactor
self.torque_params.latAccelOffset = latAccelOffset
self.torque_params.friction = friction
self.update_limits()
def update_limits(self):
self.pid.set_limits(self.lateral_accel_from_torque(self.steer_max, self.torque_params),
self.lateral_accel_from_torque(-self.steer_max, self.torque_params))
def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, calibrated_pose, curvature_limited, lat_delay):
pid_log = log.ControlsState.LateralTorqueState.new_message()
pid_log.version = VERSION
measured_curvature = -VM.calc_curvature(math.radians(CS.steeringAngleDeg - params.angleOffsetDeg), CS.vEgo, params.roll)
measurement = measured_curvature * CS.vEgo ** 2
future_desired_lateral_accel = desired_curvature * CS.vEgo ** 2
self.lat_accel_request_buffer.append(future_desired_lateral_accel)
roll_compensation = params.roll * ACCELERATION_DUE_TO_GRAVITY
curvature_deadzone = abs(VM.calc_curvature(math.radians(self.steering_angle_deadzone_deg), CS.vEgo, 0.0))
lateral_accel_deadzone = curvature_deadzone * CS.vEgo ** 2
delay_frames = int(np.clip(lat_delay / self.dt + 1, 1, self.lat_accel_request_buffer_len))
expected_lateral_accel = self.lat_accel_request_buffer[-delay_frames]
setpoint = expected_lateral_accel
error = setpoint - measurement
lookahead_idx = int(np.clip(-delay_frames + self.lookahead_frames, -self.lat_accel_request_buffer_len+1, -2))
raw_lateral_jerk = (self.lat_accel_request_buffer[lookahead_idx+1] - self.lat_accel_request_buffer[lookahead_idx-1]) / (2 * self.dt)
desired_lateral_jerk = self.jerk_filter.update(raw_lateral_jerk)
gravity_adjusted_future_lateral_accel = future_desired_lateral_accel - roll_compensation
ff = gravity_adjusted_future_lateral_accel
# latAccelOffset corrects roll compensation bias from device roll misalignment relative to car roll
ff -= self.torque_params.latAccelOffset
ff += get_friction(error + JERK_GAIN * desired_lateral_jerk, lateral_accel_deadzone, FRICTION_THRESHOLD, self.torque_params)
if not active:
output_torque = 0.0
pid_log.active = False
else:
# do error correction in lateral acceleration space, convert at end to handle non-linear torque responses correctly
pid_log.error = float(error)
freeze_integrator = steer_limited_by_safety or CS.steeringPressed or CS.vEgo < 5
output_lataccel = self.pid.update(pid_log.error, speed=CS.vEgo, feedforward=ff, freeze_integrator=freeze_integrator)
output_torque = self.torque_from_lateral_accel(output_lataccel, self.torque_params)
# Lateral acceleration torque controller extension updates
# Overrides pid_log.error and output_torque
pid_log, output_torque = self.nnff_assist.update(CS, VM, self.pid, params, ff, pid_log, setpoint, measurement, calibrated_pose, roll_compensation,
future_desired_lateral_accel, measurement, lateral_accel_deadzone, gravity_adjusted_future_lateral_accel,
desired_curvature, measured_curvature, steer_limited_by_safety, output_torque)
pid_log.active = True
pid_log.p = float(self.pid.p)
pid_log.i = float(self.pid.i)
pid_log.d = float(self.pid.d)
pid_log.f = float(self.pid.f)
pid_log.output = float(-output_torque) # TODO: log lat accel?
pid_log.actualLateralAccel = float(measurement)
pid_log.desiredLateralAccel = float(setpoint)
pid_log.desiredLateralJerk = float(desired_lateral_jerk)
pid_log.saturated = bool(self._check_saturation(self.steer_max - abs(output_torque) < 1e-3, CS, steer_limited_by_safety, curvature_limited))
# TODO left is positive in this convention
return -output_torque, 0.0, pid_log