diff --git a/v2/crates/ruview-groundtruth/examples/radar_autolabel.rs b/v2/crates/ruview-groundtruth/examples/radar_autolabel.rs new file mode 100644 index 000000000..8dc215123 --- /dev/null +++ b/v2/crates/ruview-groundtruth/examples/radar_autolabel.rs @@ -0,0 +1,150 @@ +//! Label RuView recordings with occupancy from radar logs. +//! +//! ```text +//! cargo run -p ruview-groundtruth --example radar_autolabel -- \ +//! --session mon data/recordings/mon.csi.jsonl radar/mon.jsonl \ +//! --session tue data/recordings/tue.csi.jsonl radar/tue.jsonl \ +//! --test-session tue --offset-ms 0 --out labels.jsonl +//! ``` +//! +//! Writes one labelled window per line (to `--out`, or stdout) and a JSON +//! summary to stderr: per-session kept/skipped counts and, when +//! `--test-session` is given, the session-disjoint majority/mean baseline. +//! `--offset-ms` is the recording clock minus the radar clock and applies to +//! every session. `--allow-unverified` accepts radar windows whose source is +//! not flagged as verified hardware. + +use std::fs; +use std::io::Write; +use std::process::ExitCode; + +use ruview_groundtruth::{ + auto_label, majority_baseline, parse_radar_jsonl, parse_recording_jsonl, + session_disjoint_split, AutoLabelConfig, LabelledWindow, RadarAdapterConfig, RadarLabels, +}; +use serde_json::json; + +struct Args { + sessions: Vec<(String, String, String)>, + test_sessions: Vec, + out: Option, + label: AutoLabelConfig, + radar: RadarAdapterConfig, +} + +fn next(it: &mut impl Iterator, flag: &str) -> Result { + it.next().ok_or_else(|| format!("{flag} needs a value")) +} + +fn parse_args() -> Result { + let mut a = Args { + sessions: Vec::new(), + test_sessions: Vec::new(), + out: None, + label: AutoLabelConfig::default(), + radar: RadarAdapterConfig::default(), + }; + let mut it = std::env::args().skip(1); + while let Some(flag) = it.next() { + match flag.as_str() { + "--session" => { + let name = next(&mut it, "--session")?; + let rec = next(&mut it, "--session")?; + let radar = next(&mut it, "--session")?; + a.sessions.push((name, rec, radar)); + } + "--test-session" => a.test_sessions.push(next(&mut it, &flag)?), + "--out" => a.out = Some(next(&mut it, &flag)?), + "--offset-ms" => { + a.label.radar_offset_ms = next(&mut it, &flag)? + .parse() + .map_err(|e| format!("--offset-ms: {e}"))?; + } + "--min-frames" => { + a.label.min_csi_frames = next(&mut it, &flag)? + .parse() + .map_err(|e| format!("--min-frames: {e}"))?; + } + "--empty-hold-ms" => { + a.label.empty_hold_ms = next(&mut it, &flag)? + .parse() + .map_err(|e| format!("--empty-hold-ms: {e}"))?; + } + "--allow-unverified" => a.radar.require_verified = false, + other => return Err(format!("unknown argument {other}")), + } + } + if a.sessions.is_empty() { + return Err("at least one --session NAME RECORDING RADAR is required".into()); + } + Ok(a) +} + +fn run() -> Result<(), String> { + let args = parse_args()?; + let mut all: Vec = Vec::new(); + let mut per_session = Vec::new(); + + for (name, rec_path, radar_path) in &args.sessions { + let rec = fs::read_to_string(rec_path).map_err(|e| format!("{rec_path}: {e}"))?; + let rad = fs::read_to_string(radar_path).map_err(|e| format!("{radar_path}: {e}"))?; + let frames = parse_recording_jsonl(&rec).map_err(|e| format!("{rec_path}: {e}"))?; + let windows = parse_radar_jsonl(&rad).map_err(|e| format!("{radar_path}: {e}"))?; + let labels = RadarLabels::from_windows(name, "radar", &windows, args.radar) + .map_err(|e| e.to_string())?; + let out = auto_label(name, &frames, &labels, args.label).map_err(|e| e.to_string())?; + per_session.push(json!({ + "session": name, + "csi_frames": frames.len(), + "radar_windows": windows.len(), + "kept": out.windows.len(), + "skipped": out.skipped, + "radar_unknown": out.radar_unknown, + })); + all.extend(out.windows); + } + + let mut sink: Box = match &args.out { + Some(p) => Box::new(fs::File::create(p).map_err(|e| format!("{p}: {e}"))?), + None => Box::new(std::io::stdout().lock()), + }; + for w in &all { + let line = serde_json::to_string(w).map_err(|e| e.to_string())?; + writeln!(sink, "{line}").map_err(|e| e.to_string())?; + } + + let baseline = if args.test_sessions.is_empty() { + None + } else { + let names: Vec<&str> = args.test_sessions.iter().map(String::as_str).collect(); + let split = session_disjoint_split(&all, &names).map_err(|e| e.to_string())?; + let b = majority_baseline(&split.train, &split.test).map_err(|e| e.to_string())?; + Some(json!({ + "train_sessions": split.train_sessions, + "test_sessions": split.test_sessions, + "baseline": b, + })) + }; + let summary = json!({ + "offset_ms": args.label.radar_offset_ms, + "label_config": args.label, + "radar_config": args.radar, + "sessions": per_session, + "split": baseline, + }); + eprintln!( + "{}", + serde_json::to_string_pretty(&summary).map_err(|e| e.to_string())? + ); + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("radar_autolabel: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/v2/crates/ruview-groundtruth/src/autolabel.rs b/v2/crates/ruview-groundtruth/src/autolabel.rs new file mode 100644 index 000000000..9212dfe65 --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/autolabel.rs @@ -0,0 +1,461 @@ +//! Occupancy auto-labelling from a RuView recording plus a radar log. +//! +//! A RuView recording (`.csi.jsonl`, one line per CSI frame in the +//! `RecordedFrame` shape of the sensing server's `recording.rs`) and a radar log (see [`crate::radar`]) cover the same session. This +//! module cuts the recording at the radar's window boundaries and gives each +//! cut an occupancy label (empty / occupied) and a person count taken from the +//! radar. The output is training and evaluation data for CSI occupancy models; +//! the radar never becomes an input to them. +//! +//! The radar and the recording are stamped by different clocks. The offset +//! between them is a required, reported parameter, never estimated silently. +//! +//! Windows are dropped, with a counted reason, when the radar window has no +//! label, when too few CSI frames fall inside it, or when it says "empty" too +//! soon after an occupied window (a tracker can lose a person who stops +//! moving, so a short gap is more likely a miss than an exit). + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::error::{check_bound, check_nonempty, GroundTruthError}; +use crate::radar::{LabelStatus, RadarLabels, UnknownReason}; +use crate::series::MAX_SAMPLES; + +/// The largest recording line accepted, in bytes, bounding allocation. +pub const MAX_RECORDING_LINE_BYTES: usize = 1024 * 1024; + +#[derive(Deserialize)] +struct RecordedFrameLine { + timestamp: f64, + #[serde(default)] + subcarriers: Vec, + #[serde(default)] + rssi: Option, +} + +/// One CSI frame from a recording, reduced to what windowing needs. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct CsiFrame { + /// Frame time on the recording clock, Unix milliseconds. + pub at_unix_ms: i64, + /// Received signal strength, dBm, when recorded. + pub rssi: Option, + /// Mean subcarrier amplitude of the frame, when it has subcarriers. + pub amplitude_mean: Option, +} + +/// Parse a RuView `.csi.jsonl` recording. Blank lines are skipped; a malformed +/// line or a non-finite timestamp is a row-numbered error. +/// +/// # Errors +/// [`GroundTruthError::InvalidRow`] or [`GroundTruthError::TooManySamples`]. +pub fn parse_recording_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_RECORDING_LINE_BYTES { + return Err(GroundTruthError::InvalidRow { + row, + reason: format!( + "line is {} bytes, max {MAX_RECORDING_LINE_BYTES}", + line.len() + ), + }); + } + let f: RecordedFrameLine = + serde_json::from_str(line).map_err(|e| GroundTruthError::InvalidRow { + row, + reason: e.to_string(), + })?; + if !f.timestamp.is_finite() || f.timestamp < 0.0 { + return Err(GroundTruthError::InvalidRow { + row, + reason: "timestamp must be finite, non-negative Unix seconds".into(), + }); + } + let finite: Vec = f + .subcarriers + .iter() + .copied() + .filter(|v| v.is_finite()) + .collect(); + out.push(CsiFrame { + at_unix_ms: (f.timestamp * 1000.0).round() as i64, + rssi: f.rssi.filter(|r| r.is_finite()), + amplitude_mean: (!finite.is_empty()) + .then(|| finite.iter().map(|v| v.abs()).sum::() / finite.len() as f64), + }); + if out.len() > MAX_SAMPLES { + return Err(GroundTruthError::TooManySamples { + len: out.len(), + max: MAX_SAMPLES, + }); + } + } + Ok(out) +} + +/// Occupancy class of a window. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Occupancy { + /// The radar saw nobody. + Empty, + /// The radar saw at least one person. + Occupied, +} + +/// A compact summary of the CSI frames inside one window. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct CsiWindowSummary { + /// Frames in the window. + pub n_frames: usize, + /// Mean RSSI over frames that recorded one. + pub rssi_mean: Option, + /// Mean of per-frame mean amplitude. + pub amplitude_mean: Option, + /// Population variance of per-frame mean amplitude, a crude motion proxy. + pub amplitude_var: Option, +} + +fn mean_var(xs: &[f64]) -> (Option, Option) { + if xs.is_empty() { + return (None, None); + } + let n = xs.len() as f64; + let m = xs.iter().sum::() / n; + let v = xs.iter().map(|x| (x - m) * (x - m)).sum::() / n; + (Some(m), Some(v)) +} + +fn summarize(frames: &[CsiFrame]) -> CsiWindowSummary { + let rssi: Vec = frames.iter().filter_map(|f| f.rssi).collect(); + let amp: Vec = frames.iter().filter_map(|f| f.amplitude_mean).collect(); + let (amplitude_mean, amplitude_var) = mean_var(&); + CsiWindowSummary { + n_frames: frames.len(), + rssi_mean: mean_var(&rssi).0, + amplitude_mean, + amplitude_var, + } +} + +/// One labelled window of a recording. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LabelledWindow { + /// Session (recording) name; the unit of a disjoint split. + pub session: String, + /// Window start on the recording clock, Unix milliseconds. + pub start_ms: i64, + /// Window end on the recording clock, Unix milliseconds. + pub end_ms: i64, + /// Occupancy label. + pub occupancy: Occupancy, + /// Person count, when the radar reported one. + pub person_count: Option, + /// Lowest count the radar window carried. + pub count_low: Option, + /// Highest count the radar window carried. + pub count_high: Option, + /// Index of the radar window in its log. + pub radar_window_index: usize, + /// Summary of the CSI frames in the window. + pub csi: CsiWindowSummary, +} + +/// Why a radar window produced no labelled window. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SkipReason { + /// The radar window had no label. + RadarUnknown, + /// Fewer CSI frames than `min_csi_frames` fell inside the window. + TooFewCsiFrames, + /// An empty label within `empty_hold_ms` of an occupied one. + RecentlyOccupied, +} + +/// How a session is labelled. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AutoLabelConfig { + /// Added to radar timestamps to put them on the recording clock, + /// milliseconds (recording clock minus radar clock). + pub radar_offset_ms: i64, + /// Fewest CSI frames a window needs to be kept. + pub min_csi_frames: usize, + /// An empty window starting within this long after an occupied window + /// ends is dropped, milliseconds. `0` keeps every empty window. + pub empty_hold_ms: i64, +} + +impl Default for AutoLabelConfig { + fn default() -> Self { + Self { + radar_offset_ms: 0, + min_csi_frames: 5, + empty_hold_ms: 5_000, + } + } +} + +/// The labelled windows of one session plus what was dropped and why. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AutoLabelOutput { + /// Session name. + pub session: String, + /// The configuration used, including the clock offset. + pub config: AutoLabelConfig, + /// Kept windows, in time order. + pub windows: Vec, + /// Dropped radar windows by reason. + pub skipped: BTreeMap, + /// Unlabelled radar windows by the radar adapter's reason. + pub radar_unknown: BTreeMap, +} + +/// Label one session. +/// +/// `frames` need not be sorted. Radar labels are processed in time order. +/// +/// # Errors +/// [`GroundTruthError::EmptyField`]/[`GroundTruthError::TooLong`] for a bad +/// session name, or [`GroundTruthError::InvalidConfig`] for a negative hold. +pub fn auto_label( + session: &str, + frames: &[CsiFrame], + radar: &RadarLabels, + cfg: AutoLabelConfig, +) -> Result { + check_nonempty("session", session)?; + check_bound("session", session)?; + if cfg.empty_hold_ms < 0 { + return Err(GroundTruthError::InvalidConfig { + reason: "empty_hold_ms must be non-negative", + }); + } + let mut frames = frames.to_vec(); + frames.sort_by_key(|f| f.at_unix_ms); + + let mut labels: Vec<_> = radar.labels.iter().collect(); + labels.sort_by_key(|l| (l.window_start_ms, l.window_end_ms)); + + let mut out = AutoLabelOutput { + session: session.to_owned(), + config: cfg, + windows: Vec::new(), + skipped: BTreeMap::new(), + radar_unknown: BTreeMap::new(), + }; + let mut last_occupied_end: Option = None; + + for l in labels { + if let LabelStatus::Unknown(r) = l.status { + *out.radar_unknown.entry(r).or_default() += 1; + *out.skipped.entry(SkipReason::RadarUnknown).or_default() += 1; + continue; + } + let Some(present) = l.presence else { + *out.skipped.entry(SkipReason::RadarUnknown).or_default() += 1; + continue; + }; + let start = l.window_start_ms.saturating_add(cfg.radar_offset_ms); + let end = l.window_end_ms.saturating_add(cfg.radar_offset_ms); + let occupancy = if present { + Occupancy::Occupied + } else { + Occupancy::Empty + }; + + if occupancy == Occupancy::Occupied { + last_occupied_end = Some(end); + } else if matches!(last_occupied_end, Some(e) if start.saturating_sub(e) < cfg.empty_hold_ms) + { + *out.skipped.entry(SkipReason::RecentlyOccupied).or_default() += 1; + continue; + } + + // Half-open [start, end); a zero-length window takes the frames at `start`. + let lo = frames.partition_point(|f| f.at_unix_ms < start); + let hi = if end > start { + frames.partition_point(|f| f.at_unix_ms < end) + } else { + frames.partition_point(|f| f.at_unix_ms <= start) + }; + let inside = &frames[lo..hi]; + if inside.len() < cfg.min_csi_frames.max(1) { + *out.skipped.entry(SkipReason::TooFewCsiFrames).or_default() += 1; + continue; + } + + out.windows.push(LabelledWindow { + session: session.to_owned(), + start_ms: start, + end_ms: end, + occupancy, + person_count: l.person_count, + count_low: l.uncertainty.count_low, + count_high: l.uncertainty.count_high, + radar_window_index: l.window_index, + csi: summarize(inside), + }); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::radar::{RadarAdapterConfig, RadarWindow, RadarWindowSource}; + + /// Synthetic CSI at 10 Hz from `t0` for `secs` seconds. + fn csi(t0_ms: i64, secs: i64) -> Vec { + (0..secs * 10) + .map(|i| CsiFrame { + at_unix_ms: t0_ms + i * 100, + rssi: Some(-50.0), + amplitude_mean: Some(10.0 + (i % 3) as f64), + }) + .collect() + } + + fn radar(t0_ms: i64, counts: &[Option]) -> RadarLabels { + let windows: Vec = counts + .iter() + .enumerate() + .map(|(i, c)| RadarWindow { + timestamp_ms: t0_ms + (i as i64 + 1) * 1000, + window_start_ms: Some(t0_ms + i as i64 * 1000), + health: Some("ok".into()), + quality: Some(1.0), + source: Some(RadarWindowSource { + verified: c.is_some(), + simulated: false, + }), + presence: None, + target_count: Some(c.unwrap_or(0)), + max_target_count: None, + distance_cm: None, + targets: Vec::new(), + }) + .collect(); + RadarLabels::from_windows("radar-1", "ld2450", &windows, RadarAdapterConfig::default()) + .unwrap() + } + + #[test] + fn parses_recording_lines() { + let text = r#"{"timestamp":1700000000.25,"subcarriers":[1.0,-3.0],"rssi":-48.0,"noise_floor":-90.0,"features":{}} + +{"timestamp":1700000000.35,"subcarriers":[],"rssi":-49.0,"noise_floor":-90.0,"features":{}}"#; + let f = parse_recording_jsonl(text).unwrap(); + assert_eq!(f.len(), 2); + assert_eq!(f[0].at_unix_ms, 1_700_000_000_250); + assert_eq!(f[0].amplitude_mean, Some(2.0)); + assert_eq!(f[1].amplitude_mean, None); + } + + #[test] + fn bad_recording_rows_are_numbered() { + let err = parse_recording_jsonl("{\"timestamp\":1.0}\nnope").unwrap_err(); + assert!(matches!(err, GroundTruthError::InvalidRow { row: 2, .. })); + let err = parse_recording_jsonl("{\"timestamp\":-1.0}").unwrap_err(); + assert!(matches!(err, GroundTruthError::InvalidRow { row: 1, .. })); + } + + #[test] + fn labels_follow_radar_windows_and_count_frames() { + let r = radar(0, &[Some(0), Some(1), Some(2)]); + let cfg = AutoLabelConfig { + empty_hold_ms: 0, + ..AutoLabelConfig::default() + }; + let out = auto_label("s1", &csi(0, 3), &r, cfg).unwrap(); + assert_eq!(out.windows.len(), 3); + assert_eq!(out.windows[0].occupancy, Occupancy::Empty); + assert_eq!(out.windows[2].person_count, Some(2)); + assert!(out.windows.iter().all(|w| w.csi.n_frames == 10)); + assert_eq!(out.windows[1].csi.rssi_mean, Some(-50.0)); + assert!(out.skipped.is_empty()); + } + + #[test] + fn clock_offset_moves_radar_onto_the_recording() { + // Radar clock runs 60 s behind the recording. + let r = radar(0, &[Some(1), Some(1)]); + let frames = csi(60_000, 2); + let none = auto_label("s1", &frames, &r, AutoLabelConfig::default()).unwrap(); + assert_eq!(none.windows.len(), 0); + assert_eq!(none.skipped.get(&SkipReason::TooFewCsiFrames), Some(&2)); + + let cfg = AutoLabelConfig { + radar_offset_ms: 60_000, + ..AutoLabelConfig::default() + }; + let out = auto_label("s1", &frames, &r, cfg).unwrap(); + assert_eq!(out.windows.len(), 2); + assert_eq!(out.windows[0].start_ms, 60_000); + } + + #[test] + fn short_empty_gap_after_occupancy_is_dropped() { + let r = radar( + 0, + &[ + Some(1), + Some(0), + Some(0), + Some(0), + Some(0), + Some(0), + Some(0), + ], + ); + let cfg = AutoLabelConfig { + empty_hold_ms: 3_000, + ..AutoLabelConfig::default() + }; + let out = auto_label("s1", &csi(0, 7), &r, cfg).unwrap(); + // Occupied ends at 1000; empties starting at 1000, 2000, 3000 are within 3 s. + assert_eq!(out.skipped.get(&SkipReason::RecentlyOccupied), Some(&3)); + let kept: Vec<_> = out + .windows + .iter() + .map(|w| (w.start_ms, w.occupancy)) + .collect(); + assert_eq!( + kept, + [ + (0, Occupancy::Occupied), + (4000, Occupancy::Empty), + (5000, Occupancy::Empty), + (6000, Occupancy::Empty) + ] + ); + } + + #[test] + fn unknown_radar_windows_are_counted_by_reason() { + let r = radar(0, &[Some(1), None, Some(1)]); + let out = auto_label("s1", &csi(0, 3), &r, AutoLabelConfig::default()).unwrap(); + assert_eq!(out.windows.len(), 2); + assert_eq!(out.skipped.get(&SkipReason::RadarUnknown), Some(&1)); + assert_eq!(out.radar_unknown.get(&UnknownReason::Unverified), Some(&1)); + } + + #[test] + fn bad_inputs_are_rejected() { + let r = radar(0, &[Some(1)]); + assert!(auto_label("", &[], &r, AutoLabelConfig::default()).is_err()); + let cfg = AutoLabelConfig { + empty_hold_ms: -1, + ..AutoLabelConfig::default() + }; + assert!(auto_label("s", &[], &r, cfg).is_err()); + } +} diff --git a/v2/crates/ruview-groundtruth/src/baseline.rs b/v2/crates/ruview-groundtruth/src/baseline.rs new file mode 100644 index 000000000..a60fba772 --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/baseline.rs @@ -0,0 +1,242 @@ +//! Session-disjoint splits and the trivial baseline for occupancy labels. +//! +//! Windows from one session are strongly correlated, so a split that puts +//! windows of the same session on both sides leaks. [`session_disjoint_split`] +//! splits by session only and refuses a split that cannot be disjoint. +//! +//! An occupancy or count accuracy means little without the score of a model +//! that ignores the CSI entirely. [`majority_baseline`] fits that model on the +//! training side (majority occupancy class, mean person count) and scores it +//! on the test side. Report a model's numbers next to this baseline. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use crate::autolabel::{LabelledWindow, Occupancy}; +use crate::error::GroundTruthError; + +/// Train and test windows with no session on both sides. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SessionSplit { + /// Sessions on the training side, sorted. + pub train_sessions: Vec, + /// Sessions on the test side, sorted. + pub test_sessions: Vec, + /// Training windows. + pub train: Vec, + /// Test windows. + pub test: Vec, +} + +/// Split windows by session: sessions named in `test_sessions` go to the test +/// side, every other session to training. +/// +/// # Errors +/// [`GroundTruthError::InvalidSplit`] when a named test session has no +/// windows, or when either side would be empty. +pub fn session_disjoint_split( + windows: &[LabelledWindow], + test_sessions: &[&str], +) -> Result { + let present: BTreeSet<&str> = windows.iter().map(|w| w.session.as_str()).collect(); + let test: BTreeSet<&str> = test_sessions.iter().copied().collect(); + if test.iter().any(|s| !present.contains(s)) { + return Err(GroundTruthError::InvalidSplit { + reason: "a test session has no labelled windows", + }); + } + let (te, tr): (Vec<_>, Vec<_>) = windows + .iter() + .cloned() + .partition(|w| test.contains(w.session.as_str())); + if te.is_empty() || tr.is_empty() { + return Err(GroundTruthError::InvalidSplit { + reason: "both sides of a session split need at least one session", + }); + } + Ok(SessionSplit { + train_sessions: present.difference(&test).map(|s| (*s).to_owned()).collect(), + test_sessions: test.iter().map(|s| (*s).to_owned()).collect(), + train: tr, + test: te, + }) +} + +/// The no-CSI baseline, fitted on training windows and scored on test windows. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct BaselineReport { + /// Training windows. + pub train_windows: usize, + /// Test windows. + pub test_windows: usize, + /// Test windows per occupancy class. + pub test_class_counts: BTreeMap, + /// The training majority class (ties go to `Empty`). + pub majority_occupancy: Occupancy, + /// Accuracy of always predicting the majority class on the test side. + pub majority_accuracy: f64, + /// Mean training person count, when any training window has a count. + pub mean_count: Option, + /// Mean absolute error of predicting `mean_count` on test windows with a + /// count. + pub count_mae: Option, + /// Test windows with a count. + pub count_test_windows: usize, +} + +/// Fit and score the majority-class / mean-count baseline. +/// +/// # Errors +/// [`GroundTruthError::InvalidSplit`] when either side is empty. +pub fn majority_baseline( + train: &[LabelledWindow], + test: &[LabelledWindow], +) -> Result { + if train.is_empty() || test.is_empty() { + return Err(GroundTruthError::InvalidSplit { + reason: "baseline needs training and test windows", + }); + } + let occupied = train + .iter() + .filter(|w| w.occupancy == Occupancy::Occupied) + .count(); + let majority_occupancy = if occupied * 2 > train.len() { + Occupancy::Occupied + } else { + Occupancy::Empty + }; + + let mut test_class_counts = BTreeMap::new(); + for w in test { + *test_class_counts.entry(w.occupancy).or_insert(0usize) += 1; + } + let hits = test_class_counts + .get(&majority_occupancy) + .copied() + .unwrap_or(0); + + let train_counts: Vec = train + .iter() + .filter_map(|w| w.person_count) + .map(f64::from) + .collect(); + let mean_count = (!train_counts.is_empty()) + .then(|| train_counts.iter().sum::() / train_counts.len() as f64); + let test_counts: Vec = test + .iter() + .filter_map(|w| w.person_count) + .map(f64::from) + .collect(); + let count_mae = match mean_count { + Some(m) if !test_counts.is_empty() => { + Some(test_counts.iter().map(|c| (c - m).abs()).sum::() / test_counts.len() as f64) + } + _ => None, + }; + + Ok(BaselineReport { + train_windows: train.len(), + test_windows: test.len(), + test_class_counts, + majority_occupancy, + majority_accuracy: hits as f64 / test.len() as f64, + mean_count, + count_mae, + count_test_windows: test_counts.len(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::autolabel::CsiWindowSummary; + + fn w(session: &str, i: i64, count: u32) -> LabelledWindow { + LabelledWindow { + session: session.into(), + start_ms: i * 1000, + end_ms: i * 1000 + 1000, + occupancy: if count > 0 { + Occupancy::Occupied + } else { + Occupancy::Empty + }, + person_count: Some(count), + count_low: Some(count), + count_high: Some(count), + radar_window_index: i as usize, + csi: CsiWindowSummary { + n_frames: 10, + rssi_mean: None, + amplitude_mean: None, + amplitude_var: None, + }, + } + } + + fn windows() -> Vec { + let mut v = Vec::new(); + for (i, c) in [0, 1, 1, 2].iter().enumerate() { + v.push(w("mon", i as i64, *c)); + } + for (i, c) in [0, 0, 1].iter().enumerate() { + v.push(w("tue", i as i64, *c)); + } + for (i, c) in [1, 0].iter().enumerate() { + v.push(w("wed", i as i64, *c)); + } + v + } + + #[test] + fn split_keeps_sessions_on_one_side() { + let s = session_disjoint_split(&windows(), &["tue"]).unwrap(); + assert_eq!(s.train_sessions, ["mon", "wed"]); + assert_eq!(s.test_sessions, ["tue"]); + assert_eq!((s.train.len(), s.test.len()), (6, 3)); + let train: BTreeSet<_> = s.train.iter().map(|w| &w.session).collect(); + assert!(s.test.iter().all(|w| !train.contains(&w.session))); + } + + #[test] + fn impossible_splits_are_refused() { + let all = windows(); + assert!(session_disjoint_split(&all, &[]).is_err()); + assert!(session_disjoint_split(&all, &["mon", "tue", "wed"]).is_err()); + assert!(session_disjoint_split(&all, &["thu"]).is_err()); + let one: Vec<_> = all.into_iter().filter(|w| w.session == "mon").collect(); + assert!(session_disjoint_split(&one, &["mon"]).is_err()); + } + + #[test] + fn baseline_matches_hand_computed_fixture() { + let s = session_disjoint_split(&windows(), &["tue"]).unwrap(); + // Train: mon [0,1,1,2] + wed [1,0] -> 4 occupied of 6, mean count 5/6. + // Test: tue [0,0,1] -> majority "occupied" is right once in three. + let b = majority_baseline(&s.train, &s.test).unwrap(); + assert_eq!(b.majority_occupancy, Occupancy::Occupied); + assert!((b.majority_accuracy - 1.0 / 3.0).abs() < 1e-12); + let m = 5.0 / 6.0; + assert!((b.mean_count.unwrap() - m).abs() < 1e-12); + let mae = (m + m + (1.0 - m)) / 3.0; + assert!((b.count_mae.unwrap() - mae).abs() < 1e-12); + assert_eq!(b.count_test_windows, 3); + assert_eq!(b.test_class_counts.get(&Occupancy::Empty), Some(&2)); + } + + #[test] + fn tie_goes_to_empty_and_missing_counts_are_none() { + let mut train = vec![w("a", 0, 0), w("a", 1, 1)]; + for t in &mut train { + t.person_count = None; + } + let test = vec![w("b", 0, 0)]; + let b = majority_baseline(&train, &test).unwrap(); + assert_eq!(b.majority_occupancy, Occupancy::Empty); + assert_eq!(b.majority_accuracy, 1.0); + assert_eq!((b.mean_count, b.count_mae), (None, None)); + assert!(majority_baseline(&[], &test).is_err()); + } +} diff --git a/v2/crates/ruview-groundtruth/src/error.rs b/v2/crates/ruview-groundtruth/src/error.rs index 5c98943a3..a881fb432 100644 --- a/v2/crates/ruview-groundtruth/src/error.rs +++ b/v2/crates/ruview-groundtruth/src/error.rs @@ -129,6 +129,13 @@ pub enum GroundTruthError { /// Human-readable reason. reason: String, }, + /// A train/test split could not be made session-disjoint, or a side was + /// empty. + #[error("invalid split: {reason}")] + InvalidSplit { + /// Human-readable reason. + reason: &'static str, + }, /// 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 08c940058..27eac4bfd 100644 --- a/v2/crates/ruview-groundtruth/src/lib.rs +++ b/v2/crates/ruview-groundtruth/src/lib.rs @@ -27,6 +27,10 @@ //! 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. +//! [`auto_label`] pairs those labels with a RuView recording to make +//! occupancy training windows; [`session_disjoint_split`] and +//! [`majority_baseline`] give the split and the baseline any accuracy built +//! on them must be reported against (see `examples/radar_autolabel.rs`). //! //! ## Honesty and determinism //! @@ -52,6 +56,8 @@ mod agreement; mod align; +mod autolabel; +mod baseline; mod error; mod model; mod radar; @@ -63,6 +69,11 @@ pub use agreement::{AgreementMetrics, AgreementReport, EvidenceGrade, GradingPol pub use align::{ estimate_alignment, Alignment, AlignmentConfig, MAX_GRID_POINTS, MAX_LAG_STEPS, }; +pub use autolabel::{ + auto_label, parse_recording_jsonl, AutoLabelConfig, AutoLabelOutput, CsiFrame, + CsiWindowSummary, LabelledWindow, Occupancy, SkipReason, MAX_RECORDING_LINE_BYTES, +}; +pub use baseline::{majority_baseline, session_disjoint_split, BaselineReport, SessionSplit}; pub use error::{GroundTruthError, MAX_STR_LEN}; pub use model::{DataProvenance, Measurand, Reading, ReadingKind}; pub use radar::{ diff --git a/v2/crates/ruview-groundtruth/src/radar.rs b/v2/crates/ruview-groundtruth/src/radar.rs index 8fa187d08..a77dca096 100644 --- a/v2/crates/ruview-groundtruth/src/radar.rs +++ b/v2/crates/ruview-groundtruth/src/radar.rs @@ -221,7 +221,7 @@ impl RadarAdapterConfig { } /// Why a window produced no label. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum UnknownReason { /// The reader had no sensor data for the window. diff --git a/v2/crates/ruview-groundtruth/tests/radar_autolabel_pipeline.rs b/v2/crates/ruview-groundtruth/tests/radar_autolabel_pipeline.rs new file mode 100644 index 000000000..724c5a009 --- /dev/null +++ b/v2/crates/ruview-groundtruth/tests/radar_autolabel_pipeline.rs @@ -0,0 +1,80 @@ +//! End-to-end on SYNTHETIC data: recording + radar JSONL text through +//! labelling, a session-disjoint split and the baseline. + +use std::collections::BTreeSet; + +use ruview_groundtruth::{ + auto_label, majority_baseline, parse_radar_jsonl, parse_recording_jsonl, + session_disjoint_split, AutoLabelConfig, LabelledWindow, Occupancy, RadarAdapterConfig, + RadarLabels, +}; + +/// A session: 10 Hz CSI and one 1 s radar window per entry of `counts`. The +/// radar clock runs `skew_ms` behind the recording. +fn session(t0_ms: i64, skew_ms: i64, counts: &[u32]) -> (String, String) { + let mut rec = String::new(); + for i in 0..counts.len() as i64 * 10 { + let t = (t0_ms + i * 100) as f64 / 1000.0; + rec.push_str(&format!( + "{{\"timestamp\":{t},\"subcarriers\":[1.0,{}],\"rssi\":-55.0,\"noise_floor\":-90.0,\"features\":{{}}}}\n", + 1 + i % 3 + )); + } + let mut radar = String::new(); + for (i, c) in counts.iter().enumerate() { + let start = t0_ms - skew_ms + i as i64 * 1000; + let targets: Vec = (0..*c) + .map(|k| format!("{{\"x_m\":0.2,\"y_m\":{}}}", 1 + k)) + .collect(); + radar.push_str(&format!( + "{{\"timestamp_ms\":{},\"window_start_ms\":{start},\"health\":\"ok\",\"source\":{{\"verified\":true}},\"target_count\":{c},\"targets\":[{}]}}\n", + start + 1000, + targets.join(",") + )); + } + (rec, radar) +} + +fn label(name: &str, t0_ms: i64, counts: &[u32]) -> Vec { + let skew = 2_500; + let (rec, radar) = session(t0_ms, skew, counts); + let frames = parse_recording_jsonl(&rec).unwrap(); + let windows = parse_radar_jsonl(&radar).unwrap(); + let labels = + RadarLabels::from_windows(name, "ld2450", &windows, RadarAdapterConfig::default()).unwrap(); + let cfg = AutoLabelConfig { + radar_offset_ms: skew, + min_csi_frames: 5, + empty_hold_ms: 0, + }; + let out = auto_label(name, &frames, &labels, cfg).unwrap(); + assert_eq!(out.windows.len(), counts.len(), "{name}: {:?}", out.skipped); + out.windows +} + +#[test] +fn synthetic_sessions_label_split_and_baseline() { + let mut all = label("a", 1_000_000, &[0, 1, 1, 2, 0]); + all.extend(label("b", 2_000_000, &[0, 0, 0, 1])); + all.extend(label("c", 3_000_000, &[1, 1, 0])); + + // The clock offset lands each label on its own second of CSI. + assert!(all.iter().all(|w| w.csi.n_frames == 10)); + assert_eq!(all[3].person_count, Some(2)); + assert_eq!(all[0].occupancy, Occupancy::Empty); + + let split = session_disjoint_split(&all, &["b"]).unwrap(); + let train: BTreeSet<_> = split.train.iter().map(|w| w.session.clone()).collect(); + let test: BTreeSet<_> = split.test.iter().map(|w| w.session.clone()).collect(); + assert!(train.is_disjoint(&test)); + + // Train a+c: occupied 5 of 8 -> majority occupied; test b is 1 of 4 occupied. + let b = majority_baseline(&split.train, &split.test).unwrap(); + assert_eq!(b.majority_occupancy, Occupancy::Occupied); + assert!((b.majority_accuracy - 0.25).abs() < 1e-12); + assert!((b.mean_count.unwrap() - 6.0 / 8.0).abs() < 1e-12); + + // Same inputs, same outputs. + let again = majority_baseline(&split.train, &split.test).unwrap(); + assert_eq!(b, again); +}