diff --git a/v2/crates/nvsim/src/lib.rs b/v2/crates/nvsim/src/lib.rs index e581532e..0c8021fe 100644 --- a/v2/crates/nvsim/src/lib.rs +++ b/v2/crates/nvsim/src/lib.rs @@ -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, diff --git a/v2/crates/nvsim/src/propagation.rs b/v2/crates/nvsim/src/propagation.rs new file mode 100644 index 00000000..4b92691d --- /dev/null +++ b/v2/crates/nvsim/src/propagation.rs @@ -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); + } +}