Skip to main content

oxiphysics_materials/
electromagnetic.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Electromagnetic material properties.
5//!
6//! Covers permittivity / permeability / conductivity tensors,
7//! metamaterial properties (negative-index, epsilon-near-zero),
8//! the Drude model for metals, the Lorentz oscillator model,
9//! the Sellmeier equation for optical glass, magneto-optic effects
10//! (Faraday rotation), the Meissner effect in superconductors,
11//! ferroelectric and magnetic hysteresis (P-E loop, B-H loop),
12//! the Jiles-Atherton hysteresis model, and electromagnetic shielding
13//! effectiveness.
14
15#![allow(dead_code)]
16#![allow(clippy::too_many_arguments)]
17
18use std::f64::consts::PI;
19
20// ---------------------------------------------------------------------------
21// Physical constants
22// ---------------------------------------------------------------------------
23
24/// Speed of light in vacuum \[m/s\].
25pub const C0: f64 = 2.997_924_58e8;
26
27/// Permittivity of free space ε₀ \[F/m\].
28pub const EPS0: f64 = 8.854_187_817e-12;
29
30/// Permeability of free space μ₀ \[H/m\].
31pub const MU0: f64 = 1.256_637_061_4e-6;
32
33/// Impedance of free space Z₀ \[Ω\].
34pub const Z0: f64 = 376.730_313_668;
35
36/// Electron charge \[C\].
37pub const E_CHARGE: f64 = 1.602_176_634e-19;
38
39/// Electron mass \[kg\].
40pub const E_MASS: f64 = 9.109_383_701_5e-31;
41
42/// Boltzmann constant \[J/K\].
43pub const KB: f64 = 1.380_649e-23;
44
45/// Planck constant \[J·s\].
46pub const HBAR: f64 = 1.054_571_817e-34;
47
48// ---------------------------------------------------------------------------
49// 3×3 tensor type
50// ---------------------------------------------------------------------------
51
52/// A 3×3 real-valued tensor stored in row-major order.
53///
54/// Used for permittivity, permeability, and conductivity of anisotropic
55/// media.
56#[derive(Debug, Clone, Copy, PartialEq)]
57pub struct Tensor3x3 {
58    /// Row-major elements: `data[row][col]`.
59    pub data: [[f64; 3]; 3],
60}
61
62impl Tensor3x3 {
63    /// Create a diagonal tensor with equal diagonal entries `d`.
64    pub fn diagonal(d: f64) -> Self {
65        let mut t = Self::zero();
66        t.data[0][0] = d;
67        t.data[1][1] = d;
68        t.data[2][2] = d;
69        t
70    }
71
72    /// Create a zero tensor.
73    pub fn zero() -> Self {
74        Self {
75            data: [[0.0; 3]; 3],
76        }
77    }
78
79    /// Create a tensor from three diagonal entries.
80    pub fn from_diag(d0: f64, d1: f64, d2: f64) -> Self {
81        let mut t = Self::zero();
82        t.data[0][0] = d0;
83        t.data[1][1] = d1;
84        t.data[2][2] = d2;
85        t
86    }
87
88    /// Multiply tensor by a 3-vector: y = T·x.
89    #[allow(clippy::needless_range_loop)]
90    pub fn mul_vec(&self, x: [f64; 3]) -> [f64; 3] {
91        let mut y = [0.0_f64; 3];
92        for i in 0..3 {
93            for j in 0..3 {
94                y[i] += self.data[i][j] * x[j];
95            }
96        }
97        y
98    }
99
100    /// Return the trace of the tensor.
101    pub fn trace(&self) -> f64 {
102        self.data[0][0] + self.data[1][1] + self.data[2][2]
103    }
104
105    /// Return the transpose of the tensor.
106    pub fn transpose(&self) -> Self {
107        let mut t = Self::zero();
108        for i in 0..3 {
109            for j in 0..3 {
110                t.data[i][j] = self.data[j][i];
111            }
112        }
113        t
114    }
115
116    /// Check whether the tensor is symmetric within tolerance `eps`.
117    pub fn is_symmetric(&self, eps: f64) -> bool {
118        for i in 0..3 {
119            for j in 0..3 {
120                if (self.data[i][j] - self.data[j][i]).abs() > eps {
121                    return false;
122                }
123            }
124        }
125        true
126    }
127}
128
129// ---------------------------------------------------------------------------
130// 1. GENERAL ELECTROMAGNETIC MATERIAL
131// ---------------------------------------------------------------------------
132
133/// Full set of electromagnetic material properties for a given medium.
134#[derive(Debug, Clone)]
135pub struct ElectromagneticMaterial {
136    /// Human-readable label.
137    pub name: String,
138    /// Relative permittivity tensor ε_r (dimensionless).
139    pub permittivity: Tensor3x3,
140    /// Relative permeability tensor μ_r (dimensionless).
141    pub permeability: Tensor3x3,
142    /// DC conductivity tensor σ \[S/m\].
143    pub conductivity: Tensor3x3,
144    /// Loss tangent tan(δ) at the reference frequency.
145    pub loss_tangent: f64,
146    /// Reference frequency for loss tangent \[Hz\].
147    pub ref_freq: f64,
148}
149
150impl ElectromagneticMaterial {
151    /// Create an isotropic non-magnetic dielectric.
152    pub fn dielectric(name: &str, eps_r: f64, loss_tangent: f64, ref_freq: f64) -> Self {
153        Self {
154            name: name.to_owned(),
155            permittivity: Tensor3x3::diagonal(eps_r),
156            permeability: Tensor3x3::diagonal(1.0),
157            conductivity: Tensor3x3::zero(),
158            loss_tangent,
159            ref_freq,
160        }
161    }
162
163    /// Create an isotropic conductor (σ >> ωε).
164    pub fn conductor(name: &str, sigma: f64) -> Self {
165        Self {
166            name: name.to_owned(),
167            permittivity: Tensor3x3::diagonal(1.0),
168            permeability: Tensor3x3::diagonal(1.0),
169            conductivity: Tensor3x3::diagonal(sigma),
170            loss_tangent: 0.0,
171            ref_freq: 1.0e9,
172        }
173    }
174
175    /// Scalar relative permittivity (trace / 3) for isotropic media.
176    pub fn eps_r_scalar(&self) -> f64 {
177        self.permittivity.trace() / 3.0
178    }
179
180    /// Scalar relative permeability (trace / 3) for isotropic media.
181    pub fn mu_r_scalar(&self) -> f64 {
182        self.permeability.trace() / 3.0
183    }
184
185    /// Intrinsic impedance Z = Z₀ √(μ_r / ε_r) \[Ω\] (isotropic approximation).
186    pub fn intrinsic_impedance(&self) -> f64 {
187        let eps = self.eps_r_scalar().max(1e-30);
188        let mu = self.mu_r_scalar().max(1e-30);
189        Z0 * (mu / eps).sqrt()
190    }
191
192    /// Phase velocity v_p = c / √(ε_r μ_r) \[m/s\] (isotropic).
193    pub fn phase_velocity(&self) -> f64 {
194        let eps = self.eps_r_scalar().max(1e-30);
195        let mu = self.mu_r_scalar().max(1e-30);
196        C0 / (eps * mu).sqrt()
197    }
198
199    /// Skin depth δ = 1 / √(π f μ σ) \[m\] at frequency `f` \[Hz\].
200    pub fn skin_depth(&self, f: f64) -> f64 {
201        let sigma = self.conductivity.trace() / 3.0;
202        let mu = self.mu_r_scalar();
203        if sigma < 1e-30 || f < 1e-30 {
204            return f64::INFINITY;
205        }
206        1.0 / (PI * f * mu * MU0 * sigma).sqrt()
207    }
208}
209
210// ---------------------------------------------------------------------------
211// 2. METAMATERIALS
212// ---------------------------------------------------------------------------
213
214/// Classification of metamaterial index regime.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum MetamaterialClass {
217    /// ε > 0 and μ > 0: ordinary double-positive (DPS) medium.
218    DoublePosive,
219    /// ε < 0 and μ < 0: double-negative (DNG) / left-handed medium.
220    DoubleNegative,
221    /// ε < 0 and μ > 0: epsilon-negative (ENG) medium.
222    EpsilonNegative,
223    /// ε > 0 and μ < 0: mu-negative (MNG) medium.
224    MuNegative,
225    /// |ε| < threshold: epsilon-near-zero (ENZ) medium.
226    EpsilonNearZero,
227    /// |μ| < threshold: mu-near-zero (MNZ) medium.
228    MuNearZero,
229}
230
231/// Frequency-dispersive metamaterial described by scalar Drude-Lorentz
232/// parameters at a single operating frequency.
233#[derive(Debug, Clone)]
234pub struct Metamaterial {
235    /// Label.
236    pub name: String,
237    /// Real part of relative permittivity ε'_r.
238    pub eps_r_real: f64,
239    /// Imaginary part of relative permittivity ε''_r (loss).
240    pub eps_r_imag: f64,
241    /// Real part of relative permeability μ'_r.
242    pub mu_r_real: f64,
243    /// Imaginary part of relative permeability μ''_r (loss).
244    pub mu_r_imag: f64,
245    /// Operating frequency \[Hz\].
246    pub freq: f64,
247}
248
249impl Metamaterial {
250    /// Classify the metamaterial based on ε and μ signs.
251    pub fn classify(&self) -> MetamaterialClass {
252        let enz_thresh = 0.01;
253        let mnz_thresh = 0.01;
254        if self.eps_r_real.abs() < enz_thresh {
255            return MetamaterialClass::EpsilonNearZero;
256        }
257        if self.mu_r_real.abs() < mnz_thresh {
258            return MetamaterialClass::MuNearZero;
259        }
260        match (self.eps_r_real < 0.0, self.mu_r_real < 0.0) {
261            (true, true) => MetamaterialClass::DoubleNegative,
262            (true, false) => MetamaterialClass::EpsilonNegative,
263            (false, true) => MetamaterialClass::MuNegative,
264            (false, false) => MetamaterialClass::DoublePosive,
265        }
266    }
267
268    /// Refractive index n = √(ε μ).  Negative for DNG media.
269    ///
270    /// Uses the convention that the imaginary part is negative for lossy DNG.
271    pub fn refractive_index(&self) -> f64 {
272        let n2 = self.eps_r_real * self.mu_r_real;
273        if n2 >= 0.0 {
274            // DNG (both negative): product is positive but n is defined negative.
275            if self.eps_r_real < 0.0 && self.mu_r_real < 0.0 {
276                -(n2.sqrt())
277            } else {
278                n2.sqrt()
279            }
280        } else {
281            // Single-negative: n is negative real (no imaginary part in this simplified form).
282            -((-n2).sqrt())
283        }
284    }
285
286    /// Group velocity v_g = c / (n + ω dn/dω).  Here we return -C0/n for
287    /// ideal DNG as a qualitative indicator.
288    pub fn group_velocity_approx(&self) -> f64 {
289        let n = self.refractive_index();
290        if n.abs() < 1e-30 { C0 } else { C0 / n }
291    }
292}
293
294// ---------------------------------------------------------------------------
295// 3. DRUDE MODEL
296// ---------------------------------------------------------------------------
297
298/// Drude model for the frequency-dependent permittivity of a free-electron
299/// metal.
300///
301/// ε(ω) = ε∞ − ωp² / (ω² + iγω)
302#[derive(Debug, Clone, Copy)]
303pub struct DrudeModel {
304    /// High-frequency (background) permittivity ε∞.
305    pub eps_inf: f64,
306    /// Plasma frequency ωp \[rad/s\].
307    pub omega_p: f64,
308    /// Collision (damping) rate γ \[rad/s\].
309    pub gamma: f64,
310}
311
312impl DrudeModel {
313    /// Construct a Drude model from material parameters.
314    pub fn new(eps_inf: f64, omega_p: f64, gamma: f64) -> Self {
315        Self {
316            eps_inf,
317            omega_p,
318            gamma,
319        }
320    }
321
322    /// Typical Drude parameters for gold at optical frequencies.
323    pub fn gold() -> Self {
324        Self {
325            eps_inf: 9.5,
326            omega_p: 1.37e16, // rad/s
327            gamma: 1.05e14,   // rad/s
328        }
329    }
330
331    /// Typical Drude parameters for silver.
332    pub fn silver() -> Self {
333        Self {
334            eps_inf: 5.0,
335            omega_p: 1.39e16,
336            gamma: 2.73e13,
337        }
338    }
339
340    /// Real part of the complex permittivity ε'(ω).
341    pub fn eps_real(&self, omega: f64) -> f64 {
342        self.eps_inf - self.omega_p * self.omega_p / (omega * omega + self.gamma * self.gamma)
343    }
344
345    /// Imaginary part of the complex permittivity ε''(ω) (sign convention: -iωt).
346    pub fn eps_imag(&self, omega: f64) -> f64 {
347        self.omega_p * self.omega_p * self.gamma
348            / (omega * (omega * omega + self.gamma * self.gamma))
349    }
350
351    /// DC conductivity σ₀ = ε₀ ωp² / γ \[S/m\].
352    pub fn dc_conductivity(&self) -> f64 {
353        EPS0 * self.omega_p * self.omega_p / self.gamma
354    }
355
356    /// Skin depth at angular frequency ω \[rad/s\].
357    pub fn skin_depth(&self, omega: f64) -> f64 {
358        let eps_im = self.eps_imag(omega);
359        if eps_im < 1e-30 {
360            return f64::INFINITY;
361        }
362        // δ ≈ c / (ω √(ε''/2)) for good conductors.
363        C0 / (omega * (eps_im / 2.0).sqrt())
364    }
365
366    /// Plasma frequency in Hz.
367    pub fn plasma_freq_hz(&self) -> f64 {
368        self.omega_p / (2.0 * PI)
369    }
370}
371
372// ---------------------------------------------------------------------------
373// 4. LORENTZ OSCILLATOR MODEL
374// ---------------------------------------------------------------------------
375
376/// Single-resonance Lorentz oscillator contribution to permittivity.
377///
378/// ε(ω) = ε∞ + Δε ω₀² / (ω₀² − ω² − iΓω)
379#[derive(Debug, Clone, Copy)]
380pub struct LorentzOscillator {
381    /// Background permittivity.
382    pub eps_inf: f64,
383    /// Oscillator strength Δε (static − high-freq permittivity).
384    pub delta_eps: f64,
385    /// Resonance angular frequency ω₀ \[rad/s\].
386    pub omega0: f64,
387    /// Damping rate Γ \[rad/s\].
388    pub gamma: f64,
389}
390
391impl LorentzOscillator {
392    /// Construct a Lorentz oscillator.
393    pub fn new(eps_inf: f64, delta_eps: f64, omega0: f64, gamma: f64) -> Self {
394        Self {
395            eps_inf,
396            delta_eps,
397            omega0,
398            gamma,
399        }
400    }
401
402    /// Real part of ε(ω).
403    pub fn eps_real(&self, omega: f64) -> f64 {
404        let d = (self.omega0 * self.omega0 - omega * omega).powi(2) + (self.gamma * omega).powi(2);
405        self.eps_inf
406            + self.delta_eps
407                * self.omega0
408                * self.omega0
409                * (self.omega0 * self.omega0 - omega * omega)
410                / d
411    }
412
413    /// Imaginary part of ε(ω) (absorptive, > 0 for lossy material).
414    pub fn eps_imag(&self, omega: f64) -> f64 {
415        let d = (self.omega0 * self.omega0 - omega * omega).powi(2) + (self.gamma * omega).powi(2);
416        self.delta_eps * self.omega0 * self.omega0 * self.gamma * omega / d
417    }
418
419    /// Static permittivity ε(0) = ε∞ + Δε.
420    pub fn eps_static(&self) -> f64 {
421        self.eps_inf + self.delta_eps
422    }
423
424    /// Resonant absorption peak ε''(ω₀).
425    pub fn eps_imag_at_resonance(&self) -> f64 {
426        self.eps_imag(self.omega0)
427    }
428}
429
430// ---------------------------------------------------------------------------
431// 5. SELLMEIER EQUATION
432// ---------------------------------------------------------------------------
433
434/// Sellmeier dispersion model for the refractive index of optical glass.
435///
436/// n²(λ) = 1 + Σ_i B_i λ² / (λ² − C_i)
437///
438/// where λ is in micrometres and C_i are resonance wavelengths squared \[μm²\].
439#[derive(Debug, Clone)]
440pub struct SellmeierModel {
441    /// Sellmeier B coefficients (dimensionless oscillator strengths).
442    pub b: Vec<f64>,
443    /// Sellmeier C coefficients \[μm²\].
444    pub c: Vec<f64>,
445}
446
447impl SellmeierModel {
448    /// Construct a Sellmeier model.
449    pub fn new(b: Vec<f64>, c: Vec<f64>) -> Self {
450        assert_eq!(b.len(), c.len(), "Sellmeier B and C must have equal length");
451        Self { b, c }
452    }
453
454    /// Standard Sellmeier coefficients for BK7 borosilicate glass.
455    pub fn bk7() -> Self {
456        Self::new(
457            vec![1.039_612_12, 0.231_792_344, 1.010_469_45],
458            vec![0.006_000_699_5, 0.020_017_914, 103.560_653],
459        )
460    }
461
462    /// Standard Sellmeier coefficients for fused silica (SiO₂).
463    pub fn fused_silica() -> Self {
464        Self::new(
465            vec![0.696_166_3, 0.407_942_6, 0.897_479_4],
466            vec![0.004_679_148_6, 0.013_512_063, 97.934_002_5],
467        )
468    }
469
470    /// Refractive index n(λ) for wavelength `lambda_um` in micrometres.
471    pub fn refractive_index(&self, lambda_um: f64) -> f64 {
472        let l2 = lambda_um * lambda_um;
473        let n2 = 1.0
474            + self
475                .b
476                .iter()
477                .zip(self.c.iter())
478                .map(|(&bi, &ci)| bi * l2 / (l2 - ci))
479                .sum::<f64>();
480        n2.max(0.0).sqrt()
481    }
482
483    /// Group refractive index n_g = n − λ dn/dλ, estimated by finite difference.
484    pub fn group_index(&self, lambda_um: f64) -> f64 {
485        let dl = 1e-5_f64;
486        let n1 = self.refractive_index(lambda_um - dl);
487        let n2 = self.refractive_index(lambda_um + dl);
488        let dn_dl = (n2 - n1) / (2.0 * dl);
489        self.refractive_index(lambda_um) - lambda_um * dn_dl
490    }
491
492    /// Group velocity dispersion GVD = λ³/(2πc²) · d²n/dλ² \[s²/m\] at `lambda_um`.
493    pub fn gvd(&self, lambda_um: f64) -> f64 {
494        let dl = 1e-4_f64;
495        let n0 = self.refractive_index(lambda_um);
496        let np = self.refractive_index(lambda_um + dl);
497        let nm = self.refractive_index(lambda_um - dl);
498        let d2n = (np - 2.0 * n0 + nm) / (dl * dl);
499        let lambda_m = lambda_um * 1e-6;
500        lambda_m.powi(3) / (2.0 * PI * C0 * C0) * d2n
501    }
502}
503
504// ---------------------------------------------------------------------------
505// 6. MAGNETO-OPTIC EFFECTS  –  Faraday rotation
506// ---------------------------------------------------------------------------
507
508/// Faraday rotation parameters for a magneto-optic material.
509#[derive(Debug, Clone, Copy)]
510pub struct FaradayRotation {
511    /// Verdet constant V \[rad/(T·m)\].
512    pub verdet_constant: f64,
513    /// Applied magnetic field B \[T\].
514    pub b_field: f64,
515    /// Interaction length L \[m\].
516    pub length: f64,
517}
518
519impl FaradayRotation {
520    /// Construct Faraday rotation parameters.
521    pub fn new(verdet_constant: f64, b_field: f64, length: f64) -> Self {
522        Self {
523            verdet_constant,
524            b_field,
525            length,
526        }
527    }
528
529    /// Rotation angle θ = V · B · L \[rad\].
530    pub fn rotation_angle(&self) -> f64 {
531        self.verdet_constant * self.b_field * self.length
532    }
533
534    /// Rotation angle in degrees.
535    pub fn rotation_angle_deg(&self) -> f64 {
536        self.rotation_angle().to_degrees()
537    }
538
539    /// Verdet constant for terbium gallium garnet (TGG) at 1064 nm \[rad/(T·m)\].
540    pub fn tgg_verdet_1064nm() -> f64 {
541        -40.0 // approx value
542    }
543
544    /// Off-diagonal permittivity element ε_xy for a gyromagnetic medium.
545    ///
546    /// ε_xy = i n Δn where Δn is the circular birefringence.
547    /// Here we return a qualitative estimate.
548    pub fn eps_xy_estimate(&self, n: f64) -> f64 {
549        // θ ≈ π Δn L / λ  →  Δn ≈ θ λ / (π L)
550        // ε_xy ≈ 2 n Δn (rough).
551        let theta = self.rotation_angle();
552        let lambda_approx = 1.064e-6_f64;
553        let delta_n = theta * lambda_approx / (PI * self.length.max(1e-30));
554        2.0 * n * delta_n
555    }
556}
557
558// ---------------------------------------------------------------------------
559// 7. SUPERCONDUCTOR  –  Meissner effect
560// ---------------------------------------------------------------------------
561
562/// Superconductor electromagnetic parameters.
563#[derive(Debug, Clone, Copy)]
564pub struct Superconductor {
565    /// Critical temperature Tc \[K\].
566    pub tc: f64,
567    /// London penetration depth λ_L at T = 0 \[m\].
568    pub lambda_london_0: f64,
569    /// BCS coherence length ξ₀ \[m\].
570    pub xi0: f64,
571}
572
573impl Superconductor {
574    /// Construct superconductor parameters.
575    pub fn new(tc: f64, lambda_london_0: f64, xi0: f64) -> Self {
576        Self {
577            tc,
578            lambda_london_0,
579            xi0,
580        }
581    }
582
583    /// Typical parameters for YBCO (YBa₂Cu₃O₇).
584    pub fn ybco() -> Self {
585        Self {
586            tc: 92.0,
587            lambda_london_0: 140e-9,
588            xi0: 1.2e-9,
589        }
590    }
591
592    /// Typical parameters for niobium.
593    pub fn niobium() -> Self {
594        Self {
595            tc: 9.26,
596            lambda_london_0: 39e-9,
597            xi0: 38e-9,
598        }
599    }
600
601    /// Temperature-dependent London penetration depth \[m\] using the
602    /// two-fluid model: λ(T) = λ_L(0) / √(1 − (T/Tc)⁴).
603    pub fn penetration_depth(&self, temp_k: f64) -> f64 {
604        if temp_k >= self.tc {
605            return f64::INFINITY; // Normal state.
606        }
607        let t_ratio = temp_k / self.tc;
608        self.lambda_london_0 / (1.0 - t_ratio.powi(4)).sqrt()
609    }
610
611    /// Ginzburg-Landau parameter κ = λ / ξ.
612    ///
613    /// κ > 1/√2 → type-II superconductor.
614    pub fn gl_parameter(&self, temp_k: f64) -> f64 {
615        self.penetration_depth(temp_k) / self.xi0
616    }
617
618    /// Surface resistance Rs(f) \[Ω\] in the Mattis-Bardeen limit
619    /// (low T, f << 2Δ/h): R_s ≈ μ₀² σ_n ω² λ³_L / 2.
620    pub fn surface_resistance(&self, freq: f64, sigma_normal: f64, temp_k: f64) -> f64 {
621        let omega = 2.0 * PI * freq;
622        let lam = self.penetration_depth(temp_k);
623        if lam.is_infinite() {
624            return f64::INFINITY;
625        }
626        MU0 * MU0 * sigma_normal * omega * omega * lam.powi(3) / 2.0
627    }
628
629    /// Whether the material is in the superconducting state at `temp_k`.
630    pub fn is_superconducting(&self, temp_k: f64) -> bool {
631        temp_k < self.tc
632    }
633
634    /// Lower critical field Hc1 estimate \[A/m\] (from London theory).
635    pub fn hc1_estimate(&self, temp_k: f64) -> f64 {
636        let lam = self.penetration_depth(temp_k);
637        if lam.is_infinite() {
638            return 0.0;
639        }
640        // Hc1 ≈ Φ₀ ln(κ) / (4π μ₀ λ²)
641        let phi0 = 2.067_833_848e-15_f64; // Flux quantum [Wb]
642        let kappa = self.gl_parameter(temp_k);
643        if kappa <= 1.0 {
644            return 0.0;
645        }
646        phi0 * kappa.ln() / (4.0 * PI * MU0 * lam * lam)
647    }
648}
649
650// ---------------------------------------------------------------------------
651// 8. FERROELECTRIC HYSTERESIS  –  P-E loop
652// ---------------------------------------------------------------------------
653
654/// Simple Preisach-inspired P-E loop model for ferroelectrics.
655///
656/// Uses the phenomenological tanh saturation model:
657/// P(E) = P_sat · tanh((E − E_c · sign(E)) / E₀)
658#[derive(Debug, Clone, Copy)]
659pub struct FerroelectricPE {
660    /// Saturation polarisation P_sat \[C/m²\].
661    pub p_sat: f64,
662    /// Coercive field E_c \[V/m\].
663    pub e_coercive: f64,
664    /// Slope parameter E₀ \[V/m\] (steepness of the loop wall).
665    pub e0: f64,
666}
667
668impl FerroelectricPE {
669    /// Construct a P-E hysteresis model.
670    pub fn new(p_sat: f64, e_coercive: f64, e0: f64) -> Self {
671        Self {
672            p_sat,
673            e_coercive,
674            e0,
675        }
676    }
677
678    /// Typical barium titanate parameters.
679    pub fn batio3() -> Self {
680        Self {
681            p_sat: 0.26,
682            e_coercive: 1.0e5,
683            e0: 5.0e4,
684        }
685    }
686
687    /// Polarisation on the ascending branch P⁺(E).
688    pub fn polarisation_ascending(&self, e_field: f64) -> f64 {
689        self.p_sat * ((e_field - self.e_coercive) / self.e0).tanh()
690    }
691
692    /// Polarisation on the descending branch P⁻(E).
693    pub fn polarisation_descending(&self, e_field: f64) -> f64 {
694        self.p_sat * ((e_field + self.e_coercive) / self.e0).tanh()
695    }
696
697    /// Remnant polarisation P_r at E = 0 on the ascending branch \[C/m²\].
698    pub fn remnant_polarisation(&self) -> f64 {
699        self.polarisation_ascending(0.0).abs()
700    }
701
702    /// Hysteresis loop area (energy loss per cycle per unit volume) \[J/m³\].
703    ///
704    /// Estimated by integrating E dP over one cycle.
705    pub fn hysteresis_energy_loss(&self, e_max: f64, n_steps: usize) -> f64 {
706        let de = 2.0 * e_max / n_steps as f64;
707        let mut loss = 0.0_f64;
708        // Ascending: E from -e_max to +e_max.
709        let mut e = -e_max;
710        let mut p_prev = self.polarisation_ascending(e);
711        for _ in 0..n_steps {
712            e += de;
713            let p = self.polarisation_ascending(e);
714            loss += e * (p - p_prev);
715            p_prev = p;
716        }
717        // Descending: E from +e_max to -e_max.
718        e = e_max;
719        p_prev = self.polarisation_descending(e);
720        for _ in 0..n_steps {
721            e -= de;
722            let p = self.polarisation_descending(e);
723            loss -= e * (p - p_prev); // sign for descending direction.
724            p_prev = p;
725        }
726        loss.abs()
727    }
728}
729
730// ---------------------------------------------------------------------------
731// 9. MAGNETIC HYSTERESIS  –  B-H loop & Jiles-Atherton model
732// ---------------------------------------------------------------------------
733
734/// Jiles-Atherton (JA) magnetic hysteresis model parameters.
735///
736/// The JA model describes the anhysteretic magnetisation M_an and the
737/// irreversible magnetisation via a differential equation.
738#[derive(Debug, Clone, Copy)]
739pub struct JilesAthertonParams {
740    /// Saturation magnetisation M_s \[A/m\].
741    pub m_sat: f64,
742    /// Shape parameter a \[A/m\] of the Langevin function.
743    pub a: f64,
744    /// Interdomain coupling coefficient α (dimensionless).
745    pub alpha: f64,
746    /// Pinning loss coefficient k \[A/m\].
747    pub k: f64,
748    /// Reversibility coefficient c (0..1).
749    pub c: f64,
750}
751
752impl JilesAthertonParams {
753    /// Construct JA parameters.
754    pub fn new(m_sat: f64, a: f64, alpha: f64, k: f64, c: f64) -> Self {
755        Self {
756            m_sat,
757            a,
758            alpha,
759            k,
760            c,
761        }
762    }
763
764    /// Typical soft iron parameters.
765    pub fn soft_iron() -> Self {
766        Self {
767            m_sat: 1.6e6,
768            a: 470.0,
769            alpha: 1.0e-4,
770            k: 400.0,
771            c: 0.1,
772        }
773    }
774
775    /// Langevin function L(x) = coth(x) − 1/x.
776    fn langevin(x: f64) -> f64 {
777        if x.abs() < 1e-6 {
778            x / 3.0
779        } else {
780            1.0 / x.tanh() - 1.0 / x
781        }
782    }
783
784    /// Effective field H_eff = H + α·M.
785    pub fn h_effective(&self, h: f64, m: f64) -> f64 {
786        h + self.alpha * m
787    }
788
789    /// Anhysteretic magnetisation M_an(H, M).
790    pub fn anhysteretic(&self, h: f64, m: f64) -> f64 {
791        let h_eff = self.h_effective(h, m);
792        self.m_sat * Self::langevin(h_eff / self.a)
793    }
794
795    /// Compute M(H) curve for a triangular H waveform.
796    ///
797    /// Returns `(h_vals, m_vals)` over the cycle.
798    pub fn compute_bh_loop(&self, h_max: f64, n_steps: usize) -> (Vec<f64>, Vec<f64>) {
799        let mut h_vals = Vec::with_capacity(2 * n_steps);
800        let mut m_vals = Vec::with_capacity(2 * n_steps);
801
802        let dh = 2.0 * h_max / n_steps as f64;
803        let mut m = 0.0_f64;
804
805        // Ascending branch.
806        let mut h = -h_max;
807        for _ in 0..n_steps {
808            h_vals.push(h);
809            m_vals.push(m);
810            let m_an = self.anhysteretic(h, m);
811            let denom = (1.0 - self.c) * self.k;
812            let dm_dh = if denom.abs() < 1e-30 {
813                0.0
814            } else {
815                (m_an - m) / denom + self.c * (m_an - m) / self.a
816            };
817            m += dm_dh * dh;
818            m = m.clamp(-self.m_sat, self.m_sat);
819            h += dh;
820        }
821        // Descending branch.
822        h = h_max;
823        for _ in 0..n_steps {
824            h_vals.push(h);
825            m_vals.push(m);
826            let m_an = self.anhysteretic(h, m);
827            let denom = (1.0 - self.c) * self.k;
828            let dm_dh = if denom.abs() < 1e-30 {
829                0.0
830            } else {
831                (m_an - m) / denom + self.c * (m_an - m) / self.a
832            };
833            m -= dm_dh * dh;
834            m = m.clamp(-self.m_sat, self.m_sat);
835            h -= dh;
836        }
837
838        (h_vals, m_vals)
839    }
840
841    /// Estimate coercive field Hc from the BH loop \[A/m\].
842    pub fn coercive_field(&self, h_max: f64, n_steps: usize) -> f64 {
843        let (h_vals, m_vals) = self.compute_bh_loop(h_max, n_steps);
844        // Find H where M changes sign.
845        let mut hc = 0.0_f64;
846        let n = h_vals.len();
847        for i in 1..n {
848            if m_vals[i - 1] * m_vals[i] < 0.0 {
849                hc = h_vals[i].abs();
850                break;
851            }
852        }
853        hc
854    }
855}
856
857/// Compute B = μ₀(H + M) for a JA model at field H.
858pub fn b_from_jiles_atherton(_ja: &JilesAthertonParams, h: f64, m: f64) -> f64 {
859    MU0 * (h + m)
860}
861
862// ---------------------------------------------------------------------------
863// 10. ELECTROMAGNETIC SHIELDING EFFECTIVENESS
864// ---------------------------------------------------------------------------
865
866/// Electromagnetic shielding effectiveness of a conductive enclosure.
867///
868/// SE = R + A + M  (dB)
869/// where R = reflection loss, A = absorption loss, M = re-reflection correction.
870#[derive(Debug, Clone, Copy)]
871pub struct ShieldingEffectiveness {
872    /// Shield material relative permeability μ_r.
873    pub mu_r: f64,
874    /// Shield material conductivity σ \[S/m\].
875    pub sigma: f64,
876    /// Shield thickness d \[m\].
877    pub thickness: f64,
878}
879
880impl ShieldingEffectiveness {
881    /// Construct shielding effectiveness parameters.
882    pub fn new(mu_r: f64, sigma: f64, thickness: f64) -> Self {
883        Self {
884            mu_r,
885            sigma,
886            thickness,
887        }
888    }
889
890    /// Copper shield.
891    pub fn copper(thickness: f64) -> Self {
892        Self {
893            mu_r: 1.0,
894            sigma: 5.8e7,
895            thickness,
896        }
897    }
898
899    /// Mild steel shield.
900    pub fn mild_steel(thickness: f64) -> Self {
901        Self {
902            mu_r: 200.0,
903            sigma: 1.0e7,
904            thickness,
905        }
906    }
907
908    /// Skin depth δ at frequency f \[Hz\].
909    pub fn skin_depth(&self, f: f64) -> f64 {
910        1.0 / (PI * f * self.mu_r * MU0 * self.sigma).sqrt()
911    }
912
913    /// Absorption loss A \[dB\] = 20 log₁₀(e^(d/δ)).
914    pub fn absorption_loss_db(&self, f: f64) -> f64 {
915        let delta = self.skin_depth(f);
916        if delta < 1e-30 {
917            return f64::INFINITY;
918        }
919        20.0 * (self.thickness / delta) * std::f64::consts::LOG10_E
920    }
921
922    /// Reflection loss R \[dB\] for plane-wave incidence in free space.
923    ///
924    /// R ≈ 168 + 10 log₁₀(σ / (μ_r f)) \[dB\].
925    pub fn reflection_loss_db(&self, f: f64) -> f64 {
926        168.0 + 10.0 * (self.sigma / (self.mu_r * f)).log10()
927    }
928
929    /// Re-reflection correction M \[dB\] (only significant when A < 10 dB).
930    pub fn re_reflection_correction_db(&self, f: f64) -> f64 {
931        let a = self.absorption_loss_db(f);
932        if a > 10.0 {
933            return 0.0;
934        }
935        // M ≈ 20 log₁₀|1 − 10^(−A/10)| (simplified).
936        let ratio = 10.0_f64.powf(-a / 10.0);
937        20.0 * (1.0 - ratio).abs().log10()
938    }
939
940    /// Total shielding effectiveness SE \[dB\] = R + A + M.
941    pub fn total_se_db(&self, f: f64) -> f64 {
942        self.reflection_loss_db(f)
943            + self.absorption_loss_db(f)
944            + self.re_reflection_correction_db(f)
945    }
946}
947
948// ---------------------------------------------------------------------------
949// Convenience constructors for common materials
950// ---------------------------------------------------------------------------
951
952/// Return electromagnetic properties for FR4 PCB substrate.
953pub fn fr4() -> ElectromagneticMaterial {
954    ElectromagneticMaterial::dielectric("FR4", 4.5, 0.02, 1.0e9)
955}
956
957/// Return electromagnetic properties for PTFE (Teflon).
958pub fn ptfe() -> ElectromagneticMaterial {
959    ElectromagneticMaterial::dielectric("PTFE", 2.1, 0.0002, 1.0e9)
960}
961
962/// Return electromagnetic properties for distilled water at 2.45 GHz.
963pub fn water_2_45ghz() -> ElectromagneticMaterial {
964    ElectromagneticMaterial::dielectric("Water (2.45 GHz)", 80.0, 0.157, 2.45e9)
965}
966
967/// Return electromagnetic properties for copper.
968pub fn copper_em() -> ElectromagneticMaterial {
969    ElectromagneticMaterial::conductor("Copper", 5.8e7)
970}
971
972// ---------------------------------------------------------------------------
973// Unit tests
974// ---------------------------------------------------------------------------
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979
980    const EPS: f64 = 1e-9;
981
982    // ---- Tensor3x3 ---------------------------------------------------------
983
984    #[test]
985    fn test_tensor_diagonal_trace() {
986        let t = Tensor3x3::diagonal(3.0);
987        assert!((t.trace() - 9.0).abs() < EPS);
988    }
989
990    #[test]
991    fn test_tensor_from_diag_mul_vec() {
992        let t = Tensor3x3::from_diag(2.0, 3.0, 4.0);
993        let y = t.mul_vec([1.0, 1.0, 1.0]);
994        assert!((y[0] - 2.0).abs() < EPS);
995        assert!((y[1] - 3.0).abs() < EPS);
996        assert!((y[2] - 4.0).abs() < EPS);
997    }
998
999    #[test]
1000    fn test_tensor_transpose_diagonal_unchanged() {
1001        let t = Tensor3x3::diagonal(5.0);
1002        let tr = t.transpose();
1003        for i in 0..3 {
1004            for j in 0..3 {
1005                assert!((t.data[i][j] - tr.data[i][j]).abs() < EPS);
1006            }
1007        }
1008    }
1009
1010    #[test]
1011    fn test_tensor_is_symmetric() {
1012        let t = Tensor3x3::diagonal(1.0);
1013        assert!(t.is_symmetric(1e-12));
1014    }
1015
1016    #[test]
1017    fn test_tensor_zero_trace() {
1018        let t = Tensor3x3::zero();
1019        assert_eq!(t.trace(), 0.0);
1020    }
1021
1022    // ---- ElectromagneticMaterial -------------------------------------------
1023
1024    #[test]
1025    fn test_em_material_eps_r_scalar() {
1026        let m = ElectromagneticMaterial::dielectric("test", 4.0, 0.01, 1e9);
1027        assert!((m.eps_r_scalar() - 4.0).abs() < EPS);
1028    }
1029
1030    #[test]
1031    fn test_em_material_intrinsic_impedance_free_space() {
1032        // Vacuum: eps_r = 1, mu_r = 1 → Z = Z0.
1033        let m = ElectromagneticMaterial::dielectric("vacuum", 1.0, 0.0, 1e9);
1034        assert!((m.intrinsic_impedance() - Z0).abs() < 1e-6);
1035    }
1036
1037    #[test]
1038    fn test_em_material_phase_velocity_free_space() {
1039        let m = ElectromagneticMaterial::dielectric("vacuum", 1.0, 0.0, 1e9);
1040        assert!((m.phase_velocity() - C0).abs() < 1.0); // within 1 m/s
1041    }
1042
1043    #[test]
1044    fn test_em_material_skin_depth_conductor() {
1045        let m = copper_em();
1046        let d = m.skin_depth(1.0e9); // 1 GHz
1047        assert!(d > 0.0 && d < 1e-3, "skin depth = {d}");
1048    }
1049
1050    #[test]
1051    fn test_em_material_insulator_infinite_skin_depth() {
1052        let m = ptfe();
1053        let d = m.skin_depth(1.0e9);
1054        assert!(d.is_infinite());
1055    }
1056
1057    // ---- Metamaterial ------------------------------------------------------
1058
1059    #[test]
1060    fn test_metamaterial_double_negative() {
1061        let m = Metamaterial {
1062            name: "DNG".to_owned(),
1063            eps_r_real: -1.0,
1064            eps_r_imag: 0.01,
1065            mu_r_real: -1.0,
1066            mu_r_imag: 0.01,
1067            freq: 1.0e10,
1068        };
1069        assert_eq!(m.classify(), MetamaterialClass::DoubleNegative);
1070        assert!(m.refractive_index() < 0.0);
1071    }
1072
1073    #[test]
1074    fn test_metamaterial_enz() {
1075        let m = Metamaterial {
1076            name: "ENZ".to_owned(),
1077            eps_r_real: 0.005,
1078            eps_r_imag: 0.001,
1079            mu_r_real: 1.0,
1080            mu_r_imag: 0.0,
1081            freq: 1.0e12,
1082        };
1083        assert_eq!(m.classify(), MetamaterialClass::EpsilonNearZero);
1084    }
1085
1086    #[test]
1087    fn test_metamaterial_eps_negative() {
1088        let m = Metamaterial {
1089            name: "ENG".to_owned(),
1090            eps_r_real: -2.0,
1091            eps_r_imag: 0.1,
1092            mu_r_real: 1.0,
1093            mu_r_imag: 0.0,
1094            freq: 1.0e10,
1095        };
1096        assert_eq!(m.classify(), MetamaterialClass::EpsilonNegative);
1097    }
1098
1099    // ---- Drude model -------------------------------------------------------
1100
1101    #[test]
1102    fn test_drude_gold_eps_negative_visible() {
1103        let d = DrudeModel::gold();
1104        // Gold at ω = 2π × 500 THz (green light).
1105        let omega = 2.0 * PI * 500.0e12;
1106        assert!(
1107            d.eps_real(omega) < 0.0,
1108            "gold eps_real should be negative in visible"
1109        );
1110    }
1111
1112    #[test]
1113    fn test_drude_dc_conductivity_positive() {
1114        let d = DrudeModel::silver();
1115        assert!(d.dc_conductivity() > 0.0);
1116    }
1117
1118    #[test]
1119    fn test_drude_plasma_freq_hz() {
1120        let d = DrudeModel::gold();
1121        let fp = d.plasma_freq_hz();
1122        assert!(fp > 1.0e14, "plasma freq should be >100 THz for gold: {fp}");
1123    }
1124
1125    #[test]
1126    fn test_drude_skin_depth_positive() {
1127        let d = DrudeModel::silver();
1128        let omega = 2.0 * PI * 1.0e15;
1129        let delta = d.skin_depth(omega);
1130        assert!(delta > 0.0);
1131    }
1132
1133    // ---- Lorentz oscillator ------------------------------------------------
1134
1135    #[test]
1136    fn test_lorentz_static_eps() {
1137        let l = LorentzOscillator::new(2.0, 3.0, 1.0e15, 1.0e13);
1138        assert!((l.eps_static() - 5.0).abs() < EPS);
1139    }
1140
1141    #[test]
1142    fn test_lorentz_eps_imag_positive() {
1143        let l = LorentzOscillator::new(2.0, 3.0, 1.0e15, 1.0e13);
1144        assert!(l.eps_imag(1.0e15) > 0.0);
1145    }
1146
1147    #[test]
1148    fn test_lorentz_eps_real_below_resonance() {
1149        // Far below resonance eps_real → eps_inf + delta_eps = eps_static.
1150        let l = LorentzOscillator::new(2.0, 3.0, 1.0e15, 1.0e13);
1151        let er_low = l.eps_real(1.0e10); // much below resonance
1152        assert!((er_low - l.eps_static()).abs() < 0.1);
1153    }
1154
1155    #[test]
1156    fn test_lorentz_resonance_peak() {
1157        let l = LorentzOscillator::new(2.0, 3.0, 1.0e15, 1.0e13);
1158        let peak = l.eps_imag_at_resonance();
1159        assert!(peak > 0.0);
1160    }
1161
1162    // ---- Sellmeier model ---------------------------------------------------
1163
1164    #[test]
1165    fn test_sellmeier_bk7_visible() {
1166        let s = SellmeierModel::bk7();
1167        let n = s.refractive_index(0.587); // 587 nm
1168        assert!(n > 1.5 && n < 1.6, "BK7 n at 587 nm = {n}");
1169    }
1170
1171    #[test]
1172    fn test_sellmeier_fused_silica() {
1173        let s = SellmeierModel::fused_silica();
1174        let n = s.refractive_index(1.0); // 1000 nm
1175        assert!(n > 1.44 && n < 1.46, "SiO2 n at 1 μm = {n}");
1176    }
1177
1178    #[test]
1179    fn test_sellmeier_group_index_greater_than_phase_index() {
1180        let s = SellmeierModel::bk7();
1181        let n = s.refractive_index(0.8);
1182        let ng = s.group_index(0.8);
1183        // In normal dispersion, n_g > n.
1184        assert!(ng > n, "n_g={ng}, n={n}");
1185    }
1186
1187    #[test]
1188    fn test_sellmeier_gvd_finite() {
1189        let s = SellmeierModel::fused_silica();
1190        let gvd = s.gvd(1.3); // near zero-dispersion wavelength
1191        assert!(gvd.is_finite());
1192    }
1193
1194    // ---- Faraday rotation --------------------------------------------------
1195
1196    #[test]
1197    fn test_faraday_rotation_angle() {
1198        let f = FaradayRotation::new(100.0, 1.0, 0.01); // V=100 rad/(T·m), B=1 T, L=1 cm
1199        let theta = f.rotation_angle_deg();
1200        assert!((theta - 100.0_f64.to_degrees() * 0.01).abs() < 1e-6);
1201    }
1202
1203    #[test]
1204    fn test_faraday_rotation_zero_field() {
1205        let f = FaradayRotation::new(100.0, 0.0, 0.01);
1206        assert!((f.rotation_angle()).abs() < EPS);
1207    }
1208
1209    #[test]
1210    fn test_faraday_rotation_eps_xy_finite() {
1211        let f = FaradayRotation::new(40.0, 0.5, 0.05);
1212        let eps_xy = f.eps_xy_estimate(2.3);
1213        assert!(eps_xy.is_finite());
1214    }
1215
1216    // ---- Superconductor ----------------------------------------------------
1217
1218    #[test]
1219    fn test_superconductor_below_tc() {
1220        let s = Superconductor::ybco();
1221        assert!(s.is_superconducting(77.0));
1222        assert!(!s.is_superconducting(100.0));
1223    }
1224
1225    #[test]
1226    fn test_superconductor_penetration_depth_increases_with_temp() {
1227        let s = Superconductor::niobium();
1228        let d_low = s.penetration_depth(1.0);
1229        let d_high = s.penetration_depth(8.0);
1230        assert!(d_high > d_low, "λ should increase with temperature");
1231    }
1232
1233    #[test]
1234    fn test_superconductor_gl_parameter_type_ii() {
1235        let s = Superconductor::ybco();
1236        let kappa = s.gl_parameter(77.0);
1237        assert!(
1238            kappa > std::f64::consts::FRAC_1_SQRT_2,
1239            "YBCO should be type-II (κ={kappa})"
1240        );
1241    }
1242
1243    #[test]
1244    fn test_superconductor_hc1_positive() {
1245        let s = Superconductor::ybco();
1246        let hc1 = s.hc1_estimate(77.0);
1247        assert!(hc1 > 0.0);
1248    }
1249
1250    // ---- Ferroelectric hysteresis ------------------------------------------
1251
1252    #[test]
1253    fn test_ferroelectric_remnant_polarisation_positive() {
1254        let fe = FerroelectricPE::batio3();
1255        assert!(fe.remnant_polarisation() >= 0.0);
1256    }
1257
1258    #[test]
1259    fn test_ferroelectric_saturation_at_large_field() {
1260        let fe = FerroelectricPE::batio3();
1261        let p_big = fe.polarisation_ascending(1.0e7);
1262        assert!(
1263            (p_big - fe.p_sat).abs() < 1e-4 * fe.p_sat,
1264            "should be near saturation at large E"
1265        );
1266    }
1267
1268    #[test]
1269    fn test_ferroelectric_hysteresis_energy_positive() {
1270        let fe = FerroelectricPE::batio3();
1271        let e_loss = fe.hysteresis_energy_loss(5.0e5, 100);
1272        assert!(e_loss >= 0.0);
1273    }
1274
1275    #[test]
1276    fn test_ferroelectric_ascending_descending_differ() {
1277        let fe = FerroelectricPE::batio3();
1278        let pa = fe.polarisation_ascending(0.0);
1279        let pd = fe.polarisation_descending(0.0);
1280        assert!(
1281            (pa - pd).abs() > 1e-10,
1282            "ascending and descending should differ at E=0"
1283        );
1284    }
1285
1286    // ---- Jiles-Atherton model ----------------------------------------------
1287
1288    #[test]
1289    fn test_ja_langevin_small_x() {
1290        let ja = JilesAthertonParams::soft_iron();
1291        // Anhysteretic at H=0, M=0 should be 0.
1292        let man = ja.anhysteretic(0.0, 0.0);
1293        assert!(man.abs() < 1e-10, "M_an(0) should be ~0, got {man}");
1294    }
1295
1296    #[test]
1297    fn test_ja_compute_bh_loop_length() {
1298        let ja = JilesAthertonParams::soft_iron();
1299        let (h, m) = ja.compute_bh_loop(2000.0, 50);
1300        assert_eq!(h.len(), 100);
1301        assert_eq!(m.len(), 100);
1302    }
1303
1304    #[test]
1305    fn test_ja_magnetisation_bounded() {
1306        let ja = JilesAthertonParams::soft_iron();
1307        let (_h, m) = ja.compute_bh_loop(5000.0, 100);
1308        for mi in &m {
1309            assert!(mi.abs() <= ja.m_sat * 1.01, "M exceeds M_sat: {mi}");
1310        }
1311    }
1312
1313    #[test]
1314    fn test_ja_coercive_field_positive() {
1315        let ja = JilesAthertonParams::soft_iron();
1316        let hc = ja.coercive_field(2000.0, 100);
1317        assert!(hc >= 0.0);
1318    }
1319
1320    #[test]
1321    fn test_b_from_ja_positive_at_positive_h() {
1322        let ja = JilesAthertonParams::soft_iron();
1323        let b = b_from_jiles_atherton(&ja, 1000.0, 0.5e6);
1324        assert!(b > 0.0);
1325    }
1326
1327    // ---- Shielding effectiveness -------------------------------------------
1328
1329    #[test]
1330    fn test_shielding_skin_depth_copper() {
1331        let s = ShieldingEffectiveness::copper(1e-3);
1332        let d = s.skin_depth(1.0e6); // 1 MHz
1333        assert!(d > 0.0 && d < 1e-3, "copper skin depth at 1 MHz: {d}");
1334    }
1335
1336    #[test]
1337    fn test_shielding_absorption_loss_increases_with_freq() {
1338        let s = ShieldingEffectiveness::copper(1e-3);
1339        let a1 = s.absorption_loss_db(1.0e6);
1340        let a2 = s.absorption_loss_db(1.0e9);
1341        assert!(a2 > a1, "absorption loss should increase with frequency");
1342    }
1343
1344    #[test]
1345    fn test_shielding_total_se_positive() {
1346        let s = ShieldingEffectiveness::mild_steel(2e-3);
1347        let se = s.total_se_db(1.0e6);
1348        assert!(se > 0.0, "SE should be positive: {se}");
1349    }
1350
1351    #[test]
1352    fn test_shielding_reflection_loss_copper_1mhz() {
1353        let s = ShieldingEffectiveness::copper(1e-3);
1354        let r = s.reflection_loss_db(1.0e6);
1355        // Copper at 1 MHz has high reflection loss (> 100 dB).
1356        assert!(r > 100.0, "R = {r} dB");
1357    }
1358
1359    #[test]
1360    fn test_fr4_eps_r() {
1361        let m = fr4();
1362        assert!((m.eps_r_scalar() - 4.5).abs() < EPS);
1363    }
1364
1365    #[test]
1366    fn test_water_high_permittivity() {
1367        let m = water_2_45ghz();
1368        assert!(m.eps_r_scalar() > 70.0);
1369    }
1370}