feat: KSUDP parser fix, warnings system, shift point rewrite, code quality (v0.5.1)
Bug Fixes:
- Fixed KSUDP binary parser offsets from packed (323B) to natural C
alignment (328B) — 3 bytes padding after char identifier, 2 bytes
after 6 bools
- Fixed setup_terminal() leaving terminal in raw mode on early exit
New Features:
- Thread-safe warning buffer (warnings.rs) with TUI bar + stderr fallback
- Rewrote shift point analysis per AC forum methodology: per-gear
acceleration curves, crossover detection, same-gear merging
- UseCase::guidance() — step-by-step recording instructions
- Recording metadata enrichment in analysis responses
- recording_id field on sessions
Code Quality:
- Extracted finalize_run() helper (session.rs)
- Extracted execute_and_store_analysis() helper (api.rs)
- Extracted lerp_search<T>() generic helper (analysis.rs)
Breaking API Changes:
- GET /api/use-cases: string[] -> [{id, name, guidance}[]]
- Shift point analysis: gear_ranges/shift_events -> gear_curves/crossover_points
Updated README.md with new API response examples and TUI warning bar.
This commit is contained in:
parent
090ab7e559
commit
e3aa511ac7
9 changed files with 696 additions and 312 deletions
317
src/analysis.rs
317
src/analysis.rs
|
|
@ -14,106 +14,264 @@ use crate::telemetry_frame::{gear_display, TelemetryFrame};
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct GearRpmRange {
|
||||
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 avg_rpm: f32,
|
||||
pub min_speed_kmh: f32,
|
||||
pub max_speed_kmh: f32,
|
||||
pub sample_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ShiftEvent {
|
||||
pub struct ShiftCrossoverPoint {
|
||||
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,
|
||||
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_ranges: Vec<GearRpmRange>,
|
||||
pub shift_events: Vec<ShiftEvent>,
|
||||
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(frames: &[TelemetryFrame]) -> ShiftPointAnalysis {
|
||||
let valid: Vec<&TelemetryFrame> = frames
|
||||
.iter()
|
||||
.filter(|f| f.gear >= 2 && f.engine_rpm > 0.0)
|
||||
.collect();
|
||||
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;
|
||||
|
||||
let mut buckets: HashMap<i32, Vec<f32>> = HashMap::new();
|
||||
for f in &valid {
|
||||
buckets.entry(f.gear).or_default().push(f.engine_rpm);
|
||||
}
|
||||
for (run_idx, run) in runs.iter().enumerate() {
|
||||
let label = run_labels.get(run_idx).cloned().unwrap_or_default();
|
||||
|
||||
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(),
|
||||
let valid: Vec<&TelemetryFrame> = run
|
||||
.iter()
|
||||
.filter(|f| f.throttle >= 0.9 && f.gear >= 2 && f.speed_kmh > 1.0 && f.engine_rpm > 500.0)
|
||||
.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;
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
gear_ranges.sort_by_key(|g| g.gear);
|
||||
*freq.iter().max_by_key(|(_, c)| *c).map(|(g, _)| g).unwrap_or(&2)
|
||||
};
|
||||
|
||||
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,
|
||||
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).max(3).min(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 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() {
|
||||
let rpms: Vec<f32> = crossover_points.iter().map(|c| c.crossover_rpm).collect();
|
||||
if rpms.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(upshift_rpms.iter().sum::<f32>() / upshift_rpms.len() as f32)
|
||||
Some(rpms.iter().sum::<f32>() / rpms.len() as f32)
|
||||
}
|
||||
};
|
||||
|
||||
ShiftPointAnalysis {
|
||||
gear_ranges,
|
||||
shift_events,
|
||||
gear_curves,
|
||||
crossover_points,
|
||||
recommended_shift_rpm,
|
||||
total_frames_analyzed: valid.len(),
|
||||
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 = if i >= half { i - half } else { 0 };
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -227,32 +385,9 @@ fn compute_center_line(boundaries: &[TrackBoundary]) -> Option<TrackBoundary> {
|
|||
})
|
||||
}
|
||||
|
||||
/// 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)
|
||||
};
|
||||
let (t, a, b) = lerp_search(points, |p| p.spline, target_spline)?;
|
||||
|
||||
Some(TrackPoint {
|
||||
x: a.x + t * (b.x - a.x),
|
||||
|
|
@ -273,11 +408,11 @@ pub fn analyze(
|
|||
) -> 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 {
|
||||
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::TrackMap => serde_json::to_value(analyze_track_map(frames_by_run, run_labels))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue