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}