rusty-telemetry/src/analysis.rs
Jan-Peter v. Hunnius 47a0896134 feat: add use-case session management, analysis algorithms, and REST API (v0.5.0)
- Add session layer with UseCaseSession, SessionRun, SessionManager
  for use-case driven telemetry analysis (shift points, track mapping)
- Add analysis module with shift point detection (gear RPM ranges,
  upshift events, recommended shift RPM) and track map reconstruction
  (spline-aligned interpolation for center line from boundary runs)
- Add 9 REST API endpoints for session CRUD, run management, and
  analysis triggers under /api/sessions/*
- Add RecordingManager with always-on ring buffers (5 min, 18k frames)
  replacing raw binary file dumps
- Wire SessionManager in main.rs with Arc<Mutex> shared state
- Add TelemetryFrame Serialize derives for JSON API output
- Fix stop_recording race: hold active lock across flush I/O
- Fix flush: separate ring buffer lock from JSON serialization
- Fix flush failure propagation: errors prevent Stopped status
- Add data_truncated flag on SessionRun for ring buffer wrap detection
- Deduplicate gear_display into shared free function
- Deduplicate session_display into closure
- Mutex poisoning recovery on all session API handlers
2026-06-05 10:40:17 +02:00

290 lines
8.4 KiB
Rust

//! 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<GearRpmRange>,
pub shift_events: Vec<ShiftEvent>,
pub recommended_shift_rpm: Option<f32>,
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<i32, Vec<f32>> = HashMap::new();
for f in &valid {
buckets.entry(f.gear).or_default().push(f.engine_rpm);
}
let mut gear_ranges: Vec<GearRpmRange> = 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::<f32>() / 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<usize> = frames
.iter()
.enumerate()
.filter(|(_, f)| f.gear >= 2 && f.engine_rpm > 0.0)
.map(|(i, _)| i)
.collect();
let mut shift_events: Vec<ShiftEvent> = 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<f32> = 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::<f32>() / 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<TrackPoint>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TrackMapAnalysis {
pub boundaries: Vec<TrackBoundary>,
pub center_line: Option<TrackBoundary>,
pub total_points: usize,
}
pub fn analyze_track_map(
runs: &[Vec<TelemetryFrame>],
labels: &[String],
) -> TrackMapAnalysis {
let mut boundaries: Vec<TrackBoundary> = Vec::new();
for (run, label) in runs.iter().zip(labels.iter()) {
let mut points: Vec<TrackPoint> = 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::<usize>()
+ center_line.as_ref().map_or(0, |c| c.point_count);
TrackMapAnalysis {
boundaries,
center_line,
total_points,
}
}
fn compute_center_line(boundaries: &[TrackBoundary]) -> Option<TrackBoundary> {
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<TrackPoint> = (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<TrackPoint> {
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<TelemetryFrame>],
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),
}),
}
}