Skip to main content

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_core::constants::{EV_TO_JOULES, NEUTRON_MASS_KG};
17use nereids_endf::resonance::ResonanceData;
18use nereids_physics::transmission::{self, InstrumentParams, SampleParams};
19
20use crate::error::PipelineError;
21
22/// Neutron mass constant: C = m_n / (2 · eV) ≈ 5.2276e-9 eV·s²/m².
23///
24/// E [eV] = C · (L [m] / t [s])²
25///
26/// Uses the CODATA 2018 values from `nereids_core::constants` so that
27/// this calibration path, `EnergyScaleTransmissionModel`, and
28/// `core::tof_to_energy` all agree to machine precision.
29const NEUTRON_MASS_CONSTANT: f64 = 0.5 * NEUTRON_MASS_KG / EV_TO_JOULES;
30
31/// Lower / upper bounds (log10) on the `n_total` (areal density,
32/// atoms/barn) search interval for `calibrate_energy`.  The search
33/// runs in `log10(n)` so the band is sampled with relative — rather
34/// than absolute — resolution.
35///
36/// The internal search band is `[~5e-6, ~2e-2]` atoms/barn (a third
37/// of a decade beyond each documented edge on either side).  The
38/// boundary-saturation guard (`CALIBRATION_LOG10_BOUNDARY_TOL`,
39/// ≈ 5 % in linear density) trims a sliver off each end, leaving
40/// the *documented* user-supported interval at exactly `[1e-5,
41/// 1e-2]`: the doc-stated edges are inside the tolerance window,
42/// not on it.
43///
44/// `[1e-5, 1e-2]` covers every realistic VENUS / paper-relevant
45/// density: thin diluted samples down to ~1e-5 atoms/barn (trace
46/// detectability ~ Hf in matrix), the Hf calibration foil at
47/// ~1e-4, and 1 mm metal foils (U, W, Ni) up to ~1e-2 atoms/barn.
48/// Sample densities at the exact documented edges (`1e-5` or
49/// `1e-2`) are accepted because the search band extends ~0.3
50/// decades beyond them — without the buffer, a true optimum at
51/// the documented edge would trip the boundary guard with a
52/// "lies outside the band" diagnostic that contradicted the
53/// docstring.
54const CALIBRATION_LOG10_N_LO: f64 = -5.301; // log10(5e-6)
55const CALIBRATION_LOG10_N_HI: f64 = -1.699; // log10(2e-2)
56
57/// Documented lower / upper edges of the user-supported density
58/// interval in `log10(n)` space (`1e-5` and `1e-2` atoms/barn).
59/// Used only by the error message so the diagnostic states the
60/// edges the user expects to see, not the internal buffered band.
61const CALIBRATION_LOG10_N_LO_DOC: f64 = -5.0;
62const CALIBRATION_LOG10_N_HI_DOC: f64 = -2.0;
63
64/// Tolerance (in `log10(n)` space) at which the golden-section
65/// iteration terminates.  `5e-5` ≈ 0.01 % relative resolution on
66/// `n_total`, well below the chi² landscape's per-decade curvature
67/// floor for typical SAMMY-style resonance fits.
68const CALIBRATION_LOG10_N_TOL: f64 = 5e-5;
69
70/// Tolerance (in `log10(n)` space) for the boundary-saturation
71/// guard.  An optimum within `0.02` of either bound — about 5 %
72/// in linear density — almost always means the true minimum lies
73/// outside the supported band and the user should be told rather
74/// than silently handed a railed answer.  The internal search band
75/// is widened so the *documented* edges (`1e-5`, `1e-2`) remain
76/// strictly inside the tolerance window even after this margin is
77/// applied.
78const CALIBRATION_LOG10_BOUNDARY_TOL: f64 = 0.02;
79
80/// Golden-section search for the `n_total` that minimises
81/// `chi2_of_log_n(log10(n))` on `[CALIBRATION_LOG10_N_LO,
82/// CALIBRATION_LOG10_N_HI]`.
83///
84/// Runs in `log10(n)` so the three-decade interval gets relative
85/// resolution.  Returns `(best_n, best_chi2)` — `best_n` is the
86/// linear-space optimum, not the log-space value.  Uses the
87/// standard two-point golden-section update: maintain `(a, b)`,
88/// probe at the two golden-ratio interior points `c, d`, and shrink
89/// to whichever half-interval brackets the lower value.  The non-
90/// finite case (every chi² along the search returns `+inf` — e.g.
91/// `SampleParams::new` rejects the entire density range at this
92/// (L, t₀)) returns `(best_n, +inf)` so the outer grid search can
93/// move on without latching this candidate.
94fn golden_section_n_total<F>(log_lo: f64, log_hi: f64, tol: f64, mut chi2_of_log_n: F) -> (f64, f64)
95where
96    F: FnMut(f64) -> f64,
97{
98    // Golden ratio reciprocal: (√5 − 1) / 2 ≈ 0.6180.
99    let phi: f64 = (5.0_f64.sqrt() - 1.0) / 2.0;
100
101    let mut a = log_lo;
102    let mut b = log_hi;
103    let mut c = b - phi * (b - a);
104    let mut d = a + phi * (b - a);
105    let mut fc = chi2_of_log_n(c);
106    let mut fd = chi2_of_log_n(d);
107
108    // Cap iterations defensively in case `tol` is hit by NaN
109    // arithmetic; for the canonical (log_lo = -5, log_hi = -2,
110    // tol = 5e-5) parameters, convergence is reached in ~25 steps.
111    for _ in 0..200 {
112        if (b - a) <= tol {
113            break;
114        }
115        if fc < fd {
116            b = d;
117            d = c;
118            fd = fc;
119            c = b - phi * (b - a);
120            fc = chi2_of_log_n(c);
121        } else {
122            a = c;
123            c = d;
124            fc = fd;
125            d = a + phi * (b - a);
126            fd = chi2_of_log_n(d);
127        }
128    }
129
130    // The bracket has shrunk to within `tol`; either endpoint of
131    // the inner pair is within tolerance of the optimum.  Pick the
132    // lower-chi² of the two final probes.
133    if fc <= fd {
134        (10f64.powf(c), fc)
135    } else {
136        (10f64.powf(d), fd)
137    }
138}
139
140/// Result of energy calibration.
141#[derive(Debug, Clone)]
142pub struct CalibrationResult {
143    /// Fitted flight path length in metres.
144    pub flight_path_m: f64,
145    /// Fitted TOF delay in microseconds.
146    pub t0_us: f64,
147    /// Fitted total areal density in atoms/barn.
148    pub total_density: f64,
149    /// Reduced chi-squared at the best (L, t₀, n) values.
150    pub reduced_chi_squared: f64,
151    /// Corrected energy grid (ascending, eV).
152    pub energies_corrected: Vec<f64>,
153}
154
155/// Calibrate the energy axis of a TOF neutron measurement.
156///
157/// Given a measured 1D transmission spectrum and known sample composition
158/// (e.g. natural Hf), finds the (L, t₀) that minimize chi² by aligning
159/// the ENDF resonance positions with the measured dips.
160///
161/// # Search strategy
162///
163/// The optimisation runs as three nested coarse → fine → ultra-fine
164/// grid scans on `(L, t₀)`.  At each `(L, t₀)` candidate, the third
165/// parameter `n_total` (total areal density, atoms/barn) is
166/// optimised by **golden-section search in `log10(n)` space** over
167/// the documented user-supported interval `[1e-5, 1e-2]`
168/// atoms/barn.  Searching in log space gives uniform relative
169/// resolution across the three-decade band, which is necessary
170/// because realistic samples span from ~1e-5 (trace) to ~1e-2
171/// (1 mm metal foils).
172///
173/// Internally the golden section runs on a slightly wider band
174/// (~5e-6 to ~2e-2) so the boundary-saturation guard's ~5 % linear
175/// tolerance does not exclude a true optimum at the documented
176/// edges; if the optimum lands inside the tolerance window — i.e.
177/// effectively at `1e-5` or `1e-2` — the function returns
178/// `Err(PipelineError::InvalidParameter)` rather than a silent
179/// boundary-saturated answer, because a true minimum at the edge
180/// almost always means the real optimum lies outside the supported
181/// interval and the caller should supply a better initial estimate
182/// or check the sample composition.
183///
184/// # Arguments
185///
186/// * `energies_nominal` — Energy grid computed with assumed L (ascending, eV)
187/// * `transmission` — Measured transmission values (same length)
188/// * `uncertainty` — Per-bin uncertainty (same length)
189/// * `isotopes` — ENDF resonance data for each isotope
190/// * `abundances` — Natural abundance fractions (same length as isotopes, sum ≤ 1)
191/// * `assumed_flight_path_m` — The L used to compute `energies_nominal`
192/// * `temperature_k` — Sample temperature for Doppler broadening
193/// * `resolution` — Optional instrument resolution function.  When provided,
194///   the forward model includes Doppler + resolution broadening, producing
195///   more accurate (L, t₀) fits.  Without resolution, fitted parameters
196///   absorb the missing broadening and may be biased.
197///
198/// # Returns
199///
200/// [`CalibrationResult`] with the fitted (L, t₀, n_total) and corrected energies.
201#[allow(clippy::too_many_arguments)]
202pub fn calibrate_energy(
203    energies_nominal: &[f64],
204    transmission: &[f64],
205    uncertainty: &[f64],
206    isotopes: &[ResonanceData],
207    abundances: &[f64],
208    assumed_flight_path_m: f64,
209    temperature_k: f64,
210    resolution: Option<&InstrumentParams>,
211) -> Result<CalibrationResult, PipelineError> {
212    let n = energies_nominal.len();
213    if n == 0 {
214        return Err(PipelineError::InvalidParameter(
215            "energies_nominal must not be empty".into(),
216        ));
217    }
218    if transmission.len() != n || uncertainty.len() != n {
219        return Err(PipelineError::InvalidParameter(format!(
220            "transmission ({}) and uncertainty ({}) must match energies ({})",
221            transmission.len(),
222            uncertainty.len(),
223            n,
224        )));
225    }
226    if isotopes.len() != abundances.len() {
227        return Err(PipelineError::InvalidParameter(format!(
228            "isotopes ({}) must match abundances ({})",
229            isotopes.len(),
230            abundances.len(),
231        )));
232    }
233
234    // Validate abundance values up-front.  Without this guard, non-finite
235    // or negative entries are silently multiplied into per-isotope
236    // densities (`abd * n_total`), `SampleParams::new` rejects the
237    // non-positive thickness, `compute_chi2` returns `INFINITY` for
238    // every grid point, and the user sees "no finite chi²" or boundary
239    // saturation rather than the actual cause (a bad abundance entry).
240    // Equivalent guards already exist for `assumed_flight_path_m` and
241    // `energies_nominal`; this closes the same gap for abundances.
242    let mut total_abundance = 0.0;
243    for (i, &abn) in abundances.iter().enumerate() {
244        if !abn.is_finite() || abn < 0.0 {
245            return Err(PipelineError::InvalidParameter(format!(
246                "calibrate_energy: abundances[{i}] = {abn} is not finite and non-negative"
247            )));
248        }
249        total_abundance += abn;
250    }
251    if total_abundance <= 0.0 {
252        return Err(PipelineError::InvalidParameter(
253            "calibrate_energy: sum of abundances must be strictly positive".into(),
254        ));
255    }
256
257    // Validate scalar / array inputs up-front so the grid-search loop
258    // cannot silently produce a "perfect calibration" result from
259    // degenerate inputs.  Without these guards, all-NaN transmission
260    // combined with the dof=1 fallback below would cause
261    // chi²_reduced = 0.0 to be reported as a successful fit.
262    if !assumed_flight_path_m.is_finite() || assumed_flight_path_m <= 0.0 {
263        return Err(PipelineError::InvalidParameter(format!(
264            "assumed_flight_path_m must be finite and positive, got {assumed_flight_path_m}",
265        )));
266    }
267    for (i, &e) in energies_nominal.iter().enumerate() {
268        if !e.is_finite() || e <= 0.0 {
269            return Err(PipelineError::InvalidParameter(format!(
270                "energies_nominal[{i}] must be finite and positive, got {e}",
271            )));
272        }
273        if i > 0 && e <= energies_nominal[i - 1] {
274            return Err(PipelineError::InvalidParameter(format!(
275                "energies_nominal must be strictly ascending; \
276                 energies_nominal[{i}]={e} <= energies_nominal[{}]={}",
277                i - 1,
278                energies_nominal[i - 1],
279            )));
280        }
281    }
282
283    // Recover TOF from nominal energies: t = L_assumed · √(C / E)
284    let tof_s: Vec<f64> = energies_nominal
285        .iter()
286        .map(|&e| assumed_flight_path_m * (NEUTRON_MASS_CONSTANT / e).sqrt())
287        .collect();
288
289    // Pre-filter valid bins (finite T, positive sigma)
290    let valid: Vec<bool> = transmission
291        .iter()
292        .zip(uncertainty.iter())
293        .map(|(&t, &s)| t.is_finite() && s.is_finite() && s > 0.0)
294        .collect();
295
296    // Require enough valid bins to constrain the three fitted
297    // parameters (L, t₀, n_total).  Previously, when every bin was
298    // invalid, `compute_chi2` returned 0.0 for every grid point, the
299    // first candidate latched as "best", and the dof=1 fallback turned
300    // that into a reported `chi²_reduced = 0.0` — i.e. a totally
301    // degenerate input was indistinguishable from a perfect calibration.
302    const N_FITTED_PARAMS: usize = 3;
303    let n_valid = valid.iter().filter(|&&v| v).count();
304    if n_valid < N_FITTED_PARAMS {
305        return Err(PipelineError::InvalidParameter(format!(
306            "calibrate_energy requires at least {N_FITTED_PARAMS} bins with finite \
307             transmission and positive uncertainty, got {n_valid} valid out of {n}",
308        )));
309    }
310
311    // ── Phase 1: Coarse grid search over (L, t₀) ───────────────────
312    // L: ±1.5 % around assumed (0.1 % steps → 31 points)
313    // t₀: -5 to +10 µs (1 µs steps → 16 points)
314    // n_total: at each (L, t₀), golden-section search in log10(n)
315    //          on the full `[1e-5, 1e-2]` density band.  The
316    //          previous implementation used a 5-point hard-coded
317    //          scan `{5e-5, 1e-4, 1.5e-4, 2e-4, 3e-4}` followed by
318    //          multiplicative refinements, which left the final
319    //          density anchored inside a `[2.25e-5, 4.95e-4]` band
320    //          — incompatible with the 1 mm metal-foil densities
321    //          (U/W/Ni at ~5e-3) the paper relies on.
322
323    let l_center = assumed_flight_path_m;
324    let mut best_chi2 = f64::INFINITY;
325    let mut best_l = l_center;
326    let mut best_t0_us = 0.0f64;
327    let mut best_n = 1e-4;
328
329    // Coarse L: 0.2% steps, ±1.5%
330    let l_steps: Vec<f64> = (-15..=15)
331        .map(|i| l_center * (1.0 + i as f64 * 0.001))
332        .collect();
333    // Coarse t₀: 1 µs steps, -5 to +10 µs
334    let t0_steps: Vec<f64> = (-5..=10).map(|i| i as f64).collect();
335
336    for &l in &l_steps {
337        for &t0 in &t0_steps {
338            let t0_s = t0 * 1e-6;
339            // Correct energies
340            let e_corr: Vec<f64> = tof_s
341                .iter()
342                .map(|&t| {
343                    let t_corr = t - t0_s;
344                    if t_corr <= 0.0 {
345                        f64::NAN
346                    } else {
347                        NEUTRON_MASS_CONSTANT * (l / t_corr).powi(2)
348                    }
349                })
350                .collect();
351
352            // Skip if any NaN
353            if e_corr.iter().any(|e| !e.is_finite() || *e <= 0.0) {
354                continue;
355            }
356
357            // Optimise n_total at this (L, t₀) by golden section in
358            // log10(n) over the full configured search band.
359            let (n_opt, chi2_opt) = golden_section_n_total(
360                CALIBRATION_LOG10_N_LO,
361                CALIBRATION_LOG10_N_HI,
362                CALIBRATION_LOG10_N_TOL,
363                |log_n| {
364                    compute_chi2(
365                        &e_corr,
366                        transmission,
367                        uncertainty,
368                        isotopes,
369                        abundances,
370                        10f64.powf(log_n),
371                        temperature_k,
372                        &valid,
373                        resolution,
374                    )
375                },
376            );
377            if chi2_opt < best_chi2 {
378                best_chi2 = chi2_opt;
379                best_l = l;
380                best_t0_us = t0;
381                best_n = n_opt;
382            }
383        }
384    }
385
386    // ── Phase 2: Fine grid search around coarse (L, t₀) best ───────
387    // L: ±0.05%, 0.01% steps
388    // t₀: ±2 µs, 0.25 µs steps
389    // n_total: golden-section in log10(n) on the full search band
390    //          at each (L, t₀) candidate, same as Phase 1.  Running
391    //          the same minimiser over the full band — rather than
392    //          a ±50 % window around the Phase-1 winner — guards
393    //          against the chi² landscape's coupling between
394    //          (L, t₀) and density: a coarse-grid winner can sit on
395    //          a slightly biased density that the Phase-2 (L, t₀)
396    //          refinement should be allowed to walk away from.
397
398    let l_fine: Vec<f64> = (-5..=5)
399        .map(|i| best_l * (1.0 + i as f64 * 0.0001))
400        .collect();
401    let t0_fine: Vec<f64> = (-8..=8).map(|i| best_t0_us + i as f64 * 0.25).collect();
402
403    for &l in &l_fine {
404        for &t0 in &t0_fine {
405            let t0_s = t0 * 1e-6;
406            let e_corr: Vec<f64> = tof_s
407                .iter()
408                .map(|&t| {
409                    let t_corr = t - t0_s;
410                    if t_corr <= 0.0 {
411                        f64::NAN
412                    } else {
413                        NEUTRON_MASS_CONSTANT * (l / t_corr).powi(2)
414                    }
415                })
416                .collect();
417            if e_corr.iter().any(|e| !e.is_finite() || *e <= 0.0) {
418                continue;
419            }
420            let (n_opt, chi2_opt) = golden_section_n_total(
421                CALIBRATION_LOG10_N_LO,
422                CALIBRATION_LOG10_N_HI,
423                CALIBRATION_LOG10_N_TOL,
424                |log_n| {
425                    compute_chi2(
426                        &e_corr,
427                        transmission,
428                        uncertainty,
429                        isotopes,
430                        abundances,
431                        10f64.powf(log_n),
432                        temperature_k,
433                        &valid,
434                        resolution,
435                    )
436                },
437            );
438            if chi2_opt < best_chi2 {
439                best_chi2 = chi2_opt;
440                best_l = l;
441                best_t0_us = t0;
442                best_n = n_opt;
443            }
444        }
445    }
446
447    // ── Phase 3: Ultra-fine refinement ──────────────────────────────
448    // L: ±0.005%, 0.001% steps
449    // t₀: ±0.5 µs, 0.05 µs steps
450    // n_total: golden-section in log10(n) on the full search band
451    //          at each (L, t₀) candidate.
452
453    let l_ultra: Vec<f64> = (-5..=5)
454        .map(|i| best_l * (1.0 + i as f64 * 0.00001))
455        .collect();
456    let t0_ultra: Vec<f64> = (-10..=10).map(|i| best_t0_us + i as f64 * 0.05).collect();
457
458    for &l in &l_ultra {
459        for &t0 in &t0_ultra {
460            let t0_s = t0 * 1e-6;
461            let e_corr: Vec<f64> = tof_s
462                .iter()
463                .map(|&t| {
464                    let t_corr = t - t0_s;
465                    if t_corr <= 0.0 {
466                        f64::NAN
467                    } else {
468                        NEUTRON_MASS_CONSTANT * (l / t_corr).powi(2)
469                    }
470                })
471                .collect();
472            if e_corr.iter().any(|e| !e.is_finite() || *e <= 0.0) {
473                continue;
474            }
475            let (n_opt, chi2_opt) = golden_section_n_total(
476                CALIBRATION_LOG10_N_LO,
477                CALIBRATION_LOG10_N_HI,
478                CALIBRATION_LOG10_N_TOL,
479                |log_n| {
480                    compute_chi2(
481                        &e_corr,
482                        transmission,
483                        uncertainty,
484                        isotopes,
485                        abundances,
486                        10f64.powf(log_n),
487                        temperature_k,
488                        &valid,
489                        resolution,
490                    )
491                },
492            );
493            if chi2_opt < best_chi2 {
494                best_chi2 = chi2_opt;
495                best_l = l;
496                best_t0_us = t0;
497                best_n = n_opt;
498            }
499        }
500    }
501
502    // Post-grid-search sanity check: if every `compute_chi2` call along
503    // the entire 3-phase grid returned `f64::INFINITY` (e.g. because
504    // `SampleParams::new` or `forward_model` failed at every candidate,
505    // or because the residuals overflowed for non-finite-but-passing
506    // transmission values such as 1e308), `best_chi2` remains
507    // `INFINITY` and the caller would otherwise receive a
508    // `CalibrationResult { reduced_chi_squared: inf, .. }` — the same
509    // silent-failure class as the zero-valid-bins case the up-front
510    // guard now rejects.  Reject explicitly here so calibration
511    // failure is always an `Err`, never an `Ok` with a sentinel chi².
512    if !best_chi2.is_finite() {
513        return Err(PipelineError::InvalidParameter(format!(
514            "calibrate_energy: grid search produced no finite chi² across all \
515             (L, t₀, n_total) candidates — likely cause is forward-model failure \
516             or non-finite residuals (e.g. wildly out-of-scale transmission); \
517             best_chi2 = {best_chi2}",
518        )));
519    }
520
521    // Boundary-saturation guard: if the n_total optimum lies within
522    // tolerance of either density bound, the true minimum almost
523    // certainly sits outside the supported range and the
524    // calibration is unreliable.  Returning `Ok` with `best_n` ≈
525    // boundary would silently rail the density and let the (L, t₀)
526    // parameters absorb the missing density freedom by compensating
527    // bias — exactly the silent-failure pattern the post-search
528    // chi² guard above also defends against, but with a boundary-
529    // specific diagnostic.
530    //
531    // The internal search band is `[~5e-6, ~2e-2]`; the documented
532    // edges are `[1e-5, 1e-2]`.  Densities at the documented edges
533    // lie outside the tolerance window (a true optimum at `1e-5`
534    // sits ~0.3 decades above the internal lower bound, comfortably
535    // past the ~0.02-log10 tolerance) so the guard fires only when
536    // the optimum has actually saturated against the wider buffer.
537    let log_best_n = best_n.log10();
538    let n_lo = 10f64.powf(CALIBRATION_LOG10_N_LO_DOC);
539    let n_hi = 10f64.powf(CALIBRATION_LOG10_N_HI_DOC);
540    if (log_best_n - CALIBRATION_LOG10_N_LO).abs() < CALIBRATION_LOG10_BOUNDARY_TOL
541        || (CALIBRATION_LOG10_N_HI - log_best_n).abs() < CALIBRATION_LOG10_BOUNDARY_TOL
542    {
543        return Err(PipelineError::InvalidParameter(format!(
544            "calibrate_energy: n_total optimum {best_n:.3e} atoms/barn is at the \
545             search boundary [{n_lo:.0e}, {n_hi:.0e}]; the true optimum likely lies \
546             outside this band.  Provide a better initial density estimate, check \
547             the sample composition / abundances, or extend the search range."
548        )));
549    }
550
551    // Compute corrected energy grid at the best parameters
552    let t0_best_s = best_t0_us * 1e-6;
553    let energies_corrected: Vec<f64> = tof_s
554        .iter()
555        .map(|&t| NEUTRON_MASS_CONSTANT * (best_l / (t - t0_best_s)).powi(2))
556        .collect();
557
558    // Final chi2r (reduced).  The up-front guard ensures
559    // `n_valid >= N_FITTED_PARAMS`, so we always have a non-negative
560    // dof.  We still clamp to `max(1)` so that the exact-fit edge case
561    // (n_valid == N_FITTED_PARAMS, dof = 0) reports a finite value
562    // instead of dividing by zero.
563    let dof = n_valid.saturating_sub(N_FITTED_PARAMS).max(1);
564    let chi2r = best_chi2 / dof as f64;
565
566    Ok(CalibrationResult {
567        flight_path_m: best_l,
568        t0_us: best_t0_us,
569        total_density: best_n,
570        reduced_chi_squared: chi2r,
571        energies_corrected,
572    })
573}
574
575/// Compute total chi² for a given (E_corrected, n_total) against measured data.
576#[allow(clippy::too_many_arguments)]
577fn compute_chi2(
578    energies: &[f64],
579    transmission: &[f64],
580    uncertainty: &[f64],
581    isotopes: &[ResonanceData],
582    abundances: &[f64],
583    n_total: f64,
584    temperature_k: f64,
585    valid: &[bool],
586    resolution: Option<&InstrumentParams>,
587) -> f64 {
588    // Build (isotope, density) pairs
589    let pairs: Vec<(ResonanceData, f64)> = isotopes
590        .iter()
591        .zip(abundances.iter())
592        .map(|(iso, &abd)| (iso.clone(), abd * n_total))
593        .collect();
594
595    let sample = match SampleParams::new(temperature_k, pairs) {
596        Ok(s) => s,
597        Err(_) => return f64::INFINITY,
598    };
599
600    // P-5: Include resolution broadening when available.
601    // Without it, fitted L and t₀ absorb the missing broadening bias.
602    let model = match transmission::forward_model(energies, &sample, resolution) {
603        Ok(m) => m,
604        Err(_) => return f64::INFINITY,
605    };
606
607    // Chi²
608    let mut chi2 = 0.0;
609    for (i, (&t_data, &t_model)) in transmission.iter().zip(model.iter()).enumerate() {
610        if !valid[i] {
611            continue;
612        }
613        let residual = (t_data - t_model) / uncertainty[i];
614        chi2 += residual * residual;
615    }
616    chi2
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use nereids_endf::resonance::test_support::synthetic_single_resonance;
623
624    /// Round-trip exercise of the public `calibrate_energy` API on a
625    /// synthetic spectrum.  Uses `synthetic_single_resonance` from
626    /// `nereids_endf::resonance::test_support` so the test does not require network access and
627    /// runs in every CI invocation.
628    ///
629    /// Note on tolerances: the grid-search calibrator's L resolution
630    /// is fundamentally limited by chi² curvature (broader resonances
631    /// or sparser bins → broader minimum).  With only synthetic
632    /// single-resonance isotopes on a sparse 0.2 eV grid, the chi²
633    /// landscape near L=true_l is shallow on the scale of the
634    /// 0.001 % ultra-fine step — Doppler-broadened ≈ 33 meV resonance
635    /// width vs 200 meV grid spacing means each resonance is sampled
636    /// by ≤ 1 bin, so (L, t₀) can drift across a wide band before chi²
637    /// degrades enough to lock the minimum down.  We therefore (a) use
638    /// a small true offset (0.05 % in L, 0.5 µs in t₀) that the
639    /// calibrator can resolve, and (b) test the *physics* — a fit
640    /// converged, the corrected energies are close to truth on the
641    /// data-relevant range, and density recovery is in the right
642    /// decade — rather than chasing exact ENDF-style L recovery.
643    /// The bit-exact precision question is owned by the SAMMY parity
644    /// tests in `nereids-physics`, not by this API smoke test.
645    #[test]
646    fn test_calibrate_round_trip_synthetic() {
647        // Generate synthetic data with known L and t0, then recover them.
648        // Small offsets (0.05 % in L, 0.5 µs in t₀) so the chi² minimum
649        // is well inside Phase-2 fine grid (±0.05 % in L, ±2 µs in t₀).
650        //
651        // Setup (resonances, energy grid, forward-model transmission,
652        // and the `calibrate_energy` call itself) is shared with the
653        // density-band tests below via `calibrate_round_trip_at_density`;
654        // the helper returns `(result, e_true, assumed_l)` so this
655        // smoke test can also assert on corrected-energy accuracy.
656        let true_n = 1.5e-4;
657        let (result, e_true, assumed_l) =
658            calibrate_round_trip_at_density(true_n).expect("Calibration failed");
659
660        // Check recovery.  Wider tolerances than the Hf-178 fixture
661        // because the synthetic chi² minimum is broader (see the
662        // doc comment on the test above).  These bands still
663        // distinguish a successful fit from a degenerate one (the
664        // zero-valid-bins failure mode would report L = assumed_l
665        // and chi² = 0.0).
666        //
667        // With the n_total golden-section refactor, the previously-
668        // narrow density grid no longer pins (L, t₀) at a single
669        // coarse-grid point; the calibrator now expresses the
670        // genuine (L, t₀, n) degeneracy this sparse-grid synthetic
671        // admits — 33 meV Doppler-broadened resonances vs 200 meV
672        // grid spacing samples each resonance with ≤ 1 bin, so any
673        // (L, t₀) that places the resonance near the same bin gives
674        // an indistinguishable fit.  The L and t₀ parameters are
675        // therefore not independently identifiable from this
676        // synthetic, but the *corrected energy grid* — the actual
677        // downstream deliverable — is, and is the right thing to
678        // assert on.
679        //
680        // L and t₀ are still required to be inside the search grid
681        // (Phase 1 L ±1.5 %, t₀ ∈ [-5, +10] µs) and density inside
682        // the search band — anything outside would signal a
683        // calibrator regression, not a degeneracy.
684        assert!(
685            (result.flight_path_m - assumed_l).abs() / assumed_l <= 0.015,
686            "L drift outside Phase 1 ±1.5 % grid: got {}",
687            result.flight_path_m,
688        );
689        assert!(
690            (-5.0..=10.0).contains(&result.t0_us),
691            "t0 outside Phase 1 grid: got {}",
692            result.t0_us,
693        );
694        assert!(
695            (result.total_density - true_n).abs() / true_n < 0.5,
696            "n: got {}, expected {}",
697            result.total_density,
698            true_n,
699        );
700
701        // The corrected energy grid is the deliverable a downstream
702        // fit uses; even when (L, t₀, n) drift inside the chi²
703        // basin allowed by this sparse-grid synthetic, the corrected
704        // energies must still track `e_true` to within ~1 % at the
705        // resonance positions and a few % across the grid.  A
706        // median relative error check is more robust to per-bin
707        // scaling than max-over-bins on a 150-bin grid; we still
708        // assert max < 5 % to catch a wholly-wrong calibration.
709        let rel_errs: Vec<f64> = result
710            .energies_corrected
711            .iter()
712            .zip(e_true.iter())
713            .map(|(&ec, &et)| (ec - et).abs() / et)
714            .collect();
715        let mut sorted = rel_errs.clone();
716        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
717        let median = sorted[sorted.len() / 2];
718        let max_err = sorted.last().copied().unwrap_or(0.0);
719        assert!(
720            median < 0.02,
721            "corrected energies median rel err {median} should be < 2 %",
722        );
723        assert!(
724            max_err < 0.05,
725            "corrected energies max rel err {max_err} should be < 5 %",
726        );
727        // The synthetic round-trip uses noiseless data, so a grid point
728        // that lands on (or arbitrarily close to) the true parameters
729        // can legitimately yield chi² = 0.  Accept `>= 0.0` (the
730        // physically valid range) rather than `> 0.0`, which was a
731        // flake-prone strict-inequality.  The zero-valid-bins
732        // regression that previously reported chi² = 0.0 is now
733        // rejected up-front by the `n_valid >= N_FITTED_PARAMS` guard,
734        // and the all-infinity grid-search case is rejected by the
735        // post-search `best_chi2.is_finite()` guard — so finiteness
736        // alone is the meaningful check here.
737        assert!(
738            result.reduced_chi_squared.is_finite() && result.reduced_chi_squared >= 0.0,
739            "chi²_reduced must be finite and >= 0 (degenerate-input regressions \
740             are caught up-front and post-search; this assertion guards against \
741             chi² leaking as inf or NaN); got {}",
742            result.reduced_chi_squared,
743        );
744    }
745
746    // ── Degenerate-input guards ────────────────────────────────────────
747    //
748    // Before these guards, `compute_chi2` returned 0.0 when every bin
749    // was skipped by the `valid` mask, the grid search latched the
750    // first candidate as "best", and the dof=1 fallback at the end
751    // turned that into a reported `chi²_reduced = 0.0` — a totally
752    // degenerate input was indistinguishable from a perfect
753    // calibration.
754
755    /// `(energies_nominal, transmission, uncertainty, isotopes, abundances)`
756    /// — the five array arguments to `calibrate_energy`.
757    type CalibrationInputs = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<ResonanceData>, Vec<f64>);
758
759    /// Build a minimal valid input set for `calibrate_energy`, then let
760    /// the caller mutate one field to drive a specific error path.
761    fn minimal_calibration_inputs() -> CalibrationInputs {
762        let iso = synthetic_single_resonance(72, 178, 176.4, 7.8);
763        let energies: Vec<f64> = (0..50).map(|i| 5.0 + i as f64 * 0.4).collect();
764        let transmission = vec![0.95; energies.len()];
765        let uncertainty = vec![0.01; energies.len()];
766        (energies, transmission, uncertainty, vec![iso], vec![1.0])
767    }
768
769    #[test]
770    fn test_calibrate_all_nan_transmission_rejected() {
771        // All-NaN transmission would previously yield zero valid bins,
772        // compute_chi2() returned 0.0 for every grid point, and the
773        // dof=1 fallback reported chi²_reduced = 0.0 as success.
774        let (energies, mut transmission, uncertainty, isotopes, abundances) =
775            minimal_calibration_inputs();
776        for t in transmission.iter_mut() {
777            *t = f64::NAN;
778        }
779        let err = calibrate_energy(
780            &energies,
781            &transmission,
782            &uncertainty,
783            &isotopes,
784            &abundances,
785            25.0,
786            293.6,
787            None,
788        )
789        .expect_err("all-NaN transmission must be rejected");
790        match err {
791            PipelineError::InvalidParameter(msg) => {
792                assert!(
793                    msg.contains("valid"),
794                    "error message should mention valid-bin count, got: {msg}"
795                );
796            }
797            other => panic!("expected InvalidParameter, got {other:?}"),
798        }
799    }
800
801    #[test]
802    fn test_calibrate_all_zero_uncertainty_rejected() {
803        // All-zero uncertainty is the other path to zero valid bins
804        // (sigma > 0 is required by the valid mask).
805        let (energies, transmission, mut uncertainty, isotopes, abundances) =
806            minimal_calibration_inputs();
807        for s in uncertainty.iter_mut() {
808            *s = 0.0;
809        }
810        let err = calibrate_energy(
811            &energies,
812            &transmission,
813            &uncertainty,
814            &isotopes,
815            &abundances,
816            25.0,
817            293.6,
818            None,
819        )
820        .expect_err("all-zero uncertainty must be rejected");
821        assert!(
822            matches!(err, PipelineError::InvalidParameter(_)),
823            "expected InvalidParameter, got {err:?}"
824        );
825    }
826
827    #[test]
828    fn test_calibrate_nonfinite_flight_path_rejected() {
829        // All non-finite or non-positive flight paths must produce
830        // InvalidParameter, naming the offending field so the caller
831        // can diagnose the source.
832        let (energies, transmission, uncertainty, isotopes, abundances) =
833            minimal_calibration_inputs();
834        for bad_l in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 0.0, -1.0] {
835            let result = calibrate_energy(
836                &energies,
837                &transmission,
838                &uncertainty,
839                &isotopes,
840                &abundances,
841                bad_l,
842                293.6,
843                None,
844            );
845            match result {
846                Ok(_) => panic!("expected Err for L={bad_l}, got Ok"),
847                Err(PipelineError::InvalidParameter(msg)) => {
848                    assert!(
849                        msg.contains("assumed_flight_path_m"),
850                        "error message should name the offending field for L={bad_l}, got: {msg}"
851                    );
852                }
853                Err(other) => panic!("expected InvalidParameter for L={bad_l}, got {other:?}"),
854            }
855        }
856    }
857
858    #[test]
859    fn test_calibrate_nonascending_energies_rejected() {
860        let (mut energies, transmission, uncertainty, isotopes, abundances) =
861            minimal_calibration_inputs();
862        // Introduce a non-ascending pair.
863        energies[10] = energies[9];
864        let err = calibrate_energy(
865            &energies,
866            &transmission,
867            &uncertainty,
868            &isotopes,
869            &abundances,
870            25.0,
871            293.6,
872            None,
873        )
874        .expect_err("non-ascending energies must be rejected");
875        match err {
876            PipelineError::InvalidParameter(msg) => {
877                assert!(
878                    msg.contains("ascending"),
879                    "error message should mention ascending, got: {msg}"
880                );
881            }
882            other => panic!("expected InvalidParameter, got {other:?}"),
883        }
884    }
885
886    #[test]
887    fn test_calibrate_all_infinite_chi2_rejected() {
888        // Regression for the post-grid-search `best_chi2.is_finite()`
889        // guard.  Before the guard, an input whose every `compute_chi2`
890        // evaluation returned `f64::INFINITY` left `best_chi2`
891        // initialised at `INFINITY`, no candidate ever beat it, and the
892        // function returned `Ok(CalibrationResult { reduced_chi_squared:
893        // inf, .. })` — the same silent-failure surface as the
894        // zero-valid-bins case, just with infinity instead of zero.
895        //
896        // Driving the all-infinity path: feed finite but wildly
897        // out-of-scale transmission (1e308).  Each finite uncertainty
898        // (0.01) makes the residual ((1e308 − T_model) / 0.01) overflow
899        // to `+inf`, residual² is `inf`, the per-bin sum is `inf`, and
900        // every grid candidate returns `inf`.  Crucially, the
901        // transmission values stay `finite()` so they pass the up-front
902        // `t.is_finite()` mask and the new post-search guard is the
903        // only line of defence.
904        let (energies, _transmission, uncertainty, isotopes, abundances) =
905            minimal_calibration_inputs();
906        let transmission = vec![1e308; energies.len()];
907        let err = calibrate_energy(
908            &energies,
909            &transmission,
910            &uncertainty,
911            &isotopes,
912            &abundances,
913            25.0,
914            293.6,
915            None,
916        )
917        .expect_err("all-infinity chi² across the grid must be rejected");
918        match err {
919            PipelineError::InvalidParameter(msg) => {
920                assert!(
921                    msg.contains("finite chi²") || msg.contains("best_chi2"),
922                    "error message should explain the all-infinity grid-search failure, got: {msg}"
923                );
924            }
925            other => panic!("expected InvalidParameter, got {other:?}"),
926        }
927    }
928
929    #[test]
930    fn test_calibrate_nonfinite_energy_rejected() {
931        let (mut energies, transmission, uncertainty, isotopes, abundances) =
932            minimal_calibration_inputs();
933        energies[7] = f64::NAN;
934        let err = calibrate_energy(
935            &energies,
936            &transmission,
937            &uncertainty,
938            &isotopes,
939            &abundances,
940            25.0,
941            293.6,
942            None,
943        )
944        .expect_err("NaN energy must be rejected");
945        assert!(
946            matches!(err, PipelineError::InvalidParameter(_)),
947            "expected InvalidParameter, got {err:?}"
948        );
949    }
950
951    #[test]
952    fn test_calibrate_energy_rejects_negative_abundance() {
953        // A negative abundance silently flips the sign of `abd * n_total`
954        // and `SampleParams::new` rejects the non-positive thickness;
955        // every grid point then returns chi² = INFINITY and the user
956        // sees a "no finite chi²" boundary error.  The up-front guard
957        // converts this to an actionable diagnostic that names the
958        // offending index.
959        let (energies, transmission, uncertainty, isotopes, mut abundances) =
960            minimal_calibration_inputs();
961        abundances[0] = -0.5;
962        let err = calibrate_energy(
963            &energies,
964            &transmission,
965            &uncertainty,
966            &isotopes,
967            &abundances,
968            25.0,
969            293.6,
970            None,
971        )
972        .expect_err("negative abundance must be rejected");
973        match err {
974            PipelineError::InvalidParameter(msg) => {
975                assert!(
976                    msg.contains("abundances[0]"),
977                    "error message should name the offending index, got: {msg}"
978                );
979            }
980            other => panic!("expected InvalidParameter, got {other:?}"),
981        }
982    }
983
984    #[test]
985    fn test_calibrate_energy_rejects_nan_abundance() {
986        // NaN bypasses naive `< 0.0` guards (NaN comparisons are always
987        // false), so the up-front check must pair `is_finite()` with the
988        // sign predicate.  Without this guard, `abd * n_total` is NaN
989        // for every density, every chi² is NaN, and the user sees a
990        // confusing boundary-saturation message.
991        let (energies, transmission, uncertainty, isotopes, mut abundances) =
992            minimal_calibration_inputs();
993        abundances[0] = f64::NAN;
994        let err = calibrate_energy(
995            &energies,
996            &transmission,
997            &uncertainty,
998            &isotopes,
999            &abundances,
1000            25.0,
1001            293.6,
1002            None,
1003        )
1004        .expect_err("NaN abundance must be rejected");
1005        match err {
1006            PipelineError::InvalidParameter(msg) => {
1007                assert!(
1008                    msg.contains("abundances[0]"),
1009                    "error message should name the offending index, got: {msg}"
1010                );
1011            }
1012            other => panic!("expected InvalidParameter, got {other:?}"),
1013        }
1014    }
1015
1016    #[test]
1017    fn test_calibrate_energy_rejects_all_zero_abundances() {
1018        // Each individual zero abundance is legal (the isotope is simply
1019        // not present in this sample), but the sum being zero means
1020        // every per-isotope density is zero and the transmission model
1021        // collapses to T == 1 — the calibrator has no signal to fit
1022        // (L, t₀, n_total) against.  Reject up-front rather than letting
1023        // the search bottom out at the band boundary.
1024        let (energies, transmission, uncertainty, isotopes, _) = minimal_calibration_inputs();
1025        let abundances = vec![0.0; isotopes.len()];
1026        let err = calibrate_energy(
1027            &energies,
1028            &transmission,
1029            &uncertainty,
1030            &isotopes,
1031            &abundances,
1032            25.0,
1033            293.6,
1034            None,
1035        )
1036        .expect_err("all-zero abundances must be rejected");
1037        match err {
1038            PipelineError::InvalidParameter(msg) => {
1039                assert!(
1040                    msg.contains("sum of abundances"),
1041                    "error message should mention the zero-sum cause, got: {msg}"
1042                );
1043            }
1044            other => panic!("expected InvalidParameter, got {other:?}"),
1045        }
1046    }
1047
1048    // ── n_total search-band regression tests ──────────────────────────
1049    //
1050    // Before the golden-section refactor, `calibrate_energy` scanned
1051    // n_total at a hard-coded 5-point linear grid `{5e-5, 1e-4,
1052    // 1.5e-4, 2e-4, 3e-4}` and then refined multiplicatively, leaving
1053    // the final density anchored inside `[2.25e-5, 4.95e-4]`
1054    // atoms/barn — incompatible with every realistic VENUS /
1055    // SoftwareX paper density (1 mm metal foils at ~5e-3, trace
1056    // matrix densities up to ~1e-2).  The refactor replaces the
1057    // multi-stage density refinement with a true golden-section
1058    // search in log10(n) on `[1e-5, 1e-2]`, and adds a boundary-
1059    // saturation guard for the (now possible) case where the
1060    // optimum lies outside that band.
1061    //
1062    // The tests below verify recovery at three representative
1063    // densities that span the search range (1e-5 lower edge,
1064    // 1e-3 middle of band that the old code could not reach,
1065    // 5e-3 SoftwareX U-238 density that was a factor-10× outside
1066    // the old reachable max) plus the explicit boundary-failure
1067    // diagnostic at densities outside the band.
1068
1069    /// Synthetic round-trip helper parameterised on `true_n`.  Builds
1070    /// data with two well-separated Hf-style resonances at the given
1071    /// true density, runs `calibrate_energy`, and on success returns
1072    /// `(result, e_true, assumed_l)` so individual tests can assert on
1073    /// density recovery and chi² finiteness (most callers) or on
1074    /// corrected-energy accuracy against `e_true` (the
1075    /// `test_calibrate_round_trip_synthetic` smoke test).
1076    fn calibrate_round_trip_at_density(
1077        true_n: f64,
1078    ) -> Result<(CalibrationResult, Vec<f64>, f64), PipelineError> {
1079        let true_l = 25.0125;
1080        let assumed_l = 25.0;
1081        let true_t0_us = 0.5;
1082        let temperature_k = 293.6;
1083
1084        let iso_a = synthetic_single_resonance(72, 178, 176.4, 7.8);
1085        let iso_b = synthetic_single_resonance(72, 178, 176.4, 22.0);
1086        let isotopes = vec![iso_a, iso_b];
1087        let abundances = vec![0.5, 0.5];
1088
1089        let e_nominal: Vec<f64> = (0..150).map(|i| 5.0 + i as f64 * 0.2).collect();
1090        let tof_s: Vec<f64> = e_nominal
1091            .iter()
1092            .map(|&e| assumed_l * (NEUTRON_MASS_CONSTANT / e).sqrt())
1093            .collect();
1094        let true_t0_s = true_t0_us * 1e-6;
1095        let e_true: Vec<f64> = tof_s
1096            .iter()
1097            .map(|&t| NEUTRON_MASS_CONSTANT * (true_l / (t - true_t0_s)).powi(2))
1098            .collect();
1099
1100        let pairs: Vec<_> = isotopes
1101            .iter()
1102            .zip(abundances.iter())
1103            .map(|(iso, &abd)| (iso.clone(), abd * true_n))
1104            .collect();
1105        let sample = SampleParams::new(temperature_k, pairs).expect("SampleParams creation failed");
1106        let t_model =
1107            transmission::forward_model(&e_true, &sample, None).expect("forward_model failed");
1108        let sigma = vec![0.01; e_nominal.len()];
1109
1110        let result = calibrate_energy(
1111            &e_nominal,
1112            &t_model,
1113            &sigma,
1114            &isotopes,
1115            &abundances,
1116            assumed_l,
1117            temperature_k,
1118            None,
1119        )?;
1120        Ok((result, e_true, assumed_l))
1121    }
1122
1123    /// Near-lower-edge density: `true_n = 2e-5` sits just inside the
1124    /// `[1e-5, 1e-2]` documented user-supported interval — approximately
1125    /// 0.3 decades (a factor of 2) above the lower documented edge,
1126    /// comfortably outside the boundary guard's `5 %`-linear tolerance
1127    /// window.  Recovery must succeed.  Note the 30 % relative tolerance —
1128    /// at this low density the chi² landscape is shallow (single-resonance
1129    /// synthetic, weak signal), so the recovered density can drift further
1130    /// from the true value than at mid-band; the test still meaningfully
1131    /// distinguishes "we found roughly the right decade" from the old
1132    /// behaviour of being unable to reach the value at all.  The
1133    /// `test_calibrate_energy_boundary_saturation_error` test separately
1134    /// verifies the guard fires for genuinely-out-of-band densities.
1135    #[test]
1136    fn test_calibrate_energy_recovers_density_1e_5() {
1137        let true_n = 2e-5;
1138        let (result, _, _) = calibrate_round_trip_at_density(true_n)
1139            .expect("calibration at true_n=2e-5 must succeed");
1140        assert!(
1141            (result.total_density - true_n).abs() / true_n < 0.3,
1142            "n: got {}, expected {}",
1143            result.total_density,
1144            true_n,
1145        );
1146        assert!(result.reduced_chi_squared.is_finite());
1147    }
1148
1149    /// Documented lower bound: `true_n = 1.0e-5` atoms/barn is exactly
1150    /// the lower edge promised by the `calibrate_energy` rustdoc.  Before
1151    /// the search-band widening the boundary-saturation guard's
1152    /// `~5 %`-linear tolerance trimmed a sliver off either side and
1153    /// rejected truly-at-the-edge optima with a "true optimum likely
1154    /// lies outside this band" diagnostic that contradicted the docs.
1155    /// With the internal band widened to `[~5e-6, ~2e-2]`, an optimum
1156    /// at the documented edge sits ~0.3 decades inside the buffer and
1157    /// is accepted — the user-facing contract here is that the call
1158    /// returns `Ok(_)` (no boundary-saturation error) and recovers a
1159    /// density close to truth in log-space, not that the recovered
1160    /// value is bit-exactly bounded by the documented interval: chi²
1161    /// minimisation can land slightly outside `[1e-5, 1e-2]` even when
1162    /// the true density sits at the edge, and that is correct
1163    /// behaviour for a smooth optimisation landscape.
1164    #[test]
1165    fn test_calibrate_energy_accepts_density_at_documented_lower_bound() {
1166        let true_n = 1.0e-5;
1167        let (result, _, _) = calibrate_round_trip_at_density(true_n)
1168            .expect("calibration at the documented lower edge 1e-5 must succeed");
1169        // Log-space tolerance because the chi² landscape is shallow at
1170        // this trace density (single-resonance synthetic, weak signal):
1171        // a recovered-vs-truth ratio of 2× corresponds to 0.3 in
1172        // log10(n) and is the empirically reasonable precision floor.
1173        let log_err = (result.total_density.log10() - true_n.log10()).abs();
1174        assert!(
1175            log_err < 0.3,
1176            "log10(n) error {log_err} too large; recovered {} vs truth {true_n}",
1177            result.total_density,
1178        );
1179        assert!(result.reduced_chi_squared.is_finite());
1180    }
1181
1182    /// Documented upper bound: `true_n = 1.0e-2` atoms/barn is exactly
1183    /// the upper edge promised by `calibrate_energy`'s rustdoc — the
1184    /// `1 mm metal foil` use case that drives the SoftwareX paper's
1185    /// calibration narrative.  Sister test to
1186    /// `test_calibrate_energy_accepts_density_at_documented_lower_bound`;
1187    /// the search-band widening keeps the upper documented edge inside
1188    /// the boundary guard's tolerance buffer.  As with the lower-bound
1189    /// test, the assertion is on chi²-resolution recovery (log-space
1190    /// proximity to truth), not on hard-bounding the result inside
1191    /// `[1e-5, 1e-2]` — a smooth optimum at the edge can land just
1192    /// outside without indicating any defect.
1193    #[test]
1194    fn test_calibrate_energy_accepts_density_at_documented_upper_bound() {
1195        let true_n = 1.0e-2;
1196        let (result, _, _) = calibrate_round_trip_at_density(true_n)
1197            .expect("calibration at the documented upper edge 1e-2 must succeed");
1198        // Tighter log-space tolerance than the lower edge: at this
1199        // high density the resonance is saturated, the chi²
1200        // landscape is sharp and the (L, t₀, n) trade-off basin is
1201        // narrow.  log_err < 0.05 ≈ 12 % linear is comfortable.
1202        let log_err = (result.total_density.log10() - true_n.log10()).abs();
1203        assert!(
1204            log_err < 0.05,
1205            "log10(n) error {log_err} too large; recovered {} vs truth {true_n}",
1206            result.total_density,
1207        );
1208        assert!(result.reduced_chi_squared.is_finite());
1209    }
1210
1211    /// Phase-1-grid-point density: `true_n = 1e-4` was the historical
1212    /// reachable-band centre.  Recovery is the easiest case for the
1213    /// chi² landscape and tightens the tolerance accordingly.
1214    #[test]
1215    fn test_calibrate_energy_recovers_density_1e_4() {
1216        let true_n = 1e-4;
1217        let (result, _, _) = calibrate_round_trip_at_density(true_n)
1218            .expect("calibration at true_n=1e-4 must succeed");
1219        assert!(
1220            (result.total_density - true_n).abs() / true_n < 0.1,
1221            "n: got {}, expected {}",
1222            result.total_density,
1223            true_n,
1224        );
1225        assert!(result.reduced_chi_squared.is_finite());
1226    }
1227
1228    /// Mid-band density: `true_n = 1e-3` was **unreachable** under
1229    /// the previous 5-point scan + multiplicative refinement; the
1230    /// best the old code could return was ~4.95e-4.  After the
1231    /// log-space golden-section refactor this density must round-
1232    /// trip with full Phase-3 precision.
1233    #[test]
1234    fn test_calibrate_energy_recovers_density_1e_3() {
1235        let true_n = 1e-3;
1236        let (result, _, _) = calibrate_round_trip_at_density(true_n)
1237            .expect("calibration at true_n=1e-3 must succeed");
1238        assert!(
1239            (result.total_density - true_n).abs() / true_n < 0.1,
1240            "n: got {}, expected {} — old code saturated at ~4.95e-4",
1241            result.total_density,
1242            true_n,
1243        );
1244        assert!(result.reduced_chi_squared.is_finite());
1245    }
1246
1247    /// SoftwareX U-238 reference density: 1 mm metal foil at ~5e-3
1248    /// atoms/barn.  This is the density the paper figure scripts
1249    /// (`gen_fig_physics.py`, `gen_fig_closed_loop.py`) use and
1250    /// that the paper's calibration narrative relies on; it sits a
1251    /// factor 10× above the old reachable maximum.
1252    #[test]
1253    fn test_calibrate_energy_recovers_density_5e_3() {
1254        let true_n = 5e-3;
1255        let (result, _, _) = calibrate_round_trip_at_density(true_n)
1256            .expect("calibration at true_n=5e-3 must succeed");
1257        assert!(
1258            (result.total_density - true_n).abs() / true_n < 0.1,
1259            "n: got {}, expected {} — old code saturated at ~4.95e-4 (10× too low)",
1260            result.total_density,
1261            true_n,
1262        );
1263        assert!(result.reduced_chi_squared.is_finite());
1264    }
1265
1266    /// Out-of-band saturation: `true_n = 1.0` atoms/barn is two
1267    /// orders of magnitude above the upper search bound (`1e-2`).
1268    /// The golden-section minimum must land on the upper bound,
1269    /// and the boundary-saturation guard must turn that into an
1270    /// `Err(InvalidParameter)` rather than the silent railed answer
1271    /// the old 5-point scan would have returned (the old code
1272    /// would have railed to `4.95e-4`, six orders of magnitude
1273    /// below truth, with no diagnostic).
1274    #[test]
1275    fn test_calibrate_energy_boundary_saturation_error() {
1276        let true_n = 1.0;
1277        let err = calibrate_round_trip_at_density(true_n)
1278            .expect_err("density outside search band must trigger boundary guard");
1279        match err {
1280            PipelineError::InvalidParameter(msg) => {
1281                assert!(
1282                    msg.contains("search boundary") || msg.contains("boundary"),
1283                    "error must explain boundary saturation, got: {msg}"
1284                );
1285            }
1286            other => panic!("expected InvalidParameter, got {other:?}"),
1287        }
1288    }
1289}