nereids_pipeline/
calibration.rs

1//! Energy calibration for TOF neutron instruments.
2//!
3//! Finds the flight path length (L) and TOF delay (t₀) that best align
4//! a measured transmission spectrum with the ENDF resonance model.
5//!
6//! The energy-TOF relationship is:
7//!
8//!   E = C · (L / (t − t₀))²
9//!
10//! where C = mₙ / 2 ≈ 5.2276e-9 [eV·s²/m²].
11//!
12//! When L or t₀ differ from the values assumed during data reduction,
13//! resonance positions shift in the energy domain, causing catastrophic
14//! chi² degradation (e.g. 436 → 2.7 for a 0.3% L correction on VENUS).
15
16use nereids_endf::resonance::ResonanceData;
17use nereids_physics::transmission::{self, InstrumentParams, SampleParams};
18
19use crate::error::PipelineError;
20
21/// Neutron mass constant: C = m_n / (2 · eV) = 5.2276e-9 eV·s²/m²
22///
23/// E [eV] = C · (L [m] / t [s])²
24const NEUTRON_MASS_CONSTANT: f64 = 0.5 * 1.6749e-27 / 1.602e-19;
25
26/// Result of energy calibration.
27#[derive(Debug, Clone)]
28pub struct CalibrationResult {
29    /// Fitted flight path length in metres.
30    pub flight_path_m: f64,
31    /// Fitted TOF delay in microseconds.
32    pub t0_us: f64,
33    /// Fitted total areal density in atoms/barn.
34    pub total_density: f64,
35    /// Reduced chi-squared at the best (L, t₀, n) values.
36    pub reduced_chi_squared: f64,
37    /// Corrected energy grid (ascending, eV).
38    pub energies_corrected: Vec<f64>,
39}
40
41/// Calibrate the energy axis of a TOF neutron measurement.
42///
43/// Given a measured 1D transmission spectrum and known sample composition
44/// (e.g. natural Hf), finds the (L, t₀) that minimize chi² by aligning
45/// the ENDF resonance positions with the measured dips.
46///
47/// # Arguments
48///
49/// * `energies_nominal` — Energy grid computed with assumed L (ascending, eV)
50/// * `transmission` — Measured transmission values (same length)
51/// * `uncertainty` — Per-bin uncertainty (same length)
52/// * `isotopes` — ENDF resonance data for each isotope
53/// * `abundances` — Natural abundance fractions (same length as isotopes, sum ≤ 1)
54/// * `assumed_flight_path_m` — The L used to compute `energies_nominal`
55/// * `temperature_k` — Sample temperature for Doppler broadening
56/// * `resolution` — Optional instrument resolution function.  When provided,
57///   the forward model includes Doppler + resolution broadening, producing
58///   more accurate (L, t₀) fits.  Without resolution, fitted parameters
59///   absorb the missing broadening and may be biased.
60///
61/// # Returns
62///
63/// [`CalibrationResult`] with the fitted (L, t₀, n_total) and corrected energies.
64#[allow(clippy::too_many_arguments)]
65pub fn calibrate_energy(
66    energies_nominal: &[f64],
67    transmission: &[f64],
68    uncertainty: &[f64],
69    isotopes: &[ResonanceData],
70    abundances: &[f64],
71    assumed_flight_path_m: f64,
72    temperature_k: f64,
73    resolution: Option<&InstrumentParams>,
74) -> Result<CalibrationResult, PipelineError> {
75    let n = energies_nominal.len();
76    if n == 0 {
77        return Err(PipelineError::InvalidParameter(
78            "energies_nominal must not be empty".into(),
79        ));
80    }
81    if transmission.len() != n || uncertainty.len() != n {
82        return Err(PipelineError::InvalidParameter(format!(
83            "transmission ({}) and uncertainty ({}) must match energies ({})",
84            transmission.len(),
85            uncertainty.len(),
86            n,
87        )));
88    }
89    if isotopes.len() != abundances.len() {
90        return Err(PipelineError::InvalidParameter(format!(
91            "isotopes ({}) must match abundances ({})",
92            isotopes.len(),
93            abundances.len(),
94        )));
95    }
96
97    // Recover TOF from nominal energies: t = L_assumed · √(C / E)
98    let tof_s: Vec<f64> = energies_nominal
99        .iter()
100        .map(|&e| assumed_flight_path_m * (NEUTRON_MASS_CONSTANT / e).sqrt())
101        .collect();
102
103    // Pre-filter valid bins (finite T, positive sigma)
104    let valid: Vec<bool> = transmission
105        .iter()
106        .zip(uncertainty.iter())
107        .map(|(&t, &s)| t.is_finite() && s.is_finite() && s > 0.0)
108        .collect();
109
110    // ── Phase 1: Coarse grid search over (L, t₀, n_total) ──────────
111    // L: ±1% around assumed (0.5% steps = 5 points each side)
112    // t₀: -5 to +10 µs (1 µs steps)
113    // n_total: scanned at each (L, t₀) via golden section on [1e-5, 1e-2]
114
115    let l_center = assumed_flight_path_m;
116    let mut best_chi2 = f64::INFINITY;
117    let mut best_l = l_center;
118    let mut best_t0_us = 0.0f64;
119    let mut best_n = 1e-4;
120
121    // Coarse L: 0.2% steps, ±1.5%
122    let l_steps: Vec<f64> = (-15..=15)
123        .map(|i| l_center * (1.0 + i as f64 * 0.001))
124        .collect();
125    // Coarse t₀: 1 µs steps, -5 to +10 µs
126    let t0_steps: Vec<f64> = (-5..=10).map(|i| i as f64).collect();
127
128    for &l in &l_steps {
129        for &t0 in &t0_steps {
130            let t0_s = t0 * 1e-6;
131            // Correct energies
132            let e_corr: Vec<f64> = tof_s
133                .iter()
134                .map(|&t| {
135                    let t_corr = t - t0_s;
136                    if t_corr <= 0.0 {
137                        f64::NAN
138                    } else {
139                        NEUTRON_MASS_CONSTANT * (l / t_corr).powi(2)
140                    }
141                })
142                .collect();
143
144            // Skip if any NaN
145            if e_corr.iter().any(|e| !e.is_finite() || *e <= 0.0) {
146                continue;
147            }
148
149            // Quick n_total scan: 5 values on log scale
150            for &n_total in &[5e-5, 1e-4, 1.5e-4, 2e-4, 3e-4] {
151                let chi2 = compute_chi2(
152                    &e_corr,
153                    transmission,
154                    uncertainty,
155                    isotopes,
156                    abundances,
157                    n_total,
158                    temperature_k,
159                    &valid,
160                    resolution,
161                );
162                if chi2 < best_chi2 {
163                    best_chi2 = chi2;
164                    best_l = l;
165                    best_t0_us = t0;
166                    best_n = n_total;
167                }
168            }
169        }
170    }
171
172    // ── Phase 2: Fine grid search around coarse best ────────────────
173    // L: ±0.05%, 0.01% steps
174    // t₀: ±2 µs, 0.25 µs steps
175    // n: ±50%, 5% steps
176
177    let l_fine: Vec<f64> = (-5..=5)
178        .map(|i| best_l * (1.0 + i as f64 * 0.0001))
179        .collect();
180    let t0_fine: Vec<f64> = (-8..=8).map(|i| best_t0_us + i as f64 * 0.25).collect();
181    let n_fine: Vec<f64> = (-10..=10)
182        .map(|i| best_n * (1.0 + i as f64 * 0.05))
183        .filter(|&n| n > 0.0)
184        .collect();
185
186    for &l in &l_fine {
187        for &t0 in &t0_fine {
188            let t0_s = t0 * 1e-6;
189            let e_corr: Vec<f64> = tof_s
190                .iter()
191                .map(|&t| {
192                    let t_corr = t - t0_s;
193                    if t_corr <= 0.0 {
194                        f64::NAN
195                    } else {
196                        NEUTRON_MASS_CONSTANT * (l / t_corr).powi(2)
197                    }
198                })
199                .collect();
200            if e_corr.iter().any(|e| !e.is_finite() || *e <= 0.0) {
201                continue;
202            }
203            for &n_total in &n_fine {
204                let chi2 = compute_chi2(
205                    &e_corr,
206                    transmission,
207                    uncertainty,
208                    isotopes,
209                    abundances,
210                    n_total,
211                    temperature_k,
212                    &valid,
213                    resolution,
214                );
215                if chi2 < best_chi2 {
216                    best_chi2 = chi2;
217                    best_l = l;
218                    best_t0_us = t0;
219                    best_n = n_total;
220                }
221            }
222        }
223    }
224
225    // ── Phase 3: Ultra-fine refinement ──────────────────────────────
226    // L: ±0.005%, 0.001% steps
227    // t₀: ±0.5 µs, 0.05 µs steps
228    // n: ±10%, 1% steps
229
230    let l_ultra: Vec<f64> = (-5..=5)
231        .map(|i| best_l * (1.0 + i as f64 * 0.00001))
232        .collect();
233    let t0_ultra: Vec<f64> = (-10..=10).map(|i| best_t0_us + i as f64 * 0.05).collect();
234    let n_ultra: Vec<f64> = (-10..=10)
235        .map(|i| best_n * (1.0 + i as f64 * 0.01))
236        .filter(|&n| n > 0.0)
237        .collect();
238
239    for &l in &l_ultra {
240        for &t0 in &t0_ultra {
241            let t0_s = t0 * 1e-6;
242            let e_corr: Vec<f64> = tof_s
243                .iter()
244                .map(|&t| {
245                    let t_corr = t - t0_s;
246                    if t_corr <= 0.0 {
247                        f64::NAN
248                    } else {
249                        NEUTRON_MASS_CONSTANT * (l / t_corr).powi(2)
250                    }
251                })
252                .collect();
253            if e_corr.iter().any(|e| !e.is_finite() || *e <= 0.0) {
254                continue;
255            }
256            for &n_total in &n_ultra {
257                let chi2 = compute_chi2(
258                    &e_corr,
259                    transmission,
260                    uncertainty,
261                    isotopes,
262                    abundances,
263                    n_total,
264                    temperature_k,
265                    &valid,
266                    resolution,
267                );
268                if chi2 < best_chi2 {
269                    best_chi2 = chi2;
270                    best_l = l;
271                    best_t0_us = t0;
272                    best_n = n_total;
273                }
274            }
275        }
276    }
277
278    // Compute corrected energy grid at the best parameters
279    let t0_best_s = best_t0_us * 1e-6;
280    let energies_corrected: Vec<f64> = tof_s
281        .iter()
282        .map(|&t| NEUTRON_MASS_CONSTANT * (best_l / (t - t0_best_s)).powi(2))
283        .collect();
284
285    // Final chi2r (reduced)
286    let n_valid = valid.iter().filter(|&&v| v).count();
287    let dof = if n_valid > 3 { n_valid - 3 } else { 1 }; // 3 fitted params
288    let chi2r = best_chi2 / dof as f64;
289
290    Ok(CalibrationResult {
291        flight_path_m: best_l,
292        t0_us: best_t0_us,
293        total_density: best_n,
294        reduced_chi_squared: chi2r,
295        energies_corrected,
296    })
297}
298
299/// Compute total chi² for a given (E_corrected, n_total) against measured data.
300#[allow(clippy::too_many_arguments)]
301fn compute_chi2(
302    energies: &[f64],
303    transmission: &[f64],
304    uncertainty: &[f64],
305    isotopes: &[ResonanceData],
306    abundances: &[f64],
307    n_total: f64,
308    temperature_k: f64,
309    valid: &[bool],
310    resolution: Option<&InstrumentParams>,
311) -> f64 {
312    // Build (isotope, density) pairs
313    let pairs: Vec<(ResonanceData, f64)> = isotopes
314        .iter()
315        .zip(abundances.iter())
316        .map(|(iso, &abd)| (iso.clone(), abd * n_total))
317        .collect();
318
319    let sample = match SampleParams::new(temperature_k, pairs) {
320        Ok(s) => s,
321        Err(_) => return f64::INFINITY,
322    };
323
324    // P-5: Include resolution broadening when available.
325    // Without it, fitted L and t₀ absorb the missing broadening bias.
326    let model = match transmission::forward_model(energies, &sample, resolution) {
327        Ok(m) => m,
328        Err(_) => return f64::INFINITY,
329    };
330
331    // Chi²
332    let mut chi2 = 0.0;
333    for (i, (&t_data, &t_model)) in transmission.iter().zip(model.iter()).enumerate() {
334        if !valid[i] {
335            continue;
336        }
337        let residual = (t_data - t_model) / uncertainty[i];
338        chi2 += residual * residual;
339    }
340    chi2
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    #[ignore = "requires network: downloads Hf-178 ENDF from IAEA"]
349    fn test_calibrate_round_trip() {
350        // Generate synthetic data with known L and t0, then recover them.
351        let true_l = 25.08;
352        let true_t0_us = 1.0;
353        let true_n = 1.5e-4;
354        let temperature_k = 293.6;
355
356        // Use Hf-178 as a single-isotope reference (strong resonance at ~7.8 eV)
357        let iso = {
358            use nereids_core::types::Isotope;
359            use nereids_endf::parser::parse_endf_file2;
360            use nereids_endf::retrieval::{EndfLibrary, EndfRetriever, mat_number};
361
362            let isotope = Isotope::new(72, 178).unwrap();
363            let mat = mat_number(&isotope).expect("No MAT number for Hf-178");
364            let retriever = EndfRetriever::new();
365            let (_path, contents) = retriever
366                .get_endf_file(&isotope, EndfLibrary::EndfB8_0, mat)
367                .expect("Failed to retrieve Hf-178");
368            parse_endf_file2(&contents).expect("Failed to parse Hf-178")
369        };
370
371        // Create nominal energy grid (as if L=25.0, t0=0)
372        let assumed_l = 25.0;
373        let e_nominal: Vec<f64> = (0..500)
374            .map(|i| 5.0 + i as f64 * 0.4) // 5 to 205 eV
375            .collect();
376
377        // Recover TOF from nominal E at assumed L
378        let tof_s: Vec<f64> = e_nominal
379            .iter()
380            .map(|&e| assumed_l * (NEUTRON_MASS_CONSTANT / e).sqrt())
381            .collect();
382
383        // Compute "true" energies using true L and t0
384        let true_t0_s = true_t0_us * 1e-6;
385        let e_true: Vec<f64> = tof_s
386            .iter()
387            .map(|&t| NEUTRON_MASS_CONSTANT * (true_l / (t - true_t0_s)).powi(2))
388            .collect();
389
390        // Generate synthetic transmission at true energies
391        let sample = SampleParams::new(temperature_k, vec![(iso.clone(), true_n)])
392            .expect("SampleParams creation failed");
393        let t_model =
394            transmission::forward_model(&e_true, &sample, None).expect("forward_model failed");
395
396        // Add tiny noise (sigma = 0.01, no actual noise — just for chi2 weighting)
397        let sigma = vec![0.01; e_nominal.len()];
398
399        // Calibrate (no resolution — matches synthetic data generated without resolution)
400        let result = calibrate_energy(
401            &e_nominal,
402            &t_model,
403            &sigma,
404            &[iso],
405            &[1.0], // single isotope, abundance = 1.0
406            assumed_l,
407            temperature_k,
408            None,
409        )
410        .expect("Calibration failed");
411
412        // Check recovery
413        assert!(
414            (result.flight_path_m - true_l).abs() < 0.05,
415            "L: got {}, expected {}",
416            result.flight_path_m,
417            true_l,
418        );
419        assert!(
420            (result.t0_us - true_t0_us).abs() < 1.0,
421            "t0: got {}, expected {}",
422            result.t0_us,
423            true_t0_us,
424        );
425        assert!(
426            (result.total_density - true_n).abs() / true_n < 0.2,
427            "n: got {}, expected {}",
428            result.total_density,
429            true_n,
430        );
431        assert!(
432            result.reduced_chi_squared < 1.0,
433            "chi2r should be < 1 for synthetic data, got {}",
434            result.reduced_chi_squared,
435        );
436    }
437}