mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-09-05 16:33:43 +08:00
7eb7e93deb
Assisted-by: Codex
499 lines
22 KiB
Python
499 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""Offline evaluation of Ford's native four-field path polynomial.
|
|
|
|
The experiment deliberately does not alter the live controller. It rebases the
|
|
model path into the vehicle pose expected at actuation time, fits one cubic over
|
|
the remaining short path, and converts the cubic into the LMC2 C0/C1/C2/C3
|
|
signals. A first-order C2 response envelope is included to expose commands that
|
|
would look good only if the PSCM curvature channel were instantaneous.
|
|
"""
|
|
|
|
import argparse
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
import glob
|
|
import math
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
from openpilot.tools.lib.logreader import LogReader
|
|
|
|
|
|
DBC_OFFSET = (-5.12, 5.11)
|
|
DBC_ANGLE = (-0.5, 0.5235)
|
|
DBC_CURVATURE = (-0.02, 0.02)
|
|
DBC_CURVATURE_RATE = (-0.001024, 0.001023)
|
|
MAX_LATERAL_ACCEL = 3.0 + 9.81 * 0.06
|
|
MAX_LATERAL_JERK = 3.0 + 9.81 * 0.06
|
|
@dataclass(frozen=True)
|
|
class ModelPath:
|
|
x: np.ndarray
|
|
y: np.ndarray
|
|
heading: np.ndarray
|
|
distance: np.ndarray
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Sample:
|
|
route: str
|
|
time: float
|
|
speed: float
|
|
curvature: float
|
|
steering_pressed: bool
|
|
path: ModelPath
|
|
sent_c0: float
|
|
sent_c1: float
|
|
sent_c2: float
|
|
sent_c3: float
|
|
@dataclass(frozen=True)
|
|
class NativePath:
|
|
c0: float
|
|
c1: float
|
|
c2: float
|
|
c3: float
|
|
fit_rmse: float
|
|
path_rms: float
|
|
|
|
|
|
def _model_path(model) -> ModelPath | None:
|
|
try:
|
|
x = np.asarray(model.position.x, dtype=float)
|
|
y = np.asarray(model.position.y, dtype=float)
|
|
heading = np.unwrap(np.asarray(model.orientation.z, dtype=float))
|
|
except (AttributeError, TypeError, ValueError):
|
|
return None
|
|
if len(x) < 4 or len(x) != len(y) or len(x) != len(heading):
|
|
return None
|
|
if not np.isfinite(np.concatenate((x, y, heading))).all():
|
|
return None
|
|
distance = np.concatenate(([0.0], np.cumsum(np.hypot(np.diff(x), np.diff(y)))))
|
|
unique_distance, unique = np.unique(distance, return_index=True)
|
|
if len(unique_distance) < 4 or unique_distance[-1] <= 0.0:
|
|
return None
|
|
return ModelPath(x[unique], y[unique], heading[unique], unique_distance)
|
|
|
|
|
|
def _arc_pose(distance: float, curvature: float) -> tuple[float, float, float]:
|
|
heading = curvature * distance
|
|
if abs(curvature) < 1e-9:
|
|
return distance, 0.0, 0.0
|
|
return math.sin(heading) / curvature, (1.0 - math.cos(heading)) / curvature, heading
|
|
|
|
|
|
def _relative_points(path: ModelPath, vehicle_pose: tuple[float, float, float], start: float,
|
|
horizon: float, count: int = 25) -> tuple[np.ndarray, np.ndarray]:
|
|
sample_distance = np.linspace(start, min(start + horizon, path.distance[-1]), count)
|
|
desired_x = np.interp(sample_distance, path.distance, path.x)
|
|
desired_y = np.interp(sample_distance, path.distance, path.y)
|
|
vehicle_x, vehicle_y, vehicle_heading = vehicle_pose
|
|
dx = desired_x - vehicle_x
|
|
dy = desired_y - vehicle_y
|
|
cosine = math.cos(vehicle_heading)
|
|
sine = math.sin(vehicle_heading)
|
|
return cosine * dx + sine * dy, -sine * dx + cosine * dy
|
|
|
|
|
|
def _fit_points(path: ModelPath, speed: float, current_curvature: float, delay: float,
|
|
horizon: float) -> tuple[np.ndarray, np.ndarray] | None:
|
|
advance = min(max(speed, 0.0) * delay, path.distance[-1])
|
|
available = min(horizon, path.distance[-1] - advance)
|
|
if available <= 0.25:
|
|
return None
|
|
x, y = _relative_points(path, _arc_pose(advance, current_curvature), advance, available)
|
|
forward = (x >= -0.25) & (x <= horizon)
|
|
x = x[forward]
|
|
y = y[forward]
|
|
if len(x) < 4 or np.ptp(x) <= 0.25:
|
|
return None
|
|
return x, y
|
|
|
|
|
|
def _wire_coefficients(c0: float, c1: float, c2: float, c3: float) -> tuple[float, float, float, float]:
|
|
slope = math.tan(c1)
|
|
slope_norm = 1.0 + slope ** 2
|
|
a2 = 0.5 * c2 * slope_norm ** 1.5
|
|
a3 = (c3 + 12.0 * slope * a2 ** 2 / slope_norm ** 3) * slope_norm ** 2 / 6.0
|
|
return c0, slope, a2, a3
|
|
|
|
|
|
def _wire_rmse(command: tuple[float, float, float, float], x: np.ndarray, y: np.ndarray) -> float:
|
|
a0, a1, a2, a3 = _wire_coefficients(*command)
|
|
reconstructed = a0 + a1 * x + a2 * x ** 2 + a3 * x ** 3
|
|
return float(np.sqrt(np.mean((reconstructed - y) ** 2)))
|
|
|
|
|
|
def fit_native_path(path: ModelPath, speed: float, current_curvature: float, *, delay: float,
|
|
horizon: float) -> NativePath:
|
|
"""Fit the delay-aligned path and return physical LMC2 fields.
|
|
|
|
C2 and C3 are curvature and curvature rate at the vehicle-frame origin, not
|
|
the raw quadratic and cubic polynomial coefficients.
|
|
"""
|
|
points = _fit_points(path, speed, current_curvature, delay, horizon)
|
|
if points is None:
|
|
return NativePath(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
|
x, y = points
|
|
|
|
# Scaling x before the least-squares solve keeps tight-turn fits well
|
|
# conditioned while preserving an ordinary cubic in vehicle coordinates.
|
|
scale = max(float(np.max(np.abs(x))), 1.0)
|
|
normalized_x = x / scale
|
|
design = np.column_stack((np.ones(len(x)), normalized_x, normalized_x ** 2, normalized_x ** 3))
|
|
scaled, *_ = np.linalg.lstsq(design, y, rcond=None)
|
|
a0, a1, a2, a3 = (float(scaled[index] / scale ** index) for index in range(4))
|
|
slope = a1
|
|
slope_norm = 1.0 + slope ** 2
|
|
curvature = 2.0 * a2 / slope_norm ** 1.5
|
|
curvature_rate = 6.0 * a3 / slope_norm ** 2 - 12.0 * slope * a2 ** 2 / slope_norm ** 3
|
|
command = (float(np.clip(a0, *DBC_OFFSET)),
|
|
float(np.clip(math.atan(slope), *DBC_ANGLE)),
|
|
float(np.clip(curvature, *DBC_CURVATURE)),
|
|
float(np.clip(curvature_rate, *DBC_CURVATURE_RATE)))
|
|
return NativePath(
|
|
*command,
|
|
_wire_rmse(command, x, y),
|
|
float(np.sqrt(np.mean(y ** 2))),
|
|
)
|
|
|
|
|
|
def fit_c2_aware_path(path: ModelPath, speed: float, current_curvature: float, *, delay: float,
|
|
horizon: float, target_c2: float, effective_c2: float,
|
|
use_c3: bool = True) -> NativePath:
|
|
"""Fit fast fields around the C2 curvature the PSCM is expected to realize."""
|
|
points = _fit_points(path, speed, current_curvature, delay, horizon)
|
|
if points is None:
|
|
return NativePath(0.0, 0.0, target_c2, 0.0, 0.0, 0.0)
|
|
x, y = points
|
|
|
|
slope = 0.0
|
|
a0 = a1 = a3 = 0.0
|
|
for _ in range(3):
|
|
a2 = 0.5 * effective_c2 * (1.0 + slope ** 2) ** 1.5
|
|
design = np.column_stack((np.ones(len(x)), x, x ** 3))
|
|
(a0, a1, a3), *_ = np.linalg.lstsq(design, y - a2 * x ** 2, rcond=None)
|
|
slope = float(a1)
|
|
|
|
slope_norm = 1.0 + slope ** 2
|
|
c3 = 6.0 * float(a3) / slope_norm ** 2 - 12.0 * slope * a2 ** 2 / slope_norm ** 3
|
|
c3 = float(np.clip(c3, *DBC_CURVATURE_RATE)) if use_c3 else 0.0
|
|
# Once C2 and C3 are fixed to what the hardware can realize, refit C0/C1 so
|
|
# their fast feedback preserves as much of the same path as possible.
|
|
_, _, fixed_a2, fixed_a3 = _wire_coefficients(0.0, math.atan(slope), effective_c2, c3)
|
|
(a0, a1), *_ = np.linalg.lstsq(np.column_stack((np.ones(len(x)), x)),
|
|
y - fixed_a2 * x ** 2 - fixed_a3 * x ** 3, rcond=None)
|
|
c0 = float(np.clip(a0, *DBC_OFFSET))
|
|
c1 = float(np.clip(math.atan(float(a1)), *DBC_ANGLE))
|
|
effective_command = (c0, c1, effective_c2, c3)
|
|
return NativePath(c0, c1, target_c2, c3, _wire_rmse(effective_command, x, y),
|
|
float(np.sqrt(np.mean(y ** 2))))
|
|
|
|
|
|
def _route(path: str) -> str:
|
|
return Path(path).name.split("--", 1)[0]
|
|
|
|
|
|
def load_samples(paths: list[str], stride: int = 2) -> list[Sample]:
|
|
grouped: dict[str, list[str]] = defaultdict(list)
|
|
for path in paths:
|
|
grouped[_route(path)].append(path)
|
|
|
|
samples = []
|
|
for route, route_paths in sorted(grouped.items()):
|
|
events = []
|
|
for path in sorted(route_paths):
|
|
events.extend(LogReader(path))
|
|
events.sort(key=lambda event: event.logMonoTime)
|
|
if not events:
|
|
continue
|
|
start_time = events[0].logMonoTime
|
|
model_path = None
|
|
curvature = 0.0
|
|
lat_active = path_valid = False
|
|
sent = (0.0, 0.0, 0.0, 0.0)
|
|
car_state_count = 0
|
|
for event in events:
|
|
which = event.which()
|
|
if which == "modelV2":
|
|
model_path = _model_path(event.modelV2)
|
|
elif which == "controlsState":
|
|
curvature = float(event.controlsState.curvature)
|
|
elif which == "carControl":
|
|
lat_active = bool(event.carControl.latActive)
|
|
elif which == "carControlSP":
|
|
command = event.carControlSP.fordLateralPath
|
|
path_valid = bool(command.valid)
|
|
sent = (float(command.pathOffset), float(command.pathAngle),
|
|
float(command.curvature), float(command.curvatureRate))
|
|
elif which == "carState" and lat_active and path_valid and model_path is not None:
|
|
car_state_count += 1
|
|
if car_state_count % stride:
|
|
continue
|
|
samples.append(Sample(
|
|
route, (event.logMonoTime - start_time) * 1e-9, float(event.carState.vEgo), curvature,
|
|
bool(event.carState.steeringPressed), model_path, *sent,
|
|
))
|
|
return samples
|
|
|
|
|
|
def _percentile(values: np.ndarray, percentile: float, mask: np.ndarray | None = None) -> float:
|
|
selected = values if mask is None else values[mask]
|
|
return float(np.percentile(np.abs(selected), percentile)) if len(selected) else math.nan
|
|
|
|
|
|
def _route_rate(samples: list[Sample], values: np.ndarray) -> np.ndarray:
|
|
rate = np.zeros(len(values))
|
|
for index in range(1, len(values)):
|
|
dt = samples[index].time - samples[index - 1].time
|
|
if samples[index].route == samples[index - 1].route and 0.005 <= dt <= 0.2:
|
|
rate[index] = (values[index] - values[index - 1]) / dt
|
|
return rate
|
|
|
|
|
|
def _c2_response(samples: list[Sample], target: np.ndarray, tau_load: float,
|
|
tau_unload: float) -> np.ndarray:
|
|
effective = np.zeros(len(target))
|
|
previous_route = None
|
|
previous_time = 0.0
|
|
state = 0.0
|
|
for index, sample in enumerate(samples):
|
|
if sample.route != previous_route:
|
|
state = 0.0
|
|
previous_time = sample.time
|
|
dt = float(np.clip(sample.time - previous_time, 0.005, 0.2))
|
|
loading = target[index] * state >= 0.0 and abs(target[index]) > abs(state)
|
|
tau = tau_load if loading else tau_unload
|
|
state += (1.0 - math.exp(-dt / tau)) * (target[index] - state)
|
|
effective[index] = state
|
|
previous_route, previous_time = sample.route, sample.time
|
|
return effective
|
|
|
|
|
|
def _limit_c2_command(samples: list[Sample], target: np.ndarray) -> np.ndarray:
|
|
"""Mirror the CAN-FD Ford curvature acceleration/jerk limiter."""
|
|
limited = np.zeros(len(target))
|
|
previous_route = None
|
|
previous_time = 0.0
|
|
previous = 0.0
|
|
for index, sample in enumerate(samples):
|
|
if sample.route != previous_route:
|
|
previous = 0.0
|
|
previous_time = sample.time
|
|
dt = float(np.clip(sample.time - previous_time, 0.005, 0.2))
|
|
speed = max(sample.speed, 1.0)
|
|
value = float(np.clip(target[index], -MAX_LATERAL_ACCEL / speed ** 2,
|
|
MAX_LATERAL_ACCEL / speed ** 2))
|
|
step = MAX_LATERAL_JERK / speed ** 2 * dt
|
|
value = float(np.clip(value, previous - step, previous + step))
|
|
limited[index] = float(np.clip(value, *DBC_CURVATURE))
|
|
previous = limited[index]
|
|
previous_route, previous_time = sample.route, sample.time
|
|
return limited
|
|
|
|
|
|
def _limit_fast_fields(samples: list[Sample], c0_target: np.ndarray,
|
|
c1_target: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
c0 = np.zeros(len(samples))
|
|
c1 = np.zeros(len(samples))
|
|
previous_route = None
|
|
previous_time = 0.0
|
|
previous_c0 = previous_c1 = 0.0
|
|
for index, sample in enumerate(samples):
|
|
if sample.route != previous_route:
|
|
previous_c0 = previous_c1 = 0.0
|
|
previous_time = sample.time
|
|
dt = float(np.clip(sample.time - previous_time, 0.005, 0.2))
|
|
c0[index] = np.clip(c0_target[index], previous_c0 - 4.0 * dt, previous_c0 + 4.0 * dt)
|
|
c1[index] = np.clip(c1_target[index], previous_c1 - 1.0 * dt, previous_c1 + 1.0 * dt)
|
|
previous_c0, previous_c1 = c0[index], c1[index]
|
|
previous_route, previous_time = sample.route, sample.time
|
|
return c0, c1
|
|
|
|
|
|
def evaluate(samples: list[Sample], *, delay: float, horizon: float,
|
|
tau_load: float, tau_unload: float, horizon_time: float = 0.0,
|
|
assumed_tau_load: float | None = None, assumed_tau_unload: float | None = None,
|
|
use_c3: bool = True, c2_limit: float = DBC_CURVATURE[1]) -> dict[str, float]:
|
|
horizons = np.asarray([float(np.clip(sample.speed * horizon_time, 1.0, horizon))
|
|
if horizon_time > 0.0 else horizon for sample in samples])
|
|
commands = [fit_native_path(sample.path, sample.speed, sample.curvature,
|
|
delay=delay, horizon=sample_horizon)
|
|
for sample, sample_horizon in zip(samples, horizons, strict=True)]
|
|
c0 = np.asarray([command.c0 for command in commands])
|
|
c1 = np.asarray([command.c1 for command in commands])
|
|
raw_c2 = np.asarray([command.c2 for command in commands])
|
|
c2 = np.clip(raw_c2, -c2_limit, c2_limit)
|
|
c3 = np.asarray([command.c3 for command in commands])
|
|
fit_rmse = np.asarray([command.fit_rmse for command in commands])
|
|
path_rms = np.asarray([command.path_rms for command in commands])
|
|
transmitted_c2 = _limit_c2_command(samples, c2)
|
|
effective_c2 = _c2_response(samples, transmitted_c2, tau_load, tau_unload)
|
|
estimated_c2 = _c2_response(samples, transmitted_c2,
|
|
tau_load if assumed_tau_load is None else assumed_tau_load,
|
|
tau_unload if assumed_tau_unload is None else assumed_tau_unload)
|
|
compensated = [fit_c2_aware_path(sample.path, sample.speed, sample.curvature,
|
|
delay=delay, horizon=sample_horizon, target_c2=target,
|
|
effective_c2=estimated, use_c3=use_c3)
|
|
for sample, sample_horizon, target, estimated in
|
|
zip(samples, horizons, c2, estimated_c2, strict=True)]
|
|
compensated_c0 = np.asarray([command.c0 for command in compensated])
|
|
compensated_c1 = np.asarray([command.c1 for command in compensated])
|
|
compensated_c3 = np.asarray([command.c3 for command in compensated])
|
|
limited_c0, limited_c1 = _limit_fast_fields(samples, compensated_c0, compensated_c1)
|
|
estimated_compensated_rmse = np.asarray([command.fit_rmse for command in compensated])
|
|
compensated_rmse = []
|
|
for sample, sample_horizon, command, effective in zip(samples, horizons, compensated, effective_c2, strict=True):
|
|
points = _fit_points(sample.path, sample.speed, sample.curvature, delay, sample_horizon)
|
|
compensated_rmse.append(0.0 if points is None else _wire_rmse(
|
|
(command.c0, command.c1, effective, command.c3), *points))
|
|
compensated_rmse = np.asarray(compensated_rmse)
|
|
limited_compensated_rmse = []
|
|
for sample, sample_horizon, c0_value, c1_value, c3_value, effective in \
|
|
zip(samples, horizons, limited_c0, limited_c1, compensated_c3, effective_c2, strict=True):
|
|
points = _fit_points(sample.path, sample.speed, sample.curvature, delay, sample_horizon)
|
|
limited_compensated_rmse.append(0.0 if points is None else _wire_rmse(
|
|
(c0_value, c1_value, effective, c3_value), *points))
|
|
limited_compensated_rmse = np.asarray(limited_compensated_rmse)
|
|
missing_c2 = transmitted_c2 - effective_c2
|
|
# Compare channels by their lateral contribution at the fit horizon. This
|
|
# includes C3: treating it as zero would incorrectly blame C0/C1 for a
|
|
# curvature transition the native polynomial assigns to curvature rate.
|
|
fast = (2.0 * compensated_c0 / horizons ** 2 +
|
|
2.0 * np.tan(compensated_c1) / horizons +
|
|
compensated_c3 * horizons / 3.0)
|
|
lagging = np.abs(missing_c2) > 0.0005
|
|
unloading = lagging & (np.abs(c2) < 0.75 * np.abs(effective_c2))
|
|
pressed = np.asarray([sample.steering_pressed for sample in samples])
|
|
speed = np.asarray([sample.speed for sample in samples])
|
|
sent_c2 = np.asarray([sample.sent_c2 for sample in samples])
|
|
sent_transmitted_c2 = _limit_c2_command(samples, sent_c2)
|
|
sent_effective_c2 = _c2_response(samples, sent_transmitted_c2, tau_load, tau_unload)
|
|
sent_lpf_rmse = []
|
|
for sample, sample_horizon, effective in zip(samples, horizons, sent_effective_c2, strict=True):
|
|
points = _fit_points(sample.path, sample.speed, sample.curvature, delay, sample_horizon)
|
|
sent_lpf_rmse.append(0.0 if points is None else _wire_rmse(
|
|
(sample.sent_c0, sample.sent_c1, effective, sample.sent_c3), *points))
|
|
sent_lpf_rmse = np.asarray(sent_lpf_rmse)
|
|
raw_c2_rate = _route_rate(samples, c2)
|
|
c2_rate = _route_rate(samples, transmitted_c2)
|
|
sent_c2_rate = _route_rate(samples, sent_c2)
|
|
compensated_c0_rate = _route_rate(samples, compensated_c0)
|
|
compensated_c1_rate = _route_rate(samples, compensated_c1)
|
|
compensated_c3_rate = _route_rate(samples, compensated_c3)
|
|
normalized_fit = np.divide(fit_rmse, path_rms, out=np.zeros_like(fit_rmse), where=path_rms > 1e-4)
|
|
return {
|
|
"samples": float(len(samples)),
|
|
"delay": delay,
|
|
"horizon": horizon,
|
|
"horizon_time": horizon_time,
|
|
"assumed_tau_load": tau_load if assumed_tau_load is None else assumed_tau_load,
|
|
"assumed_tau_unload": tau_unload if assumed_tau_unload is None else assumed_tau_unload,
|
|
"use_c3": float(use_c3),
|
|
"c2_limit": c2_limit,
|
|
"actual_horizon_p50": _percentile(horizons, 50),
|
|
"actual_horizon_p95": _percentile(horizons, 95),
|
|
"fit_rmse_p50": _percentile(fit_rmse, 50),
|
|
"fit_rmse_p95": _percentile(fit_rmse, 95),
|
|
"normalized_fit_p95": _percentile(normalized_fit, 95),
|
|
"c2_aware_rmse_p50": _percentile(compensated_rmse, 50),
|
|
"c2_aware_rmse_p95": _percentile(compensated_rmse, 95),
|
|
"c2_aware_estimated_rmse_p95": _percentile(estimated_compensated_rmse, 95),
|
|
"c2_aware_limited_rmse_p95": _percentile(limited_compensated_rmse, 95),
|
|
"sent_lpf_rmse_p50": _percentile(sent_lpf_rmse, 50),
|
|
"sent_lpf_rmse_p95": _percentile(sent_lpf_rmse, 95),
|
|
"c0_p95": _percentile(c0, 95),
|
|
"c1_p95": _percentile(c1, 95),
|
|
"c2_p95": _percentile(c2, 95),
|
|
"c3_p95": _percentile(c3, 95),
|
|
"c0_clip_rate": float(np.mean((c0 <= DBC_OFFSET[0]) | (c0 >= DBC_OFFSET[1]))),
|
|
"c1_clip_rate": float(np.mean((c1 <= DBC_ANGLE[0]) | (c1 >= DBC_ANGLE[1]))),
|
|
"c2_clip_rate": float(np.mean((c2 <= DBC_CURVATURE[0]) | (c2 >= DBC_CURVATURE[1]))),
|
|
"c3_clip_rate": float(np.mean((c3 <= DBC_CURVATURE_RATE[0]) | (c3 >= DBC_CURVATURE_RATE[1]))),
|
|
"c2_aware_c0_p95": _percentile(compensated_c0, 95),
|
|
"c2_aware_c1_p95": _percentile(compensated_c1, 95),
|
|
"c2_aware_c3_p95": _percentile(compensated_c3, 95),
|
|
"c2_aware_c0_rate_p95": _percentile(compensated_c0_rate, 95),
|
|
"c2_aware_c1_rate_p95": _percentile(compensated_c1_rate, 95),
|
|
"c2_aware_c0_rate_limit_rate": float(np.mean(np.abs(compensated_c0_rate) > 4.0)),
|
|
"c2_aware_c1_rate_limit_rate": float(np.mean(np.abs(compensated_c1_rate) > 1.0)),
|
|
"c2_aware_c3_rate_p95": _percentile(compensated_c3_rate, 95),
|
|
"raw_c2_rate_p95": _percentile(raw_c2_rate, 95),
|
|
"c2_rate_p95": _percentile(c2_rate, 95),
|
|
"sent_c2_rate_p95": _percentile(sent_c2_rate, 95),
|
|
"c2_lag_p95": _percentile(missing_c2, 95),
|
|
"lag_samples": float(np.count_nonzero(lagging)),
|
|
"lag_fast_support_rate": float(np.mean(fast[lagging] * missing_c2[lagging] > 0.0)) if np.any(lagging) else math.nan,
|
|
"lag_fast_coverage_p50": _percentile(np.divide(fast, missing_c2, out=np.zeros_like(fast),
|
|
where=np.abs(missing_c2) > 1e-6), 50, lagging),
|
|
"unload_samples": float(np.count_nonzero(unloading)),
|
|
"unload_fast_counter_rate": float(np.mean(fast[unloading] * effective_c2[unloading] < 0.0)) if np.any(unloading) else math.nan,
|
|
"unload_residual_c2_p95": _percentile(effective_c2 - c2, 95, unloading),
|
|
"pressed_c0_p95": _percentile(c0, 95, pressed),
|
|
"pressed_c1_p95": _percentile(c1, 95, pressed),
|
|
"low_speed_fit_p95": _percentile(fit_rmse, 95, speed < 5.0),
|
|
"road_speed_fit_p95": _percentile(fit_rmse, 95, speed >= 15.0),
|
|
}
|
|
|
|
|
|
def _expand(patterns: list[str]) -> list[str]:
|
|
return sorted({path for pattern in patterns for path in glob.glob(pattern)})
|
|
|
|
|
|
def _self_test() -> None:
|
|
distance = np.linspace(0.0, 20.0, 81)
|
|
coefficients = (0.2, 0.03, 0.004, -0.00005)
|
|
y = sum(coefficient * distance ** power for power, coefficient in enumerate(coefficients))
|
|
slope = coefficients[1] + 2.0 * coefficients[2] * distance + 3.0 * coefficients[3] * distance ** 2
|
|
heading = np.arctan(slope)
|
|
path = ModelPath(distance, y, heading, np.concatenate(([0.0], np.cumsum(np.hypot(np.diff(distance), np.diff(y))))))
|
|
command = fit_native_path(path, 0.0, 0.0, delay=0.1, horizon=7.0)
|
|
assert abs(command.c0 - coefficients[0]) < 2e-3
|
|
assert abs(command.c1 - math.atan(coefficients[1])) < 2e-3
|
|
expected_c2 = 2.0 * coefficients[2] / (1.0 + coefficients[1] ** 2) ** 1.5
|
|
assert abs(command.c2 - expected_c2) < 2e-4
|
|
assert command.fit_rmse < 1e-4
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--logs", action="append", help="rlog glob", default=[])
|
|
parser.add_argument("--delay", type=float, default=0.1)
|
|
parser.add_argument("--horizon", type=float, action="append")
|
|
parser.add_argument("--time-horizon", type=float, default=0.0,
|
|
help="if nonzero, use clamp(speed * seconds, 1 m, --horizon)")
|
|
parser.add_argument("--tau-load", type=float, default=0.75)
|
|
parser.add_argument("--tau-unload", type=float, default=1.3)
|
|
parser.add_argument("--assumed-tau-load", type=float)
|
|
parser.add_argument("--assumed-tau-unload", type=float)
|
|
parser.add_argument("--zero-c3", action="store_true")
|
|
parser.add_argument("--c2-limit", type=float, action="append",
|
|
help="C2 cap to test; defaults to gentle 0.006 and full 0.02")
|
|
parser.add_argument("--self-test", action="store_true")
|
|
args = parser.parse_args()
|
|
if args.self_test:
|
|
_self_test()
|
|
paths = _expand(args.logs)
|
|
if not paths:
|
|
if args.self_test:
|
|
return 0
|
|
parser.error("at least one usable --logs glob is required")
|
|
samples = load_samples(paths)
|
|
if not samples:
|
|
parser.error("logs contain no active Ford path samples")
|
|
print(f"loaded_logs={len(paths)} samples={len(samples)} tau_load={args.tau_load} tau_unload={args.tau_unload}")
|
|
for horizon in args.horizon or [3.5, 5.0, 7.0, 10.0]:
|
|
for c2_limit in args.c2_limit or [0.006, DBC_CURVATURE[1]]:
|
|
result = evaluate(samples, delay=args.delay, horizon=horizon,
|
|
tau_load=args.tau_load, tau_unload=args.tau_unload,
|
|
horizon_time=args.time_horizon,
|
|
assumed_tau_load=args.assumed_tau_load,
|
|
assumed_tau_unload=args.assumed_tau_unload,
|
|
use_c3=not args.zero_c3,
|
|
c2_limit=c2_limit)
|
|
print(" ".join(f"{key}={value:.8g}" for key, value in result.items()))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|