#![allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum SyncQuality {
Poor,
Fair,
Good,
Excellent,
}
impl SyncQuality {
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn score(&self) -> f64 {
match self {
Self::Poor => 0.1,
Self::Fair => 0.4,
Self::Good => 0.75,
Self::Excellent => 1.0,
}
}
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::Poor => "poor",
Self::Fair => "fair",
Self::Good => "good",
Self::Excellent => "excellent",
}
}
}
#[derive(Debug, Clone)]
pub struct SyncResult {
pub quality: SyncQuality,
pub offset_ms: f64,
pub confidence: f64,
pub stream_id: String,
}
impl SyncResult {
#[must_use]
pub fn new(quality: SyncQuality, offset_ms: f64, confidence: f64, stream_id: &str) -> Self {
Self {
quality,
offset_ms,
confidence,
stream_id: stream_id.to_owned(),
}
}
#[must_use]
pub fn is_good_sync(&self) -> bool {
self.quality >= SyncQuality::Good
}
#[must_use]
pub fn abs_offset_ms(&self) -> f64 {
self.offset_ms.abs()
}
}
#[derive(Debug, Clone)]
pub struct SyncScorerConfig {
pub excellent_threshold_ms: f64,
pub good_threshold_ms: f64,
pub fair_threshold_ms: f64,
pub min_confidence_good: f64,
}
impl Default for SyncScorerConfig {
fn default() -> Self {
Self {
excellent_threshold_ms: 0.5,
good_threshold_ms: 5.0,
fair_threshold_ms: 33.0,
min_confidence_good: 0.70,
}
}
}
#[derive(Debug)]
pub struct SyncScorer {
config: SyncScorerConfig,
}
impl SyncScorer {
#[must_use]
pub fn new(config: SyncScorerConfig) -> Self {
Self { config }
}
#[must_use]
pub fn default_scorer() -> Self {
Self::new(SyncScorerConfig::default())
}
#[must_use]
pub fn evaluate(&self, stream_id: &str, offset_ms: f64, confidence: f64) -> SyncResult {
let abs_off = offset_ms.abs();
let quality = if confidence < self.config.min_confidence_good {
SyncQuality::Poor
} else if abs_off <= self.config.excellent_threshold_ms {
SyncQuality::Excellent
} else if abs_off <= self.config.good_threshold_ms {
SyncQuality::Good
} else if abs_off <= self.config.fair_threshold_ms {
SyncQuality::Fair
} else {
SyncQuality::Poor
};
SyncResult::new(quality, offset_ms, confidence, stream_id)
}
}
#[derive(Debug, Default)]
pub struct SyncReport {
pub results: Vec<SyncResult>,
}
impl SyncReport {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, result: SyncResult) {
self.results.push(result);
}
#[must_use]
pub fn worst_quality(&self) -> Option<SyncQuality> {
self.results.iter().map(|r| r.quality).min()
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn avg_offset_ms(&self) -> f64 {
if self.results.is_empty() {
return 0.0;
}
let sum: f64 = self.results.iter().map(SyncResult::abs_offset_ms).sum();
sum / self.results.len() as f64
}
#[must_use]
pub fn good_count(&self) -> usize {
self.results.iter().filter(|r| r.is_good_sync()).count()
}
#[must_use]
pub fn all_good(&self) -> bool {
!self.results.is_empty() && self.results.iter().all(SyncResult::is_good_sync)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quality_order() {
assert!(SyncQuality::Excellent > SyncQuality::Good);
assert!(SyncQuality::Good > SyncQuality::Fair);
assert!(SyncQuality::Fair > SyncQuality::Poor);
}
#[test]
fn test_quality_score_monotone() {
assert!(SyncQuality::Excellent.score() > SyncQuality::Good.score());
assert!(SyncQuality::Good.score() > SyncQuality::Fair.score());
assert!(SyncQuality::Fair.score() > SyncQuality::Poor.score());
}
#[test]
fn test_quality_score_range() {
for q in [
SyncQuality::Poor,
SyncQuality::Fair,
SyncQuality::Good,
SyncQuality::Excellent,
] {
let s = q.score();
assert!((0.0..=1.0).contains(&s), "score {s} out of [0,1]");
}
}
#[test]
fn test_quality_labels_non_empty() {
for q in [
SyncQuality::Poor,
SyncQuality::Fair,
SyncQuality::Good,
SyncQuality::Excellent,
] {
assert!(!q.label().is_empty());
}
}
#[test]
fn test_sync_result_is_good() {
let good = SyncResult::new(SyncQuality::Good, 2.0, 0.9, "cam1");
let poor = SyncResult::new(SyncQuality::Poor, 50.0, 0.3, "cam2");
assert!(good.is_good_sync());
assert!(!poor.is_good_sync());
}
#[test]
fn test_sync_result_excellent_is_good() {
let r = SyncResult::new(SyncQuality::Excellent, 0.1, 0.99, "cam1");
assert!(r.is_good_sync());
}
#[test]
fn test_abs_offset_negative() {
let r = SyncResult::new(SyncQuality::Good, -8.0, 0.85, "cam3");
assert!((r.abs_offset_ms() - 8.0).abs() < f64::EPSILON);
}
#[test]
fn test_scorer_excellent() {
let scorer = SyncScorer::default_scorer();
let r = scorer.evaluate("cam1", 0.3, 0.95);
assert_eq!(r.quality, SyncQuality::Excellent);
}
#[test]
fn test_scorer_good() {
let scorer = SyncScorer::default_scorer();
let r = scorer.evaluate("cam1", 3.0, 0.80);
assert_eq!(r.quality, SyncQuality::Good);
}
#[test]
fn test_scorer_fair() {
let scorer = SyncScorer::default_scorer();
let r = scorer.evaluate("cam1", 15.0, 0.75);
assert_eq!(r.quality, SyncQuality::Fair);
}
#[test]
fn test_scorer_poor_large_offset() {
let scorer = SyncScorer::default_scorer();
let r = scorer.evaluate("cam1", 100.0, 0.90);
assert_eq!(r.quality, SyncQuality::Poor);
}
#[test]
fn test_scorer_poor_low_confidence() {
let scorer = SyncScorer::default_scorer();
let r = scorer.evaluate("cam1", 0.1, 0.2);
assert_eq!(r.quality, SyncQuality::Poor);
}
#[test]
fn test_report_empty() {
let report = SyncReport::new();
assert!(report.worst_quality().is_none());
assert!((report.avg_offset_ms()).abs() < f64::EPSILON);
assert!(!report.all_good());
}
#[test]
fn test_report_worst_quality() {
let scorer = SyncScorer::default_scorer();
let mut report = SyncReport::new();
report.add(scorer.evaluate("cam1", 0.2, 0.99));
report.add(scorer.evaluate("cam2", 20.0, 0.80));
assert_eq!(report.worst_quality(), Some(SyncQuality::Fair));
}
#[test]
fn test_report_avg_offset() {
let mut report = SyncReport::new();
report.add(SyncResult::new(SyncQuality::Good, 4.0, 0.9, "a"));
report.add(SyncResult::new(SyncQuality::Good, 6.0, 0.9, "b"));
assert!((report.avg_offset_ms() - 5.0).abs() < 1e-10);
}
#[test]
fn test_report_all_good() {
let scorer = SyncScorer::default_scorer();
let mut report = SyncReport::new();
report.add(scorer.evaluate("cam1", 0.3, 0.95));
report.add(scorer.evaluate("cam2", 2.0, 0.80));
assert!(report.all_good());
}
#[test]
fn test_report_good_count() {
let scorer = SyncScorer::default_scorer();
let mut report = SyncReport::new();
report.add(scorer.evaluate("cam1", 0.3, 0.95)); report.add(scorer.evaluate("cam2", 2.0, 0.80)); report.add(scorer.evaluate("cam3", 50.0, 0.8)); assert_eq!(report.good_count(), 2);
}
}