Skip to main content

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 II.A (scattering-theory equations and 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 LRF=7 (R-Matrix Limited) channels, whose particle pairs have
70/// their own masses and Q-values.
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/// Σ g_J over the channels of orbital angular momentum `l` that no J-group
180/// of `present_j` holds.
181///
182/// A neutron (spin ½) on a target of spin I has channel spins
183/// s = |I − ½| and I + ½, and each s allows J = |l − s| … l + s, so a J can
184/// belong to two channels; over every channel Σ g_J = 2l + 1. A J-group holds
185/// one channel of its |J|. A channel no J-group holds scatters as a hard
186/// sphere, π/k²·g_J·4 sin²φ_l.
187pub(crate) fn uncovered_channel_weight(
188    l: u32,
189    target_spin: f64,
190    present_j: impl IntoIterator<Item = f64>,
191) -> f64 {
192    let twice_i = (2.0 * target_spin).round() as i64;
193    let twice_l = 2 * i64::from(l);
194    let mut open: Vec<i64> = [twice_i - 1, twice_i + 1]
195        .into_iter()
196        .filter(|&twice_s| twice_s >= 0)
197        .flat_map(|twice_s| ((twice_l - twice_s).abs()..=twice_l + twice_s).step_by(2))
198        .collect();
199    for j in present_j {
200        let twice_j = (2.0 * j.abs()).round() as i64;
201        if let Some(at) = open.iter().position(|&o| o == twice_j) {
202            open.swap_remove(at);
203        }
204    }
205    open.into_iter()
206        .map(|twice_j| statistical_weight(twice_j as f64 / 2.0, target_spin))
207        .sum()
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn every_channel_together_weighs_two_l_plus_one() {
216        for twice_i in 0..=9 {
217            let target_spin = f64::from(twice_i) / 2.0;
218            for l in 0..=3 {
219                let all = uncovered_channel_weight(l, target_spin, []);
220                assert!(
221                    (all - f64::from(2 * l + 1)).abs() < 1e-12,
222                    "I = {target_spin}, l = {l}: {all}"
223                );
224            }
225        }
226    }
227
228    #[test]
229    fn a_j_group_holds_one_channel_of_its_j() {
230        // I = 1/2, l = 1: s = 0 gives J = 1; s = 1 gives J = 0, 1, 2.
231        let g = |j: f64| statistical_weight(j, 0.5);
232        assert_eq!(uncovered_channel_weight(1, 0.5, [1.0]), 3.0 - g(1.0));
233        assert_eq!(
234            uncovered_channel_weight(1, 0.5, [1.0, -1.0]),
235            g(0.0) + g(2.0)
236        );
237        assert_eq!(uncovered_channel_weight(1, 0.5, [0.0, 1.0, -1.0, 2.0]), 0.0);
238        // A J the channels do not allow holds none of them.
239        assert_eq!(uncovered_channel_weight(0, 3.5, [3.0, 4.0, 5.0]), 0.0);
240        assert_eq!(uncovered_channel_weight(0, 3.5, [5.0]), 1.0);
241    }
242
243    #[test]
244    fn test_wave_number_u238() {
245        // U-238: AWR ≈ 236.006
246        // k = sqrt(2 m_n / (ℏc)²) × AWR/(1+AWR) × sqrt(E)
247        // At E = 1 eV: k_free = 2.197e-4 fm⁻¹, mass_ratio = 236.006/237.006 = 0.99578
248        // k = 2.197e-4 × 0.99578 ≈ 2.188e-4 fm⁻¹
249        let awr = 236.006;
250        let k = wave_number(1.0, awr);
251        assert!(
252            (k - 2.188e-4).abs() < 1e-6,
253            "k = {} fm⁻¹, expected ~2.188e-4",
254            k
255        );
256    }
257
258    #[test]
259    fn test_rho_u238() {
260        // U-238 at 6.674 eV, channel radius 9.4285 fm
261        // ρ = k·a ≈ 2.188e-4 × √6.674 × 9.4285 ≈ 5.33e-3
262        let awr = 236.006;
263        let r = rho(6.674, awr, 9.4285);
264        assert!((r - 5.33e-3).abs() < 1e-4, "ρ = {}, expected ~5.33e-3", r);
265    }
266
267    #[test]
268    fn test_statistical_weight() {
269        // U-238: I=0, s=1/2, J=1/2 → g = 2/2 = 1.0
270        assert!((statistical_weight(0.5, 0.0) - 1.0).abs() < 1e-15);
271        // J=3/2, I=0 → g = 4/2 = 2.0
272        assert!((statistical_weight(1.5, 0.0) - 2.0).abs() < 1e-15);
273        // Ta-181: I=7/2, J=3 → g = 7/(8×2) = 7/16
274        assert!((statistical_weight(3.0, 3.5) - 7.0 / 16.0).abs() < 1e-15);
275    }
276
277    #[test]
278    fn test_pi_over_k2_u238() {
279        // At 6.674 eV for U-238:
280        // k² = 2·m_n·(AWR/(1+AWR))²·E / (ℏc)²
281        // With corrected formula: π/k² ≈ 9.86e4 barns
282        // (factor of (1+AWR)/AWR = 1.0042 larger than uncorrected)
283        let val = pi_over_k_squared_barns(6.674, 236.006);
284        assert!(
285            (val - 9.86e4).abs() < 1e3,
286            "π/k² = {} barns, expected ~9.86e4",
287            val
288        );
289    }
290
291    /// k_squared must return 0.0 for negative energies (guard path).
292    /// Negative E_r can appear for bound-state resonances in ENDF; the guard
293    /// prevents a negative k² from propagating NaN through sqrt downstream.
294    #[test]
295    fn test_k_squared_negative_energy() {
296        assert_eq!(k_squared(-1.0, 236.006), 0.0);
297        assert_eq!(k_squared(-100.0, 10.0), 0.0);
298        assert_eq!(k_squared(0.0, 236.006), 0.0);
299    }
300
301    /// wave_number_from_cm must return 0.0 for negative CM energies.
302    /// Negative E_cm arises near threshold energies from numerical noise.
303    #[test]
304    fn test_wave_number_from_cm_negative_energy() {
305        assert_eq!(wave_number_from_cm(-1.0, 0.5), 0.0);
306        assert_eq!(wave_number_from_cm(-1e-10, 0.9412), 0.0);
307        // Zero CM energy → zero wave number
308        assert_eq!(wave_number_from_cm(0.0, 0.5), 0.0);
309        // Positive CM energy → positive wave number
310        assert!(wave_number_from_cm(1.0, 0.5) > 0.0);
311    }
312}