wing-av · BYD Atto 3 · controller evaluation

Did the controller do what the model asked?

A question every autonomous-vehicle team has to turn into numbers. This site does that for wing-av: it defines the metrics, backs each one with how Autoware, Apollo, openpilot, Waymo, nuPlan, Stanford/CMU and the regulators measure the same thing, and then computes them on the ten laps recorded on 2026-09-15 (five AV2.0, five AV2.5) — plans, commands, CAN frames and odometry included.

The car re-driving a recorded lap — the model's trajectory, the executed path and the 1 s error (legend bottom-left)
drag to orbit · wheel to zoom

Short answers

a
How do we know the controller correctly executed the model's plan?

Split "executed" into four measurable questions, each with its own reference and its own measurement, all of which are already in the bags:

① Tracking — did the car go where the plan said? Cross-track and heading error between the model's predicted trajectory and the odometry 0.5 / 1 / 2 s later; curvature error (commanded vs yaw-rate/v); speed and acceleration error against the longitudinal plan. ② Actuation fidelity — did the car do what we commanded? Steering desired vs sent vs measured (with the limiter's clip and the saturation flag), command→response lag, and the CAN-level check that frame 482 asked what frame 287 reports. ③ Ride quality — was it done the way a passenger and a regulator accept? Lateral / longitudinal acceleration and jerk, steering rate and reversals, against UN R79, ISO 15622, ISO 2631 and nuPlan bands and against the human laps on the same road. ④ Supervision — did a human have to step in? Disengagements per engaged km, overrides, hands-on-wheel. Plus a prerequisite: ⓪ plan quality (plan jitter, MPC validity, plan age) because no controller can execute a plan that changes every frame.

→ Page 1 explains the chain and lets you play with a simulated controller; Page 2 is the full catalogue with formulas, bands and sources.

b
Can we measure this for straight cruising, curves, sharp turns, stops, decelerations and accelerations?

Yes — every 50 Hz sample of every lap is labelled by lateral situation (straight |κ| < 0.005 m⁻¹, curve, sharp |κ| ≥ 0.02 m⁻¹, i.e. R < 50 m) and longitudinal situation (cruise, decel a < −0.4, accel a > 0.4 m/s², stop = approach + standstill), then cut into episodes and scored. One honest caveat: …

→ Page 4: heatmap of drives × situations, 3-D replays of the representative episode of each situation, AV2.0 vs AV2.5 vs human.

c
How do we monitor this continuously for all vehicles?

As a post-drive batch job, the way Autoware's driving_log_replayer and comma's maneuver reports do it: every uploaded bag → one deterministic extractor (the one that produced these numbers, ~2.5 min per lap) → one JSON record per drive with the per-situation scorecard → a store keyed by vehicle / model / software version / route → a dashboard with trend lines and regression thresholds → an alert when a metric leaves its band. The online half is a guard node that publishes the same metrics on /diagnostics while driving.

→ Page 6 is that dashboard, built from the ten laps as a ten-vehicle-day "fleet".

d
Which controller metrics and topics do we need to collect?

The essentials bag already carries what the metrics on this site need: /control/predicted_trajectory, /control/lateral_plan, /control/longitudinal_plan, /control/actuators, /vehicle/actuators_output, /vehicle/state, /vehicle/engagement, /localization/odometry, /sensing/gnss/imu/data, /sensing/gnss/ekf_nav, /vehicle/can_tx, /vehicle/can_rx. What is missing is the controller's inside: a /control/debug message with the setpoints it actually used (lag-adjusted curvature, v_target_now, a_target), the PID terms, which limiter stage bound, and a per-drive software/parameter fingerprint — the list Apollo publishes every cycle.

→ Page 7: topic-by-topic table (have / partial / missing) and the proposed message.

What the ten laps say

Generated from the numbers, not written by hand — every figure links to the page that shows it. These are the "value for improvement" findings the metrics were chosen to surface.

The ten laps at a glance

Numbers on this site are computed by extract_metrics.py from the essentials bags (read-only) at 50 Hz and downsampled to 10 Hz for the charts. Every column is defined on Page 2; every threshold and its source on Page 8; every assumption on Page 9.

Pages

PAGE 1
From the question to metrics

The command chain, the four questions, and a simulated controller you can detune to see each metric react.

PAGE 2
Metric catalogue

Every metric with its formula, the topics it reads, its comfort/regulatory bands and the fleet values on the ten laps.

PAGE 3
Trajectory & origin

Where the trajectory starts on the car, what AV2.0 and AV2.5 publish, the car's own path in the same frame, and how the industry compares them (ADE / FDE).

PAGE 4
Five situations

Straight, curve, sharp turn, stop, decel/accel: how they are detected, a 3-D replay of each, and the scorecards.

PAGE 5
Drive explorer

Any lap, any second: front and cabin cameras, synchronized charts, situation bands, satellite map, lidar map and 3-D replay.

PAGE 6
Fleet monitoring

The post-drive pipeline and a dashboard over the ten laps with trend lines, thresholds and AV2.0 vs AV2.5.

PAGE 7
Data to collect

Topics, rates and fields used by each metric; what is missing; the proposed /control/debug message.

PAGE 8
Industry evidence

Verbatim quotes from Autoware, Apollo, openpilot, nuPlan, Waymo, Stanford, CMU, UNECE, ISO, NIST and the CA DMV.

PAGE 9
Method & caveats

How every number was computed, the sign conventions, the thresholds, and what the data cannot tell you.

PAGE 10
Control scorecard

The "Control" section proposed for the Drive Live Tracker: one score per uploaded bag, mocked on the ten laps in the tracker's own layout, with the record the server would write.

PAGE 11
Tuning levers

Every parameter of the deployed control chain with its file, its value on the laps and the metric it moves; a ranked shortlist of what to turn; and the hardware ceilings — with what it would take to raise them.

Page 1 of 11 · question (a)

From "did the controller execute the plan?" to numbers

A plan is a promise about the future: in one second the car will be here, heading there, at this speed. The controller's job is to turn that promise into steering and acceleration commands that the car then physically carries out. So "correctly executed" can only be checked by comparing three things that live at different places in the stack: what was planned, what was commanded, and what actually happened.

The chain, and where each comparison lives

The command chain on the car — click a box to see what it publishes and which comparison it enables
Click a box.

Four questions, one prerequisite

⓪ Plan quality
Was the plan followable at all?
plan jitter @1 s · MPC valid · plan age · inference age · solve time
① Tracking
Did the car go where the plan said?
cross-track @0.5/1/2 s · heading err · curvature err · speed err · accel err · along-track
② Actuation fidelity
Did the car do what we commanded?
steer desired→sent→measured · clip % · saturated % · lag · ACC cap % · 482 vs 287 on the wire
③ Ride quality
Was it done acceptably?
lat acc · lat jerk (0.3 s smoothed) · long acc · long jerk · steer rate · reversals/min
④ Supervision
Did a human step in?
disengagements / km · overrides · hands-on · engaged share

Reference and measurement. Every metric here is "reference minus measurement", and the trick is choosing the right pair. Tracking compares the plan with odometry. Actuation compares the command with the car's own sensors. Ride quality has no reference — it compares the measured motion with what people tolerate, which is why it needs external bands (regulations, comfort studies, the human laps). Supervision compares the state machine with the driver's hands.

Why not one number? Because the failure modes are different and need different fixes: a large cross-track error with a small steering error means the planner or the vehicle model is wrong (the controller did what it was told, the car did not end up on the plan); a large steering error with a small cross-track error means the limiter or the EPS is slow but the loop still closes; high jerk with perfect tracking means the plan itself is uncomfortable; a clean drive with three disengagements means the driver did not trust it. Apollo, Autoware and openpilot all log the layers separately for exactly this reason (Page 8).

Play with a controller

A kinematic bicycle model of the Atto 3 (wheelbase 2.72 m, steering ratio 16.36) drives a closed course — straights, an 80 m curve, a 20 m junction, a 40 m curve, a 15 m hairpin — with a simple look-ahead steering law. The sliders detune it the way real controllers go wrong: actuator delay (the EPS and the 20 ms CAN cycle), rate limit (the vehicle interface's slew limiter, 125 °/s at low speed on the real car), gain (too timid or too aggressive), look-ahead and speed. The tyres deliver at most 8 m/s² of lateral acceleration, so a corner taken too fast pushes the car wide. Watch the metrics react: delay produces lag on curve entry and, with enough of it, a car that leaves the road at the junction; low gain produces steady-state cross-track error in curves; high gain tracks tighter but buys it with steering reversals and lateral jerk — the trade-off every tuning session is about.

Simulated tracking — pink: reference path · cyan: the car's path · orange: cross-track error

The plot shows the last 20 s: cross-track error (m, orange), lateral acceleration (m/s², green) and the steering command vs actual (°, cyan / white). Presets:

How the real numbers are formed

Tracking (plan → odometry)

notation odometry pose; a PredictedTrajectory point (base_link frozen at the image time timg); the plan placed in the map frame (Page 3) trajectory_node samples the fitted curve at timg + τ + delay, delay = tpub − timg a w-second moving average; yaw; steering-wheel angle; yaw rate horizon errors (per plan, at τ = 0.5 / 1 / 2 s) where the car really was, minus where the plan said cross-track (+ left) and along-track (+ car ahead) components heading error setpoint errors (every 50 Hz sample while lat / long active) the message is + right; flipped to REP-103 left-positive the plan read at its age, as LongControl does commanded minus kinematic acceleration

Actuation, ride, supervision

actuation pre-limiter request (ActuatorCommand) vs the EPS angle (VehicleState) what went on the wire (ActuatorsOutput) vs the EPS what the limiter removed response lag: τ ∈ [0, 1.5 s] in 20 ms steps, both 0.1 s smoothed ride lateral acceleration and jerk longitudinal acceleration and jerk steering hunting supervision every reason counted (route end, driver choice) gas while enabled; the flag needs |DRIVER_EPS_TORQUE| > 80 on this profile

Cross-track at a horizon mixes two things: how well the controller tracked and how much the plan itself moved in the meantime (the model re-plans every 100 ms). That is why the plan-jitter metric sits next to it: if consecutive plans disagree by 2 cm at 1 s and the car is 7 cm off, 5 cm is the controller's. Autoware scores exactly this in its planning evaluator — it "evaluates the trajectory stability by comparing the trajectory T(0) and previous trajectory T(-1)" — in a node separate from the control evaluator that judges the controller. Page 3 shows the trajectory itself, in the car's own frame.

Page 2 of 11 · question (a), continued

Metric catalogue

Each card: what it measures, the exact formula and topics, the sign convention, the bands used to colour it — each edge tagged with its origin: a regulation or standard, a published benchmark, a constant of the deployed stack, or our choice — and its value on the ten laps for AV2.0 and AV2.5 while the corresponding loop was active. Colour is a guide, not a verdict — a band from a lane-keeping regulation is not a pass/fail for a test track.

Where the bands come from

The fleet in one table

Duration-weighted over the laps of each model — lateral metrics by lat_active seconds, longitudinal by long_active seconds; peak rows show the worst lap; disengagements are Σ/Σ over the engaged km. The "human" columns are the same road driven manually in the same laps (before engagement and after take-overs), so ride-quality metrics have a like-for-like baseline — the idea behind nuPlan's expert-derived thresholds and Waymo's log-likelihood metrics.

Page 3 of 11 · the trajectory itself

The trajectory from the car's own origin

Every metric on this site compares a trajectory the model published with what the car did. This page shows that trajectory the way the car sees it: from its own origin, in its own frame, for both models, and next to the path the car actually drove in the following seconds.

Where the origin is

The car and its frames — drag to orbit

What each model publishes, in the vehicle frame

Left and right show one automated episode each (pick any): the pink points are the model's trajectory exactly as published on /control/predicted_trajectory (x forward, y left, from the origin above), the cyan line is where the car actually went during the following seconds, transformed into the same frame at the same instant, the faint pink lines are the other plans of the episode (their spread is the plan jitter). Drag the scrubber to move through the episode; the point labels are seconds ahead.

AV2.0
AV2.5
model trajectory (this plan)other plans of the episodeactual path of the car in the next secondsdisplacement at 0.5 / 1 / 2 sorigin (base_link) and heading

Comparing trajectories the way the industry does

Page 4 of 11 · question (b)

Straight, curve, sharp turn, stop, deceleration, acceleration

Situations are labelled from the motion itself, so the same rule applies whether the driver or the controller is driving — that is what makes the human baseline comparable. Lateral and longitudinal labels are independent (a braking sharp turn is both), which gives the six labels the questions ask for.

Lateral label — from curvature

κ = ω_z / v (v > 1 m/s; 0.5 s moving average; while lat_active: max(|κ_cmd|, |κ_meas|) so under-steer cannot hide a sharp turn; labelled only while moving, v > 2 m/s)
straight |κ| < 0.005 m⁻¹ (radius > 200 m, steering wheel ≲ 13°)
curve 0.005 ≤ |κ| < 0.02 (radius 50–200 m, ≈ 13–50°)
sharp |κ| ≥ 0.02 m⁻¹ (radius < 50 m, the junctions on the mosque loop)

Steering-wheel equivalents use δ ≈ 16.36 · atan(2.72 κ); the measured κ and the steering-implied κ agree with slope 1.01 on these laps (Page 9).

Longitudinal label — from acceleration

a = d(v_ego)/dt (0.3 s smoothed, then 0.5 s average; while long_active: the larger of planned and measured)
cruise |a| ≤ 0.4 m/s² and moving (v > 2 m/s)
decel a < −0.4 m/s² for ≥ 1 s
accel a > +0.4 m/s² for ≥ 1 s
stop standstill (v < 0.3 m/s ≥ 0.5 s) plus the approach back to the last v > 3 m/s (≤ 12 s)

Episodes: gaps under 0.5 s are merged, runs under 1 s dropped. An episode counts as "AV" when the loop was active for ≥ 80 % of it and "human" when ≤ 20 %.

Drives × situations

Heatmap — pick a metric, click a cell to replay that drive's longest automated episode of that situation

Replay an episode

Episode replay
pink ribbons: the model's plans (bright = the one being followed) · cyan: executed path · orange: 1 s error

Scorecards per situation

Duration-weighted means over all automated episodes of that situation (and over the manual ones for the human column). Colours follow the bands on Page 2. Click a situation to change the replay above.

Stops — what the data can and cannot say

Page 5 of 11

Drive explorer

Any lap, any second. The map is coloured by lateral situation, the timeline strip by longitudinal situation; engaged time is drawn brighter. Hover the charts to move the cursor everywhere; drag the scrubber or press play to drive the 3-D car along the recorded odometry with the recorded steering, tilted by the INS roll and pitch, over the lap's own relief. In the 3-D view, lidar map switches on the reflectivity-coloured point-cloud map built from this lap's lidar scans (the road reads dark, paint and kerbs bright; Page 9).

Sample rates behind this page

Source topics in the bag: odometry 50 Hz · /vehicle/state, /control/actuators, engagement 100 Hz · /vehicle/actuators_output 50 Hz (emitted cycles) · INS orientation and ekf_nav 50 Hz · plans and PredictedTrajectory 10 Hz nominal (one per model inference; 100 ms median interval on every lap, AV2.5's mean 9.4–9.6 Hz because some inferences run long) · CAN tx 50 Hz, rx 100 Hz · lidar 10 Hz · front camera 20 Hz, cabin 30 fps. Analysis grid 50 Hz. What this page shows: charts, state card, HUD and map cursor at 10 Hz (every 5th grid sample); the 3-D car, its steer curves and the head of the executed path are re-posed every rendered frame while playing, interpolating x/y/yaw/steer/roll/pitch between the 10 Hz samples (the car moves < 1 m between two samples, so the interpolation error is centimetres); the fps button in the 3-D toolbar shows the render rate, and the view lowers its own resolution and far-field point density if the page cannot hold ~33 fps; the plan ribbons in the 3-D view and the vehicle-frame panel are every published plan (10 Hz nominal, the bag's own rate) with every published point (AV2.5's full ~5 s horizon, AV2.0's 4 points); the home and Page 4 replays also step through every plan but clip the ribbon to the 0–2 s the tracking metrics compare; the vehicle-frame trajectory panel shows the plan in force with all its points and its ±16 neighbours; the videos are 20 fps transcodes (the front camera's native rate); the lidar map is static (one build per lap, 0.2 m voxels on the road).

Lap
cabin
model trajectory · vehicle frame
situation:straight / cruisecurvesharpdecelaccelstopengaged row:enabledoverriding (driver on the gas, lateral still engaged)disableddriver rows: lit while the driver steers / presses gas / brakes

482 STEER_ANGLE is what byd_vehicle_interface put on the bus (post-limiter, 0.1°/bit); 287 STEER_ANGLE_2 is what the EPS reports back; 814 ACCEL_CMD is clip(accel × 18, −50, +30) → the +30 cap is 1.67 m/s². These come from /vehicle/can_tx and /vehicle/can_rx, decoded with byd_general_pt.dbc.

Episodes in this lap

Click a row to jump the replay there. "AV" = loop active ≥ 80 % of the episode.

Page 6 of 11 · question (c)

Continuous controller evaluation for every vehicle

The bag is already uploaded after every drive and split into essentials, camera, lidar, cabin. The controller evaluation only needs essentials (48–123 MiB per lap), so it can run on the NAS as soon as _UPLOADED.json appears — no GPU, no replay of the stack, deterministic, ~3 minutes per lap on one core (2.6–3.3 min measured; 28 min for the ten laps run back to back).

The post-drive pipeline (dots = one drive flowing through)
1TriggerA watcher on the upload directory (or the uploader itself) queues every new <drive>/essentials whose _UPLOADED.json is complete.
  • key = vehicle · date · drive id
  • reads run.yaml (model, route, kind)
2Extractextract_metrics.py as on this site: 50 Hz grid, labels, episodes, horizons, lags, CAN integrity.
  • pinned version of the script
  • tmux/cron, log to file
3StoreOne JSON record per drive (2.7–5.3 MB as generated here: ≈1.4 MB of 10 Hz series, ≈2.2 MB of 25 Hz episode excerpts for the replays, ≈1.6 MB of raw plans; ≈75 kB with the series stripped) into a table keyed by vehicle / software commit / model / route.
  • Parquet or SQLite is enough for years of laps
  • keep the episodes, not only the means
4DashboardThis site regenerated nightly: trends per metric, per situation, per model; drill-down to the lap and second.
  • compare any two software versions
  • human baseline per route
5AlertBands from Page 2 plus a statistical band from the last N laps of the same route/model; a breach opens a ticket with the episode replay link.
  • regression = metric outside its rolling band
  • no new lap for 7 days = also an alert
6Online guardThe same errors published live on /diagnostics by a small node (Autoware's control_validator pattern), so the driver display can show "tracking degraded" and the recorder can mark the moment.
  • thresholds looser than offline
  • never actuates, only flags

The dashboard, on the ten laps

Trend across the day — each point is one lap; colour = model

AV2.0 vs AV2.5, whole lap

Weighted by lat-active seconds. Lower is better for every row except engaged km. Δ is AV2.5 relative to AV2.0.

What a nightly record looks like

The real record for one lap, trimmed. This is what the store keeps and what the alert rule reads.

Regression check on the ten laps

Each lap against the band of the other laps of the same model (median ± 2 × MAD) — the rule a nightly job would apply. Flags mark laps outside the band; they are prompts to look, not failures.

Page 7 of 11 · question (d)

Metrics and topics to collect

What the essentials bag already gives us, what each metric reads from it, and what is missing. Rates are measured on lap 08:50.

Topics → metrics (click a topic)
Click a topic.

Topic table

What is missing, and the proposed /control/debug

Gaps found while computing the metrics

  • The controller's internal setpoints. ActuatorCommand.curvature is the lag-adjusted curvature after the ±0.5/v² clip, and steering_angle_desired_deg already is the feed-forward angle: the lateral law is open loop and angle_offset_deg, steering_wheel_angle_bias and road_roll_rad are 0 on the car (while lateral is inactive it is the measured angle; a chained steering override — the joystick controller, off on these laps — would replace it). Not published: the pre-clip curvature and the clip centre, the longitudinal plan age the PID read at, v_target_now / v_target / v_target_1sec / a_target, and the PID P/I/D/F terms. The site re-derives v_target_now (the PID setpoint) the way LongControl does, but not v_target / v_target_1sec (the state-machine inputs) nor a_target (the feed-forward is a windowed value, not the plan's accel at that age, which is what the site plots); P and F could be re-derived from the gains, the integrator only as a residual where the clip did not bind.
  • Which longitudinal source bound. LongitudinalPlan.longitudinal_plan_source (2 CRUISE, 3 E2E) and VehicleState.cruise_speed (= max(SET_SPEED, 30 km/h)) are published and in the essentials bag but not in the metrics extract. In blended mode the planner takes the tighter of the model's plan and the cruise ray t·max(v_cruise, v_ego − 2 m/s), and the 30 km/h floor (8.33 m/s) sits right on the laps' 7.8–8.3 m/s mean speed: on the 15 Sep laps the source was CRUISE on 58–73 % of long-active plans on every AV2.5 lap and 0 % on every AV2.0 lap, with cruise_speed at 8.33 m/s on every message of all ten laps — so the AV2.5 e_v_rms is mostly tracking of the set-speed ray, not of the model, and the metric cannot tell. Extract the CRUISE share per lap and the set speed; any longitudinal A/B must log both (Page 11, lever 7). The .msg comment "always CRUISE" describes the ACC branch and is stale for the deployed blended mode.
  • Which limiter stage bound. desired − sent shows that the limiter clipped, not which stage: in the deployed std-angle mode the speed-scheduled slew (2.5 / 1.5 / 0.4 ° per 20 ms frame up, 3.75 / 2.25 / 0.9 down in the x50 profile — 1.5× the opendbc port's down row), the ±200° clamp or the ±12° window around the measured angle; in std mode also the follow-the-hands path (while |DRIVER_EPS_TORQUE| exceeds driver_override_torque the request is replaced by the measured angle); in the legacy five-stage chain (not running on any of these laps) the speed-scheduled band around the measured angle (±10 / 7.5 / 5° winding up, ±9 / 6 / 4.5° down, at 0 / 5 / 15 m/s), the ±15° safety clip (inert behind that band), the 340→160° speed envelope (200° above 8 m/s), and the FIR and 10–50° low-speed clamp (both off on the car). Nothing in the chain records which stage bound; the follow counter exists in the transmit cycle but is not published.
  • steering_rate_deg_s is 0.0 on the plugin host (the host writes a hard 0.0 where the message promises NaN; the bag holds 0.0 on every /vehicle/state message), so steering rate is differentiated from the angle here.
  • a_ego is a wheel-speed Kalman estimate; it agrees with d(v)/dt at r = 0.94 but neither is an IMU-grade acceleration. The SBG IMU is recorded (50 Hz): its acceleration is a cross-check only, its INS orientation is what tilts the 3-D car (roll/pitch) and feeds the attitude charts. The ride layer's a_max / a_min / jerk_max are wheel-derived, so road bumps (v dips ~1 m/s in 0.5 s with the IMU of opposite sign) score as controller jerk — Page 11 proposes deriving the ride-layer acceleration from the IMU x-axis (already in the extract, 0.3 s smoothed) instead.
  • No route reference / lane centreline. Autoware's lateral_deviation_centerline and R79's "does not cross a lane marking" need one; the mosque loop has no map. A one-off surveyed centreline per route would enable lane-position metrics.
  • Controller fingerprint per drive. run.yaml carries only route / kind / model. The model side is already structured and latched in the essentials bag — /perception/model_info (wing_msgs/ModelInfo, one message per lap: weights, input profile, waypoint grid), which the extractor should read instead of matching run.yaml's model string. The controller side exists only as free text in three /rosout start-up banners (steering limiter, vehicle model, delay compensation) plus the planner's MPC-horizon warning, which is printed only when the horizon is not the deployed 2 s grid — the interim fingerprint the extractor hashes and Page 10 groups by. Still missing, to be added as run.yaml keys and once on a latched /system/version topic (Page 10): the wing-av git describe, the profile name, a SHA-1 of the controller-node parameters plus the limiter block, a controller-type string, the lag delay actually used (the two schema keys are dead on the deployed host, Page 11) and, as a separate key, the planner's grid and cost weights. The panda firmware needs no new field: the 10 s panda tx: … verdict: verified line in /rosout asserts the board matched the profile's expected_firmware_hex.
  • Compiled constants leave no trace. The longitudinal PID gains and deadzone (long_control.cpp:102-104), the actuator-delay bounds 0.3 / 0.4 s (:106-107), the blended-MPC weights x 0.1 / v 0.2 / a 5 / Δa 40 / j 1 (longitudinal_mpc.cpp:249-251), the accel box [−3.5, +2.0] (longitudinal_mpc.hpp:83-84) and the ±0.5/v² curvature band (kMaxLateralJerk 5.0, lateral_curvature.hpp:53) have no schema key, are printed by no banner and reach no file: the banners carry the limiter rows, the vehicle model and the profile clamp, never a compiled value, so a rebuild that changes any of them is indistinguishable in the bag from the previous build. Print them in the controller's start-up banner in the same commit that moves them (so the Page 10 fingerprint sees the change) and let the params_hash below cover them (Page 11, levers 4–8).
  • Panda health is a log line, not a message. Every 10 s /rosout carries panda tx: LIVE | verdict: verified | … errors 0, heartbeat-errors 0 | … board: mode 29, controls_allowed 1, safety-blocked 0 — zero on every line of all ten laps — but the extractor does not read it, and the board's own heartbeat_lost, fault_status and the health packet's rx / tx error counters are read over USB every 500 ms and never printed. CAN integrity on this site is therefore inferred from frame timing, counters and checksums; publishing the health packet on /diagnostics would make it a metric.
  • Replan bookkeeping is log-only on AV2.5 and absent on AV2.0. On the packed path the model node counts triggers that land on a busy graph and reports them only as an RCLCPP_INFO line every 200 inferences — replan: 8.95 Hz mean over 200 cycles against a 10.00 Hz target (5 skipped, the graph still busy) — which the extractor does not read; the AV2.0 path keeps no count at all. This site reconstructs stalls from LateralPlan inter-arrival gaps ("Longest plan gap", the Page 10 plan-feed badge) and Page 11 quotes the log line by hand. Publish the per-cycle graph time, the busy-skipped and stale-camera skip counts and the in-flight flag on /diagnostics, so a plan gap can be attributed to the GPU, to scheduling or to the camera set.
  • Driver annotations. A take-over has a cause (CA DMV requires one); today it must be reconstructed from the cabin audio. EngagementStatus has alert_text1/2 fields that the controller never fills.

Proposed wing_msgs/ControlDebug at 100 Hz, plus three fields on ActuatorsOutput at 50 Hz and one latched StackId

std_msgs/Header header             # stamped like ActuatorCommand (vehicle/can_stamp), same update()
int64   frame_counter              # same chain as ActuatorCommand — the join key
# lateral — the controller's own terms (LagAdjustedCurvature + LatControlAngle)
float64 curvature_plan_now         # the clip centre: curvatures[0] while lateral_plan_age_compensation is off (every lap so far)
float64 curvature_desired          # lag-compensated, BEFORE the ±0.5/v² clip; the clipped value is ActuatorCommand.curvature
bool    curvature_jerk_clip_bound  # |curvature_desired − curvature_plan_now| > 0.5 / max(1, speed_ema)²
float64 speed_ema_mps              # what the lag adjust and the band divide by (speed_ema_alpha 0.15), not v_ego
float64 lag_delay_s                # steer_actuator_delay + extra_delay actually used (0.21 s hard-coded on the deployed host)
float64 steer_law_deg              # LatControlAngle's output every cycle, even under a chained steering override
bool    steer_ref_overridden       # a chained steering_angle_rad reference (joystick) replaced it in ActuatorCommand
# longitudinal — locals of LongControl::update today; nothing publishes them
float64 t_since_plan_s             # the plan age the PID read at
float64 v_target_now               # interp(t_since_plan, knots, speeds) — the PID setpoint (≡ this site's v_tgt)
float64 v_target                   # min(v at age + 0.3, v at age + 0.4) — the state machine's input, not the PID's
float64 v_target_1sec              # v at age + 1.4 s — decides planned_stop and the STOPPING → PID release
float64 a_target                   # the feed-forward: min over the 0.3 / 0.4 s window of 2(v_end − v_now)/Δt − a_now
float64 pid_p
float64 pid_i
float64 pid_d                      # always 0 (kd = 0 in the port) — carried so p + i + d + f is the unclipped sum
float64 pid_f                      # a_target × kf (kf = 1) while long_control_state == PID, else 0

# ── on wing_msgs/ActuatorsOutput (50 Hz, one per emitted transmit): the limiter and the
#    CAN encode step only on even 100 Hz frames, so their result is known only there
uint8   limiter_stage_bound        # 0 none, 1 slew, 2 ±200° clamp, 3 ±12° window, 4 driver-override follow (std mode); 5–9 the legacy stages
float64 accel_raw_can              # clip(accel × 18, −50, +30) counts; 0 on brake-hold
bool    accel_capped

# ── wing_msgs/StackId on /system/version — latched, once per run (Page 10's fingerprint)
string  software_commit            # git describe of the deployed wing-av (nothing produces one today)
string  profile                    # x50 / sim / …
string  params_hash                # sha over the parameter structs the nodes hold — not the yaml — plus the compiled constants above
float64 lag_delay_s                # the two schema keys are ignored by the deployed host, so the hash cannot prove the delay
float64 pid_accel_min, pid_accel_max, angle_offset_deg, steering_wheel_angle_bias, curvature_scale
bool    std_angle_limiter          # + std_rates_up/down_deg[3], std_max_angle_deg, std_max_angle_error_deg, driver_override_torque
string  panda_firmware_hex         # the signature the arming check compares against expected_firmware_hex
# planner: plan_horizon_s, plan_lat/long_intervals, knot spacing, path / motion / jerk / steering-rate costs

This is the Apollo SimpleLateralDebug / SimpleLongitudinalDebug idea sized for this stack. With it, the metrics on Page 2 need no re-derivation and the PID split becomes a metric too. Three rates, not one: the controller's terms go out at the ActuatorCommand rate and stamp; the limiter's verdict and the raw CAN accel exist only where the limiter steps (every second cycle — the bag shows /control/actuators at 10 ms and /vehicle/actuators_output at 20 ms with a 2:1 count on all ten laps, so a 100 Hz copy would repeat every odd frame), and ActuatorsOutput has no frame_counter — join the two on header.stamp, one cycle after the command it applied. Provenance is not a per-frame field: ActuatorCommand goes through a real-time publisher whose thread copies the whole message every tick, and two strings that never change do not belong there — they go once on the latched /system/version, the pattern /perception/model_info already uses. Fields that already exist were dropped from the proposal: steering_saturated, long_control_state and the feed-forward angle are on ActuatorCommand today.

Which metric needs which topic

● = the topic supplies a value the metric is computed from. Sample masks are not shown: lat_active / long_active (ActuatorCommand, /control/actuators; the lateral mask additionally drops samples where the intervention detector sees a hand on the wheel, MAIN_TORQUE on /vehicle/state) select the samples of every tracking, actuation, ride and plan metric, and v_ego < 0.3 m/s (/vehicle/state) opens the stop window. Disengagements per km keeps /control/actuators because lat_active defines its km denominator.

Page 8 of 11 · sources

How others measure it — quoted

Every metric family on this site traces to at least one of these. The numeric bands on the ride-quality and stop cards come from the cited regulations and studies; the tracking and actuation bands are this site's working choices (openpilot's 2.5° saturation threshold, used as the watch bound of "Steering: desired vs measured", excepted) and are marked as such on the cards. Italic quotes are verbatim from the linked page (fetched 2026-09-19); lines marked summary condense a table or a list from it. The arrow line is what we take from it.

Our metrics ↔ who uses the same

Page 9 of 11

Method, checks and caveats

Pipeline

  1. Read the essentials MCAP of each lap (read-only) with mcap-ros2-support: the thirteen topics on Page 7 that feed the extractor (twelve message topics plus the /rosout start-up banners; /diagnostics is recorded but not read).
  2. Resample everything onto a 50 Hz grid keyed on header.stamp — linear for continuous signals, zero-order hold for commands, flags and plans (plans are read at their age, exactly as the controller reads them).
  3. Derive: a = d(v_ego, 0.3 s smoothed)/dt; jerk = d(a, 0.3 s)/dt; a_lat = v·ω_z; κ_meas = ω_z / v (v > 1 m/s); steering rate from the angle (0.1 s).
  4. Place every PredictedTrajectory in the map frame with the odometry pose at its img_tstamp (the points are absolute base_link displacements in that frame) and compare point τ with the odometry at header.stamp + τ — trajectory_node samples the fitted curve at image time + τ + (header.stamp − image time), so the first point is already "now" (Page 3). Horizons 0.5 / 1 / 1.5 / 2 s.
  5. Label situations, cut episodes, compute per-episode metrics, aggregate duration-weighted per situation, per lap, per model.
  6. Driver interventions (interventions.py, a port of the team's wing_interventions detector, itself wing_sense's): steering from the signed column torque MAIN_TORQUE — clip(τ + 25, ±350) → 0.1 s moving average ÷ 2 → |·| → 0.3 s median → > 50 → 0.21 s dilation, i.e. trip points τ > +75 / τ < −125 — which sees a hand resting on the rim that openpilot's |DRIVER_EPS_TORQUE| > 6 flag misses (lap 08:50: 3 steering episodes, 18 s, against 0 steering_pressed events); gas > 1 % and brake > 1 % of pedal travel (brake not at a standstill); gaps under 0.2 s closed. The flags drive the driver rows of the timeline, the pills in the 3-D HUD and the state card; the episode list (0–30 s, outside the first/last 10 s) is in each lap's JSON. The detector runs at 100 Hz on a zero-order hold of /vehicle/state, where it is bit-identical to the team's package (a 50 Hz run halves every window's noise rejection).
  7. Cross-correlate command and response derivatives for lags; check CAN counters, checksums and periods.
  8. Downsample the series to 10 Hz for the charts; keep 25 Hz excerpts (every 2nd grid sample) of the representative episodes for the 3-D replays.
  9. Lidar map and relief (build_pcl_map.py, one map per lap, the "lidar map" picker in every 3-D view): each lap's ~3,100 Livox HAP scans (/sensing/lidar/points/soa_zstd, ~140 M points) are placed with the pose interpolated at each point's own timestamp through the URDF lidar mount — x, y, yaw from the (2-D) odometry, roll and pitch from the INS with the mount offsets removed, and the road height from the pitch integral pinned to the ekf_nav altitude where the GNSS was actually aiding it (39–55 % of each lap; 120 s low-pass) and loop-closed (a lap that ends where it started ends at the height it started; the closure error of up to 2.3 m is spread over the lap) — gated to 1.5–60 m range, −1.5…+7 m height and v > 0.5 m/s, then voxelised, each voxel keeping the mean reflectivity and the mean position of its returns (so a sloping road stays a continuous surface instead of 0.2 m terraces): 0.2 m within ±0.6 m of the terrain surface inside 10 m of the recorded tracks, 0.4 m for everything else (~2.9 M voxels, ~27 MB per lap). The terrain itself is the 15th percentile of the lowest solid voxel per column on a 2 m grid (a low quantile so overhanging foliage does not lift it), median-filtered and diffusion-filled beyond the lidar's reach; it is embedded for every lap and lifts the car, the ribbon, the plans and the draped satellite image. One map per lap because the laps' odometry frames are co-registered to GNSS only to 1–3 m — a lap shown on another lap's map sits beside the road. The lidar road comes out 0.3 m below base_link, i.e. the URDF lidar height is ~0.3 m short; the car is drawn with its wheels on the lidar road. Residual: the road is scanned from both directions only around the parking spot (first and last seconds), where the two passes disagree by up to ~1 m on three laps (08:56, 09:35, 09:41) — a doubled road surface for a few seconds at the lap start; elsewhere the car's own pass sits within 0.3 m of the terrain (p99) on every lap.

Checks performed

  • Odometry yaw rate vs SBG gyro: r = 0.9997 on lap 08:50 (logs/attitude_verify.log); vs d(yaw)/dt: 0.95 (quantised heading).
  • κ from yaw rate vs κ from the steering angle through the bicycle model: slope 1.012, r = 0.995 — the vehicle model (L = 2.72 m, ratio 16.36) is consistent with the car.
  • d(v_ego)/dt vs the interface's a_ego (Kalman): r = 0.94; both agree on the ±2 m/s² envelope.
  • steering_desired vs ActuatorCommand.curvature: r = −0.999 in every lap (sign flip confirmed, so κ_cmd = −curvature is left-positive).
  • CAN: 14,700–18,600 tx frames of 482 and 814 per lap (15,497 on lap 08:50) at a 20.0 ms median period, 0 counter gaps, 0 checksum failures on every lap; rx 287 at 10 ms.
  • Steering lag measured three ways agrees to within one 20 ms frame on nine laps: sent→measured 40 ms on every lap, desired→measured 60–80 ms (80 ms on five laps; on lap 09:21 the desired-vs-measured correlation is too weak, r = 0.36, to place a peak), wire 482→287 40–60 ms (40 ms on six laps; 60 ms on lap 08:50) — the 100 Hz command is transmitted on even frames only.
  • The "human" cross-track is computed against the model's plan too — it is not a tracking error for the driver, it says how far a human drives from where the model would have gone.
  • Anchoring check (verified independently on the bags): with the pose at the image time and the delay applied, the first published point lands 0.06 m (AV2.5) / 0.14 m (AV2.0) from the recorded pose at publish time; anchoring at publish time instead misses by 0.95 / 0.30 m, and ignoring the delay adds ~1 m of along-track error at 1 s.
  • INS attitude signs (gravity test at standstill): atan2(ay, az) of the accelerometer equals the INS roll (slope 1.0) and atan2(ax, az) equals −pitch, i.e. REP-103 (roll + right side down, pitch + nose down). The INS pitch carries a constant nose-down mount offset (the loop is closed, so the height integrated from v·sin(pitch) must equal the altitude gained between start and end — with the raw pitch it ends ~60 m too low): removed per lap, after which the height integrated from the pitch matches the ekf_nav altitude to m RMS over m of relief. The roll reads a constant ≈ −2° (left side down) on every straight and at the parking spot — mount tilt and/or the camber of the left lane, which one lap direction cannot separate — and that constant is removed too; the roll shown is the body roll about the road.

What the data cannot tell you

Stops. The drivers disengaged before nearly every standstill; automated stop episodes exist in only . The stop metrics are implemented and shown on the manual stops as a baseline, but no fleet conclusion about automated stopping is possible from these laps.

One route, one day, one car. Ten laps of the mosque loop are enough to demonstrate the metrics and to compare AV2.0 with AV2.5 on that loop, not to set fleet thresholds. The regression rule on Page 6 uses ±2 MAD for illustration; a real band needs weeks of laps.

Plan-vs-execution ≠ pure tracking. The horizon cross-track includes plan changes. Read it with the plan-jitter column, and prefer the setpoint-level errors (κ, v, a) when attributing to the controller alone.

Human baseline is a different task. The drivers were driving and supervising; the manual segments are mostly the start of the lap and the recoveries after take-overs, which are not neutral samples of the road. Use the comparison for order-of-magnitude only.

Acceleration source. Longitudinal acceleration and jerk come from the wheel-speed v_ego, not from the IMU; jerk peaks above ~5 m/s³ should be read as "the speed signal moved fast", and the 3-D replay shows the IMU's own reading next to it.

No lane reference. Without a map or a surveyed centreline there is no "distance to lane marking"; all tracking here is relative to the model's own plan, which can itself be off the lane.

Provenance

Reproduce

cd /mnt/w/wingfin/docs/wing-av/wing-av-control-metrics-src
# one lap (read-only on the bags; needs a venv with mcap, mcap-ros2-support, numpy)
python extract_metrics.py 085042
# all ten, logged
./run_all.sh 2>&1 | tee logs/extract.log
# rebuild the site
python build_data.py && python assemble.py ../wing-av-control-metrics.html
Page 10 of 11 · the proposal

A "Control" section for the Drive Live Tracker

The tracker at data.wingfins.com scores every uploaded bag for the model (km per intervention, best run, S / B / G). This page proposes a fourth Control view next to it: one control scorecard per bag, computed post-drive from the essentials bag by the same job that made this site, grouped by controller build rather than by model, in the tracker's own layout — summary cards, "By controller build", "By planner configuration", "By month", "Runs", and a definitions footer. Below it is mocked on the ten 2026-09-15 laps, which are the tracker's 16:50–17:49 rows of that day.

The four questions, answered

A
How do we know the controller executed the plan?

By comparing three things that live in different places: what was planned (PredictedTrajectory placed in the map), what was commanded (ActuatorCommand / ActuatorsOutput) and what happened (odometry, VehicleState). Cross-track / heading error at 1–2 s, curvature / speed / acceleration error against the setpoint, sent-vs-measured steering and lag — Pages 1–2, 36 metrics with formulas and bands.

B
Can we measure it per situation?

Yes: laps are labelled straight / curve / sharp from the curvature (the larger of commanded and measured while the lateral loop is active, measured otherwise) and cruise / decel / accel / stop from the speed profile, and every metric is aggregated per situation with a human baseline — Page 4. Stops are the gap: no lap has an automated stop yet, so that column is human-only.

C
Can we monitor it continuously?

Yes, as a post-drive batch job on the essentials bag (~3 min per lap on one core, no GPU, deterministic), keyed by vehicle / commit / model / route, with a rolling-median regression check — Page 6. This page is what that job feeds the tracker.

D
Which topics and metrics to collect?

Thirteen recorded topics cover everything here (twelve message topics plus the /rosout banners that carry the controller fingerprint; /diagnostics is recorded but unused); the gaps are a /control/debug message (PID terms, limiter stage, raw CAN accel), a route reference for lane-centre metrics, and a controller fingerprint in the bag — Page 7 and the section below.

The Control view

The tracker's view chips would gain a fourth entry — All monthlyDailyModelsControl — that answers a different question from the Models view: not which checkpoint drives best but how well does the control stack execute whatever plan it is given, across models and routes. So its grouping key is the controller build (the fingerprint below), the model becomes a column of the runs table, and the planner configuration gets its own breakdown. Route, Range and Class filters work as they do today.

Mocked on the ten laps. Local time (UTC+8), route and model from run.yaml; the controller build and planner config are read from the /rosout start-up banners because nothing structured for the controller is in the bag yet (the model's identity and knot schedule are, on the latched /perception/model_info — Page 7). Scores are 0–100 (rule below; A ≥ 90, B ≥ 75, C ≥ 60), raw cells are coloured by their catalogue band; clean / watch / degraded is the plan feed badge — the quality of the plans the controller was given, never a verdict on the drive (a lap can be clean and score D). Click a run to open it in the explorer.

By controller build

One build drove all ten laps — the same std-angle limiter, vehicle model and delay compensation — so the ten scores are ten samples of one controller, not a comparison between two. The banners behind the fingerprint:

By planner configuration

The planner did change between the two models: the AV2.5 laps ran a 10 s / 32-interval lateral-MPC horizon (the planner's own warning), the AV2.0 laps the deployed 2 s grid. This split is why the Control view keeps the planner separate from the controller: the same controller scored 89 behind one planner setting and 82 behind the other, and the Models view cannot show that.

By month

Reading T · A · R · S

The four letters are the layer scores, one per question along the control chain; the control score is their weighted mean and the grade is on that. Each is 0–100 and means the same thing everywhere on this page: 100 = every metric in the layer is inside its ok band; 60 = the metrics sit at their watch bound on average; 0 = beyond twice the watch bound. A layer is the plain mean of its metrics' scores, so one bad metric can pull a layer down while the others are fine — the card below each letter names the metrics, and the worked example opens on the lap with the lowest score of the ten, metric by metric — pick any other lap from its menu.

What a value means

layer scorereads ascolour
90–100every metric inside, or nearly inside, its ok bandgreen
75–89most metrics ok, one or two in the watch bandpale green
60–74the layer sits around its watch bounds — look at which metricamber
< 60at least one metric is well beyond its watch boundred

The same colours are used for the total and for the grade thresholds (A ≥ 90, B ≥ 75, C ≥ 60). A metric with too little exposure (< 60 s lat_active / < 30 s long_active) is left out of its layer rather than scored; a layer with nothing left is dropped and the remaining weights renormalised.

Worked example

Runs — 10

Definitions (the footer the tracker would carry). Every figure is over the engaged drive of the bag: lateral metrics while lat_active and no hand on the wheel (the intervention detector's steering flag is masked out — a driver correction is not the controller; the stack's own flag stays in lat_active_stack and hands_on_s in the record says how much engaged time was hands-on), longitudinal while long_active, no window, no end cuts (S / B / G excepted: the detector drops episodes ≥ 30 s and any inside the first / last 10 s of the bag). control score = 0.40 tracking + 0.20 actuation + 0.25 ride + 0.15 supervision, each layer the mean of its metrics' band scores (rule below); a metric whose loop had too little exposure (< 60 s lat_active for lateral metrics, < 30 s long_active for longitudinal ones) is left out of its layer's mean, not scored as bad; a layer with no metric left is dropped and the remaining layer weights renormalised; a run whose plan-feed metrics are all missing shows unscored. grade A ≥ 90, B ≥ 75, C ≥ 60, D below. Raw metric cells (ct@1s, e_v, lag, lat acc, diseng) are coloured by their Page 2 band — ok / watch / beyond — the same rule as the catalogue cards and the Page 4 heat map; diseng is coloured by its per-engaged-km rate. plan feed is the quality of the plans the controller was given — plan jitter @1 s, MPC-invalid share, plan age, longest plan gap — shown as a badge and kept out of the score: clean = all four inside their ok band, watch = one in its watch band, degraded = one beyond it. It is not a pass / fail of the drive: clean means the controller was given a fair plan, so a low score is the control chain's to own; a degraded run is one where the tracking numbers are partly the planner's. T / A / R / S are the layer scores. ct@1s is the plan-vs-execution cross-track RMS at 1 s, per lateral situation (straight / curve / sharp). e_v speed error RMS, lag sent→measured steering lag, lat acc the lap's peak lateral acceleration while lat_active (coloured by its band), diseng falling edges of enabled, S / B / G steering / brake / gas intervention episodes that begin while enabled, from the team's wing_interventions detector (MAIN_TORQUE filter, pedals > 1 %); the tracker's own S / B / G counts steer differently (a controller override with no pedal down), so its steer counts are lower — one rule should be adopted for both views. int% intervention time over engaged time, engaged m:ss, km the lap distance. In the group rows (controller build / planner configuration / month) the score is the mean of the runs' scores and ct@1s / e_v / lag are unweighted lap means (Page 6's by-model figures are exposure-weighted and can differ in the third decimal); controller build = the fingerprint of the limiter, vehicle-model and delay-compensation banners, planner = the MPC-horizon banner. The record also carries human — the same lap's manually-driven stretches (v > 2 m/s with lat_active off for the lateral ones, v > 1 m/s with long_active off for jerk), the driver baseline for the ride metrics and the one exception to the engaged-only rule — and stop_n, automated stop episodes (≥ 80 % long_active): none on these laps. loop · exposure (scoring table): the activity flag a metric is measured under and its exposure threshold.

The scoring rule, metric by metric

Each metric is scored from its Page 2 bands: 100 inside the ok band, falling linearly to 60 at the watch bound and to 0 at twice the watch bound. Bands tagged on Page 2 as a regulation / benchmark (11 edges) or a constant of the deployed stack (7) are the only ones with an authority outside this site; the other 52 are working choices, so a score compares runs of the same route with each other — it is not a certification. Columns: the ten laps by time.

Tracking the controller itself — MPC today, something else tomorrow

The metrics do not care which controller produced the command: they compare the plan with the executed path and the command with the measured response, so an LQR, a pure-pursuit or a learned controller would be scored on the same scale as today's chain (the lateral MPC lives in wing_planner; wing_controller turns its curvature plan into an angle request through the angle controller, and the vehicle interface's std-angle limiter bounds what reaches the wire). What the tracker cannot do today is tell two controllers apart: the bag records the model name and its knot schedule (structured, on the latched /perception/model_info) and, only as free text in the /rosout start-up banners, the limiter mode, the steer ratio and the MPC horizon — nothing structured for the controller, no commit, no parameter hash. It matters already: the five AV2.5 laps ran the 10 s / 32-interval lateral MPC horizon of av2.5.yaml (the wing_planner warning is in their /rosout), the five AV2.0 laps the 2 s grid, so the model comparison above is also a planner-configuration comparison. The proposal therefore adds a controller fingerprint to every run, and the section groups by it exactly as the Models section groups by checkpoint:

Cheapest way to get them into the bag: the launch layer writes them into run.yaml next to route / kind / model, and publishes them once on a latched /system/version topic that the essentials recorder keeps. Then "By controller" is a third grouping next to "By model" and "By month", and a controller change shows up as a new row with its own mean score, not as a mystery jump in a model's curve. The only MPC-specific items on this page are two plan-feed metrics (mpc_solution_valid, plan age from LateralPlan); a planner without an MPC would report its own validity flag under the same name.

Was it the controller? — attributing a change

A score that drops tells you that something changed, not what. Three things make the cause readable:

1 · Boundaries, not trends

With the fingerprint in every record, a change of controller commit, parameter hash or profile is a boundary in the run list. The section compares the runs after it with the runs before it on the same route and model (never across models — the AV2.0/AV2.5 comparison above already hides a planner-horizon change), using Page 6's rolling median ± MAD as the "before" band. A boundary with no metric moving is a harmless change; a metric moving with no boundary is not the controller — look at the model, the route, the driver or the day.

Protocol that makes this cheap: when a controller or setting changes, drive the old and the new interleaved on the same route and model, ≥ 3 laps each (the 15 Sep session did exactly this for two models). Per-situation columns then show whether the change is in curves, straights or stops.

2 · The layers point at the cause

The four layers are ordered along the chain, so the pattern of which moved is the diagnosis:

what movedwhat stayedmost likely
plan feed (plan jitter, plan age, plan gaps)actuationplanner / model / compute load — not the controller
tracking (ct@1s, κ error)actuation (sent ≈ measured, lag)the control law or its tuning: the EPS did what it was told, the request was wrong
actuation (clip share, sent−measured, lag)plan feedlimiter / vehicle-interface settings or the EPS itself (slew table, ±12° window, delay)
ride (jerk, reversals) with tracking flattrackinggains too aggressive — a tuning change traded comfort for error
supervision (S / B / G, disengagements)everything elsedriver confidence or route conditions, not the controller
everything, on every route—a stack-wide change (timing, CAN, sensor) — check Page 7's rates and the CAN integrity counters first

3 · Replay the same plans through both controllers

The decisive test needs no new drive: the bag holds every plan the model published and every vehicle state, so the old and the new controller can be run offline against the same recorded inputs (open-loop: plan + VehicleState in, ActuatorCommand out) and their command traces compared second by second — the same plan, two commands. A difference there is the controller by construction; if the commands are identical, the on-road change was somewhere else. This is comma's maneuver-report logic applied to a whole lap, and it is the one comparison a scorecard cannot replace: on the road the model, the driver and the day always change with the controller.

How the server would compute it

The job

  • Trigger: _UPLOADED.json for a new <drive>/essentials bag (48–123 MiB per lap).
  • Run extract_metrics.py <drive> (this site's extractor; ~3 min per lap on one core, read-only on the bag, no GPU, deterministic) → the per-lap JSON; the scorecard is about 1.5 kB derived from its overall, situations and intervention_summary blocks — the record on the right.
  • Write control.json next to the bag and expose it as /api/control (runs) and /api/control/models (grouped), the shape the tracker already uses for /api/models.
  • Keep the full per-lap JSON (2.7–5.3 MB) for the drill-down: the tracker's run row links to this site's explorer for that lap.
  • Pinned script version and detector config in every record (extractor, detector), as the tracker already prints Detector config 8f3620a861b6.

The record (best run of the ten; stack is empty because the bag has no controller fingerprint yet)

What the score is not. It is a within-route, within-fleet comparison: 52 of the 70 band edges are this site's working choices (Page 2 tags each one), the regulatory ones are applied to slightly different quantities than the regulations define, and a lap's tracking score is partly the planner's (hence the plan-feed badge). Read the layer scores and the raw columns, not only the total — and re-cut the bands once the tracker has a few weeks of runs, from the fleet's own distribution.

Page 11 of 11 · what to turn

Tuning levers — every parameter of the control chain, and the hardware behind it

Page 10 says how a lap scores; this page says what in the software could move that score, where it lives, what it does today and which way each metric goes when it is turned — and where the car, a sensor or the computer, not a number in a yaml, is the limit. It is built from an inventory of … parameters and … hardware limits across the six subsystems of the chain, each entry checked against wing-av 3.5.0 @ 52d552e7 at the cited line and against the ten lap JSONs; … are worth tuning, … maybe, … are bounded by hardware. The shortlist first, then the metric → lever matrix, the full inventory and the hardware ceilings.

What "expect" means here. Every effect on this page is the direction and the mechanism, read from the code and from the lap statistics — not the result of an A/B on the car. The sim numbers quoted were measured on a ground-truth centreline rig, and the on-road AV2.0 / AV2.5 split changes the model and the planner grid together. The size of any effect is what the interleaved-lap protocol at the bottom has to establish; the Page 10 layers are how to read the result.

How a value reaches the car

The layers, in the order they are merged

autonomy.launch.py (:244-268) stacks the yaml layers and hands the merged block to each node: schema default (config/schema/*.yaml) → common.yaml → network.yaml → vehicle byd_atto3.yaml → model yaml (av2.0.yaml / av2.5.yaml) → profile x50.yaml → the per-run test-plan overlay (only the keys listed under mapped: in plan_config_map.yaml; anything else is refused, nothing accumulates). A key that no layer sets runs at its schema default — which is how all four lateral MPC weights ran on 15 Sep.

Two things sit outside that stack. The ros2_control hardware component reads its values from the URDF (vehicle_interfaces.yaml, baked at build) with ros2_control.* profile keys substituted in where the parameter is declared substitutable — the limiter table, the clamps, the override torque. And compiled constants — the longitudinal PID gains, the blended MPC weights, the ±0.5/v² band, the accel box, the CAN encoding — change only with an image rebuild. Below them are the DBC and the panda firmware, which are the car's.

What ran on the ten laps

stagein force on 2026-09-15
lateral controlleropenpilot LatControlAngle port, open loop (no gain, no integrator), 100 Hz in the ros2_control host; look-ahead 0.01 + 0.2 s hard-coded; deviation band ±0.5/v²; vehicle model steer_ratio 16.36, slip −0.00255, roll 0
std-angle limiterup 2.5 / 1.5 / 0.4, down 3.75 / 2.25 / 0.9 °/msg at 0 / 5 / 25 m/s; clamp ±200°; windup ±12°; hands above 80 counts — the banner on every lap
longitudinalLongControl PID kp 2.2 / 2.0 / 1.8, ki 0.45 / 0.4 / 0.32, deadzone 0, delay 0.3–0.4 s, clamp [−3.5, +2.0]; blended MPC (experimental_mode true), accel box [−3.5, +2.0], weights x 0.1 / v 0.2 / a 5 / Δa 40 / j 1
wireframe 482 STEER_ANGLE 0.1°/bit at 50 Hz; frame 814 ACCEL_CMD = clip(a × 18, −50, +30) = [−2.78, +1.67] m/s² at 50 Hz
planner, AV2.5 laps10 s / 32 lateral / 12 longitudinal intervals, quadratic knots, resample on; path 20 · motion 0.11 · accel 0 · jerk 0.04 · steering rate 350; B-spline fit, 1 s breakpoints
planner, AV2.0 laps2 s / 4 / 4, uniform, no resample (schema defaults on a 4-waypoint graph); same weights; cubic fit
model / timingAV2.5 av33 on TensorRT fp32, every 2nd camera set (10 Hz), ~104 ms inference age; AV2.0 epoch49 on CUDA every frame, lidar-synchronised 10 Hz, 56–73 ms; plan age at the command ~50 ms; control loop FIFO 50, model container normal priority
sensingSBG INS + ekf 50 Hz, single-antenna GNSS no RTK; wheel speed 0.019 m/s per count, no IMU fusion; cameras 20 Hz host-stamped; Livox HAP 10 Hz median-stamped

Start here — twelve levers, ranked

Ranked by how much of the laps' out-of-band error each one owns, then by how cheap it is to try. Each card names the file and line, the value in force, which metrics it moves (chips coloured by the worst lap on the ten), what to expect when it is turned, the lap evidence (computed live from the embedded metrics) and the trade-off — every lever here moves at least one metric the other way, so score the whole card, not one number. Tags: profile edit and restart, per-run overlay through the test plan, compiled rebuild, hw-bound capped by the vehicle or a sensor.

Looks like a lever, is not

parameterwhy to leave it

Metric → lever

The other direction: for each catalogue metric, every inventory entry that cites it, ordered worth-tuning → maybe → leave. The two dots are the worst lap of each model against the metric's Page 2 band.

The full inventory

Every entry: its name, where it is read (file:line at 52d552e7), the value in force on the laps and its source, the metrics it moves, and — click a row — what it is, what to expect either way and the caveat. The worth tuning / maybe / leave tag is the inventory's own verdict; the cost tag is read from the source of the value. hw-bound marks entries whose reach is set by the vehicle, a sensor or the computer.

Where the hardware is the limit

Some metrics cannot be moved by any number in the tree because a component of the car, a sensor or the computer sets the floor. The table is the short version — what each ceiling is, what was measured on this car, which metrics it caps, what software can still do in front of it, and what it would take to raise it. The … inventory entries behind it follow, one per subsystem view.

At road speed the steering rate is software-bound: the slew table allows 67 °/s up at the laps' ~8 m/s against a rack that can do ~150 °/s. The longitudinal side is the reverse — the controller assumes a 300–400 ms actuator the bags measure at 140–180 ms, and asks for +2.0 m/s² on a wire that carries +1.67. The two cheapest wins on this page are therefore agreements between software and hardware (levers 4 and 6), not new hardware.

Running a tuning experiment

Protocol

  1. One lever per experiment, and read its cost tag: mapped keys (st_rate_cost, lateral_motion_cost, OP_steerRatio, OP_slipFactor, do_desired_curvature_clip) go through the test-plan overlay and vanish after the run; limiter rows, clamps and the override torque are x50.yaml lines; the grid, the fit and fp16 are model-yaml lines; the PID gains, the delay bounds, the MPC weights and the accel box need an image built to your own tag.
  2. Interleave old and new on the same route and model, ≥ 3 laps each, same driver and day where possible — the 15 Sep session did this for two models (Page 10).
  3. Check the plan feed first: if plan jitter, plan age, the longest plan gap or MPC validity moved, the lever moved the planner or the compute, and the tracking numbers are not the controller's to compare.
  4. Read the layer pattern (Page 10's "what moved / what stayed" table), then the per-situation columns (Page 4): a limiter change shows in sharp turns and the actuation layer; a PID change in decel / accel episodes and the ride layer.
  5. For controller-only levers, replay first: the recorded plans and vehicle states run through both controllers offline (Page 10, §3) before a wheel turns.
  6. Leave a trace: until run.yaml carries the controller fingerprint, the /rosout banners are the only record — the limiter banner prints its rows, so a limiter change is visible; a compiled gain is not. Print the values you change in the start-up banner in the same commit.

What a good result looks like

The Page 10 score is 0.40 tracking + 0.20 actuation + 0.25 ride + 0.15 supervision, so the arithmetic favours levers that move tracking without spending ride — and every lever above trades between the two: path_cost and the slew rows buy cross-track with lateral jerk, kp buys speed error with longitudinal jerk. Two kinds of change escape the trade:

  • agreements — clamps that match the wire (lever 4), a delay model that matches the measured actuator (lever 6), keys that are actually read (lever 8): these remove error that no layer wanted;
  • plan quality — a fresher, smoother plan (levers 9 and 11) lowers the plan-feed metrics and gives the same controller less to chase.

After those, the honest expectation is a different balance rather than a better total until the bands are re-cut from a few weeks of fleet runs — which is why the scorecard shows the layers and the raw columns, not the total alone.

The inventory is wing-av-control-metrics-src/research/parameters.json — six subsystem passes (lateral controller, longitudinal controller, planner, vehicle interface, model-to-trajectory timing, vehicle and sensor hardware), each written from the checkout at 52d552e7 and then re-checked entry by entry: every file re-opened at the cited line, every lap figure recomputed from data/metrics_<drive>.json, and the lateral chain re-run in Python against all ten bags (it reproduces the commanded curvature to ≤ 1.8e-6 m⁻¹, which pins the deployed constants beyond the banners). Corrections made in that pass are kept in the entries' text ("CORRECTED …"). The checked-out tree is the analysis copy, not the car's.

wing-av control metrics · built 2026-09-19 from wing-av main @ 52d552e7 (v3.5.0) and the ten essentials bags of 2026-09-15 · generated from wing-av-control-metrics-src (extract_metrics.py → build_data.py → assemble.py)