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}