diff --git a/src/analysis.rs b/src/analysis.rs index 67642ae..6371942 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -717,6 +717,185 @@ fn detect_corners(curvature: &[f64]) -> Vec { 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, + pub grip_circle: Vec, + pub steering_response: Vec, + pub runs_analyzed: usize, + pub total_frames_analyzed: usize, +} + +pub fn analyze_car_grip(runs: &[Vec]) -> 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 = Vec::new(); + let mut steer_raw: Vec = 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 = 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(items: Vec, target: usize) -> Vec { + 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"})) diff --git a/src/recording.rs b/src/recording.rs index 31ba44f..839e827 100644 --- a/src/recording.rs +++ b/src/recording.rs @@ -128,6 +128,7 @@ impl RingBuffer { #[serde(rename_all = "snake_case")] pub enum UseCase { ShiftPoints, + CarGrip, RacingLine, BrakingAnalysis, LapTimes, @@ -140,6 +141,7 @@ impl UseCase { pub fn all() -> Vec { vec![ UseCase::ShiftPoints, + UseCase::CarGrip, UseCase::RacingLine, UseCase::BrakingAnalysis, UseCase::LapTimes, @@ -151,6 +153,7 @@ impl UseCase { pub fn guidance(&self) -> &'static str { match self { UseCase::ShiftPoints => "Record one run per gear: accelerate at full throttle from as low an RPM as possible to the rev limiter (or until you run out of road). Each run should be in a single gear only. Use Hotlap mode on a long straight. Start recording a run just before going WOT, stop after reaching max RPM. Repeat for each gear.", + UseCase::CarGrip => "Drive 3–5 laps at race pace. Brake hard, carry speed through corners, and accelerate aggressively out of slow corners. The harder you push, the more complete the grip profile. Different corner types (hairpins, sweepers, high-speed) help fill the curve across the speed range.", UseCase::BrakingAnalysis => "Record runs with heavy braking into corners from high speed.", UseCase::LapTimes => "Record one or more complete laps.", UseCase::SectorAnalysis => "Record one or more complete laps.",