- 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
290 lines
8.4 KiB
Rust
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),
|
|
}),
|
|
}
|
|
}
|