Skip to main content

oxiphysics_materials/
nuclear_materials_advanced.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Advanced nuclear reactor materials science.
5//!
6//! This module covers:
7//!
8//! - **Zircaloy cladding** — oxidation kinetics (cubic/linear breakaway),
9//!   hydrogen pickup fraction, hydride embrittlement
10//! - **UO₂ fuel pellet** — thermal conductivity vs burnup (Lucuta model),
11//!   fission gas release (Booth diffusion), pellet cracking and swelling
12//! - **Radiation void swelling** — Garner model for austenitic steels
13//! - **Irradiation creep** — in-reactor creep for fuel cladding
14//! - **Radiation-induced segregation (RIS)** — inverse Kirkendall mechanism
15//! - **Radiation embrittlement** — ductile-to-brittle transition temperature
16//!   shift (Charpy / USE models for RPV steel)
17//! - **Helium embrittlement** — grain boundary helium bubble model
18//! - **Reactor pressure vessel (RPV) steel** — Charpy USE correlation,
19//!   master curve approach (T₀ reference temperature)
20//! - **Graphite moderator** — dimensional change, thermal conductivity
21//!   degradation under irradiation
22//! - **Sodium fast reactor structural materials** — 316 SS and HT-9 ferritic-
23//!   martensitic steel properties, creep-fatigue interaction
24
25#![allow(dead_code)]
26#![allow(clippy::too_many_arguments)]
27
28use std::f64::consts::PI;
29
30// ---------------------------------------------------------------------------
31// Section 1 — Zircaloy cladding
32// ---------------------------------------------------------------------------
33
34/// Zircaloy alloy grade.
35#[derive(Debug, Clone, Copy, PartialEq)]
36pub enum ZircaloyGrade {
37    /// Zircaloy-2 (0.1% Sn, used in BWR).
38    Zircaloy2,
39    /// Zircaloy-4 (1.45% Sn, used in PWR).
40    Zircaloy4,
41    /// ZIRLO — enhanced corrosion resistance.
42    Zirlo,
43    /// M5 — Nb-based alloy.
44    M5,
45}
46
47/// Zircaloy cladding tube.
48#[derive(Debug, Clone)]
49pub struct ZircaloyClad {
50    /// Alloy grade.
51    pub grade: ZircaloyGrade,
52    /// Outer diameter (m).
53    pub outer_diameter: f64,
54    /// Wall thickness (m).
55    pub wall_thickness: f64,
56    /// Current oxide layer thickness (m).
57    pub oxide_thickness: f64,
58    /// Cumulative fast neutron fluence (n/m², E > 1 MeV).
59    pub fluence: f64,
60    /// Operating temperature (K).
61    pub temperature: f64,
62}
63
64impl ZircaloyClad {
65    /// Construct a new Zircaloy cladding tube.
66    pub fn new(
67        grade: ZircaloyGrade,
68        outer_diameter: f64,
69        wall_thickness: f64,
70        temperature: f64,
71    ) -> Self {
72        Self {
73            grade,
74            outer_diameter,
75            wall_thickness,
76            oxide_thickness: 0.0,
77            fluence: 0.0,
78            temperature,
79        }
80    }
81
82    /// Corrosion rate constant A (kg²/m⁴/s) for the pre-breakaway cubic law.
83    ///
84    /// Empirical fit to Urbanic & Heidrick (1978) data.
85    fn cubic_rate_constant(&self) -> f64 {
86        let ea_j = match self.grade {
87            ZircaloyGrade::Zircaloy2 => 1.42e4_f64,
88            ZircaloyGrade::Zircaloy4 => 1.35e4_f64,
89            ZircaloyGrade::Zirlo => 1.20e4_f64,
90            ZircaloyGrade::M5 => 1.10e4_f64,
91        };
92        // A = A0 * exp(-Ea/(R*T))
93        let a0 = 2.88e-3_f64; // kg²/(m⁴·s)
94        let r = 8.314_f64;
95        a0 * (-(ea_j) / (r * self.temperature)).exp()
96    }
97
98    /// Oxide weight gain (kg/m²) after time `dt` seconds using cubic parabolic law.
99    ///
100    /// Δw³ = A · t  (pre-transition regime, w < transition weight gain w_t)
101    pub fn oxide_weight_gain_cubic(&self, time_s: f64) -> f64 {
102        let a = self.cubic_rate_constant();
103        (a * time_s).cbrt()
104    }
105
106    /// Post-transition linear oxidation rate constant (kg/m²/s).
107    ///
108    /// After breakaway, a linear rate governs: Δw = B · (t − t_t).
109    pub fn linear_rate_constant(&self) -> f64 {
110        // Empirical B values
111        let ea_j = 1.10e4_f64;
112        let b0 = 6.0e-6_f64;
113        let r = 8.314_f64;
114        b0 * (-(ea_j) / (r * self.temperature)).exp()
115    }
116
117    /// Breakaway transition time (s) — approximate.
118    ///
119    /// Returns the time at which oxide weight gain transitions from cubic to linear.
120    pub fn breakaway_time(&self) -> f64 {
121        // Transition weight gain ~ 30 mg/dm² = 0.30 kg/m² for Zircaloy-4
122        let wt = match self.grade {
123            ZircaloyGrade::Zircaloy2 => 0.30_f64,
124            ZircaloyGrade::Zircaloy4 => 0.30_f64,
125            ZircaloyGrade::Zirlo => 0.40_f64,
126            ZircaloyGrade::M5 => 0.50_f64,
127        };
128        wt * wt * wt / self.cubic_rate_constant()
129    }
130
131    /// Hydrogen pickup fraction (dimensionless, 0–1).
132    ///
133    /// Fraction of hydrogen generated by oxidation that is absorbed by the Zr matrix.
134    /// Based on empirical correlation with temperature and fluence.
135    pub fn hydrogen_pickup_fraction(&self) -> f64 {
136        // f = f0 * exp(Ea/RT) * (1 + k_phi * fluence)
137        let f0 = match self.grade {
138            ZircaloyGrade::Zircaloy2 => 0.15_f64,
139            ZircaloyGrade::Zircaloy4 => 0.10_f64,
140            ZircaloyGrade::Zirlo => 0.08_f64,
141            ZircaloyGrade::M5 => 0.07_f64,
142        };
143        let ea = 2500.0_f64; // J/mol
144        let r = 8.314_f64;
145        let fluence_factor = 1.0 + 2.0e-26 * self.fluence;
146        (f0 * (ea / (r * self.temperature)).exp() * fluence_factor).min(1.0)
147    }
148
149    /// Hydrogen concentration in cladding (wppm).
150    ///
151    /// Computed from cumulative oxide weight gain and pickup fraction.
152    /// `oxide_wg` is oxide weight gain (kg/m²).
153    pub fn hydrogen_concentration_wppm(&self, oxide_wg: f64) -> f64 {
154        // Every kg/m² of ZrO2 generates ~1.11e4 wppm·m² if all absorbed
155        // Scaling for wall thickness
156        let rho_zr = 6520.0_f64; // kg/m³
157        let wt = self.wall_thickness;
158        let h_gen = oxide_wg * 1.11e4 / (rho_zr * wt);
159        h_gen * self.hydrogen_pickup_fraction()
160    }
161
162    /// Yield strength (Pa) of irradiated Zircaloy.
163    ///
164    /// Uses the Northwood & Gilbert correlation including irradiation hardening.
165    pub fn yield_strength(&self) -> f64 {
166        // Base yield strength (Pa) at temperature
167        let sigma_0 = match self.grade {
168            ZircaloyGrade::Zircaloy2 => 450e6_f64,
169            ZircaloyGrade::Zircaloy4 => 480e6_f64,
170            ZircaloyGrade::Zirlo => 500e6_f64,
171            ZircaloyGrade::M5 => 510e6_f64,
172        };
173        // Temperature softening
174        let t0 = 293.0_f64;
175        let softening = 1.0 - 5.0e-4 * (self.temperature - t0);
176        // Irradiation hardening: Δσ = k * sqrt(Φ)
177        let k_irr = 1.2e-12_f64;
178        let irr_hardening = k_irr * self.fluence.sqrt();
179        (sigma_0 * softening + irr_hardening).max(0.0)
180    }
181
182    /// Irradiation creep rate (s⁻¹) under hoop stress `sigma` (Pa).
183    ///
184    /// In-reactor creep: ε̇ = B_0 · σ · Φ̇  (irradiation creep component).
185    pub fn irradiation_creep_rate(&self, sigma_pa: f64, flux_n_m2_s: f64) -> f64 {
186        // B_0 ~ 3e-27 m²/(n·Pa)  for Zircaloy
187        let b0 = 3.0e-27_f64;
188        b0 * sigma_pa * flux_n_m2_s
189    }
190
191    /// Ductility reduction factor due to hydrides (at test temperature).
192    ///
193    /// Returns a multiplier (1.0 = no degradation, → 0 at very high H).
194    pub fn hydride_ductility_factor(&self, h_wppm: f64) -> f64 {
195        // Simple exponential decay — EPRI correlation
196        (1.0 - h_wppm / 1000.0).max(0.05)
197    }
198}
199
200// ---------------------------------------------------------------------------
201// Section 2 — UO₂ fuel pellet
202// ---------------------------------------------------------------------------
203
204/// UO₂ fuel pellet.
205#[derive(Debug, Clone)]
206pub struct Uo2Pellet {
207    /// Pellet diameter (m).
208    pub diameter: f64,
209    /// Pellet height (m).
210    pub height: f64,
211    /// Burnup (MWd/kgU).
212    pub burnup: f64,
213    /// Centre-line temperature (K).
214    pub temperature_centre: f64,
215    /// Pellet-cladding gap width (m).
216    pub gap_width: f64,
217    /// Enrichment (weight fraction U-235, 0–1).
218    pub enrichment: f64,
219}
220
221impl Uo2Pellet {
222    /// Construct a standard UO₂ pellet.
223    pub fn new(diameter: f64, height: f64, enrichment: f64) -> Self {
224        Self {
225            diameter,
226            height,
227            burnup: 0.0,
228            temperature_centre: 900.0,
229            gap_width: 1.0e-4,
230            enrichment,
231        }
232    }
233
234    /// Thermal conductivity of UO₂ (W/m/K) as a function of burnup.
235    ///
236    /// Uses the Lucuta et al. (1996) model:
237    /// k = k_0(T) · F_D · F_P · F_R · F_Por
238    pub fn thermal_conductivity(&self, temperature_k: f64) -> f64 {
239        // Base conductivity (unirradiated, theoretical density)
240        let t = temperature_k;
241        let k0 = 1.0 / (0.0452 + 2.46e-4 * t) + 88.0e9 / (t * t) * (-18531.7 / t).exp();
242
243        // Dissolved fission product factor F_D (Lucuta)
244        let bu = self.burnup.min(100.0);
245        let f_d = 1.0 / (1.0 + 0.019 * bu / (3.0 - 0.019 * bu));
246
247        // Precipitate factor F_P
248        let f_p = 1.0 - 0.2 / (1.0 + (t - 900.0).powi(2) / 40000.0) * (bu / 100.0);
249
250        // Radiation damage factor F_R
251        let f_r = 1.0 - 0.18 / (1.0 + (t - 900.0).powi(2) / 40000.0) * (bu / 100.0);
252
253        // Porosity factor (assume 95% TD → ~5% porosity)
254        let porosity = 0.05_f64;
255        let f_por = (1.0 - porosity).powf(2.5);
256
257        k0 * f_d * f_p * f_r * f_por
258    }
259
260    /// Fission gas release fraction (dimensionless, 0–1).
261    ///
262    /// Booth single-sphere diffusion model.  `temperature_k` is the representative
263    /// temperature.  `time_s` is irradiation time.
264    pub fn fission_gas_release_booth(&self, temperature_k: f64, time_s: f64) -> f64 {
265        // Diffusion coefficient of Xe in UO₂
266        let d0 = 5.0e-8_f64; // m²/s (pre-exponential)
267        let ea = 40200.0_f64; // J/mol
268        let r = 8.314_f64;
269        let d = d0 * (-(ea) / (r * temperature_k)).exp();
270        // Booth's sphere radius (equivalent)
271        let radius = self.diameter / 2.0;
272        let tau = d * time_s / (radius * radius);
273        // Fractional release (Booth 1957)
274        if tau < 0.02 {
275            6.0 * (tau / PI).sqrt() - 3.0 * tau
276        } else {
277            1.0 - (6.0 / (PI * PI))
278                * (1..=20)
279                    .map(|n| (-(n as f64).powi(2) * PI * PI * tau).exp() / (n as f64).powi(2))
280                    .sum::<f64>()
281        }
282        .clamp(0.0, 1.0)
283    }
284
285    /// Radial cracking factor — ratio of actual effective conductivity to uncracked.
286    ///
287    /// Approximate power-law fit to Oguma (1983) data.
288    pub fn cracking_conductivity_factor(&self) -> f64 {
289        // Decreases with burnup due to pellet cracking/relocation
290        let bu = self.burnup;
291        1.0 / (1.0 + 0.005 * bu)
292    }
293
294    /// Volumetric swelling (ΔV/V₀, dimensionless) due to fission products.
295    ///
296    /// Empirical correlation: ~0.98 vol% per 10 GWd/tU burnup.
297    pub fn fission_product_swelling(&self) -> f64 {
298        0.98e-2 * self.burnup / 10.0
299    }
300
301    /// Gap conductance (W/m²/K).
302    ///
303    /// Simple model: k_gap / gap_width + radiation term.
304    pub fn gap_conductance(&self, fill_gas_conductivity: f64) -> f64 {
305        let k_gas = fill_gas_conductivity.max(0.01);
306        let gap = self.gap_width.max(1e-7);
307        // Conduction through gas + radiation (Stefan-Boltzmann, simplified)
308        let t_clad = self.temperature_centre - 400.0; // approximate
309        let sigma = 5.67e-8_f64;
310        let emissivity = 0.85_f64;
311        let radiation = 4.0 * sigma * emissivity * t_clad.powi(3);
312        k_gas / gap + radiation
313    }
314
315    /// Linear heat generation rate (W/m) for a given specific power (W/kgU).
316    pub fn linear_heat_rate(&self, specific_power_w_per_kgu: f64) -> f64 {
317        let rho_uo2 = 10_400.0_f64; // kg/m³ at 95%TD
318        let area = PI * (self.diameter / 2.0).powi(2);
319        // Mass of uranium per unit length
320        let u_mass_fraction = 238.03 / (238.03 + 2.0 * 16.0); // UO₂ molar mass ratio
321        let m_u_per_m = rho_uo2 * area * u_mass_fraction;
322        specific_power_w_per_kgu * m_u_per_m / 1000.0
323    }
324}
325
326// ---------------------------------------------------------------------------
327// Section 3 — Radiation void swelling (Garner model)
328// ---------------------------------------------------------------------------
329
330/// Void swelling parameters for austenitic stainless steel (Garner model).
331#[derive(Debug, Clone, Copy)]
332pub struct VoidSwellingParams {
333    /// Transient swelling incubation dose (dpa).
334    pub incubation_dose_dpa: f64,
335    /// Steady-state swelling rate (%/dpa) after incubation.
336    pub swelling_rate_pct_per_dpa: f64,
337    /// Temperature at peak swelling (K).
338    pub peak_temperature_k: f64,
339    /// Width of temperature bell curve (K).
340    pub temperature_width_k: f64,
341}
342
343impl Default for VoidSwellingParams {
344    fn default() -> Self {
345        // Typical 316 SS (Garner 1994)
346        Self {
347            incubation_dose_dpa: 10.0,
348            swelling_rate_pct_per_dpa: 1.0,
349            peak_temperature_k: 773.0,
350            temperature_width_k: 80.0,
351        }
352    }
353}
354
355/// Compute void swelling fraction (ΔV/V) using the Garner ramp-rate model.
356///
357/// S(Φ) = 0  for Φ < Φ_inc
358/// S(Φ) = ṡ · (Φ − Φ_inc) · f_T(T)  for Φ ≥ Φ_inc
359pub fn garner_void_swelling(dose_dpa: f64, temperature_k: f64, params: &VoidSwellingParams) -> f64 {
360    if dose_dpa <= params.incubation_dose_dpa {
361        return 0.0;
362    }
363    let excess_dpa = dose_dpa - params.incubation_dose_dpa;
364    // Temperature factor: Gaussian bell
365    let dt = temperature_k - params.peak_temperature_k;
366    let f_t = (-(dt * dt) / (2.0 * params.temperature_width_k * params.temperature_width_k)).exp();
367    let swelling_pct = params.swelling_rate_pct_per_dpa * excess_dpa * f_t;
368    swelling_pct / 100.0
369}
370
371/// Void number density (m⁻³) — approximate from swelling and mean void radius.
372pub fn void_number_density(swelling: f64, mean_void_radius_m: f64) -> f64 {
373    if mean_void_radius_m < 1e-15 {
374        return 0.0;
375    }
376    swelling / ((4.0 / 3.0) * PI * mean_void_radius_m.powi(3))
377}
378
379// ---------------------------------------------------------------------------
380// Section 4 — Irradiation creep
381// ---------------------------------------------------------------------------
382
383/// Irradiation creep rate (s⁻¹) for austenitic stainless steel.
384///
385/// Total creep rate = thermal creep + irradiation-enhanced creep.
386///
387/// Uses the simplified model: ε̇_irr = B_irr · σ · Φ̇
388/// and ε̇_th = A · σⁿ · exp(−Q / RT).
389pub fn irradiation_creep_rate(
390    stress_pa: f64,
391    temperature_k: f64,
392    flux_dpa_per_s: f64,
393    b_irr: f64,
394    a_thermal: f64,
395    n_creep: f64,
396    q_creep: f64,
397) -> f64 {
398    let r = 8.314_f64;
399    let eps_irr = b_irr * stress_pa * flux_dpa_per_s;
400    let eps_th = a_thermal * stress_pa.powf(n_creep) * (-(q_creep) / (r * temperature_k)).exp();
401    eps_irr + eps_th
402}
403
404/// Stress relaxation factor due to irradiation creep after dose `dose_dpa`.
405///
406/// σ(Φ) = σ₀ · exp(−B_irr · Φ)
407pub fn irradiation_stress_relaxation(sigma_0: f64, b_irr: f64, dose_dpa: f64) -> f64 {
408    sigma_0 * (-(b_irr * dose_dpa)).exp()
409}
410
411// ---------------------------------------------------------------------------
412// Section 5 — Radiation-induced segregation (RIS)
413// ---------------------------------------------------------------------------
414
415/// Radiation-induced segregation model (inverse Kirkendall / thermodynamic).
416///
417/// Computes the segregated solute concentration at a grain boundary sink.
418#[derive(Debug, Clone, Copy)]
419pub struct RisParams {
420    /// Bulk solute concentration (atomic fraction).
421    pub bulk_concentration: f64,
422    /// Solute-interstitial binding energy (eV).
423    pub binding_energy_ev: f64,
424    /// Temperature (K).
425    pub temperature_k: f64,
426    /// Dose rate (dpa/s).
427    pub dose_rate_dpa_s: f64,
428    /// Recombination coefficient (dimensionless).
429    pub recombination_coeff: f64,
430}
431
432impl Default for RisParams {
433    fn default() -> Self {
434        Self {
435            bulk_concentration: 0.18, // 18 at% Cr in 316 SS
436            binding_energy_ev: 0.15,
437            temperature_k: 573.0,
438            dose_rate_dpa_s: 1e-8,
439            recombination_coeff: 1e17,
440        }
441    }
442}
443
444/// Steady-state solute depletion at a grain boundary (Cr depletion in stainless steel).
445///
446/// Returns the grain boundary concentration (atomic fraction) using the
447/// simplified Perks (1986) two-sink model.
448pub fn ris_grain_boundary_concentration(params: &RisParams) -> f64 {
449    let kb = 8.617e-5_f64; // eV/K
450    let kbt = kb * params.temperature_k;
451    // Partition factor for solute flux: positive → depletion
452    let binding_factor = (params.binding_energy_ev / kbt).exp();
453    // Steady-state supersaturation of defects
454    let defect_ratio = (params.dose_rate_dpa_s / params.recombination_coeff).sqrt();
455    // Concentration change at GB
456    let delta_c = params.bulk_concentration * (1.0 - binding_factor * defect_ratio);
457    (params.bulk_concentration + delta_c).clamp(0.0, 1.0)
458}
459
460// ---------------------------------------------------------------------------
461// Section 6 — Radiation embrittlement — RPV steel
462// ---------------------------------------------------------------------------
463
464/// Reactor pressure vessel steel grade.
465#[derive(Debug, Clone, Copy, PartialEq)]
466pub enum RpvSteelGrade {
467    /// A508 Class 3 (forging — typical US PWR vessel).
468    A508Class3,
469    /// A533 Grade B (plate — typical US BWR vessel).
470    A533GradeB,
471    /// 15Kh2MFA (Russian VVER vessel steel).
472    Vver15Kh2Mfa,
473}
474
475/// Charpy impact properties of RPV steel.
476#[derive(Debug, Clone)]
477pub struct RpvCharpy {
478    /// Steel grade.
479    pub grade: RpvSteelGrade,
480    /// Unirradiated upper-shelf energy (J).
481    pub use_unirradiated: f64,
482    /// Unirradiated reference temperature (°C).
483    pub rt_ndt_0: f64,
484    /// Fast neutron fluence at vessel wall (n/m², E > 1 MeV).
485    pub fluence: f64,
486    /// Copper content (weight%).
487    pub copper_wt: f64,
488    /// Nickel content (weight%).
489    pub nickel_wt: f64,
490    /// Phosphorus content (weight%).
491    pub phosphorus_wt: f64,
492}
493
494impl RpvCharpy {
495    /// Construct a new RPV Charpy record.
496    pub fn new(
497        grade: RpvSteelGrade,
498        use_unirradiated: f64,
499        rt_ndt_0: f64,
500        fluence: f64,
501        copper_wt: f64,
502        nickel_wt: f64,
503        phosphorus_wt: f64,
504    ) -> Self {
505        Self {
506            grade,
507            use_unirradiated,
508            rt_ndt_0,
509            fluence,
510            copper_wt,
511            nickel_wt,
512            phosphorus_wt,
513        }
514    }
515
516    /// Charpy upper-shelf energy drop (ΔUE, J) due to irradiation.
517    ///
518    /// Regulatory Guide 1.99 Rev.2 correlation (NRC, 1988):
519    /// ΔUE/USE₀ = A · Φ^(0.28 − 0.10 log Φ)
520    pub fn use_drop(&self) -> f64 {
521        if self.fluence < 1e18 {
522            return 0.0;
523        }
524        let phi = self.fluence / 1e19_f64; // normalise to 10¹⁹ n/cm²
525        // A factor depends on copper and nickel
526        let a =
527            0.11 * (1.0 + 1.58 * self.copper_wt.max(0.0)) * (1.0 + 3.77 * self.nickel_wt.max(0.0));
528        let exponent = 0.28 - 0.10 * phi.log10();
529        let fraction = a * phi.powf(exponent);
530        fraction * self.use_unirradiated
531    }
532
533    /// Irradiated upper-shelf energy (J).
534    pub fn use_irradiated(&self) -> f64 {
535        (self.use_unirradiated - self.use_drop()).max(0.0)
536    }
537
538    /// RTNDT shift (°C) — Reg Guide 1.99 Rev.2 CF method.
539    ///
540    /// ΔRTNDT = CF · Φ^(0.28 − 0.10 log Φ)
541    pub fn rtndt_shift(&self) -> f64 {
542        if self.fluence < 1e18 {
543            return 0.0;
544        }
545        let phi = self.fluence / 1e19_f64;
546        let cf = chemistry_factor(self.copper_wt, self.nickel_wt);
547        let exponent = 0.28 - 0.10 * phi.log10();
548        cf * phi.powf(exponent)
549    }
550
551    /// Irradiated reference temperature RTNDT (°C).
552    pub fn rtndt_irradiated(&self) -> f64 {
553        self.rt_ndt_0 + self.rtndt_shift()
554    }
555
556    /// Fracture toughness K_Ic (MPa√m) from ASME Code Case N-629 master curve.
557    ///
558    /// K_Ic(T) = 30 + 70 · exp(0.019 · (T − T₀))  where T₀ = RTNDT − 33 °C.
559    pub fn fracture_toughness_kic(&self, test_temperature_c: f64) -> f64 {
560        let t0 = self.rtndt_irradiated() - 33.0;
561        30.0 + 70.0 * (0.019 * (test_temperature_c - t0)).exp()
562    }
563}
564
565/// Chemistry factor CF (°C) from Reg Guide 1.99 Rev.2 Table 2.
566///
567/// Approximate bilinear interpolation over copper content.
568pub fn chemistry_factor(copper_wt: f64, nickel_wt: f64) -> f64 {
569    let cu = copper_wt.clamp(0.0, 0.40);
570    let ni = nickel_wt.clamp(0.0, 1.2);
571    // Simplified analytical fit
572    let cf_cu = if cu < 0.10 { 0.0 } else { (cu - 0.10) * 250.0 };
573    let cf_ni = ni * 20.0;
574    (cf_cu + cf_ni).max(0.0)
575}
576
577// ---------------------------------------------------------------------------
578// Section 7 — Helium embrittlement
579// ---------------------------------------------------------------------------
580
581/// Helium embrittlement model for high-fluence structural alloys.
582///
583/// He accumulates at grain boundaries via (n,α) transmutation reactions.
584#[derive(Debug, Clone, Copy)]
585pub struct HeliumEmbrittlement {
586    /// Grain boundary helium bubble density (m⁻²).
587    pub bubble_density: f64,
588    /// Mean helium bubble radius (m).
589    pub bubble_radius: f64,
590    /// Grain size (m).
591    pub grain_size: f64,
592}
593
594impl HeliumEmbrittlement {
595    /// Construct a new helium embrittlement record.
596    pub fn new(bubble_density: f64, bubble_radius: f64, grain_size: f64) -> Self {
597        Self {
598            bubble_density,
599            bubble_radius,
600            grain_size,
601        }
602    }
603
604    /// Grain boundary helium coverage fraction (dimensionless, 0–1).
605    ///
606    /// f_He = N_b · π · r_b²  (projected area fraction on boundary)
607    pub fn coverage_fraction(&self) -> f64 {
608        (self.bubble_density * PI * self.bubble_radius * self.bubble_radius).min(1.0)
609    }
610
611    /// Grain boundary fracture strength reduction factor.
612    ///
613    /// σ_gb / σ_gb,0 = 1 − A_He · f_He
614    /// where A_He ≈ 0.8 (empirical).
615    pub fn strength_reduction_factor(&self) -> f64 {
616        let a_he = 0.8_f64;
617        (1.0 - a_he * self.coverage_fraction()).max(0.0)
618    }
619
620    /// Helium concentration (appm) from transmutation.
621    ///
622    /// C_He = σ_nα · Φ_th · t · N  (simplified, ignoring burnout)
623    pub fn helium_appm(
624        &self,
625        cross_section_m2: f64,
626        thermal_flux: f64,
627        time_s: f64,
628        atom_density: f64,
629    ) -> f64 {
630        cross_section_m2 * thermal_flux * time_s * atom_density * 1.0e6
631    }
632}
633
634// ---------------------------------------------------------------------------
635// Section 8 — Graphite moderator
636// ---------------------------------------------------------------------------
637
638/// Nuclear graphite grade.
639#[derive(Debug, Clone, Copy, PartialEq)]
640pub enum GraphiteGrade {
641    /// Pile Grade A (UK Magnox reactors).
642    PileGradeA,
643    /// IG-110 (Japanese HTGR).
644    Ig110,
645    /// H-327 (US HTGR — Fort St. Vrain).
646    H327,
647    /// NBG-18 (European PBMR).
648    Nbg18,
649}
650
651/// Graphite moderator block.
652#[derive(Debug, Clone)]
653pub struct GraphiteBlock {
654    /// Graphite grade.
655    pub grade: GraphiteGrade,
656    /// Fast neutron fluence (n/m²).
657    pub fluence: f64,
658    /// Irradiation temperature (K).
659    pub temperature: f64,
660    /// Initial bulk density (kg/m³).
661    pub initial_density: f64,
662}
663
664impl GraphiteBlock {
665    /// Construct a new graphite block.
666    pub fn new(grade: GraphiteGrade, temperature: f64) -> Self {
667        let density = match grade {
668            GraphiteGrade::PileGradeA => 1750.0,
669            GraphiteGrade::Ig110 => 1770.0,
670            GraphiteGrade::H327 => 1740.0,
671            GraphiteGrade::Nbg18 => 1850.0,
672        };
673        Self {
674            grade,
675            fluence: 0.0,
676            temperature,
677            initial_density: density,
678        }
679    }
680
681    /// Unirradiated thermal conductivity (W/m/K).
682    pub fn thermal_conductivity_unirradiated(&self) -> f64 {
683        match self.grade {
684            GraphiteGrade::PileGradeA => 170.0,
685            GraphiteGrade::Ig110 => 130.0,
686            GraphiteGrade::H327 => 80.0,
687            GraphiteGrade::Nbg18 => 140.0,
688        }
689    }
690
691    /// Irradiated thermal conductivity (W/m/K).
692    ///
693    /// Rapid initial degradation followed by recovery at very high fluence.
694    /// Empirical fit to IAEA-TECDOC-1154 data.
695    pub fn thermal_conductivity_irradiated(&self) -> f64 {
696        let k0 = self.thermal_conductivity_unirradiated();
697        let phi = self.fluence / 1.0e25_f64; // normalise to 10²⁵ n/m²
698        // Rapid initial drop: k_irr/k0 = 1/(1 + A·phi) * recovery
699        let a = match self.grade {
700            GraphiteGrade::PileGradeA => 0.10,
701            GraphiteGrade::Ig110 => 0.12,
702            GraphiteGrade::H327 => 0.15,
703            GraphiteGrade::Nbg18 => 0.11,
704        };
705        let degradation = 1.0 / (1.0 + a * phi);
706        k0 * degradation
707    }
708
709    /// Dimensional change (ΔL/L₀) in the with-grain direction.
710    ///
711    /// Initially contracts (negative), then expands after turnaround.
712    pub fn dimensional_change_axial(&self) -> f64 {
713        let phi = self.fluence / 1.0e25_f64;
714        // Turnaround fluence depends on grade
715        let phi_t = match self.grade {
716            GraphiteGrade::PileGradeA => 3.0,
717            GraphiteGrade::Ig110 => 5.0,
718            GraphiteGrade::H327 => 4.0,
719            GraphiteGrade::Nbg18 => 4.5,
720        };
721        let contraction_rate = -0.03_f64;
722        let expansion_rate = 0.02_f64;
723        if phi < phi_t {
724            contraction_rate * phi
725        } else {
726            contraction_rate * phi_t + expansion_rate * (phi - phi_t)
727        }
728    }
729
730    /// Young's modulus (Pa) vs irradiation dose.
731    ///
732    /// Initially increases (~20% at 1 dpa), then decreases at high dose.
733    pub fn youngs_modulus(&self) -> f64 {
734        let e0 = match self.grade {
735            GraphiteGrade::PileGradeA => 8.0e9_f64,
736            GraphiteGrade::Ig110 => 9.8e9_f64,
737            GraphiteGrade::H327 => 11.0e9_f64,
738            GraphiteGrade::Nbg18 => 12.0e9_f64,
739        };
740        let phi = self.fluence / 1.0e25_f64;
741        let factor = 1.0 + 0.20 * phi * (-phi / 2.0).exp();
742        e0 * factor
743    }
744
745    /// Wigner energy stored in graphite (J/kg) — relevant for Windscale-type incidents.
746    ///
747    /// Based on Kennedy (1950) empirical correlation.
748    pub fn wigner_energy_j_per_kg(&self) -> f64 {
749        // Saturation value ~2700 J/kg at low temperature, released above ~200°C
750        let e_sat = 2700.0_f64;
751        let phi = self.fluence / 1.0e25_f64;
752        let t_factor = if self.temperature < 473.0 {
753            1.0 - (self.temperature - 300.0) / 173.0 * 0.5
754        } else {
755            0.5
756        };
757        e_sat * (1.0 - (-phi * 0.5).exp()) * t_factor.clamp(0.0, 1.0)
758    }
759}
760
761// ---------------------------------------------------------------------------
762// Section 9 — Sodium fast reactor structural materials
763// ---------------------------------------------------------------------------
764
765/// Sodium fast reactor structural steel grade.
766#[derive(Debug, Clone, Copy, PartialEq)]
767pub enum SfrSteelGrade {
768    /// 316 austenitic stainless steel (AISI 316).
769    Ss316,
770    /// HT-9 ferritic-martensitic steel (12% Cr).
771    Ht9,
772    /// D9 austenitic (Ti-modified 316 SS).
773    D9,
774    /// EP-450 Russian F/M steel.
775    Ep450,
776}
777
778/// Sodium fast reactor structural material.
779#[derive(Debug, Clone)]
780pub struct SfrMaterial {
781    /// Steel grade.
782    pub grade: SfrSteelGrade,
783    /// Operating temperature (K).
784    pub temperature: f64,
785    /// Cumulative dose (dpa).
786    pub dose_dpa: f64,
787}
788
789impl SfrMaterial {
790    /// Construct a new SFR structural material record.
791    pub fn new(grade: SfrSteelGrade, temperature: f64) -> Self {
792        Self {
793            grade,
794            temperature,
795            dose_dpa: 0.0,
796        }
797    }
798
799    /// Unirradiated tensile strength (MPa).
800    pub fn tensile_strength_unirradiated(&self) -> f64 {
801        match self.grade {
802            SfrSteelGrade::Ss316 => 515.0,
803            SfrSteelGrade::Ht9 => 690.0,
804            SfrSteelGrade::D9 => 550.0,
805            SfrSteelGrade::Ep450 => 680.0,
806        }
807    }
808
809    /// Irradiation hardening — yield strength increase (MPa).
810    ///
811    /// Δσ_y = A · (dpa)^0.5  (dispersed barrier hardening model).
812    pub fn irradiation_hardening_mpa(&self) -> f64 {
813        let a = match self.grade {
814            SfrSteelGrade::Ss316 => 50.0,
815            SfrSteelGrade::Ht9 => 30.0, // F/M steels saturate faster
816            SfrSteelGrade::D9 => 45.0,
817            SfrSteelGrade::Ep450 => 28.0,
818        };
819        a * self.dose_dpa.sqrt()
820    }
821
822    /// Void swelling (%ΔV/V₀) for austenitic steels — Garner correlation.
823    pub fn void_swelling_pct(&self) -> f64 {
824        match self.grade {
825            SfrSteelGrade::Ss316 | SfrSteelGrade::D9 => {
826                let params = VoidSwellingParams::default();
827                garner_void_swelling(self.dose_dpa, self.temperature, &params) * 100.0
828            }
829            // F/M steels have negligible void swelling
830            SfrSteelGrade::Ht9 | SfrSteelGrade::Ep450 => {
831                0.002 * self.dose_dpa // < 0.2% even at 100 dpa
832            }
833        }
834    }
835
836    /// Thermal creep rate (s⁻¹) at given stress (MPa) using Norton power law.
837    pub fn thermal_creep_rate(&self, stress_mpa: f64) -> f64 {
838        // ε̇ = A · σ^n · exp(−Q/RT)
839        let (a, n, q) = match self.grade {
840            SfrSteelGrade::Ss316 => (1.5e-32_f64, 5.0_f64, 280_000.0_f64),
841            SfrSteelGrade::Ht9 => (3.0e-28_f64, 4.5_f64, 260_000.0_f64),
842            SfrSteelGrade::D9 => (1.2e-32_f64, 5.0_f64, 275_000.0_f64),
843            SfrSteelGrade::Ep450 => (2.5e-28_f64, 4.5_f64, 255_000.0_f64),
844        };
845        let r = 8.314_f64;
846        a * stress_mpa.powf(n) * (-(q) / (r * self.temperature)).exp()
847    }
848
849    /// Fatigue life (cycles) at given strain amplitude using Coffin-Manson.
850    ///
851    /// 2N_f = (Δε / ε_f')^(1/c)  where c ≈ −0.6 (typical austenitic SS).
852    pub fn fatigue_life_cycles(&self, strain_amplitude: f64) -> f64 {
853        let (eps_f, c) = match self.grade {
854            SfrSteelGrade::Ss316 | SfrSteelGrade::D9 => (0.3_f64, -0.60_f64),
855            SfrSteelGrade::Ht9 | SfrSteelGrade::Ep450 => (0.25_f64, -0.57_f64),
856        };
857        if strain_amplitude < 1e-9 {
858            return f64::MAX;
859        }
860        let two_nf = (strain_amplitude / eps_f).powf(1.0 / c);
861        (two_nf / 2.0).max(1.0)
862    }
863
864    /// Creep-fatigue damage parameter D_cf = D_c + D_f.
865    ///
866    /// D_c = Σ(Δt/t_r)  and D_f = Σ(n/N_f).
867    /// Returns true if D_cf ≥ 1.0 (failure predicted).
868    pub fn creep_fatigue_interaction(
869        &self,
870        delta_t_s: f64,
871        rupture_time_s: f64,
872        cycles: f64,
873        n_f: f64,
874    ) -> bool {
875        let d_c = delta_t_s / rupture_time_s.max(1e-9);
876        let d_f = cycles / n_f.max(1.0);
877        (d_c + d_f) >= 1.0
878    }
879}
880
881// ---------------------------------------------------------------------------
882// Section 10 — Nuclear fuel cycle and transmutation helpers
883// ---------------------------------------------------------------------------
884
885/// Simple Bateman-style two-nuclide decay chain.
886///
887/// Parent nuclide A decays to stable daughter B with decay constant λ.
888#[derive(Debug, Clone, Copy)]
889pub struct DecayChain {
890    /// Decay constant λ (s⁻¹).
891    pub lambda: f64,
892    /// Initial number of parent atoms.
893    pub n0_parent: f64,
894}
895
896impl DecayChain {
897    /// Construct from half-life (s).
898    pub fn from_half_life(t_half_s: f64, n0_parent: f64) -> Self {
899        Self {
900            lambda: 2.0_f64.ln() / t_half_s,
901            n0_parent,
902        }
903    }
904
905    /// Parent atom count at time `t` (s).
906    pub fn parent_count(&self, t: f64) -> f64 {
907        self.n0_parent * (-self.lambda * t).exp()
908    }
909
910    /// Daughter atom count at time `t` (s) assuming zero initial daughters.
911    pub fn daughter_count(&self, t: f64) -> f64 {
912        self.n0_parent * (1.0 - (-self.lambda * t).exp())
913    }
914
915    /// Radioactivity (Bq) at time `t`.
916    pub fn activity_bq(&self, t: f64) -> f64 {
917        self.lambda * self.parent_count(t)
918    }
919
920    /// Specific activity (Bq/kg) given molar mass (g/mol).
921    pub fn specific_activity(&self, molar_mass_g_per_mol: f64, n0_mass_kg: f64) -> f64 {
922        let avogadro = 6.022e23_f64;
923        let n0 = n0_mass_kg * 1000.0 / molar_mass_g_per_mol * avogadro;
924        let chain = Self {
925            lambda: self.lambda,
926            n0_parent: n0,
927        };
928        chain.activity_bq(0.0)
929    }
930}
931
932/// Compute reactivity worth of a control rod absorber (simplified 1-group).
933///
934/// Uses Hurst-type formula: ρ = −Σ_a_rod / (ν · Σ_f).
935pub fn control_rod_worth(sigma_a_rod_m2: f64, nu_sigma_f_m2: f64) -> f64 {
936    if nu_sigma_f_m2 < 1e-40 {
937        return 0.0;
938    }
939    -sigma_a_rod_m2 / nu_sigma_f_m2
940}
941
942// ---------------------------------------------------------------------------
943// Section 11 — Pellet-Cladding Interaction (PCI)
944// ---------------------------------------------------------------------------
945
946/// Pellet-cladding interaction parameters.
947#[derive(Debug, Clone, Copy)]
948pub struct PciParams {
949    /// Pellet outer radius at operating conditions (m).
950    pub pellet_radius: f64,
951    /// Cladding inner radius (m).
952    pub clad_inner_radius: f64,
953    /// Cladding elastic modulus (Pa).
954    pub clad_youngs_modulus: f64,
955    /// Cladding Poisson's ratio.
956    pub clad_poisson: f64,
957    /// Cladding wall thickness (m).
958    pub clad_thickness: f64,
959}
960
961impl Default for PciParams {
962    fn default() -> Self {
963        Self {
964            pellet_radius: 4.1e-3,
965            clad_inner_radius: 4.18e-3,
966            clad_youngs_modulus: 75e9,
967            clad_poisson: 0.37,
968            clad_thickness: 0.57e-3,
969        }
970    }
971}
972
973/// Contact pressure (Pa) between pellet and cladding due to swelling.
974///
975/// Uses Lamé cylinder theory for the cladding.
976pub fn pci_contact_pressure(params: &PciParams, pellet_swelling_fraction: f64) -> f64 {
977    let delta = params.pellet_radius * pellet_swelling_fraction
978        - (params.clad_inner_radius - params.pellet_radius);
979    if delta <= 0.0 {
980        return 0.0;
981    }
982    // Thin-shell approximation
983    let e = params.clad_youngs_modulus;
984    let nu = params.clad_poisson;
985    let ri = params.clad_inner_radius;
986    let t = params.clad_thickness;
987    // p = E · δ · t / ((1 − ν) · ri²)
988    e * delta * t / ((1.0 - nu) * ri * ri)
989}
990
991// ---------------------------------------------------------------------------
992// Section 12 — Burnup conversion helpers
993// ---------------------------------------------------------------------------
994
995/// Convert specific power (W/kgU) and irradiation time (days) to burnup (MWd/kgU).
996pub fn power_to_burnup(specific_power_w_per_kgu: f64, time_days: f64) -> f64 {
997    specific_power_w_per_kgu * time_days / 1e6
998}
999
1000/// Convert burnup (MWd/kgU) to dpa for fuel cladding (approximate).
1001///
1002/// For Zircaloy in PWR: ~1 dpa per 10 MWd/kgU of local burnup.
1003pub fn burnup_to_dpa_clad(burnup_mwd_per_kgu: f64) -> f64 {
1004    burnup_mwd_per_kgu / 10.0
1005}
1006
1007/// Fission density (fissions/m³) from burnup.
1008pub fn fission_density(burnup_mwd_per_kgu: f64, uo2_density_kg_per_m3: f64) -> f64 {
1009    // 1 MWd = 86400 MJ; 1 fission ~ 3.2e-11 J
1010    let u_fraction = 238.03 / (238.03 + 32.0); // UO₂ heavy metal fraction
1011    let mhm_per_m3 = uo2_density_kg_per_m3 * u_fraction;
1012    // burnup in J/kgHM
1013    let burnup_j = burnup_mwd_per_kgu * 1e6 * 86400.0;
1014    mhm_per_m3 * burnup_j / 3.2e-11
1015}
1016
1017// ---------------------------------------------------------------------------
1018// Section 13 — Unit tests
1019// ---------------------------------------------------------------------------
1020
1021#[cfg(test)]
1022mod tests {
1023    use super::*;
1024
1025    #[test]
1026    fn test_zircaloy_clad_construction() {
1027        let clad = ZircaloyClad::new(ZircaloyGrade::Zircaloy4, 9.5e-3, 0.57e-3, 600.0);
1028        assert_eq!(clad.grade, ZircaloyGrade::Zircaloy4);
1029        assert_eq!(clad.oxide_thickness, 0.0);
1030    }
1031
1032    #[test]
1033    fn test_cubic_oxide_weight_gain_zero_time() {
1034        let clad = ZircaloyClad::new(ZircaloyGrade::Zircaloy4, 9.5e-3, 0.57e-3, 600.0);
1035        let wg = clad.oxide_weight_gain_cubic(0.0);
1036        assert!(wg.abs() < 1e-15, "zero time → zero weight gain");
1037    }
1038
1039    #[test]
1040    fn test_cubic_oxide_weight_gain_increases_with_time() {
1041        let clad = ZircaloyClad::new(ZircaloyGrade::Zircaloy4, 9.5e-3, 0.57e-3, 600.0);
1042        let wg1 = clad.oxide_weight_gain_cubic(1e6);
1043        let wg2 = clad.oxide_weight_gain_cubic(1e7);
1044        assert!(wg2 > wg1, "longer time should give greater weight gain");
1045    }
1046
1047    #[test]
1048    fn test_hydrogen_pickup_fraction_range() {
1049        let clad = ZircaloyClad::new(ZircaloyGrade::M5, 9.5e-3, 0.57e-3, 580.0);
1050        let f = clad.hydrogen_pickup_fraction();
1051        assert!(
1052            (0.0..=1.0).contains(&f),
1053            "pickup fraction must be [0,1]: {}",
1054            f
1055        );
1056    }
1057
1058    #[test]
1059    fn test_hydrogen_pickup_m5_lower_than_zry4() {
1060        let clad4 = ZircaloyClad::new(ZircaloyGrade::Zircaloy4, 9.5e-3, 0.57e-3, 600.0);
1061        let cladm5 = ZircaloyClad::new(ZircaloyGrade::M5, 9.5e-3, 0.57e-3, 600.0);
1062        assert!(
1063            cladm5.hydrogen_pickup_fraction() < clad4.hydrogen_pickup_fraction(),
1064            "M5 should have lower pickup than Zry-4"
1065        );
1066    }
1067
1068    #[test]
1069    fn test_yield_strength_increases_with_fluence() {
1070        let mut clad = ZircaloyClad::new(ZircaloyGrade::Zircaloy4, 9.5e-3, 0.57e-3, 600.0);
1071        let ys0 = clad.yield_strength();
1072        clad.fluence = 1e25;
1073        let ys1 = clad.yield_strength();
1074        assert!(ys1 > ys0, "irradiation should harden Zircaloy");
1075    }
1076
1077    #[test]
1078    fn test_irradiation_creep_rate_proportional_to_flux() {
1079        let clad = ZircaloyClad::new(ZircaloyGrade::Zircaloy4, 9.5e-3, 0.57e-3, 600.0);
1080        let r1 = clad.irradiation_creep_rate(100e6, 1e13);
1081        let r2 = clad.irradiation_creep_rate(100e6, 2e13);
1082        assert!(
1083            (r2 / r1 - 2.0).abs() < 1e-9,
1084            "creep rate should scale linearly with flux"
1085        );
1086    }
1087
1088    #[test]
1089    fn test_uo2_thermal_conductivity_positive() {
1090        let pellet = Uo2Pellet::new(8.19e-3, 10e-3, 0.0315);
1091        let k = pellet.thermal_conductivity(900.0);
1092        assert!(k > 0.0, "thermal conductivity must be positive, got {}", k);
1093    }
1094
1095    #[test]
1096    fn test_uo2_thermal_conductivity_decreases_with_burnup() {
1097        let mut pellet = Uo2Pellet::new(8.19e-3, 10e-3, 0.0315);
1098        let k0 = pellet.thermal_conductivity(900.0);
1099        pellet.burnup = 50.0;
1100        let k50 = pellet.thermal_conductivity(900.0);
1101        assert!(k50 < k0, "conductivity should decrease with burnup");
1102    }
1103
1104    #[test]
1105    fn test_fission_gas_release_zero_at_t0() {
1106        let pellet = Uo2Pellet::new(8.19e-3, 10e-3, 0.0315);
1107        let fgr = pellet.fission_gas_release_booth(1200.0, 0.0);
1108        assert!(fgr.abs() < 1e-9, "FGR at t=0 must be 0");
1109    }
1110
1111    #[test]
1112    fn test_fission_gas_release_bounded() {
1113        let pellet = Uo2Pellet::new(8.19e-3, 10e-3, 0.0315);
1114        let fgr = pellet.fission_gas_release_booth(1400.0, 1e10);
1115        assert!((0.0..=1.0).contains(&fgr), "FGR must be in [0,1]: {}", fgr);
1116    }
1117
1118    #[test]
1119    fn test_pellet_swelling_increases_with_burnup() {
1120        let mut p = Uo2Pellet::new(8.19e-3, 10e-3, 0.0315);
1121        p.burnup = 0.0;
1122        let s0 = p.fission_product_swelling();
1123        p.burnup = 50.0;
1124        let s50 = p.fission_product_swelling();
1125        assert!(s50 > s0, "swelling increases with burnup");
1126    }
1127
1128    #[test]
1129    fn test_garner_void_swelling_below_incubation_zero() {
1130        let params = VoidSwellingParams::default();
1131        let s = garner_void_swelling(5.0, 773.0, &params);
1132        assert_eq!(s, 0.0, "no swelling before incubation dose");
1133    }
1134
1135    #[test]
1136    fn test_garner_void_swelling_above_incubation_positive() {
1137        let params = VoidSwellingParams::default();
1138        let s = garner_void_swelling(50.0, 773.0, &params);
1139        assert!(s > 0.0, "swelling should be positive above incubation dose");
1140    }
1141
1142    #[test]
1143    fn test_garner_void_swelling_peak_temperature() {
1144        let params = VoidSwellingParams::default();
1145        let s_peak = garner_void_swelling(50.0, params.peak_temperature_k, &params);
1146        let s_off = garner_void_swelling(50.0, params.peak_temperature_k + 100.0, &params);
1147        assert!(
1148            s_peak > s_off,
1149            "peak temperature should give maximum swelling"
1150        );
1151    }
1152
1153    #[test]
1154    fn test_void_number_density_zero_for_zero_swelling() {
1155        let n = void_number_density(0.0, 5e-9);
1156        assert_eq!(n, 0.0);
1157    }
1158
1159    #[test]
1160    fn test_irradiation_creep_additive() {
1161        let r = irradiation_creep_rate(200e6, 773.0, 1e-8, 3e-27, 1e-33, 5.0, 280000.0);
1162        assert!(r > 0.0, "creep rate must be positive");
1163    }
1164
1165    #[test]
1166    fn test_stress_relaxation_decreases_with_dose() {
1167        let sigma = irradiation_stress_relaxation(200e6, 0.01, 100.0);
1168        assert!(sigma < 200e6, "stress should relax with dose");
1169        assert!(sigma > 0.0, "stress must remain positive");
1170    }
1171
1172    #[test]
1173    fn test_ris_concentration_bounded() {
1174        let params = RisParams::default();
1175        let c = ris_grain_boundary_concentration(&params);
1176        assert!(
1177            (0.0..=1.0).contains(&c),
1178            "RIS concentration out of [0,1]: {}",
1179            c
1180        );
1181    }
1182
1183    #[test]
1184    fn test_rpv_charpy_use_drop_low_fluence() {
1185        let charpy = RpvCharpy::new(
1186            RpvSteelGrade::A508Class3,
1187            100.0,
1188            -20.0,
1189            1e17,
1190            0.05,
1191            0.7,
1192            0.01,
1193        );
1194        let drop = charpy.use_drop();
1195        assert_eq!(drop, 0.0, "no USE drop below fluence threshold");
1196    }
1197
1198    #[test]
1199    fn test_rpv_charpy_use_irradiated_non_negative() {
1200        let charpy = RpvCharpy::new(
1201            RpvSteelGrade::A533GradeB,
1202            100.0,
1203            -30.0,
1204            1e20,
1205            0.20,
1206            0.8,
1207            0.02,
1208        );
1209        assert!(
1210            charpy.use_irradiated() >= 0.0,
1211            "irradiated USE must be non-negative"
1212        );
1213    }
1214
1215    #[test]
1216    fn test_rpv_rtndt_shift_positive_copper() {
1217        let charpy = RpvCharpy::new(
1218            RpvSteelGrade::A508Class3,
1219            100.0,
1220            -20.0,
1221            5e19,
1222            0.20,
1223            0.7,
1224            0.01,
1225        );
1226        let shift = charpy.rtndt_shift();
1227        assert!(shift > 0.0, "RTNDT shift must be positive with high copper");
1228    }
1229
1230    #[test]
1231    fn test_fracture_toughness_increases_with_temperature() {
1232        let charpy = RpvCharpy::new(
1233            RpvSteelGrade::A508Class3,
1234            100.0,
1235            -20.0,
1236            1e19,
1237            0.1,
1238            0.7,
1239            0.01,
1240        );
1241        let kic_cold = charpy.fracture_toughness_kic(-100.0);
1242        let kic_warm = charpy.fracture_toughness_kic(50.0);
1243        assert!(
1244            kic_warm > kic_cold,
1245            "fracture toughness increases above RTNDT"
1246        );
1247    }
1248
1249    #[test]
1250    fn test_helium_coverage_fraction_bounded() {
1251        let he = HeliumEmbrittlement::new(1e16, 2e-9, 20e-6);
1252        let f = he.coverage_fraction();
1253        assert!(
1254            (0.0..=1.0).contains(&f),
1255            "coverage fraction out of [0,1]: {}",
1256            f
1257        );
1258    }
1259
1260    #[test]
1261    fn test_helium_strength_reduction_non_negative() {
1262        let he = HeliumEmbrittlement::new(1e16, 5e-9, 20e-6);
1263        let sr = he.strength_reduction_factor();
1264        assert!(
1265            sr >= 0.0,
1266            "strength reduction factor must be non-negative: {}",
1267            sr
1268        );
1269    }
1270
1271    #[test]
1272    fn test_graphite_conductivity_decreases_with_fluence() {
1273        let mut g = GraphiteBlock::new(GraphiteGrade::Ig110, 673.0);
1274        let k0 = g.thermal_conductivity_irradiated();
1275        g.fluence = 1e26;
1276        let k_hi = g.thermal_conductivity_irradiated();
1277        assert!(
1278            k_hi < k0,
1279            "graphite conductivity should decrease with fluence"
1280        );
1281    }
1282
1283    #[test]
1284    fn test_graphite_wigner_zero_at_zero_fluence() {
1285        let g = GraphiteBlock::new(GraphiteGrade::PileGradeA, 400.0);
1286        let w = g.wigner_energy_j_per_kg();
1287        assert!(w.abs() < 1e-9, "zero fluence → zero Wigner energy");
1288    }
1289
1290    #[test]
1291    fn test_sfr_void_swelling_316_positive() {
1292        let mut mat = SfrMaterial::new(SfrSteelGrade::Ss316, 773.0);
1293        mat.dose_dpa = 50.0;
1294        let s = mat.void_swelling_pct();
1295        assert!(s >= 0.0, "void swelling must be non-negative");
1296    }
1297
1298    #[test]
1299    fn test_sfr_ht9_swelling_lower_than_316() {
1300        let mut mat_316 = SfrMaterial::new(SfrSteelGrade::Ss316, 773.0);
1301        let mut mat_ht9 = SfrMaterial::new(SfrSteelGrade::Ht9, 773.0);
1302        mat_316.dose_dpa = 100.0;
1303        mat_ht9.dose_dpa = 100.0;
1304        assert!(
1305            mat_ht9.void_swelling_pct() < mat_316.void_swelling_pct(),
1306            "HT-9 should swell less than 316 SS"
1307        );
1308    }
1309
1310    #[test]
1311    fn test_thermal_creep_rate_increases_with_temperature() {
1312        let mat_lo = SfrMaterial::new(SfrSteelGrade::Ss316, 700.0);
1313        let mat_hi = SfrMaterial::new(SfrSteelGrade::Ss316, 900.0);
1314        let r_lo = mat_lo.thermal_creep_rate(100.0);
1315        let r_hi = mat_hi.thermal_creep_rate(100.0);
1316        assert!(r_hi > r_lo, "creep rate should increase with temperature");
1317    }
1318
1319    #[test]
1320    fn test_fatigue_life_decreases_with_strain() {
1321        let mat = SfrMaterial::new(SfrSteelGrade::Ss316, 773.0);
1322        let n1 = mat.fatigue_life_cycles(0.001);
1323        let n2 = mat.fatigue_life_cycles(0.01);
1324        assert!(
1325            n1 > n2,
1326            "higher strain amplitude gives shorter fatigue life"
1327        );
1328    }
1329
1330    #[test]
1331    fn test_decay_chain_parent_count_at_zero() {
1332        let dc = DecayChain::from_half_life(1e9, 1e20);
1333        assert!((dc.parent_count(0.0) - 1e20).abs() < 1.0);
1334    }
1335
1336    #[test]
1337    fn test_decay_chain_conservation() {
1338        let dc = DecayChain::from_half_life(3600.0, 1e20);
1339        let t = 3600.0 * 3.0;
1340        let total = dc.parent_count(t) + dc.daughter_count(t);
1341        assert!(
1342            (total - 1e20).abs() / 1e20 < 1e-9,
1343            "atom count must be conserved"
1344        );
1345    }
1346
1347    #[test]
1348    fn test_pci_contact_pressure_zero_gap() {
1349        let params = PciParams::default();
1350        let p = pci_contact_pressure(&params, 0.0);
1351        assert_eq!(p, 0.0, "no contact pressure when gap is open");
1352    }
1353
1354    #[test]
1355    fn test_pci_contact_pressure_positive_on_swelling() {
1356        let params = PciParams::default();
1357        let p = pci_contact_pressure(&params, 0.01);
1358        assert!(p >= 0.0, "contact pressure must be non-negative");
1359    }
1360
1361    #[test]
1362    fn test_burnup_to_dpa_scaling() {
1363        let dpa = burnup_to_dpa_clad(50.0);
1364        assert!((dpa - 5.0).abs() < 1e-9, "50 MWd/kgU → 5 dpa");
1365    }
1366
1367    #[test]
1368    fn test_fission_density_positive() {
1369        let fd = fission_density(50.0, 10400.0);
1370        assert!(fd > 0.0, "fission density must be positive");
1371    }
1372
1373    #[test]
1374    fn test_chemistry_factor_zero_low_cu() {
1375        let cf = chemistry_factor(0.05, 0.0);
1376        assert_eq!(cf, 0.0, "no CF for very low copper");
1377    }
1378}