Files
ruvnet--RuView/v2/crates/ruview-unified/src/synth/raytrace.rs
T
Claude a1a59baf72 feat(ruview-unified): unified RF spatial world model P1 (ADR-273..278)
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
2026-07-26 19:03:39 +00:00

274 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Image-method multipath ray tracer for shoebox rooms (ADR-276 §3).
//!
//! AllenBerkley 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();
// AllenBerkley images: per axis, sign ∈ {+, } and lattice shift
// n ∈ {1, 0, 1}; bounce count is |2n| for +, |2n1| 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 TXRX 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}"
);
}
}
}