236 lines
8.7 KiB
Rust
236 lines
8.7 KiB
Rust
//! 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);
|
||
}
|
||
}
|