feat: car_grip use case — envelope extraction grip analysis

- New UseCase::CarGrip variant with race-pace guidance
- analyze_car_grip(): speed-binned envelope (peak lateral/accel/brake G),
  grip circle scatter, steering response scatter
- Envelope extraction: 10 km/h bins, max |G| per bin
- Scatter downsampled to ~800 points
- Dispatch wired in analyze()
- 33 tests pass
This commit is contained in:
Jan-Peter von Hunnius 2026-06-24 10:18:23 +02:00
parent 3985e81cdd
commit 652d059b1d
2 changed files with 190 additions and 0 deletions

View file

@ -717,6 +717,185 @@ fn detect_corners(curvature: &[f64]) -> Vec<Corner> {
corners
}
// ---------------------------------------------------------------------------
// Car Grip Analysis (envelope extraction)
// ---------------------------------------------------------------------------
/// Speed bin width for the grip envelope (km/h).
const GRIP_SPEED_BIN: f32 = 10.0;
/// Maximum speed bin center (km/h).
const GRIP_MAX_SPEED: f32 = 360.0;
/// Target number of downsampled scatter points per dataset.
const GRIP_SCATTER_TARGET: usize = 800;
#[derive(Debug, Clone, Serialize)]
pub struct GripEnvelopePoint {
pub speed_kmh: f32,
pub max_lateral_g: f32,
pub max_accel_g: f32,
pub max_brake_g: f32,
pub sample_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct GripCirclePoint {
pub longitudinal_g: f32,
pub lateral_g: f32,
}
#[derive(Debug, Clone, Serialize)]
pub struct SteeringResponsePoint {
pub steer: f32,
pub lateral_g: f32,
pub speed_kmh: f32,
}
#[derive(Debug, Clone, Serialize)]
pub struct GripSummary {
pub max_lateral_g: f32,
pub max_accel_g: f32,
pub max_brake_g: f32,
pub max_combined_g: f32,
pub peak_lateral_speed_kmh: f32,
}
#[derive(Debug, Clone, Serialize)]
pub struct CarGripAnalysis {
pub summary: GripSummary,
pub grip_envelope: Vec<GripEnvelopePoint>,
pub grip_circle: Vec<GripCirclePoint>,
pub steering_response: Vec<SteeringResponsePoint>,
pub runs_analyzed: usize,
pub total_frames_analyzed: usize,
}
pub fn analyze_car_grip(runs: &[Vec<TelemetryFrame>]) -> CarGripAnalysis {
let num_bins = (GRIP_MAX_SPEED / GRIP_SPEED_BIN) as usize;
// Per-bin trackers: (max_lat, max_accel, max_brake, count)
let mut bins: Vec<(f32, f32, f32, usize)> = vec![(0.0, 0.0, 0.0, 0); num_bins];
// Scatter accumulators (before downsampling)
let mut circle_raw: Vec<GripCirclePoint> = Vec::new();
let mut steer_raw: Vec<SteeringResponsePoint> = Vec::new();
// Summary trackers
let mut max_lat = 0.0f32;
let mut max_lat_speed = 0.0f32;
let mut max_accel = 0.0f32;
let mut max_brake = 0.0f32;
let mut max_combined = 0.0f32;
let mut total_frames: usize = 0;
let mut runs_analyzed: usize = 0;
for run in runs {
if run.is_empty() {
continue;
}
runs_analyzed += 1;
for f in run {
// Filter: skip pit, very low speed
if f.is_in_pit || f.speed_kmh < 5.0 {
continue;
}
let lat = f.acc_g_horizontal.abs();
let long = f.acc_g_frontal;
let accel = if long > 0.0 { long } else { 0.0 };
let brake = if long < 0.0 { long.abs() } else { 0.0 };
let combined = (lat * lat + long * long).sqrt();
total_frames += 1;
// Update summary
if lat > max_lat {
max_lat = lat;
max_lat_speed = f.speed_kmh;
}
if accel > max_accel {
max_accel = accel;
}
if brake > max_brake {
max_brake = brake;
}
if combined > max_combined {
max_combined = combined;
}
// Update speed bin
let bin_idx = ((f.speed_kmh / GRIP_SPEED_BIN) as usize).min(num_bins - 1);
let b = &mut bins[bin_idx];
b.0 = b.0.max(lat);
b.1 = b.1.max(accel);
b.2 = b.2.max(brake);
b.3 += 1;
// Accumulate scatter points (only for significant cornering/braking)
if lat > 0.3 || long.abs() > 0.3 {
circle_raw.push(GripCirclePoint {
longitudinal_g: long,
lateral_g: f.acc_g_horizontal,
});
}
if f.steer.abs() > 0.02 && lat > 0.1 {
steer_raw.push(SteeringResponsePoint {
steer: f.steer,
lateral_g: f.acc_g_horizontal,
speed_kmh: f.speed_kmh,
});
}
}
}
// Build envelope from bins (only include bins with data)
let grip_envelope: Vec<GripEnvelopePoint> = bins
.iter()
.enumerate()
.filter(|(_, b)| b.3 > 0)
.map(|(i, b)| GripEnvelopePoint {
speed_kmh: i as f32 * GRIP_SPEED_BIN + GRIP_SPEED_BIN / 2.0,
max_lateral_g: b.0,
max_accel_g: b.1,
max_brake_g: b.2,
sample_count: b.3,
})
.collect();
let grip_circle = downsample(circle_raw, GRIP_SCATTER_TARGET);
let steering_response = downsample(steer_raw, GRIP_SCATTER_TARGET);
CarGripAnalysis {
summary: GripSummary {
max_lateral_g: max_lat,
max_accel_g: max_accel,
max_brake_g: max_brake,
max_combined_g: max_combined,
peak_lateral_speed_kmh: max_lat_speed,
},
grip_envelope,
grip_circle,
steering_response,
runs_analyzed,
total_frames_analyzed: total_frames,
}
}
fn downsample<T: Clone>(items: Vec<T>, target: usize) -> Vec<T> {
if items.len() <= target {
return items;
}
let step = items.len() as f32 / target as f32;
let mut result = Vec::with_capacity(target);
let mut idx = 0.0f32;
while (idx as usize) < items.len() {
result.push(items[idx as usize].clone());
idx += step;
}
result
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
@ -735,6 +914,14 @@ pub fn analyze(
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}))
}
}
UseCase::CarGrip => {
if frames_by_run.is_empty() || frames_by_run.iter().all(|r| r.is_empty()) {
serde_json::json!({"error": "no frames provided"})
} else {
serde_json::to_value(analyze_car_grip(frames_by_run))
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}))
}
}
UseCase::TrackMapping => {
serde_json::to_value(analyze_track_map(frames_by_run, run_labels))
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}))