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
16#[cfg(test)]
17use nereids_core::constants::{EV_TO_JOULES, NEUTRON_MASS_KG};
18use nereids_core::types::IsotopeGroup;
19use nereids_endf::resonance::ResonanceData;
20use nereids_fitting::lm::LmConfig;
21use nereids_fitting::resolution_calib::corrected_energy_grid;
22use nereids_physics::resolution::TOF_FACTOR;
23use nereids_physics::transmission::{self, InstrumentParams, SampleParams};
24
25use crate::error::PipelineError;
26use crate::pipeline::{
27 InputData, SolverConfig, SpectrumFitResult, UnifiedFitConfig, fit_spectrum_typed,
28};
29
30/// Neutron mass constant: C = m_n / (2 · eV) ≈ 5.2276e-9 eV·s²/m².
31///
32/// E [eV] = C · (L [m] / t [s])²
33///
34/// Uses the CODATA 2018 values from `nereids_core::constants` so that
35/// this calibration path, `EnergyScaleTransmissionModel`, and
36/// `core::tof_to_energy` all agree to machine precision. Production
37/// code now routes the TOF↔energy transform through
38/// `resolution_calib::corrected_energy_grid` (issue #634); this const
39/// remains for the test fixtures that synthesize ground-truth grids.
40#[cfg(test)]
41const NEUTRON_MASS_CONSTANT: f64 = 0.5 * NEUTRON_MASS_KG / EV_TO_JOULES;
42
43/// Lower / upper bounds (log10) on the `n_total` (areal density,
44/// atoms/barn) search interval for `calibrate_energy`. The search
45/// runs in `log10(n)` so the band is sampled with relative — rather
46/// than absolute — resolution.
47///
48/// The internal search band is `[~5e-6, ~2e-2]` atoms/barn (a third
49/// of a decade beyond each documented edge on either side). The
50/// boundary-saturation guard (`CALIBRATION_LOG10_BOUNDARY_TOL`,
51/// ≈ 5 % in linear density) trims a sliver off each end, leaving
52/// the *documented* user-supported interval at exactly `[1e-5,
53/// 1e-2]`: the doc-stated edges are inside the tolerance window,
54/// not on it.
55///
56/// `[1e-5, 1e-2]` covers every realistic VENUS / paper-relevant
57/// density: thin diluted samples down to ~1e-5 atoms/barn (trace
58/// detectability ~ Hf in matrix), the Hf calibration foil at
59/// ~1e-4, and 1 mm metal foils (U, W, Ni) up to ~1e-2 atoms/barn.
60/// Sample densities at the exact documented edges (`1e-5` or
61/// `1e-2`) are accepted because the search band extends ~0.3
62/// decades beyond them — without the buffer, a true optimum at
63/// the documented edge would trip the boundary guard with a
64/// "lies outside the band" diagnostic that contradicted the
65/// docstring.
66const CALIBRATION_LOG10_N_LO: f64 = -5.301; // log10(5e-6)
67const CALIBRATION_LOG10_N_HI: f64 = -1.699; // log10(2e-2)
68
69/// Documented lower / upper edges of the user-supported density
70/// interval in `log10(n)` space (`1e-5` and `1e-2` atoms/barn).
71/// Used only by the error message so the diagnostic states the
72/// edges the user expects to see, not the internal buffered band.
73const CALIBRATION_LOG10_N_LO_DOC: f64 = -5.0;
74const CALIBRATION_LOG10_N_HI_DOC: f64 = -2.0;
75
76/// Tolerance (in `log10(n)` space) at which the golden-section
77/// iteration terminates. `5e-5` ≈ 0.01 % relative resolution on
78/// `n_total`, well below the chi² landscape's per-decade curvature
79/// floor for typical SAMMY-style resonance fits.
80const CALIBRATION_LOG10_N_TOL: f64 = 5e-5;
81
82/// Tolerance (in `log10(n)` space) for the boundary-saturation
83/// guard. An optimum within `0.02` of either bound — about 5 %
84/// in linear density — almost always means the true minimum lies
85/// outside the supported band and the user should be told rather
86/// than silently handed a railed answer. The internal search band
87/// is widened so the *documented* edges (`1e-5`, `1e-2`) remain
88/// strictly inside the tolerance window even after this margin is
89/// applied.
90const CALIBRATION_LOG10_BOUNDARY_TOL: f64 = 0.02;
91
92/// Golden-section search for the `n_total` that minimises
93/// `chi2_of_log_n(log10(n))` on `[CALIBRATION_LOG10_N_LO,
94/// CALIBRATION_LOG10_N_HI]`.
95///
96/// Runs in `log10(n)` so the three-decade interval gets relative
97/// resolution. Returns `(best_n, best_chi2)` — `best_n` is the
98/// linear-space optimum, not the log-space value. Uses the
99/// standard two-point golden-section update: maintain `(a, b)`,
100/// probe at the two golden-ratio interior points `c, d`, and shrink
101/// to whichever half-interval brackets the lower value. The non-
102/// finite case (every chi² along the search returns `+inf` — e.g.
103/// `SampleParams::new` rejects the entire density range at this
104/// (L, t₀)) returns `(best_n, +inf)` so the outer grid search can
105/// move on without latching this candidate.
106fn golden_section_n_total<F>(log_lo: f64, log_hi: f64, tol: f64, mut chi2_of_log_n: F) -> (f64, f64)
107where
108 F: FnMut(f64) -> f64,
109{
110 // Golden ratio reciprocal: (√5 − 1) / 2 ≈ 0.6180.
111 let phi: f64 = (5.0_f64.sqrt() - 1.0) / 2.0;
112
113 let mut a = log_lo;
114 let mut b = log_hi;
115 let mut c = b - phi * (b - a);
116 let mut d = a + phi * (b - a);
117 let mut fc = chi2_of_log_n(c);
118 let mut fd = chi2_of_log_n(d);
119
120 // Cap iterations defensively in case `tol` is hit by NaN
121 // arithmetic; for the canonical (log_lo = -5, log_hi = -2,
122 // tol = 5e-5) parameters, convergence is reached in ~25 steps.
123 for _ in 0..200 {
124 if (b - a) <= tol {
125 break;
126 }
127 if fc < fd {
128 b = d;
129 d = c;
130 fd = fc;
131 c = b - phi * (b - a);
132 fc = chi2_of_log_n(c);
133 } else {
134 a = c;
135 c = d;
136 fc = fd;
137 d = a + phi * (b - a);
138 fd = chi2_of_log_n(d);
139 }
140 }
141
142 // The bracket has shrunk to within `tol`; either endpoint of
143 // the inner pair is within tolerance of the optimum. Pick the
144 // lower-chi² of the two final probes.
145 if fc <= fd {
146 (10f64.powf(c), fc)
147 } else {
148 (10f64.powf(d), fd)
149 }
150}
151
152/// Plateau-robust dip-position anchor (issue #634 review P0).
153///
154/// The dip POSITIONS carry the complete (t₀, L_scale) information: with the
155/// resonance energies known, matching each measured dip to its resonance and
156/// solving the 2-parameter affine TOF map `u_dip = t₀ + L_scale · u_res` by
157/// least squares recovers the energy scale directly — no grid search. This
158/// is the same idea as the fitters' peak-match seed, with one critical
159/// difference: dip positions are the depth-weighted centres of contiguous
160/// below-threshold RUNS, so saturated flat-bottomed dips (transmission ≈ 0
161/// over several bins — the SoftwareX foil regime), which fail the strict
162/// local-minimum test of `detect_transmission_dips`, are located robustly.
163///
164/// Used as one CANDIDATE for the stage-2 anchor (scored by the same exact
165/// golden-section density as the lattice candidates); the coarse lattice
166/// remains the fallback when fewer than two dips are detectable. Restricted
167/// to the plausible window (|t₀| ≤ 10 µs, L_scale ∈ [0.98, 1.02]) — wider
168/// offsets are out of this function's documented band.
169fn dip_match_anchor(
170 energies_nominal: &[f64],
171 transmission: &[f64],
172 valid: &[bool],
173 isotopes: &[ResonanceData],
174 abundances: &[f64],
175 assumed_flight_path_m: f64,
176) -> Option<(f64, f64)> {
177 let n = energies_nominal.len();
178 // Baseline = 90th percentile of valid transmission; depth threshold at
179 // 25 % of the maximum depth (matches the fitters' seed conventions).
180 let mut vals: Vec<f64> = transmission
181 .iter()
182 .zip(valid.iter())
183 .filter(|&(_, &v)| v)
184 .map(|(&t, _)| t)
185 .collect();
186 if vals.len() < 5 {
187 return None;
188 }
189 vals.sort_by(f64::total_cmp);
190 let baseline = vals[(vals.len() * 9) / 10];
191 let max_depth = baseline - vals[0];
192 if !(max_depth.is_finite() && max_depth > 1e-6) {
193 return None;
194 }
195 let threshold = baseline - 0.25 * max_depth;
196
197 // Depth-weighted centre of each contiguous below-threshold run.
198 let mut dips: Vec<f64> = Vec::new();
199 let mut i = 0;
200 while i < n {
201 if valid[i] && transmission[i] < threshold {
202 let mut wsum = 0.0_f64;
203 let mut ewsum = 0.0_f64;
204 while i < n && valid[i] && transmission[i] < threshold {
205 let w = (baseline - transmission[i]).max(0.0);
206 wsum += w;
207 ewsum += w * energies_nominal[i];
208 i += 1;
209 }
210 if wsum > 0.0 {
211 dips.push(ewsum / wsum);
212 }
213 } else {
214 i += 1;
215 }
216 }
217 if dips.len() < 2 {
218 return None;
219 }
220
221 // Resonance energies of contributing isotopes inside the window.
222 let e_lo = energies_nominal[0];
223 let e_hi = energies_nominal[n - 1];
224 let mut res_e: Vec<f64> = Vec::new();
225 for (rd, &abd) in isotopes.iter().zip(abundances.iter()) {
226 if abd <= 0.0 {
227 continue;
228 }
229 for range in &rd.ranges {
230 for lg in &range.l_groups {
231 for r in &lg.resonances {
232 if r.energy > e_lo && r.energy < e_hi {
233 res_e.push(r.energy);
234 }
235 }
236 }
237 }
238 }
239 res_e.sort_by(f64::total_cmp);
240 res_e.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
241 if res_e.len() < 2 {
242 return None;
243 }
244
245 // Match resonances to dips ONE-TO-ONE within half the minimum resonance
246 // spacing (floored at twice the grid resolution). Greedy assignment on
247 // globally ascending distance: matching each resonance to its nearest
248 // dip independently would let one dip serve several resonances,
249 // duplicating `u_dip` rows in the affine least squares below — which
250 // biases the fit and, in the two-resonance case, degenerates it toward
251 // `sxx → 0` (Copilot review, PR #644).
252 let min_spacing = res_e
253 .windows(2)
254 .map(|w| w[1] - w[0])
255 .fold(f64::INFINITY, f64::min);
256 let grid_res = (e_hi - e_lo) / (n as f64 - 1.0);
257 let tol = (0.5 * min_spacing).max(2.0 * grid_res);
258 let kl = TOF_FACTOR * assumed_flight_path_m;
259 let mut candidates: Vec<(f64, usize, usize)> = Vec::new(); // (dist, res idx, dip idx)
260 for (ri, &er) in res_e.iter().enumerate() {
261 for (di, &d) in dips.iter().enumerate() {
262 let dist = (d - er).abs();
263 if dist < tol {
264 candidates.push((dist, ri, di));
265 }
266 }
267 }
268 candidates.sort_by(|a, b| a.0.total_cmp(&b.0));
269 let mut res_used = vec![false; res_e.len()];
270 let mut dip_used = vec![false; dips.len()];
271 let mut pairs: Vec<(f64, f64)> = Vec::new(); // (u_dip, u_res) in µs
272 for &(_, ri, di) in &candidates {
273 if res_used[ri] || dip_used[di] {
274 continue;
275 }
276 res_used[ri] = true;
277 dip_used[di] = true;
278 pairs.push((kl / dips[di].sqrt(), kl / res_e[ri].sqrt()));
279 }
280 if pairs.len() < 2 {
281 return None;
282 }
283
284 // Least squares for u_dip = t0 + ls · u_res.
285 let m = pairs.len() as f64;
286 let su: f64 = pairs.iter().map(|p| p.0).sum();
287 let sv: f64 = pairs.iter().map(|p| p.1).sum();
288 let svv: f64 = pairs.iter().map(|p| p.1 * p.1).sum();
289 let suv: f64 = pairs.iter().map(|p| p.0 * p.1).sum();
290 let sxx = svv - sv * sv / m;
291 if !(sxx.is_finite() && sxx > 0.0) {
292 return None;
293 }
294 let ls = (suv - su * sv / m) / sxx;
295 let t0 = (su - ls * sv) / m;
296 if !(t0.is_finite() && ls.is_finite()) || t0.abs() > 10.0 || !(0.98..=1.02).contains(&ls) {
297 return None;
298 }
299 Some((t0, ls))
300}
301
302/// Result of energy calibration.
303#[derive(Debug, Clone)]
304pub struct CalibrationResult {
305 /// Fitted flight path length in metres.
306 pub flight_path_m: f64,
307 /// Fitted TOF delay in microseconds.
308 pub t0_us: f64,
309 /// Fitted total areal density in atoms/barn.
310 pub total_density: f64,
311 /// Reduced chi-squared at the best (L, t₀, n) values.
312 pub reduced_chi_squared: f64,
313 /// Corrected energy grid (ascending, eV).
314 pub energies_corrected: Vec<f64>,
315}
316
317/// Calibrate the energy axis of a TOF neutron measurement.
318///
319/// Given a measured 1D transmission spectrum and known sample composition
320/// (e.g. natural Hf), finds the (L, t₀) that minimize chi² by aligning
321/// the ENDF resonance positions with the measured dips.
322///
323/// # Search strategy
324///
325/// Issue #634: the former three-phase `(L, t₀)` grid scan — ~900 (L, t₀)
326/// candidates × a ~25-evaluation golden-section density search each,
327/// i.e. ~35 000 forward evaluations, >10 min on production windows — is
328/// replaced by a staged global-then-local search built on the
329/// **`fit_energy_scale` Levenberg–Marquardt path**:
330///
331/// 1. a coarse joint `(t₀, L_scale)` scan (7 × 7 candidates, each with an
332/// exact golden-section density in `log10(n)` over the documented
333/// `[1e-5, 1e-2]` atoms/barn band — the per-candidate density is what
334/// keeps the anchor JOINT, like the old grid);
335/// 2. a direct **dip-match anchor**: measured dip positions (plateau-robust
336/// depth-weighted run centres, so saturated flat-bottom dips locate
337/// correctly) matched to the known resonance energies and solved as an
338/// affine TOF map by least squares — the discriminating anchor along
339/// the `(t₀, L_scale)` degeneracy valley;
340/// 3. a fine joint pit-scan (11 × 11 at 0.25 µs / 0.05 % steps, narrow-band
341/// golden density) around EACH anchor — the compact descendant of the
342/// old Phase-2/3 grids, needed because the chi² landscape's sub-bin
343/// aliasing pits are ~±0.3 µs wide;
344/// 4. a multi-start descent from both fine anchors (per anchor: its own
345/// density plus log-spaced starts; per start: a direct joint LM AND an
346/// exact-density ↔ alignment-only-LM alternation), with every candidate
347/// scored by the original valid-bins chi² and the argmin returned.
348///
349/// Net cost is ~4× fewer forward evaluations than the old grid on
350/// production windows, and the LM refinement removes the old grid's
351/// resolution floor (0.001 % L, 0.05 µs t₀) — the optimum is continuous.
352/// The internal LM fits disable the fitters' peak-match seed
353/// (`with_energy_scale_seed(false)`): stages 1–3 already provide a
354/// stronger anchor, and the seed's strict-local-minimum dip detector
355/// mislocates saturated flat-bottom dips.
356///
357/// Each LM fit constrains `L_scale` to ±1 % (`ENERGY_SCALE_L_SCALE_*`);
358/// when a fit rails on that box (a larger true offset), the search
359/// re-anchors on the corrected grid and composes the affine TOF maps, so
360/// the documented ±1.5 % flight-path band remains covered.
361///
362/// The golden-section seed runs on a slightly wider band (~5e-6 to
363/// ~2e-2), and if the **fitted** density optimum saturates that band —
364/// effectively at `1e-5` or `1e-2`, or anywhere beyond — the function
365/// returns `Err(PipelineError::InvalidParameter)` rather than a silent
366/// boundary-saturated answer, because a true minimum at or past the edge
367/// almost always means the real optimum lies outside the supported
368/// interval and the caller should supply a better initial estimate
369/// or check the sample composition.
370///
371/// # Arguments
372///
373/// * `energies_nominal` — Energy grid computed with assumed L (ascending, eV)
374/// * `transmission` — Measured transmission values (same length)
375/// * `uncertainty` — Per-bin uncertainty (same length)
376/// * `isotopes` — ENDF resonance data for each isotope
377/// * `abundances` — Natural abundance fractions (same length as isotopes, sum ≤ 1)
378/// * `assumed_flight_path_m` — The L used to compute `energies_nominal`
379/// * `temperature_k` — Sample temperature for Doppler broadening
380/// * `resolution` — Optional instrument resolution function. When provided,
381/// the forward model includes Doppler + resolution broadening, producing
382/// more accurate (L, t₀) fits. Without resolution, fitted parameters
383/// absorb the missing broadening and may be biased.
384///
385/// # Returns
386///
387/// [`CalibrationResult`] with the fitted (L, t₀, n_total) and corrected energies.
388#[allow(clippy::too_many_arguments)]
389pub fn calibrate_energy(
390 energies_nominal: &[f64],
391 transmission: &[f64],
392 uncertainty: &[f64],
393 isotopes: &[ResonanceData],
394 abundances: &[f64],
395 assumed_flight_path_m: f64,
396 temperature_k: f64,
397 resolution: Option<&InstrumentParams>,
398) -> Result<CalibrationResult, PipelineError> {
399 let n = energies_nominal.len();
400 if n == 0 {
401 return Err(PipelineError::InvalidParameter(
402 "energies_nominal must not be empty".into(),
403 ));
404 }
405 if transmission.len() != n || uncertainty.len() != n {
406 return Err(PipelineError::InvalidParameter(format!(
407 "transmission ({}) and uncertainty ({}) must match energies ({})",
408 transmission.len(),
409 uncertainty.len(),
410 n,
411 )));
412 }
413 if isotopes.len() != abundances.len() {
414 return Err(PipelineError::InvalidParameter(format!(
415 "isotopes ({}) must match abundances ({})",
416 isotopes.len(),
417 abundances.len(),
418 )));
419 }
420
421 // Validate abundance values up-front. Without this guard, non-finite
422 // or negative entries are silently multiplied into per-isotope
423 // densities (`abd * n_total`), `SampleParams::new` rejects the
424 // non-positive thickness, `compute_chi2` returns `INFINITY` for
425 // every grid point, and the user sees "no finite chi²" or boundary
426 // saturation rather than the actual cause (a bad abundance entry).
427 // Equivalent guards already exist for `assumed_flight_path_m` and
428 // `energies_nominal`; this closes the same gap for abundances.
429 let mut total_abundance = 0.0;
430 for (i, &abn) in abundances.iter().enumerate() {
431 if !abn.is_finite() || abn < 0.0 {
432 return Err(PipelineError::InvalidParameter(format!(
433 "calibrate_energy: abundances[{i}] = {abn} is not finite and non-negative"
434 )));
435 }
436 total_abundance += abn;
437 }
438 if total_abundance <= 0.0 {
439 return Err(PipelineError::InvalidParameter(
440 "calibrate_energy: sum of abundances must be strictly positive".into(),
441 ));
442 }
443
444 // Validate scalar / array inputs up-front so the grid-search loop
445 // cannot silently produce a "perfect calibration" result from
446 // degenerate inputs. Without these guards, all-NaN transmission
447 // combined with the dof=1 fallback below would cause
448 // chi²_reduced = 0.0 to be reported as a successful fit.
449 if !assumed_flight_path_m.is_finite() || assumed_flight_path_m <= 0.0 {
450 return Err(PipelineError::InvalidParameter(format!(
451 "assumed_flight_path_m must be finite and positive, got {assumed_flight_path_m}",
452 )));
453 }
454 // Reject an invalid temperature at the entry (issue #634 review): the
455 // sibling `UnifiedFitConfig::new` validates it, and without this guard a
456 // NaN/negative temperature is only caught deep inside the repeated
457 // search/LM stages (or silently converted to an all-INFINITY chi²).
458 if !temperature_k.is_finite() || temperature_k < 0.0 {
459 return Err(PipelineError::InvalidParameter(format!(
460 "calibrate_energy: temperature_k must be finite and non-negative, \
461 got {temperature_k}",
462 )));
463 }
464 for (i, &e) in energies_nominal.iter().enumerate() {
465 if !e.is_finite() || e <= 0.0 {
466 return Err(PipelineError::InvalidParameter(format!(
467 "energies_nominal[{i}] must be finite and positive, got {e}",
468 )));
469 }
470 if i > 0 && e <= energies_nominal[i - 1] {
471 return Err(PipelineError::InvalidParameter(format!(
472 "energies_nominal must be strictly ascending; \
473 energies_nominal[{i}]={e} <= energies_nominal[{}]={}",
474 i - 1,
475 energies_nominal[i - 1],
476 )));
477 }
478 }
479
480 // Pre-filter valid bins (finite T, positive sigma)
481 let valid: Vec<bool> = transmission
482 .iter()
483 .zip(uncertainty.iter())
484 .map(|(&t, &s)| t.is_finite() && s.is_finite() && s > 0.0)
485 .collect();
486
487 // Require enough valid bins to constrain the three fitted
488 // parameters (L, t₀, n_total). Previously, when every bin was
489 // invalid, `compute_chi2` returned 0.0 for every grid point, the
490 // first candidate latched as "best", and the dof=1 fallback turned
491 // that into a reported `chi²_reduced = 0.0` — i.e. a totally
492 // degenerate input was indistinguishable from a perfect calibration.
493 const N_FITTED_PARAMS: usize = 3;
494 let n_valid = valid.iter().filter(|&&v| v).count();
495 if n_valid < N_FITTED_PARAMS {
496 return Err(PipelineError::InvalidParameter(format!(
497 "calibrate_energy requires at least {N_FITTED_PARAMS} bins with finite \
498 transmission and positive uncertainty, got {n_valid} valid out of {n}",
499 )));
500 }
501
502 // ── Neutralise invalid bins for the LM ──────────────────────────────
503 // The grid search masked invalid bins out of its chi² sum; the LM cost
504 // has no per-bin validity mask (its active-mask is an energy-window
505 // mask only), and a NaN transmission at an active bin poisons the
506 // normal equations. Replace invalid bins with a finite dummy and a
507 // huge σ (weight ~1e-60 — numerically zero) so the energy grid stays
508 // intact for resolution broadening while the bins carry no influence
509 // on the fit. The reported chi²_r below is still computed over VALID
510 // bins only, preserving the original `dof = n_valid − 3` contract.
511 let mut t_fit = transmission.to_vec();
512 let mut sigma_fit = uncertainty.to_vec();
513 for (i, &ok) in valid.iter().enumerate() {
514 if !ok {
515 t_fit[i] = 1.0;
516 sigma_fit[i] = 1.0e30;
517 }
518 }
519
520 // ── One fitted density parameter via an isotope group ───────────────
521 // The grid fitted a single n_total with per-isotope densities
522 // `abundance_i · n_total`. Reproduce that exactly with one group whose
523 // ratios are the normalised abundances: the fitted per-isotope density
524 // is `D · abundance_i / S`, so `n_total = D / S` (S = Σ abundances).
525 // Zero-abundance isotopes contribute nothing to the model (exactly as
526 // in `compute_chi2`) and `IsotopeGroup` rejects non-positive ratios,
527 // so they are dropped from the group.
528 let mut members = Vec::new();
529 let mut rd_list: Vec<ResonanceData> = Vec::new();
530 for (rd, &abd) in isotopes.iter().zip(abundances.iter()) {
531 if abd > 0.0 {
532 members.push((rd.isotope, abd / total_abundance));
533 rd_list.push(rd.clone());
534 }
535 }
536 let group = IsotopeGroup::custom("calibration".into(), members)
537 .map_err(|e| PipelineError::InvalidParameter(format!("calibrate_energy: {e}")))?;
538 let group_pairs: [(&IsotopeGroup, &[ResonanceData]); 1] = [(&group, rd_list.as_slice())];
539
540 // ── Local helpers ────────────────────────────────────────────────────
541 // `run_lm`: one LM energy-scale fit on `grid`, cold-seeded at
542 // `(t0, L_scale) = (0, 1)` — the fitters' internal peak-match seed is
543 // deliberately DISABLED for these fits (see the
544 // `with_energy_scale_seed(false)` call and its rationale below) — with
545 // the grouped density either free or frozen (#633) at `d_init`.
546 let run_lm = |grid: &[f64],
547 d_init: f64,
548 freeze_density: bool|
549 -> Result<SpectrumFitResult, PipelineError> {
550 let mut config = UnifiedFitConfig::new(
551 grid.to_vec(),
552 vec![rd_list[0].clone()],
553 vec!["calibration".into()],
554 temperature_k,
555 resolution.map(|r| r.resolution.clone()),
556 vec![d_init],
557 )
558 .map_err(|e| PipelineError::InvalidParameter(format!("calibrate_energy: {e}")))?
559 .with_groups(&group_pairs, vec![d_init])
560 .map_err(|e| PipelineError::InvalidParameter(format!("calibrate_energy: {e}")))?
561 .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
562 // Small first steps (issue #634): at saturated sharp dips the
563 // true (t0, L_scale) pit is sub-0.3 µs narrow while the
564 // along-valley Jacobian is near-singular — the default
565 // lambda_init = 1e-3 lets the first LM step overshoot the
566 // basin (observed: a start 0.12 µs from truth walking to a
567 // 7 µs-wrong valley pit). A large initial damping keeps the
568 // early steps gradient-like and short; the LM anneals lambda
569 // back down once inside the basin.
570 lambda_init: 1.0e2,
571 ..LmConfig::default()
572 }))
573 .with_energy_scale(0.0, 1.0, assumed_flight_path_m)
574 // The anchor stages already seed (t0, L_scale); the fitters' internal
575 // peak-match seed mislocates saturated flat-bottom dips and would
576 // overwrite the anchor with an in-bounds wrong seed (issue #634).
577 .with_energy_scale_seed(false);
578 if freeze_density {
579 config = config.with_fix_densities(true);
580 }
581 let input = InputData::Transmission {
582 transmission: t_fit.clone(),
583 uncertainty: sigma_fit.clone(),
584 };
585 fit_spectrum_typed(&input, &config).map_err(|e| {
586 PipelineError::InvalidParameter(format!(
587 "calibrate_energy: LM energy-scale fit failed: {e}"
588 ))
589 })
590 };
591 // `golden_at`: the ORIGINAL exact 1-D density optimisation (golden
592 // section in log10 n against `compute_chi2`) at a given corrected grid.
593 let golden_at = |e_corr: &[f64]| {
594 golden_section_n_total(
595 CALIBRATION_LOG10_N_LO,
596 CALIBRATION_LOG10_N_HI,
597 CALIBRATION_LOG10_N_TOL,
598 |log_n| {
599 compute_chi2(
600 e_corr,
601 transmission,
602 uncertainty,
603 isotopes,
604 abundances,
605 10f64.powf(log_n),
606 temperature_k,
607 &valid,
608 resolution,
609 )
610 },
611 )
612 };
613
614 // ── Stage 1: density seed at the identity energy scale ──────────────
615 // If no candidate yields a finite chi² (wildly out-of-scale data), fall
616 // back to the band midpoint — the no-finite-chi² guard below reports
617 // the failure.
618 let (n_seed, seed_chi2) = golden_at(energies_nominal);
619 let n_seed = if seed_chi2.is_finite() {
620 n_seed
621 } else {
622 10f64.powf(0.5 * (CALIBRATION_LOG10_N_LO + CALIBRATION_LOG10_N_HI))
623 };
624
625 // ── Stage 2: coarse global JOINT alignment anchor ────────────────────
626 // A compact descendant of the old Phase-1 scan: 7 L_scale (±1.5 % in
627 // 0.5 % steps) × 7 t₀ (−5…+10 µs in 2.5 µs steps) candidates, each
628 // scored with its OWN exact golden-section density — the per-candidate
629 // density optimisation is what makes the anchor JOINT, exactly like the
630 // old grid. Scoring all candidates at one common density is NOT
631 // sufficient (issue #634 review, empirically demonstrated): on
632 // saturated (5e-3) or trace (2e-5) landscapes with production-scale
633 // offsets (0.3–1.2 % L, the module header's own VENUS example
634 // magnitude), the identity-seeded common density corrupts the candidate
635 // ranking, every descent start then inherits a wrong-basin anchor, and
636 // the calibration returns Ok with a density up to 139× off — at trace
637 // density with chi²_r ≈ 1e-4, i.e. no user-visible failure signal.
638 // ~49 golden sections ≈ 1 200 forward evaluations, still ~30× cheaper
639 // than the old three-phase grid's ~35 000.
640 let mut t0_tot = 0.0_f64;
641 let mut ls_tot = 1.0_f64;
642 let mut anchor_n = n_seed;
643 // NaN-safe latch init (issue #634 review): a NaN identity chi² would
644 // make every `chi2_c < anchor_chi2` comparison false and silently
645 // discard the whole joint scan — the same NaN-latch pattern the winner
646 // latch below is hardened against. The old grid was immune
647 // (`best_chi2 = INFINITY` with no privileged identity candidate).
648 let mut anchor_chi2 = if seed_chi2.is_finite() {
649 seed_chi2
650 } else {
651 f64::INFINITY
652 };
653 for i_l in -3..=3_i32 {
654 let ls = 1.0 + f64::from(i_l) * 0.005;
655 for i_t in 0..=6_i32 {
656 let t0 = -5.0 + 2.5 * f64::from(i_t);
657 if i_l == 0 && i_t == 2 {
658 continue; // the identity candidate is (n_seed, seed_chi2) above
659 }
660 let Ok(e_c) = corrected_energy_grid(energies_nominal, t0, ls, assumed_flight_path_m)
661 else {
662 continue; // degenerate candidate (t0 past the shortest TOF)
663 };
664 let (n_c, chi2_c) = golden_at(&e_c);
665 if chi2_c < anchor_chi2 {
666 anchor_chi2 = chi2_c;
667 t0_tot = t0;
668 ls_tot = ls;
669 anchor_n = n_c;
670 }
671 }
672 }
673
674 // ── Stage 2b: fine joint pit-scan around each anchor ────────────────
675 // The coarse 2.5 µs / 0.5 % cell — and the dip-match solve at saturated
676 // dips (its centroid is the bin-quantized saturation interval, biased up
677 // to ~0.5 µs) — are both coarser than the (t0, L_scale) landscape's
678 // sub-bin aliasing pits (~±0.3 µs wide, ~2 µs apart on the 0.2 eV test
679 // grid), so a descent seeded from either can converge into a
680 // NEIGHBOURING pit (observed: chi² 3.3 at 1.85 µs from the chi² ≈ 0 true
681 // pit, with the LM reporting converged). This fine scan — 11 × 11
682 // candidates at 0.25 µs / 0.05 % steps spanning one coarse cell, each
683 // with its own golden-section density on a ±0.5-decade band around the
684 // coarse anchor density — is the compact descendant of the old
685 // Phase-2/3 refinement grids and deterministically lands inside the
686 // true pit, from which the descent below converges. It runs around
687 // BOTH anchors (lattice winner and dip-match).
688 let fine_scan = |t0_a: f64, ls_a: f64, n_a: f64| -> (f64, f64, f64, f64) {
689 let fine_lo = (n_a.log10() - 0.5).max(CALIBRATION_LOG10_N_LO);
690 let fine_hi = (n_a.log10() + 0.5).min(CALIBRATION_LOG10_N_HI);
691 let mut best = (t0_a, ls_a, n_a, f64::INFINITY);
692 for i_l in -5..=5_i32 {
693 let ls = ls_a + f64::from(i_l) * 0.0005;
694 for i_t in -5..=5_i32 {
695 let t0 = t0_a + f64::from(i_t) * 0.25;
696 let Ok(e_c) =
697 corrected_energy_grid(energies_nominal, t0, ls, assumed_flight_path_m)
698 else {
699 continue;
700 };
701 let (n_c, chi2_c) =
702 golden_section_n_total(fine_lo, fine_hi, CALIBRATION_LOG10_N_TOL, |log_n| {
703 compute_chi2(
704 &e_c,
705 transmission,
706 uncertainty,
707 isotopes,
708 abundances,
709 10f64.powf(log_n),
710 temperature_k,
711 &valid,
712 resolution,
713 )
714 });
715 if chi2_c < best.3 {
716 best = (t0, ls, n_c, chi2_c);
717 }
718 }
719 }
720 best
721 };
722
723 // Anchor set for the descent: the fine-scanned lattice winner PLUS the
724 // fine-scanned dip-match solve (see `dip_match_anchor`). The dip-match
725 // anchor is deliberately NOT ranked against the lattice winner by a
726 // single chi² comparison: at sub-bin misalignment of sharp saturated
727 // dips, chi²(n) is multimodal — the steep dip edges make a nearly-right
728 // deep model score WORSE than a shallow one — so a chi² ranking can
729 // discard the one anchor whose fine neighbourhood contains the true pit
730 // (observed: dip-match landed 0.12 µs from truth yet scored chi² 1 809
731 // vs the lattice winner's 124). Running the full multi-start descent
732 // from BOTH fine anchors and letting the final argmin-chi² decide is
733 // robust to that ranking failure.
734 let lattice_fine = fine_scan(t0_tot, ls_tot, anchor_n);
735 let mut anchor_list: Vec<(f64, f64, f64)> =
736 vec![(lattice_fine.0, lattice_fine.1, lattice_fine.2)];
737 if let Some((t0_d, ls_d)) = dip_match_anchor(
738 energies_nominal,
739 transmission,
740 &valid,
741 isotopes,
742 abundances,
743 assumed_flight_path_m,
744 ) {
745 // Density context for the dip anchor's narrow golden band: the
746 // lattice anchor's density is the best available estimate.
747 let dip_fine = fine_scan(t0_d, ls_d, anchor_n);
748 // Skip a duplicate anchor (both scans converged on the same pit).
749 if (dip_fine.0 - lattice_fine.0).abs() > 0.05 || (dip_fine.1 - lattice_fine.1).abs() > 1e-4
750 {
751 anchor_list.push((dip_fine.0, dip_fine.1, dip_fine.2));
752 }
753 }
754
755 // (t0_tot, ls_tot, n_total, chi2 over valid bins, LM converged flag)
756 let mut winner: Option<(f64, f64, f64, f64, bool)> = None;
757 // Last LM error across all starts: per-seed failures are skippable, but
758 // when EVERY start fails (a config-class error — bad resolution kernel,
759 // config rejection — fails all seeds identically) the no-winner
760 // diagnostic must carry the actual root cause instead of misattributing
761 // it to non-finite residuals (issue #634 review).
762 let mut last_lm_err: Option<PipelineError> = None;
763 for &(a_t0, a_ls, a_n) in &anchor_list {
764 let anchor_grid = if a_t0 == 0.0 && a_ls == 1.0 {
765 energies_nominal.to_vec()
766 } else {
767 match corrected_energy_grid(energies_nominal, a_t0, a_ls, assumed_flight_path_m) {
768 Ok(g) => g,
769 // Degenerate anchor — the other anchor still runs; if every
770 // anchor is degenerate the no-winner guard below reports it.
771 Err(_) => continue,
772 }
773 };
774 // Density seeds: this anchor's own fine-scan density (the joint
775 // optimum of its pit) plus log-spaced starts across the band (the
776 // aliasing/multimodality guard).
777 let mut seeds = vec![a_n];
778 for exp in [-4.5_f64, -3.5, -2.5] {
779 let sd = 10f64.powf(exp);
780 // Skip seeds within 2× of an existing one — same basin.
781 if seeds.iter().all(|&e| (sd / e).log10().abs() > 0.301) {
782 seeds.push(sd);
783 }
784 }
785 for &start_n in &seeds {
786 // Two descent variants per (anchor, seed); both feed the same
787 // final argmin:
788 //
789 // (a) DIRECT joint LM from the anchor — robust at saturated
790 // dips, where the alternation below fails: a frozen
791 // slightly-wrong density has its alignment optimum in a
792 // COMPENSATING pit, so the alternation walks away from a
793 // good anchor before its joint stage ever runs (observed:
794 // an anchor 0.12 µs from truth descending to a chi² 3.3
795 // pit while the direct joint fit rolls into the chi² ≈ 0
796 // true pit).
797 //
798 // (b) alternation (exact density ↔ alignment-only LM, then
799 // joint LM) — robust when the density seed is decades off,
800 // where a direct joint fit would trade density against a
801 // sub-bin shift (the aliasing degeneracy).
802 let mut candidates: Vec<(f64, f64, SpectrumFitResult)> = Vec::new();
803
804 // (a) direct joint fit from the anchor.
805 match run_lm(&anchor_grid, start_n * total_abundance, false) {
806 Ok(fit) => {
807 let t0_c = a_t0 + a_ls * fit.t0_us.unwrap_or(0.0);
808 let ls_c = a_ls * fit.l_scale.unwrap_or(1.0);
809 candidates.push((t0_c, ls_c, fit));
810 }
811 Err(e) => last_lm_err = Some(e),
812 }
813
814 // (b) alternation.
815 let mut t0_tot = a_t0;
816 let mut ls_tot = a_ls;
817 let mut grid = anchor_grid.clone();
818 let mut n_cur = start_n;
819 let mut failed = false;
820 for cycle in 0..3 {
821 // First cycle keeps the start density (the whole point of the
822 // multi-start); later cycles re-optimise it at the improved
823 // alignment.
824 if cycle > 0 {
825 let (n_new, chi2_n) = golden_at(&grid);
826 if chi2_n.is_finite() {
827 n_cur = n_new;
828 }
829 }
830 let align = match run_lm(&grid, n_cur * total_abundance, true) {
831 Ok(a) => a,
832 Err(e) => {
833 last_lm_err = Some(e);
834 failed = true;
835 break;
836 }
837 };
838 let t0_k = align.t0_us.unwrap_or(0.0);
839 let ls_k = align.l_scale.unwrap_or(1.0);
840 t0_tot += ls_tot * t0_k;
841 ls_tot *= ls_k;
842 // Alignment converged (no further shift found) → stop.
843 if t0_k.abs() < 1e-6 && (ls_k - 1.0).abs() < 1e-9 {
844 break;
845 }
846 match corrected_energy_grid(energies_nominal, t0_tot, ls_tot, assumed_flight_path_m)
847 {
848 Ok(g) => grid = g,
849 Err(_) => {
850 failed = true;
851 break;
852 }
853 }
854 }
855 if !failed {
856 // Joint refinement of the alternation result.
857 let (n_stage, chi2_stage) = golden_at(&grid);
858 if chi2_stage.is_finite() {
859 n_cur = n_stage;
860 }
861 match run_lm(&grid, n_cur * total_abundance, false) {
862 Ok(fit) => {
863 let t0_c = t0_tot + ls_tot * fit.t0_us.unwrap_or(0.0);
864 let ls_c = ls_tot * fit.l_scale.unwrap_or(1.0);
865 candidates.push((t0_c, ls_c, fit));
866 }
867 Err(e) => last_lm_err = Some(e),
868 }
869 }
870
871 // Score every candidate with the ORIGINAL valid-bins objective at
872 // its solution; keep the argmin. The latch is gated on
873 // finiteness: a bare `None => true` first-candidate arm would let
874 // a NaN chi² latch (bypassing the `<` comparison), and every
875 // later candidate would then compare `chi2 < NaN` (always false)
876 // — a valid later calibration could never displace a NaN first
877 // one and the function would return the no-finite-chi² error for
878 // a calibratable spectrum. Mirrors the old grid's
879 // `best_chi2 = INFINITY` + `<` behaviour, where non-finite
880 // candidates could never latch (issue #634 review).
881 for (t0_c, ls_c, fit) in candidates {
882 let n_c = fit.densities.first().copied().unwrap_or(f64::NAN) / total_abundance;
883 let Ok(e_c) =
884 corrected_energy_grid(energies_nominal, t0_c, ls_c, assumed_flight_path_m)
885 else {
886 continue;
887 };
888 let chi2_c = compute_chi2(
889 &e_c,
890 transmission,
891 uncertainty,
892 isotopes,
893 abundances,
894 n_c,
895 temperature_k,
896 &valid,
897 resolution,
898 );
899 let better = chi2_c.is_finite()
900 && match &winner {
901 None => true,
902 Some((_, _, _, best, _)) => chi2_c < *best,
903 };
904 if better {
905 winner = Some((t0_c, ls_c, n_c, chi2_c, fit.converged));
906 }
907 }
908 }
909 }
910 let Some((t0_tot, ls_tot, best_n, _, lm_converged)) = winner else {
911 // Preserve the root cause: a config-class LM error fails every seed
912 // identically, and its message is the actionable diagnostic — the
913 // generic non-finite-residuals guess applies only when no LM error
914 // occurred.
915 let detail = match last_lm_err {
916 Some(e) => format!("last LM error: {e}"),
917 None => "likely cause is forward-model failure or non-finite \
918 residuals (e.g. wildly out-of-scale transmission)"
919 .to_string(),
920 };
921 return Err(PipelineError::InvalidParameter(format!(
922 "calibrate_energy: calibration produced no finite chi² from any \
923 density start (best_chi2 = inf) — {detail}"
924 )));
925 };
926
927 // Boundary-saturation guard: if the fitted n_total lies within
928 // tolerance of either density-band edge — or anywhere beyond it (the
929 // LM density is unbounded above, unlike the old grid) — the true
930 // minimum almost certainly sits outside the supported range and the
931 // calibration is unreliable. Returning `Ok` with `best_n` ≈
932 // boundary would silently rail the density and let the (L, t₀)
933 // parameters absorb the missing density freedom by compensating
934 // bias — exactly the silent-failure pattern the no-finite-chi²
935 // guard below also defends against, but with a boundary-specific
936 // diagnostic.
937 //
938 // The seed band is `[~5e-6, ~2e-2]`; the documented edges are
939 // `[1e-5, 1e-2]`. Fits at the documented edges lie outside the
940 // tolerance window (a true optimum at `1e-5` sits ~0.3 decades
941 // above the internal lower bound, comfortably past the ~0.02-log10
942 // tolerance) so the guard fires only when the optimum has actually
943 // saturated against — or escaped — the wider buffer. One-sided
944 // comparisons (≤ / ≥ rather than the former |·|) so a far
945 // out-of-band LM optimum (e.g. n ≈ 1) fires the same diagnostic; a
946 // NaN density falls through (NaN comparisons are false) to the
947 // no-finite-chi² guard below.
948 let log_best_n = best_n.log10();
949 let n_lo = 10f64.powf(CALIBRATION_LOG10_N_LO_DOC);
950 let n_hi = 10f64.powf(CALIBRATION_LOG10_N_HI_DOC);
951 if log_best_n <= CALIBRATION_LOG10_N_LO + CALIBRATION_LOG10_BOUNDARY_TOL
952 || log_best_n >= CALIBRATION_LOG10_N_HI - CALIBRATION_LOG10_BOUNDARY_TOL
953 {
954 return Err(PipelineError::InvalidParameter(format!(
955 "calibrate_energy: n_total optimum {best_n:.3e} atoms/barn is at the \
956 search boundary [{n_lo:.0e}, {n_hi:.0e}]; the true optimum likely lies \
957 outside this band. Provide a better initial density estimate, check \
958 the sample composition / abundances, or extend the search range."
959 )));
960 }
961
962 // Corrected energy grid at the composed (t0_tot, ls_tot): the same
963 // canonical transform the LM evaluated (SAMMY −t0 convention,
964 // `resolution_calib::corrected_energy_grid`), expressed relative to
965 // the caller's ORIGINAL nominal grid and assumed flight path — the
966 // pre-#634 output convention.
967 let energies_corrected =
968 corrected_energy_grid(energies_nominal, t0_tot, ls_tot, assumed_flight_path_m).map_err(
969 |e| {
970 PipelineError::InvalidParameter(format!(
971 "calibrate_energy: degenerate calibration: {e}"
972 ))
973 },
974 )?;
975
976 // Final chi² over VALID bins with the original grid objective
977 // (`compute_chi2`), preserving the pre-#634 chi²_r semantics exactly:
978 // invalid bins excluded, `dof = n_valid − 3` clamped to ≥ 1 for the
979 // exact-fit edge case. If the final chi² is non-finite
980 // (forward-model failure, or residual overflow for
981 // non-finite-but-passing transmission values such as 1e308), reject
982 // explicitly so calibration failure is always an `Err`, never an
983 // `Ok` with a sentinel chi² — the same contract as the old
984 // post-grid-search guard.
985 //
986 // Note the gate is the chi², NOT the LM `converged` flag: the grid
987 // search this replaced had no convergence concept — it reported the
988 // best candidate found, quality-gated only by finite chi² and the
989 // density boundary. The LM matches that contract; its parameters at
990 // lambda-breakout are the best point found (a stall is common for
991 // trace densities, where the energy-scale Jacobian columns are nearly
992 // zero and the step control gives up AFTER the density has already
993 // converged). The reported chi²_r carries the fit quality either way.
994 let best_chi2 = compute_chi2(
995 &energies_corrected,
996 transmission,
997 uncertainty,
998 isotopes,
999 abundances,
1000 best_n,
1001 temperature_k,
1002 &valid,
1003 resolution,
1004 );
1005 if !best_chi2.is_finite() {
1006 return Err(PipelineError::InvalidParameter(format!(
1007 "calibrate_energy: calibration produced no finite chi² \
1008 (LM converged = {converged}, best_chi2 = {best_chi2}) — likely cause is \
1009 forward-model failure or non-finite residuals (e.g. wildly \
1010 out-of-scale transmission)",
1011 converged = lm_converged,
1012 )));
1013 }
1014
1015 // Final chi2r (reduced). The up-front guard ensures
1016 // `n_valid >= N_FITTED_PARAMS`, so we always have a non-negative
1017 // dof. We still clamp to `max(1)` so that the exact-fit edge case
1018 // (n_valid == N_FITTED_PARAMS, dof = 0) reports a finite value
1019 // instead of dividing by zero.
1020 let dof = n_valid.saturating_sub(N_FITTED_PARAMS).max(1);
1021 let chi2r = best_chi2 / dof as f64;
1022
1023 Ok(CalibrationResult {
1024 flight_path_m: assumed_flight_path_m * ls_tot,
1025 t0_us: t0_tot,
1026 total_density: best_n,
1027 reduced_chi_squared: chi2r,
1028 energies_corrected,
1029 })
1030}
1031
1032/// Compute total chi² for a given (E_corrected, n_total) against measured data.
1033#[allow(clippy::too_many_arguments)]
1034fn compute_chi2(
1035 energies: &[f64],
1036 transmission: &[f64],
1037 uncertainty: &[f64],
1038 isotopes: &[ResonanceData],
1039 abundances: &[f64],
1040 n_total: f64,
1041 temperature_k: f64,
1042 valid: &[bool],
1043 resolution: Option<&InstrumentParams>,
1044) -> f64 {
1045 // Build (isotope, density) pairs
1046 let pairs: Vec<(ResonanceData, f64)> = isotopes
1047 .iter()
1048 .zip(abundances.iter())
1049 .map(|(iso, &abd)| (iso.clone(), abd * n_total))
1050 .collect();
1051
1052 let sample = match SampleParams::new(temperature_k, pairs) {
1053 Ok(s) => s,
1054 Err(_) => return f64::INFINITY,
1055 };
1056
1057 // P-5: Include resolution broadening when available.
1058 // Without it, fitted L and t₀ absorb the missing broadening bias.
1059 let model = match transmission::forward_model(energies, &sample, resolution) {
1060 Ok(m) => m,
1061 Err(_) => return f64::INFINITY,
1062 };
1063
1064 // Chi²
1065 let mut chi2 = 0.0;
1066 for (i, (&t_data, &t_model)) in transmission.iter().zip(model.iter()).enumerate() {
1067 if !valid[i] {
1068 continue;
1069 }
1070 let residual = (t_data - t_model) / uncertainty[i];
1071 chi2 += residual * residual;
1072 }
1073 chi2
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078 use super::*;
1079 use nereids_endf::resonance::test_support::synthetic_single_resonance;
1080
1081 /// Round-trip exercise of the public `calibrate_energy` API on a
1082 /// synthetic spectrum. Uses `synthetic_single_resonance` from
1083 /// `nereids_endf::resonance::test_support` so the test does not require network access and
1084 /// runs in every CI invocation.
1085 ///
1086 /// Note on tolerances: the grid-search calibrator's L resolution
1087 /// is fundamentally limited by chi² curvature (broader resonances
1088 /// or sparser bins → broader minimum). With only synthetic
1089 /// single-resonance isotopes on a sparse 0.2 eV grid, the chi²
1090 /// landscape near L=true_l is shallow on the scale of the
1091 /// 0.001 % ultra-fine step — Doppler-broadened ≈ 33 meV resonance
1092 /// width vs 200 meV grid spacing means each resonance is sampled
1093 /// by ≤ 1 bin, so (L, t₀) can drift across a wide band before chi²
1094 /// degrades enough to lock the minimum down. We therefore (a) use
1095 /// a small true offset (0.05 % in L, 0.5 µs in t₀) that the
1096 /// calibrator can resolve, and (b) test the *physics* — a fit
1097 /// converged, the corrected energies are close to truth on the
1098 /// data-relevant range, and density recovery is in the right
1099 /// decade — rather than chasing exact ENDF-style L recovery.
1100 /// The bit-exact precision question is owned by the SAMMY parity
1101 /// tests in `nereids-physics`, not by this API smoke test.
1102 #[test]
1103 fn test_calibrate_round_trip_synthetic() {
1104 // Generate synthetic data with known L and t0, then recover them.
1105 // Small offsets (0.05 % in L, 0.5 µs in t₀) so the chi² minimum
1106 // is well inside Phase-2 fine grid (±0.05 % in L, ±2 µs in t₀).
1107 //
1108 // Setup (resonances, energy grid, forward-model transmission,
1109 // and the `calibrate_energy` call itself) is shared with the
1110 // density-band tests below via `calibrate_round_trip_at_density`;
1111 // the helper returns `(result, e_true, assumed_l)` so this
1112 // smoke test can also assert on corrected-energy accuracy.
1113 let true_n = 1.5e-4;
1114 let (result, e_true, assumed_l) =
1115 calibrate_round_trip_at_density(true_n).expect("Calibration failed");
1116
1117 // Check recovery. Wider tolerances than the Hf-178 fixture
1118 // because the synthetic chi² minimum is broader (see the
1119 // doc comment on the test above). These bands still
1120 // distinguish a successful fit from a degenerate one (the
1121 // zero-valid-bins failure mode would report L = assumed_l
1122 // and chi² = 0.0).
1123 //
1124 // With the n_total golden-section refactor, the previously-
1125 // narrow density grid no longer pins (L, t₀) at a single
1126 // coarse-grid point; the calibrator now expresses the
1127 // genuine (L, t₀, n) degeneracy this sparse-grid synthetic
1128 // admits — 33 meV Doppler-broadened resonances vs 200 meV
1129 // grid spacing samples each resonance with ≤ 1 bin, so any
1130 // (L, t₀) that places the resonance near the same bin gives
1131 // an indistinguishable fit. The L and t₀ parameters are
1132 // therefore not independently identifiable from this
1133 // synthetic, but the *corrected energy grid* — the actual
1134 // downstream deliverable — is, and is the right thing to
1135 // assert on.
1136 //
1137 // L and t₀ are still required to be inside the search grid
1138 // (Phase 1 L ±1.5 %, t₀ ∈ [-5, +10] µs) and density inside
1139 // the search band — anything outside would signal a
1140 // calibrator regression, not a degeneracy.
1141 assert!(
1142 (result.flight_path_m - assumed_l).abs() / assumed_l <= 0.015,
1143 "L drift outside Phase 1 ±1.5 % grid: got {}",
1144 result.flight_path_m,
1145 );
1146 assert!(
1147 (-5.0..=10.0).contains(&result.t0_us),
1148 "t0 outside Phase 1 grid: got {}",
1149 result.t0_us,
1150 );
1151 assert!(
1152 (result.total_density - true_n).abs() / true_n < 0.5,
1153 "n: got {}, expected {}",
1154 result.total_density,
1155 true_n,
1156 );
1157
1158 // The corrected energy grid is the deliverable a downstream
1159 // fit uses; even when (L, t₀, n) drift inside the chi²
1160 // basin allowed by this sparse-grid synthetic, the corrected
1161 // energies must still track `e_true` to within ~1 % at the
1162 // resonance positions and a few % across the grid. A
1163 // median relative error check is more robust to per-bin
1164 // scaling than max-over-bins on a 150-bin grid; we still
1165 // assert max < 5 % to catch a wholly-wrong calibration.
1166 let rel_errs: Vec<f64> = result
1167 .energies_corrected
1168 .iter()
1169 .zip(e_true.iter())
1170 .map(|(&ec, &et)| (ec - et).abs() / et)
1171 .collect();
1172 let mut sorted = rel_errs.clone();
1173 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1174 let median = sorted[sorted.len() / 2];
1175 let max_err = sorted.last().copied().unwrap_or(0.0);
1176 assert!(
1177 median < 0.02,
1178 "corrected energies median rel err {median} should be < 2 %",
1179 );
1180 assert!(
1181 max_err < 0.05,
1182 "corrected energies max rel err {max_err} should be < 5 %",
1183 );
1184 // The synthetic round-trip uses noiseless data, so a grid point
1185 // that lands on (or arbitrarily close to) the true parameters
1186 // can legitimately yield chi² = 0. Accept `>= 0.0` (the
1187 // physically valid range) rather than `> 0.0`, which was a
1188 // flake-prone strict-inequality. The zero-valid-bins
1189 // regression that previously reported chi² = 0.0 is now
1190 // rejected up-front by the `n_valid >= N_FITTED_PARAMS` guard,
1191 // and the all-infinity grid-search case is rejected by the
1192 // post-search `best_chi2.is_finite()` guard — so finiteness
1193 // alone is the meaningful check here.
1194 assert!(
1195 result.reduced_chi_squared.is_finite() && result.reduced_chi_squared >= 0.0,
1196 "chi²_reduced must be finite and >= 0 (degenerate-input regressions \
1197 are caught up-front and post-search; this assertion guards against \
1198 chi² leaking as inf or NaN); got {}",
1199 result.reduced_chi_squared,
1200 );
1201 }
1202
1203 // ── Degenerate-input guards ────────────────────────────────────────
1204 //
1205 // Before these guards, `compute_chi2` returned 0.0 when every bin
1206 // was skipped by the `valid` mask, the grid search latched the
1207 // first candidate as "best", and the dof=1 fallback at the end
1208 // turned that into a reported `chi²_reduced = 0.0` — a totally
1209 // degenerate input was indistinguishable from a perfect
1210 // calibration.
1211
1212 /// `(energies_nominal, transmission, uncertainty, isotopes, abundances)`
1213 /// — the five array arguments to `calibrate_energy`.
1214 type CalibrationInputs = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<ResonanceData>, Vec<f64>);
1215
1216 /// Build a minimal valid input set for `calibrate_energy`, then let
1217 /// the caller mutate one field to drive a specific error path.
1218 fn minimal_calibration_inputs() -> CalibrationInputs {
1219 let iso = synthetic_single_resonance(72, 178, 176.4, 7.8);
1220 let energies: Vec<f64> = (0..50).map(|i| 5.0 + i as f64 * 0.4).collect();
1221 let transmission = vec![0.95; energies.len()];
1222 let uncertainty = vec![0.01; energies.len()];
1223 (energies, transmission, uncertainty, vec![iso], vec![1.0])
1224 }
1225
1226 #[test]
1227 fn test_calibrate_all_nan_transmission_rejected() {
1228 // All-NaN transmission would previously yield zero valid bins,
1229 // compute_chi2() returned 0.0 for every grid point, and the
1230 // dof=1 fallback reported chi²_reduced = 0.0 as success.
1231 let (energies, mut transmission, uncertainty, isotopes, abundances) =
1232 minimal_calibration_inputs();
1233 for t in transmission.iter_mut() {
1234 *t = f64::NAN;
1235 }
1236 let err = calibrate_energy(
1237 &energies,
1238 &transmission,
1239 &uncertainty,
1240 &isotopes,
1241 &abundances,
1242 25.0,
1243 293.6,
1244 None,
1245 )
1246 .expect_err("all-NaN transmission must be rejected");
1247 match err {
1248 PipelineError::InvalidParameter(msg) => {
1249 assert!(
1250 msg.contains("valid"),
1251 "error message should mention valid-bin count, got: {msg}"
1252 );
1253 }
1254 other => panic!("expected InvalidParameter, got {other:?}"),
1255 }
1256 }
1257
1258 #[test]
1259 fn test_calibrate_all_zero_uncertainty_rejected() {
1260 // All-zero uncertainty is the other path to zero valid bins
1261 // (sigma > 0 is required by the valid mask).
1262 let (energies, transmission, mut uncertainty, isotopes, abundances) =
1263 minimal_calibration_inputs();
1264 for s in uncertainty.iter_mut() {
1265 *s = 0.0;
1266 }
1267 let err = calibrate_energy(
1268 &energies,
1269 &transmission,
1270 &uncertainty,
1271 &isotopes,
1272 &abundances,
1273 25.0,
1274 293.6,
1275 None,
1276 )
1277 .expect_err("all-zero uncertainty must be rejected");
1278 assert!(
1279 matches!(err, PipelineError::InvalidParameter(_)),
1280 "expected InvalidParameter, got {err:?}"
1281 );
1282 }
1283
1284 #[test]
1285 fn test_calibrate_nonfinite_flight_path_rejected() {
1286 // All non-finite or non-positive flight paths must produce
1287 // InvalidParameter, naming the offending field so the caller
1288 // can diagnose the source.
1289 let (energies, transmission, uncertainty, isotopes, abundances) =
1290 minimal_calibration_inputs();
1291 for bad_l in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 0.0, -1.0] {
1292 let result = calibrate_energy(
1293 &energies,
1294 &transmission,
1295 &uncertainty,
1296 &isotopes,
1297 &abundances,
1298 bad_l,
1299 293.6,
1300 None,
1301 );
1302 match result {
1303 Ok(_) => panic!("expected Err for L={bad_l}, got Ok"),
1304 Err(PipelineError::InvalidParameter(msg)) => {
1305 assert!(
1306 msg.contains("assumed_flight_path_m"),
1307 "error message should name the offending field for L={bad_l}, got: {msg}"
1308 );
1309 }
1310 Err(other) => panic!("expected InvalidParameter for L={bad_l}, got {other:?}"),
1311 }
1312 }
1313 }
1314
1315 #[test]
1316 fn test_calibrate_nonascending_energies_rejected() {
1317 let (mut energies, transmission, uncertainty, isotopes, abundances) =
1318 minimal_calibration_inputs();
1319 // Introduce a non-ascending pair.
1320 energies[10] = energies[9];
1321 let err = calibrate_energy(
1322 &energies,
1323 &transmission,
1324 &uncertainty,
1325 &isotopes,
1326 &abundances,
1327 25.0,
1328 293.6,
1329 None,
1330 )
1331 .expect_err("non-ascending energies must be rejected");
1332 match err {
1333 PipelineError::InvalidParameter(msg) => {
1334 assert!(
1335 msg.contains("ascending"),
1336 "error message should mention ascending, got: {msg}"
1337 );
1338 }
1339 other => panic!("expected InvalidParameter, got {other:?}"),
1340 }
1341 }
1342
1343 #[test]
1344 fn test_calibrate_all_infinite_chi2_rejected() {
1345 // Regression for the post-grid-search `best_chi2.is_finite()`
1346 // guard. Before the guard, an input whose every `compute_chi2`
1347 // evaluation returned `f64::INFINITY` left `best_chi2`
1348 // initialised at `INFINITY`, no candidate ever beat it, and the
1349 // function returned `Ok(CalibrationResult { reduced_chi_squared:
1350 // inf, .. })` — the same silent-failure surface as the
1351 // zero-valid-bins case, just with infinity instead of zero.
1352 //
1353 // Driving the all-infinity path: feed finite but wildly
1354 // out-of-scale transmission (1e308). Each finite uncertainty
1355 // (0.01) makes the residual ((1e308 − T_model) / 0.01) overflow
1356 // to `+inf`, residual² is `inf`, the per-bin sum is `inf`, and
1357 // every grid candidate returns `inf`. Crucially, the
1358 // transmission values stay `finite()` so they pass the up-front
1359 // `t.is_finite()` mask and the new post-search guard is the
1360 // only line of defence.
1361 let (energies, _transmission, uncertainty, isotopes, abundances) =
1362 minimal_calibration_inputs();
1363 let transmission = vec![1e308; energies.len()];
1364 let err = calibrate_energy(
1365 &energies,
1366 &transmission,
1367 &uncertainty,
1368 &isotopes,
1369 &abundances,
1370 25.0,
1371 293.6,
1372 None,
1373 )
1374 .expect_err("all-infinity chi² across the grid must be rejected");
1375 match err {
1376 PipelineError::InvalidParameter(msg) => {
1377 assert!(
1378 msg.contains("finite chi²") || msg.contains("best_chi2"),
1379 "error message should explain the all-infinity grid-search failure, got: {msg}"
1380 );
1381 }
1382 other => panic!("expected InvalidParameter, got {other:?}"),
1383 }
1384 }
1385
1386 #[test]
1387 fn test_calibrate_nonfinite_energy_rejected() {
1388 let (mut energies, transmission, uncertainty, isotopes, abundances) =
1389 minimal_calibration_inputs();
1390 energies[7] = f64::NAN;
1391 let err = calibrate_energy(
1392 &energies,
1393 &transmission,
1394 &uncertainty,
1395 &isotopes,
1396 &abundances,
1397 25.0,
1398 293.6,
1399 None,
1400 )
1401 .expect_err("NaN energy must be rejected");
1402 assert!(
1403 matches!(err, PipelineError::InvalidParameter(_)),
1404 "expected InvalidParameter, got {err:?}"
1405 );
1406 }
1407
1408 #[test]
1409 fn test_calibrate_energy_rejects_negative_abundance() {
1410 // A negative abundance silently flips the sign of `abd * n_total`
1411 // and `SampleParams::new` rejects the non-positive thickness;
1412 // every grid point then returns chi² = INFINITY and the user
1413 // sees a "no finite chi²" boundary error. The up-front guard
1414 // converts this to an actionable diagnostic that names the
1415 // offending index.
1416 let (energies, transmission, uncertainty, isotopes, mut abundances) =
1417 minimal_calibration_inputs();
1418 abundances[0] = -0.5;
1419 let err = calibrate_energy(
1420 &energies,
1421 &transmission,
1422 &uncertainty,
1423 &isotopes,
1424 &abundances,
1425 25.0,
1426 293.6,
1427 None,
1428 )
1429 .expect_err("negative abundance must be rejected");
1430 match err {
1431 PipelineError::InvalidParameter(msg) => {
1432 assert!(
1433 msg.contains("abundances[0]"),
1434 "error message should name the offending index, got: {msg}"
1435 );
1436 }
1437 other => panic!("expected InvalidParameter, got {other:?}"),
1438 }
1439 }
1440
1441 #[test]
1442 fn test_calibrate_energy_rejects_nan_abundance() {
1443 // NaN bypasses naive `< 0.0` guards (NaN comparisons are always
1444 // false), so the up-front check must pair `is_finite()` with the
1445 // sign predicate. Without this guard, `abd * n_total` is NaN
1446 // for every density, every chi² is NaN, and the user sees a
1447 // confusing boundary-saturation message.
1448 let (energies, transmission, uncertainty, isotopes, mut abundances) =
1449 minimal_calibration_inputs();
1450 abundances[0] = f64::NAN;
1451 let err = calibrate_energy(
1452 &energies,
1453 &transmission,
1454 &uncertainty,
1455 &isotopes,
1456 &abundances,
1457 25.0,
1458 293.6,
1459 None,
1460 )
1461 .expect_err("NaN abundance must be rejected");
1462 match err {
1463 PipelineError::InvalidParameter(msg) => {
1464 assert!(
1465 msg.contains("abundances[0]"),
1466 "error message should name the offending index, got: {msg}"
1467 );
1468 }
1469 other => panic!("expected InvalidParameter, got {other:?}"),
1470 }
1471 }
1472
1473 #[test]
1474 fn test_calibrate_energy_rejects_all_zero_abundances() {
1475 // Each individual zero abundance is legal (the isotope is simply
1476 // not present in this sample), but the sum being zero means
1477 // every per-isotope density is zero and the transmission model
1478 // collapses to T == 1 — the calibrator has no signal to fit
1479 // (L, t₀, n_total) against. Reject up-front rather than letting
1480 // the search bottom out at the band boundary.
1481 let (energies, transmission, uncertainty, isotopes, _) = minimal_calibration_inputs();
1482 let abundances = vec![0.0; isotopes.len()];
1483 let err = calibrate_energy(
1484 &energies,
1485 &transmission,
1486 &uncertainty,
1487 &isotopes,
1488 &abundances,
1489 25.0,
1490 293.6,
1491 None,
1492 )
1493 .expect_err("all-zero abundances must be rejected");
1494 match err {
1495 PipelineError::InvalidParameter(msg) => {
1496 assert!(
1497 msg.contains("sum of abundances"),
1498 "error message should mention the zero-sum cause, got: {msg}"
1499 );
1500 }
1501 other => panic!("expected InvalidParameter, got {other:?}"),
1502 }
1503 }
1504
1505 // ── n_total search-band regression tests ──────────────────────────
1506 //
1507 // Before the golden-section refactor, `calibrate_energy` scanned
1508 // n_total at a hard-coded 5-point linear grid `{5e-5, 1e-4,
1509 // 1.5e-4, 2e-4, 3e-4}` and then refined multiplicatively, leaving
1510 // the final density anchored inside `[2.25e-5, 4.95e-4]`
1511 // atoms/barn — incompatible with every realistic VENUS /
1512 // SoftwareX paper density (1 mm metal foils at ~5e-3, trace
1513 // matrix densities up to ~1e-2). The refactor replaces the
1514 // multi-stage density refinement with a true golden-section
1515 // search in log10(n) on `[1e-5, 1e-2]`, and adds a boundary-
1516 // saturation guard for the (now possible) case where the
1517 // optimum lies outside that band.
1518 //
1519 // The tests below verify recovery at three representative
1520 // densities that span the search range (1e-5 lower edge,
1521 // 1e-3 middle of band that the old code could not reach,
1522 // 5e-3 SoftwareX U-238 density that was a factor-10× outside
1523 // the old reachable max) plus the explicit boundary-failure
1524 // diagnostic at densities outside the band.
1525
1526 /// Fully-parameterised synthetic round-trip helper. Builds data with
1527 /// two well-separated Hf-style resonances at the given true density and
1528 /// injected `(true_l = assumed_l · true_l_factor, true_t0_us)` offsets,
1529 /// runs `calibrate_energy`, and on success returns
1530 /// `(result, e_true, assumed_l)` so tests can assert on parameter
1531 /// recovery and corrected-energy accuracy against `e_true`.
1532 fn calibrate_round_trip(
1533 true_l_factor: f64,
1534 true_t0_us: f64,
1535 true_n: f64,
1536 ) -> Result<(CalibrationResult, Vec<f64>, f64), PipelineError> {
1537 let assumed_l = 25.0;
1538 let true_l = assumed_l * true_l_factor;
1539 let temperature_k = 293.6;
1540
1541 let iso_a = synthetic_single_resonance(72, 178, 176.4, 7.8);
1542 let iso_b = synthetic_single_resonance(72, 178, 176.4, 22.0);
1543 let isotopes = vec![iso_a, iso_b];
1544 let abundances = vec![0.5, 0.5];
1545
1546 let e_nominal: Vec<f64> = (0..150).map(|i| 5.0 + i as f64 * 0.2).collect();
1547 let tof_s: Vec<f64> = e_nominal
1548 .iter()
1549 .map(|&e| assumed_l * (NEUTRON_MASS_CONSTANT / e).sqrt())
1550 .collect();
1551 let true_t0_s = true_t0_us * 1e-6;
1552 let e_true: Vec<f64> = tof_s
1553 .iter()
1554 .map(|&t| NEUTRON_MASS_CONSTANT * (true_l / (t - true_t0_s)).powi(2))
1555 .collect();
1556
1557 let pairs: Vec<_> = isotopes
1558 .iter()
1559 .zip(abundances.iter())
1560 .map(|(iso, &abd)| (iso.clone(), abd * true_n))
1561 .collect();
1562 let sample = SampleParams::new(temperature_k, pairs).expect("SampleParams creation failed");
1563 let t_model =
1564 transmission::forward_model(&e_true, &sample, None).expect("forward_model failed");
1565 let sigma = vec![0.01; e_nominal.len()];
1566
1567 let result = calibrate_energy(
1568 &e_nominal,
1569 &t_model,
1570 &sigma,
1571 &isotopes,
1572 &abundances,
1573 assumed_l,
1574 temperature_k,
1575 None,
1576 )?;
1577 Ok((result, e_true, assumed_l))
1578 }
1579
1580 /// Synthetic round-trip helper parameterised on `true_n` at the small
1581 /// legacy offsets (`+0.05 % L`, `+0.5 µs`) — the easy regime. The
1582 /// wide-offset production regime is covered separately by
1583 /// `assert_wide_offset_recovery` (issue #634 review P0).
1584 fn calibrate_round_trip_at_density(
1585 true_n: f64,
1586 ) -> Result<(CalibrationResult, Vec<f64>, f64), PipelineError> {
1587 calibrate_round_trip(25.0125 / 25.0, 0.5, true_n)
1588 }
1589
1590 /// Issue #634 review P0 regression: production-scale offsets — the
1591 /// module header's own VENUS correction magnitude (0.3 % L) and
1592 /// beyond-one-±1 %-L_scale-box offsets (1.2 % L, 6 µs, which also pin
1593 /// the affine re-anchoring composition `t0 ← t0 + ls·t0_k, ls ← ls·ls_k`)
1594 /// — must recover `(L, t0, n)` and the corrected energy grid. The
1595 /// single-common-density anchor variant returned Ok in the WRONG basin
1596 /// here (density up to 139× off; at trace density with chi²_r ≈ 1e-4,
1597 /// no visible failure signal); the per-candidate golden-section density
1598 /// in the stage-2 anchor is what defeats it.
1599 fn assert_wide_offset_recovery(
1600 true_l_factor: f64,
1601 true_t0_us: f64,
1602 true_n: f64,
1603 n_rel_tol: f64,
1604 ) {
1605 let (result, e_true, assumed_l) = calibrate_round_trip(true_l_factor, true_t0_us, true_n)
1606 .expect("wide-offset calibration must succeed");
1607 let true_l = assumed_l * true_l_factor;
1608 assert!(
1609 (result.flight_path_m - true_l).abs() / true_l < 2e-3,
1610 "L: got {}, expected {true_l}",
1611 result.flight_path_m,
1612 );
1613 assert!(
1614 (result.t0_us - true_t0_us).abs() < 0.5,
1615 "t0: got {}, expected {true_t0_us}",
1616 result.t0_us,
1617 );
1618 assert!(
1619 (result.total_density - true_n).abs() / true_n < n_rel_tol,
1620 "n: got {}, expected {true_n}",
1621 result.total_density,
1622 );
1623 // The deliverable: the corrected energy grid tracks the truth.
1624 let mut rel: Vec<f64> = result
1625 .energies_corrected
1626 .iter()
1627 .zip(e_true.iter())
1628 .map(|(&c, &t)| (c - t).abs() / t)
1629 .collect();
1630 rel.sort_by(f64::total_cmp);
1631 let med = rel[rel.len() / 2];
1632 assert!(
1633 med < 5e-3,
1634 "median corrected-energy rel err {med:.3e} exceeds 5e-3"
1635 );
1636 }
1637
1638 #[test]
1639 fn test_calibrate_wide_offset_venus_scale_at_paper_density() {
1640 // 0.3 % L + 1 µs at the SoftwareX U-238 foil density.
1641 assert_wide_offset_recovery(1.003, 1.0, 5e-3, 0.15);
1642 }
1643
1644 #[test]
1645 fn test_calibrate_wide_offset_venus_scale_at_trace_density() {
1646 // Same offsets at trace density — the silent-failure regime
1647 // (wrong answer previously carried chi²_r ≈ 1e-4).
1648 assert_wide_offset_recovery(1.003, 1.0, 2e-5, 0.3);
1649 }
1650
1651 #[test]
1652 fn test_calibrate_wide_offset_beyond_one_box_at_paper_density() {
1653 // 1.2 % L exceeds the per-fit ±1 % L_scale box → exercises the
1654 // re-anchoring composition; 6 µs t0 is mid coarse-grid.
1655 assert_wide_offset_recovery(1.012, 6.0, 5e-3, 0.15);
1656 }
1657
1658 #[test]
1659 fn test_calibrate_wide_offset_beyond_one_box_at_midband_density() {
1660 assert_wide_offset_recovery(1.012, 6.0, 1.5e-4, 0.15);
1661 }
1662
1663 /// Near-lower-edge density: `true_n = 2e-5` sits just inside the
1664 /// `[1e-5, 1e-2]` documented user-supported interval — approximately
1665 /// 0.3 decades (a factor of 2) above the lower documented edge,
1666 /// comfortably outside the boundary guard's `5 %`-linear tolerance
1667 /// window. Recovery must succeed. Note the 30 % relative tolerance —
1668 /// at this low density the chi² landscape is shallow (single-resonance
1669 /// synthetic, weak signal), so the recovered density can drift further
1670 /// from the true value than at mid-band; the test still meaningfully
1671 /// distinguishes "we found roughly the right decade" from the old
1672 /// behaviour of being unable to reach the value at all. The
1673 /// `test_calibrate_energy_boundary_saturation_error` test separately
1674 /// verifies the guard fires for genuinely-out-of-band densities.
1675 #[test]
1676 fn test_calibrate_energy_recovers_density_1e_5() {
1677 let true_n = 2e-5;
1678 let (result, _, _) = calibrate_round_trip_at_density(true_n)
1679 .expect("calibration at true_n=2e-5 must succeed");
1680 assert!(
1681 (result.total_density - true_n).abs() / true_n < 0.3,
1682 "n: got {}, expected {}",
1683 result.total_density,
1684 true_n,
1685 );
1686 assert!(result.reduced_chi_squared.is_finite());
1687 }
1688
1689 /// Documented lower bound: `true_n = 1.0e-5` atoms/barn is exactly
1690 /// the lower edge promised by the `calibrate_energy` rustdoc. Before
1691 /// the search-band widening the boundary-saturation guard's
1692 /// `~5 %`-linear tolerance trimmed a sliver off either side and
1693 /// rejected truly-at-the-edge optima with a "true optimum likely
1694 /// lies outside this band" diagnostic that contradicted the docs.
1695 /// With the internal band widened to `[~5e-6, ~2e-2]`, an optimum
1696 /// at the documented edge sits ~0.3 decades inside the buffer and
1697 /// is accepted — the user-facing contract here is that the call
1698 /// returns `Ok(_)` (no boundary-saturation error) and recovers a
1699 /// density close to truth in log-space, not that the recovered
1700 /// value is bit-exactly bounded by the documented interval: chi²
1701 /// minimisation can land slightly outside `[1e-5, 1e-2]` even when
1702 /// the true density sits at the edge, and that is correct
1703 /// behaviour for a smooth optimisation landscape.
1704 #[test]
1705 fn test_calibrate_energy_accepts_density_at_documented_lower_bound() {
1706 let true_n = 1.0e-5;
1707 let (result, _, _) = calibrate_round_trip_at_density(true_n)
1708 .expect("calibration at the documented lower edge 1e-5 must succeed");
1709 // Log-space tolerance because the chi² landscape is shallow at
1710 // this trace density (single-resonance synthetic, weak signal):
1711 // a recovered-vs-truth ratio of 2× corresponds to 0.3 in
1712 // log10(n) and is the empirically reasonable precision floor.
1713 let log_err = (result.total_density.log10() - true_n.log10()).abs();
1714 assert!(
1715 log_err < 0.3,
1716 "log10(n) error {log_err} too large; recovered {} vs truth {true_n}",
1717 result.total_density,
1718 );
1719 assert!(result.reduced_chi_squared.is_finite());
1720 }
1721
1722 /// Documented upper bound: `true_n = 1.0e-2` atoms/barn is exactly
1723 /// the upper edge promised by `calibrate_energy`'s rustdoc — the
1724 /// `1 mm metal foil` use case that drives the SoftwareX paper's
1725 /// calibration narrative. Sister test to
1726 /// `test_calibrate_energy_accepts_density_at_documented_lower_bound`;
1727 /// the search-band widening keeps the upper documented edge inside
1728 /// the boundary guard's tolerance buffer. As with the lower-bound
1729 /// test, the assertion is on chi²-resolution recovery (log-space
1730 /// proximity to truth), not on hard-bounding the result inside
1731 /// `[1e-5, 1e-2]` — a smooth optimum at the edge can land just
1732 /// outside without indicating any defect.
1733 #[test]
1734 fn test_calibrate_energy_accepts_density_at_documented_upper_bound() {
1735 let true_n = 1.0e-2;
1736 let (result, _, _) = calibrate_round_trip_at_density(true_n)
1737 .expect("calibration at the documented upper edge 1e-2 must succeed");
1738 // Tighter log-space tolerance than the lower edge: at this
1739 // high density the resonance is saturated, the chi²
1740 // landscape is sharp and the (L, t₀, n) trade-off basin is
1741 // narrow. log_err < 0.05 ≈ 12 % linear is comfortable.
1742 let log_err = (result.total_density.log10() - true_n.log10()).abs();
1743 assert!(
1744 log_err < 0.05,
1745 "log10(n) error {log_err} too large; recovered {} vs truth {true_n}",
1746 result.total_density,
1747 );
1748 assert!(result.reduced_chi_squared.is_finite());
1749 }
1750
1751 /// Phase-1-grid-point density: `true_n = 1e-4` was the historical
1752 /// reachable-band centre. Recovery is the easiest case for the
1753 /// chi² landscape and tightens the tolerance accordingly.
1754 #[test]
1755 fn test_calibrate_energy_recovers_density_1e_4() {
1756 let true_n = 1e-4;
1757 let (result, _, _) = calibrate_round_trip_at_density(true_n)
1758 .expect("calibration at true_n=1e-4 must succeed");
1759 assert!(
1760 (result.total_density - true_n).abs() / true_n < 0.1,
1761 "n: got {}, expected {}",
1762 result.total_density,
1763 true_n,
1764 );
1765 assert!(result.reduced_chi_squared.is_finite());
1766 }
1767
1768 /// Mid-band density: `true_n = 1e-3` was **unreachable** under
1769 /// the previous 5-point scan + multiplicative refinement; the
1770 /// best the old code could return was ~4.95e-4. After the
1771 /// log-space golden-section refactor this density must round-
1772 /// trip with full Phase-3 precision.
1773 #[test]
1774 fn test_calibrate_energy_recovers_density_1e_3() {
1775 let true_n = 1e-3;
1776 let (result, _, _) = calibrate_round_trip_at_density(true_n)
1777 .expect("calibration at true_n=1e-3 must succeed");
1778 assert!(
1779 (result.total_density - true_n).abs() / true_n < 0.1,
1780 "n: got {}, expected {} — old code saturated at ~4.95e-4",
1781 result.total_density,
1782 true_n,
1783 );
1784 assert!(result.reduced_chi_squared.is_finite());
1785 }
1786
1787 /// SoftwareX U-238 reference density: 1 mm metal foil at ~5e-3
1788 /// atoms/barn. This is the density the paper figure scripts
1789 /// (`gen_fig_physics.py`, `gen_fig_closed_loop.py`) use and
1790 /// that the paper's calibration narrative relies on; it sits a
1791 /// factor 10× above the old reachable maximum.
1792 #[test]
1793 fn test_calibrate_energy_recovers_density_5e_3() {
1794 let true_n = 5e-3;
1795 let (result, _, _) = calibrate_round_trip_at_density(true_n)
1796 .expect("calibration at true_n=5e-3 must succeed");
1797 assert!(
1798 (result.total_density - true_n).abs() / true_n < 0.1,
1799 "n: got {}, expected {} — old code saturated at ~4.95e-4 (10× too low)",
1800 result.total_density,
1801 true_n,
1802 );
1803 assert!(result.reduced_chi_squared.is_finite());
1804 }
1805
1806 /// Out-of-band saturation: `true_n = 1.0` atoms/barn is two
1807 /// orders of magnitude above the upper search bound (`1e-2`).
1808 /// The golden-section minimum must land on the upper bound,
1809 /// and the boundary-saturation guard must turn that into an
1810 /// `Err(InvalidParameter)` rather than the silent railed answer
1811 /// the old 5-point scan would have returned (the old code
1812 /// would have railed to `4.95e-4`, six orders of magnitude
1813 /// below truth, with no diagnostic).
1814 #[test]
1815 fn test_calibrate_energy_boundary_saturation_error() {
1816 let true_n = 1.0;
1817 let err = calibrate_round_trip_at_density(true_n)
1818 .expect_err("density outside search band must trigger boundary guard");
1819 match err {
1820 PipelineError::InvalidParameter(msg) => {
1821 assert!(
1822 msg.contains("search boundary") || msg.contains("boundary"),
1823 "error must explain boundary saturation, got: {msg}"
1824 );
1825 }
1826 other => panic!("expected InvalidParameter, got {other:?}"),
1827 }
1828 }
1829}