mirror of
https://github.com/ruvnet/RuView
synced 2026-08-01 19:01:42 +00:00
a1a59baf72
One shared representation instead of another isolated RF classifier: new v2 leaf crate ruview-unified implementing all five ADR-273 pillars, plus six ADRs with measured, grade-labeled results. - Canonical RfTensor + fail-closed hardware adapter registry (802.11 CSI via wifi-densepose-core::CsiFrame, FMCW radar cubes, UWB CIR, 5G SRS); shared layout/gain/phase normalization proven by tests (ADR-274). - Universal RF foundation encoder: CFO-aligned, median-scaled tokenizer; masked-reconstruction pretraining with hand-derived backprop verified against central finite differences (max rel err 1.31e-5 over all 12 parameter groups); fusion contract z = Enc ⊙ σ(AgeEnc) + GeomEnc; task adapters under the 1% budget (129/268/387/2 params vs 40,856 backbone), enforced by test. - RF-aware Gaussian spatial memory: anisotropic primitives with per-band reflectivity, confidence-weighted fusion, decay, spatial-hash/semantic queries, closed-form Beer-Lambert channel gain (exact Friis on empty map), inverse gain updates (unseen 6.1 dB wall learned to <0.5 dB in 20 observations), task-gated scene graph (ADR-275). - Physics-guided synthetic RF worlds: image-method multipath (order ≤2), complex-permittivity Fresnel materials, emergent Doppler proven against the analytic phase rate, seeded ChaCha20 randomization of physics and hardware nuisances; byte-deterministic per seed (ADR-276). - Edge sensing control plane: 802.11bf/ETSI-ISAC-aligned purposes/zones, fail-closed authorization, double-gated identity, retention bounds; BoundedEvent-only trust boundary makes raw RF export unrepresentable (ADR-277). Radar inverse rendering stays a gated research program (ADR-278, no code by design). Anti-leakage acceptance pipeline (strict splits by room/day/person/ chipset/firmware/layout with independent disjointness verification): presence F1 1.00 on held-out rooms and held-out chipset, degradation 0.0, ECE 0.012, p95 latency 2.0 ms debug / 105 µs release — ALL SYNTHETIC until P2 real-data validation. Benchmarks + optimization pass: channel_gain 139→27 µs (O(1) in map size via segment-corridor AABB sweep), observe_link 305→74 µs, DFT twiddle plan 4.9x; hash/linear crossover (~4k Gaussians) reported honestly. Tests: ruview-unified 66 unit + 3 acceptance, 0 failed; workspace 3,771 passed 0 failed (--exclude wifi-densepose-desktop: GTK headers unavailable in this container). Python proof: VERDICT PASS. Also gitignore sensing-server test-run artifacts (incl. generated session-secret). Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01Q1R5zhz6sSfXGRXpgBwpFX
274 lines
10 KiB
Rust
274 lines
10 KiB
Rust
//! Image-method multipath ray tracer for shoebox rooms (ADR-276 §3).
|
||
//!
|
||
//! Allen–Berkley mirror images up to reflection order 2 plus single-bounce
|
||
//! bistatic scattering off each person. The channel at frequency `f` is
|
||
//!
|
||
//! ```text
|
||
//! H(f) = Σ_paths Γ_p · (c/f)/(4π) · s_p · e^{-j·2πf·d_p/c}
|
||
//! ```
|
||
//!
|
||
//! with `s_p = 1/d` for wall paths and `s_p = √(σ/4π)/(d₁·d₂)` for person
|
||
//! scattering (bistatic radar equation, amplitude form). Doppler is never
|
||
//! injected: it emerges from the person's path length changing between
|
||
//! snapshots.
|
||
|
||
use num_complex::Complex64;
|
||
|
||
use super::room::RoomSpec;
|
||
|
||
const C: f64 = 299_792_458.0;
|
||
|
||
/// One propagation path.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct PathContribution {
|
||
/// Total geometric path length (metres) — sets delay and phase.
|
||
pub distance_m: f64,
|
||
/// Amplitude scale multiplying `λ/(4π)`: `1/d` for wall paths,
|
||
/// `√(σ/4π)/(d₁·d₂)` for scatterers.
|
||
pub amp_scale: f64,
|
||
/// Product of complex reflection coefficients along the path
|
||
/// (`Γ^order`, evaluated at the carrier).
|
||
pub reflection: Complex64,
|
||
/// Number of wall bounces (0 = direct or scatterer path).
|
||
pub order: usize,
|
||
}
|
||
|
||
/// Enumerates all wall-image paths of order ≤ `max_order` plus person
|
||
/// scattering paths, for the room state at time `t` seconds.
|
||
#[must_use]
|
||
pub fn enumerate_paths(
|
||
room: &RoomSpec,
|
||
tx: [f64; 3],
|
||
rx: [f64; 3],
|
||
carrier_hz: f64,
|
||
t: f64,
|
||
max_order: usize,
|
||
) -> Vec<PathContribution> {
|
||
let gamma = room.wall_material.reflection_coefficient(carrier_hz);
|
||
let mut paths = Vec::new();
|
||
|
||
// Allen–Berkley images: per axis, sign ∈ {+, −} and lattice shift
|
||
// n ∈ {−1, 0, 1}; bounce count is |2n| for +, |2n−1| for −.
|
||
for sx in [1i64, -1] {
|
||
for nx in -1i64..=1 {
|
||
let bx = if sx == 1 { (2 * nx).unsigned_abs() } else { (2 * nx - 1).unsigned_abs() };
|
||
if bx as usize > max_order {
|
||
continue;
|
||
}
|
||
for sy in [1i64, -1] {
|
||
for ny in -1i64..=1 {
|
||
let by =
|
||
if sy == 1 { (2 * ny).unsigned_abs() } else { (2 * ny - 1).unsigned_abs() };
|
||
if (bx + by) as usize > max_order {
|
||
continue;
|
||
}
|
||
for sz in [1i64, -1] {
|
||
for nz in -1i64..=1 {
|
||
let bz = if sz == 1 {
|
||
(2 * nz).unsigned_abs()
|
||
} else {
|
||
(2 * nz - 1).unsigned_abs()
|
||
};
|
||
let order = (bx + by + bz) as usize;
|
||
if order > max_order {
|
||
continue;
|
||
}
|
||
let img = [
|
||
sx as f64 * tx[0] + 2.0 * nx as f64 * room.size[0],
|
||
sy as f64 * tx[1] + 2.0 * ny as f64 * room.size[1],
|
||
sz as f64 * tx[2] + 2.0 * nz as f64 * room.size[2],
|
||
];
|
||
let d = ((img[0] - rx[0]).powi(2)
|
||
+ (img[1] - rx[1]).powi(2)
|
||
+ (img[2] - rx[2]).powi(2))
|
||
.sqrt();
|
||
if d < 1e-9 {
|
||
continue;
|
||
}
|
||
let reflection = if order == 0 {
|
||
Complex64::new(1.0, 0.0)
|
||
} else {
|
||
gamma.powu(order as u32)
|
||
};
|
||
// Reflections with |Γ|=0 contribute nothing.
|
||
if order > 0 && reflection.norm() < 1e-15 {
|
||
continue;
|
||
}
|
||
paths.push(PathContribution {
|
||
distance_m: d,
|
||
amp_scale: 1.0 / d,
|
||
reflection,
|
||
order,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Person scatterers (single bounce TX → person → RX).
|
||
for person in &room.people {
|
||
let p = person.position_at(t);
|
||
let d1 = ((p[0] - tx[0]).powi(2) + (p[1] - tx[1]).powi(2) + (p[2] - tx[2]).powi(2)).sqrt();
|
||
let d2 = ((p[0] - rx[0]).powi(2) + (p[1] - rx[1]).powi(2) + (p[2] - rx[2]).powi(2)).sqrt();
|
||
if d1 < 1e-9 || d2 < 1e-9 {
|
||
continue;
|
||
}
|
||
paths.push(PathContribution {
|
||
distance_m: d1 + d2,
|
||
amp_scale: (person.rcs_m2 / (4.0 * std::f64::consts::PI)).sqrt() / (d1 * d2),
|
||
reflection: Complex64::new(1.0, 0.0),
|
||
order: 0,
|
||
});
|
||
}
|
||
|
||
paths
|
||
}
|
||
|
||
/// Synthesizes the channel frequency response at each `freqs_hz` for the
|
||
/// room state at time `t`.
|
||
#[must_use]
|
||
pub fn synthesize_csi(
|
||
room: &RoomSpec,
|
||
tx: [f64; 3],
|
||
rx: [f64; 3],
|
||
freqs_hz: &[f64],
|
||
t: f64,
|
||
) -> Vec<Complex64> {
|
||
let carrier = freqs_hz[freqs_hz.len() / 2];
|
||
let paths = enumerate_paths(room, tx, rx, carrier, t, 2);
|
||
freqs_hz
|
||
.iter()
|
||
.map(|&f| {
|
||
let mut h = Complex64::new(0.0, 0.0);
|
||
for p in &paths {
|
||
let amp = (C / f) / (4.0 * std::f64::consts::PI) * p.amp_scale;
|
||
let phase = -2.0 * std::f64::consts::PI * f * p.distance_m / C;
|
||
h += p.reflection * Complex64::from_polar(amp, phase);
|
||
}
|
||
h
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::synth::room::{Material, PersonSpec};
|
||
|
||
fn freqs() -> Vec<f64> {
|
||
// 56 subcarriers over 20 MHz around 2.437 GHz.
|
||
(0..56).map(|k| 2.437e9 - 10e6 + 20e6 * k as f64 / 55.0).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn direct_path_is_exact_friis() {
|
||
// Absorber walls ⇒ only the direct path survives.
|
||
let room = RoomSpec::new([8.0, 6.0, 3.0], Material::absorber(), vec![]).unwrap();
|
||
let tx = [1.0, 1.0, 1.5];
|
||
let rx = [5.0, 4.0, 1.5]; // d = 5
|
||
let freqs = freqs();
|
||
let h = synthesize_csi(&room, tx, rx, &freqs, 0.0);
|
||
for (f, hv) in freqs.iter().zip(&h) {
|
||
let expected = (C / f) / (4.0 * std::f64::consts::PI * 5.0);
|
||
assert!(
|
||
(hv.norm() - expected).abs() < 1e-15,
|
||
"at {f} Hz: |H| = {} vs Friis {expected}",
|
||
hv.norm()
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn channel_is_reciprocal() {
|
||
let people = vec![PersonSpec { start: [3.0, 2.0, 1.2], velocity: [0.4, 0.1, 0.0], rcs_m2: 0.5 }];
|
||
let room = RoomSpec::new([8.0, 6.0, 3.0], Material::concrete(), people).unwrap();
|
||
let a = [1.0, 1.0, 1.5];
|
||
let b = [6.5, 4.2, 1.1];
|
||
let freqs = freqs();
|
||
let fwd = synthesize_csi(&room, a, b, &freqs, 0.35);
|
||
let rev = synthesize_csi(&room, b, a, &freqs, 0.35);
|
||
for (x, y) in fwd.iter().zip(&rev) {
|
||
assert!((x - y).norm() < 1e-12, "H(a→b) must equal H(b→a): {x} vs {y}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn first_order_reflection_matches_mirror_geometry() {
|
||
let room = RoomSpec::new([8.0, 6.0, 3.0], Material::concrete(), vec![]).unwrap();
|
||
let tx = [2.0, 3.0, 1.5];
|
||
let rx = [6.0, 3.0, 1.5];
|
||
let paths = enumerate_paths(&room, tx, rx, 2.437e9, 0.0, 2);
|
||
|
||
// Floor bounce (z = 0): mirror TX to z = −1.5; d = √(4² + 3²) = 5.
|
||
let floor = ((tx[0] - rx[0]).powi(2)
|
||
+ (tx[1] - rx[1]).powi(2)
|
||
+ (-tx[2] - rx[2]).powi(2))
|
||
.sqrt();
|
||
assert!((floor - 5.0).abs() < 1e-12, "test geometry sanity");
|
||
assert!(
|
||
paths.iter().any(|p| p.order == 1 && (p.distance_m - 5.0).abs() < 1e-12),
|
||
"floor-bounce image path at exactly 5 m must exist"
|
||
);
|
||
|
||
// Ceiling bounce (z = 3): mirror TX to z = 4.5; d = √(16 + 9) = 5.
|
||
assert!(
|
||
paths
|
||
.iter()
|
||
.any(|p| p.order == 1 && (p.distance_m - 5.0).abs() < 1e-12
|
||
&& p.distance_m > 0.0),
|
||
"ceiling-bounce path must exist"
|
||
);
|
||
|
||
// Path count sanity: direct + 6 first-order + second-order set, all
|
||
// with |Γ| > 0 for concrete.
|
||
assert!(paths.iter().filter(|p| p.order == 1).count() == 6, "6 first-order walls");
|
||
assert!(paths.iter().any(|p| p.order == 2));
|
||
assert_eq!(paths.iter().filter(|p| p.order == 0).count(), 1, "one direct path");
|
||
}
|
||
|
||
#[test]
|
||
fn moving_person_produces_the_analytic_doppler_phase_rate() {
|
||
// Person walking radially outward along the TX–RX bisector normal;
|
||
// compare the residual (person-only) phase rotation between
|
||
// snapshots against −2πf·Δd/c.
|
||
let person = PersonSpec { start: [4.0, 2.0, 1.2], velocity: [0.0, 0.8, 0.0], rcs_m2: 0.6 };
|
||
let with_person =
|
||
RoomSpec::new([8.0, 6.0, 3.0], Material::drywall(), vec![person]).unwrap();
|
||
let empty = RoomSpec::new([8.0, 6.0, 3.0], Material::drywall(), vec![]).unwrap();
|
||
let tx = [1.0, 2.0, 1.5];
|
||
let rx = [7.0, 2.0, 1.5];
|
||
let f = [2.437e9];
|
||
let dt = 0.05;
|
||
|
||
for step in 0..4 {
|
||
let t0 = step as f64 * dt;
|
||
let t1 = t0 + dt;
|
||
let resid0 = synthesize_csi(&with_person, tx, rx, &f, t0)[0]
|
||
- synthesize_csi(&empty, tx, rx, &f, t0)[0];
|
||
let resid1 = synthesize_csi(&with_person, tx, rx, &f, t1)[0]
|
||
- synthesize_csi(&empty, tx, rx, &f, t1)[0];
|
||
let measured_dphi = (resid1 * resid0.conj()).arg();
|
||
|
||
let path_len = |t: f64| {
|
||
let p = person.position_at(t);
|
||
let d1 = ((p[0] - tx[0]).powi(2) + (p[1] - tx[1]).powi(2) + (p[2] - tx[2]).powi(2))
|
||
.sqrt();
|
||
let d2 = ((p[0] - rx[0]).powi(2) + (p[1] - rx[1]).powi(2) + (p[2] - rx[2]).powi(2))
|
||
.sqrt();
|
||
d1 + d2
|
||
};
|
||
let expected_dphi = -2.0 * std::f64::consts::PI * f[0] * (path_len(t1) - path_len(t0)) / C;
|
||
// Compare on the unit circle (phases are mod 2π).
|
||
let diff = (Complex64::from_polar(1.0, measured_dphi)
|
||
* Complex64::from_polar(1.0, -expected_dphi))
|
||
.arg();
|
||
assert!(
|
||
diff.abs() < 1e-6,
|
||
"step {step}: measured Δφ {measured_dphi} vs analytic {expected_dphi}"
|
||
);
|
||
}
|
||
}
|
||
}
|