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 => (vec![2.0, 1e-3], vec![(1e-3, 50.0), (0.0, 0.5)]),
365 ResolutionFamily::UdrCorr { .. } => {
366 // (log s0, p): s0 = exp(log_s0) clamped to [0.2, 5].
367 (
368 vec![0.0, 0.0],
369 vec![(UDR_S0_MIN.ln(), UDR_S0_MAX.ln()), (-4.0, 4.0)],
370 )
371 }
372 ResolutionFamily::IkedaCarpenter { fit_psr } => {
373 let mut x0 = vec![IC_A0_X0.ln(), IC_A1_X0.ln(), IC_BETA_X0.ln(), IC_R_X0];
374 let mut bounds = vec![
375 (IC_A0_MIN.ln(), IC_A0_MAX.ln()),
376 (IC_A1_MIN.ln(), IC_A1_MAX.ln()),
377 (IC_BETA_MIN.ln(), IC_BETA_MAX.ln()),
378 (IC_R_MIN, IC_R_MAX),
379 ];
380 if *fit_psr {
381 // cfg.psr_fwhm_ns > 0 is guaranteed here (fit_psr with a
382 // zero width is rejected up front — "0 disables" cannot
383 // silently become a fitted 0.05 µs). A positive start
384 // outside the fit box — legal as a PIN up to
385 // PSR_FWHM_PIN_CEILING_US — is CLAMPED to the nearer box
386 // edge, not rejected (#645 round 4, F3), and the clamp is
387 // announced on stderr: a clamped start that never leaves
388 // its edge additionally surfaces as "psr_fwhm_us:lower" /
389 // ":upper" in `CalibrationResult::bounds_hit`.
390 let start_us = cfg.psr_fwhm_ns * NS_TO_US;
391 let clamped_us = start_us.clamp(PSR_FWHM_US_MIN, PSR_FWHM_US_MAX);
392 if clamped_us != start_us {
393 eprintln!(
394 "warning: fit_psr starting width psr_fwhm_ns = {} ns lies \
395 outside the PSR fit box [{PSR_FWHM_US_MIN}, {PSR_FWHM_US_MAX}] µs; \
396 starting the fit at the nearer box edge ({clamped_us} µs). A fit \
397 that stays there reports \"psr_fwhm_us:lower\" / \":upper\" in \
398 bounds_hit.",
399 cfg.psr_fwhm_ns
400 );
401 }
402 x0.push(clamped_us);
403 bounds.push((PSR_FWHM_US_MIN, PSR_FWHM_US_MAX));
404 }
405 (x0, bounds)
406 }
407 }
408 }
409}
410
411/// Configuration for [`calibrate_resolution`].
412#[derive(Debug, Clone)]
413pub struct CalibrationConfig {
414 /// Flight-path length (m).
415 pub flight_path_m: f64,
416 /// Fit a low-order baseline (anorm + constant + linear) instead of anorm only.
417 pub fit_background: bool,
418 /// Number of optimizer restarts (perturbed starts; keep the best).
419 pub restarts: usize,
420 /// Nelder–Mead simplex-spread tolerance.
421 pub xatol: f64,
422 /// Nelder–Mead objective-range tolerance.
423 pub fatol: f64,
424 /// Nelder–Mead maximum iterations.
425 pub max_iter: usize,
426 /// IC synthesis grid resolution (energies × τ-samples per kernel).
427 pub ic_n_energies: usize,
428 pub ic_n_tau: usize,
429 /// SNS PSR (accumulator-ring) channel-triangle FWHM in **ns**, folded into
430 /// the **IC family only** (default [`DEFAULT_PSR_FWHM_NS`]; `0.0`
431 /// disables the fold). Tabulated/UDR (FTS) kernels already carry the fold
432 /// in the file itself (header: "folded triang FWHM 350 ns PSR") and are
433 /// structurally never re-folded here — applying it twice would
434 /// double-count the burst. When the family is
435 /// `IkedaCarpenter { fit_psr: true }` this value is the fit's starting
436 /// point instead of a pin, clamped into the 0.05–1 µs fit box: a width
437 /// in (1, 10] µs is a legal pin but an out-of-box start — the fit then
438 /// starts at the box top (announced by a stderr warning), and if it
439 /// stays there it reports `psr_fwhm_us:upper` in
440 /// [`CalibrationResult::bounds_hit`]. Nonzero widths above
441 /// [`PSR_FWHM_PIN_CEILING_US`] (10 µs = 10 000 ns) are rejected as a
442 /// ns↔µs unit slip — see that constant for the quadratic-cost rationale.
443 pub psr_fwhm_ns: f64,
444 /// Fit the SAMMY TOF-zero `t0` (µs) as a SHARED energy-scale parameter.
445 /// **Default `false`** — position is pinned at
446 /// [`position_t0_center_us`](Self::position_t0_center_us) so calibration is a
447 /// pure shape/width fit (matching SAMMY, where `t0`/`L` are a separate
448 /// energy-scale calibration). Opt in only *with* a metrology prior; see
449 /// [`with_position_prior`](CalibrationConfig::with_position_prior).
450 pub fit_t0: bool,
451 /// Fit the flight-path scale `L_scale` as a shared energy-scale parameter.
452 /// **Default `false`.** A free `L_scale` shares the asymmetric-kernel lag's
453 /// `1/√E` basis and corrupts the calibrated width — fit it only under a prior.
454 pub fit_l_scale: bool,
455 /// Prior mean (and pinned value when [`fit_t0`](Self::fit_t0) is false) of the
456 /// TOF zero `t0` (µs). Default `0.0`. Lets a caller inject a pre-calibrated `t0`.
457 pub position_t0_center_us: f64,
458 /// Prior mean (and pinned value when [`fit_l_scale`](Self::fit_l_scale) is
459 /// false) of `L_scale`. Default `1.0`.
460 pub position_l_scale_center: f64,
461 /// Gaussian prior σ on `t0` (µs); `None` = flat (bounded only). When set, adds
462 /// `((t0 − center)/σ)²` to the data χ² (a metrology penalty, *not* part of the
463 /// reported `chi2_dof`).
464 pub position_t0_prior_us: Option<f64>,
465 /// Gaussian prior σ on `L_scale`; `None` = flat. See [`position_t0_prior_us`](Self::position_t0_prior_us).
466 pub position_l_scale_prior: Option<f64>,
467}
468
469impl Default for CalibrationConfig {
470 fn default() -> Self {
471 // Matches the validated Python calibrator (fatol=1e-3, not the
472 // NelderMeadConfig default 1e-4). The IC synthesis grid is DELIBERATELY
473 // lighter than the standalone IkedaCarpenter default (64×500 here vs the
474 // DEFAULT_N_ENERGIES×DEFAULT_N_TAU = 64×600 synthesis default): the outer
475 // loop re-synthesizes the kernel on every evaluation, and 500 τ-samples is
476 // ample for χ²/dof comparison.
477 Self {
478 flight_path_m: 25.0,
479 fit_background: false,
480 restarts: 1,
481 xatol: 1e-4,
482 fatol: 1e-3,
483 max_iter: 800,
484 ic_n_energies: 64,
485 ic_n_tau: 500,
486 psr_fwhm_ns: DEFAULT_PSR_FWHM_NS,
487 // Position is PINNED by default: pure shape/width calibration on the
488 // (already energy-calibrated) grid. Energy-scale fitting is an explicit
489 // opt-in via `with_position_prior`.
490 fit_t0: false,
491 fit_l_scale: false,
492 position_t0_center_us: 0.0,
493 position_l_scale_center: 1.0,
494 position_t0_prior_us: None,
495 position_l_scale_prior: None,
496 }
497 }
498}
499
500impl CalibrationConfig {
501 /// Enable a SHARED, metrology-priored energy-scale `(t0, L_scale)` fit: sets
502 /// [`fit_t0`](Self::fit_t0)/[`fit_l_scale`](Self::fit_l_scale), the prior means
503 /// (`*_center`), and the Gaussian prior σ. Use this for joint energy-scale or
504 /// cross-family identifiability work; the default config pins position (pure
505 /// shape/width calibration). Pass the prior σ from the instrument's independent
506 /// flight-path / timing metrology — a loose σ marginalizes position (weak,
507 /// honest shape-only discrimination), a tight σ pins it.
508 #[must_use]
509 pub fn with_position_prior(
510 mut self,
511 t0_center_us: f64,
512 l_scale_center: f64,
513 sigma_t0_us: f64,
514 sigma_l_scale: f64,
515 ) -> Self {
516 self.fit_t0 = true;
517 self.fit_l_scale = true;
518 self.position_t0_center_us = t0_center_us;
519 self.position_l_scale_center = l_scale_center;
520 self.position_t0_prior_us = Some(sigma_t0_us);
521 self.position_l_scale_prior = Some(sigma_l_scale);
522 self
523 }
524}
525
526/// Result of a resolution calibration.
527#[derive(Debug, Clone)]
528pub struct CalibrationResult {
529 /// Family label (`"gaussian"` | `"udr_corr"` | `"ic"`).
530 pub family: String,
531 /// Fitted parameter vector (raw optimizer space; see [`ResolutionFamily`]
532 /// and [`ResolutionFamily::param_names`]). For the IC family these are
533 /// ln/box-encoded — read decoded physical values off
534 /// [`resolution`](Self::resolution) instead of exponentiating by hand.
535 pub theta: Vec<f64>,
536 /// Reduced **data** χ²/dof of the best fit (after anorm/baseline). The
537 /// energy-scale prior penalty is *excluded* — it is reported separately as
538 /// [`prior_penalty`](Self::prior_penalty).
539 pub chi2_dof: f64,
540 /// The calibrated resolution, ready to pin into a sample fit.
541 pub resolution: ResolutionFunction,
542 /// Optimizer iterations of the winning restart.
543 pub iterations: usize,
544 /// Whether the winning restart self-converged.
545 pub converged: bool,
546 /// Fitted (or pinned) SAMMY energy-scale TOF zero `t0` (µs). Equals
547 /// `config.position_t0_center_us` when `fit_t0` is false (pinned). When fit, it
548 /// is a SHARED energy-scale parameter (not a per-family nuisance): the resonance
549 /// dip position is confounded with flight-path geometry (the asymmetric-kernel
550 /// lag is the same `1/√E` basis as `L_scale`), so `t0`/`L_scale` are constrained
551 /// by the metrology prior, not free.
552 pub position_t0_us: f64,
553 /// Fitted (or pinned) flight-path scale `L_scale`. Equals
554 /// `config.position_l_scale_center` when `fit_l_scale` is false.
555 pub position_l_scale: f64,
556 /// Gaussian-prior penalty `Σ((θ−center)/σ)²` on the fitted `(t0, L_scale)` at the
557 /// solution (0 when no position prior is active). `objective = χ²_data +
558 /// prior_penalty`; report it alongside `chi2_dof` so a large position move
559 /// (e.g. a wrong family needing ΔL/L ≫ the metrology σ) is visible, not hidden.
560 pub prior_penalty: f64,
561 /// Total outer-loop free parameters: resolution θ plus any FITTED position
562 /// coordinates (`t0`, `L_scale`). Makes cross-family χ² comparisons and
563 /// dof bookkeeping explicit now that families differ in size (IC is 4–5
564 /// parameters, Gaussian/UdrCorr are 2).
565 pub n_free_params: usize,
566 /// Coordinates that finished within `BOUND_HIT_REL_TOL·(hi−lo)` of a box
567 /// bound, as `"name:lower"` / `"name:upper"` (names from
568 /// [`ResolutionFamily::param_names`], plus `"t0_us"` / `"l_scale"` when
569 /// position is fitted). Empty = interior solution. A pinned bound makes a
570 /// degenerate calibration visible instead of silent: e.g. an eV-regime
571 /// calibrant with no storage tail drives `R → 0` (`"r:lower"`) — on that
572 /// β↔R ridge the storage term vanishes and β is unconstrained, so the
573 /// reported β must not be physically interpreted.
574 pub bounds_hit: Vec<String>,
575}
576
577fn build_resolution(
578 family: &ResolutionFamily,
579 theta: &[f64],
580 e_min: f64,
581 e_max: f64,
582 cfg: &CalibrationConfig,
583) -> Result<ResolutionFunction, FittingError> {
584 match family {
585 ResolutionFamily::Gaussian => {
586 let params =
587 ResolutionParams::new(cfg.flight_path_m, theta[0].abs(), theta[1].abs(), 0.0)
588 .map_err(|e| FittingError::EvaluationFailed(format!("gaussian res: {e:?}")))?;
589 Ok(ResolutionFunction::Gaussian(params))
590 }
591 ResolutionFamily::UdrCorr { base } => {
592 let s0 = theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
593 let corrected = base
594 .width_corrected(s0, theta[1], UDR_E_REF)
595 .map_err(|e| FittingError::EvaluationFailed(format!("udr_corr width: {e}")))?;
596 Ok(ResolutionFunction::Tabulated(Arc::new(corrected)))
597 }
598 ResolutionFamily::IkedaCarpenter { fit_psr } => {
599 // θ = [ln a0, ln a1, ln β, R, (PSR FWHM µs iff fit_psr)] — see the
600 // IC_* constants for the bounds and their physics. Decoding the
601 // exp-encoded coordinates here (not in a new EnergyLaw variant)
602 // keeps the kernel physics in nereids-physics untouched: the
603 // optimizer space guarantees α(E) > 0 and β > 0 by construction.
604 let psr_us = if *fit_psr {
605 theta[4]
606 } else {
607 cfg.psr_fwhm_ns * NS_TO_US
608 };
609 let params = IkedaCarpenterParams {
610 alpha: EnergyLaw::SqrtE {
611 a0: theta[0].exp(),
612 a1: theta[1].exp(),
613 },
614 beta: theta[2].exp(),
615 // Scalar R: a free κ in ExpMilliEv is unidentifiable in the eV
616 // regime (R ≡ 0 across 1–200 eV for ANY plausible κ); a scalar
617 // lets the calibrant decide whether a storage tail is present.
618 r: EnergyLaw::Const(theta[3]),
619 burst_sigma_us: None,
620 // SNS PSR channel-triangle fold (0 disables). IC family only —
621 // tabulated/UDR kernels already carry the fold in the file.
622 channel_fwhm_us: (psr_us > 0.0).then_some(psr_us),
623 };
624 let grid = SynthesisGrid {
625 e_min_ev: (e_min * 0.5).max(1e-3),
626 e_max_ev: e_max * 2.0,
627 n_energies: cfg.ic_n_energies,
628 n_tau: cfg.ic_n_tau,
629 };
630 let ic = IkedaCarpenter::new(params, cfg.flight_path_m, &grid)
631 .map_err(|e| FittingError::EvaluationFailed(format!("ic res: {e:?}")))?;
632 Ok(ResolutionFunction::IkedaCarpenter(Arc::new(ic)))
633 }
634 }
635}
636
637/// Weighted residual sum of squares after analytically profiling out `anorm`
638/// (+ optional constant+linear baseline): `data ≈ a·model (+ b0 + b1·x)`, weighted
639/// by `1/unc²`. Returns `(ssr, k)` where `k` is the number of linear nuisance
640/// columns (1 = anorm only, 3 = anorm+const+linear). This is the **raw** χ² (not
641/// divided by dof) so an energy-scale **prior penalty** can be added to it in the
642/// same units before the optimizer minimizes — adding a penalty to a *reduced* χ²
643/// would silently rescale the prior by the dof. Returns `None` on a singular
644/// normal-equations system (a degenerate/constant model column), so the caller can
645/// treat the point as infeasible rather than as a spuriously zeroed fit.
646fn inner_ssr(data: &[f64], unc: &[f64], model: &[f64], fit_bg: bool) -> Option<(f64, usize)> {
647 let n = data.len();
648 let k = if fit_bg { 3 } else { 1 };
649 let mut ata = vec![0.0f64; k * k];
650 let mut atb = vec![0.0f64; k];
651 for i in 0..n {
652 let w2 = 1.0 / unc[i].max(1e-9).powi(2);
653 let x = if n > 1 {
654 -1.0 + 2.0 * (i as f64) / ((n - 1) as f64)
655 } else {
656 0.0
657 };
658 let col = [model[i], 1.0, x];
659 for a in 0..k {
660 atb[a] += w2 * col[a] * data[i];
661 for b in 0..k {
662 ata[a * k + b] += w2 * col[a] * col[b];
663 }
664 }
665 }
666 let coef = solve_small(&ata, &atb, k)?;
667 let mut ssr = 0.0;
668 for i in 0..n {
669 let w2 = 1.0 / unc[i].max(1e-9).powi(2);
670 let x = if n > 1 {
671 -1.0 + 2.0 * (i as f64) / ((n - 1) as f64)
672 } else {
673 0.0
674 };
675 let pred = if fit_bg {
676 coef[0] * model[i] + coef[1] + coef[2] * x
677 } else {
678 coef[0] * model[i]
679 };
680 ssr += (data[i] - pred).powi(2) * w2;
681 }
682 Some((ssr, k))
683}
684
685/// Reduced χ²/dof = [`inner_ssr`] `/ (n − k − n_res_params)`, ∞ on a singular
686/// system. `n_res_params` counts the outer-loop parameters (resolution + any free
687/// position) which are not in the linear system but still consume dof. Test-only:
688/// the calibrator minimizes raw `inner_ssr` (+ prior) and reduces at the solution.
689#[cfg(test)]
690fn inner_chi2(data: &[f64], unc: &[f64], model: &[f64], fit_bg: bool, n_res_params: usize) -> f64 {
691 match inner_ssr(data, unc, model, fit_bg) {
692 Some((ssr, k)) => {
693 let dof = data.len().saturating_sub(k + n_res_params).max(1) as f64;
694 ssr / dof
695 }
696 None => f64::INFINITY,
697 }
698}
699
700/// Gaussian-prior penalty `Σ((θ − center)/σ)²` on the fitted energy-scale
701/// `(t0, L_scale)`. Only active coordinates (fit + prior σ set) contribute; a flat
702/// (σ = `None`) or pinned coordinate contributes 0.
703fn position_prior_penalty(t0_us: f64, l_scale: f64, cfg: &CalibrationConfig) -> f64 {
704 let mut penalty = 0.0;
705 if cfg.fit_t0
706 && let Some(sigma) = cfg.position_t0_prior_us
707 {
708 penalty += ((t0_us - cfg.position_t0_center_us) / sigma).powi(2);
709 }
710 if cfg.fit_l_scale
711 && let Some(sigma) = cfg.position_l_scale_prior
712 {
713 penalty += ((l_scale - cfg.position_l_scale_center) / sigma).powi(2);
714 }
715 penalty
716}
717
718/// Solve a small `k×k` linear system `A x = b` (k ≤ 3) by Gaussian elimination
719/// with partial pivoting. Returns `None` on a singular system.
720fn solve_small(a: &[f64], b: &[f64], k: usize) -> Option<Vec<f64>> {
721 // Relative pivot threshold scaled by the matrix norm, so ill-conditioned
722 // systems (not just exactly-singular ones) are reported infeasible.
723 let scale = a
724 .iter()
725 .fold(0.0_f64, |m, &v| m.max(v.abs()))
726 .max(f64::MIN_POSITIVE);
727 let mut m = a.to_vec();
728 let mut y = b.to_vec();
729 for col in 0..k {
730 let mut piv = col;
731 for r in (col + 1)..k {
732 if m[r * k + col].abs() > m[piv * k + col].abs() {
733 piv = r;
734 }
735 }
736 if m[piv * k + col].abs() < 1e-12 * scale {
737 return None;
738 }
739 if piv != col {
740 for c in 0..k {
741 m.swap(piv * k + c, col * k + c);
742 }
743 y.swap(piv, col);
744 }
745 for r in (col + 1)..k {
746 let f = m[r * k + col] / m[col * k + col];
747 for c in col..k {
748 m[r * k + c] -= f * m[col * k + c];
749 }
750 y[r] -= f * y[col];
751 }
752 }
753 let mut x = vec![0.0; k];
754 for col in (0..k).rev() {
755 let mut s = y[col];
756 for c in (col + 1)..k {
757 s -= m[col * k + c] * x[c];
758 }
759 x[col] = s / m[col * k + col];
760 }
761 Some(x)
762}
763
764/// Calibrate the resolution parameters of `family` against a known-(ρ,T)
765/// calibrant.
766///
767/// `sample` carries the FIXED density and temperature (and isotopes/groups). By
768/// default **only the resolution shape/width is optimized**, at the pinned energy
769/// scale `(t0, L_scale) = (center, center)` — a pure broadening calibration on an
770/// already energy-calibrated grid (this is the SAMMY split: resolution is a
771/// broadening kernel; `t0`/`L` are a *separate* energy-scale calibration).
772///
773/// Set [`CalibrationConfig::fit_t0`]/[`fit_l_scale`](CalibrationConfig::fit_l_scale)
774/// (e.g. via [`CalibrationConfig::with_position_prior`]) to *also* fit the SHARED
775/// energy-scale `(t0, L_scale)` under a Gaussian metrology prior — for joint
776/// energy-scale work or a cross-family identifiability study. Do **not** fit
777/// position with a flat prior in production: the asymmetric-kernel mode→centroid
778/// lag is the same `1/√E` basis as `L_scale`, so a free `L_scale` absorbs the lag
779/// and corrupts the calibrated width.
780///
781/// The IC family fits the full bounded moderator shape (#642): `α(E) =
782/// e^{θ0}√E + e^{θ1}` — positive at every energy by construction — plus free
783/// bounded `β = e^{θ2}` and scalar storage fraction `R = θ3 ∈ [0, 1]`, folded
784/// with the SNS PSR channel triangle
785/// ([`CalibrationConfig::psr_fwhm_ns`], default 350 ns; `0` disables;
786/// optionally fitted via `IkedaCarpenter { fit_psr: true }` — a zero width
787/// combined with `fit_psr` contradicts "0 disables" and is rejected).
788///
789/// Returns the fitted shape parameters, the reduced **data** χ²/dof, the fitted (or
790/// pinned) `(t0, L_scale)`, the prior penalty, the calibrated
791/// [`ResolutionFunction`] (ready to pin), the free-parameter count
792/// ([`CalibrationResult::n_free_params`]), and the pinned-bound report
793/// ([`CalibrationResult::bounds_hit`]).
794///
795/// # Errors
796/// [`FittingError::EmptyData`] / [`FittingError::LengthMismatch`] for bad
797/// inputs; [`FittingError::InvalidConfig`] for a bad grid or position config;
798/// propagates optimizer errors.
799pub fn calibrate_resolution(
800 family: ResolutionFamily,
801 energies: &[f64],
802 data: &[f64],
803 unc: &[f64],
804 sample: &SampleParams,
805 config: &CalibrationConfig,
806) -> Result<CalibrationResult, FittingError> {
807 if data.is_empty() {
808 return Err(FittingError::EmptyData);
809 }
810 if energies.len() != data.len() || unc.len() != data.len() {
811 return Err(FittingError::LengthMismatch {
812 expected: data.len(),
813 actual: energies.len().min(unc.len()),
814 field: "energies/unc vs data",
815 });
816 }
817 // Reject non-finite inputs up front: a NaN datum would otherwise propagate
818 // to a NaN χ², and since `NaN < x` is false the optimizer could retain it as
819 // "best" and return a NaN-objective fit silently.
820 if !energies.iter().all(|v| v.is_finite())
821 || !data.iter().all(|v| v.is_finite())
822 || !unc.iter().all(|v| v.is_finite() && *v > 0.0)
823 {
824 return Err(FittingError::InvalidConfig(
825 "energies, data must be finite and uncertainty finite and > 0".into(),
826 ));
827 }
828 // Energy grid must be strictly positive and strictly ascending — mirror the
829 // Python entry point's `validate_energy_grid` so both public APIs reject the
830 // same inputs up front. Without this, a zero/negative energy panics deep in
831 // the Reich–Moore cross-section assert, a descending grid errors late as a
832 // generic "forward model failed", and duplicate energies are silently
833 // accepted (the recurring NEREIDS sibling-path validation gap).
834 if energies[0] <= 0.0 {
835 return Err(FittingError::InvalidConfig(
836 "energies must be strictly positive".into(),
837 ));
838 }
839 if !energies.windows(2).all(|w| w[1] > w[0]) {
840 return Err(FittingError::InvalidConfig(
841 "energies must be strictly ascending (no duplicates)".into(),
842 ));
843 }
844 // The calibrant must have at least one isotope with a finite, positive areal
845 // density. Otherwise `forward_model` skips every isotope (thickness ≤ 0) and
846 // returns a flat T≡1 that is independent of the resolution parameters, so the
847 // optimizer would converge to a finite but physically meaningless result —
848 // silently masking a whole-config error. Mirrors the Python wrapper's guard.
849 if !sample
850 .isotopes()
851 .iter()
852 .any(|(_, density)| density.is_finite() && *density > 0.0)
853 {
854 return Err(FittingError::InvalidConfig(
855 "calibrant must have at least one isotope with a finite, positive density".into(),
856 ));
857 }
858 // Reject under-determined calibrants: need strictly more data points than the
859 // total free parameters (resolution + any *fitted* position + anorm/baseline).
860 let n_res = family.n_params();
861 let n_pos = usize::from(config.fit_t0) + usize::from(config.fit_l_scale);
862 let baseline_cols = if config.fit_background { 3 } else { 1 };
863 if data.len() <= n_res + n_pos + baseline_cols {
864 return Err(FittingError::InvalidConfig(format!(
865 "calibrant has {} points but the model has {} resolution + {} position + {} baseline \
866 parameters; need strictly more data points than parameters",
867 data.len(),
868 n_res,
869 n_pos,
870 baseline_cols,
871 )));
872 }
873 // Flight path is a physical positive length. A non-positive / non-finite value
874 // would invert the t0 feasibility bound below (min_tof < 0 ⇒ t0_hi < t0_lo ⇒
875 // `clamp(lo, hi)` panic) as soon as `fit_t0` appends a bounded coordinate, and
876 // otherwise only surfaces as a generic "no finite-objective" error. Reject it
877 // precisely up front (covers every family and the fit/pin paths alike).
878 if !(config.flight_path_m.is_finite() && config.flight_path_m > 0.0) {
879 return Err(FittingError::InvalidConfig(
880 "flight_path_m must be finite and > 0".into(),
881 ));
882 }
883 // PSR triangle width: finite and >= 0 (0.0 disables the fold). A NaN or
884 // negative width would otherwise flow into IkedaCarpenter::new on every
885 // evaluation and only surface as the generic "no finite-objective
886 // resolution" error. Validated for every family (it is inert outside IC)
887 // so a mis-set config is caught regardless of the family under test.
888 if !config.psr_fwhm_ns.is_finite() || config.psr_fwhm_ns < 0.0 {
889 return Err(FittingError::InvalidConfig(format!(
890 "psr_fwhm_ns must be finite and >= 0 (0 disables the PSR fold), got {}",
891 config.psr_fwhm_ns
892 )));
893 }
894 // Sanity ceiling on the width itself (see PSR_FWHM_PIN_CEILING_US):
895 // psr_fwhm_ns is NANOSECONDS and synthesis cost is quadratic in the fold
896 // width, so a µs-as-ns unit slip pins a fictitious multi-hundred-µs fold
897 // that hangs the calibration for hours. Reject loudly, up front, for
898 // every family (inert outside IC, same rationale as the checks above).
899 if config.psr_fwhm_ns * NS_TO_US > PSR_FWHM_PIN_CEILING_US {
900 return Err(FittingError::InvalidConfig(format!(
901 "psr_fwhm_ns = {} ns (= {} µs) exceeds the {PSR_FWHM_PIN_CEILING_US} µs sanity \
902 ceiling (10x the {PSR_FWHM_US_MAX} µs fit bound). psr_fwhm_ns is in NANOSECONDS \
903 — the SNS/VENUS FTS convention is 350 ns — and kernel-synthesis cost grows \
904 quadratically with the fold width, so a µs-as-ns unit slip would hang the \
905 calibration behind a fictitious fold. Pass the width in ns, or 0 to disable \
906 the PSR fold",
907 config.psr_fwhm_ns,
908 config.psr_fwhm_ns * NS_TO_US
909 )));
910 }
911 // fit_psr fits the PSR FWHM from the psr_fwhm_ns starting value, but 0 is
912 // documented as "no fold": a zero start would be silently clamped into the
913 // [PSR_FWHM_US_MIN, PSR_FWHM_US_MAX] fit box, contradicting the "0
914 // disables" contract. Reject the contradiction loudly.
915 if matches!(family, ResolutionFamily::IkedaCarpenter { fit_psr: true })
916 && config.psr_fwhm_ns == 0.0
917 {
918 return Err(FittingError::InvalidConfig(
919 "fit_psr requires a positive psr_fwhm_ns starting value (psr_fwhm_ns = 0 disables \
920 the PSR fold; use fit_psr = false to calibrate without one)"
921 .into(),
922 ));
923 }
924 // IC synthesis-grid resolution: validate up front for the IC family (inert
925 // for the others) so an out-of-range value gives this precise error instead
926 // of every IkedaCarpenter::new evaluation failing into the generic late
927 // "no finite-objective resolution" error. Thresholds mirror both
928 // IkedaCarpenter::new (n_energies >= 2, n_tau >= 8) and the Python
929 // binding's sibling validation, so the two public entry points reject the
930 // same inputs.
931 if matches!(family, ResolutionFamily::IkedaCarpenter { .. }) {
932 if config.ic_n_energies < 2 {
933 return Err(FittingError::InvalidConfig(format!(
934 "ic_n_energies must be >= 2 for the IC family, got {}",
935 config.ic_n_energies
936 )));
937 }
938 if config.ic_n_tau < 8 {
939 return Err(FittingError::InvalidConfig(format!(
940 "ic_n_tau must be >= 8 for the IC family, got {}",
941 config.ic_n_tau
942 )));
943 }
944 }
945 // Validate the energy-scale (t0, L_scale) prior/center configuration up front.
946 if !config.position_t0_center_us.is_finite()
947 || !config.position_l_scale_center.is_finite()
948 || config.position_l_scale_center <= 0.0
949 {
950 return Err(FittingError::InvalidConfig(
951 "position centers must be finite and the L_scale center > 0".into(),
952 ));
953 }
954 if config.position_t0_center_us.abs() >= POSITION_T0_US_MAX {
955 return Err(FittingError::InvalidConfig(format!(
956 "position_t0_center_us must lie within ±{POSITION_T0_US_MAX} µs"
957 )));
958 }
959 if config.position_l_scale_center < POSITION_L_SCALE_MIN
960 || config.position_l_scale_center > POSITION_L_SCALE_MAX
961 {
962 return Err(FittingError::InvalidConfig(format!(
963 "position_l_scale_center must lie within [{POSITION_L_SCALE_MIN}, {POSITION_L_SCALE_MAX}]"
964 )));
965 }
966 for (sigma, name) in [
967 (config.position_t0_prior_us, "position_t0_prior_us"),
968 (config.position_l_scale_prior, "position_l_scale_prior"),
969 ] {
970 if let Some(s) = sigma
971 && !(s.is_finite() && s > 0.0)
972 {
973 return Err(FittingError::InvalidConfig(format!(
974 "{name} must be finite and > 0 when set"
975 )));
976 }
977 }
978
979 let e_min = energies.first().copied().unwrap_or(1.0);
980 let e_max = energies.last().copied().unwrap_or(1.0);
981 // Feasible t0 upper bound: the corrected TOF `tof − t0` must stay positive for
982 // every energy, i.e. `t0 < min(tof) = TOF_FACTOR·L/√E_max`. Far outside ±5 µs in
983 // the eV regime, but clamp defensively so a wide window can never make it bite.
984 let min_tof = TOF_FACTOR * config.flight_path_m / e_max.max(1e-12).sqrt();
985 // The (pinned or prior-mean) t0 center must itself be feasible: corrected_energy_grid
986 // needs `t0 < min(tof)` for every energy. In the eV regime min_tof ≫ 5 µs, but a
987 // short flight path or very high E_max can shrink it — reject up front with a precise
988 // message instead of a late, generic "corrected TOF ≤ 0" from the final recompute.
989 if config.position_t0_center_us >= min_tof {
990 return Err(FittingError::InvalidConfig(format!(
991 "position_t0_center_us ({:.3} µs) must be below the shortest flight time \
992 min_tof = TOF_FACTOR·L/√E_max = {min_tof:.3} µs",
993 config.position_t0_center_us
994 )));
995 }
996 let t0_lo = -POSITION_T0_US_MAX;
997 let t0_hi = POSITION_T0_US_MAX.min(min_tof - 1e-6);
998
999 // Optimizer coordinates: [resolution params (n_res)..., t0?, L_scale?]. A
1000 // position coordinate is appended only when fit; otherwise it is pinned at its
1001 // center. (Position is a SHARED energy-scale parameter, not a per-family
1002 // nuisance — fitting it is an explicit, prior-constrained opt-in.)
1003 let (mut x0, mut bounds) = family.x0_bounds(config);
1004 if config.fit_t0 {
1005 x0.push(config.position_t0_center_us.clamp(t0_lo, t0_hi));
1006 bounds.push((t0_lo, t0_hi));
1007 }
1008 if config.fit_l_scale {
1009 x0.push(
1010 config
1011 .position_l_scale_center
1012 .clamp(POSITION_L_SCALE_MIN, POSITION_L_SCALE_MAX),
1013 );
1014 bounds.push((POSITION_L_SCALE_MIN, POSITION_L_SCALE_MAX));
1015 }
1016 // PRE-FLIGHT the start (#645 round 3, F1): synthesize the resolution once
1017 // at x0 before any optimization. A start whose kernel cannot be
1018 // synthesized — e.g. any PSR triangle under ~58.6 ns: the default β/R
1019 // start (β = 0.1, R = 0.1) spans a 16/β = 160 µs storage tail, capping
1020 // the τ-step at 160/8191 ≈ 19.53 ns, above such a triangle's FWHM/3
1021 // resolution floor (note the PSR fit-box floor 0.05 µs = 50 ns is ITSELF
1022 // in this class, so a `>= PSR_FWHM_US_MIN` value check could not cover
1023 // it) — passes every value-level config check above yet makes EVERY
1024 // initial-simplex vertex infeasible (∞ objective): the Nelder–Mead
1025 // objective range is then ∞ − ∞ = NaN, so it can never self-converge,
1026 // burns max_iter, and used to die late with the generic "no
1027 // finite-objective" error blaming the forward model. Reject the START
1028 // precisely instead, surfacing the τ-geometry/synthesis diagnosis. A θ
1029 // that becomes infeasible only DURING the search remains an ∞ point the
1030 // simplex steps away from (see the objective below) — this pre-flight
1031 // rejects only an infeasible start.
1032 if let Err(synth_err) = build_resolution(&family, &x0, e_min, e_max, config) {
1033 let psr_note = if matches!(family, ResolutionFamily::IkedaCarpenter { .. }) {
1034 format!(
1035 " The starting PSR width comes from psr_fwhm_ns = {} ns — widen the \
1036 triangle (the SNS/VENUS FTS convention is 350 ns), or pass 0 to \
1037 disable the fold when not fitting it.",
1038 config.psr_fwhm_ns
1039 )
1040 } else {
1041 String::new()
1042 };
1043 return Err(FittingError::InvalidConfig(format!(
1044 "resolution kernel synthesis is infeasible at the starting parameter \
1045 vector, so every optimizer restart would begin from an all-infeasible \
1046 simplex: {synth_err}.{psr_note}"
1047 )));
1048 }
1049 let nm = NelderMeadConfig {
1050 xatol: config.xatol,
1051 fatol: config.fatol,
1052 max_iter: config.max_iter,
1053 ..Default::default()
1054 };
1055
1056 // Read the (possibly pinned) position coordinates out of an optimizer vector.
1057 let unpack_position = |theta: &[f64]| -> (f64, f64) {
1058 let mut idx = n_res;
1059 let t0 = if config.fit_t0 {
1060 let v = theta[idx];
1061 idx += 1;
1062 v
1063 } else {
1064 config.position_t0_center_us
1065 };
1066 let l_scale = if config.fit_l_scale {
1067 theta[idx]
1068 } else {
1069 config.position_l_scale_center
1070 };
1071 (t0, l_scale)
1072 };
1073
1074 let mut best: Option<NelderMeadResult> = None;
1075 for r in 0..config.restarts.max(1) {
1076 // Additive perturbation (a fraction of each parameter's bound range) so
1077 // restarts move even for zero-valued start components — a multiplicative
1078 // `x0·(1+0.1r)` left `udr_corr`'s `[0, 0]` start identical every restart.
1079 let start: Vec<f64> = x0
1080 .iter()
1081 .zip(&bounds)
1082 .map(|(&v, &(lo, hi))| (v + 0.1 * r as f64 * (hi - lo)).clamp(lo, hi))
1083 .collect();
1084 let mut obj = |theta: &[f64]| -> Result<f64, FittingError> {
1085 // theta = [resolution params (n_res)..., t0?, L_scale?]. The resolution
1086 // kernel uses only the first n_res; (t0, L_scale) set the energy scale.
1087 // An UNRESOLVABLE θ — nereids-physics rejects a kernel whose τ-grid
1088 // cannot resolve the requested fold / prompt core within the
1089 // MAX_TAU_SAMPLES cap (e.g. a fitted PSR at its 0.05 µs floor
1090 // against β at its own floor) — is an infeasible POINT of the
1091 // search, not a broken calibration: step away (mirrors the
1092 // corrected-TOF ≤ 0 guard below). Config-level failures cannot
1093 // reach here: they are rejected up front by calibrate_resolution.
1094 let Ok(res) = build_resolution(&family, theta, e_min, e_max, config) else {
1095 return Ok(f64::INFINITY);
1096 };
1097 let inst = InstrumentParams { resolution: res };
1098 let (t0, l_scale) = unpack_position(theta);
1099 // Infeasible energy scale (corrected TOF ≤ 0) → step away.
1100 let Ok(grid) = corrected_energy_grid(energies, t0, l_scale, config.flight_path_m)
1101 else {
1102 return Ok(f64::INFINITY);
1103 };
1104 let model = forward_model(&grid, sample, Some(&inst))
1105 .map_err(|e| FittingError::EvaluationFailed(format!("forward: {e:?}")))?;
1106 if !model.iter().all(|v| v.is_finite()) {
1107 return Err(FittingError::EvaluationFailed("non-finite model".into()));
1108 }
1109 // Minimize RAW χ²_data + metrology prior penalty (same units — adding
1110 // the penalty to a reduced χ² would rescale the prior by the dof).
1111 let Some((ssr, _k)) = inner_ssr(data, unc, &model, config.fit_background) else {
1112 return Ok(f64::INFINITY);
1113 };
1114 Ok(ssr + position_prior_penalty(t0, l_scale, config))
1115 };
1116 let mut res = nelder_mead_minimize(&mut obj, &start, Some(&bounds), &nm)?;
1117 // Simplex RE-INFLATION: Nelder–Mead's known failure mode is premature
1118 // simplex collapse — the spread criteria are met (`self_converged`)
1119 // at a point that is NOT the basin minimum. Observed on the
1120 // 4-parameter IC family: a 300 K synthetic calibrant stalled at
1121 // Δχ² ≈ +130 above the noise floor in the curved α↔β↔R valley, and
1122 // the ~1.5 % kernel-width error re-expressed as a ~23 K temperature
1123 // bias in the downstream pinned fit. Standard cure: restart a FRESH,
1124 // *larger* simplex at the incumbent (same 5 % edge re-collapses to
1125 // the same trap deterministically) and keep the improvement, until
1126 // it stops helping (bounded by MAX_SIMPLEX_REINFLATIONS). A
1127 // re-inflation from a true minimum re-contracts quickly, so the
1128 // extra cost there is small.
1129 let reinflate_nm = NelderMeadConfig {
1130 initial_step_frac: REINFLATE_STEP_FRAC,
1131 initial_step_abs: REINFLATE_STEP_ABS,
1132 ..nm.clone()
1133 };
1134 for _ in 0..MAX_SIMPLEX_REINFLATIONS {
1135 let again = nelder_mead_minimize(&mut obj, &res.x, Some(&bounds), &reinflate_nm)?;
1136 let improved = again.fun + nm.fatol < res.fun;
1137 res.iterations += again.iterations;
1138 res.n_evals += again.n_evals;
1139 if improved {
1140 res.x = again.x;
1141 res.fun = again.fun;
1142 res.self_converged = again.self_converged;
1143 } else {
1144 break;
1145 }
1146 }
1147 if best.as_ref().is_none_or(|b| res.fun < b.fun) {
1148 best = Some(res);
1149 }
1150 }
1151 let best = best.expect("at least one restart runs");
1152 if !best.fun.is_finite() {
1153 // Every ∞ source of the objective (#645 round 3 F1, round 4 F1):
1154 // `nelder_mead_minimize` maps every objective `Err` — forward-model
1155 // failures included — to an infeasible +∞ point rather than aborting
1156 // (see the `eval` closure in `nelder_mead.rs`), so no failure class
1157 // raises its own error during the search. Reaching here means every
1158 // vector tried hit one of them — kernel synthesis rejected (τ-grid
1159 // cap vs fold/prompt geometry), an invalid energy scale (corrected
1160 // TOF ≤ 0), a singular anorm/baseline system, or a forward-model
1161 // (transmission) failure. The start itself synthesized (pre-flighted
1162 // above), so the infeasibility arose during the search.
1163 return Err(FittingError::EvaluationFailed(
1164 "calibration found no finite-objective resolution: every parameter vector \
1165 tried was infeasible — kernel synthesis rejected it (τ-grid cap vs \
1166 fold/prompt geometry), the energy scale was invalid (corrected TOF ≤ 0), \
1167 the anorm/baseline system was singular, or the forward model failed"
1168 .into(),
1169 ));
1170 }
1171 let (position_t0_us, position_l_scale) = unpack_position(&best.x);
1172 let prior_penalty = position_prior_penalty(position_t0_us, position_l_scale, config);
1173 // Recompute the reduced DATA χ²/dof at the solution: the objective carries the
1174 // prior penalty, so `best.fun` is the penalized objective, not the data χ². dof
1175 // subtracts the linear anorm/baseline columns AND the outer-loop params
1176 // (resolution + any fitted position).
1177 let resolution = build_resolution(&family, &best.x, e_min, e_max, config)?;
1178 let grid = corrected_energy_grid(
1179 energies,
1180 position_t0_us,
1181 position_l_scale,
1182 config.flight_path_m,
1183 )?;
1184 let inst = InstrumentParams { resolution };
1185 let model = forward_model(&grid, sample, Some(&inst))
1186 .map_err(|e| FittingError::EvaluationFailed(format!("forward: {e:?}")))?;
1187 let (ssr, k) = inner_ssr(data, unc, &model, config.fit_background).ok_or_else(|| {
1188 FittingError::EvaluationFailed("singular anorm/baseline at the solution".into())
1189 })?;
1190 let dof = data.len().saturating_sub(k + n_res + n_pos).max(1) as f64;
1191 let chi2_dof = ssr / dof;
1192 let theta = best.x[..n_res].to_vec();
1193 // Bound-pinning report: label every optimizer coordinate that finished
1194 // within BOUND_HIT_REL_TOL·(hi−lo) of its box bound. This is the
1195 // degeneracy flag for the wider IC family (e.g. "r:lower" ⇒ the β↔R ridge:
1196 // no storage tail in the data, β unconstrained).
1197 let mut coord_names = family.param_names();
1198 if config.fit_t0 {
1199 coord_names.push("t0_us");
1200 }
1201 if config.fit_l_scale {
1202 coord_names.push("l_scale");
1203 }
1204 let bounds_hit: Vec<String> = best
1205 .x
1206 .iter()
1207 .zip(&bounds)
1208 .zip(&coord_names)
1209 .flat_map(|((&v, &(lo, hi)), name)| {
1210 let tol = BOUND_HIT_REL_TOL * (hi - lo);
1211 let mut hits = Vec::new();
1212 if v - lo <= tol {
1213 hits.push(format!("{name}:lower"));
1214 }
1215 if hi - v <= tol {
1216 hits.push(format!("{name}:upper"));
1217 }
1218 hits
1219 })
1220 .collect();
1221 Ok(CalibrationResult {
1222 family: family.label().to_string(),
1223 theta,
1224 chi2_dof,
1225 resolution: inst.resolution,
1226 iterations: best.iterations,
1227 converged: best.self_converged,
1228 position_t0_us,
1229 position_l_scale,
1230 prior_penalty,
1231 n_free_params: n_res + n_pos,
1232 bounds_hit,
1233 })
1234}
1235
1236#[cfg(test)]
1237mod tests {
1238 use super::*;
1239 use nereids_endf::resonance::test_support::synthetic_isotope;
1240
1241 /// Decode the calibrated IC parameters `(a0, a1, β, R, psr_fwhm_us)` off
1242 /// the result's resolution — the single source of truth (raw `theta` is
1243 /// ln/box-encoded).
1244 fn decoded_ic(r: &CalibrationResult) -> (f64, f64, f64, f64, f64) {
1245 let ResolutionFunction::IkedaCarpenter(ic) = &r.resolution else {
1246 panic!("expected an IC resolution for family {}", r.family);
1247 };
1248 let p = ic.params();
1249 let EnergyLaw::SqrtE { a0, a1 } = p.alpha else {
1250 panic!("expected a SqrtE alpha law");
1251 };
1252 let EnergyLaw::Const(rr) = p.r else {
1253 panic!("expected a Const R law");
1254 };
1255 (a0, a1, p.beta, rr, p.channel_fwhm_us.unwrap_or(0.0))
1256 }
1257
1258 fn synthetic_base_udr() -> TabulatedResolution {
1259 // Asymmetric kernel (sharp rise, +TOF tail), at two reference energies.
1260 let offs = vec![-1.0, -0.5, 0.0, 0.5, 1.0, 1.5, 2.0, 2.5];
1261 let wts = vec![0.05, 0.3, 1.0, 0.8, 0.5, 0.3, 0.15, 0.05];
1262 TabulatedResolution::from_kernels(
1263 vec![5.0, 50.0],
1264 vec![(offs.clone(), wts.clone()), (offs, wts)],
1265 25.0,
1266 )
1267 .unwrap()
1268 }
1269
1270 #[test]
1271 fn inner_chi2_zero_on_exact_anorm() {
1272 let model = vec![0.9, 0.7, 0.5, 0.8];
1273 let data: Vec<f64> = model.iter().map(|m| 1.0 * m).collect();
1274 let unc = vec![0.01; 4];
1275 assert!(inner_chi2(&data, &unc, &model, false, 0) < 1e-18);
1276 }
1277
1278 /// #645 round 4, F3: a `fit_psr` starting width outside the 0.05–1 µs fit
1279 /// box (legal as a PIN up to [`PSR_FWHM_PIN_CEILING_US`]) is clamped to
1280 /// the nearer box edge — documented behavior, not an error.
1281 #[test]
1282 fn fit_psr_out_of_box_start_clamps_to_box_edge() {
1283 let family = ResolutionFamily::IkedaCarpenter { fit_psr: true };
1284 // 5 000 ns = 5 µs: a valid pin width, above the 1 µs fit-box top.
1285 let above = CalibrationConfig {
1286 psr_fwhm_ns: 5_000.0,
1287 ..CalibrationConfig::default()
1288 };
1289 let (x0, bounds) = family.x0_bounds(&above);
1290 assert_eq!(x0.len(), 5);
1291 assert_eq!(x0[4], PSR_FWHM_US_MAX);
1292 assert_eq!(bounds[4], (PSR_FWHM_US_MIN, PSR_FWHM_US_MAX));
1293 // 10 ns: below the 50 ns identifiability floor — clamped UP.
1294 let below = CalibrationConfig {
1295 psr_fwhm_ns: 10.0,
1296 ..CalibrationConfig::default()
1297 };
1298 let (x0, _) = family.x0_bounds(&below);
1299 assert_eq!(x0[4], PSR_FWHM_US_MIN);
1300 // The in-box default (350 ns) passes through unclamped.
1301 let inside = CalibrationConfig::default();
1302 let (x0, _) = family.x0_bounds(&inside);
1303 assert_eq!(x0[4], DEFAULT_PSR_FWHM_NS * NS_TO_US);
1304 }
1305
1306 #[test]
1307 fn udr_corr_recovers_known_width_scale() {
1308 // Loop-closure / OPTIMIZER test: truth and fit both use width_corrected, so
1309 // this checks that the calibrator finds the s0=1.5 minimum — NOT that
1310 // width_corrected itself is physically correct. The width-scale physics
1311 // (centroid invariance + std scaling) is independently verified by
1312 // `width_corrected_preserves_centroid_scales_width_and_energy_dependence`
1313 // in nereids-physics.
1314 // Two well-separated resonances (15 + 45 eV) so the width is identifiable
1315 // (a single resonance leaves a width↔position ridge). Position is pinned by
1316 // default. Calibrant generated with a UDR truth scaled by s0=1.5; udr_corr
1317 // must recover s0≈1.5 at χ²≈0.
1318 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1319 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1320 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1321 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1322 let base = synthetic_base_udr();
1323 let truth = ResolutionFunction::Tabulated(Arc::new(
1324 base.width_corrected(1.5, 0.0, UDR_E_REF).unwrap(),
1325 ));
1326 let data = forward_model(
1327 &energies,
1328 &sample,
1329 Some(&InstrumentParams { resolution: truth }),
1330 )
1331 .unwrap();
1332 let unc = vec![0.004; energies.len()];
1333
1334 let cfg = CalibrationConfig {
1335 restarts: 2,
1336 ..Default::default()
1337 };
1338 let r = calibrate_resolution(
1339 ResolutionFamily::UdrCorr {
1340 base: Arc::new(base),
1341 },
1342 &energies,
1343 &data,
1344 &unc,
1345 &sample,
1346 &cfg,
1347 )
1348 .unwrap();
1349 let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1350 assert!((s0 - 1.5).abs() < 0.05, "recovered s0={s0}, expected 1.5");
1351 assert!(r.chi2_dof < 1e-2, "matched χ²/dof={} too high", r.chi2_dof);
1352 assert!(matches!(r.resolution, ResolutionFunction::Tabulated(_)));
1353 }
1354
1355 #[test]
1356 fn udr_corr_recovers_known_width_scale_and_exponent() {
1357 // Two resonances at well-separated energies make the width EXPONENT p
1358 // identifiable — a single resonance constrains only s(E) at one energy (a
1359 // ridge in (s0, p)). Truth: s0=1.3, p=-0.5; the calibrator must recover
1360 // both (the s0-only test never exercised the p knob).
1361 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1362 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1363 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1364 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1365 let base = synthetic_base_udr();
1366 let (s0_true, p_true) = (1.3, -0.5);
1367 let truth = ResolutionFunction::Tabulated(Arc::new(
1368 base.width_corrected(s0_true, p_true, UDR_E_REF).unwrap(),
1369 ));
1370 let data = forward_model(
1371 &energies,
1372 &sample,
1373 Some(&InstrumentParams { resolution: truth }),
1374 )
1375 .unwrap();
1376 let unc = vec![0.004; energies.len()];
1377 let cfg = CalibrationConfig {
1378 restarts: 3,
1379 ..Default::default()
1380 };
1381 let r = calibrate_resolution(
1382 ResolutionFamily::UdrCorr {
1383 base: Arc::new(base),
1384 },
1385 &energies,
1386 &data,
1387 &unc,
1388 &sample,
1389 &cfg,
1390 )
1391 .unwrap();
1392 let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1393 let p = r.theta[1];
1394 assert!(
1395 (s0 - s0_true).abs() < 0.1,
1396 "recovered s0={s0}, expected {s0_true}"
1397 );
1398 assert!(
1399 (p - p_true).abs() < 0.2,
1400 "recovered p={p}, expected {p_true}"
1401 );
1402 assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1403 }
1404
1405 #[test]
1406 fn udr_corr_recovers_independent_raw_kernel() {
1407 // External-oracle coverage: the truth resolution is the RAW hand-built UDR
1408 // kernel broadened directly — it does NOT pass through `width_corrected`,
1409 // so truth-generation no longer shares the width-correction code with the
1410 // fit. Fitting udr_corr against that base must recover the identity width
1411 // (s0≈1) at χ²≈0. (The broadening OPERATOR itself is independently
1412 // validated by this crate's bit-exact `broaden_presorted_reference` tests.)
1413 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1414 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1415 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1416 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1417 let base = synthetic_base_udr();
1418 // Truth = the RAW base kernel (no width_corrected call).
1419 let truth = ResolutionFunction::Tabulated(Arc::new(base.clone()));
1420 let data = forward_model(
1421 &energies,
1422 &sample,
1423 Some(&InstrumentParams { resolution: truth }),
1424 )
1425 .unwrap();
1426 let unc = vec![0.004; energies.len()];
1427 let cfg = CalibrationConfig {
1428 restarts: 3,
1429 ..Default::default()
1430 };
1431 let r = calibrate_resolution(
1432 ResolutionFamily::UdrCorr {
1433 base: Arc::new(base),
1434 },
1435 &energies,
1436 &data,
1437 &unc,
1438 &sample,
1439 &cfg,
1440 )
1441 .unwrap();
1442 let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1443 assert!((s0 - 1.0).abs() < 0.1, "recovered s0={s0}, expected ~1.0");
1444 assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1445 }
1446
1447 #[test]
1448 fn gaussian_recovers_known_width() {
1449 // Gaussian loop-closure: a Gaussian truth must be recovered by the gaussian
1450 // family (the smoke test only checked finiteness+convergence). Two
1451 // resonances break the Δt/ΔL degeneracy (Δt is flat in TOF; ΔL scales with
1452 // TOF ∝ 1/√E).
1453 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1454 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1455 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1456 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1457 let (dt_true, dl_true) = (1.5, 1.0e-3);
1458 let truth = ResolutionFunction::Gaussian(
1459 ResolutionParams::new(25.0, dt_true, dl_true, 0.0).unwrap(),
1460 );
1461 let data = forward_model(
1462 &energies,
1463 &sample,
1464 Some(&InstrumentParams { resolution: truth }),
1465 )
1466 .unwrap();
1467 let unc = vec![0.004; energies.len()];
1468 let cfg = CalibrationConfig {
1469 restarts: 3,
1470 ..Default::default()
1471 };
1472 let r = calibrate_resolution(
1473 ResolutionFamily::Gaussian,
1474 &energies,
1475 &data,
1476 &unc,
1477 &sample,
1478 &cfg,
1479 )
1480 .unwrap();
1481 let (dt, dl) = (r.theta[0].abs(), r.theta[1].abs());
1482 assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1483 assert!(
1484 (dt - dt_true).abs() < 0.2,
1485 "recovered Δt={dt}, expected {dt_true}"
1486 );
1487 assert!(
1488 (dl - dl_true).abs() < 1.0e-3,
1489 "recovered ΔL={dl}, expected {dl_true}"
1490 );
1491 }
1492
1493 #[test]
1494 fn fit_t0_recovers_injected_energy_scale_shift() {
1495 // With fit_t0 enabled, an injected TOF-zero offset in the calibrant is
1496 // recovered as the SHARED energy-scale t0 while the width is still
1497 // recovered — position is a fitted energy-scale parameter (−t0 convention),
1498 // not folded into the resolution. (Default config pins position; this test
1499 // opts in.) L_scale stays pinned at 1.
1500 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1501 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1502 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1503 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1504 let base = synthetic_base_udr();
1505 let (s0_true, t0_inject) = (1.4, 1.5_f64); // µs (energy-scale −t0 convention)
1506 let truth = ResolutionFunction::Tabulated(Arc::new(
1507 base.width_corrected(s0_true, 0.0, UDR_E_REF).unwrap(),
1508 ));
1509 // Calibrant generated on a grid displaced by the energy-scale t0.
1510 let shifted = corrected_energy_grid(&energies, t0_inject, 1.0, 25.0).unwrap();
1511 let data = forward_model(
1512 &shifted,
1513 &sample,
1514 Some(&InstrumentParams { resolution: truth }),
1515 )
1516 .unwrap();
1517 let unc = vec![0.004; energies.len()];
1518 // Opt into fitting t0 (flat prior); L_scale stays pinned at 1.
1519 let cfg = CalibrationConfig {
1520 restarts: 3,
1521 fit_t0: true,
1522 ..Default::default()
1523 };
1524 let r = calibrate_resolution(
1525 ResolutionFamily::UdrCorr {
1526 base: Arc::new(base),
1527 },
1528 &energies,
1529 &data,
1530 &unc,
1531 &sample,
1532 &cfg,
1533 )
1534 .unwrap();
1535 let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1536 assert!(
1537 (s0 - s0_true).abs() < 0.1,
1538 "recovered s0={s0}, expected {s0_true}"
1539 );
1540 assert!(
1541 (r.position_t0_us - t0_inject).abs() < 0.3,
1542 "recovered t0={}, expected {t0_inject}",
1543 r.position_t0_us
1544 );
1545 assert!(
1546 (r.position_l_scale - 1.0).abs() < 1e-9,
1547 "L_scale should stay pinned at 1, got {}",
1548 r.position_l_scale
1549 );
1550 assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1551 }
1552
1553 #[test]
1554 fn pinned_position_is_the_default_and_works_for_udr() {
1555 // The default config pins position (fit_t0/fit_l_scale = false) — a pure
1556 // shape/width fit. This is the no-position reference that the retired design
1557 // could NOT construct for the UDR family in Python (the width-correction was
1558 // Rust-internal). Self-fit must recover s0≈1 with position reported at its
1559 // pinned center and zero prior penalty.
1560 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1561 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1562 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1563 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1564 let base = synthetic_base_udr();
1565 let truth = ResolutionFunction::Tabulated(Arc::new(base.clone()));
1566 let data = forward_model(
1567 &energies,
1568 &sample,
1569 Some(&InstrumentParams { resolution: truth }),
1570 )
1571 .unwrap();
1572 let unc = vec![0.004; energies.len()];
1573 let cfg = CalibrationConfig::default();
1574 assert!(!cfg.fit_t0 && !cfg.fit_l_scale, "default must pin position");
1575 let r = calibrate_resolution(
1576 ResolutionFamily::UdrCorr {
1577 base: Arc::new(base),
1578 },
1579 &energies,
1580 &data,
1581 &unc,
1582 &sample,
1583 &cfg,
1584 )
1585 .unwrap();
1586 let s0 = r.theta[0].exp().clamp(UDR_S0_MIN, UDR_S0_MAX);
1587 assert!((s0 - 1.0).abs() < 0.1, "recovered s0={s0}, expected ~1.0");
1588 assert_eq!(r.position_t0_us, 0.0, "t0 pinned at center 0");
1589 assert_eq!(r.position_l_scale, 1.0, "L_scale pinned at center 1");
1590 assert_eq!(r.prior_penalty, 0.0, "no prior active when pinned");
1591 assert!(r.chi2_dof < 1e-2, "χ²/dof={} too high", r.chi2_dof);
1592 }
1593
1594 #[test]
1595 fn free_l_scale_absorbs_asymmetric_lag_and_erodes_discrimination() {
1596 // The asymmetric IC mode→centroid lag is pure 1/√E — the SAME basis as an
1597 // L_scale error. So a Gaussian fitting an IC-broadened calibrant fits much
1598 // BETTER when L_scale is free than when position is pinned: a free physical
1599 // position lets the wrong (symmetric) family buy back the position evidence.
1600 // This is exactly why fitting position with a flat prior is unsafe for
1601 // family discrimination (and why the default pins it).
1602 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1603 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1604 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1605 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1606 let ic = IkedaCarpenter::new(
1607 IkedaCarpenterParams {
1608 alpha: EnergyLaw::SqrtE { a0: 0.30, a1: 0.0 },
1609 beta: 0.1,
1610 r: EnergyLaw::ExpMilliEv { kappa: 25.0 },
1611 burst_sigma_us: None,
1612 channel_fwhm_us: None,
1613 },
1614 25.0,
1615 &SynthesisGrid {
1616 e_min_ev: 4.0,
1617 e_max_ev: 100.0,
1618 n_energies: 64,
1619 n_tau: 500,
1620 },
1621 )
1622 .unwrap();
1623 let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
1624 let data = forward_model(
1625 &energies,
1626 &sample,
1627 Some(&InstrumentParams { resolution: truth }),
1628 )
1629 .unwrap();
1630 let unc = vec![0.004; energies.len()];
1631 let pinned = CalibrationConfig {
1632 restarts: 3,
1633 ..Default::default()
1634 };
1635 // Free physical position (flat priors): fit_t0 + fit_l_scale, sigmas None.
1636 let free_pos = CalibrationConfig {
1637 restarts: 3,
1638 fit_t0: true,
1639 fit_l_scale: true,
1640 ..Default::default()
1641 };
1642 let gau = |cfg: &CalibrationConfig| {
1643 calibrate_resolution(
1644 ResolutionFamily::Gaussian,
1645 &energies,
1646 &data,
1647 &unc,
1648 &sample,
1649 cfg,
1650 )
1651 .unwrap()
1652 .chi2_dof
1653 };
1654 let gau_pinned = gau(&pinned);
1655 let gau_free = gau(&free_pos);
1656 assert!(
1657 gau_free < 0.5 * gau_pinned,
1658 "free (t0,L_scale) should sharply erode the wrong-family penalty: \
1659 pinned χ²={gau_pinned}, free χ²={gau_free}"
1660 );
1661 }
1662
1663 #[test]
1664 fn position_prior_penalizes_displacement() {
1665 // A tight prior on t0 (center 0) penalizes a calibrant whose true t0 is
1666 // displaced: the fit cannot freely move to the displacement, so it pays a
1667 // prior penalty and leaves residual data χ². A loose prior recovers the
1668 // displacement with ~zero penalty. (Demonstrates the prior is the real
1669 // constraint on position, per the metrology-prior design.)
1670 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1671 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1672 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1673 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1674 let base = synthetic_base_udr();
1675 let t0_inject = 1.5_f64;
1676 let truth = ResolutionFunction::Tabulated(Arc::new(
1677 base.width_corrected(1.0, 0.0, UDR_E_REF).unwrap(),
1678 ));
1679 let shifted = corrected_energy_grid(&energies, t0_inject, 1.0, 25.0).unwrap();
1680 let data = forward_model(
1681 &shifted,
1682 &sample,
1683 Some(&InstrumentParams { resolution: truth }),
1684 )
1685 .unwrap();
1686 let unc = vec![0.004; energies.len()];
1687 let mk = |sigma_t0: f64| CalibrationConfig {
1688 restarts: 3,
1689 fit_t0: true,
1690 position_t0_prior_us: Some(sigma_t0),
1691 ..Default::default()
1692 };
1693 let mkbase = || ResolutionFamily::UdrCorr {
1694 base: Arc::new(base.clone()),
1695 };
1696 // Tight prior: σ must be small enough that the quadratic prior
1697 // curvature rivals the data-χ² curvature in t0, or the optimum
1698 // sits at the displacement and the pull is invisible. The
1699 // width-correct kernel interpolation sharpened the data term
1700 // (narrower between-reference kernels carry more positional
1701 // information than the over-wide chord blend used to), so the
1702 // binding regime needs a tighter σ than it once did.
1703 let tight =
1704 calibrate_resolution(mkbase(), &energies, &data, &unc, &sample, &mk(0.005)).unwrap();
1705 // Loose prior (σ=100 µs): recovers the displacement, ~no penalty.
1706 let loose =
1707 calibrate_resolution(mkbase(), &energies, &data, &unc, &sample, &mk(100.0)).unwrap();
1708 assert!(
1709 tight.prior_penalty > 1.0,
1710 "tight prior should incur a real penalty, got {}",
1711 tight.prior_penalty
1712 );
1713 assert!(
1714 tight.position_t0_us.abs() < t0_inject,
1715 "tight prior should pull t0 toward the center, got {}",
1716 tight.position_t0_us
1717 );
1718 assert!(
1719 (loose.position_t0_us - t0_inject).abs() < 0.3,
1720 "loose prior should recover the displacement, got {}",
1721 loose.position_t0_us
1722 );
1723 assert!(
1724 loose.prior_penalty < tight.prior_penalty,
1725 "loose penalty {} should be below tight penalty {}",
1726 loose.prior_penalty,
1727 tight.prior_penalty
1728 );
1729 assert!(
1730 loose.chi2_dof < tight.chi2_dof,
1731 "loose data χ² {} should beat tight data χ² {} (tight can't reach t0)",
1732 loose.chi2_dof,
1733 tight.chi2_dof
1734 );
1735 }
1736
1737 #[test]
1738 fn with_position_prior_builder_sets_fields() {
1739 let cfg = CalibrationConfig::default().with_position_prior(0.5, 1.001, 0.3, 0.002);
1740 assert!(cfg.fit_t0 && cfg.fit_l_scale);
1741 assert_eq!(cfg.position_t0_center_us, 0.5);
1742 assert_eq!(cfg.position_l_scale_center, 1.001);
1743 assert_eq!(cfg.position_t0_prior_us, Some(0.3));
1744 assert_eq!(cfg.position_l_scale_prior, Some(0.002));
1745 }
1746
1747 #[test]
1748 fn fit_l_scale_only_pins_t0() {
1749 // Per-coordinate control: fitting ONLY L_scale (fit_t0=false) must fit
1750 // position_l_scale while pinning t0 at its center — locks the
1751 // `unpack_position` indexing when only the SECOND position coordinate is
1752 // active (the single-coordinate path the round-2 review flagged).
1753 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1754 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1755 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1756 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1757 let base = synthetic_base_udr();
1758 let truth = ResolutionFunction::Tabulated(Arc::new(base.clone()));
1759 let data = forward_model(
1760 &energies,
1761 &sample,
1762 Some(&InstrumentParams { resolution: truth }),
1763 )
1764 .unwrap();
1765 let unc = vec![0.004; energies.len()];
1766 let cfg = CalibrationConfig {
1767 restarts: 2,
1768 fit_l_scale: true,
1769 ..Default::default()
1770 };
1771 let r = calibrate_resolution(
1772 ResolutionFamily::UdrCorr {
1773 base: Arc::new(base),
1774 },
1775 &energies,
1776 &data,
1777 &unc,
1778 &sample,
1779 &cfg,
1780 )
1781 .unwrap();
1782 assert_eq!(
1783 r.position_t0_us, 0.0,
1784 "t0 must stay pinned when fit_t0=false"
1785 );
1786 assert!(
1787 (r.position_l_scale - 1.0).abs() < 0.02,
1788 "L_scale fit within bound (~1 for a self-fit), got {}",
1789 r.position_l_scale
1790 );
1791 assert!(r.chi2_dof < 1e-1, "self-fit χ²/dof={} too high", r.chi2_dof);
1792 }
1793
1794 #[test]
1795 fn cross_family_chi2_selects_the_true_shape() {
1796 // Model-family discrimination at a KNOWN (pinned) energy scale: an
1797 // asymmetric IC-broadened calibrant generated at the nominal position
1798 // (t0=0, L_scale=1) must be best-fit by the IC family and clearly worse by
1799 // the symmetric Gaussian. With position pinned (the default), the Gaussian
1800 // is penalized for both shape AND the asymmetry-induced dip shift it cannot
1801 // reproduce — legitimate here because the truth's position is known exactly.
1802 // (When position is uncertain, that shift is confounded with flight-path L —
1803 // see `free_l_scale_absorbs_asymmetric_lag_and_erodes_discrimination`.)
1804 // Truth has NO width-correction/Gaussian generator, so the Gaussian arm is a
1805 // genuinely different shape (not loop-closure).
1806 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
1807 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
1808 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
1809 let energies: Vec<f64> = (0..700).map(|i| 8.0 + i as f64 * 0.06).collect();
1810 let ic = IkedaCarpenter::new(
1811 IkedaCarpenterParams {
1812 alpha: EnergyLaw::SqrtE { a0: 0.30, a1: 0.0 },
1813 beta: 0.1,
1814 r: EnergyLaw::ExpMilliEv { kappa: 25.0 },
1815 burst_sigma_us: None,
1816 channel_fwhm_us: None,
1817 },
1818 25.0,
1819 &SynthesisGrid {
1820 e_min_ev: 4.0,
1821 e_max_ev: 100.0,
1822 n_energies: 64,
1823 n_tau: 500,
1824 },
1825 )
1826 .unwrap();
1827 let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic));
1828 let data = forward_model(
1829 &energies,
1830 &sample,
1831 Some(&InstrumentParams { resolution: truth }),
1832 )
1833 .unwrap();
1834 let unc = vec![0.004; energies.len()];
1835 // The truth kernel carries NO PSR fold, so disable the calibrator's
1836 // default 350 ns fold — otherwise the IC family could not close.
1837 let cfg = CalibrationConfig {
1838 restarts: 3,
1839 psr_fwhm_ns: 0.0,
1840 ..Default::default()
1841 };
1842 let chi2 = |fam| {
1843 calibrate_resolution(fam, &energies, &data, &unc, &sample, &cfg)
1844 .unwrap()
1845 .chi2_dof
1846 };
1847 let ic_chi2 = chi2(ResolutionFamily::IkedaCarpenter { fit_psr: false });
1848 let gau_chi2 = chi2(ResolutionFamily::Gaussian);
1849 assert!(
1850 ic_chi2 < gau_chi2,
1851 "true (IC) shape χ²={ic_chi2} should beat the Gaussian χ²={gau_chi2}"
1852 );
1853 assert!(
1854 ic_chi2 < 1.0,
1855 "IC (true shape) should fit well: χ²={ic_chi2}"
1856 );
1857 }
1858
1859 #[test]
1860 fn gaussian_and_ic_families_run_and_converge() {
1861 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
1862 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
1863 let energies: Vec<f64> = (0..300).map(|i| 12.0 + i as f64 * 0.05).collect();
1864 let base = synthetic_base_udr();
1865 let truth = ResolutionFunction::Tabulated(Arc::new(
1866 base.width_corrected(1.2, 0.0, UDR_E_REF).unwrap(),
1867 ));
1868 let data = forward_model(
1869 &energies,
1870 &sample,
1871 Some(&InstrumentParams { resolution: truth }),
1872 )
1873 .unwrap();
1874 let unc = vec![0.004; energies.len()];
1875 let cfg = CalibrationConfig::default();
1876 for (fam, n_expected) in [
1877 (ResolutionFamily::Gaussian, 2),
1878 (ResolutionFamily::IkedaCarpenter { fit_psr: false }, 4),
1879 ] {
1880 let label = fam.label().to_string();
1881 let r = calibrate_resolution(fam, &energies, &data, &unc, &sample, &cfg).unwrap();
1882 assert!(r.chi2_dof.is_finite(), "{label} χ² not finite");
1883 assert_eq!(
1884 r.theta.len(),
1885 n_expected,
1886 "{label} should fit {n_expected} params"
1887 );
1888 assert_eq!(r.n_free_params, n_expected, "{label} n_free_params");
1889 // The objective is smooth and noise-free, so Nelder–Mead reaches its
1890 // tolerance well within max_iter — guard the "_and_converge" promise.
1891 assert!(r.converged, "{label} did not self-converge");
1892 }
1893 }
1894
1895 #[test]
1896 fn n_params_matches_family() {
1897 assert_eq!(ResolutionFamily::Gaussian.n_params(), 2);
1898 assert_eq!(
1899 ResolutionFamily::UdrCorr {
1900 base: Arc::new(synthetic_base_udr())
1901 }
1902 .n_params(),
1903 2
1904 );
1905 // IC fits θ = [ln a0, ln a1, ln β, R] (+ PSR FWHM iff fit_psr).
1906 assert_eq!(
1907 ResolutionFamily::IkedaCarpenter { fit_psr: false }.n_params(),
1908 4
1909 );
1910 assert_eq!(
1911 ResolutionFamily::IkedaCarpenter { fit_psr: true }.n_params(),
1912 5
1913 );
1914 // param_names track n_params, coordinate for coordinate.
1915 for fam in [
1916 ResolutionFamily::Gaussian,
1917 ResolutionFamily::IkedaCarpenter { fit_psr: false },
1918 ResolutionFamily::IkedaCarpenter { fit_psr: true },
1919 ] {
1920 assert_eq!(fam.param_names().len(), fam.n_params());
1921 }
1922 assert_eq!(
1923 ResolutionFamily::IkedaCarpenter { fit_psr: true }
1924 .param_names()
1925 .last()
1926 .copied(),
1927 Some("psr_fwhm_us")
1928 );
1929 }
1930
1931 #[test]
1932 fn rejects_empty_mismatched_and_non_finite_inputs() {
1933 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
1934 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
1935 let cfg = CalibrationConfig::default();
1936 assert!(matches!(
1937 calibrate_resolution(ResolutionFamily::Gaussian, &[], &[], &[], &sample, &cfg),
1938 Err(FittingError::EmptyData)
1939 ));
1940 let e = vec![1.0, 2.0, 3.0];
1941 let d = vec![0.5, 0.5];
1942 let u = vec![0.1, 0.1];
1943 assert!(matches!(
1944 calibrate_resolution(ResolutionFamily::Gaussian, &e, &d, &u, &sample, &cfg),
1945 Err(FittingError::LengthMismatch { .. })
1946 ));
1947 // Non-finite data and non-positive uncertainty are rejected up front.
1948 let e = vec![1.0, 2.0, 3.0];
1949 assert!(matches!(
1950 calibrate_resolution(
1951 ResolutionFamily::Gaussian,
1952 &e,
1953 &[0.5, f64::NAN, 0.7],
1954 &[0.1; 3],
1955 &sample,
1956 &cfg
1957 ),
1958 Err(FittingError::InvalidConfig(_))
1959 ));
1960 assert!(matches!(
1961 calibrate_resolution(
1962 ResolutionFamily::Gaussian,
1963 &e,
1964 &[0.5; 3],
1965 &[0.1, 0.0, 0.1],
1966 &sample,
1967 &cfg
1968 ),
1969 Err(FittingError::InvalidConfig(_))
1970 ));
1971 }
1972
1973 #[test]
1974 fn rejects_nonascending_and_nonpositive_energy_grid() {
1975 // Sibling-path parity with the Python `validate_energy_grid`: descending,
1976 // duplicate, zero, and negative energy grids must be rejected up front
1977 // rather than panicking deep in the cross-section assert or erroring late.
1978 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
1979 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
1980 let cfg = CalibrationConfig::default();
1981 for grid in [
1982 vec![4.0, 3.0, 2.0, 1.0], // descending
1983 vec![1.0, 2.0, 2.0, 3.0], // duplicate
1984 vec![0.0, 1.0, 2.0, 3.0], // zero
1985 vec![-1.0, 1.0, 2.0, 3.0], // negative
1986 ] {
1987 let n = grid.len();
1988 assert!(
1989 matches!(
1990 calibrate_resolution(
1991 ResolutionFamily::Gaussian,
1992 &grid,
1993 &vec![0.5; n],
1994 &vec![0.1; n],
1995 &sample,
1996 &cfg
1997 ),
1998 Err(FittingError::InvalidConfig(_))
1999 ),
2000 "expected InvalidConfig for grid {grid:?}"
2001 );
2002 }
2003 }
2004
2005 #[test]
2006 fn rejects_degenerate_calibrant_composition() {
2007 // A calibrant with no isotopes, or only zero/negative densities, yields a
2008 // flat (resolution-independent) forward model; the optimizer would return
2009 // a finite but meaningless result. Reject up front (Python-sibling parity).
2010 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2011 let energies: Vec<f64> = (0..64).map(|i| 5.0 + i as f64 * 0.4).collect();
2012 let data = vec![0.8; energies.len()];
2013 let unc = vec![0.01; energies.len()];
2014 let cfg = CalibrationConfig::default();
2015 for bad_sample in [
2016 SampleParams::new(300.0, vec![]).unwrap(),
2017 SampleParams::new(300.0, vec![(iso.clone(), 0.0)]).unwrap(),
2018 SampleParams::new(300.0, vec![(iso, -1.0e-3)]).unwrap(),
2019 ] {
2020 assert!(
2021 matches!(
2022 calibrate_resolution(
2023 ResolutionFamily::Gaussian,
2024 &energies,
2025 &data,
2026 &unc,
2027 &bad_sample,
2028 &cfg
2029 ),
2030 Err(FittingError::InvalidConfig(_))
2031 ),
2032 "degenerate calibrant composition should be rejected"
2033 );
2034 }
2035 }
2036
2037 #[test]
2038 fn invalid_flight_path_propagates_build_error() {
2039 // flight_path <= 0 makes ResolutionParams::new fail on every eval, so the
2040 // calibration cannot build a resolution and returns an error.
2041 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2042 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2043 let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2044 let d = vec![0.9; 60];
2045 let u = vec![0.01; 60];
2046 let cfg = CalibrationConfig {
2047 flight_path_m: -1.0,
2048 ..Default::default()
2049 };
2050 assert!(
2051 calibrate_resolution(ResolutionFamily::Gaussian, &e, &d, &u, &sample, &cfg).is_err()
2052 );
2053 }
2054
2055 #[test]
2056 fn inner_chi2_background_path_and_degenerate_model() {
2057 // 3-column baseline fit (anorm + const + linear) recovers an offset exactly.
2058 let model = vec![0.9, 0.7, 0.5, 0.8, 0.6];
2059 let data: Vec<f64> = model.iter().map(|m| 0.5 * m + 0.1).collect();
2060 let unc = vec![0.01; 5];
2061 assert!(inner_chi2(&data, &unc, &model, true, 0) < 1e-12);
2062 // all-zero model -> singular normal equations -> infeasible (χ²=∞), so the
2063 // optimizer steps away rather than seeing a spuriously inflated finite χ².
2064 let v = inner_chi2(&data, &unc, &[0.0; 5], false, 0);
2065 assert_eq!(v, f64::INFINITY);
2066 }
2067
2068 #[test]
2069 fn calibrate_with_background_runs() {
2070 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2071 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2072 let energies: Vec<f64> = (0..200).map(|i| 14.0 + i as f64 * 0.06).collect();
2073 let base = synthetic_base_udr();
2074 let truth = ResolutionFunction::Tabulated(Arc::new(
2075 base.width_corrected(1.3, 0.0, UDR_E_REF).unwrap(),
2076 ));
2077 let data = forward_model(
2078 &energies,
2079 &sample,
2080 Some(&InstrumentParams { resolution: truth }),
2081 )
2082 .unwrap();
2083 let unc = vec![0.004; energies.len()];
2084 let cfg = CalibrationConfig {
2085 fit_background: true,
2086 ..Default::default()
2087 };
2088 let r = calibrate_resolution(
2089 ResolutionFamily::UdrCorr {
2090 base: Arc::new(base),
2091 },
2092 &energies,
2093 &data,
2094 &unc,
2095 &sample,
2096 &cfg,
2097 )
2098 .unwrap();
2099 assert!(r.chi2_dof.is_finite());
2100 }
2101
2102 #[test]
2103 fn ic_recovers_known_alpha() {
2104 // Loop-closure / optimizer test (same caveat as udr_corr): truth and fit
2105 // both use the IC synthesis, so this checks the optimizer recovers the
2106 // full bounded 4-parameter family (#642) — the IC pulse physics is
2107 // independently covered by the ic_pulse tests in nereids-physics.
2108 // Truth, all interior to the new boxes: a0=0.35, a1=0.05, β=0.1, R=0.1,
2109 // PSR triangle 0.35 µs (= the calibrator's default 350 ns pin).
2110 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2111 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2112 let energies: Vec<f64> = (0..400).map(|i| 12.0 + i as f64 * 0.04).collect();
2113 let cfg = CalibrationConfig {
2114 restarts: 2,
2115 ..Default::default()
2116 };
2117 // Truth kernel on the SAME derived grid the calibrator synthesizes on,
2118 // so the loop closes exactly (a grid mismatch would leak into recovery).
2119 let ic_truth = IkedaCarpenter::new(
2120 IkedaCarpenterParams {
2121 alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2122 beta: 0.1,
2123 r: EnergyLaw::Const(0.1),
2124 burst_sigma_us: None,
2125 channel_fwhm_us: Some(0.35),
2126 },
2127 cfg.flight_path_m,
2128 &SynthesisGrid {
2129 e_min_ev: (energies[0] * 0.5).max(1e-3),
2130 e_max_ev: energies.last().unwrap() * 2.0,
2131 n_energies: cfg.ic_n_energies,
2132 n_tau: cfg.ic_n_tau,
2133 },
2134 )
2135 .unwrap();
2136 let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2137 let data = forward_model(
2138 &energies,
2139 &sample,
2140 Some(&InstrumentParams { resolution: truth }),
2141 )
2142 .unwrap();
2143 let unc = vec![0.004; energies.len()];
2144 let r = calibrate_resolution(
2145 ResolutionFamily::IkedaCarpenter { fit_psr: false },
2146 &energies,
2147 &data,
2148 &unc,
2149 &sample,
2150 &cfg,
2151 )
2152 .unwrap();
2153 let (a0, a1, beta, rr, psr) = decoded_ic(&r);
2154 assert!((a0 - 0.35).abs() < 0.05, "recovered a0={a0}, expected 0.35");
2155 assert!(a1 > 0.0, "a1 positive by construction, got {a1}");
2156 // β and R shape the kernel only jointly through the storage tail
2157 // (weight R, decay 1/β), so their windows are deliberately loose.
2158 assert!(
2159 (beta - 0.1).abs() < 0.08,
2160 "recovered β={beta}, expected 0.1"
2161 );
2162 assert!((rr - 0.1).abs() < 0.08, "recovered R={rr}, expected 0.1");
2163 assert!(
2164 (psr - 0.35).abs() < 1e-12,
2165 "PSR pin {psr} µs != 0.35 µs (config default)"
2166 );
2167 assert_eq!(r.n_free_params, 4);
2168 assert!(
2169 r.bounds_hit.is_empty(),
2170 "interior truth must not pin bounds, got {:?}",
2171 r.bounds_hit
2172 );
2173 assert!(r.chi2_dof < 1.0, "matched χ²/dof={} too high", r.chi2_dof);
2174 }
2175
2176 #[test]
2177 fn ic_recovers_known_psr_when_fit() {
2178 // Loop-closure / optimizer test for fit_psr (#645 F2, same caveat as
2179 // ic_recovers_known_alpha: truth and fit share the IC synthesis, so
2180 // this checks the 5-parameter optimizer, not the pulse physics).
2181 // Truth PSR FWHM = 0.6 µs — interior to the [0.05, 1] µs box and far
2182 // from the 0.35 µs default start — with the rest of the truth kernel
2183 // identical to ic_recovers_known_alpha. Two resonances (15 + 45 eV)
2184 // give the E-leverage that separates the E-independent triangle
2185 // width from the α(E) = a0·√E + a1 prompt law (a single resonance
2186 // probes the kernel at essentially one energy).
2187 // Full-density run (420 pts, 64×500 grid, restarts 2) recovers
2188 // psr = 0.5984 µs at χ²/dof ≈ 1e-6; this slimmed grid keeps the same
2189 // loop-closure semantics at a debug-friendly runtime.
2190 let iso_lo = synthetic_isotope(72, 178, 15.0, 0.05, 0.06);
2191 let iso_hi = synthetic_isotope(72, 179, 45.0, 0.05, 0.06);
2192 let sample = SampleParams::new(300.0, vec![(iso_lo, 2.0e-3), (iso_hi, 2.0e-3)]).unwrap();
2193 let energies: Vec<f64> = (0..280).map(|i| 8.0 + i as f64 * 0.15).collect();
2194 let cfg = CalibrationConfig {
2195 restarts: 1,
2196 ic_n_energies: 32,
2197 ic_n_tau: 320,
2198 ..Default::default()
2199 };
2200 let psr_true = 0.6;
2201 let ic_truth = IkedaCarpenter::new(
2202 IkedaCarpenterParams {
2203 alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2204 beta: 0.1,
2205 r: EnergyLaw::Const(0.1),
2206 burst_sigma_us: None,
2207 channel_fwhm_us: Some(psr_true),
2208 },
2209 cfg.flight_path_m,
2210 &SynthesisGrid {
2211 e_min_ev: (energies[0] * 0.5).max(1e-3),
2212 e_max_ev: energies.last().unwrap() * 2.0,
2213 n_energies: cfg.ic_n_energies,
2214 n_tau: cfg.ic_n_tau,
2215 },
2216 )
2217 .unwrap();
2218 let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2219 let data = forward_model(
2220 &energies,
2221 &sample,
2222 Some(&InstrumentParams { resolution: truth }),
2223 )
2224 .unwrap();
2225 let unc = vec![0.004; energies.len()];
2226 let r = calibrate_resolution(
2227 ResolutionFamily::IkedaCarpenter { fit_psr: true },
2228 &energies,
2229 &data,
2230 &unc,
2231 &sample,
2232 &cfg,
2233 )
2234 .unwrap();
2235 let (a0, _a1, _beta, _rr, psr) = decoded_ic(&r);
2236 assert_eq!(r.n_free_params, 5);
2237 assert!(
2238 (psr - psr_true).abs() < 0.1,
2239 "recovered PSR FWHM {psr} µs, expected {psr_true} µs"
2240 );
2241 assert!((a0 - 0.35).abs() < 0.05, "recovered a0={a0}, expected 0.35");
2242 assert!(r.chi2_dof < 1.0, "matched χ²/dof={} too high", r.chi2_dof);
2243 }
2244
2245 #[test]
2246 fn psr_disabled_at_zero_width() {
2247 // psr_fwhm_ns = 0.0 disables the triangle fold entirely: an UNFOLDED
2248 // truth is reproduced and the calibrated kernel carries no channel.
2249 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2250 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2251 let energies: Vec<f64> = (0..250).map(|i| 14.0 + i as f64 * 0.05).collect();
2252 let cfg = CalibrationConfig {
2253 psr_fwhm_ns: 0.0,
2254 ic_n_energies: 32,
2255 ic_n_tau: 300,
2256 ..Default::default()
2257 };
2258 let ic_truth = IkedaCarpenter::new(
2259 IkedaCarpenterParams {
2260 alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2261 beta: 0.1,
2262 r: EnergyLaw::Const(0.1),
2263 burst_sigma_us: None,
2264 channel_fwhm_us: None, // unfolded truth
2265 },
2266 cfg.flight_path_m,
2267 &SynthesisGrid {
2268 e_min_ev: (energies[0] * 0.5).max(1e-3),
2269 e_max_ev: energies.last().unwrap() * 2.0,
2270 n_energies: cfg.ic_n_energies,
2271 n_tau: cfg.ic_n_tau,
2272 },
2273 )
2274 .unwrap();
2275 let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2276 let data = forward_model(
2277 &energies,
2278 &sample,
2279 Some(&InstrumentParams { resolution: truth }),
2280 )
2281 .unwrap();
2282 let unc = vec![0.004; energies.len()];
2283 let r = calibrate_resolution(
2284 ResolutionFamily::IkedaCarpenter { fit_psr: false },
2285 &energies,
2286 &data,
2287 &unc,
2288 &sample,
2289 &cfg,
2290 )
2291 .unwrap();
2292 let ResolutionFunction::IkedaCarpenter(ic) = &r.resolution else {
2293 panic!("expected an IC resolution");
2294 };
2295 assert!(
2296 ic.params().channel_fwhm_us.is_none(),
2297 "psr_fwhm_ns = 0 must leave channel_fwhm_us = None, got {:?}",
2298 ic.params().channel_fwhm_us
2299 );
2300 assert!(
2301 r.chi2_dof < 1.0,
2302 "unfolded self-fit χ²/dof={} too high",
2303 r.chi2_dof
2304 );
2305 }
2306
2307 #[test]
2308 fn bounds_hit_reports_pinned_parameter() {
2309 // A truth WITHOUT a storage tail (R = 0) drives the fitted R onto its
2310 // lower box bound; the result must say so ("r:lower") — the β↔R-ridge
2311 // degeneracy flag (with no tail, β is unconstrained).
2312 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2313 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2314 let energies: Vec<f64> = (0..250).map(|i| 14.0 + i as f64 * 0.05).collect();
2315 let cfg = CalibrationConfig {
2316 ic_n_energies: 32,
2317 ic_n_tau: 300,
2318 restarts: 2,
2319 ..Default::default()
2320 };
2321 let ic_truth = IkedaCarpenter::new(
2322 IkedaCarpenterParams {
2323 alpha: EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 },
2324 beta: 0.1, // irrelevant at R = 0 (no storage term)
2325 r: EnergyLaw::Const(0.0),
2326 burst_sigma_us: None,
2327 channel_fwhm_us: Some(0.35),
2328 },
2329 cfg.flight_path_m,
2330 &SynthesisGrid {
2331 e_min_ev: (energies[0] * 0.5).max(1e-3),
2332 e_max_ev: energies.last().unwrap() * 2.0,
2333 n_energies: cfg.ic_n_energies,
2334 n_tau: cfg.ic_n_tau,
2335 },
2336 )
2337 .unwrap();
2338 let truth = ResolutionFunction::IkedaCarpenter(Arc::new(ic_truth));
2339 let data = forward_model(
2340 &energies,
2341 &sample,
2342 Some(&InstrumentParams { resolution: truth }),
2343 )
2344 .unwrap();
2345 let unc = vec![0.004; energies.len()];
2346 let r = calibrate_resolution(
2347 ResolutionFamily::IkedaCarpenter { fit_psr: false },
2348 &energies,
2349 &data,
2350 &unc,
2351 &sample,
2352 &cfg,
2353 )
2354 .unwrap();
2355 assert!(
2356 r.bounds_hit.iter().any(|s| s == "r:lower"),
2357 "R = 0 truth must pin the storage fraction: bounds_hit = {:?}, decoded = {:?}",
2358 r.bounds_hit,
2359 decoded_ic(&r)
2360 );
2361 }
2362
2363 #[test]
2364 fn rejects_invalid_psr_fwhm_ns() {
2365 // NaN / negative / infinite PSR widths are config errors caught up
2366 // front (NaN would silently disable the `> 0.0` fold gate; a negative
2367 // width would fail deep in IkedaCarpenter::new on every evaluation).
2368 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2369 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2370 let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2371 let d = vec![0.9; 60];
2372 let u = vec![0.01; 60];
2373 for bad in [f64::NAN, -1.0, f64::INFINITY] {
2374 let cfg = CalibrationConfig {
2375 psr_fwhm_ns: bad,
2376 ..Default::default()
2377 };
2378 assert!(
2379 matches!(
2380 calibrate_resolution(
2381 ResolutionFamily::IkedaCarpenter { fit_psr: false },
2382 &e,
2383 &d,
2384 &u,
2385 &sample,
2386 &cfg
2387 ),
2388 Err(FittingError::InvalidConfig(_))
2389 ),
2390 "psr_fwhm_ns={bad} should be rejected"
2391 );
2392 }
2393 }
2394
2395 #[test]
2396 fn rejects_absurd_pinned_psr_width() {
2397 // Review #645 round 2, F1: psr_fwhm_ns is NANOSECONDS (FTS convention
2398 // 350 ns) and synthesis cost is quadratic in the fold width — a
2399 // µs-as-ns unit slip (350 meaning µs → 350_000 ns) previously passed
2400 // the finite/sign check and pinned a fictitious 350 µs fold: a
2401 // multi-hour silent hang. Widths above PSR_FWHM_PIN_CEILING_US
2402 // (10 µs = 10_000 ns) must be a loud up-front config error.
2403 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2404 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2405 let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2406 let d = vec![0.9; 60];
2407 let u = vec![0.01; 60];
2408 let cfg = CalibrationConfig {
2409 psr_fwhm_ns: 350_000.0, // "350 µs" unit slip
2410 ..Default::default()
2411 };
2412 let err = calibrate_resolution(
2413 ResolutionFamily::IkedaCarpenter { fit_psr: false },
2414 &e,
2415 &d,
2416 &u,
2417 &sample,
2418 &cfg,
2419 )
2420 .expect_err("a 350_000 ns (350 µs) pinned PSR width must be rejected");
2421 assert!(
2422 matches!(
2423 &err,
2424 FittingError::InvalidConfig(msg)
2425 if msg.contains("NANOSECONDS") && msg.contains("350 ns")
2426 ),
2427 "ceiling error must name the ns unit and the 350-ns convention, got {err:?}"
2428 );
2429
2430 // Boundary + normal pins stay valid: exactly 10_000 ns sits ON the
2431 // ceiling (rejection is strict `>`; 10_000·1e-3 rounds to exactly
2432 // 10.0) and 350 ns is the FTS default. psr_fwhm_ns = 0 (disable) is
2433 // pinned valid by rejects_fit_psr_with_zero_psr_width. Tiny
2434 // grid/iteration budget: these arms assert config validity, not fit
2435 // quality.
2436 for ok_ns in [350.0, 10_000.0] {
2437 let cheap = CalibrationConfig {
2438 psr_fwhm_ns: ok_ns,
2439 ic_n_energies: 8,
2440 ic_n_tau: 32,
2441 max_iter: 10,
2442 ..Default::default()
2443 };
2444 assert!(
2445 calibrate_resolution(
2446 ResolutionFamily::IkedaCarpenter { fit_psr: false },
2447 &e,
2448 &d,
2449 &u,
2450 &sample,
2451 &cheap,
2452 )
2453 .is_ok(),
2454 "psr_fwhm_ns = {ok_ns} ns must remain a valid pinned width"
2455 );
2456 }
2457 }
2458
2459 #[test]
2460 fn rejects_infeasible_psr_start_width() {
2461 // Review #645 round 3, F1: a nonzero PSR width in (0, ~58.6 ns)
2462 // passes every value-level check (finite / sign / ceiling) yet cannot
2463 // be SYNTHESIZED at the optimizer start: the default β/R start
2464 // (β = 0.1, R = 0.1 > R_NEGLIGIBLE) spans a 16/β = 160 µs storage
2465 // tail, capping the τ-step at 160/8191 ≈ 19.53 ns, and tau_geometry
2466 // rejects any triangle whose FWHM/3 floor is below that (fwhm <
2467 // ~58.6 ns). Every initial-simplex vertex was then ∞ (objective range
2468 // ∞ − ∞ = NaN — no self-convergence), so the calibration burned
2469 // max_iter and died with the generic "no finite-objective" error
2470 // blaming the forward model. The pre-flight must reject the START
2471 // precisely, surfacing the τ-geometry diagnosis and naming
2472 // psr_fwhm_ns. The fit_psr arm starts AT the fit-box floor
2473 // PSR_FWHM_US_MIN = 0.05 µs (50 ns), which is itself infeasible at
2474 // the default start — proof that a `>= PSR_FWHM_US_MIN` value check
2475 // would not be sufficient.
2476 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2477 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2478 let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2479 let d = vec![0.9; 60];
2480 let u = vec![0.01; 60];
2481 for (fit_psr, psr_ns) in [(false, 55.0), (true, 50.0)] {
2482 let cfg = CalibrationConfig {
2483 psr_fwhm_ns: psr_ns,
2484 ..Default::default()
2485 };
2486 let err = calibrate_resolution(
2487 ResolutionFamily::IkedaCarpenter { fit_psr },
2488 &e,
2489 &d,
2490 &u,
2491 &sample,
2492 &cfg,
2493 )
2494 .expect_err("a sub-59-ns PSR start must be rejected up front");
2495 assert!(
2496 matches!(
2497 &err,
2498 FittingError::InvalidConfig(msg)
2499 if msg.contains("starting parameter vector")
2500 && msg.contains("psr_fwhm_ns")
2501 && msg.contains("cannot resolve")
2502 ),
2503 "pre-flight error must name the start, psr_fwhm_ns and the τ-cap cause \
2504 (fit_psr = {fit_psr}, psr_ns = {psr_ns}), got {err:?}"
2505 );
2506 }
2507 }
2508
2509 #[test]
2510 fn rejects_fit_psr_with_zero_psr_width() {
2511 // psr_fwhm_ns = 0 means "no PSR fold"; fit_psr = true would silently
2512 // clamp that 0 start into the [0.05, 1] µs fit box, contradicting the
2513 // documented "0 disables". The contradiction is a config error.
2514 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2515 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2516 let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2517 let d = vec![0.9; 60];
2518 let u = vec![0.01; 60];
2519 let cfg = CalibrationConfig {
2520 psr_fwhm_ns: 0.0,
2521 ..Default::default()
2522 };
2523 let err = calibrate_resolution(
2524 ResolutionFamily::IkedaCarpenter { fit_psr: true },
2525 &e,
2526 &d,
2527 &u,
2528 &sample,
2529 &cfg,
2530 )
2531 .expect_err("fit_psr with psr_fwhm_ns = 0 must be rejected");
2532 assert!(
2533 matches!(&err, FittingError::InvalidConfig(msg) if msg.contains("fit_psr")),
2534 "expected an InvalidConfig naming fit_psr, got {err:?}"
2535 );
2536 // The same zero width WITHOUT fit_psr stays valid ("0 disables").
2537 // Tiny grid/iteration budget: this arm only asserts the config
2538 // passes validation, not fit quality.
2539 let cheap = CalibrationConfig {
2540 psr_fwhm_ns: 0.0,
2541 ic_n_energies: 8,
2542 ic_n_tau: 32,
2543 max_iter: 10,
2544 ..Default::default()
2545 };
2546 assert!(
2547 calibrate_resolution(
2548 ResolutionFamily::IkedaCarpenter { fit_psr: false },
2549 &e,
2550 &d,
2551 &u,
2552 &sample,
2553 &cheap,
2554 )
2555 .is_ok(),
2556 "psr_fwhm_ns = 0 with fit_psr = false must remain a valid config"
2557 );
2558 }
2559
2560 #[test]
2561 fn rejects_undersized_ic_synthesis_grid() {
2562 // ic_n_energies < 2 / ic_n_tau < 8 previously surfaced only as the
2563 // late, generic "no finite-objective resolution" error (every
2564 // IkedaCarpenter::new evaluation failed). They must be precise
2565 // up-front InvalidConfig errors for the IC family — sibling parity
2566 // with the Python binding's validation.
2567 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2568 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2569 let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2570 let d = vec![0.9; 60];
2571 let u = vec![0.01; 60];
2572 for (ne, nt, what) in [(1, 500, "ic_n_energies"), (64, 7, "ic_n_tau")] {
2573 // The loose iteration/tolerance budget only cheapens the Gaussian
2574 // is_ok arm below (validation-only assertion, not fit quality);
2575 // the InvalidConfig arm rejects before any optimization runs.
2576 let cfg = CalibrationConfig {
2577 ic_n_energies: ne,
2578 ic_n_tau: nt,
2579 max_iter: 5,
2580 xatol: 1.0,
2581 fatol: 1.0,
2582 ..Default::default()
2583 };
2584 let err = calibrate_resolution(
2585 ResolutionFamily::IkedaCarpenter { fit_psr: false },
2586 &e,
2587 &d,
2588 &u,
2589 &sample,
2590 &cfg,
2591 )
2592 .expect_err("undersized IC synthesis grid must be rejected");
2593 assert!(
2594 matches!(&err, FittingError::InvalidConfig(msg) if msg.contains(what)),
2595 "expected an InvalidConfig naming {what}, got {err:?}"
2596 );
2597 // The same values are inert for a non-IC family (mirrors the
2598 // Python binding: the knobs only size the IC synthesis grid).
2599 assert!(
2600 calibrate_resolution(ResolutionFamily::Gaussian, &e, &d, &u, &sample, &cfg).is_ok(),
2601 "ic grid knobs must stay inert for the Gaussian family"
2602 );
2603 }
2604 }
2605
2606 #[test]
2607 fn ic_box_worst_corner_synthesizes_within_tau_cap() {
2608 // #645 F1: the calibrator's box must not abort a calibration on an
2609 // unresolvable τ-grid. Worst corner of the box in the τ-cap sense:
2610 // β at its floor (slow reach 16/β = 800 µs — the longest admitted
2611 // storage tail, so the capped step is at its widest ≈ 0.098 µs),
2612 // R = 1 (storage fully active), a1 at its ceiling and a0 at the
2613 // documented physical ceiling (α ≈ 1–3 µs⁻¹ in the eV regime ⇒
2614 // a0 ≈ 0.2–0.5, see IC_A0_MIN), at the calibrator's default
2615 // n_tau = 500 on a representative eV-regime synthesis window. The
2616 // capped step resolves both the PSR triangle box (0.35 µs default
2617 // pin up to the 1 µs fitted ceiling: ≥ 3 samples per side) and any
2618 // prompt core with α ≤ 18/(7 · 0.098) ≈ 26 µs⁻¹ — far above eV-regime
2619 // moderator physics. (The remaining unresolvable pockets — a fitted
2620 // PSR near its 0.05 µs floor together with β near its floor, or
2621 // a0 driven ~50× past the physical ceiling — are handled as
2622 // infeasible points, see the companion test below.)
2623 let cfg = CalibrationConfig::default();
2624 for fwhm_us in [DEFAULT_PSR_FWHM_NS * NS_TO_US, PSR_FWHM_US_MAX] {
2625 let corner = IkedaCarpenterParams {
2626 alpha: EnergyLaw::SqrtE {
2627 a0: 0.5,
2628 a1: IC_A1_MAX,
2629 },
2630 beta: IC_BETA_MIN,
2631 r: EnergyLaw::Const(IC_R_MAX),
2632 burst_sigma_us: None,
2633 channel_fwhm_us: Some(fwhm_us),
2634 };
2635 let grid = SynthesisGrid {
2636 e_min_ev: 6.0,
2637 e_max_ev: 112.0,
2638 n_energies: cfg.ic_n_energies,
2639 n_tau: cfg.ic_n_tau,
2640 };
2641 assert!(
2642 IkedaCarpenter::new(corner, cfg.flight_path_m, &grid).is_ok(),
2643 "calibration-box worst corner must synthesize (fwhm = {fwhm_us} µs)"
2644 );
2645 }
2646 }
2647
2648 #[test]
2649 fn ic_unresolvable_theta_errs_in_build_resolution() {
2650 // A θ inside the box can still be unresolvable: a fitted PSR at its
2651 // 0.05 µs floor against β at its own floor needs a τ-step ≤ FWHM/3 ≈
2652 // 0.017 µs across an 800 µs storage tail — past the 8192-sample cap.
2653 // This test asserts the build_resolution half only: such θ must Err.
2654 // The calibration-level half — the objective maps that Err to an ∞
2655 // point the simplex steps away from, never aborting the calibration —
2656 // is asserted by ic_infeasible_pocket_inside_box_completes_calibration
2657 // below (#645 round 3, F4).
2658 let cfg = CalibrationConfig::default();
2659 let theta = [
2660 IC_A0_X0.ln(),
2661 IC_A1_X0.ln(),
2662 IC_BETA_MIN.ln(),
2663 0.5,
2664 PSR_FWHM_US_MIN,
2665 ];
2666 let fam = ResolutionFamily::IkedaCarpenter { fit_psr: true };
2667 assert!(
2668 build_resolution(&fam, &theta, 6.0, 112.0, &cfg).is_err(),
2669 "β at its floor + PSR at its floor must be unresolvable"
2670 );
2671 }
2672
2673 #[test]
2674 fn ic_infeasible_pocket_inside_box_completes_calibration() {
2675 // Review #645 round 3, F4 — the calibration-level half of the claim
2676 // above: with fit_psr the box CONTAINS the unresolvable pocket (PSR
2677 // near its 0.05 µs floor against β near its own floor), and the
2678 // simplex demonstrably brushes it — the 60 ns start sits just above
2679 // the ~58.6 ns feasibility edge at the default β/R start (the
2680 // pre-flight passes: 60/3 = 20 ns floor > 19.53 ns capped step), so
2681 // the FIRST simplex already carries an ∞ vertex: the β-decreased
2682 // vertex (ln β step is negative, β 0.1 → ~0.089) widens the storage
2683 // reach to ~180 µs and the capped step to ~21.9 ns, past the 20 ns
2684 // floor. The optimizer must treat such vertices as infeasible points
2685 // and finish: Ok, finite χ², decoded resolution inside the box. Tiny
2686 // grid/iteration budget — this asserts non-abortion, not fit quality.
2687 let iso = synthetic_isotope(72, 178, 20.0, 0.05, 0.06);
2688 let sample = SampleParams::new(300.0, vec![(iso, 2.0e-3)]).unwrap();
2689 let e: Vec<f64> = (0..60).map(|i| 15.0 + i as f64 * 0.2).collect();
2690 let d = vec![0.9; 60];
2691 let u = vec![0.01; 60];
2692 let cfg = CalibrationConfig {
2693 psr_fwhm_ns: 60.0,
2694 ic_n_energies: 8,
2695 ic_n_tau: 32,
2696 max_iter: 60,
2697 ..Default::default()
2698 };
2699 let r = calibrate_resolution(
2700 ResolutionFamily::IkedaCarpenter { fit_psr: true },
2701 &e,
2702 &d,
2703 &u,
2704 &sample,
2705 &cfg,
2706 )
2707 .expect("an infeasible pocket inside the box must not abort the calibration");
2708 assert!(
2709 r.chi2_dof.is_finite(),
2710 "calibration through the infeasible pocket must return a finite χ²/dof, got {}",
2711 r.chi2_dof
2712 );
2713 let (_a0, _a1, beta, _r, psr_us) = decoded_ic(&r);
2714 assert!(
2715 (PSR_FWHM_US_MIN..=PSR_FWHM_US_MAX).contains(&psr_us)
2716 && (IC_BETA_MIN..=IC_BETA_MAX).contains(&beta),
2717 "decoded solution must be feasible and inside the box: β = {beta}, \
2718 psr = {psr_us} µs"
2719 );
2720 }
2721}