feat(nvsim): propagation.rs material attenuation [nvsim:pass3]
Pass 3 of the implementation plan. Adds per-material attenuation along
sensor–source line-of-sight segments. Free-space 1/r³ falloff stays in
source.rs (it's part of the dipole formula); this layer applies the
*additional* attenuation when LoS crosses material slabs.
Public API:
- Material enum: Air, Drywall, Brick, ConcreteDry,
ReinforcedConcrete, SheetSteel
- LosSegment { material, path_m }
- material_loss_db_per_m(Material) -> f64 — table lookup
- material_is_heavy(Material) -> bool — gates HEAVY_ATTENUATION flag
- attenuate(B, segments) -> (Vec3, heavy_flag) — top-level transform
- Propagator struct as a stateless wrapper with room for future
per-frequency parameters
Per-material loss values (DC–10 kHz) per plan §2.2:
- Air / Drywall / Brick: 0 dB/m (drywall + brick conjectural; no
systematic primary source for residential-wall magnetic-field
penetration loss at RuView geometry — gap flagged in plan §6.3)
- ConcreteDry: 0.5 dB/m (Ulrich NDT&E Int. 35, 2002 proxy — also
conjectural)
- ReinforcedConcrete: 20 dB/m + heavy_flag
- SheetSteel: 100 dB/m representative DC bulk loss + heavy_flag
NaN-safety per Pass-3 acceptance gate: segments with non-finite or
non-positive `path_m` are silently skipped — no NaN/Inf propagates
to the digitiser. Asserted in
test_nan_or_negative_path_is_skipped_without_nan_in_output.
7 new tests:
- free_space_is_identity_transform
- drywall_is_approximately_zero_db
- dry_concrete_attenuates_at_half_db_per_meter
(1 dB total = 10^(-1/20) ≈ 0.8913 linear)
- reinforced_concrete_attenuates_and_raises_heavy_flag
(4 dB total = 10^(-0.2) ≈ 0.6310 linear)
- nan_or_negative_path_is_skipped_without_nan_in_output
— Pass-3 NaN guard
- empty_los_returns_input_unchanged
- propagator_struct_dispatches_to_free_function
Validated:
- cargo test -p nvsim → 26 passed (was 19; +7).
- cargo test --workspace --no-default-features → 1,601 passed,
0 failed, 8 ignored (was 1,594; +7).
- ESP32-S3 on COM7 streaming live CSI (cb #200, recent reboot).
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
a6ac08c662
commit
8c062fbaa4
|
|
@ -28,10 +28,14 @@
|
|||
#![warn(missing_docs)]
|
||||
|
||||
pub mod frame;
|
||||
pub mod propagation;
|
||||
pub mod scene;
|
||||
pub mod source;
|
||||
|
||||
pub use frame::{MagFrame, MAG_FRAME_MAGIC, MAG_FRAME_VERSION};
|
||||
pub use propagation::{
|
||||
attenuate, material_is_heavy, material_loss_db_per_m, LosSegment, Material, Propagator,
|
||||
};
|
||||
pub use scene::{CurrentLoop, DipoleSource, EddyCurrent, FerrousObject, Scene};
|
||||
pub use source::{
|
||||
current_loop_field, dipole_field, ferrous_field, scene_field_at, scene_field_at_sensors,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
//! Per-material magnetic-field attenuation along sensor–source line-of-sight
|
||||
//! segments — Pass 3 of the implementation plan.
|
||||
//!
|
||||
//! Free-space `1/r³` falloff lives in [`crate::source`] (it's part of the
|
||||
//! dipole formula). This layer applies *additional* attenuation when the LoS
|
||||
//! crosses material slabs of known thickness. Default — for air / vacuum —
|
||||
//! is the identity transform.
|
||||
//!
|
||||
//! # Primary sources
|
||||
//!
|
||||
//! - Jackson, *Classical Electrodynamics* 3e (1999) §5.8, §8.1 — skin depth.
|
||||
//! - Cullity & Graham, *Introduction to Magnetic Materials* 2e (2009) Ch. 2.
|
||||
//! - Ulrich, *NDT&E Int.* 35 (2002) — concrete-attenuation proxy (cited as
|
||||
//! *proxy*; the real research gap is plan §6.3).
|
||||
//!
|
||||
//! # Honest scope
|
||||
//!
|
||||
//! Plan §2.2 explicitly marks drywall / brick / dry-concrete loss values as
|
||||
//! **conjectural** with defensible defaults. We re-state that here in code:
|
||||
//! the table is the best public-domain estimate at DC–10 kHz, but no
|
||||
//! systematic measurement of residential-wall magnetic-field penetration
|
||||
//! loss at RuView geometry has been published. Reinforced concrete carries
|
||||
//! a warning flag so consumers know to escalate.
|
||||
|
||||
use crate::scene::Vec3;
|
||||
|
||||
/// Material categories the simulator knows about. Extend by adding to this
|
||||
/// enum + the per-material entry in [`material_loss_db_per_m`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Material {
|
||||
/// Vacuum / air. Identity attenuation.
|
||||
Air,
|
||||
/// Gypsum drywall, dry. Conjectural 0 dB/m.
|
||||
Drywall,
|
||||
/// Dry brick. Conjectural 0 dB/m.
|
||||
Brick,
|
||||
/// Dry concrete, no rebar. Conjectural 0.5 dB/m (Ulrich 2002 proxy).
|
||||
ConcreteDry,
|
||||
/// Reinforced concrete. 20 dB/m + raises the heavy-attenuation flag.
|
||||
ReinforcedConcrete,
|
||||
/// Sheet steel (low-carbon). Frequency-dependent skin-depth attenuation
|
||||
/// per Jackson §8.1; the simulator passes a representative DC value.
|
||||
SheetSteel,
|
||||
}
|
||||
|
||||
/// One slab of material along a line-of-sight segment.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct LosSegment {
|
||||
/// Material in this slab.
|
||||
pub material: Material,
|
||||
/// Path length through the slab (m). Must be `>= 0` and finite; `0`
|
||||
/// is the documented no-op input.
|
||||
pub path_m: f64,
|
||||
}
|
||||
|
||||
/// Per-meter loss in decibels at DC–10 kHz. See plan §2.2 for primary
|
||||
/// sources and conjecture markers.
|
||||
pub fn material_loss_db_per_m(m: Material) -> f64 {
|
||||
match m {
|
||||
Material::Air => 0.0,
|
||||
Material::Drywall => 0.0, // conjecture: gypsum non-ferromagnetic
|
||||
Material::Brick => 0.0, // conjecture: same logic as drywall
|
||||
Material::ConcreteDry => 0.5, // conjecture: Ulrich 2002 proxy
|
||||
Material::ReinforcedConcrete => 20.0, // proxy + warning flag (plan §2.2)
|
||||
Material::SheetSteel => 100.0, // frequency-dependent in reality;
|
||||
// representative DC bulk loss
|
||||
}
|
||||
}
|
||||
|
||||
/// True iff this material warrants the `HEAVY_ATTENUATION` frame flag
|
||||
/// (i.e. the simulator's confidence in the per-meter loss is poor and the
|
||||
/// downstream consumer should know to interpret the reading with caution).
|
||||
pub fn material_is_heavy(m: Material) -> bool {
|
||||
matches!(m, Material::ReinforcedConcrete | Material::SheetSteel)
|
||||
}
|
||||
|
||||
/// Apply per-segment attenuation to an incoming 3-vector field. Returns
|
||||
/// `(B_out, heavy_flag)` where `heavy_flag` is `true` if any segment was
|
||||
/// flagged as heavy / low-confidence.
|
||||
///
|
||||
/// Total loss is the sum of `path_m × loss_db_per_m` across segments,
|
||||
/// converted to a linear scale factor. NaN-safe — segments with non-finite
|
||||
/// `path_m` are skipped (no contribution, no panic).
|
||||
pub fn attenuate(b_in: Vec3, segments: &[LosSegment]) -> (Vec3, bool) {
|
||||
let mut total_db = 0.0_f64;
|
||||
let mut heavy = false;
|
||||
for seg in segments {
|
||||
if !seg.path_m.is_finite() || seg.path_m <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
total_db += seg.path_m * material_loss_db_per_m(seg.material);
|
||||
heavy |= material_is_heavy(seg.material);
|
||||
}
|
||||
let scale = 10.0_f64.powf(-total_db / 20.0);
|
||||
(
|
||||
[b_in[0] * scale, b_in[1] * scale, b_in[2] * scale],
|
||||
heavy,
|
||||
)
|
||||
}
|
||||
|
||||
/// Aggregate "propagator" type — currently a stateless wrapper over
|
||||
/// [`attenuate`] but a struct to keep room for future per-frequency or
|
||||
/// per-thickness parameters without breaking the call-site shape.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Propagator;
|
||||
|
||||
impl Propagator {
|
||||
/// Identity-attenuation propagator (air/free-space).
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Run [`attenuate`] across a slice of LoS segments.
|
||||
pub fn attenuate(self, b_in: Vec3, segments: &[LosSegment]) -> (Vec3, bool) {
|
||||
attenuate(b_in, segments)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn free_space_is_identity_transform() {
|
||||
// Air with any path length: B_out == B_in, no heavy flag.
|
||||
let b_in = [1.0e-9, 2.0e-9, 3.0e-9];
|
||||
let segs = [LosSegment {
|
||||
material: Material::Air,
|
||||
path_m: 5.0,
|
||||
}];
|
||||
let (b_out, heavy) = attenuate(b_in, &segs);
|
||||
assert_relative_eq!(b_out[0], b_in[0], max_relative = 1e-12);
|
||||
assert_relative_eq!(b_out[1], b_in[1], max_relative = 1e-12);
|
||||
assert_relative_eq!(b_out[2], b_in[2], max_relative = 1e-12);
|
||||
assert!(!heavy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drywall_is_approximately_zero_db() {
|
||||
// Plan §2.2 marks drywall as conjectural 0 dB/m. The simulator
|
||||
// commits to identity for now; if a primary source is ever cited
|
||||
// this test is the regression boundary.
|
||||
let b_in = [1.0e-9, 0.0, 0.0];
|
||||
let segs = [LosSegment {
|
||||
material: Material::Drywall,
|
||||
path_m: 0.1,
|
||||
}];
|
||||
let (b_out, heavy) = attenuate(b_in, &segs);
|
||||
assert_relative_eq!(b_out[0], b_in[0], max_relative = 1e-12);
|
||||
assert!(!heavy, "drywall is not flagged as heavy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_concrete_attenuates_at_half_db_per_meter() {
|
||||
// 0.5 dB/m × 2 m = 1 dB total. Linear scale = 10^(-1/20) ≈ 0.8913.
|
||||
let b_in = [1.0_f64, 0.0, 0.0];
|
||||
let segs = [LosSegment {
|
||||
material: Material::ConcreteDry,
|
||||
path_m: 2.0,
|
||||
}];
|
||||
let (b_out, heavy) = attenuate(b_in, &segs);
|
||||
let expected = 10.0_f64.powf(-1.0 / 20.0);
|
||||
assert_relative_eq!(b_out[0], expected, max_relative = 1e-12);
|
||||
assert!(!heavy, "dry concrete is not flagged heavy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reinforced_concrete_attenuates_and_raises_heavy_flag() {
|
||||
// 20 dB/m × 0.2 m = 4 dB. Linear scale = 10^(-0.2) ≈ 0.6310.
|
||||
let b_in = [1.0_f64; 3];
|
||||
let segs = [LosSegment {
|
||||
material: Material::ReinforcedConcrete,
|
||||
path_m: 0.2,
|
||||
}];
|
||||
let (b_out, heavy) = attenuate(b_in, &segs);
|
||||
let expected = 10.0_f64.powf(-4.0 / 20.0);
|
||||
for k in 0..3 {
|
||||
assert_relative_eq!(b_out[k], expected, max_relative = 1e-12);
|
||||
}
|
||||
assert!(heavy, "reinforced concrete must raise heavy_flag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nan_or_negative_path_is_skipped_without_nan_in_output() {
|
||||
// A degenerate or hostile input must not propagate NaN/Inf to the
|
||||
// pipeline (the digitiser would otherwise produce a poisoned frame).
|
||||
let b_in = [1.0_f64, 2.0, 3.0];
|
||||
let segs = [
|
||||
LosSegment {
|
||||
material: Material::ConcreteDry,
|
||||
path_m: f64::NAN,
|
||||
},
|
||||
LosSegment {
|
||||
material: Material::Drywall,
|
||||
path_m: -1.0, // negative paths are skipped, not negated
|
||||
},
|
||||
LosSegment {
|
||||
material: Material::Air,
|
||||
path_m: 5.0,
|
||||
},
|
||||
];
|
||||
let (b_out, heavy) = attenuate(b_in, &segs);
|
||||
for k in 0..3 {
|
||||
assert!(
|
||||
b_out[k].is_finite(),
|
||||
"B[{k}] = {} is non-finite — pass-3 NaN guard failed",
|
||||
b_out[k]
|
||||
);
|
||||
// Air alone -> identity; the malformed segments contributed nothing.
|
||||
assert_relative_eq!(b_out[k], b_in[k], max_relative = 1e-12);
|
||||
}
|
||||
assert!(!heavy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_los_returns_input_unchanged() {
|
||||
let b_in = [1.0_f64, 2.0, 3.0];
|
||||
let (b_out, heavy) = attenuate(b_in, &[]);
|
||||
assert_eq!(b_out, b_in);
|
||||
assert!(!heavy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagator_struct_dispatches_to_free_function() {
|
||||
let b_in = [1.0_f64, 2.0, 3.0];
|
||||
let segs = [LosSegment {
|
||||
material: Material::Air,
|
||||
path_m: 1.0,
|
||||
}];
|
||||
let p = Propagator::new();
|
||||
let (b_out, _) = p.attenuate(b_in, &segs);
|
||||
assert_eq!(b_out, b_in);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue