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
This commit is contained in:
parent
56d3169033
commit
47a0896134
10 changed files with 2333 additions and 91 deletions
290
src/analysis.rs
Normal file
290
src/analysis.rs
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
//! 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),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue