feat(survey): add check-mode node self-survey crate (ADR-381)

Adds ruview-survey, a host-side check that compares pairwise node ranges
with the configured --node-positions and warns, never writes. It reports
per-link residuals, a fitted scale with feet/inch/cm/mm unit detection,
a suggested id swap (exhaustive up to 8 nodes, fewest-changes rule,
explicit ambiguity for symmetric layouts), moved nodes, and unknown or
unranged ids.

RangeObservation is source-agnostic (FTM, UWB, BLE CS, manual). Per-boot
calibration keys offsets by (initiator, responder, responder_boot)
because the measured FTM bias moved with every responder start. Fill
mode is not implemented; fill_gate keeps it off until a source has
MEASURED p90 link error <= 0.3 m over >= 6 links.

FTM tolerances come from a two-board ESP32-S3 bench (20 MHz, ch 1):
spread within a start <= 0.31 m, bias between starts -0.5 to +1.9 m at
1.14 m, clamp to 0 below ~0.8 m. Tests are SYNTHETIC.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Mathew Beaneandclaude-flow committed 2026-10-03 23:08:04 -04:00
1 parent 03a897938f
commit 951ffcc254
12 files changed
+1778

No files matched your search

Generated
+9
View File
@@ -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"
+1
View File
@@ -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",
+15
View File
@@ -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
@@ -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<Verdict, String> {
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::<RangeObservation>(l)
.map_err(|e| format!("{path}:{}: {e}", i + 1))
})
.collect::<Result<Vec<_>, _>>()?;
let report = check(&positions, &observations, &params).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)
}
}
}
+154
View File
@@ -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<f64, SurveyError> {
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<f64> {
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<u32>) -> 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);
}
}
+394
View File
@@ -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<CheckReport, SurveyError> {
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<u8, usize> = 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<LinkResidual> = 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<u8> = 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<f64> = pool.iter().map(|o| o.range_m).collect();
let sigmas: Vec<f64> = 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::<f64>() / ranges.len() as f64;
(ranges.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / 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<ScaleFit> {
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<Finding> {
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<u8>,
scored: &[&LinkResidual],
positions: &NodePositions,
params: &CheckParams,
) -> Option<Finding> {
let nodes: Vec<u8> = ranged.iter().copied().collect();
let index: BTreeMap<u8, usize> = 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<usize> = (0..n).collect();
let configured_cost = cost(&identity);
let mut all: Vec<(f64, Vec<usize>)> = 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<usize>)> = all.iter().filter(|(c, _)| *c <= tie).collect();
let fewest = winners.iter().map(|(_, p)| changed(p)).min()?;
let minimal: Vec<&&(f64, Vec<usize>)> = 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<usize> = (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<u8>, scored: &[&LinkResidual]) -> BTreeSet<u8> {
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 { .. })
));
}
}
+116
View File
@@ -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(()));
}
}
+122
View File
@@ -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<f64> {
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));
}
}
+154
View File
@@ -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<i16>,
/// Channel bandwidth in MHz, if relevant (FTM: 20 or 40).
#[serde(default)]
pub bandwidth_mhz: Option<u16>,
/// 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<u32>,
/// 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);
}
}
+123
View File
@@ -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<u8, [f64; 3]>;
/// Parse a `--node-positions` value into a keyed map.
pub fn parse_node_positions(input: &str) -> Result<NodePositions, SurveyError> {
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::<u8>().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::<f64>().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::<Vec<_>>()
.join(";");
assert!(matches!(
parse_node_positions(&s),
Err(SurveyError::TooMany { .. })
));
}
}
+243
View File
@@ -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<LinkResidual>,
/// Scale fit over scored links, when at least two exist.
pub scale: Option<ScaleFit>,
/// Findings, in the order they were derived.
pub findings: Vec<Finding>,
/// Verdict.
pub verdict: Verdict,
}
@@ -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::<Vec<_>>()
.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<RangeObservation> {
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<RangeObservation> {
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<u8> = 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<RangeObservation> = (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);
}