diff --git a/src/analysis.rs b/src/analysis.rs index 38e350a..074c5e4 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -25,6 +25,74 @@ 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 // --------------------------------------------------------------------------- @@ -80,9 +148,28 @@ pub fn analyze_shift_points( for (run_idx, run) in runs.iter().enumerate() { let label = run_labels.get(run_idx).cloned().unwrap_or_default(); - let valid: Vec<&TelemetryFrame> = run + // 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) + .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 { @@ -292,7 +379,7 @@ fn lerp_search( // Track Map Analysis // --------------------------------------------------------------------------- -#[derive(Debug, Clone, Serialize, PartialEq)] +#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq)] pub enum PositionMode { Spline, Coordinate, @@ -362,9 +449,15 @@ pub fn analyze_track_map( 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 => { - run.iter() + trimmed + .iter() .filter(|f| f.spline_position > 0.0) .map(|f| TrackPoint { x: f.world_x, @@ -375,7 +468,7 @@ pub fn analyze_track_map( .collect() } PositionMode::Coordinate => { - let mut pts: Vec = run + let mut pts: Vec = trimmed .iter() .filter(|f| f.world_x != 0.0 || f.world_z != 0.0) .map(|f| TrackPoint { @@ -909,6 +1002,195 @@ fn downsample(items: Vec, target: usize) -> Vec { 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 // --------------------------------------------------------------------------- @@ -939,6 +1221,14 @@ pub fn analyze( 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), diff --git a/src/api.rs b/src/api.rs index 634290f..d4343f4 100644 --- a/src/api.rs +++ b/src/api.rs @@ -636,6 +636,10 @@ async fn build_track( let game = req.game.unwrap_or_else(|| manifest.game.to_track_dir_name().to_string()); let track_name = req.track_name.unwrap_or(manifest.track); + if game.is_empty() || track_name.is_empty() { + return err(StatusCode::BAD_REQUEST, "Game and track name are required. Specify them in the request or set them when starting the recording."); + } + let boundaries = { let mgr = lock_sessions(&state); let session = match mgr.get_session(&req.session_id) { diff --git a/src/session.rs b/src/session.rs index 1948832..465cd3a 100644 --- a/src/session.rs +++ b/src/session.rs @@ -161,8 +161,13 @@ impl SessionManager { } /// Atomically write bytes to a path via temp file + rename (same directory). + /// Uses a unique temp file name to avoid collisions between concurrent writes. fn atomic_write(path: &Path, bytes: &[u8]) { - let tmp = path.with_extension("json.tmp"); + let tmp = path.with_file_name(format!( + ".{}.{}.tmp", + path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + uuid::Uuid::new_v4() + )); if let Err(e) = std::fs::write(&tmp, bytes) { eprintln!("[WARN] Failed to write temp file {}: {}", tmp.display(), e); return; @@ -173,8 +178,8 @@ impl SessionManager { } } - /// Persist session metadata (compact JSON, excludes frames) synchronously, - /// then write frame data off-thread to a separate file. + /// Persist session metadata (compact JSON, excludes frames) and frame data + /// synchronously. Both files use atomic write (temp file + rename). fn persist_session(&self, id: &str) { let Some(session) = self.sessions.get(id) else { return; @@ -188,17 +193,17 @@ impl SessionManager { Err(e) => eprintln!("[WARN] Failed to serialize session metadata {}: {}", id, e), } - // Frames are large — clone under lock (memcpy) then serialize + write off-thread. + // Frames — clone under lock, then serialize + write synchronously. + // Must be synchronous: off-thread writes race on the same file, + // and an earlier call (with empty frames) can overwrite a later one. let runs_frames: Vec> = session.runs.iter().map(|r| r.frames.clone()).collect(); let frames_path = self.frames_file_path(&id); - std::thread::spawn(move || { - let frames_file = SessionFramesFile { runs: runs_frames }; - match serde_json::to_vec(&frames_file) { - Ok(bytes) => Self::atomic_write(&frames_path, &bytes), - Err(e) => eprintln!("[WARN] Failed to serialize frames for session {}: {}", id, e), - } - }); + let frames_file = SessionFramesFile { runs: runs_frames }; + match serde_json::to_vec(&frames_file) { + Ok(bytes) => Self::atomic_write(&frames_path, &bytes), + Err(e) => eprintln!("[WARN] Failed to serialize frames for session {}: {}", id, e), + } } fn delete_session_file(&self, id: &str) {