diff --git a/v2/crates/ruview-groundtruth/Cargo.toml b/v2/crates/ruview-groundtruth/Cargo.toml index 850f487e2..38b9c0fd7 100644 --- a/v2/crates/ruview-groundtruth/Cargo.toml +++ b/v2/crates/ruview-groundtruth/Cargo.toml @@ -9,8 +9,7 @@ repository.workspace = true [dependencies] thiserror.workspace = true serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true ruview-ontology = { path = "../ruview-ontology" } ruview-evidence = { path = "../ruview-evidence" } -[dev-dependencies] -serde_json.workspace = true diff --git a/v2/crates/ruview-groundtruth/src/error.rs b/v2/crates/ruview-groundtruth/src/error.rs index 1abf28f4d..5c98943a3 100644 --- a/v2/crates/ruview-groundtruth/src/error.rs +++ b/v2/crates/ruview-groundtruth/src/error.rs @@ -121,6 +121,14 @@ pub enum GroundTruthError { /// Human-readable reason. reason: &'static str, }, + /// A row of an untrusted input file could not be read. + #[error("row {row}: {reason}")] + InvalidRow { + /// 1-based row (line) number. + row: usize, + /// Human-readable reason. + reason: String, + }, /// A failure raised by the [`ruview_evidence`] ledger boundary when /// emitting a record. #[error(transparent)] diff --git a/v2/crates/ruview-groundtruth/src/lib.rs b/v2/crates/ruview-groundtruth/src/lib.rs index 8388acc92..08c940058 100644 --- a/v2/crates/ruview-groundtruth/src/lib.rs +++ b/v2/crates/ruview-groundtruth/src/lib.rs @@ -24,6 +24,10 @@ //! └─ to_evidence_record → ruview_evidence ledger //! ``` //! +//! Radar reference logs (one JSON summary per window) enter through +//! [`RadarLabels`], which turns each window into presence, count and range +//! labels with an uncertainty and a reason when a window cannot be labelled. +//! //! ## Honesty and determinism //! //! - **Canonical vocabulary (ADR-300 rule 3):** the report speaks the @@ -50,6 +54,7 @@ mod agreement; mod align; mod error; mod model; +mod radar; mod scope; mod series; mod source; @@ -60,6 +65,11 @@ pub use align::{ }; pub use error::{GroundTruthError, MAX_STR_LEN}; pub use model::{DataProvenance, Measurand, Reading, ReadingKind}; +pub use radar::{ + label_window, parse_radar_jsonl, LabelStatus, LabelUncertainty, PresenceBasis, + RadarAdapterConfig, RadarLabel, RadarLabels, RadarTarget, RadarWindow, RadarWindowSource, + UnknownReason, LABEL_ABSENT, LABEL_PRESENT, MAX_RADAR_LINE_BYTES, MAX_RADAR_TARGETS, +}; pub use scope::{DistanceBand, LineOfSight, MotionState, SessionScope, MAX_SUBJECTS}; pub use series::{EstimateSeries, ReferenceObservation, ReferenceSeries, MAX_SAMPLES}; pub use source::{ReferenceModality, ReferenceSource}; diff --git a/v2/crates/ruview-groundtruth/src/radar.rs b/v2/crates/ruview-groundtruth/src/radar.rs new file mode 100644 index 000000000..8fa187d08 --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/radar.rs @@ -0,0 +1,490 @@ +//! mmWave radar windows as reference labels (ADR-303 §1). +//! +//! A radar reader (an LD2450-class target tracker, an LD6002/MR60-class vitals +//! radar, or anything that writes the same shape) summarises its UART stream +//! into one JSON object per time window. This module turns those windows into +//! reference labels for presence, person count and nearest-target range, each +//! carrying where it came from and how uncertain it is, and then into the +//! crate's [`ReferenceSeries`] so the usual alignment and agreement machinery +//! applies. +//! +//! The radar stays on the validation plane: nothing here feeds an estimator. +//! +//! ## Input shape +//! +//! One JSON object per line. Every field except `timestamp_ms` is optional and +//! unknown fields are ignored, so a reader that reports less (no targets, no +//! presence flag) still parses: +//! +//! ```json +//! {"timestamp_ms": 1700000001000, "window_start_ms": 1700000000000, +//! "health": "ok", "quality": 1.0, +//! "source": {"verified": true, "simulated": false}, +//! "presence": true, "target_count": 2, "max_target_count": 2, +//! "distance_cm": 140.0, +//! "targets": [{"x_m": 0.4, "y_m": 1.3}, {"x_m": -0.9, "y_m": 2.2}]} +//! ``` +//! +//! ## What a label means +//! +//! - **Presence** comes from the explicit `presence` flag when there is one, +//! otherwise from the count. If the flag and the count disagree, the window is +//! [`LabelStatus::Unknown`], never resolved by guessing. +//! - **Count** is reported as an interval: the low and high of every count the +//! window carries (`target_count`, `max_target_count`, the number of +//! `targets`). The label value is the latest-frame count when present. A +//! radar that tracks moving targets can miss a person who is sitting still, +//! so a count is a lower bound in practice; the interval only captures +//! disagreement inside the window. +//! - **Range** is the nearest target's planar distance `sqrt(x² + y²)`, or +//! `distance_cm` when no target positions are given. Its uncertainty is the +//! configured `range_sigma_m`, which is a datasheet figure (CLAIMED), not a +//! measured one. +//! - A window from a simulated source, an unverified source, or a reader that +//! reports `health: "no_source"` is `Unknown` with a reason. + +use serde::{Deserialize, Serialize}; + +use crate::error::{check_bound, GroundTruthError}; +use crate::model::Measurand; +use crate::series::{ReferenceObservation, ReferenceSeries, MAX_SAMPLES}; +use crate::source::{ReferenceModality, ReferenceSource}; + +/// The largest single JSONL line accepted, in bytes, bounding allocation. +pub const MAX_RADAR_LINE_BYTES: usize = 64 * 1024; +/// The most targets read from one window; extra entries are ignored. +pub const MAX_RADAR_TARGETS: usize = 64; +/// Label used for an occupied window in a presence series. +pub const LABEL_PRESENT: &str = "present"; +/// Label used for an empty window in a presence series. +pub const LABEL_ABSENT: &str = "absent"; + +/// One target position in the radar's local frame, metres (x across, y away). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct RadarTarget { + /// Lateral offset, metres. + pub x_m: f64, + /// Distance away from the radar face, metres. + pub y_m: f64, +} + +/// Where a radar window came from, as reported by its reader. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadarWindowSource { + /// True only when checksum-valid frames arrived from real hardware. + #[serde(default)] + pub verified: bool, + /// True when the window came from a simulator or replay generator. + #[serde(default)] + pub simulated: bool, +} + +/// One radar summary window as written by a reader. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RadarWindow { + /// End of the window, Unix milliseconds. + pub timestamp_ms: i64, + /// Start of the window, Unix milliseconds. + #[serde(default)] + pub window_start_ms: Option, + /// Reader health: `ok`, `degraded` or `no_source`. + #[serde(default)] + pub health: Option, + /// Reader-reported frame quality in `[0, 1]`. + #[serde(default)] + pub quality: Option, + /// Source flags. + #[serde(default)] + pub source: Option, + /// Explicit presence flag (vitals-class radars). + #[serde(default)] + pub presence: Option, + /// Targets in the latest frame of the window. + #[serde(default)] + pub target_count: Option, + /// Most targets in any frame of the window. + #[serde(default)] + pub max_target_count: Option, + /// Distance to the nearest target, centimetres. + #[serde(default)] + pub distance_cm: Option, + /// Target positions. + #[serde(default)] + pub targets: Vec, +} + +impl RadarWindow { + /// The time a label from this window is stamped at: the window midpoint + /// when the start is known, else the window end. + #[must_use] + pub fn label_time_ms(&self) -> i64 { + match self.window_start_ms { + Some(s) if s <= self.timestamp_ms => s + (self.timestamp_ms - s) / 2, + _ => self.timestamp_ms, + } + } +} + +/// Parse radar windows from JSONL text. Blank lines are skipped. A line that +/// is not a valid window is a row-numbered error, never silently dropped. +/// +/// # Errors +/// [`GroundTruthError::InvalidRow`] for an over-long or malformed line, and +/// [`GroundTruthError::TooManySamples`] past [`MAX_SAMPLES`] windows. +pub fn parse_radar_jsonl(text: &str) -> Result, GroundTruthError> { + let mut out = Vec::new(); + for (i, line) in text.lines().enumerate() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let row = i + 1; + if line.len() > MAX_RADAR_LINE_BYTES { + return Err(GroundTruthError::InvalidRow { + row, + reason: format!("line is {} bytes, max {MAX_RADAR_LINE_BYTES}", line.len()), + }); + } + let mut w: RadarWindow = + serde_json::from_str(line).map_err(|e| GroundTruthError::InvalidRow { + row, + reason: e.to_string(), + })?; + w.targets.truncate(MAX_RADAR_TARGETS); + if let Some(h) = &w.health { + check_bound("health", h)?; + } + out.push(w); + if out.len() > MAX_SAMPLES { + return Err(GroundTruthError::TooManySamples { + len: out.len(), + max: MAX_SAMPLES, + }); + } + } + Ok(out) +} + +/// How radar windows are turned into labels. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct RadarAdapterConfig { + /// One-sigma range uncertainty of a single target, metres. A datasheet + /// figure; it is reported with every range label. + pub range_sigma_m: f64, + /// Targets farther than this are ignored as implausible, metres. + pub max_range_m: f64, + /// Refuse windows whose source is not flagged `verified`. + pub require_verified: bool, + /// Accept windows from a simulated source. Off by default: a simulated + /// radar is not ground truth. + pub allow_simulated: bool, + /// Windows below this reader quality are refused. `0.0` accepts all. + pub min_quality: f64, +} + +impl Default for RadarAdapterConfig { + fn default() -> Self { + Self { + range_sigma_m: 0.15, + max_range_m: 10.0, + require_verified: true, + allow_simulated: false, + min_quality: 0.0, + } + } +} + +impl RadarAdapterConfig { + /// Validate the configuration. + /// + /// # Errors + /// [`GroundTruthError::InvalidConfig`] for a non-finite or negative sigma, + /// a non-positive range limit, or a quality floor outside `[0, 1]`. + pub fn validate(&self) -> Result<(), GroundTruthError> { + if !self.range_sigma_m.is_finite() || self.range_sigma_m < 0.0 { + return Err(GroundTruthError::InvalidConfig { + reason: "range_sigma_m must be finite and non-negative", + }); + } + if !self.max_range_m.is_finite() || self.max_range_m <= 0.0 { + return Err(GroundTruthError::InvalidConfig { + reason: "max_range_m must be finite and positive", + }); + } + if !(0.0..=1.0).contains(&self.min_quality) { + return Err(GroundTruthError::InvalidConfig { + reason: "min_quality must be within [0, 1]", + }); + } + Ok(()) + } +} + +/// Why a window produced no label. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UnknownReason { + /// The reader had no sensor data for the window. + NoSource, + /// The source was not flagged as verified hardware. + Unverified, + /// The source was a simulator. + Simulated, + /// The reader's quality was below the configured floor. + LowQuality, + /// The presence flag and the target count disagree. + PresenceCountConflict, + /// The window carried neither a presence flag nor any count. + NoObservation, + /// The window's timestamps were inconsistent (start after end). + BadTimestamps, +} + +/// Whether a window yielded a label. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "status", content = "reason")] +pub enum LabelStatus { + /// The window produced a label. + Labelled, + /// The window produced no label; UNKNOWN is a value, not an error. + Unknown(UnknownReason), +} + +/// Uncertainty attached to one radar label. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct LabelUncertainty { + /// Lowest count the window carries. + pub count_low: Option, + /// Highest count the window carries. + pub count_high: Option, + /// One-sigma range uncertainty, metres (from configuration, CLAIMED). + pub range_sigma_m: Option, + /// Reader-reported quality, when given. + pub quality: Option, +} + +/// Where a radar label came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PresenceBasis { + /// The window's explicit presence flag (agreeing with the count, if any). + PresenceFlag, + /// Derived from the target count. + TargetCount, +} + +/// One radar window turned into reference labels. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RadarLabel { + /// Label time, Unix milliseconds (window midpoint, see + /// [`RadarWindow::label_time_ms`]). + pub at_unix_ms: i64, + /// Window start, Unix milliseconds (the end when the reader omits it). + pub window_start_ms: i64, + /// Window end, Unix milliseconds. + pub window_end_ms: i64, + /// Whether a label was produced. + pub status: LabelStatus, + /// Someone present. + pub presence: Option, + /// What presence was derived from. + pub presence_basis: Option, + /// Person count (latest-frame count when given). + pub person_count: Option, + /// Nearest-target planar range, metres. + pub nearest_range_m: Option, + /// Uncertainty of the above. + pub uncertainty: LabelUncertainty, + /// 0-based index of the source window in its log. + pub window_index: usize, +} + +impl RadarLabel { + fn unknown(w: &RadarWindow, index: usize, reason: UnknownReason) -> Self { + Self { + at_unix_ms: w.label_time_ms(), + window_start_ms: w.window_start_ms.unwrap_or(w.timestamp_ms), + window_end_ms: w.timestamp_ms, + status: LabelStatus::Unknown(reason), + presence: None, + presence_basis: None, + person_count: None, + nearest_range_m: None, + uncertainty: LabelUncertainty { + count_low: None, + count_high: None, + range_sigma_m: None, + quality: w.quality, + }, + window_index: index, + } + } +} + +/// Turn one radar window into a label. +#[must_use] +pub fn label_window(w: &RadarWindow, index: usize, cfg: &RadarAdapterConfig) -> RadarLabel { + if matches!(w.window_start_ms, Some(s) if s > w.timestamp_ms) { + return RadarLabel::unknown(w, index, UnknownReason::BadTimestamps); + } + if w.health.as_deref() == Some("no_source") { + return RadarLabel::unknown(w, index, UnknownReason::NoSource); + } + let src = w.source.unwrap_or_default(); + if src.simulated && !cfg.allow_simulated { + return RadarLabel::unknown(w, index, UnknownReason::Simulated); + } + if cfg.require_verified && !src.verified && !(src.simulated && cfg.allow_simulated) { + return RadarLabel::unknown(w, index, UnknownReason::Unverified); + } + if cfg.min_quality > 0.0 && !matches!(w.quality, Some(q) if q >= cfg.min_quality) { + return RadarLabel::unknown(w, index, UnknownReason::LowQuality); + } + + let in_range: Vec<&RadarTarget> = w + .targets + .iter() + .filter(|t| t.x_m.is_finite() && t.y_m.is_finite()) + .filter(|t| t.x_m.hypot(t.y_m) <= cfg.max_range_m) + .collect(); + + let positions = (!w.targets.is_empty()).then_some(in_range.len() as u32); + let counts = [w.target_count, w.max_target_count, positions]; + let count_low = counts.iter().flatten().copied().min(); + let count_high = counts.iter().flatten().copied().max(); + let person_count = w.target_count.or(positions).or(w.max_target_count); + + let (presence, basis) = match (w.presence, person_count) { + (Some(p), Some(c)) if p != (c > 0) => { + return RadarLabel::unknown(w, index, UnknownReason::PresenceCountConflict); + } + (Some(p), _) => (p, PresenceBasis::PresenceFlag), + (None, Some(c)) => (c > 0, PresenceBasis::TargetCount), + (None, None) => return RadarLabel::unknown(w, index, UnknownReason::NoObservation), + }; + + let nearest_range_m = in_range + .iter() + .map(|t| t.x_m.hypot(t.y_m)) + .fold(None, |acc: Option, r| { + Some(acc.map_or(r, |a| a.min(r))) + }) + .or_else(|| { + w.distance_cm + .filter(|d| presence && d.is_finite() && *d > 0.0) + .map(|d| d / 100.0) + .filter(|r| *r <= cfg.max_range_m) + }); + + RadarLabel { + at_unix_ms: w.label_time_ms(), + window_start_ms: w.window_start_ms.unwrap_or(w.timestamp_ms), + window_end_ms: w.timestamp_ms, + status: LabelStatus::Labelled, + presence: Some(presence), + presence_basis: Some(basis), + person_count, + nearest_range_m, + uncertainty: LabelUncertainty { + count_low, + count_high, + range_sigma_m: nearest_range_m.map(|_| cfg.range_sigma_m), + quality: w.quality, + }, + window_index: index, + } +} + +/// Labels for a whole radar log, with the source they are attributed to. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RadarLabels { + /// The reference source (always [`ReferenceModality::MmWave`]). + pub source: ReferenceSource, + /// The configuration the labels were made with. + pub config: RadarAdapterConfig, + /// One label per window, in log order. + pub labels: Vec, +} + +impl RadarLabels { + /// Label every window of a radar log. + /// + /// # Errors + /// [`GroundTruthError::InvalidConfig`] for a bad configuration, or a + /// metadata error from [`ReferenceSource::new`]. + pub fn from_windows( + name: &str, + device: &str, + windows: &[RadarWindow], + cfg: RadarAdapterConfig, + ) -> Result { + cfg.validate()?; + let source = ReferenceSource::new(ReferenceModality::MmWave, name, device, "fmcw-radar")?; + let labels = windows + .iter() + .enumerate() + .map(|(i, w)| label_window(w, i, &cfg)) + .collect(); + Ok(Self { + source, + config: cfg, + labels, + }) + } + + /// Labelled windows only. + pub fn labelled(&self) -> impl Iterator { + self.labels + .iter() + .filter(|l| l.status == LabelStatus::Labelled) + } + + fn series( + &self, + measurand: Measurand, + f: impl Fn(&RadarLabel) -> Option, + ) -> Result { + let samples = self.labelled().filter_map(f).collect(); + ReferenceSeries::new(self.source.clone(), measurand, samples) + } + + /// A [`Measurand::Presence`] series of `present`/`absent` labels. + /// + /// # Errors + /// As [`ReferenceSeries::new`] (an empty or non-monotonic series). + pub fn presence_series(&self) -> Result { + self.series(Measurand::Presence, |l| { + l.presence.map(|p| { + ReferenceObservation::label( + l.at_unix_ms, + if p { LABEL_PRESENT } else { LABEL_ABSENT }, + ) + }) + }) + } + + /// A [`Measurand::PersonCount`] series. + /// + /// # Errors + /// As [`ReferenceSeries::new`]. + pub fn person_count_series(&self) -> Result { + self.series(Measurand::PersonCount, |l| { + l.person_count + .map(|c| ReferenceObservation::scalar(l.at_unix_ms, f64::from(c))) + }) + } + + /// A [`Measurand::RangeMeters`] series of nearest-target range. + /// + /// # Errors + /// As [`ReferenceSeries::new`]. + pub fn range_series(&self) -> Result { + self.series(Measurand::RangeMeters, |l| { + l.nearest_range_m + .map(|r| ReferenceObservation::scalar(l.at_unix_ms, r)) + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/v2/crates/ruview-groundtruth/src/radar/tests.rs b/v2/crates/ruview-groundtruth/src/radar/tests.rs new file mode 100644 index 000000000..a5de7516d --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/radar/tests.rs @@ -0,0 +1,317 @@ +//! Unit tests for the radar window adapter. + +use super::*; +use crate::model::Reading; + +fn win(t: i64) -> RadarWindow { + RadarWindow { + timestamp_ms: t + 1000, + window_start_ms: Some(t), + health: Some("ok".into()), + quality: Some(1.0), + source: Some(RadarWindowSource { + verified: true, + simulated: false, + }), + presence: None, + target_count: None, + max_target_count: None, + distance_cm: None, + targets: Vec::new(), + } +} + +fn cfg() -> RadarAdapterConfig { + RadarAdapterConfig::default() +} + +#[test] +fn parses_tracker_and_vitals_lines_and_ignores_unknown_fields() { + let text = r#" +{"timestamp_ms":2000,"window_start_ms":1000,"health":"ok","quality":1.0,"source":{"kind":"uart","verified":true,"simulated":false},"target_count":2,"max_target_count":3,"targets":[{"slot":1,"x_m":0.3,"y_m":1.2,"speed_mps":0.1},{"slot":2,"x_m":-1.0,"y_m":2.0}],"frames":10} + +{"timestamp_ms":3000,"presence":true,"distance_cm":150.0,"heart_rate_bpm":61.0} +"#; + let w = parse_radar_jsonl(text).unwrap(); + assert_eq!(w.len(), 2); + assert_eq!(w[0].targets.len(), 2); + assert_eq!(w[0].max_target_count, Some(3)); + assert_eq!(w[1].presence, Some(true)); + assert_eq!(w[1].window_start_ms, None); +} + +#[test] +fn malformed_line_is_a_row_numbered_error() { + let err = parse_radar_jsonl("{\"timestamp_ms\":1}\n\nnot json\n").unwrap_err(); + assert!(matches!(err, GroundTruthError::InvalidRow { row: 3, .. })); + let err = parse_radar_jsonl("{\"presence\":true}").unwrap_err(); + assert!(matches!(err, GroundTruthError::InvalidRow { row: 1, .. })); +} + +#[test] +fn over_long_line_is_rejected() { + let line = format!( + "{{\"timestamp_ms\":1,\"health\":\"{}\"}}", + "x".repeat(MAX_RADAR_LINE_BYTES) + ); + let err = parse_radar_jsonl(&line).unwrap_err(); + assert!(matches!(err, GroundTruthError::InvalidRow { row: 1, .. })); +} + +#[test] +fn tracker_window_gives_count_interval_and_nearest_range() { + let mut w = win(0); + w.target_count = Some(2); + w.max_target_count = Some(3); + w.targets = vec![ + RadarTarget { x_m: 0.6, y_m: 0.8 }, + RadarTarget { x_m: 0.0, y_m: 2.5 }, + ]; + let l = label_window(&w, 0, &cfg()); + assert_eq!(l.status, LabelStatus::Labelled); + assert_eq!(l.presence, Some(true)); + assert_eq!(l.presence_basis, Some(PresenceBasis::TargetCount)); + assert_eq!(l.person_count, Some(2)); + assert_eq!( + (l.uncertainty.count_low, l.uncertainty.count_high), + (Some(2), Some(3)) + ); + assert!((l.nearest_range_m.unwrap() - 1.0).abs() < 1e-12); + assert_eq!(l.uncertainty.range_sigma_m, Some(0.15)); + assert_eq!(l.at_unix_ms, 500); +} + +#[test] +fn empty_tracker_window_is_absent_with_zero_count() { + let mut w = win(0); + w.target_count = Some(0); + let l = label_window(&w, 0, &cfg()); + assert_eq!(l.presence, Some(false)); + assert_eq!(l.person_count, Some(0)); + assert_eq!(l.nearest_range_m, None); + assert_eq!(l.uncertainty.range_sigma_m, None); +} + +#[test] +fn vitals_window_uses_presence_flag_and_distance() { + let mut w = win(0); + w.presence = Some(true); + w.distance_cm = Some(180.0); + let l = label_window(&w, 0, &cfg()); + assert_eq!(l.presence_basis, Some(PresenceBasis::PresenceFlag)); + assert_eq!(l.person_count, None); + assert!((l.nearest_range_m.unwrap() - 1.8).abs() < 1e-12); +} + +#[test] +fn presence_count_conflict_is_unknown() { + let mut w = win(0); + w.presence = Some(false); + w.target_count = Some(1); + let l = label_window(&w, 0, &cfg()); + assert_eq!( + l.status, + LabelStatus::Unknown(UnknownReason::PresenceCountConflict) + ); + assert_eq!(l.presence, None); +} + +#[test] +fn untrusted_sources_are_unknown() { + let mut w = win(0); + w.target_count = Some(1); + + let mut sim = w.clone(); + sim.source = Some(RadarWindowSource { + verified: false, + simulated: true, + }); + assert_eq!( + label_window(&sim, 0, &cfg()).status, + LabelStatus::Unknown(UnknownReason::Simulated) + ); + let allow = RadarAdapterConfig { + allow_simulated: true, + ..cfg() + }; + assert_eq!(label_window(&sim, 0, &allow).status, LabelStatus::Labelled); + + let mut unv = w.clone(); + unv.source = None; + assert_eq!( + label_window(&unv, 0, &cfg()).status, + LabelStatus::Unknown(UnknownReason::Unverified) + ); + let lax = RadarAdapterConfig { + require_verified: false, + ..cfg() + }; + assert_eq!(label_window(&unv, 0, &lax).status, LabelStatus::Labelled); + + let mut dead = w.clone(); + dead.health = Some("no_source".into()); + assert_eq!( + label_window(&dead, 0, &cfg()).status, + LabelStatus::Unknown(UnknownReason::NoSource) + ); + + let mut low = w.clone(); + low.quality = Some(0.2); + let strict = RadarAdapterConfig { + min_quality: 0.5, + ..cfg() + }; + assert_eq!( + label_window(&low, 0, &strict).status, + LabelStatus::Unknown(UnknownReason::LowQuality) + ); + low.quality = None; + assert_eq!( + label_window(&low, 0, &strict).status, + LabelStatus::Unknown(UnknownReason::LowQuality) + ); + + let mut nothing = win(0); + nothing.targets.clear(); + assert_eq!( + label_window(¬hing, 0, &cfg()).status, + LabelStatus::Unknown(UnknownReason::NoObservation) + ); + + let mut bad = w; + bad.window_start_ms = Some(bad.timestamp_ms + 1); + assert_eq!( + label_window(&bad, 0, &cfg()).status, + LabelStatus::Unknown(UnknownReason::BadTimestamps) + ); +} + +#[test] +fn out_of_range_and_non_finite_targets_are_ignored() { + let mut w = win(0); + w.targets = vec![ + RadarTarget { + x_m: 0.0, + y_m: 40.0, + }, + RadarTarget { + x_m: f64::NAN, + y_m: 1.0, + }, + ]; + let l = label_window(&w, 0, &cfg()); + assert_eq!(l.person_count, Some(0)); + assert_eq!(l.presence, Some(false)); +} + +#[test] +fn series_feed_the_agreement_machinery() { + let mut windows = Vec::new(); + for (i, n) in [0u32, 1, 2, 1].iter().enumerate() { + let mut w = win(i as i64 * 1000); + w.target_count = Some(*n); + w.targets = (0..*n) + .map(|k| RadarTarget { + x_m: 0.0, + y_m: 1.0 + f64::from(k), + }) + .collect(); + windows.push(w); + } + let mut unknown = win(10_000); + unknown.source = None; + windows.push(unknown); + + let labels = RadarLabels::from_windows("radar-1", "ld2450", &windows, cfg()).unwrap(); + assert_eq!(labels.labels.len(), 5); + assert_eq!(labels.labelled().count(), 4); + assert_eq!(labels.source.modality, ReferenceModality::MmWave); + + let p = labels.presence_series().unwrap(); + let got: Vec<&str> = p + .samples() + .iter() + .filter_map(|s| s.reading.as_label()) + .collect(); + assert_eq!(got, ["absent", "present", "present", "present"]); + + let c = labels.person_count_series().unwrap(); + assert_eq!(c.samples()[2].reading, Reading::Scalar(2.0)); + + let r = labels.range_series().unwrap(); + assert_eq!(r.len(), 3); + assert_eq!(r.samples()[0].reading, Reading::Scalar(1.0)); +} + +#[test] +fn bad_config_is_rejected() { + let bad = RadarAdapterConfig { + range_sigma_m: -1.0, + ..cfg() + }; + assert!(RadarLabels::from_windows("r", "d", &[], bad).is_err()); + let bad = RadarAdapterConfig { + min_quality: 2.0, + ..cfg() + }; + assert!(bad.validate().is_err()); +} + +#[test] +fn radar_presence_grades_an_rf_estimate() { + use crate::{ + AgreementMetrics, AgreementReport, AlignmentConfig, DataProvenance, DistanceBand, + EstimateSeries, GradingPolicy, LineOfSight, MotionState, SessionScope, + }; + use ruview_ontology::EvidenceLevel; + + let truth = [0u32, 0, 1, 1, 1, 0]; + let windows: Vec = truth + .iter() + .enumerate() + .map(|(i, n)| { + let mut w = win(i as i64 * 1000); + w.target_count = Some(*n); + w + }) + .collect(); + let labels = RadarLabels::from_windows("radar-1", "ld2450", &windows, cfg()).unwrap(); + let reference = labels.presence_series().unwrap(); + + // RF estimate stamped at the same midpoints, wrong on one window. + let est_labels = [ + "absent", "present", "present", "present", "present", "absent", + ]; + let est = EstimateSeries::new( + Measurand::Presence, + "rf-presence-v1", + DataProvenance::Real, + est_labels + .iter() + .enumerate() + .map(|(i, l)| ReferenceObservation::label(i as i64 * 1000 + 500, *l)) + .collect(), + ) + .unwrap(); + let scope = + SessionScope::new(1, MotionState::Mixed, LineOfSight::Los, DistanceBand::Near).unwrap(); + let align = AlignmentConfig { + grid_ms: 1000, + max_lag_ms: 0, + max_gap_ms: 400, + }; + let policy = GradingPolicy::new(0.5, EvidenceLevel::L2, None).unwrap(); + let report = AgreementReport::build(&est, &reference, scope, &align, 0.0, &policy).unwrap(); + match report.metrics { + AgreementMetrics::Categorical { n_agree, .. } => assert_eq!(n_agree, 5), + other => panic!("expected categorical metrics, got {other:?}"), + } +} + +#[test] +fn labels_are_deterministic() { + let mut w = win(0); + w.target_count = Some(1); + w.targets = vec![RadarTarget { x_m: 0.3, y_m: 0.4 }]; + assert_eq!(label_window(&w, 0, &cfg()), label_window(&w, 0, &cfg())); +}