Skip to main content

oxiphysics_materials/
radiation_shielding.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Radiation shielding and nuclear material models.
5//!
6//! Implements attenuation calculations, half-value layer, buildup factors,
7//! dose rate from kerma, and radioactivation analysis for gamma and neutron
8//! radiation in shielding materials.
9
10#![allow(dead_code)]
11#![allow(clippy::too_many_arguments)]
12
13use std::f64::consts::LN_2;
14
15// ─────────────────────────────────────────────────────────────────────────────
16// Core struct
17// ─────────────────────────────────────────────────────────────────────────────
18
19/// A material used for radiation shielding.
20#[derive(Debug, Clone)]
21pub struct RadiationMaterial {
22    /// Name of the material (e.g. "Lead", "Concrete").
23    pub name: String,
24    /// Atomic number Z of the primary constituent element.
25    pub atomic_number: u32,
26    /// Mass density \[kg/m³\].
27    pub density: f64,
28    /// Mass attenuation coefficient μ/ρ \[m²/kg\] at the relevant photon energy.
29    pub mass_attenuation: f64,
30    /// Thermal neutron absorption cross-section \[barn = 1e-28 m²\].
31    pub neutron_cross_section: f64,
32}
33
34impl RadiationMaterial {
35    /// Create a new radiation shielding material.
36    ///
37    /// # Arguments
38    /// * `name` – Human-readable label.
39    /// * `atomic_number` – Atomic number Z.
40    /// * `density` – Mass density \[kg/m³\].
41    /// * `mass_attenuation` – μ/ρ at the relevant energy \[m²/kg\].
42    /// * `neutron_cross_section` – σ_a in barn for thermal neutrons.
43    pub fn new(
44        name: impl Into<String>,
45        atomic_number: u32,
46        density: f64,
47        mass_attenuation: f64,
48        neutron_cross_section: f64,
49    ) -> Self {
50        Self {
51            name: name.into(),
52            atomic_number,
53            density,
54            mass_attenuation,
55            neutron_cross_section,
56        }
57    }
58
59    /// Preset: lead (Pb).  μ/ρ at 1 MeV ≈ 7.1e-3 m²/kg.
60    pub fn lead() -> Self {
61        Self::new("Lead", 82, 11_340.0, 7.1e-3, 0.171)
62    }
63
64    /// Preset: ordinary concrete.  μ/ρ at 1 MeV ≈ 6.5e-3 m²/kg.
65    pub fn concrete() -> Self {
66        Self::new("Concrete", 14, 2_300.0, 6.5e-3, 0.17)
67    }
68
69    /// Preset: water.  μ/ρ at 1 MeV ≈ 6.7e-3 m²/kg.
70    pub fn water() -> Self {
71        Self::new("Water", 1, 1_000.0, 6.7e-3, 0.333)
72    }
73
74    /// Preset: polyethylene (high-density neutron moderator).
75    pub fn polyethylene() -> Self {
76        Self::new("Polyethylene", 6, 950.0, 2.0e-3, 0.333)
77    }
78}
79
80// ─────────────────────────────────────────────────────────────────────────────
81// Attenuation
82// ─────────────────────────────────────────────────────────────────────────────
83
84/// Compute the linear attenuation coefficient μ = ρ · (μ/ρ) \[1/m\].
85///
86/// # Arguments
87/// * `density` – Material density \[kg/m³\].
88/// * `mass_attenuation` – Mass attenuation coefficient μ/ρ \[m²/kg\].
89pub fn linear_attenuation(density: f64, mass_attenuation: f64) -> f64 {
90    density * mass_attenuation
91}
92
93/// Compute the transmitted intensity fraction after thickness `x` \[m\]:
94/// I/I₀ = exp(-μ · x), neglecting buildup.
95///
96/// # Arguments
97/// * `mu` – Linear attenuation coefficient \[1/m\].
98/// * `thickness` – Shield thickness \[m\].
99pub fn narrow_beam_transmission(mu: f64, thickness: f64) -> f64 {
100    (-mu * thickness).exp()
101}
102
103// ─────────────────────────────────────────────────────────────────────────────
104// Half-Value Layer
105// ─────────────────────────────────────────────────────────────────────────────
106
107/// Compute the half-value layer HVL = ln(2) / μ \[m\].
108///
109/// The HVL is the thickness of material that attenuates a narrow beam of
110/// radiation to half its original intensity.
111///
112/// # Arguments
113/// * `mu` – Linear attenuation coefficient \[1/m\].
114pub fn half_value_layer(mu: f64) -> f64 {
115    if mu.abs() < 1e-300 {
116        f64::INFINITY
117    } else {
118        LN_2 / mu
119    }
120}
121
122/// Compute the tenth-value layer TVL = ln(10) / μ \[m\].
123///
124/// # Arguments
125/// * `mu` – Linear attenuation coefficient \[1/m\].
126pub fn tenth_value_layer(mu: f64) -> f64 {
127    if mu.abs() < 1e-300 {
128        f64::INFINITY
129    } else {
130        10.0_f64.ln() / mu
131    }
132}
133
134/// Determine the number of HVLs needed to achieve a given transmission fraction.
135///
136/// # Arguments
137/// * `transmission` – Desired I/I₀ (0 < transmission ≤ 1).
138pub fn hvls_for_transmission(transmission: f64) -> f64 {
139    assert!(transmission > 0.0 && transmission <= 1.0);
140    -transmission.log2()
141}
142
143// ─────────────────────────────────────────────────────────────────────────────
144// Buildup Factor (Taylor two-term approximation)
145// ─────────────────────────────────────────────────────────────────────────────
146
147/// Taylor two-term buildup factor B(μx) = A·exp(α₁·μx) + (1-A)·exp(α₂·μx).
148///
149/// Accounts for scattered photons that still deposit dose inside the shield.
150///
151/// # Arguments
152/// * `mu_x` – Dimensionless penetration depth (μ · x).
153/// * `cap_a` – Taylor coefficient A (material and energy dependent).
154/// * `alpha1` – Taylor exponent α₁.
155/// * `alpha2` – Taylor exponent α₂.
156pub fn buildup_factor(mu_x: f64, cap_a: f64, alpha1: f64, alpha2: f64) -> f64 {
157    cap_a * (alpha1 * mu_x).exp() + (1.0 - cap_a) * (alpha2 * mu_x).exp()
158}
159
160/// Berger buildup factor B(μx) = 1 + C·(μx)·exp(D·μx).
161///
162/// Alternative simple form used for low-Z materials.
163///
164/// # Arguments
165/// * `mu_x` – Dimensionless penetration depth (μ · x).
166/// * `c_coeff` – Berger coefficient C.
167/// * `d_coeff` – Berger coefficient D.
168pub fn berger_buildup_factor(mu_x: f64, c_coeff: f64, d_coeff: f64) -> f64 {
169    1.0 + c_coeff * mu_x * (d_coeff * mu_x).exp()
170}
171
172// ─────────────────────────────────────────────────────────────────────────────
173// Dose Rate
174// ─────────────────────────────────────────────────────────────────────────────
175
176/// Compute air-kerma dose rate \[Gy/s\] from photon fluence rate and energy.
177///
178/// K̇ = Φ̇ · E · (μ_en/ρ)_air
179///
180/// # Arguments
181/// * `fluence_rate` – Photon fluence rate \[photons/m²/s\].
182/// * `energy_j` – Photon energy \[J\].
183/// * `mu_en_over_rho_air` – Mass energy-absorption coefficient of air \[m²/kg\]
184///   (≈ 2.7e-3 m²/kg at 1 MeV).
185pub fn dose_rate(fluence_rate: f64, energy_j: f64, mu_en_over_rho_air: f64) -> f64 {
186    fluence_rate * energy_j * mu_en_over_rho_air
187}
188
189/// Compute dose rate behind a slab shield \[Gy/s\], including buildup.
190///
191/// D̊ = Φ̇₀ · E · (μ_en/ρ)_air · B(μx) · exp(-μx)
192///
193/// # Arguments
194/// * `fluence_rate_0` – Source-side photon fluence rate \[photons/m²/s\].
195/// * `energy_j` – Photon energy \[J\].
196/// * `mu_en_over_rho_air` – (μ_en/ρ) for air \[m²/kg\].
197/// * `mu` – Linear attenuation coefficient of shielding material \[1/m\].
198/// * `thickness` – Shield thickness \[m\].
199/// * `buildup` – Pre-computed buildup factor B(μx).
200pub fn shielded_dose_rate(
201    fluence_rate_0: f64,
202    energy_j: f64,
203    mu_en_over_rho_air: f64,
204    mu: f64,
205    thickness: f64,
206    buildup: f64,
207) -> f64 {
208    let unshielded = dose_rate(fluence_rate_0, energy_j, mu_en_over_rho_air);
209    unshielded * buildup * (-mu * thickness).exp()
210}
211
212// ─────────────────────────────────────────────────────────────────────────────
213// Radioactivation
214// ─────────────────────────────────────────────────────────────────────────────
215
216/// Result of a radioactivation analysis.
217#[derive(Debug, Clone, Copy)]
218pub struct ActivationResult {
219    /// Saturation activity \[Bq\].
220    pub saturation_activity: f64,
221    /// Activity at end of irradiation \[Bq\].
222    pub activity_at_eoi: f64,
223    /// Activity after cooling time t_cool \[Bq\].
224    pub activity_after_cooling: f64,
225    /// Decay constant λ \[1/s\].
226    pub decay_constant: f64,
227}
228
229/// Radioactivation under constant neutron flux (single-nuclide model).
230///
231/// Solves A(t) = N·σ·Φ·(1 − exp(−λ·t_irr)) · exp(−λ·t_cool)
232/// where N = (m/M)·N_A is the number of target atoms.
233///
234/// # Arguments
235/// * `mass_kg` – Mass of target nuclide \[kg\].
236/// * `molar_mass` – Molar mass \[kg/mol\].
237/// * `cross_section_m2` – Microscopic activation cross-section \[m²\].
238/// * `neutron_flux` – Thermal neutron flux \[n/m²/s\].
239/// * `half_life_s` – Half-life of the product nuclide \[s\].
240/// * `irradiation_time_s` – Duration of irradiation \[s\].
241/// * `cooling_time_s` – Decay time after end of irradiation \[s\].
242pub fn activation_analysis(
243    mass_kg: f64,
244    molar_mass: f64,
245    cross_section_m2: f64,
246    neutron_flux: f64,
247    half_life_s: f64,
248    irradiation_time_s: f64,
249    cooling_time_s: f64,
250) -> ActivationResult {
251    const AVOGADRO: f64 = 6.022_140_76e23;
252    let n_atoms = (mass_kg / molar_mass) * AVOGADRO;
253    let lambda = LN_2 / half_life_s;
254    let saturation_activity = n_atoms * cross_section_m2 * neutron_flux * lambda;
255    // Activity at end of irradiation:
256    let activity_at_eoi = saturation_activity * (1.0 - (-lambda * irradiation_time_s).exp());
257    // After cooling:
258    let activity_after_cooling = activity_at_eoi * (-lambda * cooling_time_s).exp();
259    ActivationResult {
260        saturation_activity,
261        activity_at_eoi,
262        activity_after_cooling,
263        decay_constant: lambda,
264    }
265}
266
267/// Compute the effective dose rate \[Sv/s\] from activity using a dose
268/// conversion factor (DCF) \[Sv/Bq\].
269///
270/// # Arguments
271/// * `activity_bq` – Activity of the source \[Bq\].
272/// * `dcf` – Dose conversion factor \[Sv/Bq\].
273pub fn effective_dose_rate(activity_bq: f64, dcf: f64) -> f64 {
274    activity_bq * dcf
275}
276
277// ─────────────────────────────────────────────────────────────────────────────
278// Neutron shielding helpers
279// ─────────────────────────────────────────────────────────────────────────────
280
281/// Compute neutron removal cross-section contribution Σ_r = N · σ_r
282/// where N = (ρ/M)·N_A is the atomic number density.
283///
284/// # Arguments
285/// * `density` – Material density \[kg/m³\].
286/// * `molar_mass` – Molar mass \[kg/mol\].
287/// * `removal_cross_section_m2` – Fast-neutron removal cross-section \[m²\].
288pub fn macroscopic_removal_cross_section(
289    density: f64,
290    molar_mass: f64,
291    removal_cross_section_m2: f64,
292) -> f64 {
293    const AVOGADRO: f64 = 6.022_140_76e23;
294    let number_density = (density / molar_mass) * AVOGADRO;
295    number_density * removal_cross_section_m2
296}
297
298/// Compute the mean free path for neutrons: λ = 1 / Σ_t \[m\].
299///
300/// # Arguments
301/// * `macroscopic_cross_section` – Total macroscopic cross-section Σ_t \[1/m\].
302pub fn mean_free_path(macroscopic_cross_section: f64) -> f64 {
303    1.0 / macroscopic_cross_section
304}
305
306// ─────────────────────────────────────────────────────────────────────────────
307// Shield design helper
308// ─────────────────────────────────────────────────────────────────────────────
309
310/// Compute the required shield thickness to achieve a target dose-rate reduction
311/// factor R (= D̊_0 / D̊_target), neglecting buildup.
312///
313/// x = ln(R) / μ
314///
315/// # Arguments
316/// * `reduction_factor` – D̊_0 / D̊_target (must be > 1).
317/// * `mu` – Linear attenuation coefficient \[1/m\].
318pub fn required_thickness(reduction_factor: f64, mu: f64) -> f64 {
319    assert!(reduction_factor > 1.0);
320    reduction_factor.ln() / mu
321}
322
323// ─────────────────────────────────────────────────────────────────────────────
324// Tests
325// ─────────────────────────────────────────────────────────────────────────────
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    const EPS: f64 = 1e-9;
332
333    // 1. Linear attenuation: μ = ρ * (μ/ρ)
334    #[test]
335    fn test_linear_attenuation_lead() {
336        let mat = RadiationMaterial::lead();
337        let mu = linear_attenuation(mat.density, mat.mass_attenuation);
338        // Lead: 11340 * 7.1e-3 ≈ 80.5 m⁻¹
339        assert!(
340            (mu - 80.514).abs() < 0.5,
341            "Lead μ should be ~80.5, got {mu}"
342        );
343    }
344
345    // 2. μ = 0 → infinite HVL
346    #[test]
347    fn test_hvl_zero_mu() {
348        assert_eq!(half_value_layer(0.0), f64::INFINITY);
349    }
350
351    // 3. HVL × μ = ln(2)
352    #[test]
353    fn test_hvl_identity() {
354        let mu = 50.0;
355        let hvl = half_value_layer(mu);
356        assert!((hvl * mu - LN_2).abs() < EPS, "HVL * μ must equal ln2");
357    }
358
359    // 4. Narrow-beam transmission at x = HVL should be 0.5
360    #[test]
361    fn test_narrow_beam_half_at_hvl() {
362        let mu = 30.0;
363        let hvl = half_value_layer(mu);
364        let t = narrow_beam_transmission(mu, hvl);
365        assert!(
366            (t - 0.5).abs() < EPS,
367            "Transmission at HVL must be 0.5, got {t}"
368        );
369    }
370
371    // 5. Narrow-beam transmission at x = 0 must be 1.0
372    #[test]
373    fn test_narrow_beam_zero_thickness() {
374        let t = narrow_beam_transmission(100.0, 0.0);
375        assert!((t - 1.0).abs() < EPS);
376    }
377
378    // 6. Transmission decreases monotonically with thickness.
379    #[test]
380    fn test_transmission_monotone() {
381        let mu = 20.0;
382        let t1 = narrow_beam_transmission(mu, 0.01);
383        let t2 = narrow_beam_transmission(mu, 0.05);
384        let t3 = narrow_beam_transmission(mu, 0.1);
385        assert!(
386            t1 > t2 && t2 > t3,
387            "Transmission must decrease with thickness"
388        );
389    }
390
391    // 7. Tenth-value layer: TVL * μ = ln(10)
392    #[test]
393    fn test_tvl_identity() {
394        let mu = 80.0;
395        let tvl = tenth_value_layer(mu);
396        assert!(
397            (tvl * mu - 10.0_f64.ln()).abs() < EPS,
398            "TVL * μ must equal ln(10)"
399        );
400    }
401
402    // 8. TVL = HVL * log2(10)
403    #[test]
404    fn test_tvl_vs_hvl_ratio() {
405        let mu = 45.0;
406        let hvl = half_value_layer(mu);
407        let tvl = tenth_value_layer(mu);
408        let ratio = tvl / hvl;
409        let expected = 10.0_f64.log2(); // ≈ 3.3219
410        assert!(
411            (ratio - expected).abs() < EPS,
412            "TVL/HVL must equal log2(10), got {ratio}"
413        );
414    }
415
416    // 9. Taylor buildup at μx = 0 equals 1.0 (no penetration → no scatter).
417    #[test]
418    fn test_taylor_buildup_at_zero() {
419        let b = buildup_factor(0.0, 0.5, -0.1, 0.05);
420        assert!((b - 1.0).abs() < EPS, "Buildup at μx=0 must be 1, got {b}");
421    }
422
423    // 10. Berger buildup at μx = 0 equals 1.0.
424    #[test]
425    fn test_berger_buildup_at_zero() {
426        let b = berger_buildup_factor(0.0, 2.0, 0.3);
427        assert!(
428            (b - 1.0).abs() < EPS,
429            "Berger buildup at μx=0 must be 1, got {b}"
430        );
431    }
432
433    // 11. Berger buildup is always ≥ 1 for positive μx.
434    #[test]
435    fn test_berger_buildup_gte_one() {
436        for &mu_x in &[0.5, 1.0, 2.0, 5.0, 10.0] {
437            let b = berger_buildup_factor(mu_x, 1.5, 0.2);
438            assert!(b >= 1.0, "Berger buildup must be ≥ 1, got {b} at μx={mu_x}");
439        }
440    }
441
442    // 12. Dose rate scales linearly with fluence rate.
443    #[test]
444    fn test_dose_rate_linear_fluence() {
445        let e_j = 1.6e-13; // 1 MeV in J
446        let mu_en = 2.7e-3;
447        let d1 = dose_rate(1e6, e_j, mu_en);
448        let d2 = dose_rate(2e6, e_j, mu_en);
449        assert!(
450            (d2 / d1 - 2.0).abs() < EPS,
451            "Dose rate must scale linearly with fluence"
452        );
453    }
454
455    // 13. Dose rate is zero for zero fluence.
456    #[test]
457    fn test_dose_rate_zero_fluence() {
458        let d = dose_rate(0.0, 1.6e-13, 2.7e-3);
459        assert_eq!(d, 0.0);
460    }
461
462    // 14. Shielded dose rate at zero thickness equals unshielded (buildup=1).
463    #[test]
464    fn test_shielded_dose_no_shield() {
465        let d_unshielded = dose_rate(1e8, 1.6e-13, 2.7e-3);
466        let d_shielded = shielded_dose_rate(1e8, 1.6e-13, 2.7e-3, 80.0, 0.0, 1.0);
467        assert!((d_shielded - d_unshielded).abs() < EPS * d_unshielded);
468    }
469
470    // 15. Shielded dose rate decreases with increasing thickness.
471    #[test]
472    fn test_shielded_dose_decreases_with_thickness() {
473        let (phi0, e, mu_en, mu) = (1e10, 1.6e-13, 2.7e-3, 80.0);
474        let d1 = shielded_dose_rate(phi0, e, mu_en, mu, 0.01, 1.0);
475        let d2 = shielded_dose_rate(phi0, e, mu_en, mu, 0.05, 1.0);
476        assert!(d1 > d2, "Dose rate must decrease with shield thickness");
477    }
478
479    // 16. Activation analysis: saturation activity scales linearly with flux.
480    #[test]
481    fn test_activation_saturation_scales_with_flux() {
482        let r1 = activation_analysis(1e-3, 0.059, 1e-28, 1e14, 3.7e3, 1e6, 0.0);
483        let r2 = activation_analysis(1e-3, 0.059, 1e-28, 2e14, 3.7e3, 1e6, 0.0);
484        let ratio = r2.saturation_activity / r1.saturation_activity;
485        assert!(
486            (ratio - 2.0).abs() < 1e-6,
487            "Saturation activity must scale with flux, ratio={ratio}"
488        );
489    }
490
491    // 17. Activity at end of irradiation → saturation for very long irradiation.
492    #[test]
493    fn test_activation_approaches_saturation() {
494        let half_life = 600.0; // 10 min
495        // 100 half-lives of irradiation → should be ≈ saturation
496        let result =
497            activation_analysis(1e-3, 0.059, 1e-28, 1e14, half_life, 100.0 * half_life, 0.0);
498        let ratio = result.activity_at_eoi / result.saturation_activity;
499        assert!(
500            (ratio - 1.0).abs() < 1e-4,
501            "Long irradiation should approach saturation, ratio={ratio}"
502        );
503    }
504
505    // 18. Activity decays to zero after many half-lives of cooling.
506    #[test]
507    fn test_activation_decay_after_cooling() {
508        let half_life = 600.0;
509        let result = activation_analysis(
510            1e-3,
511            0.059,
512            1e-28,
513            1e14,
514            half_life,
515            10.0 * half_life,
516            100.0 * half_life,
517        );
518        assert!(
519            result.activity_after_cooling < result.activity_at_eoi * 1e-25,
520            "Activity should be negligible after 100 half-lives"
521        );
522    }
523
524    // 19. Decay constant λ = ln(2) / t½
525    #[test]
526    fn test_decay_constant() {
527        let half_life = 1234.5;
528        let result = activation_analysis(1e-3, 0.059, 1e-28, 1e14, half_life, 1.0, 0.0);
529        let expected_lambda = LN_2 / half_life;
530        assert!(
531            (result.decay_constant - expected_lambda).abs() < EPS,
532            "Decay constant mismatch: {} vs {}",
533            result.decay_constant,
534            expected_lambda
535        );
536    }
537
538    // 20. Required thickness: round-trip with transmission.
539    #[test]
540    fn test_required_thickness_round_trip() {
541        let mu = 80.0;
542        let target_reduction = 1000.0;
543        let x = required_thickness(target_reduction, mu);
544        let actual_reduction = 1.0 / narrow_beam_transmission(mu, x);
545        assert!(
546            (actual_reduction - target_reduction).abs() < 1e-6,
547            "Round-trip failed: {actual_reduction} vs {target_reduction}"
548        );
549    }
550
551    // 21. HVLs for transmission: 1 HVL → 0.5 transmission.
552    #[test]
553    fn test_hvls_for_half_transmission() {
554        let n = hvls_for_transmission(0.5);
555        assert!(
556            (n - 1.0).abs() < EPS,
557            "Need exactly 1 HVL for 50% transmission, got {n}"
558        );
559    }
560
561    // 22. HVLs for transmission: 1/4 transmission → 2 HVLs.
562    #[test]
563    fn test_hvls_for_quarter_transmission() {
564        let n = hvls_for_transmission(0.25);
565        assert!(
566            (n - 2.0).abs() < EPS,
567            "Need 2 HVLs for 25% transmission, got {n}"
568        );
569    }
570
571    // 23. Macroscopic removal cross-section is positive for valid inputs.
572    #[test]
573    fn test_macroscopic_removal_cross_section_positive() {
574        // Water: ρ=1000, M≈0.018 kg/mol, σ_r≈3e-30 m²
575        let sigma = macroscopic_removal_cross_section(1000.0, 0.018, 3e-30);
576        assert!(
577            sigma > 0.0,
578            "Macroscopic cross-section must be positive, got {sigma}"
579        );
580    }
581
582    // 24. Mean free path is reciprocal of macroscopic cross-section.
583    #[test]
584    fn test_mean_free_path_reciprocal() {
585        let sigma_t = 100.0; // 1/m
586        let mfp = mean_free_path(sigma_t);
587        assert!(
588            (mfp - 0.01).abs() < EPS,
589            "MFP must be 1/Σ_t = 0.01 m, got {mfp}"
590        );
591    }
592
593    // 25. Water preset has density 1000 kg/m³.
594    #[test]
595    fn test_water_preset_density() {
596        let water = RadiationMaterial::water();
597        assert_eq!(water.density, 1000.0);
598    }
599
600    // 26. Concrete preset has lower density than lead.
601    #[test]
602    fn test_density_ordering() {
603        let lead = RadiationMaterial::lead();
604        let concrete = RadiationMaterial::concrete();
605        assert!(
606            lead.density > concrete.density,
607            "Lead must be denser than concrete"
608        );
609    }
610
611    // 27. Effective dose rate scales linearly with activity.
612    #[test]
613    fn test_effective_dose_rate_linear() {
614        let dcf = 1e-15; // Sv/Bq
615        let d1 = effective_dose_rate(1e6, dcf);
616        let d2 = effective_dose_rate(3e6, dcf);
617        assert!(
618            (d2 / d1 - 3.0).abs() < EPS,
619            "Effective dose rate must scale with activity"
620        );
621    }
622
623    // 28. Required thickness increases with higher reduction factors.
624    #[test]
625    fn test_required_thickness_monotone() {
626        let mu = 50.0;
627        let x10 = required_thickness(10.0, mu);
628        let x100 = required_thickness(100.0, mu);
629        let x1000 = required_thickness(1000.0, mu);
630        assert!(
631            x10 < x100 && x100 < x1000,
632            "Required thickness must increase with reduction factor"
633        );
634    }
635
636    // 29. Taylor buildup is always ≥ 1 for positive α₁ and α₂.
637    #[test]
638    fn test_taylor_buildup_gte_one_positive_alphas() {
639        // With both exponents positive the buildup only grows with depth.
640        let (cap_a, a1, a2) = (0.4, 0.1, 0.05);
641        for &mu_x in &[0.0, 0.5, 1.0, 2.0, 5.0] {
642            let b = buildup_factor(mu_x, cap_a, a1, a2);
643            assert!(b >= 1.0, "Buildup must be ≥ 1 at μx={mu_x}, got {b}");
644        }
645    }
646
647    // 30. Polyethylene has lower density than concrete.
648    #[test]
649    fn test_polyethylene_density() {
650        let pe = RadiationMaterial::polyethylene();
651        let concrete = RadiationMaterial::concrete();
652        assert!(
653            pe.density < concrete.density,
654            "Polyethylene must be lighter than concrete"
655        );
656    }
657}