Merge PR #2144 (aepod) into release integration

This commit is contained in:
ruv committed 2026-10-05 11:47:35 -04:00
commit e223d86ba2
7 files changed
+952 -1

No files matched your search

@@ -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<String>,
out: Option<String>,
label: AutoLabelConfig,
radar: RadarAdapterConfig,
}
fn next(it: &mut impl Iterator<Item = String>, flag: &str) -> Result<String, String> {
it.next().ok_or_else(|| format!("{flag} needs a value"))
}
fn parse_args() -> Result<Args, String> {
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<LabelledWindow> = 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<dyn Write> = 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
}
}
}
@@ -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<f64>,
#[serde(default)]
rssi: Option<f64>,
}
/// 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<f64>,
/// Mean subcarrier amplitude of the frame, when it has subcarriers.
pub amplitude_mean: Option<f64>,
}
/// 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<Vec<CsiFrame>, 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<f64> = 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::<f64>() / 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<f64>,
/// Mean of per-frame mean amplitude.
pub amplitude_mean: Option<f64>,
/// Population variance of per-frame mean amplitude, a crude motion proxy.
pub amplitude_var: Option<f64>,
}
fn mean_var(xs: &[f64]) -> (Option<f64>, Option<f64>) {
if xs.is_empty() {
return (None, None);
}
let n = xs.len() as f64;
let m = xs.iter().sum::<f64>() / n;
let v = xs.iter().map(|x| (x - m) * (x - m)).sum::<f64>() / n;
(Some(m), Some(v))
}
fn summarize(frames: &[CsiFrame]) -> CsiWindowSummary {
let rssi: Vec<f64> = frames.iter().filter_map(|f| f.rssi).collect();
let amp: Vec<f64> = frames.iter().filter_map(|f| f.amplitude_mean).collect();
let (amplitude_mean, amplitude_var) = mean_var(&amp);
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<u32>,
/// Lowest count the radar window carried.
pub count_low: Option<u32>,
/// Highest count the radar window carried.
pub count_high: Option<u32>,
/// 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<LabelledWindow>,
/// Dropped radar windows by reason.
pub skipped: BTreeMap<SkipReason, usize>,
/// Unlabelled radar windows by the radar adapter's reason.
pub radar_unknown: BTreeMap<UnknownReason, usize>,
}
/// 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<AutoLabelOutput, GroundTruthError> {
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<i64> = 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<CsiFrame> {
(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<u32>]) -> RadarLabels {
let windows: Vec<RadarWindow> = 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());
}
}
@@ -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<String>,
/// Sessions on the test side, sorted.
pub test_sessions: Vec<String>,
/// Training windows.
pub train: Vec<LabelledWindow>,
/// Test windows.
pub test: Vec<LabelledWindow>,
}
/// 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<SessionSplit, GroundTruthError> {
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<Occupancy, usize>,
/// 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<f64>,
/// Mean absolute error of predicting `mean_count` on test windows with a
/// count.
pub count_mae: Option<f64>,
/// 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<BaselineReport, GroundTruthError> {
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<f64> = train
.iter()
.filter_map(|w| w.person_count)
.map(f64::from)
.collect();
let mean_count = (!train_counts.is_empty())
.then(|| train_counts.iter().sum::<f64>() / train_counts.len() as f64);
let test_counts: Vec<f64> = 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::<f64>() / 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<LabelledWindow> {
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());
}
}
@@ -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)]
+11
View File
@@ -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::{
+1 -1
View File
@@ -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.
@@ -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<String> = (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<LabelledWindow> {
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);
}