nereids_core/constants.rs
1//! Physical constants used throughout NEREIDS.
2//!
3//! Values from CODATA 2018 recommended values.
4//! Reference: <https://physics.nist.gov/cuu/Constants/>
5
6/// Neutron mass in kg.
7pub const NEUTRON_MASS_KG: f64 = 1.674_927_498_04e-27;
8
9/// Neutron mass in atomic mass units (u).
10pub const NEUTRON_MASS_AMU: f64 = 1.008_664_915_95;
11
12/// Neutron mass in MeV/c².
13pub const NEUTRON_MASS_MEV: f64 = 939.565_420_52;
14
15/// Boltzmann constant in eV/K.
16pub const BOLTZMANN_EV_PER_K: f64 = 8.617_333_262e-5;
17
18/// Planck constant (reduced, ħ) in eV·s.
19///
20/// Derived from h = 6.626_070_15e-34 J·s (exact, 2019 SI) and
21/// e = 1.602_176_634e-19 C (exact): ħ = h / (2π·e).
22/// Rounded to 10 significant figures.
23pub const HBAR_EV_S: f64 = 6.582_119_569e-16;
24
25/// Speed of light in m/s.
26pub const SPEED_OF_LIGHT: f64 = 2.997_924_58e8;
27
28/// 1 eV in joules.
29pub const EV_TO_JOULES: f64 = 1.602_176_634e-19;
30
31/// Avogadro's number in mol⁻¹.
32pub const AVOGADRO: f64 = 6.022_140_76e23;
33
34/// Convert neutron energy (eV) to wavelength (Å).
35///
36/// λ = h / √(2·m·E), result in angstroms.
37pub fn energy_to_wavelength_angstrom(energy_ev: f64) -> f64 {
38 // λ(Å) = 0.2860 / √(E in eV) (standard neutron relation)
39 0.286_014_3 / energy_ev.sqrt()
40}
41
42/// Convert neutron time-of-flight (μs) and flight path (m) to energy (eV).
43///
44/// E = ½·m_n·(L/t)²
45///
46/// # Domain contract
47/// Returns [`f64::NAN`] for out-of-domain input rather than a misleading
48/// value. Both arguments must be finite and strictly positive:
49/// * a non-positive `tof_us` is unphysical — the unguarded formula squares
50/// the velocity, so a *negative* TOF would return a *positive* energy, and
51/// `tof_us == 0` would return `+∞`;
52/// * a non-positive `flight_path_m` is equally unphysical — `flight_path_m
53/// == 0` makes the velocity (and energy) `0`, and a *negative* flight path
54/// would (after squaring) return a *positive* energy, masking a bad
55/// detector geometry the same way a negative TOF would;
56/// * a non-finite `tof_us` or `flight_path_m` cannot map to a real energy.
57///
58/// Callers that need to surface bad input as an error (e.g. the PyO3
59/// boundary) check the input before calling or test the result with
60/// `is_finite()`; the in-crate callers (`nereids_io::tof`, the GUI) already
61/// guard `tof > 0 && finite` up-front, so this NaN sentinel never fires on
62/// valid data.
63pub fn tof_to_energy(tof_us: f64, flight_path_m: f64) -> f64 {
64 // `is_finite()` first excludes NaN, so the `<= 0.0` total-order
65 // comparisons that follow are well-defined (clippy's
66 // `neg_cmp_op_on_partial_ord`).
67 if !tof_us.is_finite() || tof_us <= 0.0 || !flight_path_m.is_finite() || flight_path_m <= 0.0 {
68 return f64::NAN;
69 }
70 let t_s = tof_us * 1.0e-6;
71 let v = flight_path_m / t_s;
72 0.5 * NEUTRON_MASS_KG * v * v / EV_TO_JOULES
73}
74
75/// Convert neutron energy (eV) to time-of-flight (μs) given flight path (m).
76///
77/// # Domain contract
78/// Mirrors [`tof_to_energy`]: returns [`f64::NAN`] unless *both* arguments are
79/// finite and strictly positive. A non-positive or non-finite `energy_ev` (the
80/// unguarded formula takes `√energy`, so a negative energy would yield a `NaN`
81/// velocity and `energy_ev == 0` would yield `+∞`), and a non-positive or
82/// non-finite `flight_path_m` (which would yield a zero or *negative* TOF — a
83/// physically impossible time), are all rejected. This keeps the two
84/// directions consistent — both refuse out-of-domain input instead of
85/// returning a plausible-looking number.
86pub fn energy_to_tof(energy_ev: f64, flight_path_m: f64) -> f64 {
87 if !energy_ev.is_finite()
88 || energy_ev <= 0.0
89 || !flight_path_m.is_finite()
90 || flight_path_m <= 0.0
91 {
92 return f64::NAN;
93 }
94 let v = (2.0 * energy_ev * EV_TO_JOULES / NEUTRON_MASS_KG).sqrt();
95 (flight_path_m / v) * 1.0e6
96}
97
98// ── Numerical tolerances ─────────────────────────────────────────────
99// Named constants for magic-number epsilons scattered across physics code.
100
101/// Epsilon for floating-point comparison of quantum numbers (J, L, spin).
102pub const QUANTUM_NUMBER_EPS: f64 = 1e-10;
103
104/// Floor for Poisson model values to avoid log(0) in NLL computation.
105pub const POISSON_EPSILON: f64 = 1e-10;
106
107/// Floor for denominators in physics evaluations (penetrability, shift, etc.)
108/// to avoid division by zero.
109pub const DIVISION_FLOOR: f64 = 1e-50;
110
111/// Generic tiny positive floor used as a near-zero tolerance across physics
112/// calculations (e.g., cross-sections in barns, energies in eV, widths,
113/// dimensionless parameters). Values below this are treated as negligible.
114pub const NEAR_ZERO_FLOOR: f64 = 1e-60;
115
116/// Floor for pivot detection and division safety in numerical linear algebra
117/// (LM solver, Gaussian elimination). Values below this indicate a
118/// (near-)singular system.
119pub const PIVOT_FLOOR: f64 = 1e-30;
120
121/// Floor for Levenberg-Marquardt diagonal elements to ensure damping stability.
122/// Intentionally much larger than PIVOT_FLOOR — LM requires a meaningful
123/// minimum curvature for numerical stability of the trust-region step.
124pub const LM_DIAGONAL_FLOOR: f64 = 1e-10;
125
126/// Floor for avoiding log(0) or division by zero in general computations.
127pub const LOG_FLOOR: f64 = 1e-300;
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn test_tof_energy_roundtrip() {
135 let energy = 6.67; // eV (first U-238 resonance)
136 let flight_path = 25.0; // meters (VENUS)
137 let tof = energy_to_tof(energy, flight_path);
138 let energy_back = tof_to_energy(tof, flight_path);
139 assert!((energy - energy_back).abs() < 1e-10);
140 }
141
142 #[test]
143 fn test_wavelength_thermal() {
144 // Thermal neutrons at 0.0253 eV should have λ ≈ 1.8 Å
145 let lambda = energy_to_wavelength_angstrom(0.0253);
146 assert!((lambda - 1.8).abs() < 0.1);
147 }
148
149 #[test]
150 fn test_tof_to_energy_rejects_non_positive_and_non_finite() {
151 let l = 25.0;
152 // Negative TOF used to return a *positive* energy (v² hides the
153 // sign), masking an upstream sign/loader bug.
154 assert!(
155 tof_to_energy(-100.0, l).is_nan(),
156 "negative TOF must be NaN"
157 );
158 // Zero TOF used to return +∞.
159 assert!(tof_to_energy(0.0, l).is_nan(), "zero TOF must be NaN");
160 assert!(tof_to_energy(f64::NAN, l).is_nan(), "NaN TOF must stay NaN");
161 assert!(
162 tof_to_energy(f64::INFINITY, l).is_nan(),
163 "Inf TOF must be NaN"
164 );
165 assert!(
166 tof_to_energy(100.0, f64::NAN).is_nan(),
167 "NaN flight path must be NaN"
168 );
169 assert!(
170 tof_to_energy(100.0, f64::INFINITY).is_nan(),
171 "Inf flight path must be NaN"
172 );
173 // Zero flight path used to return a finite 0 energy (v = L/t = 0),
174 // masking a bad detector geometry.
175 assert!(
176 tof_to_energy(100.0, 0.0).is_nan(),
177 "zero flight path must be NaN"
178 );
179 // Negative flight path used to return a *positive* energy (v² hides
180 // the sign), the same trap as a negative TOF.
181 assert!(
182 tof_to_energy(100.0, -25.0).is_nan(),
183 "negative flight path must be NaN"
184 );
185 // Valid input still produces a finite, positive energy.
186 assert!(tof_to_energy(100.0, l).is_finite());
187 assert!(tof_to_energy(100.0, l) > 0.0);
188 }
189
190 #[test]
191 fn test_energy_to_tof_rejects_non_positive_and_non_finite() {
192 let l = 25.0;
193 // Negative energy used to return NaN already (√ of negative), but
194 // zero energy returned +∞ — both are now an explicit NaN contract.
195 assert!(
196 energy_to_tof(-1.0, l).is_nan(),
197 "negative energy must be NaN"
198 );
199 assert!(energy_to_tof(0.0, l).is_nan(), "zero energy must be NaN");
200 assert!(
201 energy_to_tof(f64::NAN, l).is_nan(),
202 "NaN energy must stay NaN"
203 );
204 assert!(
205 energy_to_tof(f64::INFINITY, l).is_nan(),
206 "Inf energy must be NaN"
207 );
208 assert!(
209 energy_to_tof(10.0, f64::NAN).is_nan(),
210 "NaN flight path must be NaN"
211 );
212 assert!(
213 energy_to_tof(10.0, f64::INFINITY).is_nan(),
214 "Inf flight path must be NaN"
215 );
216 // Zero / negative flight path used to return 0 / a *negative* TOF —
217 // a physically impossible time.
218 assert!(
219 energy_to_tof(10.0, 0.0).is_nan(),
220 "zero flight path must be NaN"
221 );
222 assert!(
223 energy_to_tof(10.0, -25.0).is_nan(),
224 "negative flight path must be NaN"
225 );
226 // Valid input still produces a finite, positive TOF.
227 assert!(energy_to_tof(10.0, l).is_finite());
228 assert!(energy_to_tof(10.0, l) > 0.0);
229 }
230}