//! 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}; // --------------------------------------------------------------------------- // Shift Point Analysis // --------------------------------------------------------------------------- #[derive(Debug, Clone, Serialize)] pub struct GearRpmRange { pub gear: i32, pub gear_display: String, pub min_rpm: f32, pub max_rpm: f32, pub avg_rpm: f32, pub sample_count: usize, } #[derive(Debug, Clone, Serialize)] pub struct ShiftEvent { pub from_gear: i32, pub to_gear: i32, pub rpm_before_shift: f32, pub rpm_after_shift: f32, pub rpm_drop: f32, pub speed_kmh: f32, } #[derive(Debug, Clone, Serialize)] pub struct ShiftPointAnalysis { pub gear_ranges: Vec, pub shift_events: Vec, pub recommended_shift_rpm: Option, pub total_frames_analyzed: usize, } pub fn analyze_shift_points(frames: &[TelemetryFrame]) -> ShiftPointAnalysis { let valid: Vec<&TelemetryFrame> = frames .iter() .filter(|f| f.gear >= 2 && f.engine_rpm > 0.0) .collect(); let mut buckets: HashMap> = HashMap::new(); for f in &valid { buckets.entry(f.gear).or_default().push(f.engine_rpm); } let mut gear_ranges: Vec = buckets .into_iter() .map(|(gear, rpms)| { let min_rpm = rpms.iter().cloned().fold(f32::INFINITY, f32::min); let max_rpm = rpms.iter().cloned().fold(f32::NEG_INFINITY, f32::max); let avg_rpm = rpms.iter().sum::() / rpms.len() as f32; GearRpmRange { gear, gear_display: gear_display(gear), min_rpm, max_rpm, avg_rpm, sample_count: rpms.len(), } }) .collect(); gear_ranges.sort_by_key(|g| g.gear); let valid_indices: Vec = frames .iter() .enumerate() .filter(|(_, f)| f.gear >= 2 && f.engine_rpm > 0.0) .map(|(i, _)| i) .collect(); let mut shift_events: Vec = Vec::new(); for window in valid_indices.windows(2) { let prev = &frames[window[0]]; let curr = &frames[window[1]]; if prev.gear + 1 == curr.gear { shift_events.push(ShiftEvent { from_gear: prev.gear, to_gear: curr.gear, rpm_before_shift: prev.engine_rpm, rpm_after_shift: curr.engine_rpm, rpm_drop: prev.engine_rpm - curr.engine_rpm, speed_kmh: prev.speed_kmh, }); } } let recommended_shift_rpm = { let upshift_rpms: Vec = shift_events .iter() .filter(|e| e.from_gear >= 2 && e.to_gear >= 2) .map(|e| e.rpm_before_shift) .collect(); if upshift_rpms.is_empty() { None } else { Some(upshift_rpms.iter().sum::() / upshift_rpms.len() as f32) } }; ShiftPointAnalysis { gear_ranges, shift_events, recommended_shift_rpm, total_frames_analyzed: valid.len(), } } // --------------------------------------------------------------------------- // Track Map Analysis // --------------------------------------------------------------------------- #[derive(Debug, Clone, Serialize)] pub struct TrackPoint { pub x: f32, pub y: f32, pub z: f32, pub spline: f32, } #[derive(Debug, Clone, Serialize)] 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 fn analyze_track_map( runs: &[Vec], labels: &[String], ) -> TrackMapAnalysis { let mut boundaries: Vec = Vec::new(); for (run, label) in runs.iter().zip(labels.iter()) { let mut points: Vec = run .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(); points.sort_by(|a, b| a.spline.partial_cmp(&b.spline).unwrap_or(std::cmp::Ordering::Equal)); 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, } } 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, }) } /// Linearly interpolate a TrackPoint at the given spline position from a boundary. fn interpolate_boundary(boundary: &TrackBoundary, target_spline: f32) -> Option { let points = &boundary.points; if points.is_empty() { return None; } if target_spline <= points.first()?.spline { return Some(points.first()?.clone()); } if target_spline >= points.last()?.spline { return Some(points.last()?.clone()); } let idx = points.partition_point(|p| p.spline < target_spline); if idx == 0 || idx >= points.len() { return points.first().cloned(); } let a = &points[idx - 1]; let b = &points[idx]; let t = if (b.spline - a.spline).abs() < f32::EPSILON { 0.0 } else { (target_spline - a.spline) / (b.spline - a.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, }) } // --------------------------------------------------------------------------- // Dispatch // --------------------------------------------------------------------------- pub fn analyze( use_case: &UseCase, frames_by_run: &[Vec], run_labels: &[String], ) -> serde_json::Value { match use_case { UseCase::ShiftPoints => { if let Some(first_run) = frames_by_run.first() { serde_json::to_value(analyze_shift_points(first_run)) .unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"})) } else { serde_json::json!({"error": "no frames provided"}) } } UseCase::TrackMap => serde_json::to_value(analyze_track_map(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), }), } }