nereids_physics/
channel.rs

1//! Neutron channel calculations: wave number, ρ parameter, statistical weights.
2//!
3//! Computes channel-level quantities needed for R-matrix cross-section
4//! evaluation: the neutron wave number k(E), channel parameter ρ = k·a,
5//! and spin statistical weight factors g_J.
6//!
7//! ## SAMMY Reference
8//! - `rml/mrml03.f` Fxradi: wave number and radius computations
9//! - SAMMY manual Section 2 (kinematics)
10//!
11//! ## Units
12//! All quantities in natural units: energies in eV, lengths in fm.
13//!
14//! ## Key Relations
15//! - k = √(2·μ·E_cm) / ℏc = [AWR/(1+AWR)] × √(2·m_n·E_lab) / ℏc
16//! - ρ = k·a, where a = channel radius
17//! - g_J = (2J+1) / ((2I+1)·(2s+1))
18
19use nereids_core::constants;
20
21/// Neutron wave number squared k² in fm⁻².
22///
23/// k² = 2·μ·E_cm / (ℏc)² = 2·m_n·[AWR/(1+AWR)]²·E_lab / (ℏc)²
24///
25/// where μ = m_n · AWR / (1 + AWR) is the reduced mass,
26/// E_cm = E_lab · AWR / (1 + AWR) is the center-of-mass energy,
27/// and the two factors of AWR/(1+AWR) come from μ and E_cm respectively.
28///
29/// ## SAMMY Reference
30/// - `rml/mrml03.f` Fxradi: Zke = Twomhb × √(Redmas × Factor)
31///   where Redmas = AWR/(1+AWR) and Factor = AWR/(1+AWR).
32///
33/// # Arguments
34/// * `energy_ev` — Neutron energy in eV (lab frame, as stored in ENDF).
35/// * `awr` — Ratio of target mass to neutron mass (from ENDF).
36pub fn k_squared(energy_ev: f64, awr: f64) -> f64 {
37    // Guard: bound-state resonances have negative E_r in ENDF, but k² is
38    // evaluated at the lab energy which is always positive.  If a caller
39    // passes E ≤ 0 (e.g. during extrapolation or misuse), return 0.0 to
40    // prevent a negative k² from propagating NaN through sqrt downstream.
41    if energy_ev <= 0.0 {
42        return 0.0;
43    }
44
45    // μ/m_n = AWR / (1 + AWR)
46    let mass_ratio = awr / (1.0 + awr);
47
48    // k² = 2 · m_n · (AWR/(1+AWR))² · E_lab / (ℏc)²
49    // One factor from reduced mass, one from lab→CM energy conversion.
50    let mn_ev = constants::NEUTRON_MASS_MEV * 1e6; // MeV → eV
51    let hbar_c = constants::HBAR_EV_S * constants::SPEED_OF_LIGHT * 1e15; // eV·fm
52
53    2.0 * mn_ev * mass_ratio * mass_ratio * energy_ev / (hbar_c * hbar_c)
54}
55
56/// Neutron wave number k in fm⁻¹.
57///
58/// # Arguments
59/// * `energy_ev` — Neutron energy in eV (must be positive).
60/// * `awr` — Ratio of target mass to neutron mass.
61pub fn wave_number(energy_ev: f64, awr: f64) -> f64 {
62    k_squared(energy_ev, awr).sqrt()
63}
64
65/// Wave number k_c from reduced mass ratio and center-of-mass kinetic energy.
66///
67/// k_c = √(2·m_n·μ·E_cm) / ℏc
68///
69/// Used for non-elastic channels in LRF=7 (R-Matrix Limited) where each
70/// channel may have a different particle pair (masses, Q-value).
71///
72/// For the elastic channel (MA=1 neutron, MB=AWR target):
73///   μ = AWR/(1+AWR),  E_cm = E_lab·AWR/(1+AWR)
74///   → k = `wave_number(E_lab, AWR)` (algebraically identical).
75///
76/// ## SAMMY Reference
77/// - `rml/mrml03.f` Fxradi: `Zke = Twomhb × √(Redmas × Factor)`
78///   where Redmas = MA·MB/(MA+MB) and Factor = E_cm for the channel.
79///
80/// # Arguments
81/// * `e_cm` — Center-of-mass kinetic energy in this channel (eV). Must be ≥ 0.
82/// * `reduced_mass_ratio` — μ = MA·MB/(MA+MB) in neutron mass units.
83pub fn wave_number_from_cm(e_cm: f64, reduced_mass_ratio: f64) -> f64 {
84    // Guard against non-positive CM energies, which can arise from numerical noise
85    // near threshold energies. Zero wave number means no contribution from this channel.
86    // Uses `<= 0.0` to match `k_squared` (line 41), which also catches zero.
87    if e_cm <= 0.0 {
88        return 0.0;
89    }
90    let mn_ev = constants::NEUTRON_MASS_MEV * 1e6; // MeV → eV
91    let hbar_c = constants::HBAR_EV_S * constants::SPEED_OF_LIGHT * 1e15; // eV·fm
92    (2.0 * mn_ev * reduced_mass_ratio * e_cm / (hbar_c * hbar_c)).sqrt()
93}
94
95/// Channel parameter ρ = k·a.
96///
97/// # Arguments
98/// * `energy_ev` — Neutron energy in eV.
99/// * `awr` — Mass ratio from ENDF.
100/// * `channel_radius_fm` — Channel radius in fm.
101pub fn rho(energy_ev: f64, awr: f64, channel_radius_fm: f64) -> f64 {
102    wave_number(energy_ev, awr) * channel_radius_fm
103}
104
105/// Spin statistical weight factor g_J.
106///
107///   g_J = (2|J| + 1) / ((2I + 1) · (2s + 1))
108///
109/// where I = target spin, s = neutron spin (1/2), J = total angular momentum.
110///
111/// Uses `|J|` so that the SAMMY sign convention (negative J to distinguish
112/// spin groups with the same |J|) produces the correct weight.  ENDF data
113/// always has J ≥ 0, so the `abs()` is a no-op for standard ENDF inputs.
114///
115/// # Arguments
116/// * `j_total` — Total angular momentum J (may be negative per SAMMY convention).
117/// * `target_spin` — Target nucleus spin I.
118pub fn statistical_weight(j_total: f64, target_spin: f64) -> f64 {
119    let neutron_spin = 0.5;
120    (2.0 * j_total.abs() + 1.0) / ((2.0 * target_spin + 1.0) * (2.0 * neutron_spin + 1.0))
121}
122
123/// ENDF-6 §2.2.1 default channel radius (fm).
124///
125/// a_c = (0.123 · A^(1/3) + 0.08) × 10  \[fm\]
126///
127/// The ENDF formula gives values in 10⁻¹² cm; the × 10 converts to fm
128/// (matching the ENDF_RADIUS_TO_FM factor applied to AP during parsing).
129pub fn endf_channel_radius_fm(awr: f64) -> f64 {
130    (0.123 * awr.cbrt() + 0.08) * 10.0
131}
132
133/// Convert between lab and center-of-mass energy.
134///
135/// E_cm = E_lab · AWR / (1 + AWR)
136pub fn lab_to_cm_energy(energy_lab: f64, awr: f64) -> f64 {
137    energy_lab * awr / (1.0 + awr)
138}
139
140/// π/k² factor that appears in all cross-section formulas.
141///
142/// Returns π/k² in barns (1 barn = 100 fm²).
143///
144/// # Arguments
145/// * `energy_ev` — Neutron energy in eV.
146/// * `awr` — Mass ratio from ENDF.
147pub fn pi_over_k_squared_barns(energy_ev: f64, awr: f64) -> f64 {
148    // Catch non-positive energies in debug builds: k_squared(0.0) returns 0.0,
149    // and pi_over_k_squared_barns would silently map 0.0 → 1e-20 via the
150    // floor, producing a finite (wrong) result instead of the expected
151    // infinity/zero. Callers should never pass non-positive energy here.
152    //
153    // Note on debug_assert vs assert: the check `energy_ev > 0.0` fires
154    // for both zero and negative energies, but only in debug builds.
155    // In release builds, a non-positive energy is silently clamped to 1e-20
156    // by the floor below, yielding a large but finite π/k² (~10¹⁸ barns).
157    // This is acceptable because:
158    //   1. All call sites guarantee positive energies (ENDF grids, fitting
159    //      bounds, transmission forward model).
160    //   2. The 1e-20 floor is defense-in-depth: it prevents division by zero
161    //      and NaN propagation without paying for a branch in hot loops.
162    //   3. A full `assert!` would add a branch to the hottest inner loop of
163    //      cross-section evaluation for a condition that cannot occur in
164    //      correct usage.
165    debug_assert!(
166        energy_ev > 0.0,
167        "pi_over_k_squared_barns called with non-positive energy: {energy_ev}"
168    );
169    // Apply a tiny energy floor (1e-20 eV) so that k² is never zero.
170    // This preserves the correct 1/E functional form at very low energies
171    // instead of returning an arbitrary sentinel value that could mislead
172    // a fitting optimizer.
173    let e_safe = energy_ev.max(1e-20);
174    let k2 = k_squared(e_safe, awr);
175    // π/k² in fm², convert to barns (1 barn = 100 fm²)
176    std::f64::consts::PI / k2 / 100.0
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_wave_number_u238() {
185        // U-238: AWR ≈ 236.006
186        // k = sqrt(2 m_n / (ℏc)²) × AWR/(1+AWR) × sqrt(E)
187        // At E = 1 eV: k_free = 2.197e-4 fm⁻¹, mass_ratio = 236.006/237.006 = 0.99578
188        // k = 2.197e-4 × 0.99578 ≈ 2.188e-4 fm⁻¹
189        let awr = 236.006;
190        let k = wave_number(1.0, awr);
191        assert!(
192            (k - 2.188e-4).abs() < 1e-6,
193            "k = {} fm⁻¹, expected ~2.188e-4",
194            k
195        );
196    }
197
198    #[test]
199    fn test_rho_u238() {
200        // U-238 at 6.674 eV, channel radius 9.4285 fm
201        // ρ = k·a ≈ 2.188e-4 × √6.674 × 9.4285 ≈ 5.33e-3
202        let awr = 236.006;
203        let r = rho(6.674, awr, 9.4285);
204        assert!((r - 5.33e-3).abs() < 1e-4, "ρ = {}, expected ~5.33e-3", r);
205    }
206
207    #[test]
208    fn test_statistical_weight() {
209        // U-238: I=0, s=1/2, J=1/2 → g = 2/2 = 1.0
210        assert!((statistical_weight(0.5, 0.0) - 1.0).abs() < 1e-15);
211        // J=3/2, I=0 → g = 4/2 = 2.0
212        assert!((statistical_weight(1.5, 0.0) - 2.0).abs() < 1e-15);
213        // Ta-181: I=7/2, J=3 → g = 7/(8×2) = 7/16
214        assert!((statistical_weight(3.0, 3.5) - 7.0 / 16.0).abs() < 1e-15);
215    }
216
217    #[test]
218    fn test_pi_over_k2_u238() {
219        // At 6.674 eV for U-238:
220        // k² = 2·m_n·(AWR/(1+AWR))²·E / (ℏc)²
221        // With corrected formula: π/k² ≈ 9.86e4 barns
222        // (factor of (1+AWR)/AWR = 1.0042 larger than uncorrected)
223        let val = pi_over_k_squared_barns(6.674, 236.006);
224        assert!(
225            (val - 9.86e4).abs() < 1e3,
226            "π/k² = {} barns, expected ~9.86e4",
227            val
228        );
229    }
230
231    /// k_squared must return 0.0 for negative energies (guard path).
232    /// Negative E_r can appear for bound-state resonances in ENDF; the guard
233    /// prevents a negative k² from propagating NaN through sqrt downstream.
234    #[test]
235    fn test_k_squared_negative_energy() {
236        assert_eq!(k_squared(-1.0, 236.006), 0.0);
237        assert_eq!(k_squared(-100.0, 10.0), 0.0);
238        assert_eq!(k_squared(0.0, 236.006), 0.0);
239    }
240
241    /// wave_number_from_cm must return 0.0 for negative CM energies.
242    /// Negative E_cm arises near threshold energies from numerical noise.
243    #[test]
244    fn test_wave_number_from_cm_negative_energy() {
245        assert_eq!(wave_number_from_cm(-1.0, 0.5), 0.0);
246        assert_eq!(wave_number_from_cm(-1e-10, 0.9412), 0.0);
247        // Zero CM energy → zero wave number
248        assert_eq!(wave_number_from_cm(0.0, 0.5), 0.0);
249        // Positive CM energy → positive wave number
250        assert!(wave_number_from_cm(1.0, 0.5) > 0.0);
251    }
252}