- Add analyze_racing_line(): lap-by-lap path extraction with speed data, downsampled to 400 pts/lap. Spline-sorted in spline mode for clean paths. - Add complete_lap_frames() + split_by_lap() utilities: filters to laps whose spline spans >= 0.9 of [0,1], discarding partial out/in laps. - Fix complete_lap_frames logic: was checking if segment STARTS at spline~0, but a full lap starting mid-track (joined before start/finish) also qualifies. Now checks spline span coverage instead. - WOT window trimming in analyze_shift_points(): trims to first/last frame with throttle >= 0.9, filters rev-limiter plateau (acc_g < 0.01). - Fix frame persistence race condition: persist_session now writes frames synchronously instead of spawning a thread. Multiple concurrent calls could race on the same temp file, with earlier calls (empty frames) overwriting later calls with correct data. - atomic_write uses UUID-based temp file names as defense-in-depth. - PositionMode now derives Deserialize for RacingLineAnalysis. - Empty game/track_name validation in build_track() endpoint. - Wired RacingLine use case into analysis dispatch.
1237 lines
39 KiB
Rust
1237 lines
39 KiB
Rust
// rusty-telemetry — Sim racing telemetry analysis platform
|
|
// Copyright (C) 2026 Jan-Peter v. Hunnius
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
//! 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};
|
|
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<TelemetryFrame> {
|
|
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<f32> = 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct AccelerationPoint {
|
|
pub speed_kmh: f32,
|
|
pub rpm: f32,
|
|
pub acceleration_g: f32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct GearAccelerationCurve {
|
|
pub gear: i32,
|
|
pub gear_display: String,
|
|
pub run_label: String,
|
|
pub data_points: Vec<AccelerationPoint>,
|
|
pub min_rpm: f32,
|
|
pub max_rpm: f32,
|
|
pub min_speed_kmh: f32,
|
|
pub max_speed_kmh: f32,
|
|
pub sample_count: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ShiftCrossoverPoint {
|
|
pub from_gear: i32,
|
|
pub to_gear: i32,
|
|
pub from_gear_display: String,
|
|
pub to_gear_display: String,
|
|
pub crossover_speed_kmh: f32,
|
|
pub crossover_rpm: f32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ShiftPointAnalysis {
|
|
pub gear_curves: Vec<GearAccelerationCurve>,
|
|
pub crossover_points: Vec<ShiftCrossoverPoint>,
|
|
pub recommended_shift_rpm: Option<f32>,
|
|
pub runs_analyzed: usize,
|
|
pub total_frames_analyzed: usize,
|
|
}
|
|
|
|
pub fn analyze_shift_points(
|
|
runs: &[Vec<TelemetryFrame>],
|
|
run_labels: &[String],
|
|
) -> ShiftPointAnalysis {
|
|
let mut gear_data: HashMap<i32, Vec<AccelerationPoint>> = HashMap::new();
|
|
let mut gear_labels: HashMap<i32, Vec<String>> = HashMap::new();
|
|
let mut total_frames_analyzed: usize = 0;
|
|
let mut runs_analyzed: usize = 0;
|
|
|
|
for (run_idx, run) in runs.iter().enumerate() {
|
|
let label = run_labels.get(run_idx).cloned().unwrap_or_default();
|
|
|
|
// 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
|
|
&& f.acc_g_frontal > 0.01
|
|
})
|
|
.collect();
|
|
|
|
if valid.len() < 10 {
|
|
continue;
|
|
}
|
|
|
|
let dominant_gear = {
|
|
let mut freq: HashMap<i32, usize> = HashMap::new();
|
|
for f in &valid {
|
|
*freq.entry(f.gear).or_default() += 1;
|
|
}
|
|
*freq.iter().max_by_key(|(_, c)| *c).map(|(g, _)| g).unwrap_or(&2)
|
|
};
|
|
|
|
for f in valid.into_iter().filter(|f| f.gear == dominant_gear) {
|
|
gear_data.entry(dominant_gear).or_default().push(AccelerationPoint {
|
|
speed_kmh: f.speed_kmh,
|
|
rpm: f.engine_rpm,
|
|
acceleration_g: f.acc_g_frontal,
|
|
});
|
|
}
|
|
|
|
gear_labels.entry(dominant_gear).or_default().push(label);
|
|
runs_analyzed += 1;
|
|
}
|
|
|
|
let mut gear_curves: Vec<GearAccelerationCurve> = Vec::new();
|
|
|
|
for (gear, mut points) in gear_data {
|
|
total_frames_analyzed += points.len();
|
|
|
|
points.sort_by(|a, b| a.speed_kmh.partial_cmp(&b.speed_kmh).unwrap_or(std::cmp::Ordering::Equal));
|
|
|
|
let window = (points.len() / 5).clamp(3, 15);
|
|
let acc_values: Vec<f32> = points.iter().map(|p| p.acceleration_g).collect();
|
|
let smoothed = smooth(&acc_values, window);
|
|
|
|
for (i, s) in smoothed.into_iter().enumerate() {
|
|
points[i].acceleration_g = s;
|
|
}
|
|
|
|
let min_rpm = points.iter().map(|p| p.rpm).fold(f32::INFINITY, f32::min);
|
|
let max_rpm = points.iter().map(|p| p.rpm).fold(f32::NEG_INFINITY, f32::max);
|
|
let min_speed = points.iter().map(|p| p.speed_kmh).fold(f32::INFINITY, f32::min);
|
|
let max_speed = points.iter().map(|p| p.speed_kmh).fold(f32::NEG_INFINITY, f32::max);
|
|
let sample_count = points.len();
|
|
|
|
let labels = gear_labels.get(&gear).cloned().unwrap_or_default();
|
|
let run_label = if labels.len() == 1 {
|
|
labels[0].clone()
|
|
} else {
|
|
format!("{} ({} runs)", labels.join(", "), labels.len())
|
|
};
|
|
|
|
gear_curves.push(GearAccelerationCurve {
|
|
gear,
|
|
gear_display: gear_display(gear),
|
|
run_label,
|
|
data_points: points,
|
|
min_rpm,
|
|
max_rpm,
|
|
min_speed_kmh: min_speed,
|
|
max_speed_kmh: max_speed,
|
|
sample_count,
|
|
});
|
|
}
|
|
|
|
gear_curves.sort_by_key(|c| c.gear);
|
|
|
|
let crossover_points = find_crossovers(&gear_curves);
|
|
|
|
let recommended_shift_rpm = {
|
|
let rpms: Vec<f32> = crossover_points.iter().map(|c| c.crossover_rpm).collect();
|
|
if rpms.is_empty() {
|
|
None
|
|
} else {
|
|
Some(rpms.iter().sum::<f32>() / rpms.len() as f32)
|
|
}
|
|
};
|
|
|
|
ShiftPointAnalysis {
|
|
gear_curves,
|
|
crossover_points,
|
|
recommended_shift_rpm,
|
|
runs_analyzed,
|
|
total_frames_analyzed,
|
|
}
|
|
}
|
|
|
|
fn smooth(values: &[f32], window: usize) -> Vec<f32> {
|
|
let half = window / 2;
|
|
let n = values.len();
|
|
let mut result = Vec::with_capacity(n);
|
|
for i in 0..n {
|
|
let start = i.saturating_sub(half);
|
|
let end = (i + half + 1).min(n);
|
|
let sum: f32 = values[start..end].iter().sum();
|
|
result.push(sum / (end - start) as f32);
|
|
}
|
|
result
|
|
}
|
|
|
|
fn find_crossovers(curves: &[GearAccelerationCurve]) -> Vec<ShiftCrossoverPoint> {
|
|
let mut crossovers = Vec::new();
|
|
|
|
for pair in curves.windows(2) {
|
|
let low = &pair[0];
|
|
let high = &pair[1];
|
|
|
|
if low.gear == high.gear {
|
|
continue;
|
|
}
|
|
|
|
let overlap_min = low.min_speed_kmh.max(high.min_speed_kmh);
|
|
let overlap_max = low.max_speed_kmh.min(high.max_speed_kmh);
|
|
|
|
if overlap_max - overlap_min < 1.0 {
|
|
continue;
|
|
}
|
|
|
|
let grid_size = 100;
|
|
let step = (overlap_max - overlap_min) / (grid_size as f32 - 1.0);
|
|
|
|
let mut found_crossover: Option<(f32, f32)> = None;
|
|
|
|
for i in 0..grid_size {
|
|
let target_speed = overlap_min + i as f32 * step;
|
|
let low_accel = match interpolate_curve(&low.data_points, target_speed) {
|
|
Some(a) => a,
|
|
None => continue,
|
|
};
|
|
let high_accel = match interpolate_curve(&high.data_points, target_speed) {
|
|
Some(a) => a,
|
|
None => continue,
|
|
};
|
|
|
|
if low_accel < high_accel {
|
|
let rpm = interpolate_rpm(&low.data_points, target_speed).unwrap_or(0.0);
|
|
found_crossover = Some((target_speed, rpm));
|
|
break;
|
|
}
|
|
}
|
|
|
|
if let Some((speed, rpm)) = found_crossover {
|
|
crossovers.push(ShiftCrossoverPoint {
|
|
from_gear: low.gear,
|
|
to_gear: high.gear,
|
|
from_gear_display: low.gear_display.clone(),
|
|
to_gear_display: high.gear_display.clone(),
|
|
crossover_speed_kmh: speed,
|
|
crossover_rpm: rpm,
|
|
});
|
|
}
|
|
}
|
|
|
|
crossovers
|
|
}
|
|
|
|
fn interpolate_field<F>(curve: &[AccelerationPoint], target_speed: f32, field: F) -> Option<f32>
|
|
where
|
|
F: Fn(&AccelerationPoint) -> f32,
|
|
{
|
|
let (t, a, b) = lerp_search(curve, |p| p.speed_kmh, target_speed)?;
|
|
Some(field(a) + t * (field(b) - field(a)))
|
|
}
|
|
|
|
fn interpolate_curve(curve: &[AccelerationPoint], target_speed: f32) -> Option<f32> {
|
|
interpolate_field(curve, target_speed, |p| p.acceleration_g)
|
|
}
|
|
|
|
fn interpolate_rpm(curve: &[AccelerationPoint], target_speed: f32) -> Option<f32> {
|
|
interpolate_field(curve, target_speed, |p| p.rpm)
|
|
}
|
|
|
|
fn lerp_search<T>(
|
|
items: &[T],
|
|
key: impl Fn(&T) -> f32,
|
|
target: f32,
|
|
) -> Option<(f32, &T, &T)> {
|
|
if items.is_empty() {
|
|
return None;
|
|
}
|
|
if items.len() == 1 {
|
|
return Some((0.0, &items[0], &items[0]));
|
|
}
|
|
|
|
let idx = items.partition_point(|p| key(p) < target);
|
|
if idx == 0 {
|
|
return Some((0.0, &items[0], &items[0]));
|
|
}
|
|
if idx >= items.len() {
|
|
return Some((0.0, &items[items.len() - 1], &items[items.len() - 1]));
|
|
}
|
|
|
|
let a = &items[idx - 1];
|
|
let b = &items[idx];
|
|
let t = if (key(b) - key(a)).abs() < f32::EPSILON {
|
|
0.0
|
|
} else {
|
|
(target - key(a)) / (key(b) - key(a))
|
|
};
|
|
|
|
Some((t, a, b))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Track Map Analysis
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq)]
|
|
pub enum PositionMode {
|
|
Spline,
|
|
Coordinate,
|
|
}
|
|
|
|
fn detect_position_mode(frames: &[TelemetryFrame]) -> PositionMode {
|
|
if frames.is_empty() {
|
|
return PositionMode::Coordinate;
|
|
}
|
|
let with_spline = frames.iter().filter(|f| f.spline_position > 0.0).count();
|
|
let spline_ratio = with_spline as f32 / frames.len() as f32;
|
|
if spline_ratio > 0.5 {
|
|
PositionMode::Spline
|
|
} else {
|
|
PositionMode::Coordinate
|
|
}
|
|
}
|
|
|
|
fn compute_cumulative_distance(points: &mut [TrackPoint]) {
|
|
if points.is_empty() { return; }
|
|
let mut cumulative = 0.0f32;
|
|
points[0].spline = 0.0;
|
|
for i in 1..points.len() {
|
|
let dx = points[i].x - points[i-1].x;
|
|
let dz = points[i].z - points[i-1].z;
|
|
cumulative += (dx * dx + dz * dz).sqrt();
|
|
points[i].spline = cumulative;
|
|
}
|
|
if cumulative > 0.0 {
|
|
for p in points.iter_mut() {
|
|
p.spline /= cumulative;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
|
|
pub struct TrackPoint {
|
|
pub x: f32,
|
|
pub y: f32,
|
|
pub z: f32,
|
|
pub spline: f32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
|
|
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 position_mode: PositionMode,
|
|
}
|
|
|
|
pub fn analyze_track_map(
|
|
runs: &[Vec<TelemetryFrame>],
|
|
labels: &[String],
|
|
) -> TrackMapAnalysis {
|
|
let all_frames: Vec<TelemetryFrame> = runs.iter().flat_map(|r| r.iter()).cloned().collect();
|
|
let mode = detect_position_mode(&all_frames);
|
|
|
|
let mut boundaries: Vec<TrackBoundary> = 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<TrackPoint> = match &mode {
|
|
PositionMode::Spline => {
|
|
trimmed
|
|
.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()
|
|
}
|
|
PositionMode::Coordinate => {
|
|
let mut pts: Vec<TrackPoint> = trimmed
|
|
.iter()
|
|
.filter(|f| f.world_x != 0.0 || f.world_z != 0.0)
|
|
.map(|f| TrackPoint {
|
|
x: f.world_x,
|
|
y: f.world_y,
|
|
z: f.world_z,
|
|
spline: 0.0,
|
|
})
|
|
.collect();
|
|
compute_cumulative_distance(&mut pts);
|
|
pts
|
|
}
|
|
};
|
|
|
|
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);
|
|
|
|
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,
|
|
position_mode: mode,
|
|
}
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
fn interpolate_boundary(boundary: &TrackBoundary, target_spline: f32) -> Option<TrackPoint> {
|
|
let points = &boundary.points;
|
|
let (t, a, b) = lerp_search(points, |p| p.spline, target_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,
|
|
})
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Track Model Builder
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const NUM_SAMPLES: usize = 2000;
|
|
const CORNER_THRESHOLD: f64 = 0.005;
|
|
|
|
|
|
|
|
|
|
pub fn build_track_model(
|
|
boundaries: &[TrackBoundary],
|
|
game: String,
|
|
track_name: String,
|
|
source: TrackModelSource,
|
|
) -> Result<TrackModel, String> {
|
|
// Step 1: Validate input
|
|
if boundaries.len() != 2 {
|
|
return Err(format!(
|
|
"Expected exactly 2 boundaries, got {}",
|
|
boundaries.len()
|
|
));
|
|
}
|
|
|
|
let left_idx = boundaries
|
|
.iter()
|
|
.position(|b| b.label == "left")
|
|
.ok_or_else(|| "No boundary labeled 'left' found".to_string())?;
|
|
let right_idx = boundaries
|
|
.iter()
|
|
.position(|b| b.label == "right")
|
|
.ok_or_else(|| "No boundary labeled 'right' found".to_string())?;
|
|
|
|
let left = &boundaries[left_idx];
|
|
let right = &boundaries[right_idx];
|
|
|
|
if left.points.len() < 10 {
|
|
return Err(format!(
|
|
"Left boundary needs at least 10 points, has {}",
|
|
left.points.len()
|
|
));
|
|
}
|
|
if right.points.len() < 10 {
|
|
return Err(format!(
|
|
"Right boundary needs at least 10 points, has {}",
|
|
right.points.len()
|
|
));
|
|
}
|
|
|
|
// Step 2: Resample both boundaries
|
|
let left_resampled: Vec<(f64, f64)> = (0..NUM_SAMPLES)
|
|
.map(|i| {
|
|
let s = i as f32 / (NUM_SAMPLES - 1) as f32;
|
|
let p = interpolate_boundary(left, s).expect("interpolation within range");
|
|
(p.x as f64, p.z as f64)
|
|
})
|
|
.collect();
|
|
|
|
let right_resampled: Vec<(f64, f64)> = (0..NUM_SAMPLES)
|
|
.map(|i| {
|
|
let s = i as f32 / (NUM_SAMPLES - 1) as f32;
|
|
let p = interpolate_boundary(right, s).expect("interpolation within range");
|
|
(p.x as f64, p.z as f64)
|
|
})
|
|
.collect();
|
|
|
|
// Step 3: Compute center line
|
|
let center_line: Vec<(f64, f64)> = left_resampled
|
|
.iter()
|
|
.zip(right_resampled.iter())
|
|
.map(|((lx, lz), (rx, rz))| ((lx + rx) / 2.0, (lz + rz) / 2.0))
|
|
.collect();
|
|
|
|
// Step 4: Compute track width
|
|
let track_width: Vec<f64> = left_resampled
|
|
.iter()
|
|
.zip(right_resampled.iter())
|
|
.map(|((lx, lz), (rx, rz))| {
|
|
let dx = rx - lx;
|
|
let dz = rz - lz;
|
|
(dx * dx + dz * dz).sqrt()
|
|
})
|
|
.collect();
|
|
|
|
// Step 5: Compute heading
|
|
let mut heading: Vec<f64> = Vec::with_capacity(NUM_SAMPLES);
|
|
for i in 0..center_line.len() - 1 {
|
|
let dx = center_line[i + 1].0 - center_line[i].0;
|
|
let dz = center_line[i + 1].1 - center_line[i].1;
|
|
heading.push(dz.atan2(dx));
|
|
}
|
|
heading.push(*heading.last().unwrap_or(&0.0));
|
|
|
|
// Step 6: Compute curvature
|
|
let mut curvature: Vec<f64> = Vec::with_capacity(NUM_SAMPLES);
|
|
for i in 0..center_line.len() - 1 {
|
|
let dx = center_line[i + 1].0 - center_line[i].0;
|
|
let dz = center_line[i + 1].1 - center_line[i].1;
|
|
let ds = (dx * dx + dz * dz).sqrt().max(1e-10);
|
|
let mut dtheta = heading[i + 1] - heading[i];
|
|
// Normalize to [-π, π]
|
|
while dtheta > std::f64::consts::PI {
|
|
dtheta -= 2.0 * std::f64::consts::PI;
|
|
}
|
|
while dtheta < -std::f64::consts::PI {
|
|
dtheta += 2.0 * std::f64::consts::PI;
|
|
}
|
|
curvature.push(dtheta / ds);
|
|
}
|
|
curvature.push(0.0);
|
|
|
|
// Step 7: Smooth curvature
|
|
let window = (curvature.len() / 50).clamp(3, 21);
|
|
let smoothed_curvature = smooth_f64(&curvature, window);
|
|
|
|
// Step 8: Auto-detect corners
|
|
let corners = detect_corners(&smoothed_curvature);
|
|
|
|
// Step 9: Define sectors
|
|
let sectors = vec![
|
|
Sector {
|
|
index: 0,
|
|
start_s: 0.0,
|
|
end_s: 1.0 / 3.0,
|
|
},
|
|
Sector {
|
|
index: 1,
|
|
start_s: 1.0 / 3.0,
|
|
end_s: 2.0 / 3.0,
|
|
},
|
|
Sector {
|
|
index: 2,
|
|
start_s: 2.0 / 3.0,
|
|
end_s: 1.0,
|
|
},
|
|
];
|
|
|
|
// Step 10: Normalize coordinates using bounds from all arrays
|
|
let mut min_x = f64::INFINITY;
|
|
let mut max_x = f64::NEG_INFINITY;
|
|
let mut min_z = f64::INFINITY;
|
|
let mut max_z = f64::NEG_INFINITY;
|
|
|
|
for (x, z) in center_line.iter().chain(left_resampled.iter()).chain(right_resampled.iter()) {
|
|
min_x = min_x.min(*x);
|
|
max_x = max_x.max(*x);
|
|
min_z = min_z.min(*z);
|
|
max_z = max_z.max(*z);
|
|
}
|
|
|
|
let scale = (max_x - min_x).max(max_z - min_z).max(1e-6);
|
|
|
|
let normalize = |px: f64, pz: f64| -> (f64, f64) { ((px - min_x) / scale, (pz - min_z) / scale) };
|
|
|
|
let normalized_center: Vec<(f64, f64)> = center_line
|
|
.into_iter()
|
|
.map(|(x, z)| normalize(x, z))
|
|
.collect();
|
|
let normalized_left: Vec<(f64, f64)> = left_resampled
|
|
.into_iter()
|
|
.map(|(x, z)| normalize(x, z))
|
|
.collect();
|
|
let normalized_right: Vec<(f64, f64)> = right_resampled
|
|
.into_iter()
|
|
.map(|(x, z)| normalize(x, z))
|
|
.collect();
|
|
|
|
// Step 11: Build and return TrackModel
|
|
Ok(TrackModel {
|
|
game,
|
|
track_name,
|
|
center_line: normalized_center,
|
|
left_boundary: normalized_left,
|
|
right_boundary: normalized_right,
|
|
track_width,
|
|
heading,
|
|
curvature: smoothed_curvature,
|
|
corners,
|
|
sectors,
|
|
source,
|
|
created_at: chrono::Utc::now().to_rfc3339(),
|
|
})
|
|
}
|
|
|
|
fn smooth_f64(values: &[f64], window: usize) -> Vec<f64> {
|
|
let half = window / 2;
|
|
let n = values.len();
|
|
let mut result = Vec::with_capacity(n);
|
|
for i in 0..n {
|
|
let start = i.saturating_sub(half);
|
|
let end = (i + half + 1).min(n);
|
|
let sum: f64 = values[start..end].iter().sum();
|
|
result.push(sum / (end - start) as f64);
|
|
}
|
|
result
|
|
}
|
|
|
|
fn detect_corners(curvature: &[f64]) -> Vec<Corner> {
|
|
let mut corners = Vec::new();
|
|
let mut in_corner = false;
|
|
let mut corner_start_s = 0.0f64;
|
|
let mut peak_idx = 0usize;
|
|
let mut peak_kappa = 0.0f64;
|
|
|
|
let len = curvature.len();
|
|
let denom = (len - 1).max(1) as f64;
|
|
|
|
for (i, &k) in curvature.iter().enumerate() {
|
|
let abs_k = k.abs();
|
|
if abs_k > CORNER_THRESHOLD && !in_corner {
|
|
in_corner = true;
|
|
corner_start_s = i.saturating_sub(5) as f64 / denom;
|
|
peak_idx = i;
|
|
peak_kappa = abs_k;
|
|
} else if abs_k > peak_kappa && in_corner {
|
|
peak_idx = i;
|
|
peak_kappa = abs_k;
|
|
} else if abs_k <= CORNER_THRESHOLD * 0.5 && in_corner {
|
|
in_corner = false;
|
|
let corner_end = (i + 5).min(len - 1);
|
|
let apex_s = peak_idx as f64 / denom;
|
|
let exit_s = corner_end as f64 / denom;
|
|
|
|
if exit_s - corner_start_s > 0.005 {
|
|
corners.push(Corner {
|
|
index: peak_idx,
|
|
number: 0,
|
|
entry_s: corner_start_s,
|
|
apex_s,
|
|
exit_s,
|
|
curvature_peak: peak_kappa,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if in_corner {
|
|
let apex_s = peak_idx as f64 / denom;
|
|
corners.push(Corner {
|
|
index: peak_idx,
|
|
number: 0,
|
|
entry_s: corner_start_s,
|
|
apex_s,
|
|
exit_s: 1.0,
|
|
curvature_peak: peak_kappa,
|
|
});
|
|
}
|
|
|
|
for (i, corner) in corners.iter_mut().enumerate() {
|
|
corner.number = (i + 1) as u32;
|
|
}
|
|
|
|
corners
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// Car Grip Analysis (envelope extraction)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Speed bin width for the grip envelope (km/h).
|
|
const GRIP_SPEED_BIN: f32 = 10.0;
|
|
/// Maximum speed bin center (km/h).
|
|
const GRIP_MAX_SPEED: f32 = 360.0;
|
|
/// Target number of downsampled scatter points per dataset.
|
|
const GRIP_SCATTER_TARGET: usize = 800;
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct GripEnvelopePoint {
|
|
pub speed_kmh: f32,
|
|
pub max_lateral_g: f32,
|
|
pub max_accel_g: f32,
|
|
pub max_brake_g: f32,
|
|
pub sample_count: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct GripCirclePoint {
|
|
pub longitudinal_g: f32,
|
|
pub lateral_g: f32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct SteeringResponsePoint {
|
|
pub steer: f32,
|
|
pub lateral_g: f32,
|
|
pub speed_kmh: f32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct GripSummary {
|
|
pub max_lateral_g: f32,
|
|
pub max_accel_g: f32,
|
|
pub max_brake_g: f32,
|
|
pub max_combined_g: f32,
|
|
pub peak_lateral_speed_kmh: f32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct CarGripAnalysis {
|
|
pub summary: GripSummary,
|
|
pub grip_envelope: Vec<GripEnvelopePoint>,
|
|
pub grip_circle: Vec<GripCirclePoint>,
|
|
pub steering_response: Vec<SteeringResponsePoint>,
|
|
pub runs_analyzed: usize,
|
|
pub total_frames_analyzed: usize,
|
|
}
|
|
|
|
pub fn analyze_car_grip(runs: &[Vec<TelemetryFrame>]) -> CarGripAnalysis {
|
|
let num_bins = (GRIP_MAX_SPEED / GRIP_SPEED_BIN) as usize;
|
|
|
|
// Per-bin trackers: (max_lat, max_accel, max_brake, count)
|
|
let mut bins: Vec<(f32, f32, f32, usize)> = vec![(0.0, 0.0, 0.0, 0); num_bins];
|
|
|
|
// Scatter accumulators (before downsampling)
|
|
let mut circle_raw: Vec<GripCirclePoint> = Vec::new();
|
|
let mut steer_raw: Vec<SteeringResponsePoint> = Vec::new();
|
|
|
|
// Summary trackers
|
|
let mut max_lat = 0.0f32;
|
|
let mut max_lat_speed = 0.0f32;
|
|
let mut max_accel = 0.0f32;
|
|
let mut max_brake = 0.0f32;
|
|
let mut max_combined = 0.0f32;
|
|
|
|
let mut total_frames: usize = 0;
|
|
let mut runs_analyzed: usize = 0;
|
|
|
|
for run in runs {
|
|
if run.is_empty() {
|
|
continue;
|
|
}
|
|
runs_analyzed += 1;
|
|
|
|
for f in run {
|
|
// Filter: skip pit, very low speed
|
|
if f.is_in_pit || f.speed_kmh < 5.0 {
|
|
continue;
|
|
}
|
|
|
|
let lat = f.acc_g_horizontal.abs();
|
|
let long = f.acc_g_frontal;
|
|
let accel = if long > 0.0 { long } else { 0.0 };
|
|
let brake = if long < 0.0 { long.abs() } else { 0.0 };
|
|
let combined = (lat * lat + long * long).sqrt();
|
|
|
|
total_frames += 1;
|
|
|
|
// Update summary
|
|
if lat > max_lat {
|
|
max_lat = lat;
|
|
max_lat_speed = f.speed_kmh;
|
|
}
|
|
if accel > max_accel {
|
|
max_accel = accel;
|
|
}
|
|
if brake > max_brake {
|
|
max_brake = brake;
|
|
}
|
|
if combined > max_combined {
|
|
max_combined = combined;
|
|
}
|
|
|
|
// Update speed bin
|
|
let bin_idx = ((f.speed_kmh / GRIP_SPEED_BIN) as usize).min(num_bins - 1);
|
|
let b = &mut bins[bin_idx];
|
|
b.0 = b.0.max(lat);
|
|
b.1 = b.1.max(accel);
|
|
b.2 = b.2.max(brake);
|
|
b.3 += 1;
|
|
|
|
// Accumulate scatter points (only for significant cornering/braking)
|
|
if lat > 0.3 || long.abs() > 0.3 {
|
|
circle_raw.push(GripCirclePoint {
|
|
longitudinal_g: long,
|
|
lateral_g: f.acc_g_horizontal,
|
|
});
|
|
}
|
|
if f.steer.abs() > 0.02 && lat > 0.1 {
|
|
steer_raw.push(SteeringResponsePoint {
|
|
steer: f.steer,
|
|
lateral_g: f.acc_g_horizontal,
|
|
speed_kmh: f.speed_kmh,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build envelope from bins (only include bins with data)
|
|
let grip_envelope: Vec<GripEnvelopePoint> = bins
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, b)| b.3 > 0)
|
|
.map(|(i, b)| GripEnvelopePoint {
|
|
speed_kmh: i as f32 * GRIP_SPEED_BIN + GRIP_SPEED_BIN / 2.0,
|
|
max_lateral_g: b.0,
|
|
max_accel_g: b.1,
|
|
max_brake_g: b.2,
|
|
sample_count: b.3,
|
|
})
|
|
.collect();
|
|
|
|
let grip_circle = downsample(circle_raw, GRIP_SCATTER_TARGET);
|
|
let steering_response = downsample(steer_raw, GRIP_SCATTER_TARGET);
|
|
|
|
CarGripAnalysis {
|
|
summary: GripSummary {
|
|
max_lateral_g: max_lat,
|
|
max_accel_g: max_accel,
|
|
max_brake_g: max_brake,
|
|
max_combined_g: max_combined,
|
|
peak_lateral_speed_kmh: max_lat_speed,
|
|
},
|
|
grip_envelope,
|
|
grip_circle,
|
|
steering_response,
|
|
runs_analyzed,
|
|
total_frames_analyzed: total_frames,
|
|
}
|
|
}
|
|
|
|
fn downsample<T: Clone>(items: Vec<T>, target: usize) -> Vec<T> {
|
|
if items.len() <= target {
|
|
return items;
|
|
}
|
|
let step = items.len() as f32 / target as f32;
|
|
let mut result = Vec::with_capacity(target);
|
|
let mut idx = 0.0f32;
|
|
while (idx as usize) < items.len() {
|
|
result.push(items[idx as usize].clone());
|
|
idx += step;
|
|
}
|
|
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<RacingLinePoint>,
|
|
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<RacingLap>,
|
|
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<Vec<TelemetryFrame>> {
|
|
if frames.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
|
|
let mut laps: Vec<Vec<TelemetryFrame>> = Vec::new();
|
|
let mut current: Vec<TelemetryFrame> = 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<TelemetryFrame>],
|
|
labels: &[String],
|
|
) -> RacingLineAnalysis {
|
|
let all_frames: Vec<TelemetryFrame> = runs.iter().flat_map(|r| r.iter()).cloned().collect();
|
|
let mode = detect_position_mode(&all_frames);
|
|
let mut laps_out: Vec<RacingLap> = 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<f32> = 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<RacingLinePoint> = 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::<f32>() / 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn analyze(
|
|
use_case: &UseCase,
|
|
frames_by_run: &[Vec<TelemetryFrame>],
|
|
run_labels: &[String],
|
|
) -> serde_json::Value {
|
|
match use_case {
|
|
UseCase::ShiftPoints => {
|
|
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_shift_points(frames_by_run, run_labels))
|
|
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}))
|
|
}
|
|
}
|
|
UseCase::CarGrip => {
|
|
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_car_grip(frames_by_run))
|
|
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}))
|
|
}
|
|
}
|
|
UseCase::TrackMapping => {
|
|
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),
|
|
}),
|
|
}
|
|
}
|