Skip to main content

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}