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}