Skip to main content

oxiphysics_materials/
nuclear_materials.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Nuclear materials science — radiation damage and actinide materials.
5//!
6//! This module provides:
7//! - [`RadiationDamage`]: NRT-DPA model, Kinchin-Pease, cascade, swelling
8//! - [`FuelPellet`]: Thermal conductivity, fission gas release, gap width
9//! - [`ActinideMaterial`]: Lattice parameter, bulk modulus, magnetic moment
10//! - [`ZircaloyClad`]: Yield strength, creep rate, oxidation, hydrogen pickup
11//! - [`lindhard_electronic_stopping`]: Electronic stopping power (Lindhard model)
12//! - [`orowan_strengthening`]: Radiation-induced obstacle strengthening
13
14#![allow(dead_code)]
15#![allow(clippy::too_many_arguments)]
16
17use std::f64::consts::PI;
18
19// ---------------------------------------------------------------------------
20// Enumerations
21// ---------------------------------------------------------------------------
22
23/// Nuclear fuel composition variants.
24#[derive(Debug, Clone, PartialEq)]
25pub enum NuclearFuel {
26    /// Uranium dioxide (standard LWR fuel).
27    UO2,
28    /// Mixed oxide fuel (UO₂ + PuO₂).
29    MixedOxide,
30    /// Uranium nitride (advanced fuel).
31    UN,
32    /// Uranium carbide (fast reactor fuel).
33    UC,
34}
35
36/// Actinide element.
37#[derive(Debug, Clone, PartialEq)]
38pub enum Actinide {
39    /// Uranium (Z = 92).
40    Uranium,
41    /// Plutonium (Z = 94).
42    Plutonium,
43    /// Thorium (Z = 90).
44    Thorium,
45    /// Neptunium (Z = 93).
46    Neptunium,
47    /// Americium (Z = 95).
48    Americium,
49}
50
51/// Zircaloy alloy grade.
52#[derive(Debug, Clone, PartialEq)]
53pub enum ZircaloyGrade {
54    /// Zircaloy-2 (Sn-Fe-Cr-Ni alloy, BWR cladding).
55    Zircaloy2,
56    /// Zircaloy-4 (Sn-Fe-Cr alloy, PWR cladding, low Ni).
57    Zircaloy4,
58    /// Zirlo (advanced Zr-Nb-Sn alloy).
59    Zirlo,
60    /// M5 (Zr-1 %Nb binary alloy).
61    M5,
62}
63
64// ---------------------------------------------------------------------------
65// RadiationDamage
66// ---------------------------------------------------------------------------
67
68/// Radiation damage model for crystalline metals.
69///
70/// Implements the NRT (Norgett-Robinson-Torrens) displacement model,
71/// Kinchin-Pease approximation, cascade statistics, recombination,
72/// and void swelling correlations relevant to structural materials
73/// in fission and fusion reactors.
74#[allow(dead_code)]
75#[derive(Debug, Clone)]
76pub struct RadiationDamage {
77    /// Threshold displacement energy Ed \[eV\] for the host lattice atom.
78    pub displacement_energy: f64,
79    /// Primary knock-on atom (PKA) kinetic energy \[eV\].
80    pub pka_energy: f64,
81    /// Atomic number of the target atom.
82    pub atomic_number: u32,
83    /// Atomic mass of the target atom \[amu\].
84    pub atomic_mass: f64,
85}
86
87impl RadiationDamage {
88    /// Create a new radiation damage model.
89    ///
90    /// # Arguments
91    /// * `displacement_energy` – Ed in eV (typical: 25–40 eV for metals).
92    /// * `pka_energy` – PKA recoil energy in eV.
93    /// * `atomic_number` – Z of host lattice.
94    /// * `atomic_mass` – M of host lattice in amu.
95    pub fn new(
96        displacement_energy: f64,
97        pka_energy: f64,
98        atomic_number: u32,
99        atomic_mass: f64,
100    ) -> Self {
101        Self {
102            displacement_energy,
103            pka_energy,
104            atomic_number,
105            atomic_mass,
106        }
107    }
108
109    /// NRT displacements per atom (dpa) for a given neutron fluence.
110    ///
111    /// dpa = 0.8 · ν_NRT · fluence / (2 · Ed)
112    /// where ν_NRT is the number of displacements from one PKA event.
113    ///
114    /// # Arguments
115    /// * `fluence` – Neutron fluence \[n/m²\].
116    pub fn nrt_dpa(&self, fluence: f64) -> f64 {
117        let nu = self.kinchin_pease(self.pka_energy) as f64;
118        0.8 * nu * fluence / (2.0 * self.displacement_energy)
119    }
120
121    /// Kinchin-Pease number of displacements from a single PKA.
122    ///
123    /// Returns the integer number of Frenkel pairs produced:
124    /// - 0 if energy < Ed
125    /// - 1 if Ed ≤ energy < 2Ed
126    /// - energy/(2Ed) otherwise (integer division)
127    ///
128    /// # Arguments
129    /// * `energy` – PKA kinetic energy in eV.
130    pub fn kinchin_pease(&self, energy: f64) -> usize {
131        let ed = self.displacement_energy;
132        if energy < ed {
133            0
134        } else if energy < 2.0 * ed {
135            1
136        } else {
137            (energy / (2.0 * ed)) as usize
138        }
139    }
140
141    /// Estimate the number of atoms in a displacement cascade.
142    ///
143    /// Uses an empirical power law: N_cascade ≈ (E_PKA / Ed)^0.75.
144    ///
145    /// # Arguments
146    /// * `energy` – PKA energy in eV.
147    pub fn cascade_size(&self, energy: f64) -> usize {
148        if energy <= self.displacement_energy {
149            return 1;
150        }
151        let ratio = energy / self.displacement_energy;
152        ratio.powf(0.75).round() as usize
153    }
154
155    /// Recombination fraction of Frenkel pairs in a cascade.
156    ///
157    /// Based on Brinkman's model: f_rec = 1 − 1/(1 + α·ν) where α = 0.15.
158    pub fn recombination_fraction(&self) -> f64 {
159        let nu = self.kinchin_pease(self.pka_energy) as f64;
160        if nu < 1.0 {
161            return 0.0;
162        }
163        let alpha = 0.15_f64;
164        1.0 - 1.0 / (1.0 + alpha * nu)
165    }
166
167    /// Void swelling as a function of cumulative damage in dpa.
168    ///
169    /// Empirical model: ΔV/V ≈ A · (dpa − dpa_inc)^n for dpa > dpa_inc,
170    /// with A = 1.5e-3 %/dpa^n, dpa_inc = 5 dpa (incubation dose), n = 1.5.
171    ///
172    /// Returns swelling in percent.
173    ///
174    /// # Arguments
175    /// * `dpa` – Cumulative dose in displacements per atom.
176    pub fn swelling(&self, dpa: f64) -> f64 {
177        let dpa_inc = 5.0_f64;
178        let a = 1.5e-3_f64;
179        let n = 1.5_f64;
180        if dpa <= dpa_inc {
181            0.0
182        } else {
183            a * (dpa - dpa_inc).powf(n)
184        }
185    }
186}
187
188// ---------------------------------------------------------------------------
189// FuelPellet
190// ---------------------------------------------------------------------------
191
192/// Nuclear fuel pellet model.
193///
194/// Computes thermal-mechanical properties relevant to fuel performance:
195/// thermal conductivity, fission gas release, pellet-cladding gap, and
196/// centerline temperature under linear heat rate.
197#[allow(dead_code)]
198#[derive(Debug, Clone)]
199pub struct FuelPellet {
200    /// Fuel composition.
201    pub material: NuclearFuel,
202    /// Burnup \[MWd/tHM\].
203    pub burnup: f64,
204    /// Mean fuel pellet temperature \[K\].
205    pub temperature: f64,
206    /// Pellet radius \[m\].
207    pub radius: f64,
208}
209
210impl FuelPellet {
211    /// Create a new fuel pellet.
212    ///
213    /// # Arguments
214    /// * `material` – Fuel type.
215    /// * `burnup` – Accumulated burnup in MWd/tHM.
216    /// * `temperature` – Average pellet temperature in K.
217    /// * `radius` – Pellet outer radius in m.
218    pub fn new(material: NuclearFuel, burnup: f64, temperature: f64, radius: f64) -> Self {
219        Self {
220            material,
221            burnup,
222            temperature,
223            radius,
224        }
225    }
226
227    /// Thermal conductivity of the fuel \[W/(m·K)\].
228    ///
229    /// Uses the Lucuta correlation for UO₂:
230    /// k = 1/(A + B·T) + C·T³·exp(−D/T) corrected for burnup degradation.
231    /// MOX uses a 20 % reduction factor.  UN and UC use empirical values.
232    pub fn thermal_conductivity(&self) -> f64 {
233        let t = self.temperature;
234        let base = match self.material {
235            NuclearFuel::UO2 => {
236                // Lucuta model: 1/(0.0452 + 2.46e-4·T) + 5.47e9·T^-2.5·exp(-16350/T)
237                let a = 0.0452_f64;
238                let b = 2.46e-4_f64;
239                let lattice_term = 1.0 / (a + b * t);
240                let electron_term = 5.47e9 * t.powf(-2.5) * (-16350.0 / t).exp();
241                lattice_term + electron_term
242            }
243            NuclearFuel::MixedOxide => {
244                let a = 0.0452_f64;
245                let b = 2.46e-4_f64;
246                0.80 / (a + b * t)
247            }
248            NuclearFuel::UN => 1.864e-2 * t.powf(0.5) + 12.0,
249            NuclearFuel::UC => 20.0 * (t / 1000.0).powf(-0.2),
250        };
251        // Burnup degradation factor (porosity + fission products)
252        let bu_factor = 1.0 - 0.02 * (self.burnup / 10_000.0).min(1.0);
253        base * bu_factor
254    }
255
256    /// Fractional fission gas release (Xe + Kr) from Booth diffusion model.
257    ///
258    /// f_FGR = 1 − (6/π²) Σ_{n=1}^{10} exp(−n²·π²·D_eff·t/a²) / n²
259    /// Simplified to: f_FGR ≈ 1 − exp(−β·Bu·T²) with empirical β.
260    pub fn fission_gas_release(&self) -> f64 {
261        let beta = 3.0e-12_f64;
262        let raw = 1.0 - (-beta * self.burnup * self.temperature * self.temperature).exp();
263        raw.clamp(0.0, 1.0)
264    }
265
266    /// Pellet-cladding gap width \[m\].
267    ///
268    /// Accounts for fuel swelling due to fission products and thermal expansion.
269    /// Gap = initial_gap − Δr_fuel where Δr_fuel = r₀·(α·ΔT + β_sw·burnup).
270    pub fn pellet_cladding_gap(&self) -> f64 {
271        let initial_gap = 80e-6_f64; // 80 μm
272        let alpha_th = 10e-6_f64; // thermal expansion coefficient K⁻¹
273        let beta_sw = 0.5e-9_f64; // swelling coefficient per MWd/tHM
274        let delta_t = (self.temperature - 300.0).max(0.0);
275        let delta_r = self.radius * (alpha_th * delta_t + beta_sw * self.burnup);
276        (initial_gap - delta_r).max(0.0)
277    }
278
279    /// Centerline temperature of the fuel pellet under a given linear heat rate.
280    ///
281    /// T_cl = T_surface + q′/(4π·k_avg) where k_avg is evaluated at T_surface.
282    ///
283    /// # Arguments
284    /// * `q_linear` – Linear heat rate \[W/m\].
285    pub fn centerline_temperature(&self, q_linear: f64) -> f64 {
286        let k = self.thermal_conductivity();
287        let t_surface = self.temperature;
288        t_surface + q_linear / (4.0 * PI * k)
289    }
290}
291
292// ---------------------------------------------------------------------------
293// ActinideMaterial
294// ---------------------------------------------------------------------------
295
296/// Actinide metal or oxide material descriptor.
297///
298/// Provides crystallographic and electronic properties derived from
299/// experimental data and relativistic DFT calculations.
300#[allow(dead_code)]
301#[derive(Debug, Clone)]
302pub struct ActinideMaterial {
303    /// Actinide element.
304    pub element: Actinide,
305    /// Formal oxidation state (e.g. +4 for UO₂).
306    pub oxidation_state: i32,
307    /// Temperature \[K\].
308    pub temperature: f64,
309}
310
311impl ActinideMaterial {
312    /// Create a new actinide material descriptor.
313    ///
314    /// # Arguments
315    /// * `element` – Actinide element.
316    /// * `oxidation_state` – Formal oxidation state.
317    /// * `temperature` – Temperature in K.
318    pub fn new(element: Actinide, oxidation_state: i32, temperature: f64) -> Self {
319        Self {
320            element,
321            oxidation_state,
322            temperature,
323        }
324    }
325
326    /// Equilibrium lattice parameter \[Å\].
327    ///
328    /// Returns experimental 0 K values corrected by linear thermal expansion.
329    pub fn lattice_parameter(&self) -> f64 {
330        let a0 = match self.element {
331            Actinide::Uranium => 2.854,   // orthorhombic α-U a-axis
332            Actinide::Plutonium => 3.159, // δ-Pu fcc
333            Actinide::Thorium => 5.084,   // fcc α-Th
334            Actinide::Neptunium => 6.663, // α-Np orthorhombic
335            Actinide::Americium => 3.468, // α-Am dhcp
336        };
337        let alpha = match self.element {
338            Actinide::Uranium => 14e-6,
339            Actinide::Plutonium => 60e-6, // anomalously large
340            Actinide::Thorium => 11e-6,
341            Actinide::Neptunium => 15e-6,
342            Actinide::Americium => 12e-6,
343        };
344        a0 * (1.0 + alpha * (self.temperature - 293.0))
345    }
346
347    /// Bulk modulus \[GPa\].
348    ///
349    /// Returns experimental values at room temperature
350    /// with a small linear temperature correction.
351    pub fn bulk_modulus(&self) -> f64 {
352        let b0 = match self.element {
353            Actinide::Uranium => 115.0,
354            Actinide::Plutonium => 35.0,
355            Actinide::Thorium => 58.0,
356            Actinide::Neptunium => 67.0,
357            Actinide::Americium => 30.0,
358        };
359        let db_dt = -0.02_f64; // GPa/K
360        (b0 + db_dt * (self.temperature - 300.0)).max(0.0)
361    }
362
363    /// Ordered magnetic moment \[μ_B / atom\].
364    ///
365    /// Returns the experimental low-temperature moment; returns 0.0 for
366    /// non-magnetic phases (U, Pu in metallic form at RT).
367    pub fn magnetic_moment(&self) -> f64 {
368        match self.element {
369            Actinide::Uranium => 0.0,   // itinerant, quenched
370            Actinide::Plutonium => 0.0, // paramagnetic
371            Actinide::Thorium => 0.0,   // non-magnetic
372            Actinide::Neptunium => 0.3, // weak moment in α-Np
373            Actinide::Americium => 0.0, // non-magnetic metal
374        }
375    }
376
377    /// Ground-state electronic configuration string.
378    ///
379    /// Returns the abbreviated electron configuration beyond \[Rn\].
380    pub fn electronic_configuration(&self) -> &str {
381        match self.element {
382            Actinide::Thorium => "[Rn] 6d2 7s2",
383            Actinide::Uranium => "[Rn] 5f3 6d1 7s2",
384            Actinide::Neptunium => "[Rn] 5f4 6d1 7s2",
385            Actinide::Plutonium => "[Rn] 5f6 7s2",
386            Actinide::Americium => "[Rn] 5f7 7s2",
387        }
388    }
389}
390
391// ---------------------------------------------------------------------------
392// ZircaloyClad
393// ---------------------------------------------------------------------------
394
395/// Zircaloy cladding model for LWR fuel rods.
396///
397/// Computes mechanical and corrosion properties relevant to in-reactor
398/// and out-of-reactor cladding performance: yield strength, creep rate,
399/// oxidation kinetics, and hydrogen pickup.
400#[allow(dead_code)]
401#[derive(Debug, Clone)]
402pub struct ZircaloyClad {
403    /// Zircaloy alloy grade.
404    pub composition: ZircaloyGrade,
405    /// Cladding temperature \[K\].
406    pub temperature: f64,
407    /// Fast neutron fluence \[n/m²\] (E > 1 MeV).
408    pub fluence: f64,
409}
410
411impl ZircaloyClad {
412    /// Create a new Zircaloy cladding instance.
413    ///
414    /// # Arguments
415    /// * `composition` – Alloy grade.
416    /// * `temperature` – Temperature in K.
417    /// * `fluence` – Accumulated fast fluence in n/m².
418    pub fn new(composition: ZircaloyGrade, temperature: f64, fluence: f64) -> Self {
419        Self {
420            composition,
421            temperature,
422            fluence,
423        }
424    }
425
426    /// 0.2 % proof (yield) strength \[MPa\].
427    ///
428    /// Based on the Khan-Hall correlation including radiation hardening:
429    /// σ_y = (σ_y0 + Δσ_irr) · exp(−T/T_ref)^0.3
430    pub fn yield_strength(&self) -> f64 {
431        let sigma_y0 = match self.composition {
432            ZircaloyGrade::Zircaloy2 => 380.0,
433            ZircaloyGrade::Zircaloy4 => 400.0,
434            ZircaloyGrade::Zirlo => 480.0,
435            ZircaloyGrade::M5 => 420.0,
436        };
437        // Radiation hardening: Δσ ≈ A·φt^0.5
438        let a_irr = 2.5e-10_f64;
439        let delta_sigma = a_irr * self.fluence.sqrt();
440        let t_ref = 900.0_f64;
441        (sigma_y0 + delta_sigma) * (self.temperature / t_ref).powf(-0.3)
442    }
443
444    /// Steady-state in-reactor creep rate \[s⁻¹\].
445    ///
446    /// εdot = A · σ^n · φ · exp(−Q/(R·T))
447    /// where σ = 100 MPa (reference), Q = 230 kJ/mol (thermal component).
448    pub fn creep_rate(&self) -> f64 {
449        let a = match self.composition {
450            ZircaloyGrade::Zircaloy2 | ZircaloyGrade::Zircaloy4 => 1.0e-25,
451            ZircaloyGrade::Zirlo | ZircaloyGrade::M5 => 5.0e-26,
452        };
453        let sigma_ref = 100.0_f64; // MPa
454        let n = 3.0_f64;
455        let q = 230_000.0_f64; // J/mol
456        let r = 8.314_f64;
457        a * sigma_ref.powf(n) * self.fluence * (-q / (r * self.temperature)).exp()
458    }
459
460    /// Zirconium oxide (ZrO₂) layer growth rate \[nm/s\].
461    ///
462    /// Pre-transition cubic kinetics: dr/dt = A · exp(−Q/(R·T))
463    /// where A and Q depend on alloy grade.
464    pub fn oxidation_rate(&self) -> f64 {
465        let (a, q) = match self.composition {
466            ZircaloyGrade::Zircaloy2 => (2.3e4_f64, 96_000.0_f64),
467            ZircaloyGrade::Zircaloy4 => (2.0e4_f64, 96_000.0_f64),
468            ZircaloyGrade::Zirlo => (1.2e4_f64, 99_000.0_f64),
469            ZircaloyGrade::M5 => (1.0e4_f64, 102_000.0_f64),
470        };
471        let r = 8.314_f64;
472        a * (-q / (r * self.temperature)).exp()
473    }
474
475    /// Cumulative hydrogen pickup in the cladding wall \[wppm\].
476    ///
477    /// H pickup = f_H · (δ_oxide(t) / δ_0) where f_H is the pickup fraction
478    /// and δ_oxide = k_c · t^(1/3) (cubic oxidation law).
479    ///
480    /// # Arguments
481    /// * `time` – Irradiation / corrosion time in seconds.
482    pub fn hydrogen_pickup(&self, time: f64) -> f64 {
483        let f_h = match self.composition {
484            ZircaloyGrade::Zircaloy2 => 0.15, // BWR, higher pickup
485            ZircaloyGrade::Zircaloy4 => 0.10,
486            ZircaloyGrade::Zirlo => 0.08,
487            ZircaloyGrade::M5 => 0.05, // lowest pickup
488        };
489        // Cubic oxidation: thickness proportional to t^(1/3)
490        let kc = self.oxidation_rate() * 1e9; // nm/s to nm^(1/3)·s^(-1/3) scale
491        let oxide_thickness = kc * time.powf(1.0 / 3.0); // nm
492        // H pickup in wppm: empirical proportionality
493        f_h * oxide_thickness * 0.4
494    }
495}
496
497// ---------------------------------------------------------------------------
498// Standalone functions
499// ---------------------------------------------------------------------------
500
501/// Lindhard electronic stopping cross section \[eV·cm²/atom\].
502///
503/// Calculates the low-energy electronic stopping of an ion (Z₁, A₁) in a
504/// target (Z₂, A₂) using the Lindhard-Scharff-Schiøtt formula:
505///
506/// Se = k_L · ε^{1/2}
507///
508/// where ε is the reduced energy and k_L is the Lindhard factor.
509///
510/// # Arguments
511/// * `energy` – Ion kinetic energy \[eV\].
512/// * `z1` – Atomic number of the projectile.
513/// * `z2` – Atomic number of the target atom.
514/// * `a1` – Atomic mass of the projectile \[amu\].
515/// * `a2` – Atomic mass of the target atom \[amu\].
516pub fn lindhard_electronic_stopping(energy: f64, z1: f64, z2: f64, a1: f64, a2: f64) -> f64 {
517    // Screening length (Thomas-Fermi, Ziegler form) [Å]
518    let a_tf = 0.529 * 0.8853 / (z1.powf(2.0 / 3.0) + z2.powf(2.0 / 3.0)).sqrt();
519    // Reduced mass
520    let m12 = a1 * a2 / (a1 + a2);
521    // Reduced energy ε (dimensionless)
522    let epsilon = energy * a2 * a_tf / ((a1 + a2) * z1 * z2 * 14.4);
523    // Lindhard factor k_L
524    let k_l = 0.0793 * z1.powf(2.0 / 3.0) * z2.powf(1.0 / 2.0) * (a1 + a2).powf(3.0 / 2.0)
525        / ((z1.powf(2.0 / 3.0) + z2.powf(2.0 / 3.0)).powf(3.0 / 4.0)
526            * a1.powf(3.0 / 2.0)
527            * a2.powf(1.0 / 2.0));
528    let _ = m12; // mass used in extended model
529    k_l * epsilon.sqrt() * a_tf
530}
531
532/// Orowan strengthening increment due to radiation-induced obstacles \[MPa\].
533///
534/// Computes the critical resolved shear stress increment from a dispersed
535/// field of impenetrable obstacles (loops, voids, precipitates) using the
536/// Orowan-Taylor relation:
537///
538/// Δτ = M · μ · b / (2π · L · √(1−ν))
539///
540/// Simplified form without Poisson's ratio:
541///
542/// Δτ = 0.84 · M · μ · b / L
543///
544/// where L = √(spacing²−(2r)²) ≈ spacing for small radii.
545///
546/// # Arguments
547/// * `spacing` – Mean obstacle spacing \[m\] (= 1/√(N·r) for N obstacles/vol).
548/// * `radius` – Obstacle radius \[m\].
549/// * `shear_mod` – Shear modulus of the matrix \[Pa\].
550/// * `burgers` – Burgers vector magnitude \[m\].
551pub fn orowan_strengthening(spacing: f64, radius: f64, shear_mod: f64, burgers: f64) -> f64 {
552    let taylor_factor = 3.06_f64; // Taylor factor M for fcc/bcc polycrystal
553    // Effective obstacle spacing: bypass requires bowing between obstacles
554    let l_eff = (spacing * spacing - 4.0 * radius * radius)
555        .max(spacing * 0.01)
556        .sqrt();
557    let delta_tau = 0.84 * taylor_factor * shear_mod * burgers / l_eff;
558    delta_tau * 1e-6 // Pa → MPa
559}
560
561// ---------------------------------------------------------------------------
562// Tests
563// ---------------------------------------------------------------------------
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    // --- RadiationDamage ---
570
571    #[test]
572    fn test_kinchin_pease_below_threshold() {
573        let rd = RadiationDamage::new(40.0, 100.0, 26, 55.85);
574        assert_eq!(rd.kinchin_pease(20.0), 0);
575    }
576
577    #[test]
578    fn test_kinchin_pease_at_threshold() {
579        let rd = RadiationDamage::new(40.0, 100.0, 26, 55.85);
580        assert_eq!(rd.kinchin_pease(40.0), 1);
581    }
582
583    #[test]
584    fn test_kinchin_pease_above_threshold() {
585        let rd = RadiationDamage::new(40.0, 100.0, 26, 55.85);
586        // 400 eV / (2*40) = 5
587        assert_eq!(rd.kinchin_pease(400.0), 5);
588    }
589
590    #[test]
591    fn test_nrt_dpa_positive() {
592        let rd = RadiationDamage::new(40.0, 200.0, 26, 55.85);
593        let dpa = rd.nrt_dpa(1e22);
594        assert!(dpa > 0.0);
595    }
596
597    #[test]
598    fn test_nrt_dpa_scales_with_fluence() {
599        let rd = RadiationDamage::new(40.0, 200.0, 26, 55.85);
600        let dpa1 = rd.nrt_dpa(1e22);
601        let dpa2 = rd.nrt_dpa(2e22);
602        assert!((dpa2 / dpa1 - 2.0).abs() < 1e-10);
603    }
604
605    #[test]
606    fn test_cascade_size_below_threshold() {
607        let rd = RadiationDamage::new(40.0, 100.0, 26, 55.85);
608        assert_eq!(rd.cascade_size(30.0), 1);
609    }
610
611    #[test]
612    fn test_cascade_size_increases_with_energy() {
613        let rd = RadiationDamage::new(25.0, 1000.0, 26, 55.85);
614        let s1 = rd.cascade_size(100.0);
615        let s2 = rd.cascade_size(1000.0);
616        assert!(s2 > s1);
617    }
618
619    #[test]
620    fn test_recombination_fraction_range() {
621        let rd = RadiationDamage::new(40.0, 400.0, 26, 55.85);
622        let f = rd.recombination_fraction();
623        assert!((0.0..=1.0).contains(&f));
624    }
625
626    #[test]
627    fn test_swelling_below_incubation() {
628        let rd = RadiationDamage::new(40.0, 100.0, 26, 55.85);
629        assert_eq!(rd.swelling(3.0), 0.0);
630    }
631
632    #[test]
633    fn test_swelling_above_incubation() {
634        let rd = RadiationDamage::new(40.0, 100.0, 26, 55.85);
635        assert!(rd.swelling(20.0) > 0.0);
636    }
637
638    #[test]
639    fn test_swelling_monotone() {
640        let rd = RadiationDamage::new(40.0, 100.0, 26, 55.85);
641        assert!(rd.swelling(30.0) > rd.swelling(20.0));
642    }
643
644    // --- FuelPellet ---
645
646    #[test]
647    fn test_uo2_thermal_conductivity_positive() {
648        let p = FuelPellet::new(NuclearFuel::UO2, 10_000.0, 800.0, 4.1e-3);
649        assert!(p.thermal_conductivity() > 0.0);
650    }
651
652    #[test]
653    fn test_mox_lower_conductivity_than_uo2() {
654        let uo2 = FuelPellet::new(NuclearFuel::UO2, 0.0, 800.0, 4.1e-3);
655        let mox = FuelPellet::new(NuclearFuel::MixedOxide, 0.0, 800.0, 4.1e-3);
656        assert!(mox.thermal_conductivity() < uo2.thermal_conductivity());
657    }
658
659    #[test]
660    fn test_un_thermal_conductivity_reasonable() {
661        let p = FuelPellet::new(NuclearFuel::UN, 5_000.0, 900.0, 4.1e-3);
662        let k = p.thermal_conductivity();
663        assert!(k > 1.0 && k < 50.0);
664    }
665
666    #[test]
667    fn test_fission_gas_release_at_low_temp_burnup() {
668        let p = FuelPellet::new(NuclearFuel::UO2, 100.0, 500.0, 4.1e-3);
669        let fgr = p.fission_gas_release();
670        assert!((0.0..=1.0).contains(&fgr));
671    }
672
673    #[test]
674    fn test_fission_gas_release_increases_with_burnup() {
675        let p1 = FuelPellet::new(NuclearFuel::UO2, 5_000.0, 1000.0, 4.1e-3);
676        let p2 = FuelPellet::new(NuclearFuel::UO2, 50_000.0, 1000.0, 4.1e-3);
677        assert!(p2.fission_gas_release() >= p1.fission_gas_release());
678    }
679
680    #[test]
681    fn test_pellet_cladding_gap_nonnegative() {
682        let p = FuelPellet::new(NuclearFuel::UO2, 60_000.0, 1200.0, 4.1e-3);
683        assert!(p.pellet_cladding_gap() >= 0.0);
684    }
685
686    #[test]
687    fn test_centerline_temperature_above_surface() {
688        let p = FuelPellet::new(NuclearFuel::UO2, 10_000.0, 700.0, 4.1e-3);
689        let t_cl = p.centerline_temperature(20_000.0);
690        assert!(t_cl > p.temperature);
691    }
692
693    // --- ActinideMaterial ---
694
695    #[test]
696    fn test_uranium_lattice_parameter_positive() {
697        let am = ActinideMaterial::new(Actinide::Uranium, 0, 293.0);
698        assert!(am.lattice_parameter() > 0.0);
699    }
700
701    #[test]
702    fn test_plutonium_lattice_expands_with_temperature() {
703        let am1 = ActinideMaterial::new(Actinide::Plutonium, 0, 300.0);
704        let am2 = ActinideMaterial::new(Actinide::Plutonium, 0, 600.0);
705        assert!(am2.lattice_parameter() > am1.lattice_parameter());
706    }
707
708    #[test]
709    fn test_thorium_bulk_modulus_decreases_with_temperature() {
710        let am1 = ActinideMaterial::new(Actinide::Thorium, 0, 300.0);
711        let am2 = ActinideMaterial::new(Actinide::Thorium, 0, 800.0);
712        assert!(am2.bulk_modulus() < am1.bulk_modulus());
713    }
714
715    #[test]
716    fn test_neptunium_magnetic_moment_nonzero() {
717        let am = ActinideMaterial::new(Actinide::Neptunium, 0, 293.0);
718        assert!(am.magnetic_moment() > 0.0);
719    }
720
721    #[test]
722    fn test_uranium_magnetic_moment_zero() {
723        let am = ActinideMaterial::new(Actinide::Uranium, 4, 293.0);
724        assert_eq!(am.magnetic_moment(), 0.0);
725    }
726
727    #[test]
728    fn test_electronic_configuration_uranium() {
729        let am = ActinideMaterial::new(Actinide::Uranium, 4, 293.0);
730        assert!(am.electronic_configuration().contains("5f3"));
731    }
732
733    #[test]
734    fn test_electronic_configuration_thorium() {
735        let am = ActinideMaterial::new(Actinide::Thorium, 4, 293.0);
736        assert!(am.electronic_configuration().contains("6d2"));
737    }
738
739    // --- ZircaloyClad ---
740
741    #[test]
742    fn test_yield_strength_positive() {
743        let zr = ZircaloyClad::new(ZircaloyGrade::Zircaloy4, 600.0, 1e25);
744        assert!(zr.yield_strength() > 0.0);
745    }
746
747    #[test]
748    fn test_yield_strength_increases_with_fluence() {
749        let zr1 = ZircaloyClad::new(ZircaloyGrade::Zircaloy4, 600.0, 0.0);
750        let zr2 = ZircaloyClad::new(ZircaloyGrade::Zircaloy4, 600.0, 1e26);
751        assert!(zr2.yield_strength() > zr1.yield_strength());
752    }
753
754    #[test]
755    fn test_creep_rate_positive() {
756        let zr = ZircaloyClad::new(ZircaloyGrade::Zircaloy2, 620.0, 1e25);
757        assert!(zr.creep_rate() > 0.0);
758    }
759
760    #[test]
761    fn test_oxidation_rate_positive() {
762        let zr = ZircaloyClad::new(ZircaloyGrade::M5, 620.0, 1e25);
763        assert!(zr.oxidation_rate() > 0.0);
764    }
765
766    #[test]
767    fn test_m5_lower_oxidation_than_zircaloy4() {
768        let zr4 = ZircaloyClad::new(ZircaloyGrade::Zircaloy4, 620.0, 1e25);
769        let m5 = ZircaloyClad::new(ZircaloyGrade::M5, 620.0, 1e25);
770        assert!(m5.oxidation_rate() < zr4.oxidation_rate());
771    }
772
773    #[test]
774    fn test_hydrogen_pickup_increases_with_time() {
775        let zr = ZircaloyClad::new(ZircaloyGrade::Zircaloy4, 620.0, 1e25);
776        let h1 = zr.hydrogen_pickup(1e6);
777        let h2 = zr.hydrogen_pickup(1e8);
778        assert!(h2 > h1);
779    }
780
781    #[test]
782    fn test_m5_lower_hydrogen_pickup_than_zircaloy2() {
783        let zr2 = ZircaloyClad::new(ZircaloyGrade::Zircaloy2, 620.0, 1e25);
784        let m5 = ZircaloyClad::new(ZircaloyGrade::M5, 620.0, 1e25);
785        assert!(m5.hydrogen_pickup(1e7) < zr2.hydrogen_pickup(1e7));
786    }
787
788    // --- Standalone functions ---
789
790    #[test]
791    fn test_lindhard_stopping_positive() {
792        // Fission fragment (Kr, Z=36, A=84) in UO2 target (U, Z=92, A=238)
793        let s = lindhard_electronic_stopping(1e6, 36.0, 92.0, 84.0, 238.0);
794        assert!(s > 0.0);
795    }
796
797    #[test]
798    fn test_lindhard_stopping_increases_with_energy() {
799        // Higher PKA energy → larger ε → larger Se (Se ∝ ε^0.5)
800        let s1 = lindhard_electronic_stopping(1e5, 18.0, 26.0, 40.0, 55.85);
801        let s2 = lindhard_electronic_stopping(1e6, 18.0, 26.0, 40.0, 55.85);
802        assert!(s2 > s1);
803    }
804
805    #[test]
806    fn test_orowan_strengthening_positive() {
807        // Typical loop: spacing = 50 nm, radius = 2 nm, μ = 80 GPa, b = 0.286 nm
808        let delta = orowan_strengthening(50e-9, 2e-9, 80e9, 0.286e-9);
809        assert!(delta > 0.0);
810    }
811
812    #[test]
813    fn test_orowan_strengthening_decreases_with_spacing() {
814        let d1 = orowan_strengthening(20e-9, 1e-9, 80e9, 0.286e-9);
815        let d2 = orowan_strengthening(100e-9, 1e-9, 80e9, 0.286e-9);
816        assert!(d1 > d2);
817    }
818
819    #[test]
820    fn test_uc_thermal_conductivity() {
821        let p = FuelPellet::new(NuclearFuel::UC, 0.0, 1000.0, 4.1e-3);
822        assert!(p.thermal_conductivity() > 0.0);
823    }
824
825    #[test]
826    fn test_americium_electronic_config() {
827        let am = ActinideMaterial::new(Actinide::Americium, 3, 300.0);
828        assert!(am.electronic_configuration().contains("5f7"));
829    }
830
831    #[test]
832    fn test_zirlo_yield_strength() {
833        let zr = ZircaloyClad::new(ZircaloyGrade::Zirlo, 600.0, 1e25);
834        assert!(zr.yield_strength() > 0.0);
835    }
836
837    #[test]
838    fn test_plutonium_magnetic_moment_zero() {
839        let am = ActinideMaterial::new(Actinide::Plutonium, 0, 300.0);
840        assert_eq!(am.magnetic_moment(), 0.0);
841    }
842}