// rusty-telemetry — Sim racing telemetry analysis platform // Copyright (C) 2026 Jan-Peter v. Hunnius // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . //! Telemetry analysis algorithms for sim racing data. //! //! Provides shift point analysis, track map reconstruction, and a dispatch //! function that routes a [`UseCase`] to the appropriate analysis pipeline. use serde::Serialize; use std::collections::HashMap; use crate::recording::UseCase; use crate::telemetry_frame::{gear_display, TelemetryFrame}; use crate::track_model::{Corner, Sector, TrackModel, TrackModelSource}; // --------------------------------------------------------------------------- // Frame trimming utilities // --------------------------------------------------------------------------- /// Split a run's frames into segments by lap_count transitions. fn split_by_lap(frames: &[TelemetryFrame]) -> Vec<&[TelemetryFrame]> { if frames.is_empty() { return Vec::new(); } let mut segments = Vec::new(); let mut seg_start = 0; let mut prev_lap = frames[0].lap_count; for (i, f) in frames.iter().enumerate() { if f.lap_count != prev_lap { segments.push(&frames[seg_start..i]); seg_start = i; prev_lap = f.lap_count; } } segments.push(&frames[seg_start..]); segments } /// Return frames belonging to complete laps only. /// /// A complete lap covers most of the track (spline spans ≥ 0.9 of [0, 1]). /// The first segment may start mid-track (driver joined before start/finish) /// but still complete a full revolution — spline wraps from the join point /// through 1.0 back to near 0. The last segment is often a partial out-lap /// (driver stopped before completing the lap). fn complete_lap_frames(frames: &[TelemetryFrame]) -> Vec { let segments = split_by_lap(frames); if segments.is_empty() { return frames.to_vec(); } let mut result = Vec::new(); for seg in &segments { let splines: Vec = seg.iter().filter(|f| f.spline_position > 0.0).map(|f| f.spline_position).collect(); if splines.is_empty() { continue; } let min_s = splines.iter().cloned().fold(f32::INFINITY, f32::min); let max_s = splines.iter().cloned().fold(f32::NEG_INFINITY, f32::max); let span = max_s - min_s; // Complete lap: spline covers ≥ 0.9 of the track, or wraps around // (min near 0, max near 1). let wraps = min_s < 0.1 && max_s > 0.9; if span >= 0.9 || wraps { result.extend_from_slice(seg); } } // If no segment qualifies (e.g., no spline data), fall back to longest. if result.is_empty() { let longest = segments.iter().max_by_key(|s| s.len()); if let Some(seg) = longest { return seg.to_vec(); } } if result.is_empty() { frames.to_vec() } else { result } } // --------------------------------------------------------------------------- // Shift Point Analysis // --------------------------------------------------------------------------- #[derive(Debug, Clone, Serialize)] pub struct AccelerationPoint { pub speed_kmh: f32, pub rpm: f32, pub acceleration_g: f32, } #[derive(Debug, Clone, Serialize)] pub struct GearAccelerationCurve { pub gear: i32, pub gear_display: String, pub run_label: String, pub data_points: Vec, pub min_rpm: f32, pub max_rpm: f32, pub min_speed_kmh: f32, pub max_speed_kmh: f32, pub sample_count: usize, } #[derive(Debug, Clone, Serialize)] pub struct ShiftCrossoverPoint { pub from_gear: i32, pub to_gear: i32, pub from_gear_display: String, pub to_gear_display: String, pub crossover_speed_kmh: f32, pub crossover_rpm: f32, } #[derive(Debug, Clone, Serialize)] pub struct ShiftPointAnalysis { pub gear_curves: Vec, pub crossover_points: Vec, pub recommended_shift_rpm: Option, pub runs_analyzed: usize, pub total_frames_analyzed: usize, } pub fn analyze_shift_points( runs: &[Vec], run_labels: &[String], ) -> ShiftPointAnalysis { let mut gear_data: HashMap> = HashMap::new(); let mut gear_labels: HashMap> = HashMap::new(); let mut total_frames_analyzed: usize = 0; let mut runs_analyzed: usize = 0; for (run_idx, run) in runs.iter().enumerate() { let label = run_labels.get(run_idx).cloned().unwrap_or_default(); // Trim to the WOT window: find the first and last frames where // throttle is near 100%. This removes pre-launch and post-lift data. let wot_start = run.iter().position(|f| f.throttle >= 0.9); let wot_end = run.iter().rposition(|f| f.throttle >= 0.9); let trimmed: &[TelemetryFrame] = match (wot_start, wot_end) { (Some(s), Some(e)) if e > s => &run[s..=e], _ => run.as_slice(), }; // Filter for valid WOT acceleration frames. // Exclude rev-limiter plateau: when speed stops increasing (delta < 0.5 km/h // over the window), the engine is bouncing off the limiter and acceleration // data is meaningless. let valid: Vec<&TelemetryFrame> = trimmed .iter() .filter(|f| { f.throttle >= 0.9 && f.gear >= 2 && f.speed_kmh > 1.0 && f.engine_rpm > 500.0 && f.acc_g_frontal > 0.01 }) .collect(); if valid.len() < 10 { continue; } let dominant_gear = { let mut freq: HashMap = HashMap::new(); for f in &valid { *freq.entry(f.gear).or_default() += 1; } *freq.iter().max_by_key(|(_, c)| *c).map(|(g, _)| g).unwrap_or(&2) }; for f in valid.into_iter().filter(|f| f.gear == dominant_gear) { gear_data.entry(dominant_gear).or_default().push(AccelerationPoint { speed_kmh: f.speed_kmh, rpm: f.engine_rpm, acceleration_g: f.acc_g_frontal, }); } gear_labels.entry(dominant_gear).or_default().push(label); runs_analyzed += 1; } let mut gear_curves: Vec = Vec::new(); for (gear, mut points) in gear_data { total_frames_analyzed += points.len(); points.sort_by(|a, b| a.speed_kmh.partial_cmp(&b.speed_kmh).unwrap_or(std::cmp::Ordering::Equal)); let window = (points.len() / 5).clamp(3, 15); let acc_values: Vec = points.iter().map(|p| p.acceleration_g).collect(); let smoothed = smooth(&acc_values, window); for (i, s) in smoothed.into_iter().enumerate() { points[i].acceleration_g = s; } let min_rpm = points.iter().map(|p| p.rpm).fold(f32::INFINITY, f32::min); let max_rpm = points.iter().map(|p| p.rpm).fold(f32::NEG_INFINITY, f32::max); let min_speed = points.iter().map(|p| p.speed_kmh).fold(f32::INFINITY, f32::min); let max_speed = points.iter().map(|p| p.speed_kmh).fold(f32::NEG_INFINITY, f32::max); let sample_count = points.len(); let labels = gear_labels.get(&gear).cloned().unwrap_or_default(); let run_label = if labels.len() == 1 { labels[0].clone() } else { format!("{} ({} runs)", labels.join(", "), labels.len()) }; gear_curves.push(GearAccelerationCurve { gear, gear_display: gear_display(gear), run_label, data_points: points, min_rpm, max_rpm, min_speed_kmh: min_speed, max_speed_kmh: max_speed, sample_count, }); } gear_curves.sort_by_key(|c| c.gear); let crossover_points = find_crossovers(&gear_curves); let recommended_shift_rpm = { let rpms: Vec = crossover_points.iter().map(|c| c.crossover_rpm).collect(); if rpms.is_empty() { None } else { Some(rpms.iter().sum::() / rpms.len() as f32) } }; ShiftPointAnalysis { gear_curves, crossover_points, recommended_shift_rpm, runs_analyzed, total_frames_analyzed, } } fn smooth(values: &[f32], window: usize) -> Vec { let half = window / 2; let n = values.len(); let mut result = Vec::with_capacity(n); for i in 0..n { let start = i.saturating_sub(half); let end = (i + half + 1).min(n); let sum: f32 = values[start..end].iter().sum(); result.push(sum / (end - start) as f32); } result } fn find_crossovers(curves: &[GearAccelerationCurve]) -> Vec { let mut crossovers = Vec::new(); for pair in curves.windows(2) { let low = &pair[0]; let high = &pair[1]; if low.gear == high.gear { continue; } let overlap_min = low.min_speed_kmh.max(high.min_speed_kmh); let overlap_max = low.max_speed_kmh.min(high.max_speed_kmh); if overlap_max - overlap_min < 1.0 { continue; } let grid_size = 100; let step = (overlap_max - overlap_min) / (grid_size as f32 - 1.0); let mut found_crossover: Option<(f32, f32)> = None; for i in 0..grid_size { let target_speed = overlap_min + i as f32 * step; let low_accel = match interpolate_curve(&low.data_points, target_speed) { Some(a) => a, None => continue, }; let high_accel = match interpolate_curve(&high.data_points, target_speed) { Some(a) => a, None => continue, }; if low_accel < high_accel { let rpm = interpolate_rpm(&low.data_points, target_speed).unwrap_or(0.0); found_crossover = Some((target_speed, rpm)); break; } } if let Some((speed, rpm)) = found_crossover { crossovers.push(ShiftCrossoverPoint { from_gear: low.gear, to_gear: high.gear, from_gear_display: low.gear_display.clone(), to_gear_display: high.gear_display.clone(), crossover_speed_kmh: speed, crossover_rpm: rpm, }); } } crossovers } fn interpolate_field(curve: &[AccelerationPoint], target_speed: f32, field: F) -> Option where F: Fn(&AccelerationPoint) -> f32, { let (t, a, b) = lerp_search(curve, |p| p.speed_kmh, target_speed)?; Some(field(a) + t * (field(b) - field(a))) } fn interpolate_curve(curve: &[AccelerationPoint], target_speed: f32) -> Option { interpolate_field(curve, target_speed, |p| p.acceleration_g) } fn interpolate_rpm(curve: &[AccelerationPoint], target_speed: f32) -> Option { interpolate_field(curve, target_speed, |p| p.rpm) } fn lerp_search( items: &[T], key: impl Fn(&T) -> f32, target: f32, ) -> Option<(f32, &T, &T)> { if items.is_empty() { return None; } if items.len() == 1 { return Some((0.0, &items[0], &items[0])); } let idx = items.partition_point(|p| key(p) < target); if idx == 0 { return Some((0.0, &items[0], &items[0])); } if idx >= items.len() { return Some((0.0, &items[items.len() - 1], &items[items.len() - 1])); } let a = &items[idx - 1]; let b = &items[idx]; let t = if (key(b) - key(a)).abs() < f32::EPSILON { 0.0 } else { (target - key(a)) / (key(b) - key(a)) }; Some((t, a, b)) } // --------------------------------------------------------------------------- // Track Map Analysis // --------------------------------------------------------------------------- #[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq)] pub enum PositionMode { Spline, Coordinate, } fn detect_position_mode(frames: &[TelemetryFrame]) -> PositionMode { if frames.is_empty() { return PositionMode::Coordinate; } let with_spline = frames.iter().filter(|f| f.spline_position > 0.0).count(); let spline_ratio = with_spline as f32 / frames.len() as f32; if spline_ratio > 0.5 { PositionMode::Spline } else { PositionMode::Coordinate } } fn compute_cumulative_distance(points: &mut [TrackPoint]) { if points.is_empty() { return; } let mut cumulative = 0.0f32; points[0].spline = 0.0; for i in 1..points.len() { let dx = points[i].x - points[i-1].x; let dz = points[i].z - points[i-1].z; cumulative += (dx * dx + dz * dz).sqrt(); points[i].spline = cumulative; } if cumulative > 0.0 { for p in points.iter_mut() { p.spline /= cumulative; } } } #[derive(Debug, Clone, Serialize, serde::Deserialize)] pub struct TrackPoint { pub x: f32, pub y: f32, pub z: f32, pub spline: f32, } #[derive(Debug, Clone, Serialize, serde::Deserialize)] pub struct TrackBoundary { pub label: String, pub point_count: usize, pub points: Vec, } #[derive(Debug, Clone, Serialize)] pub struct TrackMapAnalysis { pub boundaries: Vec, pub center_line: Option, pub total_points: usize, pub position_mode: PositionMode, } pub fn analyze_track_map( runs: &[Vec], labels: &[String], ) -> TrackMapAnalysis { let all_frames: Vec = runs.iter().flat_map(|r| r.iter()).cloned().collect(); let mode = detect_position_mode(&all_frames); let mut boundaries: Vec = Vec::new(); for (run, label) in runs.iter().zip(labels.iter()) { // Trim to complete laps only — partial laps at the start/end of // the recording produce artifacts when spline-sorted (the car // was not at the boundary edge during those segments). let trimmed = complete_lap_frames(run); let mut points: Vec = match &mode { PositionMode::Spline => { trimmed .iter() .filter(|f| f.spline_position > 0.0) .map(|f| TrackPoint { x: f.world_x, y: f.world_y, z: f.world_z, spline: f.spline_position, }) .collect() } PositionMode::Coordinate => { let mut pts: Vec = trimmed .iter() .filter(|f| f.world_x != 0.0 || f.world_z != 0.0) .map(|f| TrackPoint { x: f.world_x, y: f.world_y, z: f.world_z, spline: 0.0, }) .collect(); compute_cumulative_distance(&mut pts); pts } }; points.sort_by(|a, b| a.spline.partial_cmp(&b.spline).unwrap_or(std::cmp::Ordering::Equal)); points.dedup_by(|a, b| (a.spline - b.spline).abs() < 0.0001); let point_count = points.len(); boundaries.push(TrackBoundary { label: label.clone(), point_count, points, }); } let center_line = compute_center_line(&boundaries); let total_points: usize = boundaries.iter().map(|b| b.point_count).sum::() + center_line.as_ref().map_or(0, |c| c.point_count); TrackMapAnalysis { boundaries, center_line, total_points, position_mode: mode, } } fn compute_center_line(boundaries: &[TrackBoundary]) -> Option { if boundaries.len() != 2 { return None; } let left_idx = boundaries .iter() .position(|b| b.label == "left")?; let right_idx = boundaries .iter() .position(|b| b.label == "right")?; if left_idx == right_idx { return None; } let left = &boundaries[left_idx]; let right = &boundaries[right_idx]; if left.points.is_empty() || right.points.is_empty() { return None; } let num_samples = left.point_count.max(right.point_count).min(2000); let center_points: Vec = (0..num_samples) .filter_map(|i| { let spline = i as f32 / (num_samples - 1).max(1) as f32; let lp = interpolate_boundary(left, spline)?; let rp = interpolate_boundary(right, spline)?; Some(TrackPoint { x: (lp.x + rp.x) / 2.0, y: (lp.y + rp.y) / 2.0, z: (lp.z + rp.z) / 2.0, spline, }) }) .collect(); if center_points.is_empty() { return None; } Some(TrackBoundary { label: "center_line".to_owned(), point_count: center_points.len(), points: center_points, }) } fn interpolate_boundary(boundary: &TrackBoundary, target_spline: f32) -> Option { let points = &boundary.points; let (t, a, b) = lerp_search(points, |p| p.spline, target_spline)?; Some(TrackPoint { x: a.x + t * (b.x - a.x), y: a.y + t * (b.y - a.y), z: a.z + t * (b.z - a.z), spline: target_spline, }) } // --------------------------------------------------------------------------- // Track Model Builder // --------------------------------------------------------------------------- const NUM_SAMPLES: usize = 2000; const CORNER_THRESHOLD: f64 = 0.005; pub fn build_track_model( boundaries: &[TrackBoundary], game: String, track_name: String, source: TrackModelSource, ) -> Result { // Step 1: Validate input if boundaries.len() != 2 { return Err(format!( "Expected exactly 2 boundaries, got {}", boundaries.len() )); } let left_idx = boundaries .iter() .position(|b| b.label == "left") .ok_or_else(|| "No boundary labeled 'left' found".to_string())?; let right_idx = boundaries .iter() .position(|b| b.label == "right") .ok_or_else(|| "No boundary labeled 'right' found".to_string())?; let left = &boundaries[left_idx]; let right = &boundaries[right_idx]; if left.points.len() < 10 { return Err(format!( "Left boundary needs at least 10 points, has {}", left.points.len() )); } if right.points.len() < 10 { return Err(format!( "Right boundary needs at least 10 points, has {}", right.points.len() )); } // Step 2: Resample both boundaries let left_resampled: Vec<(f64, f64)> = (0..NUM_SAMPLES) .map(|i| { let s = i as f32 / (NUM_SAMPLES - 1) as f32; let p = interpolate_boundary(left, s).expect("interpolation within range"); (p.x as f64, p.z as f64) }) .collect(); let right_resampled: Vec<(f64, f64)> = (0..NUM_SAMPLES) .map(|i| { let s = i as f32 / (NUM_SAMPLES - 1) as f32; let p = interpolate_boundary(right, s).expect("interpolation within range"); (p.x as f64, p.z as f64) }) .collect(); // Step 3: Compute center line let center_line: Vec<(f64, f64)> = left_resampled .iter() .zip(right_resampled.iter()) .map(|((lx, lz), (rx, rz))| ((lx + rx) / 2.0, (lz + rz) / 2.0)) .collect(); // Step 4: Compute track width let track_width: Vec = left_resampled .iter() .zip(right_resampled.iter()) .map(|((lx, lz), (rx, rz))| { let dx = rx - lx; let dz = rz - lz; (dx * dx + dz * dz).sqrt() }) .collect(); // Step 5: Compute heading let mut heading: Vec = Vec::with_capacity(NUM_SAMPLES); for i in 0..center_line.len() - 1 { let dx = center_line[i + 1].0 - center_line[i].0; let dz = center_line[i + 1].1 - center_line[i].1; heading.push(dz.atan2(dx)); } heading.push(*heading.last().unwrap_or(&0.0)); // Step 6: Compute curvature let mut curvature: Vec = Vec::with_capacity(NUM_SAMPLES); for i in 0..center_line.len() - 1 { let dx = center_line[i + 1].0 - center_line[i].0; let dz = center_line[i + 1].1 - center_line[i].1; let ds = (dx * dx + dz * dz).sqrt().max(1e-10); let mut dtheta = heading[i + 1] - heading[i]; // Normalize to [-π, π] while dtheta > std::f64::consts::PI { dtheta -= 2.0 * std::f64::consts::PI; } while dtheta < -std::f64::consts::PI { dtheta += 2.0 * std::f64::consts::PI; } curvature.push(dtheta / ds); } curvature.push(0.0); // Step 7: Smooth curvature let window = (curvature.len() / 50).clamp(3, 21); let smoothed_curvature = smooth_f64(&curvature, window); // Step 8: Auto-detect corners let corners = detect_corners(&smoothed_curvature); // Step 9: Define sectors let sectors = vec![ Sector { index: 0, start_s: 0.0, end_s: 1.0 / 3.0, }, Sector { index: 1, start_s: 1.0 / 3.0, end_s: 2.0 / 3.0, }, Sector { index: 2, start_s: 2.0 / 3.0, end_s: 1.0, }, ]; // Step 10: Normalize coordinates using bounds from all arrays let mut min_x = f64::INFINITY; let mut max_x = f64::NEG_INFINITY; let mut min_z = f64::INFINITY; let mut max_z = f64::NEG_INFINITY; for (x, z) in center_line.iter().chain(left_resampled.iter()).chain(right_resampled.iter()) { min_x = min_x.min(*x); max_x = max_x.max(*x); min_z = min_z.min(*z); max_z = max_z.max(*z); } let scale = (max_x - min_x).max(max_z - min_z).max(1e-6); let normalize = |px: f64, pz: f64| -> (f64, f64) { ((px - min_x) / scale, (pz - min_z) / scale) }; let normalized_center: Vec<(f64, f64)> = center_line .into_iter() .map(|(x, z)| normalize(x, z)) .collect(); let normalized_left: Vec<(f64, f64)> = left_resampled .into_iter() .map(|(x, z)| normalize(x, z)) .collect(); let normalized_right: Vec<(f64, f64)> = right_resampled .into_iter() .map(|(x, z)| normalize(x, z)) .collect(); // Step 11: Build and return TrackModel Ok(TrackModel { game, track_name, center_line: normalized_center, left_boundary: normalized_left, right_boundary: normalized_right, track_width, heading, curvature: smoothed_curvature, corners, sectors, source, created_at: chrono::Utc::now().to_rfc3339(), }) } fn smooth_f64(values: &[f64], window: usize) -> Vec { let half = window / 2; let n = values.len(); let mut result = Vec::with_capacity(n); for i in 0..n { let start = i.saturating_sub(half); let end = (i + half + 1).min(n); let sum: f64 = values[start..end].iter().sum(); result.push(sum / (end - start) as f64); } result } fn detect_corners(curvature: &[f64]) -> Vec { let mut corners = Vec::new(); let mut in_corner = false; let mut corner_start_s = 0.0f64; let mut peak_idx = 0usize; let mut peak_kappa = 0.0f64; let len = curvature.len(); let denom = (len - 1).max(1) as f64; for (i, &k) in curvature.iter().enumerate() { let abs_k = k.abs(); if abs_k > CORNER_THRESHOLD && !in_corner { in_corner = true; corner_start_s = i.saturating_sub(5) as f64 / denom; peak_idx = i; peak_kappa = abs_k; } else if abs_k > peak_kappa && in_corner { peak_idx = i; peak_kappa = abs_k; } else if abs_k <= CORNER_THRESHOLD * 0.5 && in_corner { in_corner = false; let corner_end = (i + 5).min(len - 1); let apex_s = peak_idx as f64 / denom; let exit_s = corner_end as f64 / denom; if exit_s - corner_start_s > 0.005 { corners.push(Corner { index: peak_idx, number: 0, entry_s: corner_start_s, apex_s, exit_s, curvature_peak: peak_kappa, }); } } } if in_corner { let apex_s = peak_idx as f64 / denom; corners.push(Corner { index: peak_idx, number: 0, entry_s: corner_start_s, apex_s, exit_s: 1.0, curvature_peak: peak_kappa, }); } for (i, corner) in corners.iter_mut().enumerate() { corner.number = (i + 1) as u32; } 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 } // --------------------------------------------------------------------------- // Racing Line Analysis // --------------------------------------------------------------------------- #[derive(Debug, Clone, Serialize, serde::Deserialize)] pub struct RacingLinePoint { pub x: f32, pub y: f32, pub z: f32, pub speed_kmh: f32, pub spline: f32, } #[derive(Debug, Clone, Serialize, serde::Deserialize)] pub struct RacingLap { pub label: String, pub lap_index: usize, pub point_count: usize, pub points: Vec, pub lap_time_ms: i32, pub length_m: f32, pub avg_speed_kmh: f32, pub max_speed_kmh: f32, } #[derive(Debug, Clone, Serialize, serde::Deserialize)] pub struct RacingLineAnalysis { pub laps: Vec, pub position_mode: PositionMode, pub total_laps: usize, pub total_frames_analyzed: usize, } /// Split a run's frames into individual laps based on lap_count transitions. fn split_into_laps(frames: &[TelemetryFrame]) -> Vec> { if frames.is_empty() { return Vec::new(); } let mut laps: Vec> = Vec::new(); let mut current: Vec = Vec::new(); let mut prev_lap = frames[0].lap_count; for f in frames { if f.lap_count != prev_lap && !current.is_empty() { laps.push(std::mem::take(&mut current)); } current.push(f.clone()); prev_lap = f.lap_count; } if !current.is_empty() { laps.push(current); } laps } pub fn analyze_racing_line( runs: &[Vec], labels: &[String], ) -> RacingLineAnalysis { let all_frames: Vec = runs.iter().flat_map(|r| r.iter()).cloned().collect(); let mode = detect_position_mode(&all_frames); let mut laps_out: Vec = Vec::new(); let mut total_frames = 0usize; for (run, label) in runs.iter().zip(labels.iter()) { let laps = split_into_laps(run); for (lap_idx, lap_frames) in laps.iter().enumerate() { if lap_frames.is_empty() { continue; } // Skip partial laps: check spline coverage. A complete lap's // spline values span ≥ 0.9 of [0, 1] (the car went almost all // the way around). Partial laps at the start (joined mid-track) // and end (session ended early) are discarded. if laps.len() > 1 { let splines: Vec = lap_frames.iter() .filter(|f| f.spline_position > 0.0) .map(|f| f.spline_position) .collect(); if !splines.is_empty() { let min_s = splines.iter().cloned().fold(f32::INFINITY, f32::min); let max_s = splines.iter().cloned().fold(f32::NEG_INFINITY, f32::max); let span = max_s - min_s; let wraps = min_s < 0.1 && max_s > 0.9; if span < 0.9 && !wraps { continue; } } } let mut points: Vec = lap_frames .iter() .filter(|f| { if matches!(mode, PositionMode::Spline) { f.spline_position > 0.0 } else { f.world_x != 0.0 || f.world_z != 0.0 } }) .map(|f| RacingLinePoint { x: f.world_x, y: f.world_y, z: f.world_z, speed_kmh: f.speed_kmh, spline: f.spline_position, }) .collect(); if points.is_empty() { continue; } total_frames += lap_frames.len(); // Compute track length from consecutive point distances let mut length_m = 0.0f32; for i in 1..points.len() { let dx = points[i].x - points[i - 1].x; let dz = points[i].z - points[i - 1].z; length_m += (dx * dx + dz * dz).sqrt(); } // Sort by spline if in spline mode for a clean path if matches!(mode, PositionMode::Spline) { points.sort_by(|a, b| { a.spline .partial_cmp(&b.spline) .unwrap_or(std::cmp::Ordering::Equal) }); points.dedup_by(|a, b| (a.spline - b.spline).abs() < 0.0001); } // Downsample for efficient rendering (target ~400 points/lap) let point_count_before = points.len(); points = downsample(points, 400); let max_speed = lap_frames .iter() .map(|f| f.speed_kmh) .fold(0.0f32, f32::max); let avg_speed = if length_m > 0.0 { let best_lap_s = lap_frames .last() .map(|f| f.lap_time_ms) .unwrap_or(0) .max(0) as f32 / 1000.0; if best_lap_s > 0.0 { (length_m / best_lap_s) * 3.6 } else { let frame_count = lap_frames.len() as f32; lap_frames.iter().map(|f| f.speed_kmh).sum::() / frame_count } } else { 0.0 }; let lap_time = lap_frames .last() .map(|f| f.lap_time_ms) .unwrap_or(0); laps_out.push(RacingLap { label: label.clone(), lap_index: lap_idx, point_count: point_count_before, points, lap_time_ms: lap_time, length_m, avg_speed_kmh: avg_speed, max_speed_kmh: max_speed, }); } } let total_laps = laps_out.len(); RacingLineAnalysis { laps: laps_out, position_mode: mode, total_laps, total_frames_analyzed: total_frames, } } // --------------------------------------------------------------------------- // Dispatch // --------------------------------------------------------------------------- pub fn analyze( use_case: &UseCase, frames_by_run: &[Vec], run_labels: &[String], ) -> serde_json::Value { match use_case { UseCase::ShiftPoints => { 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_shift_points(frames_by_run, run_labels)) .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"})) } UseCase::RacingLine => { 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_racing_line(frames_by_run, run_labels)) .unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"})) } } _ => serde_json::json!({ "status": "not_implemented", "use_case": format!("{:?}", use_case), }), } }