//! `movement_detector` — ego-motion compensated residual-motion clustering. //! //! AZ-662: ego-motion estimator + telemetry-skew gate (this batch). //! AZ-663: residual clustering + candidate emission (next batch). //! AZ-664: FP cap + Q14 learned-CV fallback. use std::sync::Arc; use tokio::sync::broadcast; use shared::health::{ComponentHealth, HealthLevel}; use shared::models::movement::MovementCandidate; pub(crate) mod internal; use internal::ego_motion::EgoMotionCounters; const NAME: &str = "movement_detector"; pub struct MovementDetector { tx: broadcast::Sender, counters: Arc, } impl MovementDetector { pub fn new(channel_capacity: usize) -> Self { let (tx, _rx) = broadcast::channel(channel_capacity); Self { tx, counters: Arc::new(EgoMotionCounters::new()) } } pub fn handle(&self) -> MovementDetectorHandle { MovementDetectorHandle { tx: self.tx.clone(), counters: Arc::clone(&self.counters), } } } #[derive(Clone)] pub struct MovementDetectorHandle { tx: broadcast::Sender, counters: Arc, } impl MovementDetectorHandle { pub fn candidates(&self) -> broadcast::Receiver { self.tx.subscribe() } pub fn health(&self) -> ComponentHealth { let skew_drops = self.counters.skew_drops_total(); let degenerate = self.counters.degenerate_total(); if skew_drops > 0 || degenerate > 0 { ComponentHealth::yellow( NAME, format!( "skew_drops_total={skew_drops} optical_flow_degenerate_total={degenerate}" ), ) } else { ComponentHealth { level: HealthLevel::Disabled, component: NAME, detail: None, } } } } #[cfg(test)] mod tests { use super::*; #[test] fn it_compiles() { let h = MovementDetector::new(16).handle(); assert!(matches!( h.health().level, HealthLevel::Disabled | HealthLevel::Yellow )); } }