1#![allow(dead_code)]
2use std::collections::BTreeMap;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
12pub enum AlignGrade {
13 Excellent,
15 Good,
17 Acceptable,
19 Poor,
21 Failed,
23}
24
25impl AlignGrade {
26 #[allow(clippy::cast_precision_loss)]
28 #[must_use]
29 pub fn from_frame_error(error_frames: f64) -> Self {
30 if error_frames < 0.5 {
31 Self::Excellent
32 } else if error_frames < 1.5 {
33 Self::Good
34 } else if error_frames < 3.5 {
35 Self::Acceptable
36 } else if error_frames < f64::INFINITY {
37 Self::Poor
38 } else {
39 Self::Failed
40 }
41 }
42
43 #[must_use]
45 pub fn label(&self) -> &'static str {
46 match self {
47 Self::Excellent => "Excellent",
48 Self::Good => "Good",
49 Self::Acceptable => "Acceptable",
50 Self::Poor => "Poor",
51 Self::Failed => "Failed",
52 }
53 }
54
55 #[must_use]
57 pub fn score(&self) -> f64 {
58 match self {
59 Self::Excellent => 1.0,
60 Self::Good => 0.8,
61 Self::Acceptable => 0.6,
62 Self::Poor => 0.3,
63 Self::Failed => 0.0,
64 }
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq)]
70pub struct FrameMeasurement {
71 pub frame_index: u64,
73 pub offset_secs: f64,
75 pub confidence: f64,
77 pub spatial_error_px: f64,
79}
80
81impl FrameMeasurement {
82 #[must_use]
84 pub fn new(frame_index: u64, offset_secs: f64, confidence: f64, spatial_error_px: f64) -> Self {
85 Self {
86 frame_index,
87 offset_secs,
88 confidence: confidence.clamp(0.0, 1.0),
89 spatial_error_px,
90 }
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq)]
96pub struct DriftStats {
97 pub mean_drift: f64,
99 pub max_drift: f64,
101 pub std_dev: f64,
103 pub drift_rate: f64,
105}
106
107impl DriftStats {
108 #[allow(clippy::cast_precision_loss)]
110 #[must_use]
111 pub fn compute(offsets: &[f64]) -> Self {
112 if offsets.is_empty() {
113 return Self {
114 mean_drift: 0.0,
115 max_drift: 0.0,
116 std_dev: 0.0,
117 drift_rate: 0.0,
118 };
119 }
120
121 let n = offsets.len() as f64;
122 let mean = offsets.iter().sum::<f64>() / n;
123 let max_abs = offsets.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
124 let variance = offsets.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
125 let std_dev = variance.sqrt();
126
127 let drift_rate = if offsets.len() >= 2 {
129 let n_minus_1 = (offsets.len() - 1) as f64;
130 let first = offsets[0];
131 let last = offsets[offsets.len() - 1];
132 (last - first) / n_minus_1
133 } else {
134 0.0
135 };
136
137 Self {
138 mean_drift: mean,
139 max_drift: max_abs,
140 std_dev,
141 drift_rate,
142 }
143 }
144
145 #[must_use]
147 pub fn exceeds_threshold(&self, threshold_secs: f64) -> bool {
148 self.max_drift > threshold_secs
149 }
150}
151
152#[derive(Debug, Clone)]
154pub struct AlignReport {
155 pub title: String,
157 pub measurements: BTreeMap<u64, FrameMeasurement>,
159 pub drift_stats: Option<DriftStats>,
161 pub grade: AlignGrade,
163 pub notes: Vec<String>,
165}
166
167impl AlignReport {
168 #[must_use]
170 pub fn new(title: &str) -> Self {
171 Self {
172 title: title.to_string(),
173 measurements: BTreeMap::new(),
174 drift_stats: None,
175 grade: AlignGrade::Failed,
176 notes: Vec::new(),
177 }
178 }
179
180 pub fn add_measurement(&mut self, m: FrameMeasurement) {
182 self.measurements.insert(m.frame_index, m);
183 }
184
185 pub fn add_note(&mut self, note: &str) {
187 self.notes.push(note.to_string());
188 }
189
190 #[allow(clippy::cast_precision_loss)]
192 pub fn finalize(&mut self) {
193 let offsets: Vec<f64> = self.measurements.values().map(|m| m.offset_secs).collect();
194 let drift = DriftStats::compute(&offsets);
195 self.drift_stats = Some(drift);
196
197 let frame_error = drift.max_drift / 0.0333;
199 self.grade = AlignGrade::from_frame_error(frame_error);
200
201 if drift.drift_rate.abs() > 1e-6 {
202 self.add_note(&format!(
203 "Linear drift detected: {:.6} s/frame",
204 drift.drift_rate
205 ));
206 }
207 }
208
209 #[must_use]
211 pub fn measurement_count(&self) -> usize {
212 self.measurements.len()
213 }
214
215 #[allow(clippy::cast_precision_loss)]
217 #[must_use]
218 pub fn average_confidence(&self) -> f64 {
219 if self.measurements.is_empty() {
220 return 0.0;
221 }
222 let total: f64 = self.measurements.values().map(|m| m.confidence).sum();
223 total / self.measurements.len() as f64
224 }
225
226 #[must_use]
228 pub fn summary_text(&self) -> String {
229 let mut lines = Vec::new();
230 lines.push(format!("=== {} ===", self.title));
231 lines.push(format!("Grade: {}", self.grade.label()));
232 lines.push(format!("Measurements: {}", self.measurement_count()));
233 lines.push(format!("Avg confidence: {:.3}", self.average_confidence()));
234 if let Some(ref drift) = self.drift_stats {
235 lines.push(format!("Mean drift: {:.6} s", drift.mean_drift));
236 lines.push(format!("Max drift: {:.6} s", drift.max_drift));
237 lines.push(format!("Drift rate: {:.9} s/frame", drift.drift_rate));
238 }
239 for note in &self.notes {
240 lines.push(format!("NOTE: {note}"));
241 }
242 lines.join("\n")
243 }
244}
245
246#[derive(Debug)]
248pub struct AlignReportBuilder {
249 title: String,
251 measurements: Vec<FrameMeasurement>,
253 notes: Vec<String>,
255}
256
257impl AlignReportBuilder {
258 #[must_use]
260 pub fn new(title: &str) -> Self {
261 Self {
262 title: title.to_string(),
263 measurements: Vec::new(),
264 notes: Vec::new(),
265 }
266 }
267
268 #[must_use]
270 pub fn measurement(mut self, m: FrameMeasurement) -> Self {
271 self.measurements.push(m);
272 self
273 }
274
275 #[must_use]
277 pub fn note(mut self, note: &str) -> Self {
278 self.notes.push(note.to_string());
279 self
280 }
281
282 #[must_use]
284 pub fn build(self) -> AlignReport {
285 let mut report = AlignReport::new(&self.title);
286 for m in self.measurements {
287 report.add_measurement(m);
288 }
289 for note in &self.notes {
290 report.add_note(note);
291 }
292 report.finalize();
293 report
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn test_grade_from_frame_error_excellent() {
303 assert_eq!(AlignGrade::from_frame_error(0.1), AlignGrade::Excellent);
304 assert_eq!(AlignGrade::from_frame_error(0.0), AlignGrade::Excellent);
305 }
306
307 #[test]
308 fn test_grade_from_frame_error_good() {
309 assert_eq!(AlignGrade::from_frame_error(1.0), AlignGrade::Good);
310 }
311
312 #[test]
313 fn test_grade_from_frame_error_acceptable() {
314 assert_eq!(AlignGrade::from_frame_error(2.0), AlignGrade::Acceptable);
315 assert_eq!(AlignGrade::from_frame_error(3.0), AlignGrade::Acceptable);
316 }
317
318 #[test]
319 fn test_grade_from_frame_error_poor() {
320 assert_eq!(AlignGrade::from_frame_error(5.0), AlignGrade::Poor);
321 assert_eq!(AlignGrade::from_frame_error(100.0), AlignGrade::Poor);
322 }
323
324 #[test]
325 fn test_grade_labels() {
326 assert_eq!(AlignGrade::Excellent.label(), "Excellent");
327 assert_eq!(AlignGrade::Failed.label(), "Failed");
328 }
329
330 #[test]
331 fn test_grade_scores() {
332 assert!((AlignGrade::Excellent.score() - 1.0).abs() < f64::EPSILON);
333 assert!((AlignGrade::Failed.score()).abs() < f64::EPSILON);
334 }
335
336 #[test]
337 fn test_frame_measurement_confidence_clamped() {
338 let m = FrameMeasurement::new(0, 0.0, 1.5, 0.0);
339 assert!((m.confidence - 1.0).abs() < f64::EPSILON);
340
341 let m2 = FrameMeasurement::new(0, 0.0, -0.5, 0.0);
342 assert!((m2.confidence).abs() < f64::EPSILON);
343 }
344
345 #[test]
346 fn test_drift_stats_empty() {
347 let stats = DriftStats::compute(&[]);
348 assert!((stats.mean_drift).abs() < f64::EPSILON);
349 assert!((stats.max_drift).abs() < f64::EPSILON);
350 }
351
352 #[test]
353 fn test_drift_stats_constant_offset() {
354 let offsets = vec![0.01, 0.01, 0.01, 0.01];
355 let stats = DriftStats::compute(&offsets);
356 assert!((stats.mean_drift - 0.01).abs() < 1e-10);
357 assert!((stats.max_drift - 0.01).abs() < 1e-10);
358 assert!(stats.std_dev < 1e-10);
359 assert!(stats.drift_rate.abs() < 1e-10);
360 }
361
362 #[test]
363 fn test_drift_stats_linear_drift() {
364 let offsets = vec![0.0, 0.001, 0.002, 0.003];
365 let stats = DriftStats::compute(&offsets);
366 assert!((stats.drift_rate - 0.001).abs() < 1e-10);
367 }
368
369 #[test]
370 fn test_drift_exceeds_threshold() {
371 let stats = DriftStats {
372 mean_drift: 0.05,
373 max_drift: 0.1,
374 std_dev: 0.02,
375 drift_rate: 0.0001,
376 };
377 assert!(stats.exceeds_threshold(0.05));
378 assert!(!stats.exceeds_threshold(0.2));
379 }
380
381 #[test]
382 fn test_report_finalize_and_grade() {
383 let mut report = AlignReport::new("Test Report");
384 for i in 0..10 {
385 report.add_measurement(FrameMeasurement::new(i, 0.001, 0.9, 0.5));
386 }
387 report.finalize();
388 assert_eq!(report.grade, AlignGrade::Excellent);
389 assert!(report.drift_stats.is_some());
390 }
391
392 #[test]
393 fn test_report_average_confidence() {
394 let mut report = AlignReport::new("Conf test");
395 report.add_measurement(FrameMeasurement::new(0, 0.0, 0.8, 0.0));
396 report.add_measurement(FrameMeasurement::new(1, 0.0, 0.6, 0.0));
397 assert!((report.average_confidence() - 0.7).abs() < 1e-10);
398 }
399
400 #[test]
401 fn test_report_empty_confidence() {
402 let report = AlignReport::new("Empty");
403 assert!((report.average_confidence()).abs() < f64::EPSILON);
404 }
405
406 #[test]
407 fn test_report_summary_text_contains_title() {
408 let mut report = AlignReport::new("My Alignment");
409 report.finalize();
410 let text = report.summary_text();
411 assert!(text.contains("My Alignment"));
412 assert!(text.contains("Grade:"));
413 }
414
415 #[test]
416 fn test_builder_builds_finalized_report() {
417 let report = AlignReportBuilder::new("Builder Test")
418 .measurement(FrameMeasurement::new(0, 0.0, 0.95, 0.1))
419 .measurement(FrameMeasurement::new(1, 0.001, 0.92, 0.2))
420 .note("Test note")
421 .build();
422 assert_eq!(report.measurement_count(), 2);
423 assert_eq!(report.notes.len(), 2);
425 assert!(report.drift_stats.is_some());
426 }
427
428 #[test]
429 fn test_grade_ordering() {
430 assert!(AlignGrade::Excellent < AlignGrade::Good);
431 assert!(AlignGrade::Good < AlignGrade::Acceptable);
432 assert!(AlignGrade::Poor < AlignGrade::Failed);
433 }
434}