nereids_fitting/resolution_calib.rs
1//! Instrument-resolution calibration.
2//!
3//! Fits the **instrument-resolution parameters** of a chosen model family to a
4//! known-(ρ, T) calibrant, holding the sample density and temperature FIXED.
5//! This is the calibrate step of the standard calibrate→pin→fit procedure:
6//! characterize the beamline resolution once on a known standard, pin it, then
7//! fit unknown samples ([`crate::transmission_model`] / the typed fitters).
8//!
9//! Mechanism: an outer [`crate::nelder_mead`] optimizer over the few resolution
10//! parameters; each evaluation builds a [`ResolutionFunction`] from the
11//! parameter vector, runs the existing [`forward_model`] at the fixed
12//! [`SampleParams`], and returns χ²/dof after analytically fitting a
13//! normalization (`anorm`) and optional low-order baseline (so a baseline offset
14//! does not leak into the resolution). Calibration is once-per-experiment, so a
15//! derivative-free outer loop — not LM resolution-Jacobians — is the right tool;
16//! this mirrors the established outer-loop pattern in
17//! [`crate::joint_poisson`]'s polish stage.
18//!
19//! Families ([`ResolutionFamily`]):
20//! - **Gaussian** — fit `(Δt, ΔL)`.
21//! - **UdrCorr** — fit a shape-preserving width correction `s(E)=s0·(E/Eref)^p`
22//! on a base tabulated UDR ([`TabulatedResolution::width_corrected`]); trusts
23//! the Monte-Carlo shape, calibrates its width/energy-dependence. **UDR** =
24//! *User-Defined Resolution*, SAMMY's term for a numerical (table-supplied)
25//! resolution function.
26//! - **IkedaCarpenter** — physics-complete bounded moderator fit (#642):
27//! `α(E) = e^{θ0}·√E + e^{θ1}` (positive at every energy by construction),
28//! `β = e^{θ2}` (bounded), scalar storage fraction `R = θ3 ∈ [0, 1]`, all
29//! folded with the SNS PSR channel triangle
30//! ([`CalibrationConfig::psr_fwhm_ns`], default 350 ns; optionally fitted
31//! via `fit_psr`). Beware the β↔R ridge: as `R → 0` the storage term
32//! vanishes and β is unconstrained — such a fit reports `"r:lower"` in
33//! [`CalibrationResult::bounds_hit`] and its β carries no information.
34
35use std::sync::Arc;
36
37use nereids_physics::ikeda_carpenter::{
38 EnergyLaw, IkedaCarpenter, IkedaCarpenterParams, SynthesisGrid,
39};
40use nereids_physics::resolution::{
41 ResolutionFunction, ResolutionParams, TOF_FACTOR, TabulatedResolution,
42};
43use nereids_physics::transmission::{InstrumentParams, SampleParams, forward_model};
44
45use crate::error::FittingError;
46use crate::nelder_mead::{NelderMeadConfig, NelderMeadResult, nelder_mead_minimize};
47
48/// Reference energy (eV) for the UDR width-correction power law `s(E)`.
49const UDR_E_REF: f64 = 10.0;
50/// Width-scale clamp for the UDR correction (`s0 = clamp(exp(log_s0), …)`).
51/// `pub` so the Python binding decodes the reported `s0` against the *same*
52/// bounds the optimizer used, rather than duplicating the literals.
53pub const UDR_S0_MIN: f64 = 0.2;
54/// Upper width-scale clamp; see [`UDR_S0_MIN`].
55pub const UDR_S0_MAX: f64 = 5.0;
56// --- Ikeda–Carpenter calibration family: θ encoding and physics bounds (#642) ---
57//
58// θ = [ln a0, ln a1, ln β, R, (PSR FWHM µs iff fit_psr)]. The rates are
59// exp-encoded so that `α(E) = e^{θ0}·√E + e^{θ1} > 0` at EVERY energy — on the
60// calibration grid and on any production grid the pinned kernel is later
61// applied to — by construction (a real calibration under the old plain-(a0,a1)
62// box once returned a1 = −0.396, which drives α(E) < 0 at low energy and makes
63// the pulse unphysical). β and R are FREE: the 2-parameter predecessor (β
64// pinned, R ≡ exp(−E_meV/25) ≈ 0 in the eV regime) lacked the storage-shape
65// freedom, which re-expressed as a ~90 K temperature degeneracy on real data.
66
67/// Lower box bound on the prompt-rate coefficient `a0` (µs⁻¹ per √eV) of
68/// `α(E) = a0·√E + a1`; same span as the previous plain box `(0.01, 5.0)`.
69/// α ≈ 1–3 µs⁻¹ in the eV regime (Ikeda & Carpenter, NIM A239 (1985) 536)
70/// gives a0 ≈ 0.2–0.5; the decade of head-room on each side is deliberate.
71const IC_A0_MIN: f64 = 0.01;
72/// Upper box bound on `a0`; see [`IC_A0_MIN`].
73const IC_A0_MAX: f64 = 5.0;
74/// Optimizer start for `a0` — the VENUS-scale prompt slope used since the
75/// family was introduced (matches the previous start).
76const IC_A0_X0: f64 = 0.30;
77/// Lower box bound on the energy-independent prompt offset `a1` (µs⁻¹).
78/// Strictly positive (exp-encoded) so `α(E) → a1 > 0` as `E → 0`: the offset
79/// can no longer flip α negative below the calibration window.
80const IC_A1_MIN: f64 = 1e-3;
81/// Upper box bound on `a1`: 2 µs⁻¹ already exceeds the whole prompt rate at
82/// 1 eV for any plausible a0, so the bound is not physically restrictive.
83const IC_A1_MAX: f64 = 2.0;
84/// Optimizer start for `a1` — small positive (a mostly-√E law).
85const IC_A1_X0: f64 = 0.05;
86/// Lower box bound on the storage (slow) rate `β` (µs⁻¹). Covers the
87/// canonical Ikeda–Carpenter ambient-moderator value β ≈ 0.031 µs⁻¹
88/// (NIM A239 (1985) 536; also Mantid `IkedaCarpenterPV`'s β default) with
89/// margin below it. The τ-grid is prompt-anchored and capped (nereids-physics
90/// `MAX_TAU_SAMPLES`), so the 16/β ≈ 800 µs tail at this bound stays sampled
91/// at a ≈ 0.098 µs capped step — fine enough for the default 0.35 µs (and
92/// any ≥ ~0.3 µs) PSR triangle and for prompt rates up to α ≈ 26 µs⁻¹. A
93/// fitted PSR near its 0.05 µs floor combined with β near this bound is
94/// unresolvable within the cap; such θ are treated as infeasible points
95/// (∞ objective) during the search, never as a calibration abort — see
96/// `ic_box_worst_corner_synthesizes_within_tau_cap` /
97/// `ic_unresolvable_theta_errs_in_build_resolution` /
98/// `ic_infeasible_pocket_inside_box_completes_calibration`.
99const IC_BETA_MIN: f64 = 0.02;
100/// Upper box bound on `β`: at 5 µs⁻¹ the storage tail is as fast as the
101/// prompt core itself (α range), beyond which β↔α are indistinguishable.
102const IC_BETA_MAX: f64 = 5.0;
103/// Optimizer start for `β` — the value the retired fixed-β family pinned.
104const IC_BETA_X0: f64 = 0.10;
105/// Lower box bound on the storage mixing fraction `R` (physical: a fraction).
106const IC_R_MIN: f64 = 0.0;
107/// Upper box bound on `R`; see [`IC_R_MIN`].
108const IC_R_MAX: f64 = 1.0;
109/// Optimizer start for `R`. A scalar `R` replaces the retired
110/// `ExpMilliEv{κ=25}` law: a free κ is unidentifiable in the eV regime
111/// (R ≡ 0 across 1–200 eV for ANY plausible κ), whereas a scalar R lets the
112/// data decide whether a storage tail is present at all.
113const IC_R_X0: f64 = 0.1;
114/// Default SNS PSR (proton-storage-ring / accumulator) channel-triangle FWHM
115/// in **ns**, folded into the IC family's kernel. The SNS proton pulse is
116/// shaped by the accumulator ring into an ~triangular ~700 ns base (FWHM ≈
117/// 350 ns) — the VENUS tabulated FTS kernel header records exactly this
118/// ("folded triang FWHM 350 ns PSR"). SAMMY's analog is the Gaussian-burst
119/// FWHM `DELTAG` (Manual Sec. III.C.1.a, eq. III C1 a.12) or square `BURST`
120/// width (Sec. III.C.2.a). `pub` so the Python binding's default and the Rust
121/// default cannot drift apart.
122pub const DEFAULT_PSR_FWHM_NS: f64 = 350.0;
123/// Lower box bound (µs) on a FITTED PSR triangle FWHM (`fit_psr = true`):
124/// below 50 ns the triangle is far under the IC prompt width in the eV
125/// regime and unidentifiable.
126const PSR_FWHM_US_MIN: f64 = 0.05;
127/// Upper box bound (µs) on a fitted PSR FWHM: 1 µs is ~3× the physical SNS
128/// pulse base — anything larger is the moderator's job (α, β), not the burst.
129const PSR_FWHM_US_MAX: f64 = 1.0;
130/// Sanity ceiling (µs) on the configured PSR triangle FWHM: one decade above
131/// the `PSR_FWHM_US_MAX` fit bound. [`CalibrationConfig::psr_fwhm_ns`] is in
132/// NANOSECONDS (the VENUS FTS header convention: "folded triang FWHM 350 ns
133/// PSR"), and kernel-synthesis cost grows QUADRATICALLY with a wide fold's
134/// width. The mechanism is NOT τ-step refinement — that applies only to
135/// folds FINER than the prompt design step, and a 50–350 µs fold's FWHM/3
136/// resolution floor is far coarser, leaving the step unchanged — it is the
137/// convolution itself: the ±FWHM fold-reach margin adds O(FWHM/step)
138/// τ-samples, each folded in `convolve_same` against a sampled triangle
139/// itself O(FWHM/step) long. Measured ~12 ms at 0.35 µs but ~1.3 s at 50 µs
140/// and ~28 s at 350 µs per single kernel-table synthesis at the default
141/// grid. A µs-as-ns unit slip
142/// (passing `350` meaning µs → interpreted as a 350 µs pin) would therefore
143/// turn a calibration into a multi-hour silent hang behind a physically
144/// fictitious fold. Any genuine width sits inside the fitted box; one decade
145/// of headroom keeps deliberate sensitivity studies possible while still
146/// catching the 1000× ns↔µs slip. `0.0` (fold disabled) is always accepted.
147/// `pub` for parity with the Python binding's mirrored validation.
148pub const PSR_FWHM_PIN_CEILING_US: f64 = 10.0 * PSR_FWHM_US_MAX;
149/// Nanoseconds → microseconds ([`CalibrationConfig::psr_fwhm_ns`] is in ns to
150/// match the FTS header convention; the kernel synthesis takes µs). `pub` so
151/// the Python binding's mirrored [`PSR_FWHM_PIN_CEILING_US`] check converts
152/// with the identical factor.
153pub const NS_TO_US: f64 = 1e-3;
154/// A coordinate within this fraction of its box range of a bound is reported
155/// in [`CalibrationResult::bounds_hit`] as pinned.
156const BOUND_HIT_REL_TOL: f64 = 1e-3;
157/// Cap on per-restart Nelder–Mead simplex re-inflations (fresh simplex
158/// restarted at the incumbent while it keeps improving by more than `fatol`).
159/// Guards against premature simplex collapse — see the re-inflation comment
160/// in [`calibrate_resolution`] — while guaranteeing termination.
161const MAX_SIMPLEX_REINFLATIONS: usize = 5;
162/// Initial-step fraction for the RE-INFLATED simplex (vs the default 0.05 of
163/// the first descent). A collapsed simplex rebuilt at the same 5 % scale
164/// deterministically re-collapses to the same trap (observed on the IC
165/// family's curved α↔β↔R valley); a 25 % edge straddles the valley and lets
166/// the restarted simplex see the descent direction.
167const REINFLATE_STEP_FRAC: f64 = 0.25;
168/// Absolute re-inflation step for near-zero coordinates (`|x| < 1e-8`),
169/// matching the box scale of the bounded coordinates (R ∈ [0, 1]).
170const REINFLATE_STEP_ABS: f64 = 0.1;
171/// Guard-rail bound (µs) on the optional fitted TOF zero `t0`. `t0` and `L_scale`
172/// are the SAMMY *energy-scale* parameters; resolution calibration pins them by
173/// default and fits them only as an explicit, prior-constrained opt-in (see
174/// [`CalibrationConfig::with_position_prior`]). ±5 µs is a guard rail far inside
175/// the feasible `|t0| < min(TOF)`; the *real* constraint on `t0` is the metrology
176/// prior, not this bound.
177///
178/// History: a previous design fit a *free per-family* constant `t0` and discarded
179/// it ("position nuisance") to make the cross-family χ² compare shape/width. That
180/// was wrong — the asymmetric-kernel mode→centroid lag is `≈1/√E` (exact for the
181/// `a1=0` prompt law `α=a0√E`, leading-order otherwise), the SAME basis as an
182/// `L_scale` error, so a free per-family `t0`/`L_scale` lets a wrong (symmetric)
183/// family imitate the lag and buy back the strongest evidence against it (χ² 6.0 →
184/// 1.3 in the Hf-177 study). Position is now a SHARED energy-scale parameter with a
185/// metrology prior, never a free per-family knob.
186const POSITION_T0_US_MAX: f64 = 5.0;
187/// Guard-rail bounds (±2%) on the optional fitted flight-path scale `L_scale`.
188/// The IC mode→centroid lag needs only `ΔL/L ≈ 0.22%` to be mimicked, so a *free*
189/// `L_scale` absorbs the lag and corrupts the calibrated width — fit it only under
190/// a prior, for an explicit energy-scale / identifiability study.
191const POSITION_L_SCALE_MIN: f64 = 0.98;
192/// Upper guard-rail bound on `L_scale`; see [`POSITION_L_SCALE_MIN`].
193const POSITION_L_SCALE_MAX: f64 = 1.02;
194
195/// Map an energy grid through the SAMMY energy-scale `(t0, L_scale)`, using the
196/// SAME convention as `EnergyScaleTransmissionModel::corrected_energies`: with
197/// nominal `tof(E) = TOF_FACTOR·L/√E`, the corrected energy is
198/// `E' = (TOF_FACTOR·L·L_scale / (tof − t0))²`. Identity at `(t0, L_scale) = (0, 1)`.
199///
200/// Note the `−t0` sign (a positive `t0` is *subtracted* from the measured TOF, so
201/// it raises the corrected energy) — this is the shipped energy-scale convention,
202/// opposite to the `+t0` form used by the retired position nuisance. Errors if any
203/// corrected TOF `tof − t0 ≤ 0` (a `t0` past the shortest flight time).
204///
205/// SAMMY reference for the `−t0` sign convention: `dat/mdat0.f90:189`
206/// (`etzero = ee*(Elzero/(1−Tzero·√ee/Tttzzz))²`) — the measured TOF has TZERO
207/// subtracted before the energy conversion, so a positive `t0` raises the
208/// corrected energy. This is the canonical NEREIDS implementation of the
209/// formula; the runtime
210/// `EnergyScaleTransmissionModel::corrected_energies` is pinned bit-for-bit
211/// to it by `corrected_energy_grid_matches_energy_scale_model`, and
212/// `SpectrumFitResult::corrected_energies` (issue #634) reuses it so callers
213/// never re-derive the transform (a `+t0` slip caused a silent +400 K bias).
214pub fn corrected_energy_grid(
215 energies: &[f64],
216 t0_us: f64,
217 l_scale: f64,
218 flight_path_m: f64,
219) -> Result<Vec<f64>, FittingError> {
220 // Validate scale inputs up front (issue #634 review): the transform is
221 // EVEN in `l_scale` (`(kl·l_scale/denom)²`), so a negative `l_scale`
222 // would silently return the identical plausible grid as its positive
223 // counterpart, a NaN `l_scale` would pass the denominator-only guard and
224 // return `Ok(vec![NaN])` ("NaN bypasses guards"), and
225 // `flight_path_m = 0` with a negative fitted `t0` would return an
226 // all-zeros grid as `Ok`. Matches the sibling fit entry points'
227 // `validate_energy_scale_params` rejection (issue #458) — this is the
228 // canonical public transform, so it must not hand back plausible
229 // garbage for invalid inputs.
230 if !t0_us.is_finite() {
231 return Err(FittingError::EvaluationFailed(format!(
232 "corrected_energy_grid: t0_us must be finite, got {t0_us}"
233 )));
234 }
235 if !l_scale.is_finite() || l_scale <= 0.0 {
236 return Err(FittingError::EvaluationFailed(format!(
237 "corrected_energy_grid: l_scale must be finite and positive, got {l_scale}"
238 )));
239 }
240 if !flight_path_m.is_finite() || flight_path_m <= 0.0 {
241 return Err(FittingError::EvaluationFailed(format!(
242 "corrected_energy_grid: flight_path_m must be finite and positive, \
243 got {flight_path_m}"
244 )));
245 }
246 // Per-bin grid validation runs BEFORE the identity shortcut so the
247 // Ok/Err contract is uniform: previously (t0=0, l_scale=1) returned a
248 // NaN/non-positive grid verbatim while any other transform rejected the
249 // same grid via the denominator guard (#634 review). Empty grids are
250 // rejected for the same uniformity (the Python binding already errors
251 // on them; a per-bin loop is vacuous on an empty slice).
252 if energies.is_empty() {
253 return Err(FittingError::EvaluationFailed(
254 "corrected_energy_grid: energies must not be empty".into(),
255 ));
256 }
257 for (i, &e) in energies.iter().enumerate() {
258 if !e.is_finite() || e <= 0.0 {
259 return Err(FittingError::EvaluationFailed(format!(
260 "corrected_energy_grid: energies[{i}] must be finite and positive, got {e}"
261 )));
262 }
263 // Strict ascending order, matching the Python binding's standard
264 // energy-grid validation (issue #634 review): a non-monotone input
265 // would otherwise map to a plausible but non-monotone corrected axis.
266 if i > 0 && e <= energies[i - 1] {
267 return Err(FittingError::EvaluationFailed(format!(
268 "corrected_energy_grid: energies must be strictly ascending; \
269 energies[{i}] = {e} <= energies[{}] = {}",
270 i - 1,
271 energies[i - 1],
272 )));
273 }
274 }
275 if t0_us == 0.0 && l_scale == 1.0 {
276 return Ok(energies.to_vec());
277 }
278 let kl = TOF_FACTOR * flight_path_m;
279 energies
280 .iter()
281 .map(|&e| {
282 let tof = kl / e.sqrt();
283 let denom = tof - t0_us;
284 if denom <= 0.0 || !denom.is_finite() {
285 return Err(FittingError::EvaluationFailed(
286 "corrected TOF ≤ 0: t0 exceeds the shortest flight time".into(),
287 ));
288 }
289 Ok((kl * l_scale / denom).powi(2))
290 })
291 .collect()
292}
293
294/// The resolution-model family to calibrate.
295#[derive(Debug, Clone)]
296pub enum ResolutionFamily {
297 /// Gaussian `(Δt_µs, ΔL_m)`.
298 Gaussian,
299 /// Width-corrected tabulated UDR: fit `(log s0, p)` against `base`.
300 UdrCorr {
301 /// Base Monte-Carlo kernel to correct.
302 base: Arc<TabulatedResolution>,
303 },
304 /// Ikeda–Carpenter, physics-complete and bounded (#642):
305 /// `θ = [ln a0, ln a1, ln β, R]` with `α(E) = e^{θ0}√E + e^{θ1}` (positive
306 /// by construction), `β = e^{θ2}` bounded, scalar `R = θ3 ∈ [0, 1]`, all
307 /// folded with the SNS PSR channel triangle
308 /// ([`CalibrationConfig::psr_fwhm_ns`], default [`DEFAULT_PSR_FWHM_NS`]).
309 IkedaCarpenter {
310 /// Also fit the PSR triangle FWHM: appends `θ4` (µs, box-bounded
311 /// 0.05–1.0 µs, started at [`CalibrationConfig::psr_fwhm_ns`]
312 /// clamped into that box). A positive starting width outside the box
313 /// — legal as a pin up to [`PSR_FWHM_PIN_CEILING_US`] — starts at
314 /// the nearer box edge with a stderr warning; a fit that stays there
315 /// reports `psr_fwhm_us:lower` / `:upper` in
316 /// [`CalibrationResult::bounds_hit`]. Off by default — the 350 ns
317 /// SNS PSR width is machine metrology, not a per-experiment unknown.
318 fit_psr: bool,
319 },
320}
321
322impl ResolutionFamily {
323 /// Number of free parameters.
324 #[must_use]
325 pub fn n_params(&self) -> usize {
326 match self {
327 ResolutionFamily::Gaussian | ResolutionFamily::UdrCorr { .. } => 2,
328 ResolutionFamily::IkedaCarpenter { fit_psr } => 4 + usize::from(*fit_psr),
329 }
330 }
331
332 /// Names of the raw optimizer coordinates, in [`CalibrationResult::theta`]
333 /// order. Used to label [`CalibrationResult::bounds_hit`]; the `ln_*`
334 /// prefixes flag exp-encoded coordinates (decode via
335 /// [`CalibrationResult::resolution`] rather than by hand).
336 #[must_use]
337 pub fn param_names(&self) -> Vec<&'static str> {
338 match self {
339 ResolutionFamily::Gaussian => vec!["delta_t_us", "delta_l_m"],
340 ResolutionFamily::UdrCorr { .. } => vec!["log_s0", "p"],
341 ResolutionFamily::IkedaCarpenter { fit_psr } => {
342 let mut names = vec!["ln_a0", "ln_a1", "ln_beta", "r"];
343 if *fit_psr {
344 names.push("psr_fwhm_us");
345 }
346 names
347 }
348 }
349 }
350
351 fn label(&self) -> &'static str {
352 match self {
353 ResolutionFamily::Gaussian => "gaussian",
354 ResolutionFamily::UdrCorr { .. } => "udr_corr",
355 ResolutionFamily::IkedaCarpenter { .. } => "ic",
356 }
357 }
358
359 /// `(start vector, box bounds)` for the optimizer (mirrors the validated
360 /// Python reference: `udr_corr` uses log-`s0`; bounds keep widths positive).
361 /// `cfg` supplies the starting PSR FWHM when the IC family fits it.
362 fn x0_bounds(&self, cfg: &CalibrationConfig) -> (Vec<f64>, Vec<(f64, f64)>) {
363 match self {
364 ResolutionFamily::Gaussian => (
365 vec![2.0, 1e-3],
366 vec![GAUSSIAN_DELTA_T_BOUNDS_US, GAUSSIAN_DELTA_L_BOUNDS_M],
367 ),
368 ResolutionFamily::UdrCorr { .. } => {
369 // (log s0, p): s0 = exp(log_s0) clamped to [0.2, 5].
370 (
371 vec![0.0, 0.0],
372 vec![(UDR_S0_MIN.ln(), UDR_S0_MAX.ln()), (-4.0, 4.0)],
373 )
374 }
375 ResolutionFamily::IkedaCarpenter { fit_psr } => {
376 let mut x0 = vec![IC_A0_X0.ln(), IC_A1_X0.ln(), IC_BETA_X0.ln(), IC_R_X0];
377 let mut bounds = vec![
378 (IC_A0_MIN.ln(), IC_A0_MAX.ln()),
379 (IC_A1_MIN.ln(), IC_A1_MAX.ln()),
380 (IC_BETA_MIN.ln(), IC_BETA_MAX.ln()),
381 (IC_R_MIN, IC_R_MAX),
382 ];
383 if *fit_psr {
384 // cfg.psr_fwhm_ns > 0 is guaranteed here (fit_psr with a
385 // zero width is rejected up front — "0 disables" cannot
386 // silently become a fitted 0.05 µs). A positive start
387 // outside the fit box — legal as a PIN up to
388 // PSR_FWHM_PIN_CEILING_US — is CLAMPED to the nearer box
389 // edge, not rejected (#645 round 4, F3), and the clamp is
390 // announced on stderr: a clamped start that never leaves
391 // its edge additionally surfaces as "psr_fwhm_us:lower" /
392 // ":upper" in `CalibrationResult::bounds_hit`.
393 let start_us = cfg.psr_fwhm_ns * NS_TO_US;
394 let clamped_us = start_us.clamp(PSR_FWHM_US_MIN, PSR_FWHM_US_MAX);
395 if clamped_us != start_us {
396 eprintln!(
397 "warning: fit_psr starting width psr_fwhm_ns = {} ns lies \
398 outside the PSR fit box [{PSR_FWHM_US_MIN}, {PSR_FWHM_US_MAX}] µs; \
399 starting the fit at the nearer box edge ({clamped_us} µs). A fit \
400 that stays there reports \"psr_fwhm_us:lower\" / \":upper\" in \
401 bounds_hit.",
402 cfg.psr_fwhm_ns
403 );
404 }
405 x0.push(clamped_us);
406 bounds.push((PSR_FWHM_US_MIN, PSR_FWHM_US_MAX));
407 }
408 (x0, bounds)
409 }
410 }
411 }
412}
413
414/// Configuration for [`calibrate_resolution`].
415#[derive(Debug, Clone)]
416pub struct CalibrationConfig {
417 /// Flight-path length (m).
418 pub flight_path_m: f64,
419 /// Fit a low-order baseline (anorm + constant + linear) instead of anorm only.
420 pub fit_background: bool,
421 /// Number of optimizer restarts (perturbed starts; keep the best).
422 pub restarts: usize,
423 /// Nelder–Mead simplex-spread tolerance.
424 pub xatol: f64,
425 /// Nelder–Mead objective-range tolerance.
426 pub fatol: f64,
427 /// Nelder–Mead maximum iterations.
428 pub max_iter: usize,
429 /// IC synthesis grid resolution (energies × τ-samples per kernel).
430 pub ic_n_energies: usize,
431 pub ic_n_tau: usize,
432 /// SNS PSR (accumulator-ring) channel-triangle FWHM in **ns**, folded into
433 /// the **IC family only** (default [`DEFAULT_PSR_FWHM_NS`]; `0.0`
434 /// disables the fold). Tabulated/UDR (FTS) kernels already carry the fold
435 /// in the file itself (header: "folded triang FWHM 350 ns PSR") and are
436 /// structurally never re-folded here — applying it twice would
437 /// double-count the burst. When the family is
438 /// `IkedaCarpenter { fit_psr: true }` this value is the fit's starting
439 /// point instead of a pin, clamped into the 0.05–1 µs fit box: a width
440 /// in (1, 10] µs is a legal pin but an out-of-box start — the fit then
441 /// starts at the box top (announced by a stderr warning), and if it
442 /// stays there it reports `psr_fwhm_us:upper` in
443 /// [`CalibrationResult::bounds_hit`]. Nonzero widths above
444 /// [`PSR_FWHM_PIN_CEILING_US`] (10 µs = 10 000 ns) are rejected as a
445 /// ns↔µs unit slip — see that constant for the quadratic-cost rationale.
446 pub psr_fwhm_ns: f64,
447 /// Fit the SAMMY TOF-zero `t0` (µs) as a SHARED energy-scale parameter.
448 /// **Default `false`** — position is pinned at
449 /// [`position_t0_center_us`](Self::position_t0_center_us) so calibration is a
450 /// pure shape/width fit (matching SAMMY, where `t0`/`L` are a separate
451 /// energy-scale calibration). Opt in only *with* a metrology prior; see
452 /// [`with_position_prior`](CalibrationConfig::with_position_prior).
453 pub fit_t0: bool,
454 /// Fit the flight-path scale `L_scale` as a shared energy-scale parameter.
455 /// **Default `false`.** A free `L_scale` shares the asymmetric-kernel lag's
456 /// `1/√E` basis and corrupts the calibrated width — fit it only under a prior.
457 pub fit_l_scale: bool,
458 /// Prior mean (and pinned value when [`fit_t0`](Self::fit_t0) is false) of the
459 /// TOF zero `t0` (µs). Default `0.0`. Lets a caller inject a pre-calibrated `t0`.
460 pub position_t0_center_us: f64,
461 /// Prior mean (and pinned value when [`fit_l_scale`](Self::fit_l_scale) is
462 /// false) of `L_scale`. Default `1.0`.
463 pub position_l_scale_center: f64,
464 /// Gaussian prior σ on `t0` (µs); `None` = flat (bounded only). When set, adds
465 /// `((t0 − center)/σ)²` to the data χ² (a metrology penalty, *not* part of the
466 /// reported `chi2_dof`).
467 pub position_t0_prior_us: Option<f64>,
468 /// Gaussian prior σ on `L_scale`; `None` = flat. See [`position_t0_prior_us`](Self::position_t0_prior_us).
469 pub position_l_scale_prior: Option<f64>,
470 /// Also measure [`CalibrationResult::intervals`]. Default `false`.
471 ///
472 /// Finding the solution and measuring how well it is determined are two
473 /// separate measurements, and the second costs several times the first:
474 /// every trial point along a coordinate re-minimizes the others. A caller
475 /// that only needs the calibrated resolution to pin into a sample fit
476 /// does not pay for it.
477 pub intervals: bool,
478}
479
480impl Default for CalibrationConfig {
481 fn default() -> Self {
482 // Matches the validated Python calibrator (fatol=1e-3, not the
483 // NelderMeadConfig default 1e-4). The IC synthesis grid is DELIBERATELY
484 // lighter than the standalone IkedaCarpenter default (64×500 here vs the
485 // DEFAULT_N_ENERGIES×DEFAULT_N_TAU = 64×600 synthesis default): the outer
486 // loop re-synthesizes the kernel on every evaluation, and 500 τ-samples is
487 // ample for χ²/dof comparison.
488 Self {
489 flight_path_m: 25.0,
490 fit_background: false,
491 restarts: 1,
492 xatol: 1e-4,
493 fatol: 1e-3,
494 max_iter: 800,
495 ic_n_energies: 64,
496 ic_n_tau: 500,
497 psr_fwhm_ns: DEFAULT_PSR_FWHM_NS,
498 // Position is PINNED by default: pure shape/width calibration on the
499 // (already energy-calibrated) grid. Energy-scale fitting is an explicit
500 // opt-in via `with_position_prior`.
501 fit_t0: false,
502 fit_l_scale: false,
503 position_t0_center_us: 0.0,
504 position_l_scale_center: 1.0,
505 position_t0_prior_us: None,
506 position_l_scale_prior: None,
507 intervals: false,
508 }
509 }
510}
511
512impl CalibrationConfig {
513 /// Enable a SHARED, metrology-priored energy-scale `(t0, L_scale)` fit: sets
514 /// [`fit_t0`](Self::fit_t0)/[`fit_l_scale`](Self::fit_l_scale), the prior means
515 /// (`*_center`), and the Gaussian prior σ. Use this for joint energy-scale or
516 /// cross-family identifiability work; the default config pins position (pure
517 /// shape/width calibration). Pass the prior σ from the instrument's independent
518 /// flight-path / timing metrology — a loose σ marginalizes position (weak,
519 /// honest shape-only discrimination), a tight σ pins it.
520 #[must_use]
521 pub fn with_position_prior(
522 mut self,
523 t0_center_us: f64,
524 l_scale_center: f64,
525 sigma_t0_us: f64,
526 sigma_l_scale: f64,
527 ) -> Self {
528 self.fit_t0 = true;
529 self.fit_l_scale = true;
530 self.position_t0_center_us = t0_center_us;
531 self.position_l_scale_center = l_scale_center;
532 self.position_t0_prior_us = Some(sigma_t0_us);
533 self.position_l_scale_prior = Some(sigma_l_scale);
534 self
535 }
536}
537
538/// Result of a resolution calibration.
539#[derive(Debug, Clone)]
540pub struct CalibrationResult {
541 /// Family label (`"gaussian"` | `"udr_corr"` | `"ic"`).
542 pub family: String,
543 /// Fitted parameter vector (raw optimizer space; see [`ResolutionFamily`]
544 /// and [`ResolutionFamily::param_names`]). For the IC family these are
545 /// ln/box-encoded — read decoded physical values off
546 /// [`resolution`](Self::resolution) instead of exponentiating by hand.
547 pub theta: Vec<f64>,
548 /// Reduced **data** χ²/dof of the best fit (after anorm/baseline). The
549 /// energy-scale prior penalty is *excluded* — it is reported separately as
550 /// [`prior_penalty`](Self::prior_penalty).
551 pub chi2_dof: f64,
552 /// The calibrated resolution, ready to pin into a sample fit.
553 pub resolution: ResolutionFunction,
554 /// Optimizer iterations of the winning restart.
555 pub iterations: usize,
556 /// Whether the winning restart self-converged.
557 pub converged: bool,
558 /// Fitted (or pinned) SAMMY energy-scale TOF zero `t0` (µs). Equals
559 /// `config.position_t0_center_us` when `fit_t0` is false (pinned). When fit, it
560 /// is a SHARED energy-scale parameter (not a per-family nuisance): the resonance
561 /// dip position is confounded with flight-path geometry (the asymmetric-kernel
562 /// lag is the same `1/√E` basis as `L_scale`), so `t0`/`L_scale` are constrained
563 /// by the metrology prior, not free.
564 pub position_t0_us: f64,
565 /// Fitted (or pinned) flight-path scale `L_scale`. Equals
566 /// `config.position_l_scale_center` when `fit_l_scale` is false.
567 pub position_l_scale: f64,
568 /// Gaussian-prior penalty `Σ((θ−center)/σ)²` on the fitted `(t0, L_scale)` at the
569 /// solution (0 when no position prior is active). `objective = χ²_data +
570 /// prior_penalty`; report it alongside `chi2_dof` so a large position move
571 /// (e.g. a wrong family needing ΔL/L ≫ the metrology σ) is visible, not hidden.
572 pub prior_penalty: f64,
573 /// Total outer-loop free parameters: resolution θ plus any FITTED position
574 /// coordinates (`t0`, `L_scale`). Makes cross-family χ² comparisons and
575 /// dof bookkeeping explicit now that families differ in size (IC is 4–5
576 /// parameters, Gaussian/UdrCorr are 2).
577 pub n_free_params: usize,
578 /// One-sigma interval of each fitted coordinate as absolute `(lower,
579 /// upper)` bounds, in the same raw optimizer space as
580 /// [`theta`](Self::theta) (plus any fitted `t0` / `L_scale`), one entry
581 /// per [`n_free_params`](Self::n_free_params).
582 ///
583 /// Each bound is where the objective, minimized over the other
584 /// coordinates, rises by one above its floor. The two sides are
585 /// independent numbers because the interval is genuinely asymmetric: a
586 /// kernel narrower than the line it broadens stops being visible, so the
587 /// objective is nearly flat below the intrinsic width and steep above it.
588 ///
589 /// A bound equal to the coordinate's box edge means the data does not
590 /// constrain that side at all.
591 ///
592 /// `None` when [`CalibrationConfig::intervals`] is off, or when the run
593 /// exhausted its iteration budget without self-converging (which
594 /// [`converged`](Self::converged) reports): an interval about a point
595 /// that was never shown to be a minimum is not an uncertainty.
596 ///
597 /// This is what a sample fit needs in order to carry the calibrated
598 /// resolution as a prior instead of pinning it. Pinning does not bias the
599 /// fitted temperature much, but it reports it as more certain than it is:
600 /// resolution width and temperature both broaden the line, so the
601 /// uncertainty that belongs to their degeneracy is dropped.
602 pub intervals: Option<Vec<(f64, f64)>>,
603 /// Coordinates that finished within `BOUND_HIT_REL_TOL·(hi−lo)` of a box
604 /// bound, as `"name:lower"` / `"name:upper"` (names from
605 /// [`ResolutionFamily::param_names`], plus `"t0_us"` / `"l_scale"` when
606 /// position is fitted). Empty = interior solution. A pinned bound makes a
607 /// degenerate calibration visible instead of silent: e.g. an eV-regime
608 /// calibrant with no storage tail drives `R → 0` (`"r:lower"`) — on that
609 /// β↔R ridge the storage term vanishes and β is unconstrained, so the
610 /// reported β must not be physically interpreted.
611 pub bounds_hit: Vec<String>,
612}
613
614fn build_resolution(
615 family: &ResolutionFamily,
616 theta: &[f64],
617 e_min: f64,
618 e_max: f64,
619 cfg: &CalibrationConfig,
620) -> Result<ResolutionFunction, FittingError> {
621 match family {
622 ResolutionFamily::Gaussian => {
623 let params =
624 ResolutionParams::new(cfg.flight_path_m, theta[0].abs(), theta[1].abs(), 0.0)
625 .map_err(|e| FittingError::EvaluationFailed(format!("gaussian res: {e:?}")))?;
626 Ok(ResolutionFunction::Gaussian(params))
627 }
628 ResolutionFamily::UdrCorr { base } => {
629 let s0 = theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
630 let corrected = base
631 .width_corrected(s0, theta[1], UDR_E_REF)
632 .map_err(|e| FittingError::EvaluationFailed(format!("udr_corr width: {e}")))?;
633 Ok(ResolutionFunction::Tabulated(Arc::new(corrected)))
634 }
635 ResolutionFamily::IkedaCarpenter { fit_psr } => {
636 // θ = [ln a0, ln a1, ln β, R, (PSR FWHM µs iff fit_psr)] — see the
637 // IC_* constants for the bounds and their physics. Decoding the
638 // exp-encoded coordinates here (not in a new EnergyLaw variant)
639 // keeps the kernel physics in nereids-physics untouched: the
640 // optimizer space guarantees α(E) > 0 and β > 0 by construction.
641 let psr_us = if *fit_psr {
642 theta[4]
643 } else {
644 cfg.psr_fwhm_ns * NS_TO_US
645 };
646 let params = IkedaCarpenterParams {
647 alpha: EnergyLaw::SqrtE {
648 a0: theta[0].exp(),
649 a1: theta[1].exp(),
650 },
651 beta: EnergyLaw::Const(theta[2].exp()),
652 // Scalar R: a free κ in ExpMilliEv is unidentifiable in the eV
653 // regime (R ≡ 0 across 1–200 eV for ANY plausible κ); a scalar
654 // lets the calibrant decide whether a storage tail is present.
655 r: EnergyLaw::Const(theta[3]),
656 burst_sigma_us: None,
657 // SNS PSR channel-triangle fold (0 disables). IC family only —
658 // tabulated/UDR kernels already carry the fold in the file.
659 channel_fwhm_us: (psr_us > 0.0).then_some(psr_us),
660 };
661 let grid = SynthesisGrid {
662 e_min_ev: (e_min * 0.5).max(1e-3),
663 e_max_ev: e_max * 2.0,
664 n_energies: cfg.ic_n_energies,
665 n_tau: cfg.ic_n_tau,
666 };
667 let ic = IkedaCarpenter::new(params, cfg.flight_path_m, &grid)
668 .map_err(|e| FittingError::EvaluationFailed(format!("ic res: {e:?}")))?;
669 Ok(ResolutionFunction::IkedaCarpenter(Arc::new(ic)))
670 }
671 }
672}
673
674/// Weighted residual sum of squares after analytically profiling out `anorm`
675/// (+ optional constant+linear baseline): `data ≈ a·model (+ b0 + b1·x)`, weighted
676/// by `1/unc²`. Returns `(ssr, k)` where `k` is the number of linear nuisance
677/// columns (1 = anorm only, 3 = anorm+const+linear). This is the **raw** χ² (not
678/// divided by dof) so an energy-scale **prior penalty** can be added to it in the
679/// same units before the optimizer minimizes — adding a penalty to a *reduced* χ²
680/// would silently rescale the prior by the dof. Returns `None` on a singular
681/// normal-equations system (a degenerate/constant model column), so the caller can
682/// treat the point as infeasible rather than as a spuriously zeroed fit.
683fn inner_ssr(data: &[f64], unc: &[f64], model: &[f64], fit_bg: bool) -> Option<(f64, usize)> {
684 let n = data.len();
685 let k = if fit_bg { 3 } else { 1 };
686 let mut ata = vec![0.0f64; k * k];
687 let mut atb = vec![0.0f64; k];
688 for i in 0..n {
689 let w2 = 1.0 / unc[i].max(1e-9).powi(2);
690 let x = if n > 1 {
691 -1.0 + 2.0 * (i as f64) / ((n - 1) as f64)
692 } else {
693 0.0
694 };
695 let col = [model[i], 1.0, x];
696 for a in 0..k {
697 atb[a] += w2 * col[a] * data[i];
698 for b in 0..k {
699 ata[a * k + b] += w2 * col[a] * col[b];
700 }
701 }
702 }
703 let coef = solve_small(&ata, &atb, k)?;
704 let mut ssr = 0.0;
705 for i in 0..n {
706 let w2 = 1.0 / unc[i].max(1e-9).powi(2);
707 let x = if n > 1 {
708 -1.0 + 2.0 * (i as f64) / ((n - 1) as f64)
709 } else {
710 0.0
711 };
712 let pred = if fit_bg {
713 coef[0] * model[i] + coef[1] + coef[2] * x
714 } else {
715 coef[0] * model[i]
716 };
717 ssr += (data[i] - pred).powi(2) * w2;
718 }
719 Some((ssr, k))
720}
721
722/// Reduced χ²/dof = [`inner_ssr`] `/ (n − k − n_res_params)`, ∞ on a singular
723/// system. `n_res_params` counts the outer-loop parameters (resolution + any free
724/// position) which are not in the linear system but still consume dof. Test-only:
725/// the calibrator minimizes raw `inner_ssr` (+ prior) and reduces at the solution.
726#[cfg(test)]
727fn inner_chi2(data: &[f64], unc: &[f64], model: &[f64], fit_bg: bool, n_res_params: usize) -> f64 {
728 match inner_ssr(data, unc, model, fit_bg) {
729 Some((ssr, k)) => {
730 let dof = data.len().saturating_sub(k + n_res_params).max(1) as f64;
731 ssr / dof
732 }
733 None => f64::INFINITY,
734 }
735}
736
737/// Gaussian-prior penalty `Σ((θ − center)/σ)²` on the fitted energy-scale
738/// `(t0, L_scale)`. Only active coordinates (fit + prior σ set) contribute; a flat
739/// (σ = `None`) or pinned coordinate contributes 0.
740fn position_prior_penalty(t0_us: f64, l_scale: f64, cfg: &CalibrationConfig) -> f64 {
741 let mut penalty = 0.0;
742 if cfg.fit_t0
743 && let Some(sigma) = cfg.position_t0_prior_us
744 {
745 penalty += ((t0_us - cfg.position_t0_center_us) / sigma).powi(2);
746 }
747 if cfg.fit_l_scale
748 && let Some(sigma) = cfg.position_l_scale_prior
749 {
750 penalty += ((l_scale - cfg.position_l_scale_center) / sigma).powi(2);
751 }
752 penalty
753}
754
755/// Optimizer box for the Gaussian timing width `delta_t_us`.
756///
757/// The upper edge is what the auxiliary grid can carry: the Gaussian
758/// broadening grid is extended by five sigma at each boundary, so its point
759/// count grows with the width, and a forward model at 50 µs already costs two
760/// orders of magnitude more than one at 1 µs on a typical eV-range grid. Any
761/// fit that frees this width uses this box, so none of them can wander into a
762/// grid the machine cannot hold.
763pub const GAUSSIAN_DELTA_T_BOUNDS_US: (f64, f64) = (1.0e-3, 50.0);
764
765/// Optimizer box for the Gaussian flight-path width `delta_l_m`.
766///
767/// Zero is a real value here — a beamline with no measurable path spread —
768/// and the upper edge bounds the same grid growth as
769/// [`GAUSSIAN_DELTA_T_BOUNDS_US`].
770pub const GAUSSIAN_DELTA_L_BOUNDS_M: (f64, f64) = (0.0, 0.5);
771
772/// Rise in the objective that marks one sigma of a single coordinate, the
773/// others minimized over. `chi^2 = -2 ln L` up to a constant, so one sigma is
774/// a rise of one.
775const PROFILE_DELTA_CHI2: f64 = 1.0;
776
777/// First trial displacement of the bracketing search, as a fraction of the
778/// coordinate's own magnitude. It doubles from there until it crosses the
779/// target or reaches the box edge.
780const PROFILE_BRACKET_START_FRACTION: f64 = 1.0e-2;
781
782/// Floor on the magnitude the first displacement is taken from, so a
783/// coordinate sitting near zero still gets a finite one.
784const PROFILE_MIN_SCALE: f64 = 1.0e-3;
785
786/// Bisections inside the bracket. The bracket is a factor of two wide, so
787/// this locates the crossing to about a percent of it.
788const PROFILE_BISECTION_STEPS: usize = 5;
789
790/// Iteration cap for the minimization over the other coordinates at each
791/// trial point.
792const PROFILE_INNER_MAX_ITER: usize = 60;
793
794/// One-sigma interval for each fitted coordinate, from the rise of the
795/// objective rather than its curvature at the minimum.
796///
797/// For a Gaussian likelihood `chi^2 = -2 ln L` up to a constant, so the
798/// one-sigma interval of a coordinate is where the objective, minimized over
799/// every other coordinate, rises by one above its floor. Each bound is found
800/// by bisecting on that crossing.
801///
802/// The interval is followed rather than inferred from the curvature at the
803/// minimum, because the objective is not quadratic out to one sigma: a kernel
804/// narrower than the line it broadens stops being visible, so the objective
805/// flattens below the intrinsic width, while above it the dip smears and the
806/// objective climbs steeply. Across that turn the two sides differ by more
807/// than an order of magnitude.
808///
809/// Bounds are absolute values in the same raw optimizer space as
810/// [`CalibrationResult::theta`]. A side whose crossing lies outside the box
811/// is reported as the box edge: the data does not bound the coordinate there,
812/// and the interval touching an edge is how the caller sees it.
813///
814/// Returns `None` when the floor cannot be evaluated or a profile
815/// minimization fails.
816fn profile_intervals<F>(
817 objective: &mut F,
818 theta: &[f64],
819 bounds: &[(f64, f64)],
820 floor: f64,
821 nm: &NelderMeadConfig,
822) -> Option<Vec<(f64, f64)>>
823where
824 F: FnMut(&[f64]) -> Result<f64, FittingError>,
825{
826 let k = theta.len();
827 if k == 0 || bounds.len() != k || !floor.is_finite() {
828 return None;
829 }
830 let target = floor + PROFILE_DELTA_CHI2;
831 // The minimization at each trial point starts from the solution and only
832 // has to slide along the valley, so it is capped well below the search
833 // that found the solution.
834 let inner_nm = NelderMeadConfig {
835 max_iter: nm.max_iter.min(PROFILE_INNER_MAX_ITER),
836 ..nm.clone()
837 };
838
839 let mut intervals = Vec::with_capacity(k);
840 for i in 0..k {
841 let free_bounds: Vec<(f64, f64)> = (0..k).filter(|&j| j != i).map(|j| bounds[j]).collect();
842
843 // The objective at `theta[i] = fixed`, minimized over the rest. The
844 // minimizer starts from where the previous trial point left it: the
845 // trials walk along one valley, so its solution is the next one's
846 // neighbourhood.
847 let profiled = |fixed: f64, start: &mut Vec<f64>, objective: &mut F| -> Option<f64> {
848 let mut at = theta.to_vec();
849 at[i] = fixed;
850 if k == 1 {
851 return objective(&at).ok().filter(|v| v.is_finite());
852 }
853 let mut inner = |x: &[f64]| -> Result<f64, FittingError> {
854 let mut full = at.clone();
855 for (slot, &v) in (0..k).filter(|&j| j != i).zip(x) {
856 full[slot] = v;
857 }
858 objective(&full)
859 };
860 let res =
861 nelder_mead_minimize(&mut inner, start, Some(&free_bounds), &inner_nm).ok()?;
862 if !res.fun.is_finite() {
863 return None;
864 }
865 *start = res.x;
866 Some(res.fun)
867 };
868
869 // Walk outward from the solution, doubling the displacement, until
870 // the profile clears the target; then bisect the last bracket. An
871 // edge reached without clearing it means the data does not bound that
872 // side, and the edge is the answer.
873 let side = |edge: f64, objective: &mut F| -> Option<f64> {
874 let reach = (edge - theta[i]).abs();
875 if reach == 0.0 {
876 return Some(edge);
877 }
878 let direction = (edge - theta[i]).signum();
879 let at = |d: f64| theta[i] + direction * d;
880
881 let mut start: Vec<f64> = (0..k).filter(|&j| j != i).map(|j| theta[j]).collect();
882 let mut inside = 0.0_f64;
883 let mut step =
884 (PROFILE_BRACKET_START_FRACTION * theta[i].abs().max(PROFILE_MIN_SCALE)).min(reach);
885 let mut outside = loop {
886 if profiled(at(step), &mut start, objective)? > target {
887 break step;
888 }
889 inside = step;
890 // The edge itself was the last trial and the objective has
891 // still not risen: the data does not bound this side.
892 if step >= reach {
893 return Some(edge);
894 }
895 step = (step * 2.0).min(reach);
896 };
897 for _ in 0..PROFILE_BISECTION_STEPS {
898 let mid = 0.5 * (inside + outside);
899 if profiled(at(mid), &mut start, objective)? <= target {
900 inside = mid;
901 } else {
902 outside = mid;
903 }
904 }
905 Some(at(0.5 * (inside + outside)))
906 };
907
908 intervals.push((side(bounds[i].0, objective)?, side(bounds[i].1, objective)?));
909 }
910 Some(intervals)
911}
912
913/// Solve a small `k×k` linear system `A x = b` (k ≤ 3) by Gaussian elimination
914/// with partial pivoting. Returns `None` on a singular system.
915fn solve_small(a: &[f64], b: &[f64], k: usize) -> Option<Vec<f64>> {
916 // Relative pivot threshold scaled by the matrix norm, so ill-conditioned
917 // systems (not just exactly-singular ones) are reported infeasible.
918 let scale = a
919 .iter()
920 .fold(0.0_f64, |m, &v| m.max(v.abs()))
921 .max(f64::MIN_POSITIVE);
922 let mut m = a.to_vec();
923 let mut y = b.to_vec();
924 for col in 0..k {
925 let mut piv = col;
926 for r in (col + 1)..k {
927 if m[r * k + col].abs() > m[piv * k + col].abs() {
928 piv = r;
929 }
930 }
931 if m[piv * k + col].abs() < 1e-12 * scale {
932 return None;
933 }
934 if piv != col {
935 for c in 0..k {
936 m.swap(piv * k + c, col * k + c);
937 }
938 y.swap(piv, col);
939 }
940 for r in (col + 1)..k {
941 let f = m[r * k + col] / m[col * k + col];
942 for c in col..k {
943 m[r * k + c] -= f * m[col * k + c];
944 }
945 y[r] -= f * y[col];
946 }
947 }
948 let mut x = vec![0.0; k];
949 for col in (0..k).rev() {
950 let mut s = y[col];
951 for c in (col + 1)..k {
952 s -= m[col * k + c] * x[c];
953 }
954 x[col] = s / m[col * k + col];
955 }
956 Some(x)
957}
958
959/// Calibrate the resolution parameters of `family` against a known-(ρ,T)
960/// calibrant.
961///
962/// `sample` carries the FIXED density and temperature (and isotopes/groups). By
963/// default **only the resolution shape/width is optimized**, at the pinned energy
964/// scale `(t0, L_scale) = (center, center)` — a pure broadening calibration on an
965/// already energy-calibrated grid (this is the SAMMY split: resolution is a
966/// broadening kernel; `t0`/`L` are a *separate* energy-scale calibration).
967///
968/// Set [`CalibrationConfig::fit_t0`]/[`fit_l_scale`](CalibrationConfig::fit_l_scale)
969/// (e.g. via [`CalibrationConfig::with_position_prior`]) to *also* fit the SHARED
970/// energy-scale `(t0, L_scale)` under a Gaussian metrology prior — for joint
971/// energy-scale work or a cross-family identifiability study. Do **not** fit
972/// position with a flat prior in production: the asymmetric-kernel mode→centroid
973/// lag is the same `1/√E` basis as `L_scale`, so a free `L_scale` absorbs the lag
974/// and corrupts the calibrated width.
975///
976/// The IC family fits the full bounded moderator shape (#642): `α(E) =
977/// e^{θ0}√E + e^{θ1}` — positive at every energy by construction — plus free
978/// bounded `β = e^{θ2}` and scalar storage fraction `R = θ3 ∈ [0, 1]`, folded
979/// with the SNS PSR channel triangle
980/// ([`CalibrationConfig::psr_fwhm_ns`], default 350 ns; `0` disables;
981/// optionally fitted via `IkedaCarpenter { fit_psr: true }` — a zero width
982/// combined with `fit_psr` contradicts "0 disables" and is rejected).
983///
984/// Returns the fitted shape parameters, the reduced **data** χ²/dof, the fitted (or
985/// pinned) `(t0, L_scale)`, the prior penalty, the calibrated
986/// [`ResolutionFunction`] (ready to pin), the free-parameter count
987/// ([`CalibrationResult::n_free_params`]), and the pinned-bound report
988/// ([`CalibrationResult::bounds_hit`]).
989///
990/// # Errors
991/// [`FittingError::EmptyData`] / [`FittingError::LengthMismatch`] for bad
992/// inputs; [`FittingError::InvalidConfig`] for a bad grid or position config;
993/// propagates optimizer errors.
994pub fn calibrate_resolution(
995 family: ResolutionFamily,
996 energies: &[f64],
997 data: &[f64],
998 unc: &[f64],
999 sample: &SampleParams,
1000 config: &CalibrationConfig,
1001) -> Result<CalibrationResult, FittingError> {
1002 if data.is_empty() {
1003 return Err(FittingError::EmptyData);
1004 }
1005 if energies.len() != data.len() || unc.len() != data.len() {
1006 return Err(FittingError::LengthMismatch {
1007 expected: data.len(),
1008 actual: energies.len().min(unc.len()),
1009 field: "energies/unc vs data",
1010 });
1011 }
1012 // Reject non-finite inputs up front: a NaN datum would otherwise propagate
1013 // to a NaN χ², and since `NaN < x` is false the optimizer could retain it as
1014 // "best" and return a NaN-objective fit silently.
1015 if !energies.iter().all(|v| v.is_finite())
1016 || !data.iter().all(|v| v.is_finite())
1017 || !unc.iter().all(|v| v.is_finite() && *v > 0.0)
1018 {
1019 return Err(FittingError::InvalidConfig(
1020 "energies, data must be finite and uncertainty finite and > 0".into(),
1021 ));
1022 }
1023 // Energy grid must be strictly positive and strictly ascending — mirror the
1024 // Python entry point's `validate_energy_grid` so both public APIs reject the
1025 // same inputs up front. Without this, a zero/negative energy panics deep in
1026 // the Reich–Moore cross-section assert, a descending grid errors late as a
1027 // generic "forward model failed", and duplicate energies are silently
1028 // accepted (the recurring NEREIDS sibling-path validation gap).
1029 if energies[0] <= 0.0 {
1030 return Err(FittingError::InvalidConfig(
1031 "energies must be strictly positive".into(),
1032 ));
1033 }
1034 if !energies.windows(2).all(|w| w[1] > w[0]) {
1035 return Err(FittingError::InvalidConfig(
1036 "energies must be strictly ascending (no duplicates)".into(),
1037 ));
1038 }
1039 // The calibrant must have at least one isotope with a finite, positive areal
1040 // density. Otherwise `forward_model` skips every isotope (thickness ≤ 0) and
1041 // returns a flat T≡1 that is independent of the resolution parameters, so the
1042 // optimizer would converge to a finite but physically meaningless result —
1043 // silently masking a whole-config error. Mirrors the Python wrapper's guard.
1044 if !sample
1045 .isotopes()
1046 .iter()
1047 .any(|(_, density)| density.is_finite() && *density > 0.0)
1048 {
1049 return Err(FittingError::InvalidConfig(
1050 "calibrant must have at least one isotope with a finite, positive density".into(),
1051 ));
1052 }
1053 // Reject under-determined calibrants: need strictly more data points than the
1054 // total free parameters (resolution + any *fitted* position + anorm/baseline).
1055 let n_res = family.n_params();
1056 let n_pos = usize::from(config.fit_t0) + usize::from(config.fit_l_scale);
1057 let baseline_cols = if config.fit_background { 3 } else { 1 };
1058 if data.len() <= n_res + n_pos + baseline_cols {
1059 return Err(FittingError::InvalidConfig(format!(
1060 "calibrant has {} points but the model has {} resolution + {} position + {} baseline \
1061 parameters; need strictly more data points than parameters",
1062 data.len(),
1063 n_res,
1064 n_pos,
1065 baseline_cols,
1066 )));
1067 }
1068 // Flight path is a physical positive length. A non-positive / non-finite value
1069 // would invert the t0 feasibility bound below (min_tof < 0 ⇒ t0_hi < t0_lo ⇒
1070 // `clamp(lo, hi)` panic) as soon as `fit_t0` appends a bounded coordinate, and
1071 // otherwise only surfaces as a generic "no finite-objective" error. Reject it
1072 // precisely up front (covers every family and the fit/pin paths alike).
1073 if !(config.flight_path_m.is_finite() && config.flight_path_m > 0.0) {
1074 return Err(FittingError::InvalidConfig(
1075 "flight_path_m must be finite and > 0".into(),
1076 ));
1077 }
1078 // PSR triangle width: finite and >= 0 (0.0 disables the fold). A NaN or
1079 // negative width would otherwise flow into IkedaCarpenter::new on every
1080 // evaluation and only surface as the generic "no finite-objective
1081 // resolution" error. Validated for every family (it is inert outside IC)
1082 // so a mis-set config is caught regardless of the family under test.
1083 if !config.psr_fwhm_ns.is_finite() || config.psr_fwhm_ns < 0.0 {
1084 return Err(FittingError::InvalidConfig(format!(
1085 "psr_fwhm_ns must be finite and >= 0 (0 disables the PSR fold), got {}",
1086 config.psr_fwhm_ns
1087 )));
1088 }
1089 // Sanity ceiling on the width itself (see PSR_FWHM_PIN_CEILING_US):
1090 // psr_fwhm_ns is NANOSECONDS and synthesis cost is quadratic in the fold
1091 // width, so a µs-as-ns unit slip pins a fictitious multi-hundred-µs fold
1092 // that hangs the calibration for hours. Reject loudly, up front, for
1093 // every family (inert outside IC, same rationale as the checks above).
1094 if config.psr_fwhm_ns * NS_TO_US > PSR_FWHM_PIN_CEILING_US {
1095 return Err(FittingError::InvalidConfig(format!(
1096 "psr_fwhm_ns = {} ns (= {} µs) exceeds the {PSR_FWHM_PIN_CEILING_US} µs sanity \
1097 ceiling (10x the {PSR_FWHM_US_MAX} µs fit bound). psr_fwhm_ns is in NANOSECONDS \
1098 — the SNS/VENUS FTS convention is 350 ns — and kernel-synthesis cost grows \
1099 quadratically with the fold width, so a µs-as-ns unit slip would hang the \
1100 calibration behind a fictitious fold. Pass the width in ns, or 0 to disable \
1101 the PSR fold",
1102 config.psr_fwhm_ns,
1103 config.psr_fwhm_ns * NS_TO_US
1104 )));
1105 }
1106 // fit_psr fits the PSR FWHM from the psr_fwhm_ns starting value, but 0 is
1107 // documented as "no fold": a zero start would be silently clamped into the
1108 // [PSR_FWHM_US_MIN, PSR_FWHM_US_MAX] fit box, contradicting the "0
1109 // disables" contract. Reject the contradiction loudly.
1110 if matches!(family, ResolutionFamily::IkedaCarpenter { fit_psr: true })
1111 && config.psr_fwhm_ns == 0.0
1112 {
1113 return Err(FittingError::InvalidConfig(
1114 "fit_psr requires a positive psr_fwhm_ns starting value (psr_fwhm_ns = 0 disables \
1115 the PSR fold; use fit_psr = false to calibrate without one)"
1116 .into(),
1117 ));
1118 }
1119 // IC synthesis-grid resolution: validate up front for the IC family (inert
1120 // for the others) so an out-of-range value gives this precise error instead
1121 // of every IkedaCarpenter::new evaluation failing into the generic late
1122 // "no finite-objective resolution" error. Thresholds mirror both
1123 // IkedaCarpenter::new (n_energies >= 2, n_tau >= 8) and the Python
1124 // binding's sibling validation, so the two public entry points reject the
1125 // same inputs.
1126 if matches!(family, ResolutionFamily::IkedaCarpenter { .. }) {
1127 if config.ic_n_energies < 2 {
1128 return Err(FittingError::InvalidConfig(format!(
1129 "ic_n_energies must be >= 2 for the IC family, got {}",
1130 config.ic_n_energies
1131 )));
1132 }
1133 if config.ic_n_tau < 8 {
1134 return Err(FittingError::InvalidConfig(format!(
1135 "ic_n_tau must be >= 8 for the IC family, got {}",
1136 config.ic_n_tau
1137 )));
1138 }
1139 }
1140 // Validate the energy-scale (t0, L_scale) prior/center configuration up front.
1141 if !config.position_t0_center_us.is_finite()
1142 || !config.position_l_scale_center.is_finite()
1143 || config.position_l_scale_center <= 0.0
1144 {
1145 return Err(FittingError::InvalidConfig(
1146 "position centers must be finite and the L_scale center > 0".into(),
1147 ));
1148 }
1149 if config.position_t0_center_us.abs() >= POSITION_T0_US_MAX {
1150 return Err(FittingError::InvalidConfig(format!(
1151 "position_t0_center_us must lie within ±{POSITION_T0_US_MAX} µs"
1152 )));
1153 }
1154 if config.position_l_scale_center < POSITION_L_SCALE_MIN
1155 || config.position_l_scale_center > POSITION_L_SCALE_MAX
1156 {
1157 return Err(FittingError::InvalidConfig(format!(
1158 "position_l_scale_center must lie within [{POSITION_L_SCALE_MIN}, {POSITION_L_SCALE_MAX}]"
1159 )));
1160 }
1161 for (sigma, name) in [
1162 (config.position_t0_prior_us, "position_t0_prior_us"),
1163 (config.position_l_scale_prior, "position_l_scale_prior"),
1164 ] {
1165 if let Some(s) = sigma
1166 && !(s.is_finite() && s > 0.0)
1167 {
1168 return Err(FittingError::InvalidConfig(format!(
1169 "{name} must be finite and > 0 when set"
1170 )));
1171 }
1172 }
1173
1174 let e_min = energies.first().copied().unwrap_or(1.0);
1175 let e_max = energies.last().copied().unwrap_or(1.0);
1176 // Feasible t0 upper bound: the corrected TOF `tof − t0` must stay positive for
1177 // every energy, i.e. `t0 < min(tof) = TOF_FACTOR·L/√E_max`. Far outside ±5 µs in
1178 // the eV regime, but clamp defensively so a wide window can never make it bite.
1179 let min_tof = TOF_FACTOR * config.flight_path_m / e_max.max(1e-12).sqrt();
1180 // The (pinned or prior-mean) t0 center must itself be feasible: corrected_energy_grid
1181 // needs `t0 < min(tof)` for every energy. In the eV regime min_tof ≫ 5 µs, but a
1182 // short flight path or very high E_max can shrink it — reject up front with a precise
1183 // message instead of a late, generic "corrected TOF ≤ 0" from the final recompute.
1184 if config.position_t0_center_us >= min_tof {
1185 return Err(FittingError::InvalidConfig(format!(
1186 "position_t0_center_us ({:.3} µs) must be below the shortest flight time \
1187 min_tof = TOF_FACTOR·L/√E_max = {min_tof:.3} µs",
1188 config.position_t0_center_us
1189 )));
1190 }
1191 let t0_lo = -POSITION_T0_US_MAX;
1192 let t0_hi = POSITION_T0_US_MAX.min(min_tof - 1e-6);
1193
1194 // Optimizer coordinates: [resolution params (n_res)..., t0?, L_scale?]. A
1195 // position coordinate is appended only when fit; otherwise it is pinned at its
1196 // center. (Position is a SHARED energy-scale parameter, not a per-family
1197 // nuisance — fitting it is an explicit, prior-constrained opt-in.)
1198 let (mut x0, mut bounds) = family.x0_bounds(config);
1199 if config.fit_t0 {
1200 x0.push(config.position_t0_center_us.clamp(t0_lo, t0_hi));
1201 bounds.push((t0_lo, t0_hi));
1202 }
1203 if config.fit_l_scale {
1204 x0.push(
1205 config
1206 .position_l_scale_center
1207 .clamp(POSITION_L_SCALE_MIN, POSITION_L_SCALE_MAX),
1208 );
1209 bounds.push((POSITION_L_SCALE_MIN, POSITION_L_SCALE_MAX));
1210 }
1211 // PRE-FLIGHT the start (#645 round 3, F1): synthesize the resolution once
1212 // at x0 before any optimization. A start whose kernel cannot be
1213 // synthesized — e.g. any PSR triangle under ~58.6 ns: the default β/R
1214 // start (β = 0.1, R = 0.1) spans a 16/β = 160 µs storage tail, capping
1215 // the τ-step at 160/8191 ≈ 19.53 ns, above such a triangle's FWHM/3
1216 // resolution floor (note the PSR fit-box floor 0.05 µs = 50 ns is ITSELF
1217 // in this class, so a `>= PSR_FWHM_US_MIN` value check could not cover
1218 // it) — passes every value-level config check above yet makes EVERY
1219 // initial-simplex vertex infeasible (∞ objective): the Nelder–Mead
1220 // objective range is then ∞ − ∞ = NaN, so it can never self-converge,
1221 // burns max_iter, and used to die late with the generic "no
1222 // finite-objective" error blaming the forward model. Reject the START
1223 // precisely instead, surfacing the τ-geometry/synthesis diagnosis. A θ
1224 // that becomes infeasible only DURING the search remains an ∞ point the
1225 // simplex steps away from (see the objective below) — this pre-flight
1226 // rejects only an infeasible start.
1227 if let Err(synth_err) = build_resolution(&family, &x0, e_min, e_max, config) {
1228 let psr_note = if matches!(family, ResolutionFamily::IkedaCarpenter { .. }) {
1229 format!(
1230 " The starting PSR width comes from psr_fwhm_ns = {} ns — widen the \
1231 triangle (the SNS/VENUS FTS convention is 350 ns), or pass 0 to \
1232 disable the fold when not fitting it.",
1233 config.psr_fwhm_ns
1234 )
1235 } else {
1236 String::new()
1237 };
1238 return Err(FittingError::InvalidConfig(format!(
1239 "resolution kernel synthesis is infeasible at the starting parameter \
1240 vector, so every optimizer restart would begin from an all-infeasible \
1241 simplex: {synth_err}.{psr_note}"
1242 )));
1243 }
1244 let nm = NelderMeadConfig {
1245 xatol: config.xatol,
1246 fatol: config.fatol,
1247 max_iter: config.max_iter,
1248 ..Default::default()
1249 };
1250
1251 // Read the (possibly pinned) position coordinates out of an optimizer vector.
1252 let unpack_position = |theta: &[f64]| -> (f64, f64) {
1253 let mut idx = n_res;
1254 let t0 = if config.fit_t0 {
1255 let v = theta[idx];
1256 idx += 1;
1257 v
1258 } else {
1259 config.position_t0_center_us
1260 };
1261 let l_scale = if config.fit_l_scale {
1262 theta[idx]
1263 } else {
1264 config.position_l_scale_center
1265 };
1266 (t0, l_scale)
1267 };
1268
1269 let mut best: Option<NelderMeadResult> = None;
1270 // Hoisted out of the restart loop: the objective does not depend on the
1271 // restart, and the covariance at the end needs the same function that was
1272 // minimized rather than a rebuilt copy of it.
1273 let mut obj = |theta: &[f64]| -> Result<f64, FittingError> {
1274 // theta = [resolution params (n_res)..., t0?, L_scale?]. The resolution
1275 // kernel uses only the first n_res; (t0, L_scale) set the energy scale.
1276 // An UNRESOLVABLE θ — nereids-physics rejects a kernel whose τ-grid
1277 // cannot resolve the requested fold / prompt core within the
1278 // MAX_TAU_SAMPLES cap (e.g. a fitted PSR at its 0.05 µs floor
1279 // against β at its own floor) — is an infeasible POINT of the
1280 // search, not a broken calibration: step away (mirrors the
1281 // corrected-TOF ≤ 0 guard below). Config-level failures cannot
1282 // reach here: they are rejected up front by calibrate_resolution.
1283 let Ok(res) = build_resolution(&family, theta, e_min, e_max, config) else {
1284 return Ok(f64::INFINITY);
1285 };
1286 let inst = InstrumentParams { resolution: res };
1287 let (t0, l_scale) = unpack_position(theta);
1288 // Infeasible energy scale (corrected TOF ≤ 0) → step away.
1289 let Ok(grid) = corrected_energy_grid(energies, t0, l_scale, config.flight_path_m) else {
1290 return Ok(f64::INFINITY);
1291 };
1292 let model = forward_model(&grid, sample, Some(&inst))
1293 .map_err(|e| FittingError::EvaluationFailed(format!("forward: {e:?}")))?;
1294 if !model.iter().all(|v| v.is_finite()) {
1295 return Err(FittingError::EvaluationFailed("non-finite model".into()));
1296 }
1297 // Minimize RAW χ²_data + metrology prior penalty (same units — adding
1298 // the penalty to a reduced χ² would rescale the prior by the dof).
1299 let Some((ssr, _k)) = inner_ssr(data, unc, &model, config.fit_background) else {
1300 return Ok(f64::INFINITY);
1301 };
1302 Ok(ssr + position_prior_penalty(t0, l_scale, config))
1303 };
1304 for r in 0..config.restarts.max(1) {
1305 // Additive perturbation (a fraction of each parameter's bound range) so
1306 // restarts move even for zero-valued start components — a multiplicative
1307 // `x0·(1+0.1r)` left `udr_corr`'s `[0, 0]` start identical every restart.
1308 let start: Vec<f64> = x0
1309 .iter()
1310 .zip(&bounds)
1311 .map(|(&v, &(lo, hi))| (v + 0.1 * r as f64 * (hi - lo)).clamp(lo, hi))
1312 .collect();
1313 let mut res = nelder_mead_minimize(&mut obj, &start, Some(&bounds), &nm)?;
1314 // Simplex RE-INFLATION: Nelder–Mead's known failure mode is premature
1315 // simplex collapse — the spread criteria are met (`self_converged`)
1316 // at a point that is NOT the basin minimum. Observed on the
1317 // 4-parameter IC family: a 300 K synthetic calibrant stalled at
1318 // Δχ² ≈ +130 above the noise floor in the curved α↔β↔R valley, and
1319 // the ~1.5 % kernel-width error re-expressed as a ~23 K temperature
1320 // bias in the downstream pinned fit. Standard cure: restart a FRESH,
1321 // *larger* simplex at the incumbent (same 5 % edge re-collapses to
1322 // the same trap deterministically) and keep the improvement, until
1323 // it stops helping (bounded by MAX_SIMPLEX_REINFLATIONS). A
1324 // re-inflation from a true minimum re-contracts quickly, so the
1325 // extra cost there is small.
1326 let reinflate_nm = NelderMeadConfig {
1327 initial_step_frac: REINFLATE_STEP_FRAC,
1328 initial_step_abs: REINFLATE_STEP_ABS,
1329 ..nm.clone()
1330 };
1331 for _ in 0..MAX_SIMPLEX_REINFLATIONS {
1332 let again = nelder_mead_minimize(&mut obj, &res.x, Some(&bounds), &reinflate_nm)?;
1333 let improved = again.fun + nm.fatol < res.fun;
1334 res.iterations += again.iterations;
1335 res.n_evals += again.n_evals;
1336 if improved {
1337 res.x = again.x;
1338 res.fun = again.fun;
1339 res.self_converged = again.self_converged;
1340 } else {
1341 break;
1342 }
1343 }
1344 if best.as_ref().is_none_or(|b| res.fun < b.fun) {
1345 best = Some(res);
1346 }
1347 }
1348 let best = best.expect("at least one restart runs");
1349 if !best.fun.is_finite() {
1350 // Every ∞ source of the objective (#645 round 3 F1, round 4 F1):
1351 // `nelder_mead_minimize` maps every objective `Err` — forward-model
1352 // failures included — to an infeasible +∞ point rather than aborting
1353 // (see the `eval` closure in `nelder_mead.rs`), so no failure class
1354 // raises its own error during the search. Reaching here means every
1355 // vector tried hit one of them — kernel synthesis rejected (τ-grid
1356 // cap vs fold/prompt geometry), an invalid energy scale (corrected
1357 // TOF ≤ 0), a singular anorm/baseline system, or a forward-model
1358 // (transmission) failure. The start itself synthesized (pre-flighted
1359 // above), so the infeasibility arose during the search.
1360 return Err(FittingError::EvaluationFailed(
1361 "calibration found no finite-objective resolution: every parameter vector \
1362 tried was infeasible — kernel synthesis rejected it (τ-grid cap vs \
1363 fold/prompt geometry), the energy scale was invalid (corrected TOF ≤ 0), \
1364 the anorm/baseline system was singular, or the forward model failed"
1365 .into(),
1366 ));
1367 }
1368 let (position_t0_us, position_l_scale) = unpack_position(&best.x);
1369 let prior_penalty = position_prior_penalty(position_t0_us, position_l_scale, config);
1370 // Recompute the reduced DATA χ²/dof at the solution: the objective carries the
1371 // prior penalty, so `best.fun` is the penalized objective, not the data χ². dof
1372 // subtracts the linear anorm/baseline columns AND the outer-loop params
1373 // (resolution + any fitted position).
1374 let resolution = build_resolution(&family, &best.x, e_min, e_max, config)?;
1375 let grid = corrected_energy_grid(
1376 energies,
1377 position_t0_us,
1378 position_l_scale,
1379 config.flight_path_m,
1380 )?;
1381 let inst = InstrumentParams { resolution };
1382 let model = forward_model(&grid, sample, Some(&inst))
1383 .map_err(|e| FittingError::EvaluationFailed(format!("forward: {e:?}")))?;
1384 let (ssr, k) = inner_ssr(data, unc, &model, config.fit_background).ok_or_else(|| {
1385 FittingError::EvaluationFailed("singular anorm/baseline at the solution".into())
1386 })?;
1387 let dof = data.len().saturating_sub(k + n_res + n_pos).max(1) as f64;
1388 let chi2_dof = ssr / dof;
1389 let theta = best.x[..n_res].to_vec();
1390 // Bound-pinning report: label every optimizer coordinate that finished
1391 // within BOUND_HIT_REL_TOL·(hi−lo) of its box bound. This is the
1392 // degeneracy flag for the wider IC family (e.g. "r:lower" ⇒ the β↔R ridge:
1393 // no storage tail in the data, β unconstrained).
1394 let mut coord_names = family.param_names();
1395 if config.fit_t0 {
1396 coord_names.push("t0_us");
1397 }
1398 if config.fit_l_scale {
1399 coord_names.push("l_scale");
1400 }
1401 let bounds_hit: Vec<String> = best
1402 .x
1403 .iter()
1404 .zip(&bounds)
1405 .zip(&coord_names)
1406 .flat_map(|((&v, &(lo, hi)), name)| {
1407 let tol = BOUND_HIT_REL_TOL * (hi - lo);
1408 let mut hits = Vec::new();
1409 if v - lo <= tol {
1410 hits.push(format!("{name}:lower"));
1411 }
1412 if hi - v <= tol {
1413 hits.push(format!("{name}:upper"));
1414 }
1415 hits
1416 })
1417 .collect();
1418 // Interval in the same coordinates the optimizer used. `obj` is the raw
1419 // chi-squared plus the position prior, so this is the interval of exactly
1420 // what was minimized.
1421 //
1422 // A run that exhausted its iteration budget has not shown it reached a
1423 // minimum — Nelder-Mead stops wherever it happens to be, and a rise
1424 // measured from an unverified floor is not a calibrated uncertainty.
1425 let intervals = if config.intervals && best.self_converged {
1426 profile_intervals(&mut obj, &best.x, &bounds, best.fun, &nm)
1427 } else {
1428 None
1429 };
1430
1431 Ok(CalibrationResult {
1432 family: family.label().to_string(),
1433 theta,
1434 chi2_dof,
1435 intervals,
1436 resolution: inst.resolution,
1437 iterations: best.iterations,
1438 converged: best.self_converged,
1439 position_t0_us,
1440 position_l_scale,
1441 prior_penalty,
1442 n_free_params: n_res + n_pos,
1443 bounds_hit,
1444 })
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449 use super::*;
1450 use nereids_endf::resonance::test_support::synthetic_isotope;
1451
1452 /// Decode the calibrated IC parameters `(a0, a1, β, R, psr_fwhm_us)` off
1453 /// the result's resolution — the single source of truth (raw `theta` is
1454 /// ln/box-encoded).
1455 fn decoded_ic(r: &CalibrationResult) -> (f64, f64, f64, f64, f64) {
1456 let ResolutionFunction::IkedaCarpenter(ic) = &r.resolution else {
1457 panic!("expected an IC resolution for family {}", r.family);
1458 };
1459 let p = ic.params();
1460 let EnergyLaw::SqrtE { a0, a1 } = p.alpha else {
1461 panic!("expected a SqrtE alpha law");
1462 };
1463 let EnergyLaw::Const(rr) = p.r else {
1464 panic!("expected a Const R law");
1465 };
1466 let EnergyLaw::Const(beta) = p.beta else {
1467 panic!("expected a Const beta law");
1468 };
1469 (a0, a1, beta, rr, p.channel_fwhm_us.unwrap_or(0.0))
1470 }
1471
1472 fn synthetic_base_udr() -> TabulatedResolution {
1473 // Asymmetric kernel (sharp rise, +TOF tail), at two reference energies.
1474 let offs = vec![-1.0, -0.5, 0.0, 0.5, 1.0, 1.5, 2.0, 2.5];
1475 let wts = vec![0.05, 0.3, 1.0, 0.8, 0.5, 0.3, 0.15, 0.05];
1476 TabulatedResolution::from_kernels(
1477 vec![5.0, 50.0],
1478 vec![(offs.clone(), wts.clone()), (offs, wts)],
1479 25.0,
1480 )
1481 .unwrap()
1482 }
1483
1484 #[test]
1485 fn inner_chi2_zero_on_exact_anorm() {
1486 let model = vec![0.9, 0.7, 0.5, 0.8];
1487 let data: Vec<f64> = model.iter().map(|m| 1.0 * m).collect();
1488 let unc = vec![0.01; 4];
1489 assert!(inner_chi2(&data, &unc, &model, false, 0) < 1e-18);
1490 }
1491
1492 /// #645 round 4, F3: a `fit_psr` starting width outside the 0.05–1 µs fit
1493 /// box (legal as a PIN up to [`PSR_FWHM_PIN_CEILING_US`]) is clamped to
1494 /// the nearer box edge — documented behavior, not an error.
1495 #[test]
1496 fn fit_psr_out_of_box_start_clamps_to_box_edge() {
1497 let family = ResolutionFamily::IkedaCarpenter { fit_psr: true };
1498 // 5 000 ns = 5 µs: a valid pin width, above the 1 µs fit-box top.
1499 let above = CalibrationConfig {
1500 psr_fwhm_ns: 5_000.0,
1501 ..CalibrationConfig::default()
1502 };
1503 let (x0, bounds) = family.x0_bounds(&above);
1504 assert_eq!(x0.len(), 5);
1505 assert_eq!(x0[4], PSR_FWHM_US_MAX);
1506 assert_eq!(bounds[4], (PSR_FWHM_US_MIN, PSR_FWHM_US_MAX));
1507 // 10 ns: below the 50 ns identifiability floor — clamped UP.
1508 let below = CalibrationConfig {
1509 psr_fwhm_ns: 10.0,
1510 ..CalibrationConfig::default()
1511 };
1512 let (x0, _) = family.x0_bounds(&below);
1513 assert_eq!(x0[4], PSR_FWHM_US_MIN);
1514 // The in-box default (350 ns) passes through unclamped.
1515 let inside = CalibrationConfig::default();
1516 let (x0, _) = family.x0_bounds(&inside);
1517 assert_eq!(x0[4], DEFAULT_PSR_FWHM_NS * NS_TO_US);
1518 }
1519
1520 #[test]
1521 fn udr_corr_recovers_known_width_scale() {
1522 // Loop-closure / OPTIMIZER test: truth and fit both use width_corrected, so
1523 // this checks that the calibrator finds the s0=1.5 minimum — NOT that
1524 // width_corrected itself is physically correct. The width-scale physics
1525 // (centroid invariance + std scaling) is independently verified by
1526 // `width_corrected_preserves_centroid_scales_width_and_energy_dependence`
1527 // in nereids-physics.
1528 // Two well-separated resonances (15 + 45 eV) so the width is identifiable
1529 // (a single resonance leaves a width↔position ridge). Position is pinned by
1530 // default. Calibrant generated with a UDR truth scaled by s0=1.5; udr_corr
1531 // must recover s0≈1.5 at χ²≈0.
1532 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1533 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1534 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1535 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1536 let base = synthetic_base_udr();
1537 let truth = ResolutionFunction::Tabulated(Arc::new(
1538 base.width_corrected(1.5, 0.0, UDR_E_REF).unwrap(),
1539 ));
1540 let data = forward_model(
1541 &energies,
1542 &sample,
1543 Some(&InstrumentParams { resolution: truth }),
1544 )
1545 .unwrap();
1546 let unc = vec![0.004; energies.len()];
1547
1548 let cfg = CalibrationConfig {
1549 restarts: 2,
1550 ..Default::default()
1551 };
1552 let r = calibrate_resolution(
1553 ResolutionFamily::UdrCorr {
1554 base: Arc::new(base),
1555 },
1556 &energies,
1557 &data,
1558 &unc,
1559 &sample,
1560 &cfg,
1561 )
1562 .unwrap();
1563 let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1564 assert!((s0 - 1.5).abs() < 0.05, "recovered s0={s0}, expected 1.5");
1565 assert!(r.chi2_dof < 1e-2, "matched χ²/dof={} too high", r.chi2_dof);
1566 assert!(matches!(r.resolution, ResolutionFunction::Tabulated(_)));
1567 }
1568
1569 #[test]
1570 fn udr_corr_recovers_known_width_scale_and_exponent() {
1571 // Two resonances at well-separated energies make the width EXPONENT p
1572 // identifiable — a single resonance constrains only s(E) at one energy (a
1573 // ridge in (s0, p)). Truth: s0=1.3, p=-0.5; the calibrator must recover
1574 // both (the s0-only test never exercised the p knob).
1575 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1576 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1577 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1578 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1579 let base = synthetic_base_udr();
1580 let (s0_true, p_true) = (1.3, -0.5);
1581 let truth = ResolutionFunction::Tabulated(Arc::new(
1582 base.width_corrected(s0_true, p_true, UDR_E_REF).unwrap(),
1583 ));
1584 let data = forward_model(
1585 &energies,
1586 &sample,
1587 Some(&InstrumentParams { resolution: truth }),
1588 )
1589 .unwrap();
1590 let unc = vec![0.004; energies.len()];
1591 let cfg = CalibrationConfig {
1592 restarts: 3,
1593 ..Default::default()
1594 };
1595 let r = calibrate_resolution(
1596 ResolutionFamily::UdrCorr {
1597 base: Arc::new(base),
1598 },
1599 &energies,
1600 &data,
1601 &unc,
1602 &sample,
1603 &cfg,
1604 )
1605 .unwrap();
1606 let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1607 let p = r.theta[1];
1608 assert!(
1609 (s0 - s0_true).abs() < 0.1,
1610 "recovered s0={s0}, expected {s0_true}"
1611 );
1612 assert!(
1613 (p - p_true).abs() < 0.2,
1614 "recovered p={p}, expected {p_true}"
1615 );
1616 assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1617 }
1618
1619 #[test]
1620 fn udr_corr_recovers_independent_raw_kernel() {
1621 // External-oracle coverage: the truth resolution is the RAW hand-built UDR
1622 // kernel broadened directly — it does NOT pass through `width_corrected`,
1623 // so truth-generation no longer shares the width-correction code with the
1624 // fit. Fitting udr_corr against that base must recover the identity width
1625 // (s0≈1) at χ²≈0. (The broadening OPERATOR itself is independently
1626 // validated by this crate's bit-exact `broaden_presorted_reference` tests.)
1627 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1628 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1629 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1630 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1631 let base = synthetic_base_udr();
1632 // Truth = the RAW base kernel (no width_corrected call).
1633 let truth = ResolutionFunction::Tabulated(Arc::new(base.clone()));
1634 let data = forward_model(
1635 &energies,
1636 &sample,
1637 Some(&InstrumentParams { resolution: truth }),
1638 )
1639 .unwrap();
1640 let unc = vec![0.004; energies.len()];
1641 let cfg = CalibrationConfig {
1642 restarts: 3,
1643 ..Default::default()
1644 };
1645 let r = calibrate_resolution(
1646 ResolutionFamily::UdrCorr {
1647 base: Arc::new(base),
1648 },
1649 &energies,
1650 &data,
1651 &unc,
1652 &sample,
1653 &cfg,
1654 )
1655 .unwrap();
1656 let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1657 assert!((s0 - 1.0).abs() < 0.1, "recovered s0={s0}, expected ~1.0");
1658 assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1659 }
1660
1661 #[test]
1662 fn gaussian_recovers_known_width() {
1663 // Gaussian loop-closure: a Gaussian truth must be recovered by the gaussian
1664 // family (the smoke test only checked finiteness+convergence). Two
1665 // resonances break the Δt/ΔL degeneracy (Δt is flat in TOF; ΔL scales with
1666 // TOF ∝ 1/√E).
1667 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1668 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1669 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1670 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1671 let (dt_true, dl_true) = (1.5, 1.0e-3);
1672 let truth = ResolutionFunction::Gaussian(
1673 ResolutionParams::new(25.0, dt_true, dl_true, 0.0).unwrap(),
1674 );
1675 let data = forward_model(
1676 &energies,
1677 &sample,
1678 Some(&InstrumentParams { resolution: truth }),
1679 )
1680 .unwrap();
1681 let unc = vec![0.004; energies.len()];
1682 let cfg = CalibrationConfig {
1683 restarts: 3,
1684 ..Default::default()
1685 };
1686 let r = calibrate_resolution(
1687 ResolutionFamily::Gaussian,
1688 &energies,
1689 &data,
1690 &unc,
1691 &sample,
1692 &cfg,
1693 )
1694 .unwrap();
1695 let (dt, dl) = (r.theta[0].abs(), r.theta[1].abs());
1696 assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1697 assert!(
1698 (dt - dt_true).abs() < 0.2,
1699 "recovered Δt={dt}, expected {dt_true}"
1700 );
1701 assert!(
1702 (dl - dl_true).abs() < 1.0e-3,
1703 "recovered ΔL={dl}, expected {dl_true}"
1704 );
1705 }
1706
1707 #[test]
1708 fn fit_t0_recovers_injected_energy_scale_shift() {
1709 // With fit_t0 enabled, an injected TOF-zero offset in the calibrant is
1710 // recovered as the SHARED energy-scale t0 while the width is still
1711 // recovered — position is a fitted energy-scale parameter (−t0 convention),
1712 // not folded into the resolution. (Default config pins position; this test
1713 // opts in.) L_scale stays pinned at 1.
1714 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1715 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1716 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1717 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1718 let base = synthetic_base_udr();
1719 let (s0_true, t0_inject) = (1.4, 1.5_f64); // µs (energy-scale −t0 convention)
1720 let truth = ResolutionFunction::Tabulated(Arc::new(
1721 base.width_corrected(s0_true, 0.0, UDR_E_REF).unwrap(),
1722 ));
1723 // Calibrant generated on a grid displaced by the energy-scale t0.
1724 let shifted = corrected_energy_grid(&energies, t0_inject, 1.0, 25.0).unwrap();
1725 let data = forward_model(
1726 &shifted,
1727 &sample,
1728 Some(&InstrumentParams { resolution: truth }),
1729 )
1730 .unwrap();
1731 let unc = vec![0.004; energies.len()];
1732 // Opt into fitting t0 (flat prior); L_scale stays pinned at 1.
1733 let cfg = CalibrationConfig {
1734 restarts: 3,
1735 fit_t0: true,
1736 ..Default::default()
1737 };
1738 let r = calibrate_resolution(
1739 ResolutionFamily::UdrCorr {
1740 base: Arc::new(base),
1741 },
1742 &energies,
1743 &data,
1744 &unc,
1745 &sample,
1746 &cfg,
1747 )
1748 .unwrap();
1749 let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1750 assert!(
1751 (s0 - s0_true).abs() < 0.1,
1752 "recovered s0={s0}, expected {s0_true}"
1753 );
1754 assert!(
1755 (r.position_t0_us - t0_inject).abs() < 0.3,
1756 "recovered t0={}, expected {t0_inject}",
1757 r.position_t0_us
1758 );
1759 assert!(
1760 (r.position_l_scale - 1.0).abs() < 1e-9,
1761 "L_scale should stay pinned at 1, got {}",
1762 r.position_l_scale
1763 );
1764 assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1765 }
1766
1767 #[test]
1768 fn pinned_position_is_the_default_and_works_for_udr() {
1769 // The default config pins position (fit_t0/fit_l_scale = false) — a pure
1770 // shape/width fit. This is the no-position reference that the retired design
1771 // could NOT construct for the UDR family in Python (the width-correction was
1772 // Rust-internal). Self-fit must recover s0≈1 with position reported at its
1773 // pinned center and zero prior penalty.
1774 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1775 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1776 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1777 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1778 let base = synthetic_base_udr();
1779 let truth = ResolutionFunction::Tabulated(Arc::new(base.clone()));
1780 let data = forward_model(
1781 &energies,
1782 &sample,
1783 Some(&InstrumentParams { resolution: truth }),
1784 )
1785 .unwrap();
1786 let unc = vec![0.004; energies.len()];
1787 let cfg = CalibrationConfig::default();
1788 assert!(!cfg.fit_t0 && !cfg.fit_l_scale, "default must pin position");
1789 let r = calibrate_resolution(
1790 ResolutionFamily::UdrCorr {
1791 base: Arc::new(base),
1792 },
1793 &energies,
1794 &data,
1795 &unc,
1796 &sample,
1797 &cfg,
1798 )
1799 .unwrap();
1800 let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1801 assert!((s0 - 1.0).abs() < 0.1, "recovered s0={s0}, expected ~1.0");
1802 assert_eq!(r.position_t0_us, 0.0, "t0 pinned at center 0");
1803 assert_eq!(r.position_l_scale, 1.0, "L_scale pinned at center 1");
1804 assert_eq!(r.prior_penalty, 0.0, "no prior active when pinned");
1805 assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1806 }
1807
1808 #[test]
1809 #[ignore = "slow; runs nightly"]
1810 fn free_l_scale_absorbs_asymmetric_lag_and_erodes_discrimination() {
1811 // The asymmetric IC mode→centroid lag is pure 1/√E — the SAME basis as an
1812 // L_scale error. So a Gaussian fitting an IC-broadened calibrant fits much
1813 // BETTER when L_scale is free than when position is pinned: a free physical
1814 // position lets the wrong (symmetric) family buy back the position evidence.
1815 // This is exactly why fitting position with a flat prior is unsafe for
1816 // family discrimination (and why the default pins it).
1817 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1818 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1819 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1820 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1821 let ic = IkedaCarpenter::new(
1822 IkedaCarpenterParams {
1823 alpha: EnergyLaw::SqrtE { a0: 0.30, a1: 0.0 },
1824 beta: EnergyLaw::Const(0.1),
1825 r: EnergyLaw::ExpMilliEv { kappa: 25.0 },
1826 burst_sigma_us: None,
1827 channel_fwhm_us: None,
1828 },
1829 25.0,
1830 &SynthesisGrid {
1831 e_min_ev: 4.0,
1832 e_max_ev: 100.0,
1833 n_energies: 64,
1834 n_tau: 500,
1835 },
1836 )
1837 .unwrap();
1838 let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
1839 let data = forward_model(
1840 &energies,
1841 &sample,
1842 Some(&InstrumentParams { resolution: truth }),
1843 )
1844 .unwrap();
1845 let unc = vec![0.004; energies.len()];
1846 let pinned = CalibrationConfig {
1847 restarts: 3,
1848 ..Default::default()
1849 };
1850 // Free physical position (flat priors): fit_t0 + fit_l_scale, sigmas None.
1851 let free_pos = CalibrationConfig {
1852 restarts: 3,
1853 fit_t0: true,
1854 fit_l_scale: true,
1855 ..Default::default()
1856 };
1857 let gau = |cfg: &CalibrationConfig| {
1858 calibrate_resolution(
1859 ResolutionFamily::Gaussian,
1860 &energies,
1861 &data,
1862 &unc,
1863 &sample,
1864 cfg,
1865 )
1866 .unwrap()
1867 .chi2_dof
1868 };
1869 let gau_pinned = gau(&pinned);
1870 let gau_free = gau(&free_pos);
1871 assert!(
1872 gau_free < 0.5 * gau_pinned,
1873 "free (t0,L_scale) should sharply erode the wrong-family penalty: \
1874 pinned χ²={gau_pinned}, free χ²={gau_free}"
1875 );
1876 }
1877
1878 #[test]
1879 fn position_prior_penalizes_displacement() {
1880 // A tight prior on t0 (center 0) penalizes a calibrant whose true t0 is
1881 // displaced: the fit cannot freely move to the displacement, so it pays a
1882 // prior penalty and leaves residual data χ². A loose prior recovers the
1883 // displacement with ~zero penalty. (Demonstrates the prior is the real
1884 // constraint on position, per the metrology-prior design.)
1885 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1886 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1887 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1888 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1889 let base = synthetic_base_udr();
1890 let t0_inject = 1.5_f64;
1891 let truth = ResolutionFunction::Tabulated(Arc::new(
1892 base.width_corrected(1.0, 0.0, UDR_E_REF).unwrap(),
1893 ));
1894 let shifted = corrected_energy_grid(&energies, t0_inject, 1.0, 25.0).unwrap();
1895 let data = forward_model(
1896 &shifted,
1897 &sample,
1898 Some(&InstrumentParams { resolution: truth }),
1899 )
1900 .unwrap();
1901 let unc = vec![0.004; energies.len()];
1902 let mk = |sigma_t0: f64| CalibrationConfig {
1903 restarts: 3,
1904 fit_t0: true,
1905 position_t0_prior_us: Some(sigma_t0),
1906 ..Default::default()
1907 };
1908 let mkbase = || ResolutionFamily::UdrCorr {
1909 base: Arc::new(base.clone()),
1910 };
1911 // Tight prior: σ must be small enough that the quadratic prior
1912 // curvature rivals the data-χ² curvature in t0, or the optimum
1913 // sits at the displacement and the pull is invisible. The
1914 // width-correct kernel interpolation sharpened the data term
1915 // (narrower between-reference kernels carry more positional
1916 // information than the over-wide chord blend used to), so the
1917 // binding regime needs a tighter σ than it once did.
1918 let tight =
1919 calibrate_resolution(mkbase(), &energies, &data, &unc, &sample, &mk(0.005)).unwrap();
1920 // Loose prior (σ=100 µs): recovers the displacement, ~no penalty.
1921 let loose =
1922 calibrate_resolution(mkbase(), &energies, &data, &unc, &sample, &mk(100.0)).unwrap();
1923 assert!(
1924 tight.prior_penalty > 1.0,
1925 "tight prior should incur a real penalty, got {}",
1926 tight.prior_penalty
1927 );
1928 assert!(
1929 tight.position_t0_us.abs() < t0_inject,
1930 "tight prior should pull t0 toward the center, got {}",
1931 tight.position_t0_us
1932 );
1933 assert!(
1934 (loose.position_t0_us - t0_inject).abs() < 0.3,
1935 "loose prior should recover the displacement, got {}",
1936 loose.position_t0_us
1937 );
1938 assert!(
1939 loose.prior_penalty < tight.prior_penalty,
1940 "loose penalty {} should be below tight penalty {}",
1941 loose.prior_penalty,
1942 tight.prior_penalty
1943 );
1944 assert!(
1945 loose.chi2_dof < tight.chi2_dof,
1946 "loose data χ² {} should beat tight data χ² {} (tight can't reach t0)",
1947 loose.chi2_dof,
1948 tight.chi2_dof
1949 );
1950 }
1951
1952 #[test]
1953 fn with_position_prior_builder_sets_fields() {
1954 let cfg = CalibrationConfig::default().with_position_prior(0.5, 1.001, 0.3, 0.002);
1955 assert!(cfg.fit_t0 && cfg.fit_l_scale);
1956 assert_eq!(cfg.position_t0_center_us, 0.5);
1957 assert_eq!(cfg.position_l_scale_center, 1.001);
1958 assert_eq!(cfg.position_t0_prior_us, Some(0.3));
1959 assert_eq!(cfg.position_l_scale_prior, Some(0.002));
1960 }
1961
1962 #[test]
1963 fn fit_l_scale_only_pins_t0() {
1964 // Per-coordinate control: fitting ONLY L_scale (fit_t0=false) must fit
1965 // position_l_scale while pinning t0 at its center — locks the
1966 // `unpack_position` indexing when only the SECOND position coordinate is
1967 // active (the single-coordinate path the round-2 review flagged).
1968 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1969 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1970 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1971 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1972 let base = synthetic_base_udr();
1973 let truth = ResolutionFunction::Tabulated(Arc::new(base.clone()));
1974 let data = forward_model(
1975 &energies,
1976 &sample,
1977 Some(&InstrumentParams { resolution: truth }),
1978 )
1979 .unwrap();
1980 let unc = vec![0.004; energies.len()];
1981 let cfg = CalibrationConfig {
1982 restarts: 2,
1983 fit_l_scale: true,
1984 ..Default::default()
1985 };
1986 let r = calibrate_resolution(
1987 ResolutionFamily::UdrCorr {
1988 base: Arc::new(base),
1989 },
1990 &energies,
1991 &data,
1992 &unc,
1993 &sample,
1994 &cfg,
1995 )
1996 .unwrap();
1997 assert_eq!(
1998 r.position_t0_us, 0.0,
1999 "t0 must stay pinned when fit_t0=false"
2000 );
2001 assert!(
2002 (r.position_l_scale - 1.0).abs() < 0.02,
2003 "L_scale fit within bound (~1 for a self-fit), got {}",
2004 r.position_l_scale
2005 );
2006 assert!(r.chi2_dof < 1e-1, "self-fit χ²/dof={} too high", r.chi2_dof);
2007 }
2008
2009 #[test]
2010 #[ignore = "slow; runs nightly"]
2011 fn cross_family_chi2_selects_the_true_shape() {
2012 // Model-family discrimination at a KNOWN (pinned) energy scale: an
2013 // asymmetric IC-broadened calibrant generated at the nominal position
2014 // (t0=0, L_scale=1) must be best-fit by the IC family and clearly worse by
2015 // the symmetric Gaussian. With position pinned (the default), the Gaussian
2016 // is penalized for both shape AND the asymmetry-induced dip shift it cannot
2017 // reproduce — legitimate here because the truth's position is known exactly.
2018 // (When position is uncertain, that shift is confounded with flight-path L —
2019 // see `free_l_scale_absorbs_asymmetric_lag_and_erodes_discrimination`.)
2020 // Truth has NO width-correction/Gaussian generator, so the Gaussian arm is a
2021 // genuinely different shape (not loop-closure).
2022 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
2023 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
2024 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
2025 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
2026 let ic = IkedaCarpenter::new(
2027 IkedaCarpenterParams {
2028 alpha: EnergyLaw::SqrtE { a0: 0.30, a1: 0.0 },
2029 beta: EnergyLaw::Const(0.1),
2030 r: EnergyLaw::ExpMilliEv { kappa: 25.0 },
2031 burst_sigma_us: None,
2032 channel_fwhm_us: None,
2033 },
2034 25.0,
2035 &SynthesisGrid {
2036 e_min_ev: 4.0,
2037 e_max_ev: 100.0,
2038 n_energies: 64,
2039 n_tau: 500,
2040 },
2041 )
2042 .unwrap();
2043 let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
2044 let data = forward_model(
2045 &energies,
2046 &sample,
2047 Some(&InstrumentParams { resolution: truth }),
2048 )
2049 .unwrap();
2050 let unc = vec![0.004; energies.len()];
2051 // The truth kernel carries NO PSR fold, so disable the calibrator's
2052 // default 350 ns fold — otherwise the IC family could not close.
2053 let cfg = CalibrationConfig {
2054 restarts: 3,
2055 psr_fwhm_ns: 0.0,
2056 ..Default::default()
2057 };
2058 let chi2 = |fam| {
2059 calibrate_resolution(fam, &energies, &data, &unc, &sample, &cfg)
2060 .unwrap()
2061 .chi2_dof
2062 };
2063 let ic_chi2 = chi2(ResolutionFamily::IkedaCarpenter { fit_psr: false });
2064 let gau_chi2 = chi2(ResolutionFamily::Gaussian);
2065 assert!(
2066 ic_chi2 < gau_chi2,
2067 "true (IC) shape χ²={ic_chi2} should beat the Gaussian χ²={gau_chi2}"
2068 );
2069 assert!(
2070 ic_chi2 < 1.0,
2071 "IC (true shape) should fit well: χ²={ic_chi2}"
2072 );
2073 }
2074
2075 #[test]
2076 #[ignore = "slow; runs nightly"]
2077 fn gaussian_and_ic_families_run_and_converge() {
2078 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2079 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2080 let energies: Vec<f64> = (0..300).map(|i| 12.0 + i as f64 * 0.05).collect();
2081 let base = synthetic_base_udr();
2082 let truth = ResolutionFunction::Tabulated(Arc::new(
2083 base.width_corrected(1.2, 0.0, UDR_E_REF).unwrap(),
2084 ));
2085 let data = forward_model(
2086 &energies,
2087 &sample,
2088 Some(&InstrumentParams { resolution: truth }),
2089 )
2090 .unwrap();
2091 let unc = vec![0.004; energies.len()];
2092 let cfg = CalibrationConfig::default();
2093 for (fam, n_expected) in [
2094 (ResolutionFamily::Gaussian, 2),
2095 (ResolutionFamily::IkedaCarpenter { fit_psr: false }, 4),
2096 ] {
2097 let label = fam.label().to_string();
2098 let r = calibrate_resolution(fam, &energies, &data, &unc, &sample, &cfg).unwrap();
2099 assert!(r.chi2_dof.is_finite(), "{label} χ² not finite");
2100 assert_eq!(
2101 r.theta.len(),
2102 n_expected,
2103 "{label} should fit {n_expected} params"
2104 );
2105 assert_eq!(r.n_free_params, n_expected, "{label} n_free_params");
2106 // The objective is smooth and noise-free, so Nelder–Mead reaches its
2107 // tolerance well within max_iter — guard the "_and_converge" promise.
2108 assert!(r.converged, "{label} did not self-converge");
2109 }
2110 }
2111
2112 #[test]
2113 fn n_params_matches_family() {
2114 assert_eq!(ResolutionFamily::Gaussian.n_params(), 2);
2115 assert_eq!(
2116 ResolutionFamily::UdrCorr {
2117 base: Arc::new(synthetic_base_udr())
2118 }
2119 .n_params(),
2120 2
2121 );
2122 // IC fits θ = [ln a0, ln a1, ln β, R] (+ PSR FWHM iff fit_psr).
2123 assert_eq!(
2124 ResolutionFamily::IkedaCarpenter { fit_psr: false }.n_params(),
2125 4
2126 );
2127 assert_eq!(
2128 ResolutionFamily::IkedaCarpenter { fit_psr: true }.n_params(),
2129 5
2130 );
2131 // param_names track n_params, coordinate for coordinate.
2132 for fam in [
2133 ResolutionFamily::Gaussian,
2134 ResolutionFamily::IkedaCarpenter { fit_psr: false },
2135 ResolutionFamily::IkedaCarpenter { fit_psr: true },
2136 ] {
2137 assert_eq!(fam.param_names().len(), fam.n_params());
2138 }
2139 assert_eq!(
2140 ResolutionFamily::IkedaCarpenter { fit_psr: true }
2141 .param_names()
2142 .last()
2143 .copied(),
2144 Some("psr_fwhm_us")
2145 );
2146 }
2147
2148 #[test]
2149 fn rejects_empty_mismatched_and_non_finite_inputs() {
2150 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2151 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2152 let cfg = CalibrationConfig::default();
2153 assert!(matches!(
2154 calibrate_resolution(ResolutionFamily::Gaussian, &[], &[], &[], &sample, &cfg),
2155 Err(FittingError::EmptyData)
2156 ));
2157 let e = vec![1.0, 2.0, 3.0];
2158 let d = vec![0.5, 0.5];
2159 let u = vec![0.1, 0.1];
2160 assert!(matches!(
2161 calibrate_resolution(ResolutionFamily::Gaussian, &e, &d, &u, &sample, &cfg),
2162 Err(FittingError::LengthMismatch { .. })
2163 ));
2164 // Non-finite data and non-positive uncertainty are rejected up front.
2165 let e = vec![1.0, 2.0, 3.0];
2166 assert!(matches!(
2167 calibrate_resolution(
2168 ResolutionFamily::Gaussian,
2169 &e,
2170 &[0.5, f64::NAN, 0.7],
2171 &[0.1; 3],
2172 &sample,
2173 &cfg
2174 ),
2175 Err(FittingError::InvalidConfig(_))
2176 ));
2177 assert!(matches!(
2178 calibrate_resolution(
2179 ResolutionFamily::Gaussian,
2180 &e,
2181 &[0.5; 3],
2182 &[0.1, 0.0, 0.1],
2183 &sample,
2184 &cfg
2185 ),
2186 Err(FittingError::InvalidConfig(_))
2187 ));
2188 }
2189
2190 #[test]
2191 fn rejects_nonascending_and_nonpositive_energy_grid() {
2192 // Sibling-path parity with the Python `validate_energy_grid`: descending,
2193 // duplicate, zero, and negative energy grids must be rejected up front
2194 // rather than panicking deep in the cross-section assert or erroring late.
2195 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2196 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2197 let cfg = CalibrationConfig::default();
2198 for grid in [
2199 vec![4.0, 3.0, 2.0, 1.0], // descending
2200 vec![1.0, 2.0, 2.0, 3.0], // duplicate
2201 vec![0.0, 1.0, 2.0, 3.0], // zero
2202 vec![-1.0, 1.0, 2.0, 3.0], // negative
2203 ] {
2204 let n = grid.len();
2205 assert!(
2206 matches!(
2207 calibrate_resolution(
2208 ResolutionFamily::Gaussian,
2209 &grid,
2210 &vec![0.5; n],
2211 &vec![0.1; n],
2212 &sample,
2213 &cfg
2214 ),
2215 Err(FittingError::InvalidConfig(_))
2216 ),
2217 "expected InvalidConfig for grid {grid:?}"
2218 );
2219 }
2220 }
2221
2222 #[test]
2223 fn rejects_degenerate_calibrant_composition() {
2224 // A calibrant with no isotopes, or only zero/negative densities, yields a
2225 // flat (resolution-independent) forward model; the optimizer would return
2226 // a finite but meaningless result. Reject up front (Python-sibling parity).
2227 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2228 let energies: Vec<f64> = (0..64).map(|i| 5.0 + i as f64 * 0.4).collect();
2229 let data = vec![0.8; energies.len()];
2230 let unc = vec![0.01; energies.len()];
2231 let cfg = CalibrationConfig::default();
2232 for bad_sample in [
2233 SampleParams::new(300.0, vec![]).unwrap(),
2234 SampleParams::new(300.0, vec![(iso.clone(), 0.0)]).unwrap(),
2235 SampleParams::new(300.0, vec![(iso, -1.0e-3)]).unwrap(),
2236 ] {
2237 assert!(
2238 matches!(
2239 calibrate_resolution(
2240 ResolutionFamily::Gaussian,
2241 &energies,
2242 &data,
2243 &unc,
2244 &bad_sample,
2245 &cfg
2246 ),
2247 Err(FittingError::InvalidConfig(_))
2248 ),
2249 "degenerate calibrant composition should be rejected"
2250 );
2251 }
2252 }
2253
2254 #[test]
2255 fn invalid_flight_path_propagates_build_error() {
2256 // flight_path <= 0 makes ResolutionParams::new fail on every eval, so the
2257 // calibration cannot build a resolution and returns an error.
2258 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2259 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2260 let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2261 let d = vec![0.9; 60];
2262 let u = vec![0.01; 60];
2263 let cfg = CalibrationConfig {
2264 flight_path_m: -1.0,
2265 ..Default::default()
2266 };
2267 assert!(
2268 calibrate_resolution(ResolutionFamily::Gaussian, &e, &d, &u, &sample, &cfg).is_err()
2269 );
2270 }
2271
2272 #[test]
2273 fn inner_chi2_background_path_and_degenerate_model() {
2274 // 3-column baseline fit (anorm + const + linear) recovers an offset exactly.
2275 let model = vec![0.9, 0.7, 0.5, 0.8, 0.6];
2276 let data: Vec<f64> = model.iter().map(|m| 0.5 * m + 0.1).collect();
2277 let unc = vec![0.01; 5];
2278 assert!(inner_chi2(&data, &unc, &model, true, 0) < 1e-12);
2279 // all-zero model -> singular normal equations -> infeasible (χ²=∞), so the
2280 // optimizer steps away rather than seeing a spuriously inflated finite χ².
2281 let v = inner_chi2(&data, &unc, &[0.0; 5], false, 0);
2282 assert_eq!(v, f64::INFINITY);
2283 }
2284
2285 #[test]
2286 fn calibrate_with_background_runs() {
2287 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2288 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2289 let energies: Vec<f64> = (0..200).map(|i| 14.0 + i as f64 * 0.06).collect();
2290 let base = synthetic_base_udr();
2291 let truth = ResolutionFunction::Tabulated(Arc::new(
2292 base.width_corrected(1.3, 0.0, UDR_E_REF).unwrap(),
2293 ));
2294 let data = forward_model(
2295 &energies,
2296 &sample,
2297 Some(&InstrumentParams { resolution: truth }),
2298 )
2299 .unwrap();
2300 let unc = vec![0.004; energies.len()];
2301 let cfg = CalibrationConfig {
2302 fit_background: true,
2303 ..Default::default()
2304 };
2305 let r = calibrate_resolution(
2306 ResolutionFamily::UdrCorr {
2307 base: Arc::new(base),
2308 },
2309 &energies,
2310 &data,
2311 &unc,
2312 &sample,
2313 &cfg,
2314 )
2315 .unwrap();
2316 assert!(r.chi2_dof.is_finite());
2317 }
2318
2319 #[test]
2320 #[ignore = "slow; runs nightly"]
2321 fn ic_recovers_known_alpha() {
2322 // Loop-closure / optimizer test (same caveat as udr_corr): truth and fit
2323 // both use the IC synthesis, so this checks the optimizer recovers the
2324 // full bounded 4-parameter family (#642) — the IC pulse physics is
2325 // independently covered by the ic_pulse tests in nereids-physics.
2326 // Truth, all interior to the new boxes: a0=0.35, a1=0.05, β=0.1, R=0.1,
2327 // PSR triangle 0.35 µs (= the calibrator's default 350 ns pin).
2328 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2329 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2330 let energies: Vec<f64> = (0..400).map(|i| 12.0 + i as f64 * 0.04).collect();
2331 let cfg = CalibrationConfig {
2332 restarts: 2,
2333 ..Default::default()
2334 };
2335 // Truth kernel on the SAME derived grid the calibrator synthesizes on,
2336 // so the loop closes exactly (a grid mismatch would leak into recovery).
2337 let ic_truth = IkedaCarpenter::new(
2338 IkedaCarpenterParams {
2339 alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2340 beta: EnergyLaw::Const(0.1),
2341 r: EnergyLaw::Const(0.1),
2342 burst_sigma_us: None,
2343 channel_fwhm_us: Some(0.35),
2344 },
2345 cfg.flight_path_m,
2346 &SynthesisGrid {
2347 e_min_ev: (energies[0] * 0.5).max(1e-3),
2348 e_max_ev: energies.last().unwrap() * 2.0,
2349 n_energies: cfg.ic_n_energies,
2350 n_tau: cfg.ic_n_tau,
2351 },
2352 )
2353 .unwrap();
2354 let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2355 let data = forward_model(
2356 &energies,
2357 &sample,
2358 Some(&InstrumentParams { resolution: truth }),
2359 )
2360 .unwrap();
2361 let unc = vec![0.004; energies.len()];
2362 let r = calibrate_resolution(
2363 ResolutionFamily::IkedaCarpenter { fit_psr: false },
2364 &energies,
2365 &data,
2366 &unc,
2367 &sample,
2368 &cfg,
2369 )
2370 .unwrap();
2371 let (a0, a1, beta, rr, psr) = decoded_ic(&r);
2372 assert!((a0 - 0.35).abs() < 0.05, "recovered a0={a0}, expected 0.35");
2373 assert!(a1 > 0.0, "a1 positive by construction, got {a1}");
2374 // β and R shape the kernel only jointly through the storage tail
2375 // (weight R, decay 1/β), so their windows are deliberately loose.
2376 assert!(
2377 (beta - 0.1).abs() < 0.08,
2378 "recovered β={beta}, expected 0.1"
2379 );
2380 assert!((rr - 0.1).abs() < 0.08, "recovered R={rr}, expected 0.1");
2381 assert!(
2382 (psr - 0.35).abs() < 1e-12,
2383 "PSR pin {psr} µs != 0.35 µs (config default)"
2384 );
2385 assert_eq!(r.n_free_params, 4);
2386 assert!(
2387 r.bounds_hit.is_empty(),
2388 "interior truth must not pin bounds, got {:?}",
2389 r.bounds_hit
2390 );
2391 assert!(r.chi2_dof < 1.0, "matched χ²/dof={} too high", r.chi2_dof);
2392 }
2393
2394 #[test]
2395 #[ignore = "slow; runs nightly"]
2396 fn ic_recovers_known_psr_when_fit() {
2397 // Loop-closure / optimizer test for fit_psr (#645 F2, same caveat as
2398 // ic_recovers_known_alpha: truth and fit share the IC synthesis, so
2399 // this checks the 5-parameter optimizer, not the pulse physics).
2400 // Truth PSR FWHM = 0.6 µs — interior to the [0.05, 1] µs box and far
2401 // from the 0.35 µs default start — with the rest of the truth kernel
2402 // identical to ic_recovers_known_alpha. Two resonances (15 + 45 eV)
2403 // give the E-leverage that separates the E-independent triangle
2404 // width from the α(E) = a0·√E + a1 prompt law (a single resonance
2405 // probes the kernel at essentially one energy).
2406 // Full-density run (420 pts, 64×500 grid, restarts 2) recovers
2407 // psr = 0.5984 µs at χ²/dof ≈ 1e-6; this slimmed grid keeps the same
2408 // loop-closure semantics at a debug-friendly runtime.
2409 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
2410 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
2411 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
2412 let energies: Vec<f64> = (0..280).map(|i| 8.0 + i as f64 * 0.15).collect();
2413 let cfg = CalibrationConfig {
2414 restarts: 1,
2415 ic_n_energies: 32,
2416 ic_n_tau: 320,
2417 ..Default::default()
2418 };
2419 let psr_true = 0.6;
2420 let ic_truth = IkedaCarpenter::new(
2421 IkedaCarpenterParams {
2422 alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2423 beta: EnergyLaw::Const(0.1),
2424 r: EnergyLaw::Const(0.1),
2425 burst_sigma_us: None,
2426 channel_fwhm_us: Some(psr_true),
2427 },
2428 cfg.flight_path_m,
2429 &SynthesisGrid {
2430 e_min_ev: (energies[0] * 0.5).max(1e-3),
2431 e_max_ev: energies.last().unwrap() * 2.0,
2432 n_energies: cfg.ic_n_energies,
2433 n_tau: cfg.ic_n_tau,
2434 },
2435 )
2436 .unwrap();
2437 let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2438 let data = forward_model(
2439 &energies,
2440 &sample,
2441 Some(&InstrumentParams { resolution: truth }),
2442 )
2443 .unwrap();
2444 let unc = vec![0.004; energies.len()];
2445 let r = calibrate_resolution(
2446 ResolutionFamily::IkedaCarpenter { fit_psr: true },
2447 &energies,
2448 &data,
2449 &unc,
2450 &sample,
2451 &cfg,
2452 )
2453 .unwrap();
2454 let (a0, _a1, _beta, _rr, psr) = decoded_ic(&r);
2455 assert_eq!(r.n_free_params, 5);
2456 assert!(
2457 (psr - psr_true).abs() < 0.1,
2458 "recovered PSR FWHM {psr} µs, expected {psr_true} µs"
2459 );
2460 assert!((a0 - 0.35).abs() < 0.05, "recovered a0={a0}, expected 0.35");
2461 assert!(r.chi2_dof < 1.0, "matched χ²/dof={} too high", r.chi2_dof);
2462 }
2463
2464 #[test]
2465 #[ignore = "slow; runs nightly"]
2466 fn psr_disabled_at_zero_width() {
2467 // psr_fwhm_ns = 0.0 disables the triangle fold entirely: an UNFOLDED
2468 // truth is reproduced and the calibrated kernel carries no channel.
2469 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2470 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2471 let energies: Vec<f64> = (0..250).map(|i| 14.0 + i as f64 * 0.05).collect();
2472 let cfg = CalibrationConfig {
2473 psr_fwhm_ns: 0.0,
2474 ic_n_energies: 32,
2475 ic_n_tau: 300,
2476 ..Default::default()
2477 };
2478 let ic_truth = IkedaCarpenter::new(
2479 IkedaCarpenterParams {
2480 alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2481 beta: EnergyLaw::Const(0.1),
2482 r: EnergyLaw::Const(0.1),
2483 burst_sigma_us: None,
2484 channel_fwhm_us: None, // unfolded truth
2485 },
2486 cfg.flight_path_m,
2487 &SynthesisGrid {
2488 e_min_ev: (energies[0] * 0.5).max(1e-3),
2489 e_max_ev: energies.last().unwrap() * 2.0,
2490 n_energies: cfg.ic_n_energies,
2491 n_tau: cfg.ic_n_tau,
2492 },
2493 )
2494 .unwrap();
2495 let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2496 let data = forward_model(
2497 &energies,
2498 &sample,
2499 Some(&InstrumentParams { resolution: truth }),
2500 )
2501 .unwrap();
2502 let unc = vec![0.004; energies.len()];
2503 let r = calibrate_resolution(
2504 ResolutionFamily::IkedaCarpenter { fit_psr: false },
2505 &energies,
2506 &data,
2507 &unc,
2508 &sample,
2509 &cfg,
2510 )
2511 .unwrap();
2512 let ResolutionFunction::IkedaCarpenter(ic) = &r.resolution else {
2513 panic!("expected an IC resolution");
2514 };
2515 assert!(
2516 ic.params().channel_fwhm_us.is_none(),
2517 "psr_fwhm_ns = 0 must leave channel_fwhm_us = None, got {:?}",
2518 ic.params().channel_fwhm_us
2519 );
2520 assert!(
2521 r.chi2_dof < 1.0,
2522 "unfolded self-fit χ²/dof={} too high",
2523 r.chi2_dof
2524 );
2525 }
2526
2527 #[test]
2528 #[ignore = "slow; runs nightly"]
2529 fn bounds_hit_reports_pinned_parameter() {
2530 // A truth WITHOUT a storage tail (R = 0) drives the fitted R onto its
2531 // lower box bound; the result must say so ("r:lower") — the β↔R-ridge
2532 // degeneracy flag (with no tail, β is unconstrained).
2533 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2534 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2535 let energies: Vec<f64> = (0..250).map(|i| 14.0 + i as f64 * 0.05).collect();
2536 let cfg = CalibrationConfig {
2537 ic_n_energies: 32,
2538 ic_n_tau: 300,
2539 restarts: 2,
2540 ..Default::default()
2541 };
2542 let ic_truth = IkedaCarpenter::new(
2543 IkedaCarpenterParams {
2544 alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2545 beta: EnergyLaw::Const(0.1), // irrelevant at R = 0 (no storage term)
2546 r: EnergyLaw::Const(0.0),
2547 burst_sigma_us: None,
2548 channel_fwhm_us: Some(0.35),
2549 },
2550 cfg.flight_path_m,
2551 &SynthesisGrid {
2552 e_min_ev: (energies[0] * 0.5).max(1e-3),
2553 e_max_ev: energies.last().unwrap() * 2.0,
2554 n_energies: cfg.ic_n_energies,
2555 n_tau: cfg.ic_n_tau,
2556 },
2557 )
2558 .unwrap();
2559 let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2560 let data = forward_model(
2561 &energies,
2562 &sample,
2563 Some(&InstrumentParams { resolution: truth }),
2564 )
2565 .unwrap();
2566 let unc = vec![0.004; energies.len()];
2567 let r = calibrate_resolution(
2568 ResolutionFamily::IkedaCarpenter { fit_psr: false },
2569 &energies,
2570 &data,
2571 &unc,
2572 &sample,
2573 &cfg,
2574 )
2575 .unwrap();
2576 // At R_truth = 0 the β↔R ridge is FLAT: a fast-β storage term is
2577 // absorbable by the freed α(E) coefficients, so the optimizer's
2578 // endpoint — pinned on the r lower bound, or slightly interior —
2579 // depends on the kernel discretization. The physical contract is
2580 // that no MATERIAL storage fraction is claimed either way; the
2581 // deterministic positive pin for the bounds_hit labeling mechanism
2582 // is bounds_hit_labels_saturated_upper_bound below.
2583 let (_, _, beta_cal, r_cal, _) = decoded_ic(&r);
2584 assert!(
2585 r.bounds_hit.iter().any(|s| s == "r:lower") || (r_cal < 0.08 && beta_cal > 1.0),
2586 "R = 0 truth must not claim a material storage fraction: an \
2587 interior ridge endpoint is admissible only when the claimed \
2588 storage is small AND fast (β above the ~1.6 µs⁻¹ prompt rate, \
2589 i.e. absorbable) — a slow visible tail must fail. \
2590 bounds_hit = {:?}, decoded = {:?}",
2591 r.bounds_hit,
2592 decoded_ic(&r)
2593 );
2594 }
2595
2596 #[test]
2597 #[ignore = "slow; runs nightly"]
2598 fn bounds_hit_labels_saturated_upper_bound() {
2599 // A truth channel fold WIDER than the fitted PSR box (2.5 µs vs
2600 // PSR_FWHM_US_MAX = 1.0 µs) pulls the fitted width monotonically
2601 // into the upper bound: fold width is identifiable through the
2602 // kernel's second moment (unlike the β↔R ridge coordinates), so the
2603 // pin is deterministic — the positive test for the bounds_hit
2604 // labeling mechanism.
2605 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2606 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2607 let energies: Vec<f64> = (0..120).map(|i| 14.0 + i as f64 * 0.05).collect();
2608 let cfg = CalibrationConfig {
2609 ic_n_energies: 32,
2610 ic_n_tau: 300,
2611 restarts: 1,
2612 ..Default::default()
2613 };
2614 let ic_truth = IkedaCarpenter::new(
2615 IkedaCarpenterParams {
2616 alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2617 beta: EnergyLaw::Const(0.1),
2618 r: EnergyLaw::Const(0.0),
2619 burst_sigma_us: None,
2620 channel_fwhm_us: Some(2.5),
2621 },
2622 cfg.flight_path_m,
2623 &SynthesisGrid {
2624 e_min_ev: (energies[0] * 0.5).max(1e-3),
2625 e_max_ev: energies.last().unwrap() * 2.0,
2626 n_energies: cfg.ic_n_energies,
2627 n_tau: cfg.ic_n_tau,
2628 },
2629 )
2630 .unwrap();
2631 let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2632 let data = forward_model(
2633 &energies,
2634 &sample,
2635 Some(&InstrumentParams { resolution: truth }),
2636 )
2637 .unwrap();
2638 let unc = vec![0.004; energies.len()];
2639 let r = calibrate_resolution(
2640 ResolutionFamily::IkedaCarpenter { fit_psr: true },
2641 &energies,
2642 &data,
2643 &unc,
2644 &sample,
2645 &cfg,
2646 )
2647 .unwrap();
2648 assert!(
2649 r.bounds_hit.iter().any(|s| s == "psr_fwhm_us:upper"),
2650 "a truth fold wider than the PSR box must pin the upper bound: \
2651 bounds_hit = {:?}, decoded = {:?}",
2652 r.bounds_hit,
2653 decoded_ic(&r)
2654 );
2655 }
2656
2657 #[test]
2658 fn rejects_invalid_psr_fwhm_ns() {
2659 // NaN / negative / infinite PSR widths are config errors caught up
2660 // front (NaN would silently disable the `> 0.0` fold gate; a negative
2661 // width would fail deep in IkedaCarpenter::new on every evaluation).
2662 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2663 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2664 let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2665 let d = vec![0.9; 60];
2666 let u = vec![0.01; 60];
2667 for bad in [f64::NAN, -1.0, f64::INFINITY] {
2668 let cfg = CalibrationConfig {
2669 psr_fwhm_ns: bad,
2670 ..Default::default()
2671 };
2672 assert!(
2673 matches!(
2674 calibrate_resolution(
2675 ResolutionFamily::IkedaCarpenter { fit_psr: false },
2676 &e,
2677 &d,
2678 &u,
2679 &sample,
2680 &cfg
2681 ),
2682 Err(FittingError::InvalidConfig(_))
2683 ),
2684 "psr_fwhm_ns={bad} should be rejected"
2685 );
2686 }
2687 }
2688
2689 #[test]
2690 fn rejects_absurd_pinned_psr_width() {
2691 // Review #645 round 2, F1: psr_fwhm_ns is NANOSECONDS (FTS convention
2692 // 350 ns) and synthesis cost is quadratic in the fold width — a
2693 // µs-as-ns unit slip (350 meaning µs → 350_000 ns) previously passed
2694 // the finite/sign check and pinned a fictitious 350 µs fold: a
2695 // multi-hour silent hang. Widths above PSR_FWHM_PIN_CEILING_US
2696 // (10 µs = 10_000 ns) must be a loud up-front config error.
2697 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2698 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2699 let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2700 let d = vec![0.9; 60];
2701 let u = vec![0.01; 60];
2702 let cfg = CalibrationConfig {
2703 psr_fwhm_ns: 350_000.0, // "350 µs" unit slip
2704 ..Default::default()
2705 };
2706 let err = calibrate_resolution(
2707 ResolutionFamily::IkedaCarpenter { fit_psr: false },
2708 &e,
2709 &d,
2710 &u,
2711 &sample,
2712 &cfg,
2713 )
2714 .expect_err("a 350_000 ns (350 µs) pinned PSR width must be rejected");
2715 assert!(
2716 matches!(
2717 &err,
2718 FittingError::InvalidConfig(msg)
2719 if msg.contains("NANOSECONDS") && msg.contains("350 ns")
2720 ),
2721 "ceiling error must name the ns unit and the 350-ns convention, got {err:?}"
2722 );
2723
2724 // Boundary + normal pins stay valid: exactly 10_000 ns sits ON the
2725 // ceiling (rejection is strict `>`; 10_000·1e-3 rounds to exactly
2726 // 10.0) and 350 ns is the FTS default. psr_fwhm_ns = 0 (disable) is
2727 // pinned valid by rejects_fit_psr_with_zero_psr_width. Tiny
2728 // grid/iteration budget: these arms assert config validity, not fit
2729 // quality.
2730 for ok_ns in [350.0, 10_000.0] {
2731 let cheap = CalibrationConfig {
2732 psr_fwhm_ns: ok_ns,
2733 ic_n_energies: 8,
2734 ic_n_tau: 32,
2735 max_iter: 10,
2736 ..Default::default()
2737 };
2738 assert!(
2739 calibrate_resolution(
2740 ResolutionFamily::IkedaCarpenter { fit_psr: false },
2741 &e,
2742 &d,
2743 &u,
2744 &sample,
2745 &cheap,
2746 )
2747 .is_ok(),
2748 "psr_fwhm_ns = {ok_ns} ns must remain a valid pinned width"
2749 );
2750 }
2751 }
2752
2753 #[test]
2754 fn rejects_infeasible_psr_start_width() {
2755 // Review #645 round 3, F1: a nonzero PSR width in (0, ~58.6 ns)
2756 // passes every value-level check (finite / sign / ceiling) yet cannot
2757 // be SYNTHESIZED at the optimizer start: the default β/R start
2758 // (β = 0.1, R = 0.1 > R_NEGLIGIBLE) spans a 16/β = 160 µs storage
2759 // tail, capping the τ-step at 160/8191 ≈ 19.53 ns, and tau_geometry
2760 // rejects any triangle whose FWHM/3 floor is below that (fwhm <
2761 // ~58.6 ns). Every initial-simplex vertex was then ∞ (objective range
2762 // ∞ − ∞ = NaN — no self-convergence), so the calibration burned
2763 // max_iter and died with the generic "no finite-objective" error
2764 // blaming the forward model. The pre-flight must reject the START
2765 // precisely, surfacing the τ-geometry diagnosis and naming
2766 // psr_fwhm_ns. The fit_psr arm starts AT the fit-box floor
2767 // PSR_FWHM_US_MIN = 0.05 µs (50 ns), which is itself infeasible at
2768 // the default start — proof that a `>= PSR_FWHM_US_MIN` value check
2769 // would not be sufficient.
2770 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2771 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2772 let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2773 let d = vec![0.9; 60];
2774 let u = vec![0.01; 60];
2775 for (fit_psr, psr_ns) in [(false, 55.0), (true, 50.0)] {
2776 let cfg = CalibrationConfig {
2777 psr_fwhm_ns: psr_ns,
2778 ..Default::default()
2779 };
2780 let err = calibrate_resolution(
2781 ResolutionFamily::IkedaCarpenter { fit_psr },
2782 &e,
2783 &d,
2784 &u,
2785 &sample,
2786 &cfg,
2787 )
2788 .expect_err("a sub-59-ns PSR start must be rejected up front");
2789 assert!(
2790 matches!(
2791 &err,
2792 FittingError::InvalidConfig(msg)
2793 if msg.contains("starting parameter vector")
2794 && msg.contains("psr_fwhm_ns")
2795 && msg.contains("cannot resolve")
2796 ),
2797 "pre-flight error must name the start, psr_fwhm_ns and the τ-cap cause \
2798 (fit_psr = {fit_psr}, psr_ns = {psr_ns}), got {err:?}"
2799 );
2800 }
2801 }
2802
2803 #[test]
2804 fn rejects_fit_psr_with_zero_psr_width() {
2805 // psr_fwhm_ns = 0 means "no PSR fold"; fit_psr = true would silently
2806 // clamp that 0 start into the [0.05, 1] µs fit box, contradicting the
2807 // documented "0 disables". The contradiction is a config error.
2808 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2809 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2810 let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2811 let d = vec![0.9; 60];
2812 let u = vec![0.01; 60];
2813 let cfg = CalibrationConfig {
2814 psr_fwhm_ns: 0.0,
2815 ..Default::default()
2816 };
2817 let err = calibrate_resolution(
2818 ResolutionFamily::IkedaCarpenter { fit_psr: true },
2819 &e,
2820 &d,
2821 &u,
2822 &sample,
2823 &cfg,
2824 )
2825 .expect_err("fit_psr with psr_fwhm_ns = 0 must be rejected");
2826 assert!(
2827 matches!(&err, FittingError::InvalidConfig(msg) if msg.contains("fit_psr")),
2828 "expected an InvalidConfig naming fit_psr, got {err:?}"
2829 );
2830 // The same zero width WITHOUT fit_psr stays valid ("0 disables").
2831 // Tiny grid/iteration budget: this arm only asserts the config
2832 // passes validation, not fit quality.
2833 let cheap = CalibrationConfig {
2834 psr_fwhm_ns: 0.0,
2835 ic_n_energies: 8,
2836 ic_n_tau: 32,
2837 max_iter: 10,
2838 ..Default::default()
2839 };
2840 assert!(
2841 calibrate_resolution(
2842 ResolutionFamily::IkedaCarpenter { fit_psr: false },
2843 &e,
2844 &d,
2845 &u,
2846 &sample,
2847 &cheap,
2848 )
2849 .is_ok(),
2850 "psr_fwhm_ns = 0 with fit_psr = false must remain a valid config"
2851 );
2852 }
2853
2854 #[test]
2855 fn rejects_undersized_ic_synthesis_grid() {
2856 // ic_n_energies < 2 / ic_n_tau < 8 previously surfaced only as the
2857 // late, generic "no finite-objective resolution" error (every
2858 // IkedaCarpenter::new evaluation failed). They must be precise
2859 // up-front InvalidConfig errors for the IC family — sibling parity
2860 // with the Python binding's validation.
2861 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2862 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2863 let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2864 let d = vec![0.9; 60];
2865 let u = vec![0.01; 60];
2866 for (ne, nt, what) in [(1, 500, "ic_n_energies"), (64, 7, "ic_n_tau")] {
2867 // The loose iteration/tolerance budget only cheapens the Gaussian
2868 // is_ok arm below (validation-only assertion, not fit quality);
2869 // the InvalidConfig arm rejects before any optimization runs.
2870 let cfg = CalibrationConfig {
2871 ic_n_energies: ne,
2872 ic_n_tau: nt,
2873 max_iter: 5,
2874 xatol: 1.0,
2875 fatol: 1.0,
2876 ..Default::default()
2877 };
2878 let err = calibrate_resolution(
2879 ResolutionFamily::IkedaCarpenter { fit_psr: false },
2880 &e,
2881 &d,
2882 &u,
2883 &sample,
2884 &cfg,
2885 )
2886 .expect_err("undersized IC synthesis grid must be rejected");
2887 assert!(
2888 matches!(&err, FittingError::InvalidConfig(msg) if msg.contains(what)),
2889 "expected an InvalidConfig naming {what}, got {err:?}"
2890 );
2891 // The same values are inert for a non-IC family (mirrors the
2892 // Python binding: the knobs only size the IC synthesis grid).
2893 assert!(
2894 calibrate_resolution(ResolutionFamily::Gaussian, &e, &d, &u, &sample, &cfg).is_ok(),
2895 "ic grid knobs must stay inert for the Gaussian family"
2896 );
2897 }
2898 }
2899
2900 #[test]
2901 fn ic_box_worst_corner_synthesizes_within_tau_cap() {
2902 // #645 F1: the calibrator's box must not abort a calibration on an
2903 // unresolvable τ-grid. Worst corner of the box in the τ-cap sense:
2904 // β at its floor (slow reach 16/β = 800 µs — the longest admitted
2905 // storage tail, so the capped step is at its widest ≈ 0.098 µs),
2906 // R = 1 (storage fully active), a1 at its ceiling and a0 at the
2907 // documented physical ceiling (α ≈ 1–3 µs⁻¹ in the eV regime ⇒
2908 // a0 ≈ 0.2–0.5, see IC_A0_MIN), at the calibrator's default
2909 // n_tau = 500 on a representative eV-regime synthesis window. The
2910 // capped step resolves both the PSR triangle box (0.35 µs default
2911 // pin up to the 1 µs fitted ceiling: ≥ 3 samples per side) and any
2912 // prompt core with α ≤ 18/(7 · 0.098) ≈ 26 µs⁻¹ — far above eV-regime
2913 // moderator physics. (The remaining unresolvable pockets — a fitted
2914 // PSR near its 0.05 µs floor together with β near its floor, or
2915 // a0 driven ~50× past the physical ceiling — are handled as
2916 // infeasible points, see the companion test below.)
2917 let cfg = CalibrationConfig::default();
2918 for fwhm_us in [DEFAULT_PSR_FWHM_NS * NS_TO_US, PSR_FWHM_US_MAX] {
2919 let corner = IkedaCarpenterParams {
2920 alpha: EnergyLaw::SqrtE {
2921 a0: 0.5,
2922 a1: IC_A1_MAX,
2923 },
2924 beta: EnergyLaw::Const(IC_BETA_MIN),
2925 r: EnergyLaw::Const(IC_R_MAX),
2926 burst_sigma_us: None,
2927 channel_fwhm_us: Some(fwhm_us),
2928 };
2929 let grid = SynthesisGrid {
2930 e_min_ev: 6.0,
2931 e_max_ev: 112.0,
2932 n_energies: cfg.ic_n_energies,
2933 n_tau: cfg.ic_n_tau,
2934 };
2935 assert!(
2936 IkedaCarpenter::new(corner, cfg.flight_path_m, &grid).is_ok(),
2937 "calibration-box worst corner must synthesize (fwhm = {fwhm_us} µs)"
2938 );
2939 }
2940 }
2941
2942 #[test]
2943 fn ic_unresolvable_theta_errs_in_build_resolution() {
2944 // A θ inside the box can still be unresolvable: a fitted PSR at its
2945 // 0.05 µs floor against β at its own floor needs a τ-step ≤ FWHM/3 ≈
2946 // 0.017 µs across an 800 µs storage tail — past the 8192-sample cap.
2947 // This test asserts the build_resolution half only: such θ must Err.
2948 // The calibration-level half — the objective maps that Err to an ∞
2949 // point the simplex steps away from, never aborting the calibration —
2950 // is asserted by ic_infeasible_pocket_inside_box_completes_calibration
2951 // below (#645 round 3, F4).
2952 let cfg = CalibrationConfig::default();
2953 let theta = [
2954 IC_A0_X0.ln(),
2955 IC_A1_X0.ln(),
2956 IC_BETA_MIN.ln(),
2957 0.5,
2958 PSR_FWHM_US_MIN,
2959 ];
2960 let fam = ResolutionFamily::IkedaCarpenter { fit_psr: true };
2961 assert!(
2962 build_resolution(&fam, &theta, 6.0, 112.0, &cfg).is_err(),
2963 "β at its floor + PSR at its floor must be unresolvable"
2964 );
2965 }
2966
2967 #[test]
2968 #[ignore = "slow; runs nightly"]
2969 fn ic_infeasible_pocket_inside_box_completes_calibration() {
2970 // Review #645 round 3, F4 — the calibration-level half of the claim
2971 // above: with fit_psr the box CONTAINS the unresolvable pocket (PSR
2972 // near its 0.05 µs floor against β near its own floor), and the
2973 // simplex demonstrably brushes it — the 60 ns start sits just above
2974 // the ~58.6 ns feasibility edge at the default β/R start (the
2975 // pre-flight passes: 60/3 = 20 ns floor > 19.53 ns capped step), so
2976 // the FIRST simplex already carries an ∞ vertex: the β-decreased
2977 // vertex (ln β step is negative, β 0.1 → ~0.089) widens the storage
2978 // reach to ~180 µs and the capped step to ~21.9 ns, past the 20 ns
2979 // floor. The optimizer must treat such vertices as infeasible points
2980 // and finish: Ok, finite χ², decoded resolution inside the box. Tiny
2981 // grid/iteration budget — this asserts non-abortion, not fit quality.
2982 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2983 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2984 let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2985 let d = vec![0.9; 60];
2986 let u = vec![0.01; 60];
2987 let cfg = CalibrationConfig {
2988 psr_fwhm_ns: 60.0,
2989 ic_n_energies: 8,
2990 ic_n_tau: 32,
2991 max_iter: 60,
2992 ..Default::default()
2993 };
2994 let r = calibrate_resolution(
2995 ResolutionFamily::IkedaCarpenter { fit_psr: true },
2996 &e,
2997 &d,
2998 &u,
2999 &sample,
3000 &cfg,
3001 )
3002 .expect("an infeasible pocket inside the box must not abort the calibration");
3003 assert!(
3004 r.chi2_dof.is_finite(),
3005 "calibration through the infeasible pocket must return a finite χ²/dof, got {}",
3006 r.chi2_dof
3007 );
3008 let (_a0, _a1, beta, _r, psr_us) = decoded_ic(&r);
3009 assert!(
3010 (PSR_FWHM_US_MIN..=PSR_FWHM_US_MAX).contains(&psr_us)
3011 && (IC_BETA_MIN..=IC_BETA_MAX).contains(&beta),
3012 "decoded solution must be feasible and inside the box: β = {beta}, \
3013 psr = {psr_us} µs"
3014 );
3015 }
3016 /// The reported interval is the spread a repeated calibration actually
3017 /// shows.
3018 ///
3019 /// An interval checked only for shape can be any pair of numbers and
3020 /// still pass. The claim it makes is about repetition, so the oracle is
3021 /// repetition: calibrate many noise realizations of one calibrant and
3022 /// compare how far the answers scatter against what a single calibration
3023 /// said they would.
3024 ///
3025 /// Compared against the half-width, `(upper - lower) / 2`. The interval
3026 /// itself is asymmetric, and which side is the long one depends on where
3027 /// in the flat valley a realization landed, so neither side alone is the
3028 /// scatter; their average is.
3029 #[test]
3030 #[ignore = "slow; runs nightly"]
3031 fn the_reported_interval_predicts_the_scatter_of_repeated_calibrations() {
3032 use rand::SeedableRng;
3033 use rand_chacha::ChaCha12Rng;
3034 use rand_distr::{Distribution, Normal};
3035
3036 const REALIZATIONS: usize = 24;
3037 const NOISE: f64 = 0.002;
3038
3039 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
3040 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
3041 let energies: Vec<f64> = (0..120).map(|i| 18.0 + i as f64 * 0.04).collect();
3042 let cfg = CalibrationConfig {
3043 ic_n_energies: 8,
3044 ic_n_tau: 32,
3045 max_iter: 400,
3046 intervals: true,
3047 ..Default::default()
3048 };
3049 let truth = forward_model(
3050 &energies,
3051 &sample,
3052 Some(&InstrumentParams {
3053 resolution: ResolutionFunction::Gaussian(
3054 ResolutionParams::new(cfg.flight_path_m, 0.30, 0.05, 0.0)
3055 .expect("valid truth resolution"),
3056 ),
3057 }),
3058 )
3059 .expect("truth forward model");
3060 let unc = vec![NOISE; energies.len()];
3061
3062 let mut rng = ChaCha12Rng::seed_from_u64(20260917);
3063 let normal = Normal::new(0.0, NOISE).expect("valid noise distribution");
3064 let mut widths = Vec::new();
3065 let mut half_widths = Vec::new();
3066 for _ in 0..REALIZATIONS {
3067 let noisy: Vec<f64> = truth.iter().map(|t| t + normal.sample(&mut rng)).collect();
3068 let result = calibrate_resolution(
3069 ResolutionFamily::Gaussian,
3070 &energies,
3071 &noisy,
3072 &unc,
3073 &sample,
3074 &cfg,
3075 )
3076 .expect("calibration runs");
3077 let Some(intervals) = result.intervals.as_ref() else {
3078 assert!(
3079 !result.converged,
3080 "no interval was reported for a self-converged run, so its \
3081 rejection is unaccounted for"
3082 );
3083 continue;
3084 };
3085 let width = result.theta[0].abs();
3086 let (lo, hi) = intervals[0];
3087 assert!(
3088 lo <= width && width <= hi,
3089 "the interval [{lo}, {hi}] does not bracket its own solution {width}"
3090 );
3091 widths.push(width);
3092 half_widths.push(0.5 * (hi - lo));
3093 }
3094 assert!(
3095 widths.len() >= REALIZATIONS / 2,
3096 "only {} of {REALIZATIONS} realizations reported an interval; the \
3097 comparison would be drawn from a selected subset",
3098 widths.len()
3099 );
3100
3101 let n = widths.len() as f64;
3102 let mean = widths.iter().sum::<f64>() / n;
3103 let observed = (widths.iter().map(|w| (w - mean).powi(2)).sum::<f64>() / (n - 1.0)).sqrt();
3104 let predicted = half_widths.iter().sum::<f64>() / n;
3105 let ratio = observed / predicted;
3106 assert!(
3107 (0.5..=2.0).contains(&ratio),
3108 "the calibrations scatter by {observed:.4e} while their own \
3109 intervals predict {predicted:.4e} (ratio {ratio:.2}); an interval \
3110 that misses the spread by more than a factor of two is not an \
3111 uncertainty"
3112 );
3113 }
3114
3115 /// The width has no lower bound on this calibrant, and the interval says
3116 /// so.
3117 ///
3118 /// A resolution kernel narrower than the line it broadens leaves no trace
3119 /// in the spectrum, so the objective is flat all the way down and the
3120 /// data cannot distinguish a narrow kernel from none. The interval
3121 /// reports that by reaching the box floor.
3122 ///
3123 /// The upper bound is the opposite case and must be strictly inside the
3124 /// box: past the intrinsic width the dip smears and the objective climbs,
3125 /// so that side is measured, not open.
3126 #[test]
3127 fn the_width_interval_is_open_below_and_closed_above() {
3128 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
3129 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
3130 let energies: Vec<f64> = (0..120).map(|i| 18.0 + i as f64 * 0.04).collect();
3131 let cfg = CalibrationConfig {
3132 ic_n_energies: 8,
3133 ic_n_tau: 32,
3134 max_iter: 400,
3135 intervals: true,
3136 ..Default::default()
3137 };
3138 let truth = forward_model(
3139 &energies,
3140 &sample,
3141 Some(&InstrumentParams {
3142 resolution: ResolutionFunction::Gaussian(
3143 ResolutionParams::new(cfg.flight_path_m, 0.30, 0.05, 0.0)
3144 .expect("valid truth resolution"),
3145 ),
3146 }),
3147 )
3148 .expect("truth forward model");
3149 let unc = vec![0.002; energies.len()];
3150
3151 let result = calibrate_resolution(
3152 ResolutionFamily::Gaussian,
3153 &energies,
3154 &truth,
3155 &unc,
3156 &sample,
3157 &cfg,
3158 )
3159 .expect("calibration runs");
3160 let intervals = result
3161 .intervals
3162 .as_ref()
3163 .expect("a self-converged calibration reports an interval");
3164 assert_eq!(
3165 intervals.len(),
3166 result.n_free_params,
3167 "one interval per fitted coordinate"
3168 );
3169
3170 let (box_lo, box_hi) = ResolutionFamily::Gaussian.x0_bounds(&cfg).1[0];
3171 let (lo, hi) = intervals[0];
3172 let width = result.theta[0].abs();
3173 assert!(
3174 lo <= box_lo + 1e-9,
3175 "the width interval starts at {lo}, inside the box floor {box_lo}; \
3176 a kernel narrower than the line leaves no trace, so the data \
3177 cannot bound the width from below"
3178 );
3179 assert!(
3180 hi > width && hi < box_hi,
3181 "the width interval ends at {hi}, outside ({width}, {box_hi}); \
3182 past the intrinsic width the dip smears, so that side is measured"
3183 );
3184 }
3185
3186 /// A crossing far from a solution that sits near its box floor is still
3187 /// found.
3188 ///
3189 /// The bracketing walk starts from the coordinate's own magnitude, so a
3190 /// solution near zero inside a wide box starts many doublings away from
3191 /// its own crossing. Against a parabola of known width the interval is
3192 /// `x0 ± sigma` exactly, and the side whose crossing lies outside the box
3193 /// is the box edge.
3194 #[test]
3195 fn a_crossing_far_from_a_small_solution_is_not_reported_as_unbounded() {
3196 const X0: f64 = 1.0e-3;
3197 const SIGMA: f64 = 20.0;
3198 let bounds = [(1.0e-4, 1.0e6)];
3199 let mut parabola =
3200 |x: &[f64]| -> Result<f64, FittingError> { Ok(((x[0] - X0) / SIGMA).powi(2)) };
3201 let nm = NelderMeadConfig::default();
3202
3203 let intervals = profile_intervals(&mut parabola, &[X0], &bounds, 0.0, &nm)
3204 .expect("a parabola has a curvature everywhere");
3205 let (lo, hi) = intervals[0];
3206 assert!(
3207 (hi - (X0 + SIGMA)).abs() < 0.05 * SIGMA,
3208 "the upper bound is {hi}, not the analytic crossing {}; a bracket \
3209 that stops short of the box reports a measured side as unbounded",
3210 X0 + SIGMA
3211 );
3212 assert!(
3213 (lo - bounds[0].0).abs() < 1e-12,
3214 "the lower crossing lies below the box floor {}, so the interval \
3215 must report the floor, not {lo}",
3216 bounds[0].0
3217 );
3218 }
3219}