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