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:
parent
3985e81cdd
commit
652d059b1d
2 changed files with 190 additions and 0 deletions
187
src/analysis.rs
187
src/analysis.rs
|
|
@ -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"}))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue