diff --git a/v2/Cargo.lock b/v2/Cargo.lock index d9648e583..b36c93f14 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -9573,6 +9573,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ruview-survey" +version = "0.3.1" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "ruview-swarm" version = "0.1.0" diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 284a32bf7..6a3ad9e16 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -120,6 +120,7 @@ members = [ "crates/ruview-counterfactual",# ADR-313 counterfactual spatial inference "crates/ruview-infogain", # ADR-314 information-gain scheduler "crates/ruview-active", # ADR-309 active sensing control + "crates/ruview-survey", # ADR-381 node self-survey (check mode) # ADR-324 — clean-room Kooima off-axis (head-coupled perspective) projection. # Dependency-free native core; wasm-bindgen surface only on wasm32. "crates/ruview-offaxis", diff --git a/v2/crates/ruview-survey/Cargo.toml b/v2/crates/ruview-survey/Cargo.toml new file mode 100644 index 000000000..8a39275e1 --- /dev/null +++ b/v2/crates/ruview-survey/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ruview-survey" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Node self-survey check mode: compare pairwise node ranges with configured node positions (ADR-381)" + +[dependencies] +thiserror.workspace = true +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +serde_json.workspace = true diff --git a/v2/crates/ruview-survey/examples/check_ranges.rs b/v2/crates/ruview-survey/examples/check_ranges.rs new file mode 100644 index 000000000..236044d2e --- /dev/null +++ b/v2/crates/ruview-survey/examples/check_ranges.rs @@ -0,0 +1,78 @@ +//! Run check mode on a JSONL file of range observations. +//! +//! ```text +//! cargo run -p ruview-survey --example check_ranges -- \ +//! --node-positions "1:0,0,1.5;2:4.2,0,1.5;3:4.2,3.6,1.5" \ +//! --observations ranges.jsonl [--profile ftm|uwb] +//! ``` +//! +//! Each line of `ranges.jsonl` is one `RangeObservation`, for example +//! `{"initiator":1,"responder":2,"range_m":4.6,"sigma_m":0.3,"method":"ftm"}`. +//! Prints the report as JSON. Exit status 0 when consistent, 1 on warnings +//! or insufficient data, 2 on bad input. Never writes anything. + +use std::process::ExitCode; + +use ruview_survey::{check, parse_node_positions, CheckParams, RangeObservation, Verdict}; + +/// Largest observations file read, in bytes. +const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024; + +fn run() -> Result { + let mut positions = None; + let mut path = None; + let mut profile = "ftm".to_string(); + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + let mut value = || args.next().ok_or(format!("{arg} needs a value")); + match arg.as_str() { + "--node-positions" => positions = Some(value()?), + "--observations" => path = Some(value()?), + "--profile" => profile = value()?, + other => return Err(format!("unknown argument {other}")), + } + } + let positions = parse_node_positions(&positions.ok_or("--node-positions is required")?) + .map_err(|e| e.to_string())?; + let path = path.ok_or("--observations is required")?; + let params = match profile.as_str() { + "ftm" => CheckParams::ftm_uncalibrated(), + "uwb" => CheckParams::uwb(), + other => return Err(format!("unknown profile {other} (ftm|uwb)")), + }; + + let meta = std::fs::metadata(&path).map_err(|e| format!("{path}: {e}"))?; + if !meta.is_file() || meta.len() > MAX_FILE_BYTES { + return Err(format!( + "{path}: not a regular file under {MAX_FILE_BYTES} bytes" + )); + } + let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?; + let observations = text + .lines() + .enumerate() + .filter(|(_, l)| !l.trim().is_empty()) + .map(|(i, l)| { + serde_json::from_str::(l) + .map_err(|e| format!("{path}:{}: {e}", i + 1)) + }) + .collect::, _>>()?; + + let report = check(&positions, &observations, ¶ms).map_err(|e| e.to_string())?; + println!( + "{}", + serde_json::to_string_pretty(&report).map_err(|e| e.to_string())? + ); + Ok(report.verdict) +} + +fn main() -> ExitCode { + match run() { + Ok(Verdict::Consistent) => ExitCode::SUCCESS, + Ok(_) => ExitCode::from(1), + Err(e) => { + eprintln!("check_ranges: {e}"); + ExitCode::from(2) + } + } +} diff --git a/v2/crates/ruview-survey/src/calibration.rs b/v2/crates/ruview-survey/src/calibration.rs new file mode 100644 index 000000000..30e1b0bd6 --- /dev/null +++ b/v2/crates/ruview-survey/src/calibration.rs @@ -0,0 +1,154 @@ +//! Per-boot range calibration. +//! +//! On the 2026-10-04 FTM bench the spread inside one responder start was +//! ±0.2 to 0.4 m with no drift beyond that over two minutes (40 sessions, +//! half medians 3.30 and 3.00 m), but the bias changed with +//! every responder start (−0.5 to +1.9 m at 1.14 m). So an offset is only +//! trusted for the link and responder boot it was measured on. An +//! observation from any other boot passes through uncalibrated. +//! +//! The reference must be a known non-trivial distance. Zero-separation +//! calibration (ESP-IDF's `cm0`) is suspect on this hardware because +//! readings clamped to 0 below about 0.8 m. + +use std::collections::BTreeMap; + +use crate::{median, RangeObservation, SurveyError}; + +/// Shortest reference distance accepted, in metres. Below this the bench +/// readings clamped to zero, so an offset measured there is meaningless. +pub const MIN_REFERENCE_M: f64 = 1.0; + +/// Offsets keyed by `(initiator, responder, responder_boot)`. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CalibrationTable { + offsets: BTreeMap<(u8, u8, u32), f64>, +} + +impl CalibrationTable { + /// An empty table. + pub fn new() -> Self { + Self::default() + } + + /// Record an offset from sessions taken at a known reference distance. + /// + /// Every observation must share initiator, responder and responder boot. + /// The offset is `median(range_m) - reference_m` and is returned. + pub fn record_reference( + &mut self, + observations: &[RangeObservation], + reference_m: f64, + ) -> Result { + if !reference_m.is_finite() || reference_m < MIN_REFERENCE_M { + return Err(SurveyError::Calibration(format!( + "reference {reference_m} m is below {MIN_REFERENCE_M} m" + ))); + } + let first = observations + .first() + .ok_or_else(|| SurveyError::Calibration("no reference observations".into()))?; + let boot = first.responder_boot.ok_or_else(|| { + SurveyError::Calibration("reference observations carry no responder boot".into()) + })?; + let key = (first.initiator, first.responder, boot); + let mut ranges = Vec::with_capacity(observations.len()); + for o in observations { + o.validate()?; + if (o.initiator, o.responder, o.responder_boot) != (key.0, key.1, Some(key.2)) { + return Err(SurveyError::Calibration( + "reference observations mix links or responder boots".into(), + )); + } + ranges.push(o.range_m); + } + let offset = median(&ranges).unwrap_or(0.0) - reference_m; + self.offsets.insert(key, offset); + Ok(offset) + } + + /// The offset for an observation's link and boot, if one was recorded. + pub fn offset_for(&self, obs: &RangeObservation) -> Option { + let boot = obs.responder_boot?; + self.offsets + .get(&(obs.initiator, obs.responder, boot)) + .copied() + } + + /// Return a calibrated copy when an offset matches, else an unchanged copy. + pub fn apply(&self, obs: &RangeObservation) -> RangeObservation { + let mut out = obs.clone(); + if obs.calibrated { + return out; + } + if let Some(offset) = self.offset_for(obs) { + out.range_m = (obs.range_m - offset).max(0.0); + out.calibrated = true; + } + out + } + + /// Number of recorded offsets. + pub fn len(&self) -> usize { + self.offsets.len() + } + + /// True when no offsets are recorded. + pub fn is_empty(&self) -> bool { + self.offsets.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn obs(range: f64, boot: Option) -> RangeObservation { + RangeObservation { + responder_boot: boot, + ..RangeObservation::ftm(1, 2, range, 0.3) + } + } + + #[test] + fn offset_applies_only_to_the_same_boot() { + let mut t = CalibrationTable::new(); + let off = t + .record_reference( + &[obs(3.0, Some(7)), obs(3.1, Some(7)), obs(3.2, Some(7))], + 1.143, + ) + .unwrap(); + assert!((off - (3.1 - 1.143)).abs() < 1e-9); + + let same = t.apply(&obs(5.0, Some(7))); + assert!(same.calibrated); + assert!((same.range_m - (5.0 - off)).abs() < 1e-9); + + let other_boot = t.apply(&obs(5.0, Some(8))); + assert!(!other_boot.calibrated); + assert_eq!(other_boot.range_m, 5.0); + + let no_boot = t.apply(&obs(5.0, None)); + assert!(!no_boot.calibrated); + } + + #[test] + fn rejects_short_reference_and_mixed_links() { + let mut t = CalibrationTable::new(); + assert!(t.record_reference(&[obs(0.0, Some(1))], 0.5).is_err()); + assert!(t.record_reference(&[obs(1.0, None)], 1.2).is_err()); + assert!(t + .record_reference(&[obs(1.0, Some(1)), obs(1.0, Some(2))], 1.2) + .is_err()); + assert!(t.record_reference(&[], 1.2).is_err()); + assert!(t.is_empty()); + } + + #[test] + fn calibrated_range_never_goes_negative() { + let mut t = CalibrationTable::new(); + t.record_reference(&[obs(3.0, Some(1))], 1.0).unwrap(); + assert_eq!(t.apply(&obs(0.5, Some(1))).range_m, 0.0); + } +} diff --git a/v2/crates/ruview-survey/src/check.rs b/v2/crates/ruview-survey/src/check.rs new file mode 100644 index 000000000..bb3bfbdb4 --- /dev/null +++ b/v2/crates/ruview-survey/src/check.rs @@ -0,0 +1,394 @@ +//! Check mode: compare measured node ranges with configured node positions. +//! +//! Warn-only. [`check`] returns a [`CheckReport`]; nothing in this module +//! produces positions to write back. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::positions::distance; +use crate::report::{ + CheckParams, CheckReport, Finding, LinkResidual, LinkStatus, ScaleFit, Verdict, +}; +use crate::{median, NodePositions, RangeObservation, SurveyError, MAX_OBSERVATIONS}; + +/// Largest node set the id-swap search permutes (8! = 40 320 assignments). +pub const MAX_SWAP_NODES: usize = 8; + +/// Unit factors tried by the scale check: configured value × factor = metres. +const UNIT_FACTORS: [(&str, f64); 4] = [ + ("feet", 0.3048), + ("inches", 0.0254), + ("centimetres", 0.01), + ("millimetres", 0.001), +]; + +/// Cap on one link's contribution to the assignment cost, in squared +/// tolerances, so one bad link cannot dominate the id-swap search. +const LINK_COST_CAP: f64 = 4.0; + +/// Compare observations with configured positions. Never mutates either. +pub fn check( + positions: &NodePositions, + observations: &[RangeObservation], + params: &CheckParams, +) -> Result { + if observations.len() > MAX_OBSERVATIONS { + return Err(SurveyError::TooMany { + what: "observations", + got: observations.len(), + max: MAX_OBSERVATIONS, + }); + } + let mut findings = Vec::new(); + let mut unknown: BTreeMap = BTreeMap::new(); + let mut by_pair: BTreeMap<(u8, u8), Vec<&RangeObservation>> = BTreeMap::new(); + for o in observations { + o.validate()?; + let mut known = true; + for node in [o.initiator, o.responder] { + if !positions.contains_key(&node) { + *unknown.entry(node).or_default() += 1; + known = false; + } + } + if known { + by_pair.entry(o.pair()).or_default().push(o); + } + } + for (node, n) in unknown { + findings.push(Finding::UnknownNode { + node, + observations: n, + }); + } + + let links: Vec = by_pair + .iter() + .map(|(&(a, b), obs)| link_residual(a, b, obs, positions, params)) + .collect(); + let scored: Vec<&LinkResidual> = links + .iter() + .filter(|l| matches!(l.status, LinkStatus::Ok | LinkStatus::Outlier)) + .collect(); + + let ranged: BTreeSet = scored.iter().flat_map(|l| [l.a, l.b]).collect(); + if !scored.is_empty() { + for node in positions.keys().filter(|n| !ranged.contains(n)) { + findings.push(Finding::UnrangedNode { node: *node }); + } + } + if scored.is_empty() { + return Ok(CheckReport { + links, + scale: None, + findings, + verdict: Verdict::Insufficient, + }); + } + + let scale = fit_scale(&scored); + let unit = scale + .as_ref() + .and_then(|s| unit_mismatch(s.scale, &scored, params)); + let explained = unit.is_some(); + findings.extend(unit); + + let mut swapped = false; + if !explained { + if ranged.len() > MAX_SWAP_NODES { + findings.push(Finding::SwapSearchSkipped { + nodes: ranged.len(), + }); + } else if ranged.len() >= 3 { + if let Some(f) = swap_search(&ranged, &scored, positions, params) { + swapped = matches!(f, Finding::IdSwapSuggested { .. }); + findings.push(f); + } + } + } + + if !explained && !swapped { + let moved = moved_nodes(&ranged, &scored); + for l in scored.iter().filter(|l| l.status == LinkStatus::Outlier) { + if !moved.contains(&l.a) && !moved.contains(&l.b) { + findings.push(Finding::LinkOutlier { + a: l.a, + b: l.b, + residual_m: l.residual_m, + }); + } + } + for node in &moved { + let touching: Vec<&&LinkResidual> = scored + .iter() + .filter(|l| l.a == *node || l.b == *node) + .collect(); + findings.push(Finding::NodeMoved { + node: *node, + flagged_links: touching + .iter() + .filter(|l| l.status == LinkStatus::Outlier) + .count(), + links: touching.len(), + }); + } + } + + let verdict = if findings.iter().any(Finding::is_warning) { + Verdict::Warnings + } else { + Verdict::Consistent + }; + Ok(CheckReport { + links, + scale, + findings, + verdict, + }) +} + +fn link_residual( + a: u8, + b: u8, + obs: &[&RangeObservation], + positions: &NodePositions, + params: &CheckParams, +) -> LinkResidual { + let usable: Vec<&&RangeObservation> = obs + .iter() + .filter(|o| o.rssi_dbm.is_none_or(|r| r >= params.min_rssi_dbm)) + .collect(); + let pool: Vec<&&RangeObservation> = if usable.is_empty() { + obs.iter().collect() + } else { + usable.clone() + }; + let ranges: Vec = pool.iter().map(|o| o.range_m).collect(); + let sigmas: Vec = pool.iter().map(|o| o.sigma_m).collect(); + let measured = median(&ranges).unwrap_or(0.0); + let session_sigma = median(&sigmas).unwrap_or(0.0); + let spread = if ranges.len() >= 3 { + let mean = ranges.iter().sum::() / ranges.len() as f64; + (ranges.iter().map(|r| (r - mean).powi(2)).sum::() / ranges.len() as f64).sqrt() + } else { + 0.0 + }; + let sigma = session_sigma.max(spread); + let calibrated = pool.iter().all(|o| o.calibrated); + let expected = distance(&positions[&a], &positions[&b]); + let floor = if calibrated { + params.calibrated_floor_m + } else { + params.floor_m + }; + let tolerance = floor + params.sigma_k * sigma; + let residual = measured - expected; + let status = if usable.is_empty() { + LinkStatus::Weak + } else if expected < params.min_expected_m { + LinkStatus::TooShort + } else if sigma > params.max_sigma_m { + LinkStatus::Noisy + } else if residual.abs() > tolerance { + LinkStatus::Outlier + } else { + LinkStatus::Ok + }; + LinkResidual { + a, + b, + measured_m: measured, + expected_m: expected, + residual_m: residual, + tolerance_m: tolerance, + sigma_m: sigma, + observations: pool.len(), + calibrated, + status, + } +} + +fn fit_scale(scored: &[&LinkResidual]) -> Option { + let usable: Vec<&&LinkResidual> = scored.iter().filter(|l| l.expected_m > 0.0).collect(); + if usable.len() < 2 { + return None; + } + let num: f64 = usable.iter().map(|l| l.measured_m * l.expected_m).sum(); + let den: f64 = usable.iter().map(|l| l.expected_m * l.expected_m).sum(); + (den > 0.0).then(|| ScaleFit { + scale: num / den, + links: usable.len(), + }) +} + +fn unit_mismatch(scale: f64, scored: &[&LinkResidual], params: &CheckParams) -> Option { + let outliers = scored + .iter() + .filter(|l| l.status == LinkStatus::Outlier) + .count(); + if outliers == 0 { + return None; + } + UNIT_FACTORS.iter().find_map(|&(unit, factor)| { + if ((scale / factor) - 1.0).abs() > params.unit_band { + return None; + } + let rescaled = scored + .iter() + .filter(|l| (l.measured_m - l.expected_m * factor).abs() > l.tolerance_m) + .count(); + (rescaled < outliers).then(|| Finding::UnitMismatch { + unit: unit.to_string(), + factor, + fitted_scale: scale, + }) + }) +} + +fn swap_search( + ranged: &BTreeSet, + scored: &[&LinkResidual], + positions: &NodePositions, + params: &CheckParams, +) -> Option { + let nodes: Vec = ranged.iter().copied().collect(); + let index: BTreeMap = nodes.iter().enumerate().map(|(i, n)| (*n, i)).collect(); + let pos: Vec<[f64; 3]> = nodes.iter().map(|n| positions[n]).collect(); + let links: Vec<(usize, usize, f64, f64)> = scored + .iter() + .map(|l| (index[&l.a], index[&l.b], l.measured_m, l.tolerance_m)) + .collect(); + let cost = |perm: &[usize]| -> f64 { + links + .iter() + .map(|&(i, j, m, tol)| { + let r = (m - distance(&pos[perm[i]], &pos[perm[j]])) / tol; + (r * r).min(LINK_COST_CAP) + }) + .sum() + }; + + let n = nodes.len(); + let identity: Vec = (0..n).collect(); + let configured_cost = cost(&identity); + let mut all: Vec<(f64, Vec)> = Vec::new(); + for_each_permutation(n, |perm| all.push((cost(perm), perm.to_vec()))); + let best = all.iter().map(|(c, _)| *c).fold(f64::INFINITY, f64::min); + let tie = best + 0.1 * best + 0.05; + if configured_cost <= tie { + return None; + } + if configured_cost < params.min_swap_cost || best > params.swap_gain * configured_cost { + return None; + } + // Several assignments can fit equally well: ranges cannot tell a layout + // from its reflections, and corner-mounted nodes in a rectangular room + // are nearly symmetric. Prefer the assignment that changes the fewest + // ids (an operator typo moves few ids), and give up only when that + // minimum is itself tied. + let changed = |perm: &[usize]| perm.iter().enumerate().filter(|(i, p)| i != *p).count(); + let winners: Vec<&(f64, Vec)> = all.iter().filter(|(c, _)| *c <= tie).collect(); + let fewest = winners.iter().map(|(_, p)| changed(p)).min()?; + let minimal: Vec<&&(f64, Vec)> = winners + .iter() + .filter(|(_, p)| changed(p) == fewest) + .collect(); + if minimal.len() > 1 { + return Some(Finding::IdSwapAmbiguous { + equivalent_assignments: winners.len(), + }); + } + let (best_cost, perm) = minimal[0]; + let assignment = perm + .iter() + .enumerate() + .filter(|(i, p)| *i != **p) + .map(|(i, p)| (nodes[i], nodes[*p])) + .collect(); + Some(Finding::IdSwapSuggested { + assignment, + cost_configured: configured_cost, + cost_suggested: *best_cost, + equivalent_assignments: winners.len(), + }) +} + +/// Visit every permutation of `0..n` (Heap's algorithm, iterative). +fn for_each_permutation(n: usize, mut visit: impl FnMut(&[usize])) { + let mut perm: Vec = (0..n).collect(); + let mut c = vec![0usize; n]; + visit(&perm); + let mut i = 1; + while i < n { + if c[i] < i { + if i % 2 == 0 { + perm.swap(0, i); + } else { + perm.swap(c[i], i); + } + visit(&perm); + c[i] += 1; + i = 1; + } else { + c[i] = 0; + i += 1; + } + } +} + +fn moved_nodes(ranged: &BTreeSet, scored: &[&LinkResidual]) -> BTreeSet { + ranged + .iter() + .copied() + .filter(|node| { + let touching: Vec<&&LinkResidual> = scored + .iter() + .filter(|l| l.a == *node || l.b == *node) + .collect(); + let flagged = touching + .iter() + .filter(|l| l.status == LinkStatus::Outlier) + .count(); + flagged >= 2 && 2 * flagged > touching.len() + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn heap_visits_every_permutation_once() { + for n in 0..=5 { + let mut seen = BTreeSet::new(); + let mut count = 0; + for_each_permutation(n, |p| { + seen.insert(p.to_vec()); + count += 1; + }); + let expected: usize = (1..=n).product(); + assert_eq!(count, expected.max(1)); + assert_eq!(seen.len(), expected.max(1)); + } + } + + #[test] + fn no_scored_links_is_insufficient() { + let positions = crate::parse_node_positions("1:0,0,0;2:0.5,0,0").unwrap(); + let obs = [RangeObservation::ftm(1, 2, 0.0, 0.1)]; + let r = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap(); + assert_eq!(r.verdict, Verdict::Insufficient); + assert_eq!(r.links[0].status, LinkStatus::TooShort); + } + + #[test] + fn rejects_too_many_observations() { + let positions = crate::parse_node_positions("1:0,0,0;2:3,0,0").unwrap(); + let obs = vec![RangeObservation::ftm(1, 2, 3.0, 0.1); MAX_OBSERVATIONS + 1]; + assert!(matches!( + check(&positions, &obs, &CheckParams::ftm_uncalibrated()), + Err(SurveyError::TooMany { .. }) + )); + } +} diff --git a/v2/crates/ruview-survey/src/fill.rs b/v2/crates/ruview-survey/src/fill.rs new file mode 100644 index 000000000..b38f5167b --- /dev/null +++ b/v2/crates/ruview-survey/src/fill.rs @@ -0,0 +1,116 @@ +//! Fill-mode gate. +//! +//! Fill mode would write solved positions to a file the operator then passes +//! to `--node-positions`. It is not implemented here. This gate records the +//! ADR-381 rule so that whatever implements it later cannot be enabled on a +//! source that has not earned it: MEASURED evidence, enough links, and a p90 +//! link error of at most [`FILL_MAX_P90_M`]. + +use serde::{Deserialize, Serialize}; + +use crate::RangeMethod; + +/// Largest p90 link error, in metres, at which fill mode may be enabled. +pub const FILL_MAX_P90_M: f64 = 0.3; + +/// Fewest ground-truth links behind the p90 figure. +pub const FILL_MIN_LINKS: usize = 6; + +/// Evidence tag of a source accuracy figure (repository rule). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum EvidenceTag { + /// Our own bench, with a reproducer. + Measured, + /// Vendor or paper figure we have not reproduced. + Claimed, + /// Simulation. + Synthetic, +} + +/// Accuracy evidence for one ranging source. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SourceEvidence { + /// The source. + pub method: RangeMethod, + /// p90 of |measured − ground truth| over the links below, in metres. + pub p90_link_error_m: f64, + /// Number of ground-truth links behind the figure. + pub links: usize, + /// Evidence tag. + pub tag: EvidenceTag, +} + +/// Why fill mode stays off. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "reason")] +pub enum FillBlocked { + /// No evidence was supplied for the source. + NoEvidence, + /// The evidence is not MEASURED. + NotMeasured { + /// The tag that was supplied. + tag: EvidenceTag, + }, + /// Too few ground-truth links. + TooFewLinks { + /// Links supplied. + links: usize, + }, + /// p90 link error above [`FILL_MAX_P90_M`]. + TooInaccurate { + /// p90 supplied. + p90_link_error_m: f64, + }, +} + +/// Decide whether fill mode may run for a source. +pub fn fill_gate(evidence: Option<&SourceEvidence>) -> Result<(), FillBlocked> { + let e = evidence.ok_or(FillBlocked::NoEvidence)?; + if e.tag != EvidenceTag::Measured { + return Err(FillBlocked::NotMeasured { tag: e.tag }); + } + if e.links < FILL_MIN_LINKS { + return Err(FillBlocked::TooFewLinks { links: e.links }); + } + if !e.p90_link_error_m.is_finite() || e.p90_link_error_m > FILL_MAX_P90_M { + return Err(FillBlocked::TooInaccurate { + p90_link_error_m: e.p90_link_error_m, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ev(p90: f64, links: usize, tag: EvidenceTag) -> SourceEvidence { + SourceEvidence { + method: RangeMethod::Uwb, + p90_link_error_m: p90, + links, + tag, + } + } + + #[test] + fn gate_blocks_until_measured_and_accurate() { + assert_eq!(fill_gate(None), Err(FillBlocked::NoEvidence)); + assert!(matches!( + fill_gate(Some(&ev(0.1, 10, EvidenceTag::Claimed))), + Err(FillBlocked::NotMeasured { .. }) + )); + assert!(matches!( + fill_gate(Some(&ev(0.1, 3, EvidenceTag::Measured))), + Err(FillBlocked::TooFewLinks { .. }) + )); + // FTM on the 2026-10-04 bench: errors of metres. + assert!(matches!( + fill_gate(Some(&ev(4.1, 8, EvidenceTag::Measured))), + Err(FillBlocked::TooInaccurate { .. }) + )); + assert!(fill_gate(Some(&ev(f64::NAN, 8, EvidenceTag::Measured))).is_err()); + assert_eq!(fill_gate(Some(&ev(0.3, 6, EvidenceTag::Measured))), Ok(())); + } +} diff --git a/v2/crates/ruview-survey/src/lib.rs b/v2/crates/ruview-survey/src/lib.rs new file mode 100644 index 000000000..1c7d00faa --- /dev/null +++ b/v2/crates/ruview-survey/src/lib.rs @@ -0,0 +1,122 @@ +//! # `ruview-survey` — node self-survey, check mode (ADR-381) +//! +//! **Check mode only. Warn-only. This crate never writes node positions.** +//! +//! `--node-positions` is typed by hand and keyed by node id (#1804, #1866). +//! A swapped id, positions entered in feet, or a node moved after setup all +//! degrade multistatic fusion without any error. This crate takes pairwise +//! node-to-node ranges ([`RangeObservation`], from Wi-Fi FTM today and UWB +//! later) and the configured positions, and reports: +//! +//! - a per-link residual (measured minus configured distance) with a +//! tolerance and a status ([`LinkResidual`]); +//! - a fitted scale, and a unit-mismatch finding when the scale sits near a +//! feet/inches/centimetre factor ([`Finding::UnitMismatch`]); +//! - a suggested id swap when a different assignment of the configured +//! positions to node ids fits much better ([`Finding::IdSwapSuggested`]), +//! or an explicit ambiguity when the layout is symmetric; +//! - a node whose links disagree as a group ([`Finding::NodeMoved`]). +//! +//! Ranges see only distances, so any rotation, reflection or translation of +//! the layout fits equally well. Check mode compares distances, not +//! coordinates, and reports symmetric ambiguity rather than guessing. +//! +//! ## Evidence +//! +//! Default tolerances for FTM ([`CheckParams::ftm_uncalibrated`]) come from a +//! MEASURED two-board ESP32-S3 bench on 2026-10-04 (20 MHz, channel 1, 32 +//! frames per session). Within one responder start the spread was ±0.2 to +//! 0.4 m, but the bias moved from −0.5 to +1.9 m between responder starts at +//! 1.14 m, a 3.71 m link read 7.8 m, and readings clamp to 0 below about +//! 0.8 m. That is good enough to catch gross configuration mistakes and not +//! good enough to place nodes. Fill mode (writing solved positions) is gated +//! off by [`fill_gate`] until a source has a MEASURED p90 link error of at +//! most [`FILL_MAX_P90_M`]. +//! +//! ## Determinism and bounds +//! +//! No I/O, no clock, no randomness. Inputs are bounded ([`MAX_NODES`], +//! [`MAX_OBSERVATIONS`], [`MAX_RANGE_M`]); the id-swap search runs only up to +//! [`MAX_SWAP_NODES`] nodes. Malformed input yields a typed [`SurveyError`], +//! never a panic. +//! +//! ``` +//! use ruview_survey::*; +//! +//! let positions = parse_node_positions("1:0,0,1;2:4,0,1;3:0,3,1").unwrap(); +//! let obs = [ +//! RangeObservation::ftm(1, 2, 4.2, 0.3), +//! RangeObservation::ftm(1, 3, 3.1, 0.3), +//! RangeObservation::ftm(2, 3, 5.4, 0.3), +//! ]; +//! let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap(); +//! assert_eq!(report.verdict, Verdict::Consistent); +//! ``` + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +mod calibration; +mod check; +mod fill; +mod observation; +mod positions; +mod report; + +pub use calibration::CalibrationTable; +pub use check::{check, MAX_SWAP_NODES}; +pub use fill::{fill_gate, EvidenceTag, FillBlocked, SourceEvidence, FILL_MAX_P90_M}; +pub use observation::{RangeMethod, RangeObservation, MAX_OBSERVATIONS, MAX_RANGE_M}; +pub use positions::{parse_node_positions, NodePositions, MAX_NODES}; +pub use report::{CheckParams, CheckReport, Finding, LinkResidual, LinkStatus, ScaleFit, Verdict}; + +/// Errors from malformed survey input. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum SurveyError { + /// A range observation failed validation. + #[error("invalid range observation: {0}")] + InvalidObservation(String), + /// The node position string failed to parse. + #[error("invalid node positions: {0}")] + InvalidPositions(String), + /// An input exceeded its bound. + #[error("too many {what}: {got} > {max}")] + TooMany { + /// What overflowed. + what: &'static str, + /// How many were supplied. + got: usize, + /// The bound. + max: usize, + }, + /// A calibration reference was unusable. + #[error("calibration: {0}")] + Calibration(String), +} + +/// Median of a non-empty slice. Returns `None` for an empty slice. +pub(crate) fn median(values: &[f64]) -> Option { + if values.is_empty() { + return None; + } + let mut v = values.to_vec(); + v.sort_by(|a, b| a.total_cmp(b)); + let mid = v.len() / 2; + Some(if v.len() % 2 == 0 { + (v[mid - 1] + v[mid]) / 2.0 + } else { + v[mid] + }) +} + +#[cfg(test)] +mod tests { + use super::median; + + #[test] + fn median_handles_odd_even_and_empty() { + assert_eq!(median(&[]), None); + assert_eq!(median(&[3.0, 1.0, 2.0]), Some(2.0)); + assert_eq!(median(&[4.0, 1.0, 2.0, 3.0]), Some(2.5)); + } +} diff --git a/v2/crates/ruview-survey/src/observation.rs b/v2/crates/ruview-survey/src/observation.rs new file mode 100644 index 000000000..8330afb92 --- /dev/null +++ b/v2/crates/ruview-survey/src/observation.rs @@ -0,0 +1,154 @@ +//! [`RangeObservation`]: one measured distance between two fixed nodes. +//! +//! The record is source-agnostic. FTM, UWB, BLE Channel Sounding and a tape +//! measure all produce the same shape; [`RangeMethod`] says which one did. + +use serde::{Deserialize, Serialize}; + +use crate::SurveyError; + +/// Largest range accepted, in metres. Anything longer is a parse or unit +/// error for an indoor deployment. +pub const MAX_RANGE_M: f64 = 100.0; + +/// Most observations accepted by one [`crate::check`] call. +pub const MAX_OBSERVATIONS: usize = 10_000; + +/// Which ranging source produced an observation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RangeMethod { + /// Wi-Fi Fine Timing Measurement (802.11mc RTT), ESP32 STA to SoftAP. + Ftm, + /// UWB two-way ranging (ADR-144 hardware family). + Uwb, + /// Bluetooth Channel Sounding, through the BLE CS adapter boundary. + BleCs, + /// Operator-entered distance (tape or laser). + Manual, +} + +/// One measured node-to-node distance. +/// +/// For FTM, `initiator` is the station and `responder` is the SoftAP. The +/// pair is unordered for geometry, but the roles matter for calibration +/// because the measured bias moved with each responder start. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RangeObservation { + /// Node id that initiated the exchange (`node_id` in `--node-positions`). + pub initiator: u8, + /// Node id that responded. + pub responder: u8, + /// Reported distance in metres (session median for FTM). + pub range_m: f64, + /// One-sigma spread of the session in metres. Use 0 when unknown. + #[serde(default)] + pub sigma_m: f64, + /// Ranging method. + pub method: RangeMethod, + /// Number of valid frames or exchanges behind `range_m`. + #[serde(default)] + pub samples: u16, + /// Median RSSI of the session, if the source reports one. + #[serde(default)] + pub rssi_dbm: Option, + /// Channel bandwidth in MHz, if relevant (FTM: 20 or 40). + #[serde(default)] + pub bandwidth_mhz: Option, + /// Host receive time in microseconds since the Unix epoch. + #[serde(default)] + pub at_us: u64, + /// Boot counter or boot nonce of the responder. Calibration is only + /// valid within one responder boot. + #[serde(default)] + pub responder_boot: Option, + /// True once a per-boot calibration offset has been applied. + #[serde(default)] + pub calibrated: bool, +} + +impl RangeObservation { + /// A minimal FTM observation, mostly for tests and examples. + pub fn ftm(initiator: u8, responder: u8, range_m: f64, sigma_m: f64) -> Self { + Self { + initiator, + responder, + range_m, + sigma_m, + method: RangeMethod::Ftm, + samples: 0, + rssi_dbm: None, + bandwidth_mhz: None, + at_us: 0, + responder_boot: None, + calibrated: false, + } + } + + /// The unordered node pair, smaller id first. + pub fn pair(&self) -> (u8, u8) { + if self.initiator <= self.responder { + (self.initiator, self.responder) + } else { + (self.responder, self.initiator) + } + } + + /// Reject self links, non-finite values and out-of-range distances. + pub fn validate(&self) -> Result<(), SurveyError> { + if self.initiator == self.responder { + return Err(SurveyError::InvalidObservation(format!( + "node {} ranged to itself", + self.initiator + ))); + } + if !self.range_m.is_finite() || !(0.0..=MAX_RANGE_M).contains(&self.range_m) { + return Err(SurveyError::InvalidObservation(format!( + "range {} m for {}-{} is outside 0..={MAX_RANGE_M}", + self.range_m, self.initiator, self.responder + ))); + } + if !self.sigma_m.is_finite() || !(0.0..=MAX_RANGE_M).contains(&self.sigma_m) { + return Err(SurveyError::InvalidObservation(format!( + "sigma {} m for {}-{} is not a finite non-negative value", + self.sigma_m, self.initiator, self.responder + ))); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pair_is_unordered() { + assert_eq!(RangeObservation::ftm(5, 2, 1.0, 0.1).pair(), (2, 5)); + assert_eq!(RangeObservation::ftm(2, 5, 1.0, 0.1).pair(), (2, 5)); + } + + #[test] + fn validate_rejects_bad_values() { + assert!(RangeObservation::ftm(1, 1, 1.0, 0.1).validate().is_err()); + assert!(RangeObservation::ftm(1, 2, -0.1, 0.1).validate().is_err()); + assert!(RangeObservation::ftm(1, 2, f64::NAN, 0.1) + .validate() + .is_err()); + assert!(RangeObservation::ftm(1, 2, 101.0, 0.1).validate().is_err()); + assert!(RangeObservation::ftm(1, 2, 1.0, f64::INFINITY) + .validate() + .is_err()); + assert!(RangeObservation::ftm(1, 2, 0.0, 0.0).validate().is_ok()); + } + + #[test] + fn deserializes_with_optional_fields_missing() { + let o: RangeObservation = + serde_json::from_str(r#"{"initiator":1,"responder":2,"range_m":3.5,"method":"ftm"}"#) + .unwrap(); + assert_eq!(o.sigma_m, 0.0); + assert_eq!(o.responder_boot, None); + assert!(!o.calibrated); + } +} diff --git a/v2/crates/ruview-survey/src/positions.rs b/v2/crates/ruview-survey/src/positions.rs new file mode 100644 index 000000000..49b31003d --- /dev/null +++ b/v2/crates/ruview-survey/src/positions.rs @@ -0,0 +1,123 @@ +//! Parse the sensing server's `--node-positions` string. +//! +//! Same grammar as `field_bridge::parse_node_position_entries`: +//! `node_id:x,y,z;node_id:x,y,z` or, without the prefix, `x,y,z;...` keyed by +//! list index from 0. The server skips bad entries with a warning; a checker +//! should not, so this parser returns an error instead. + +use std::collections::BTreeMap; + +use crate::SurveyError; + +/// Most node positions accepted. +pub const MAX_NODES: usize = 64; + +/// Configured positions keyed by node id, in metres. +pub type NodePositions = BTreeMap; + +/// Parse a `--node-positions` value into a keyed map. +pub fn parse_node_positions(input: &str) -> Result { + let entries: Vec<&str> = input + .split(';') + .map(str::trim) + .filter(|e| !e.is_empty()) + .collect(); + if entries.len() > MAX_NODES { + return Err(SurveyError::TooMany { + what: "node positions", + got: entries.len(), + max: MAX_NODES, + }); + } + let mut map = NodePositions::new(); + for (idx, entry) in entries.iter().enumerate() { + let (id, triplet) = match entry.split_once(':') { + Some((id, rest)) => { + let id = id.trim().parse::().map_err(|_| { + SurveyError::InvalidPositions(format!( + "entry {idx} '{entry}': node id must be 0..=255" + )) + })?; + (id, rest) + } + None => ( + u8::try_from(idx).map_err(|_| { + SurveyError::InvalidPositions(format!("entry {idx}: index exceeds 255")) + })?, + *entry, + ), + }; + let parts: Vec<&str> = triplet.split(',').map(str::trim).collect(); + if parts.len() != 3 { + return Err(SurveyError::InvalidPositions(format!( + "entry {idx} '{entry}': expected x,y,z" + ))); + } + let mut xyz = [0.0f64; 3]; + for (slot, part) in xyz.iter_mut().zip(&parts) { + let v = part.parse::().map_err(|_| { + SurveyError::InvalidPositions(format!( + "entry {idx} '{entry}': '{part}' is not a number" + )) + })?; + if !v.is_finite() { + return Err(SurveyError::InvalidPositions(format!( + "entry {idx} '{entry}': non-finite coordinate" + ))); + } + *slot = v; + } + if map.insert(id, xyz).is_some() { + return Err(SurveyError::InvalidPositions(format!( + "node {id} is given twice" + ))); + } + } + Ok(map) +} + +/// Euclidean distance between two positions. +pub(crate) fn distance(a: &[f64; 3], b: &[f64; 3]) -> f64 { + ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_keyed_and_indexed_forms() { + let keyed = parse_node_positions("3:0,0,1.5; 7:4.5,0,1.5").unwrap(); + assert_eq!(keyed.len(), 2); + assert_eq!(keyed[&7], [4.5, 0.0, 1.5]); + + let indexed = parse_node_positions("0,0,1;2,0,1").unwrap(); + assert_eq!(indexed[&1], [2.0, 0.0, 1.0]); + } + + #[test] + fn rejects_malformed_entries() { + assert!(parse_node_positions("1:0,0").is_err()); + assert!(parse_node_positions("x:0,0,0").is_err()); + assert!(parse_node_positions("1:0,a,0").is_err()); + assert!(parse_node_positions("1:0,0,inf").is_err()); + assert!(parse_node_positions("1:0,0,0;1:1,1,1").is_err()); + } + + #[test] + fn empty_input_is_empty_map() { + assert!(parse_node_positions("").unwrap().is_empty()); + } + + #[test] + fn rejects_too_many_entries() { + let s = (0..=MAX_NODES) + .map(|i| format!("{i}:0,0,0")) + .collect::>() + .join(";"); + assert!(matches!( + parse_node_positions(&s), + Err(SurveyError::TooMany { .. }) + )); + } +} diff --git a/v2/crates/ruview-survey/src/report.rs b/v2/crates/ruview-survey/src/report.rs new file mode 100644 index 000000000..835063453 --- /dev/null +++ b/v2/crates/ruview-survey/src/report.rs @@ -0,0 +1,243 @@ +//! Check-mode parameters and report types. + +use serde::Serialize; + +/// Tolerances and gates for [`crate::check`]. +/// +/// A link's sigma is the larger of the median per-session sigma and, with +/// three or more sessions, the spread of the session ranges. Its tolerance +/// is `floor + sigma_k × sigma`, where `floor` is +/// [`CheckParams::calibrated_floor_m`] when every observation on the link was +/// calibrated for the current responder boot and [`CheckParams::floor_m`] +/// otherwise. A link whose sigma exceeds [`CheckParams::max_sigma_m`] is not +/// scored: a wide tolerance would otherwise accept anything. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct CheckParams { + /// Tolerance floor for uncalibrated links, in metres. + pub floor_m: f64, + /// Tolerance floor for calibrated links, in metres. + pub calibrated_floor_m: f64, + /// Multiplier on the per-link sigma. + pub sigma_k: f64, + /// Links whose configured length is below this are not scored. + pub min_expected_m: f64, + /// Observations below this RSSI are dropped. + pub min_rssi_dbm: i16, + /// Links with a sigma above this are not scored. + pub max_sigma_m: f64, + /// A swap is suggested only if it cuts the assignment cost to at most + /// this fraction of the configured assignment's cost. + pub swap_gain: f64, + /// A swap is suggested only if the configured assignment's cost is at + /// least this (in squared tolerances). + pub min_swap_cost: f64, + /// Relative band around a unit factor that counts as a unit match. + pub unit_band: f64, +} + +impl CheckParams { + /// Wi-Fi FTM, ESP32-S3, 20 MHz, from the 2026-10-04 bench (MEASURED). + /// + /// - `floor_m = 2.0`: bias between responder starts at 1.14 m ranged + /// from −0.5 to +1.9 m. One start at 3.71 m read +4.1 m and will be + /// flagged as a link outlier; that is the honest state of uncalibrated + /// FTM, so a single-link outlier is reported as such and not as a + /// configuration error. + /// - `calibrated_floor_m = 0.6`: spread within one start was ±0.2 to + /// 0.4 m with no drift beyond that over two minutes. Longer stability is untested + /// (ESTIMATE). + /// - `min_expected_m = 1.0`: readings clamped to 0 below about 0.8 m. + /// - `min_rssi_dbm = -70`: Espressif's guidance. The bench's 7.7 m + /// placement outlier had a median of −68 dBm and passed this gate. + /// - `max_sigma_m = 1.0`: that outlier's session spread was 1.92 m; every + /// other run's spread was 0.49 m or less. + pub fn ftm_uncalibrated() -> Self { + Self { + floor_m: 2.0, + calibrated_floor_m: 0.6, + sigma_k: 3.0, + min_expected_m: 1.0, + min_rssi_dbm: -70, + max_sigma_m: 1.0, + swap_gain: 0.5, + min_swap_cost: 1.0, + unit_band: 0.2, + } + } + + /// UWB two-way ranging. ESTIMATE pending our own bench: published + /// line-of-sight accuracy is about 0.1 m (CLAIMED). + pub fn uwb() -> Self { + Self { + floor_m: 0.3, + calibrated_floor_m: 0.3, + sigma_k: 3.0, + min_expected_m: 0.0, + min_rssi_dbm: i16::MIN, + max_sigma_m: 0.3, + swap_gain: 0.5, + min_swap_cost: 1.0, + unit_band: 0.1, + } + } +} + +/// Status of one link. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum LinkStatus { + /// Residual within tolerance. + Ok, + /// Residual beyond tolerance. + Outlier, + /// Configured length below `min_expected_m`; not scored. + TooShort, + /// Every observation was below `min_rssi_dbm`; not scored. + Weak, + /// Sigma above `max_sigma_m`; not scored. + Noisy, +} + +/// Measured versus configured length of one node pair. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct LinkResidual { + /// Smaller node id. + pub a: u8, + /// Larger node id. + pub b: u8, + /// Median measured range, metres. + pub measured_m: f64, + /// Distance between the configured positions, metres. + pub expected_m: f64, + /// `measured_m − expected_m`. + pub residual_m: f64, + /// Tolerance applied. + pub tolerance_m: f64, + /// Link sigma (see [`CheckParams`]). + pub sigma_m: f64, + /// Observations behind the median. + pub observations: usize, + /// True if every observation used was calibrated. + pub calibrated: bool, + /// Status. + pub status: LinkStatus, +} + +/// Least-squares scale `s` in `measured ≈ s × expected`. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ScaleFit { + /// Fitted scale. + pub scale: f64, + /// Links used. + pub links: usize, +} + +/// A warning or note from [`crate::check`]. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "snake_case", tag = "kind")] +pub enum Finding { + /// Observations name a node that has no configured position. + UnknownNode { + /// Node id. + node: u8, + /// Observations dropped. + observations: usize, + }, + /// A configured node has no scored link, so it was not checked. + UnrangedNode { + /// Node id. + node: u8, + }, + /// The fitted scale matches a unit factor: positions were probably + /// entered in that unit while the server reads metres. + UnitMismatch { + /// Unit name. + unit: String, + /// Factor from that unit to metres. + factor: f64, + /// Fitted scale. + fitted_scale: f64, + }, + /// Another assignment of the configured positions fits much better. + /// Each pair is `(node, position_of)`: the node's ranges match the + /// position configured for `position_of`. When several assignments fit + /// equally well, this is the one that changes the fewest ids. + IdSwapSuggested { + /// Changed assignments only. + assignment: Vec<(u8, u8)>, + /// Cost of the configured assignment. + cost_configured: f64, + /// Cost of the suggested assignment. + cost_suggested: f64, + /// Assignments that fit as well as the suggestion, itself included. + /// Above 1, the layout is (nearly) symmetric and the suggestion + /// rests on the fewest-changes rule. + equivalent_assignments: usize, + }, + /// The configured assignment fits badly and the best-fitting + /// assignments that change the fewest ids are tied (symmetric layout); + /// no single swap can be named. + IdSwapAmbiguous { + /// Number of equally good alternative assignments. + equivalent_assignments: usize, + }, + /// Most of a node's links disagree: it was probably moved. + NodeMoved { + /// Node id. + node: u8, + /// Outlier links touching it. + flagged_links: usize, + /// Scored links touching it. + links: usize, + }, + /// One link is out of tolerance with no configuration explanation + /// (bias, a body or wall in the path, or a bad session). + LinkOutlier { + /// Smaller node id. + a: u8, + /// Larger node id. + b: u8, + /// Residual, metres. + residual_m: f64, + }, + /// Too many nodes for the exhaustive id-swap search. + SwapSearchSkipped { + /// Nodes with scored links. + nodes: usize, + }, +} + +impl Finding { + /// True for findings that should raise the verdict to warnings. + pub fn is_warning(&self) -> bool { + !matches!( + self, + Finding::UnrangedNode { .. } | Finding::SwapSearchSkipped { .. } + ) + } +} + +/// Overall result. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Verdict { + /// Every scored link agrees with the configuration. + Consistent, + /// At least one warning finding. + Warnings, + /// No link could be scored. + Insufficient, +} + +/// Result of [`crate::check`]. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct CheckReport { + /// One entry per observed pair whose nodes are both configured. + pub links: Vec, + /// Scale fit over scored links, when at least two exist. + pub scale: Option, + /// Findings, in the order they were derived. + pub findings: Vec, + /// Verdict. + pub verdict: Verdict, +} diff --git a/v2/crates/ruview-survey/tests/check_synthetic.rs b/v2/crates/ruview-survey/tests/check_synthetic.rs new file mode 100644 index 000000000..63c110b6f --- /dev/null +++ b/v2/crates/ruview-survey/tests/check_synthetic.rs @@ -0,0 +1,369 @@ +//! SYNTHETIC check-mode scenarios. Ranges are generated from a known layout +//! with FTM-like bias and noise taken from the 2026-10-04 bench envelope +//! (bias −0.5 to +1.9 m per link, spread ±0.3 m). No hardware is involved. + +use ruview_survey::*; + +/// Asymmetric five-node layout in a 10 × 6 m space, metres. +const TRUTH: [(u8, [f64; 3]); 5] = [ + (1, [0.3, 0.2, 1.0]), + (2, [9.6, 0.5, 1.2]), + (3, [8.8, 5.7, 1.0]), + (4, [1.1, 5.2, 1.4]), + (5, [4.4, 2.1, 0.8]), +]; + +fn positions_string(layout: &[(u8, [f64; 3])], scale: f64) -> String { + layout + .iter() + .map(|(id, p)| format!("{id}:{},{},{}", p[0] * scale, p[1] * scale, p[2] * scale)) + .collect::>() + .join(";") +} + +fn dist(a: &[f64; 3], b: &[f64; 3]) -> f64 { + ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() +} + +/// Deterministic pseudo-random value in [0, 1) from a seed. +fn unit(seed: u64) -> f64 { + let x = seed + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + ((x >> 11) as f64) / ((1u64 << 53) as f64) +} + +/// Ranges for every pair of `layout`: per-link bias in [bias_lo, bias_hi] +/// plus ±`noise` m session noise (reported as sigma), `sessions` per link. +fn ranges_with( + layout: &[(u8, [f64; 3])], + bias_lo: f64, + bias_hi: f64, + noise: f64, + sessions: u64, +) -> Vec { + let mut out = Vec::new(); + for (i, (a, pa)) in layout.iter().enumerate() { + for (b, pb) in layout.iter().skip(i + 1) { + let link_seed = u64::from(*a) * 131 + u64::from(*b); + let bias = bias_lo + (bias_hi - bias_lo) * unit(link_seed); + for s in 0..sessions { + let jitter = noise * (2.0 * unit(link_seed * 977 + s) - 1.0); + let range = (dist(pa, pb) + bias + jitter).max(0.0); + out.push(RangeObservation { + rssi_dbm: Some(-55), + ..RangeObservation::ftm(*a, *b, range, noise) + }); + } + } + } + out +} + +/// FTM-like ranges: ±0.3 m session noise, as on the bench. +fn ranges( + layout: &[(u8, [f64; 3])], + bias_lo: f64, + bias_hi: f64, + sessions: u64, +) -> Vec { + ranges_with(layout, bias_lo, bias_hi, 0.3, sessions) +} + +#[test] +fn correct_config_with_ftm_bias_is_consistent() { + let positions = parse_node_positions(&positions_string(&TRUTH, 1.0)).unwrap(); + let obs = ranges(&TRUTH, -0.5, 1.9, 5); + let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap(); + assert_eq!(report.verdict, Verdict::Consistent, "{report:#?}"); + assert_eq!(report.links.len(), 10); + assert!(report.links.iter().all(|l| l.status == LinkStatus::Ok)); +} + +#[test] +fn positions_entered_in_feet_are_flagged_as_unit_mismatch() { + // Operator measured in feet and typed the numbers as if they were metres. + let feet = 1.0 / 0.3048; + let positions = parse_node_positions(&positions_string(&TRUTH, feet)).unwrap(); + let obs = ranges(&TRUTH, -0.5, 1.9, 5); + let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap(); + assert_eq!(report.verdict, Verdict::Warnings); + let unit = report + .findings + .iter() + .find_map(|f| match f { + Finding::UnitMismatch { + unit, fitted_scale, .. + } => Some((unit.clone(), *fitted_scale)), + _ => None, + }) + .unwrap_or_else(|| panic!("no unit finding: {report:#?}")); + assert_eq!(unit.0, "feet"); + assert!((0.25..0.37).contains(&unit.1), "scale {}", unit.1); + // The unit finding explains the residuals; no swap or per-link noise. + assert!(!report.findings.iter().any(|f| matches!( + f, + Finding::IdSwapSuggested { .. } | Finding::LinkOutlier { .. } | Finding::NodeMoved { .. } + ))); +} + +#[test] +fn positions_in_centimetres_are_flagged() { + let positions = parse_node_positions(&positions_string(&TRUTH, 100.0)).unwrap(); + let obs = ranges(&TRUTH, -0.5, 1.9, 3); + let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap(); + assert!(report + .findings + .iter() + .any(|f| matches!(f, Finding::UnitMismatch { unit, .. } if unit == "centimetres"))); +} + +#[test] +fn swapped_ids_are_suggested_back() { + // The operator typed node 2's position under id 4 and vice versa. + let mut configured = TRUTH; + configured[1].1 = TRUTH[3].1; + configured[3].1 = TRUTH[1].1; + let positions = parse_node_positions(&positions_string(&configured, 1.0)).unwrap(); + let obs = ranges(&TRUTH, -0.5, 1.9, 5); + let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap(); + assert_eq!(report.verdict, Verdict::Warnings); + let (assignment, equivalent) = report + .findings + .iter() + .find_map(|f| match f { + Finding::IdSwapSuggested { + assignment, + equivalent_assignments, + .. + } => Some((assignment.clone(), *equivalent_assignments)), + _ => None, + }) + .unwrap_or_else(|| panic!("no swap finding: {report:#?}")); + assert_eq!(assignment, vec![(2, 4), (4, 2)]); + // Nodes 1-4 sit near the corners of a rectangle, so at FTM tolerances a + // four-id rotation fits about as well. The fewest-changes rule picks + // the two-id swap and the tie is reported. + assert!(equivalent >= 2, "{report:#?}"); +} + +#[test] +fn three_way_rotation_is_suggested_back() { + // Ids 1, 3, 5 rotated: 1 got 3's position, 3 got 5's, 5 got 1's. + let mut configured = TRUTH; + configured[0].1 = TRUTH[2].1; + configured[2].1 = TRUTH[4].1; + configured[4].1 = TRUTH[0].1; + let positions = parse_node_positions(&positions_string(&configured, 1.0)).unwrap(); + let obs = ranges(&TRUTH, -0.5, 1.9, 5); + let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap(); + let assignment = report + .findings + .iter() + .find_map(|f| match f { + Finding::IdSwapSuggested { assignment, .. } => Some(assignment.clone()), + _ => None, + }) + .unwrap_or_else(|| panic!("no swap finding: {report:#?}")); + // Node 1's ranges match the position configured for node 5, and so on. + assert_eq!(assignment, vec![(1, 5), (3, 1), (5, 3)]); +} + +/// A 6 × 4 m rectangle with a node in each corner. +const RECT: [(u8, [f64; 3]); 4] = [ + (1, [0.0, 0.0, 1.0]), + (2, [6.0, 0.0, 1.0]), + (3, [6.0, 4.0, 1.0]), + (4, [0.0, 4.0, 1.0]), +]; + +#[test] +fn relabelling_that_is_a_symmetry_is_invisible() { + // Ids rotated 180 degrees (1<->3, 2<->4). Every pairwise distance is + // unchanged, so ranges cannot see it and the check must not invent a + // swap. Check mode cannot catch this class of mistake. + let mut configured = RECT; + configured[0].1 = RECT[2].1; + configured[2].1 = RECT[0].1; + configured[1].1 = RECT[3].1; + configured[3].1 = RECT[1].1; + let positions = parse_node_positions(&positions_string(&configured, 1.0)).unwrap(); + let obs = ranges_with(&RECT, 0.0, 0.05, 0.05, 3); + let report = check(&positions, &obs, &CheckParams::uwb()).unwrap(); + assert_eq!(report.verdict, Verdict::Consistent, "{report:#?}"); +} + +#[test] +fn swap_along_a_wall_of_a_rectangle_is_ambiguous() { + // Ids 1 and 2 swapped along the long wall. Mirroring the rectangle + // turns that into a 3<->4 swap with identical distances, so both + // two-id fixes fit equally and the check must say it cannot choose. + let mut configured = RECT; + configured[0].1 = RECT[1].1; + configured[1].1 = RECT[0].1; + let positions = parse_node_positions(&positions_string(&configured, 1.0)).unwrap(); + let obs = ranges_with(&RECT, 0.0, 0.05, 0.05, 3); + let report = check(&positions, &obs, &CheckParams::uwb()).unwrap(); + assert_eq!(report.verdict, Verdict::Warnings); + assert!( + report + .findings + .iter() + .any(|f| matches!(f, Finding::IdSwapAmbiguous { equivalent_assignments } if *equivalent_assignments >= 2)), + "{report:#?}" + ); +} + +#[test] +fn moved_node_is_flagged_without_blaming_others() { + // Node 5 was moved 2 m after setup; its configured position is stale. + // UWB-grade ranges: uncalibrated FTM tolerances hide moves this size. + let mut actual = TRUTH; + actual[4].1 = [2.6, 1.2, 0.8]; + let positions = parse_node_positions(&positions_string(&TRUTH, 1.0)).unwrap(); + let obs = ranges_with(&actual, 0.0, 0.1, 0.05, 5); + let report = check(&positions, &obs, &CheckParams::uwb()).unwrap(); + assert_eq!(report.verdict, Verdict::Warnings); + let moved: Vec = report + .findings + .iter() + .filter_map(|f| match f { + Finding::NodeMoved { node, .. } => Some(*node), + _ => None, + }) + .collect(); + assert_eq!(moved, vec![5], "{report:#?}"); + assert!(!report + .findings + .iter() + .any(|f| matches!(f, Finding::IdSwapSuggested { .. }))); +} + +#[test] +fn bench_style_single_link_outlier_is_reported_as_link_outlier() { + // One link over-reads by +4.1 m, as the 3.71 m bench start did. + let positions = parse_node_positions(&positions_string(&TRUTH, 1.0)).unwrap(); + let mut obs = ranges(&TRUTH, 0.0, 0.5, 3); + for o in obs.iter_mut().filter(|o| o.pair() == (1, 5)) { + o.range_m += 4.1; + } + let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap(); + assert_eq!(report.verdict, Verdict::Warnings); + assert!(report + .findings + .iter() + .any(|f| matches!(f, Finding::LinkOutlier { a: 1, b: 5, .. }))); + assert!(!report.findings.iter().any(|f| matches!( + f, + Finding::IdSwapSuggested { .. } | Finding::NodeMoved { .. } | Finding::UnitMismatch { .. } + ))); +} + +#[test] +fn per_boot_calibration_tightens_tolerance() { + // Same responder boot for every session; one reference link at a taped + // 3.0 m recovers a +0.7 m bias, so a 1.5 m misplacement that + // uncalibrated FTM cannot see becomes visible. + let positions = parse_node_positions("1:0,0,1;2:5,0,1").unwrap(); + let boot = Some(42); + let reference: Vec = (0..5) + .map(|_| RangeObservation { + responder_boot: boot, + ..RangeObservation::ftm(1, 2, 3.0 + 0.7, 0.3) + }) + .collect(); + let mut table = CalibrationTable::new(); + let offset = table.record_reference(&reference, 3.0).unwrap(); + assert!((offset - 0.7).abs() < 1e-9); + + // Node 2 actually sits at 6.5 m, not the configured 5.0 m. + let live = RangeObservation { + responder_boot: boot, + ..RangeObservation::ftm(1, 2, 6.5 + 0.7, 0.1) + }; + let raw = check( + &positions, + std::slice::from_ref(&live), + &CheckParams::ftm_uncalibrated(), + ) + .unwrap(); + assert_eq!(raw.links[0].status, LinkStatus::Ok); + + let calibrated = table.apply(&live); + let report = check(&positions, &[calibrated], &CheckParams::ftm_uncalibrated()).unwrap(); + assert!(report.links[0].calibrated); + assert_eq!(report.links[0].status, LinkStatus::Outlier, "{report:#?}"); + + // After a responder reboot the offset no longer applies. + let rebooted = RangeObservation { + responder_boot: Some(43), + ..live + }; + assert!(!table.apply(&rebooted).calibrated); +} + +#[test] +fn unknown_and_unranged_nodes_are_reported() { + let positions = parse_node_positions("1:0,0,1;2:4,0,1;3:0,3,1;9:2,2,1").unwrap(); + let obs = [ + RangeObservation::ftm(1, 2, 4.0, 0.2), + RangeObservation::ftm(1, 3, 3.0, 0.2), + RangeObservation::ftm(2, 3, 5.0, 0.2), + RangeObservation::ftm(1, 7, 2.0, 0.2), + ]; + let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap(); + assert!(report.findings.contains(&Finding::UnknownNode { + node: 7, + observations: 1 + })); + assert!(report.findings.contains(&Finding::UnrangedNode { node: 9 })); + assert_eq!(report.verdict, Verdict::Warnings); +} + +#[test] +fn weak_sessions_are_not_scored() { + let positions = parse_node_positions("1:0,0,1;2:4,0,1").unwrap(); + let obs = [RangeObservation { + rssi_dbm: Some(-80), + ..RangeObservation::ftm(1, 2, 12.0, 0.2) + }]; + let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap(); + assert_eq!(report.links[0].status, LinkStatus::Weak); + assert_eq!(report.verdict, Verdict::Insufficient); +} + +#[test] +fn noisy_link_is_not_scored() { + // Shaped like the bench's 7.7 m run at 1.14 m: −68 dBm passes the RSSI + // gate, but the session ranges spread 5.25 to 12.3 m. + let positions = parse_node_positions("1:0,0,1;2:4,0,1;3:0,3,1").unwrap(); + let mut obs = vec![ + RangeObservation::ftm(1, 3, 3.2, 0.3), + RangeObservation::ftm(2, 3, 5.3, 0.3), + ]; + for r in [5.25, 6.9, 7.7, 7.75, 9.6, 12.3] { + obs.push(RangeObservation { + rssi_dbm: Some(-68), + ..RangeObservation::ftm(1, 2, r, 0.3) + }); + } + let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap(); + let link = report.links.iter().find(|l| (l.a, l.b) == (1, 2)).unwrap(); + assert_eq!(link.status, LinkStatus::Noisy, "{report:#?}"); + assert!(link.sigma_m > 1.0); + assert_eq!(report.verdict, Verdict::Consistent); +} + +#[test] +fn report_serializes_to_json() { + let positions = parse_node_positions(&positions_string(&TRUTH, 1.0)).unwrap(); + let report = check( + &positions, + &ranges(&TRUTH, 0.0, 0.5, 2), + &CheckParams::ftm_uncalibrated(), + ) + .unwrap(); + let json = serde_json::to_value(&report).unwrap(); + assert_eq!(json["verdict"], "consistent"); + assert_eq!(json["links"].as_array().unwrap().len(), 10); +}