Files
StarPilot/tools/tuning/analyze_bolt_lateral.py
T
2026-04-08 18:28:20 -05:00

239 lines
9.3 KiB
Python

#!/usr/bin/env python3
import argparse
import math
from dataclasses import dataclass
import numpy as np
from openpilot.tools.lib.logreader import LogReader, ReadMode
from openpilot.selfdrive.locationd.torqued import TorqueEstimator
from opendbc.car.gm.interface import NON_LINEAR_TORQUE_PARAMS
@dataclass
class ControlSample:
v_ego: float
steering_pressed: bool
lat_active: bool
saturated: bool
roll_rad: float
actual_la: float
desired_la: float
desired_jerk: float
p_term: float
i_term: float
f_term: float
torque_cmd: float
LOW_ROLL_THRESHOLD_DEG = 1.5
def siglin_torque(lat_accel: float, params: dict[str, list[float]]) -> float:
side = "left" if lat_accel >= 0.0 else "right"
a, b, c, d = params[side]
sig_input = a * lat_accel
sig = math.copysign((1.0 / (1.0 + math.exp(-abs(sig_input))) - 0.5), sig_input)
return (sig * b) + (lat_accel * c) + d
def summarize_control_samples(samples: list[ControlSample]) -> None:
if not samples:
print("No lateral torque samples found.")
return
v = np.array([s.v_ego for s in samples])
steering_pressed = np.array([s.steering_pressed for s in samples], dtype=bool)
lat_active = np.array([s.lat_active for s in samples], dtype=bool)
saturated = np.array([s.saturated for s in samples], dtype=bool)
roll = np.array([s.roll_rad for s in samples])
actual = np.array([s.actual_la for s in samples])
desired = np.array([s.desired_la for s in samples])
jerk = np.array([s.desired_jerk for s in samples])
p_term = np.array([s.p_term for s in samples])
i_term = np.array([s.i_term for s in samples])
f_term = np.array([s.f_term for s in samples])
torque_cmd = np.array([s.torque_cmd for s in samples])
roll_valid = np.isfinite(roll)
low_roll = roll_valid & (np.abs(roll) <= math.radians(LOW_ROLL_THRESHOLD_DEG))
high_roll = roll_valid & (~low_roll)
base = lat_active & (~steering_pressed) & (v > 8.0)
transition_base = lat_active & (~steering_pressed) & (v > 4.0) & (~saturated)
if np.any(roll_valid):
print("\nRoll context:")
print(
f" valid={int(roll_valid.sum()):5d}/{len(samples):5d} "
f"abs_mean_deg={np.mean(np.degrees(np.abs(roll[roll_valid]))):.3f} "
f"abs_p95_deg={np.percentile(np.degrees(np.abs(roll[roll_valid])), 95):.3f} "
f"low_roll_deg<={LOW_ROLL_THRESHOLD_DEG:.1f}"
)
masks = (
("all", base),
("all_non_sat", base & (~saturated)),
("left", base & (~saturated) & (desired >= 0.1)),
("right", base & (~saturated) & (desired <= -0.1)),
("center", base & (~saturated) & (np.abs(desired) < 0.1)),
("steady_left", base & (~saturated) & (desired >= 0.1) & (np.abs(jerk) < 0.2)),
("steady_right", base & (~saturated) & (desired <= -0.1) & (np.abs(jerk) < 0.2)),
("steady_left_low_roll", base & (~saturated) & low_roll & (desired >= 0.1) & (np.abs(jerk) < 0.2)),
("steady_right_low_roll", base & (~saturated) & low_roll & (desired <= -0.1) & (np.abs(jerk) < 0.2)),
("center_low_roll", base & (~saturated) & low_roll & (np.abs(desired) < 0.1)),
("steady_left_high_roll", base & (~saturated) & high_roll & (desired >= 0.1) & (np.abs(jerk) < 0.2)),
("steady_right_high_roll", base & (~saturated) & high_roll & (desired <= -0.1) & (np.abs(jerk) < 0.2)),
("low_speed_sharp", transition_base & (v < 14.0) & (np.abs(desired) >= 0.4) & (np.abs(jerk) >= 0.35)),
("turn_in", transition_base & (v < 14.0) & (np.abs(desired) >= 0.4) & (np.abs(jerk) >= 0.35) & ((desired * jerk) > 0.0)),
("unwind", transition_base & (v < 14.0) & (np.abs(desired) >= 0.4) & (np.abs(jerk) >= 0.35) & ((desired * jerk) < 0.0)),
)
print("\nControlsState tracking:")
for name, mask in masks:
if not np.any(mask):
continue
print(
f" {name:12s} n={int(mask.sum()):5d} "
f"mae={np.mean(np.abs(desired[mask] - actual[mask])):.4f} "
f"bias={np.mean(actual[mask] - desired[mask]):+.4f} "
f"|p|={np.mean(np.abs(p_term[mask])):.4f} "
f"|i|={np.mean(np.abs(i_term[mask])):.4f} "
f"|f|={np.mean(np.abs(f_term[mask])):.4f} "
f"torque={np.mean(torque_cmd[mask]):+.4f}"
)
def summarize_torque_points(car_fingerprint: str, points: np.ndarray) -> None:
if points.size == 0:
print("No torque-estimator points found.")
return
params = NON_LINEAR_TORQUE_PARAMS.get(car_fingerprint)
if params is None:
print(f"No siglin torque params configured for {car_fingerprint}.")
return
steer = points[:, 0]
lat = points[:, 1]
pred = np.array([siglin_torque(x, params) for x in lat])
err = pred - steer
print("\nTorque map residuals:")
print(f" all n={points.shape[0]:5d} mae={np.mean(np.abs(err)):.4f} bias={np.mean(err):+.4f}")
for name, mask in (("left", lat >= 0.0), ("right", lat < 0.0), ("small", np.abs(lat) < 0.3), ("mid", (np.abs(lat) >= 0.3) & (np.abs(lat) < 0.8))):
if np.any(mask):
print(f" {name:12s} n={int(mask.sum()):5d} mae={np.mean(np.abs(err[mask])):.4f} bias={np.mean(err[mask]):+.4f}")
print("\nLinearized correction against current siglin:")
for name, mask in (("all", np.ones_like(lat, dtype=bool)), ("left", lat >= 0.0), ("right", lat < 0.0)):
x = np.column_stack([pred[mask], np.ones(mask.sum())])
y = steer[mask]
scale, offset = np.linalg.lstsq(x, y, rcond=None)[0]
fit = scale * pred[mask] + offset
print(
f" {name:12s} scale={scale:.4f} offset={offset:+.4f} "
f"mae_fit={np.mean(np.abs(fit - y)):.4f}"
)
def main() -> None:
parser = argparse.ArgumentParser(description="Analyze a Bolt route for lateral tuning opportunities.")
parser.add_argument("route", help="Route name, e.g. dongle/route")
parser.add_argument("--mode", choices=("auto", "qlog", "rlog"), default="auto")
args = parser.parse_args()
mode_map = {
"auto": ReadMode.AUTO,
"qlog": ReadMode.QLOG,
"rlog": ReadMode.RLOG,
}
log_reader = LogReader(args.route, default_mode=mode_map[args.mode], sort_by_time=True)
car_params = None
live_torque_snapshots = []
torque_estimator = None
latest = {}
control_samples: list[ControlSample] = []
for msg in log_reader:
which = msg.which()
if which == "carParams" and car_params is None:
car_params = msg.carParams
torque_estimator = TorqueEstimator(car_params, track_all_points=True)
continue
if car_params is None:
continue
if which in ("carState", "carControl"):
latest[which] = getattr(msg, which)
elif which == "liveParameters":
latest[which] = msg.liveParameters
elif which == "controlsState" and "carState" in latest and "carControl" in latest:
lateral_state = msg.controlsState.lateralControlState
if lateral_state.which() == "torqueState":
torque_state = lateral_state.torqueState
control_samples.append(ControlSample(
v_ego=latest["carState"].vEgo,
steering_pressed=latest["carState"].steeringPressed,
lat_active=latest["carControl"].latActive,
saturated=torque_state.saturated,
roll_rad=float(getattr(latest.get("liveParameters"), "roll", float("nan"))),
actual_la=torque_state.actualLateralAccel,
desired_la=torque_state.desiredLateralAccel,
desired_jerk=torque_state.desiredLateralJerk,
p_term=torque_state.p,
i_term=torque_state.i,
f_term=torque_state.f,
torque_cmd=latest["carControl"].actuators.torque,
))
elif which == "liveTorqueParameters" and len(live_torque_snapshots) < 8:
live_torque_snapshots.append(msg.liveTorqueParameters)
if torque_estimator is not None and which in ("carControl", "carOutput", "carState", "liveCalibration", "livePose", "liveDelay"):
torque_estimator.handle_log(msg.logMonoTime / 1e9, which, getattr(msg, which))
if car_params is None:
raise RuntimeError("No carParams found in route.")
torque_tune = car_params.lateralTuning.torque
print(f"carFingerprint={car_params.carFingerprint}")
print(f"steerRatio={car_params.steerRatio:.4f} steerActuatorDelay={car_params.steerActuatorDelay:.4f}")
print(
"torqueTune="
f"latAccelFactor={torque_tune.latAccelFactor:.4f} "
f"friction={torque_tune.friction:.4f} "
f"latAccelOffset={torque_tune.latAccelOffset:.4f} "
f"kp={getattr(torque_tune, 'kpDEPRECATED', 0.0):.4f} "
f"ki={getattr(torque_tune, 'kiDEPRECATED', 0.0):.4f} "
f"kd={getattr(torque_tune, 'kdDEPRECATED', 0.0):.4f} "
f"kf={getattr(torque_tune, 'kfDEPRECATED', 0.0):.4f}"
)
if live_torque_snapshots:
last = live_torque_snapshots[-1]
print(
"liveTorqueFiltered="
f"latAccelFactor={last.latAccelFactorFiltered:.4f} "
f"latAccelOffset={last.latAccelOffsetFiltered:.4f} "
f"friction={last.frictionCoefficientFiltered:.4f} "
f"useParams={last.useParams} liveValid={last.liveValid}"
)
summarize_control_samples(control_samples)
points = np.array(torque_estimator.all_torque_points) if torque_estimator is not None else np.empty((0, 2))
if torque_estimator is not None and torque_estimator.filtered_points.is_calculable():
slope, offset, friction = torque_estimator.estimate_params()
print(
"\nTorqueEstimator fit:"
f" latAccelFactor={slope:.4f}"
f" latAccelOffset={offset:.4f}"
f" friction={friction:.4f}"
f" bucket_points={len(torque_estimator.filtered_points)}"
)
summarize_torque_points(car_params.carFingerprint, points)
if __name__ == "__main__":
main()