feat(groundtruth): radar window adapter for presence, count and range labels

ruview-groundtruth had an MmWave reference modality but no way to read a
radar log. RadarLabels reads one JSON summary per window (the shape a
UART radar reader writes: timestamp_ms, window_start_ms, health, source
flags, presence, target_count, max_target_count, distance_cm, targets
x/y) and turns each window into presence, person-count and nearest-range
labels.

Each label carries its basis and an uncertainty: a count interval from
the counts the window reports, the configured range sigma (a datasheet
figure, so CLAIMED), and the reader's quality. Windows from a simulated
or unverified source, a no_source reader, or with a presence flag that
disagrees with the count are UNKNOWN with a reason rather than guessed.
Labelled windows convert to ReferenceSeries for the existing alignment
and agreement code. Malformed lines are row-numbered InvalidRow errors.

serde_json moves from a dev-dependency to a dependency for the parser.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Mathew Beaneandclaude-flow committed 2026-10-03 23:55:10 -04:00
1 parent 03a897938f
commit b033fd309e
5 files changed
+826 -2

No files matched your search

+1 -2
View File
@@ -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
@@ -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)]
+10
View File
@@ -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};
+490
View File
@@ -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<i64>,
/// Reader health: `ok`, `degraded` or `no_source`.
#[serde(default)]
pub health: Option<String>,
/// Reader-reported frame quality in `[0, 1]`.
#[serde(default)]
pub quality: Option<f64>,
/// Source flags.
#[serde(default)]
pub source: Option<RadarWindowSource>,
/// Explicit presence flag (vitals-class radars).
#[serde(default)]
pub presence: Option<bool>,
/// Targets in the latest frame of the window.
#[serde(default)]
pub target_count: Option<u32>,
/// Most targets in any frame of the window.
#[serde(default)]
pub max_target_count: Option<u32>,
/// Distance to the nearest target, centimetres.
#[serde(default)]
pub distance_cm: Option<f64>,
/// Target positions.
#[serde(default)]
pub targets: Vec<RadarTarget>,
}
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<Vec<RadarWindow>, 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<u32>,
/// Highest count the window carries.
pub count_high: Option<u32>,
/// One-sigma range uncertainty, metres (from configuration, CLAIMED).
pub range_sigma_m: Option<f64>,
/// Reader-reported quality, when given.
pub quality: Option<f64>,
}
/// 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<bool>,
/// What presence was derived from.
pub presence_basis: Option<PresenceBasis>,
/// Person count (latest-frame count when given).
pub person_count: Option<u32>,
/// Nearest-target planar range, metres.
pub nearest_range_m: Option<f64>,
/// 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<f64>, 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<RadarLabel>,
}
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<Self, GroundTruthError> {
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<Item = &RadarLabel> {
self.labels
.iter()
.filter(|l| l.status == LabelStatus::Labelled)
}
fn series(
&self,
measurand: Measurand,
f: impl Fn(&RadarLabel) -> Option<ReferenceObservation>,
) -> Result<ReferenceSeries, GroundTruthError> {
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<ReferenceSeries, GroundTruthError> {
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<ReferenceSeries, GroundTruthError> {
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<ReferenceSeries, GroundTruthError> {
self.series(Measurand::RangeMeters, |l| {
l.nearest_range_m
.map(|r| ReferenceObservation::scalar(l.at_unix_ms, r))
})
}
}
#[cfg(test)]
mod tests;
@@ -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(&nothing, 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<RadarWindow> = 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()));
}