Skip to main content

nereids_pipeline/
spatial.rs

1//! Spatial mapping: per-pixel fitting with rayon parallelization.
2//!
3//! Applies the single-spectrum fitting pipeline across all pixels in
4//! a hyperspectral neutron imaging dataset to produce 2D composition maps.
5
6use ndarray::{Array2, Array3, ArrayView3, s};
7use rayon::prelude::*;
8use std::sync::Arc;
9use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10
11use nereids_physics::resolution::build_resolution_plan;
12use nereids_physics::transmission::{
13    InstrumentParams, broadened_cross_sections_on_working_grid, unbroadened_cross_sections,
14};
15
16use crate::error::PipelineError;
17use crate::pipeline::{PrecomputedXs, SpectrumFitResult};
18
19/// Result of spatial mapping over a 2D image.
20///
21/// **NaN-on-failure contract (issue #458 B1/B2):**
22/// every per-pixel parameter map
23/// (`density_maps`, `uncertainty_maps`, `chi_squared_map`,
24/// `deviance_per_dof_map`, `temperature_map`,
25/// `temperature_uncertainty_map`, `anorm_map`, `background_maps`,
26/// `back_d_map`, `back_f_map`, `t0_us_map`, `l_scale_map`)
27/// contains `NaN` at every pixel where
28/// `converged_map` is `false`.  The only map written unconditionally
29/// is `converged_map` itself — it is how callers discover that a
30/// pixel failed.  Callers rendering numeric values should gate on
31/// `converged_map` (or check `value.is_finite()`) to avoid displaying
32/// the placeholder `NaN`.
33#[derive(Debug)]
34pub struct SpatialResult {
35    /// Fitted areal density maps, one per isotope.
36    /// Each Array2 has shape (height, width).
37    /// NaN at pixels where `converged_map` is `false`.
38    pub density_maps: Vec<Array2<f64>>,
39    /// Uncertainty maps, one per isotope.
40    /// NaN at pixels where `converged_map` is `false`.
41    pub uncertainty_maps: Vec<Array2<f64>>,
42    /// Reduced chi-squared map.  For the counts-KL dispatch (joint-Poisson
43    /// deviance) this is back-compat-mirrored to
44    /// `D/(n−k)`; the semantically-correct per-pixel value is also
45    /// exposed as [`Self::deviance_per_dof_map`].
46    /// NaN at pixels where `converged_map` is `false`.
47    pub chi_squared_map: Array2<f64>,
48    /// Per-pixel conditional binomial deviance `D/(n−k)` map.  `Some` when
49    /// the effective per-pixel solver is the counts-KL dispatch
50    /// (joint-Poisson); `None` for LM transmission runs, where Pearson
51    /// χ²/dof is the GOF.
52    /// NaN at pixels where `converged_map` is `false`.
53    pub deviance_per_dof_map: Option<Array2<f64>>,
54    /// Convergence map (true = converged).
55    pub converged_map: Array2<bool>,
56    /// Fitted temperature map (K). `Some` when `config.fit_temperature()` is true.
57    /// NaN at pixels where `converged_map` is `false`.
58    pub temperature_map: Option<Array2<f64>>,
59    /// Per-pixel temperature uncertainty map (K, 1-sigma).
60    /// `Some` when `config.fit_temperature()` is true.
61    /// Entries are NaN where uncertainty was unavailable for that pixel.
62    ///
63    /// **Covariance-only lower bound.** For the raw-count joint-Poisson
64    /// path each σ_T is the square root of the
65    /// temperature entry of the inverse curvature (Fisher) matrix at the
66    /// converged point. That is a *lower bound* on the true uncertainty: it
67    /// captures only the statistical curvature and omits baseline/model
68    /// mis-specification noise, so on real data it can **underestimate the
69    /// observed per-superpixel scatter by ~3–4×**. Enable
70    /// `UnifiedFitConfig::scale_by_chi2` to inflate σ_T by `sqrt` of the
71    /// goodness-of-fit this result reports (Gaussian `reduced_chi_squared` on the
72    /// transmission paths, `deviance_per_dof` on the counts joint-Poisson path)
73    /// for a goodness-of-fit-scaled estimate. The LM transmission path is already
74    /// χ²-scaled (Numerical Recipes §15.6), so the flag is a no-op there.
75    pub temperature_uncertainty_map: Option<Array2<f64>>,
76    /// Isotope labels captured at compute time, one per density map.
77    /// Ensures display labels stay in sync with density data even if the
78    /// user modifies the isotope list after fitting.
79    pub isotope_labels: Vec<String>,
80    /// Per-pixel SAMMY `Anorm` map (when background fitting is enabled).
81    /// NaN at pixels where `converged_map` is `false`.
82    pub anorm_map: Option<Array2<f64>>,
83    /// Per-pixel SAMMY background polynomial coefficient maps —
84    /// **only the first three coefficients** `[BackA, BackB, BackC]` of
85    /// the SAMMY 6-term form
86    /// `bg(E) = BackA + BackB/√E + BackC·√E + BackD·exp(-BackF/√E)`.
87    /// Both LM-transmission and counts-KL paths use these semantics
88    /// (legacy alpha-fitting `[b0, b1, alpha_2]` layout was retired
89    /// together with `fit_counts_poisson`).
90    ///
91    /// The exponential `BackD`/`BackF` terms are surfaced separately
92    /// in [`Self::back_d_map`] / [`Self::back_f_map`] — both `None`
93    /// for counts-KL runs (the joint-Poisson dispatch never fits the
94    /// exponential tail) and for LM transmission runs that left
95    /// `fit_back_d` / `fit_back_f` at their default `false`.
96    ///
97    /// NaN at pixels where `converged_map` is `false`.
98    pub background_maps: Option<[Array2<f64>; 3]>,
99    /// Per-pixel fitted SAMMY exponential background amplitude `BackD`.
100    /// `Some` only when the LM transmission background path was active
101    /// AND `fit_back_d=true`; `None` otherwise (counts-KL runs, LM
102    /// runs without a background model, and LM runs that fit the
103    /// polynomial terms but left the exponential tail at its initial
104    /// value).
105    /// NaN at pixels where `converged_map` is `false`.
106    pub back_d_map: Option<Array2<f64>>,
107    /// Per-pixel fitted SAMMY exponential background decay constant
108    /// `BackF`.  `Some` only when the LM transmission background path
109    /// was active AND `fit_back_f=true`; `None` otherwise.  Mirrors
110    /// [`Self::back_d_map`]'s gating because `BackD` and `BackF` are
111    /// required to fit together (see `validate_transmission_background`
112    /// in `crate::pipeline`).
113    /// NaN at pixels where `converged_map` is `false`.
114    pub back_f_map: Option<Array2<f64>>,
115    /// Per-pixel fitted SAMMY TZERO offset (µs) map.
116    /// `Some` when `config.fit_energy_scale` is true; `None` otherwise.
117    /// NaN at pixels where `converged_map` is `false`.
118    pub t0_us_map: Option<Array2<f64>>,
119    /// Per-pixel fitted SAMMY TZERO flight-path scale factor.
120    /// `Some` when `config.fit_energy_scale` is true; `None` otherwise.
121    /// NaN at pixels where `converged_map` is `false`.
122    pub l_scale_map: Option<Array2<f64>>,
123    /// Nominal flight path (m) the energy-scale fit was configured with —
124    /// recorded AT FIT TIME so downstream consumers (e.g. the GUI overlay's
125    /// per-pixel `SpectrumFitResult::corrected_energies`) reproduce the
126    /// transform with the fit's own flight path even if the live beamline
127    /// setting is edited afterwards (issue #634 review).  `Some` when
128    /// `config.fit_energy_scale` is true; `None` otherwise.
129    pub energy_scale_flight_path_m: Option<f64>,
130    /// Global multiplicative-baseline coefficients `[b0, b1, b2]` (issue
131    /// #635).  `Some` when a baseline was configured with
132    /// `spatial_global = true`: stage 1 fits the baseline ONCE on the
133    /// aggregated mean spectrum, then freezes it for every pixel (per-pixel
134    /// baselines at low counts biased fitted temperatures by up to +150 K;
135    /// the global mode removed ~80 % of that).  `None` when no baseline was
136    /// configured or in per-pixel mode (see [`Self::baseline_maps`]).
137    pub baseline_global: Option<[f64; 3]>,
138    /// Reference energy `E_ref` (eV) of the baseline's centered
139    /// `ln(E/E_ref)` basis — the geometric midpoint `√(E_min·E_max)` of the
140    /// fit grid, stored so consumers reconstruct `B(E)` with the exact
141    /// reference the fit used.  `Some` whenever a baseline was configured
142    /// (global or per-pixel mode).
143    pub baseline_e_ref_ev: Option<f64>,
144    /// Per-pixel multiplicative-baseline coefficient maps `[b0, b1, b2]`.
145    /// `Some` when a baseline was configured with `spatial_global = false`
146    /// (each pixel fits its own baseline); `None` in global mode.
147    /// NaN at pixels where `converged_map` is `false`.
148    pub baseline_maps: Option<[Array2<f64>; 3]>,
149    /// Structured fit-configuration warnings (issue #635) — currently the
150    /// degenerate normalization trio (free `Anorm` + free temperature +
151    /// ≥1 free density).  Mirrors `SpectrumFitResult::warnings`; also
152    /// printed once to stderr since spatial runs are long.
153    pub warnings: Vec<String>,
154    /// Number of pixels that converged.
155    pub n_converged: usize,
156    /// Total number of pixels fitted.
157    pub n_total: usize,
158    /// Number of pixels where the fitter returned an error (not just
159    /// non-convergence — a hard failure like invalid parameters or NaN
160    /// model output). These pixels have NaN density and false convergence.
161    pub n_failed: usize,
162}
163
164// ── Phase 3: InputData3D + spatial_map_typed ─────────────────────────────
165
166use crate::pipeline::{
167    InputData, MultiplicativeBaselineConfig, SolverConfig, UnifiedFitConfig, count_free_params,
168    degenerate_normalization_warning, fit_spectrum_typed, fit_spectrum_validated,
169    required_active_bins, validate_counts_resolution_route, validate_multiplicative_baseline,
170    validate_transmission_background,
171};
172
173/// 3D input data for spatial mapping.
174///
175/// The outer dimension is energy (axis 0), inner dimensions are spatial (y, x).
176/// The two variants correspond to [`InputData`] but carry 3D arrays.
177#[derive(Debug)]
178pub enum InputData3D<'a> {
179    /// Pre-normalized transmission + uncertainty.
180    Transmission {
181        transmission: ArrayView3<'a, f64>,
182        uncertainty: ArrayView3<'a, f64>,
183    },
184    /// Raw detector counts + open beam reference.
185    Counts {
186        sample_counts: ArrayView3<'a, f64>,
187        open_beam_counts: ArrayView3<'a, f64>,
188    },
189    /// Raw detector counts with explicit nuisance spectra.
190    CountsWithNuisance {
191        sample_counts: ArrayView3<'a, f64>,
192        flux: ArrayView3<'a, f64>,
193        background: ArrayView3<'a, f64>,
194    },
195}
196
197impl InputData3D<'_> {
198    /// Shape of the data: (n_energies, height, width).
199    pub(crate) fn shape(&self) -> (usize, usize, usize) {
200        let s = match self {
201            Self::Transmission { transmission, .. } => transmission.shape(),
202            Self::Counts { sample_counts, .. } => sample_counts.shape(),
203            Self::CountsWithNuisance { sample_counts, .. } => sample_counts.shape(),
204        };
205        (s[0], s[1], s[2])
206    }
207
208    /// `true` when the input is a counts variant (Counts or CountsWithNuisance)
209    /// — i.e. the per-pixel dispatch goes through the counts-KL path
210    /// (joint-Poisson deviance) rather than transmission.
211    pub fn is_counts(&self) -> bool {
212        matches!(self, Self::Counts { .. } | Self::CountsWithNuisance { .. })
213    }
214}
215
216/// Spatial mapping using the typed input data API.
217///
218/// Dispatches per-pixel fitting based on the `InputData3D` variant:
219/// - **Transmission**: per-pixel LM on transmission values (a Poisson/KL
220///   count objective is rejected for ratios).
221/// - **Counts**: per-pixel counts-KL dispatch (joint-Poisson conditional
222///   binomial deviance) on the sample cube, paired
223///   against the **spatially-averaged open-beam flux**.  See the inline
224///   comment on `averaged_flux` for the rationale: this is a deliberate
225///   bias-variance trade that reduces per-pixel OB shot-noise at the
226///   cost of the exact per-pixel paired joint-Poisson observation model.
227///   Callers needing the exact paired form should supply per-pixel
228///   nuisance spectra via [`InputData3D::CountsWithNuisance`] instead.
229/// - **CountsWithNuisance**: per-pixel counts-KL dispatch with the
230///   caller-supplied per-pixel flux and background cubes.  No averaging.
231///
232/// Always returns [`SpatialResult`].
233/// Apply the multi-pixel polish auto-disable rule.
234///
235/// For `n_pixels > 1`, return a config with `counts_enable_polish`
236/// forced to `Some(false)` UNLESS the caller already set an explicit
237/// override — in which case the caller's choice wins.  For `n_pixels
238/// <= 1` or when the caller overrode, the config is returned as-is.
239///
240/// Extracted as a pure helper so the decision logic is directly
241/// unit-testable without timing-based assertions in spatial tests.
242fn apply_spatial_polish_default(config: UnifiedFitConfig, n_pixels: usize) -> UnifiedFitConfig {
243    if n_pixels > 1 && config.counts_enable_polish().is_none() {
244        config.with_counts_enable_polish(Some(false))
245    } else {
246        config
247    }
248}
249
250/// Hoist whole-config `InvalidParameter` rejections out of the per-pixel
251/// rayon closure so they surface as a single boundary error instead of
252/// silently degrading to an all-NaN `SpatialResult` via the
253/// `Err(_) => failed_count += 1` swallow at the bottom of the loop.
254///
255/// Every gate here mirrors a per-pixel `Err(PipelineError::InvalidParameter)`
256/// raised inside `fit_spectrum_typed` /
257/// `fit_counts_joint_poisson` whose decision depends only on
258/// `(input variant, config)` — i.e. fires identically for every pixel.
259/// Per-pixel numerical fit failures intentionally stay inside the closure,
260/// where they correctly produce a NaN-only single pixel rather than a
261/// whole-map error. Unsupported detector background is *not* such a case:
262/// it is a model choice that no pixel can honour, so it is validated
263/// across every live pixel by `validate_spatial_data_values` before the
264/// closure starts.
265///
266/// The error messages here are kept byte-identical to the originating
267/// per-pixel sites so the user-facing diagnostic does not bifurcate
268/// based on whether the call came through the single-spectrum or
269/// spatial entry point.
270fn validate_spatial_fit_preflight(
271    input: &InputData3D<'_>,
272    config: &UnifiedFitConfig,
273) -> Result<(), PipelineError> {
274    // Gate: `fit_temperature && temperature_k < 1.0` (mirrors
275    // `pipeline.rs::fit_spectrum_typed` temperature-init guard).
276    // Without hoisting, a user who forgets units and writes `0.025`
277    // for 25 meV would see `Ok(SpatialResult { n_converged: 0,
278    // density_maps: all-NaN })` instead of the actionable message.
279    if config.fit_temperature() && config.temperature_k() < 1.0 {
280        return Err(PipelineError::InvalidParameter(format!(
281            "temperature must be >= 1.0 K when fit_temperature is true, got {}",
282            config.temperature_k(),
283        )));
284    }
285
286    // Gate: a fully-constrained fit (issue #633) — every density frozen and
287    // no other free parameter — would leave each pixel a converged no-op via
288    // the all-fixed solver fast path, i.e. an all-frozen "success" map.
289    // Reject the whole map up front with a clear message (mirrors the
290    // `fit_spectrum_typed` guard).
291    if count_free_params(config) == 0 {
292        return Err(PipelineError::InvalidParameter(
293            "no free parameters to fit: all densities are frozen and no other \
294             parameter is free — free at least one density (with_density_free) \
295             or enable fit_temperature / energy-scale / background"
296                .into(),
297        ));
298    }
299
300    // Gate (issue #635): in GLOBAL baseline mode, stage 2 freezes the
301    // baseline coefficients before the per-pixel fits — so the free-param
302    // count that matters per-pixel EXCLUDES the baseline flags.  Without
303    // this check, a config whose only free parameters are the baseline
304    // coefficients passes the guard above, stage 1 fits the global
305    // baseline, and then EVERY pixel hits fit_spectrum_typed's
306    // "no free parameters" rejection — which the rayon loop records as a
307    // per-pixel failure, returning Ok(SpatialResult) with all-NaN maps and
308    // n_failed == n_total.  That masks a whole-config error as per-pixel
309    // failures (the exact class the validate-up-front rule forbids).
310    if let Some(bl) = config.multiplicative_baseline()
311        && bl.spatial_global
312    {
313        let n_baseline_free =
314            usize::from(bl.fit_b0) + usize::from(bl.fit_b1) + usize::from(bl.fit_b2);
315        if count_free_params(config) == n_baseline_free {
316            return Err(PipelineError::InvalidParameter(
317                "global multiplicative baseline (spatial_global = true) is the \
318                 only free parameter block: after stage 1 freezes the fitted \
319                 baseline, the per-pixel fits would have nothing left to fit. \
320                 Free at least one per-pixel parameter (density / temperature / \
321                 energy scale / background), fit the aggregated spectrum with a \
322                 single-spectrum fitter instead, or set spatial_global = false \
323                 to fit per-pixel baselines."
324                    .into(),
325            ));
326        }
327    }
328
329    // Resolve `SolverConfig::Auto` against the input variant — counts
330    // → PoissonKL, transmission → LM.  `effective_solver` lives on
331    // `UnifiedFitConfig` but takes the 1D `InputData`; inline the
332    // resolution here so we do not have to materialise a 1D stub.
333    let is_counts = input.is_counts();
334    if config.exact_count_response().is_some() {
335        return Err(PipelineError::InvalidParameter(
336            "exact resolved counts are currently supported by the single-spectrum \
337             count fitter only; spatial mapping would rebuild the detector matrix \
338             for every pixel and is disabled until that fixed matrix is cached once"
339                .into(),
340        ));
341    }
342    // Counts + resolution: intercept BEFORE the shared validator, whose
343    // remedy ("provide ... through exact_count_response") is unreachable
344    // here — the gate directly above rejects any exact config on the spatial
345    // path. Sending a caller to a config this same function refuses is the
346    // misdirected-remedy pattern; name the spatial-actionable options.
347    if is_counts && config.resolution().is_some() {
348        return Err(PipelineError::InvalidParameter(
349            "spatial_map_typed: resolved count mapping needs the exact separate-arm \
350             model R[Phi] and R[Phi*T], which is currently available on the \
351             single-spectrum count fitter only: fit pre-normalized transmission \
352             cubes with resolution, aggregate to a spectrum and use \
353             fit_counts_spectrum_typed with exact_count_response, or disable \
354             instrument resolution for this count map"
355                .into(),
356        ));
357    }
358    // Hoist the remaining scientifically unsupported combinations so they
359    // become one actionable boundary error, not an all-NaN map after every
360    // per-pixel error is swallowed by the rayon loop.
361    validate_counts_resolution_route(is_counts, input.shape().0, config)?;
362    let is_kl = matches!(config.solver(), SolverConfig::PoissonKL(_))
363        || (matches!(config.solver(), SolverConfig::Auto) && is_counts);
364
365    // Fractional transmission is not Poisson count data. Hoist the
366    // single-spectrum rejection so the pixel loop cannot turn it into an
367    // all-NaN success-shaped result.
368    if !is_counts && is_kl {
369        return Err(PipelineError::InvalidParameter(
370            "spatial_map_typed: normalized transmission cannot use the Poisson/KL \
371             count objective because fractional transmission is not Poisson count \
372             data and the supplied uncertainty would be ignored; use the LM \
373             least-squares transmission engine, or supply separate open/sample counts"
374                .into(),
375        ));
376    }
377    if !is_counts && config.counts_background().is_some() {
378        return Err(PipelineError::InvalidParameter(
379            "spatial_map_typed: counts background configuration cannot be used with \
380             transmission data; use SAMMY transmission_background or \
381             multiplicative_baseline, or supply separate open/sample counts"
382                .into(),
383        ));
384    }
385
386    // Gate: `fit_energy_range` selects fewer active bins than the
387    // dispatch can solve.  The active-mask + grid are shared by every
388    // pixel, so the per-pixel `n_active < required` rejection in the
389    // LM transmission path (`pipeline.rs::fit_transmission_lm`) and
390    // the joint-Poisson path (`pipeline.rs::fit_counts_joint_poisson`)
391    // both fire identically across the map.  We compute `required`
392    // from the config's free-parameter count (densities + temperature
393    // + energy-scale + transmission_background flags +
394    // multiplicative-baseline flags, #635), clamped to a
395    // floor of 2 — that combined `max(2, n_free)` covers both the
396    // numerical-stability minimum and the underdetermined-system
397    // rejection.  Without the `n_free` factor, a config with
398    // multiple densities + background terms + temperature + energy-
399    // scale (n_free can reach ~10) would silently pass the preflight
400    // with a 3-bin window and every pixel would return non-converged
401    // / NaN — the all-NaN spatial-result class this preflight exists
402    // to prevent.  See [`required_active_bins`] in `pipeline.rs`.
403    if let Some((e_min, e_max)) = config.fit_energy_range() {
404        let active_mask = nereids_fitting::active_mask::build_active_mask(
405            config.energies(),
406            config.fit_energy_range(),
407        );
408        let n_active = nereids_fitting::active_mask::active_count(
409            active_mask.as_deref(),
410            config.energies().len(),
411        );
412        let required = required_active_bins(config);
413        if n_active < required {
414            // Mirror the per-pixel string from whichever path the
415            // dispatcher would actually take.  LM and joint-Poisson
416            // both reach this branch; transmission + Poisson-KL is
417            // already rejected by the previous gate above.
418            let path_msg = if is_counts && is_kl {
419                "joint-Poisson"
420            } else {
421                "LM transmission"
422            };
423            return Err(PipelineError::InvalidParameter(format!(
424                "fit_energy_range [{e_min}, {e_max}] eV selects {n_active} active bin(s) \
425                 on the configured energy grid; at least {required} active bin(s) are \
426                 required for {path_msg} fitting with {n_free} free parameter(s) \
427                 (underdetermined when n_active < n_free)",
428                n_free = count_free_params(config),
429            )));
430        }
431    }
432
433    // Gate: multiplicative-baseline config errors (issue #635) fire
434    // identically for every pixel (inits/bounds/positivity are grid+config
435    // properties, and the free-Anorm degeneracy is a config property) —
436    // hoist them so the caller gets one clear error instead of an all-NaN
437    // map with n_failed == n_total.
438    validate_multiplicative_baseline(config)?;
439
440    // ── Counts-KL (joint-Poisson) whole-config gates ────────────────
441    // Every gate below mirrors a per-pixel rejection in
442    // `pipeline.rs::fit_counts_joint_poisson`.  All fire identically
443    // across the map because they depend only on shared config flags
444    // (alpha fitting, B_A/B/C interlock, `c` value). Unsupported nonzero
445    // detector background is rejected across all live pixels by
446    // `validate_spatial_data_values` before any fit starts.
447    if is_counts && is_kl {
448        if let Some(bg) = config.counts_background() {
449            if bg.fit_alpha_1 || bg.fit_alpha_2 {
450                return Err(PipelineError::InvalidParameter(
451                    "joint-Poisson solver does not support fit_alpha_1/fit_alpha_2: \
452                     the profile lambda-hat absorbs the global flux scale (alpha_1 redundant); \
453                     alpha_2 / B_det wiring is not yet implemented."
454                        .into(),
455                ));
456            }
457            // `c` defaults to `1.0` when absent, matching the
458            // `.unwrap_or(1.0)` in `fit_counts_joint_poisson`; only an
459            // explicit non-finite or non-positive `c` is rejected.
460            // Python pre-validates this at the binding boundary so
461            // Python users hit a `ValueError` before this gate, but
462            // Rust core callers can still reach this path.
463            if !(bg.c.is_finite() && bg.c > 0.0) {
464                return Err(PipelineError::InvalidParameter(format!(
465                    "joint-Poisson solver requires finite c > 0 in CountsBackgroundConfig, got {}",
466                    bg.c,
467                )));
468            }
469        }
470        if let Some(bg) = config.transmission_background()
471            && (bg.fit_back_b || bg.fit_back_c)
472            && !bg.fit_back_a
473        {
474            return Err(PipelineError::InvalidParameter(
475                "joint-Poisson transmission_background: B_A (fit_back_a) must be \
476                 enabled whenever any of B_B / B_C is enabled (A_n alone cannot \
477                 absorb a constant offset — benchmarked at −23% density bias)."
478                    .into(),
479            ));
480        }
481    }
482
483    Ok(())
484}
485
486/// Stage 1 of the two-stage global multiplicative baseline (issue #635):
487/// fit the FULL configured model (density / temperature / background /
488/// baseline) once on the **aggregated mean spectrum** over all live pixels,
489/// and return the fitted `[b0, b1, b2]` for stage 2 to freeze per-pixel.
490///
491/// Aggregation conventions (must mirror the per-pixel dispatch):
492/// - **Transmission**: per-bin mean transmission over live pixels, with the
493///   standard error of the mean `√(Σσ²)/n` as the aggregated 1-σ.
494/// - **Counts**: per-bin mean sample counts, paired against the SAME
495///   spatially-averaged open-beam flux the per-pixel KL dispatch uses
496///   (`averaged_flux`); routed as `CountsWithNuisance` + zero background
497///   for the KL solver exactly like the rayon closure.  Mean counts are
498///   non-integer, which the binomial deviance handles exactly.
499/// - **CountsWithNuisance**: per-bin means of all three caller cubes.
500///
501/// Non-convergence is a HARD error by design: silently falling back to
502/// per-pixel baselines would reintroduce the +150 K low-count temperature
503/// bias the global mode exists to remove.
504#[allow(clippy::too_many_arguments)]
505fn fit_global_baseline_stage1(
506    input: &InputData3D<'_>,
507    fast_config: &UnifiedFitConfig,
508    data_a: &Array3<f64>,
509    data_b: &Array3<f64>,
510    data_c: Option<&Array3<f64>>,
511    pixel_coords: &[(usize, usize)],
512    averaged_flux: Option<&[f64]>,
513) -> Result<[f64; 3], PipelineError> {
514    let n_e = data_a.shape()[2];
515    let n_live = pixel_coords.len() as f64;
516    let mean_over = |cube: &Array3<f64>| -> Vec<f64> {
517        let mut m = vec![0.0f64; n_e];
518        for &(y, x) in pixel_coords {
519            for (e, &v) in cube.slice(s![y, x, ..]).iter().enumerate() {
520                m[e] += v;
521            }
522        }
523        for v in &mut m {
524            *v /= n_live;
525        }
526        m
527    };
528
529    let aggregate = match input {
530        InputData3D::Transmission { .. } => {
531            let mean_t = mean_over(data_a);
532            // Standard error of the mean under independent per-pixel σ.
533            let mut se = vec![0.0f64; n_e];
534            for &(y, x) in pixel_coords {
535                for (e, &sig) in data_b.slice(s![y, x, ..]).iter().enumerate() {
536                    se[e] += sig * sig;
537                }
538            }
539            for v in &mut se {
540                *v = v.sqrt() / n_live;
541            }
542            InputData::Transmission {
543                transmission: mean_t,
544                uncertainty: se,
545            }
546        }
547        InputData3D::Counts { .. } => {
548            let mean_s = mean_over(data_a);
549            let flux = averaged_flux
550                .expect("averaged_flux is Some for InputData3D::Counts")
551                .to_vec();
552            // Mirror the per-pixel dispatch: KL → CountsWithNuisance with
553            // the averaged flux + zero background; LM → raw Counts.
554            let effective = fast_config.effective_solver(&InputData::Counts {
555                sample_counts: mean_s.clone(),
556                open_beam_counts: flux.clone(),
557            });
558            match effective {
559                SolverConfig::PoissonKL(_) => InputData::CountsWithNuisance {
560                    sample_counts: mean_s,
561                    flux,
562                    background: vec![0.0f64; n_e],
563                },
564                _ => InputData::Counts {
565                    sample_counts: mean_s,
566                    open_beam_counts: flux,
567                },
568            }
569        }
570        InputData3D::CountsWithNuisance { .. } => InputData::CountsWithNuisance {
571            sample_counts: mean_over(data_a),
572            flux: mean_over(data_b),
573            background: mean_over(data_c.expect("CountsWithNuisance carries a background cube")),
574        },
575    };
576
577    let agg = fit_spectrum_typed(&aggregate, fast_config).map_err(|e| {
578        PipelineError::InvalidParameter(format!(
579            "multiplicative-baseline stage 1 (global fit on the aggregated \
580             mean spectrum) failed: {e}"
581        ))
582    })?;
583    if !agg.converged {
584        return Err(PipelineError::InvalidParameter(
585            "multiplicative-baseline stage 1 did not converge on the \
586             aggregated mean spectrum; refusing to fall back to per-pixel \
587             baselines (at low counts they biased fitted temperatures by up \
588             to +150 K). Check the baseline bounds/inits, or set \
589             spatial_global = false to fit per-pixel baselines explicitly."
590                .into(),
591        ));
592    }
593    Ok(agg
594        .baseline
595        .expect("stage 1 ran with a configured baseline, so the result carries it"))
596}
597
598/// Validity domain for an up-front detector-cube value check.
599///
600/// Each variant encodes the physically-meaningful constraint for one class of
601/// cube (see [`validate_spatial_data_values`]).
602#[derive(Clone, Copy)]
603enum CubeDomain {
604    /// Finite (NaN / ±∞ rejected); sign unconstrained.  Used for the
605    /// transmission **value**: SAMMY does not reject negative transmission —
606    /// measurement noise / open-beam over-subtraction can push a measured
607    /// point below 0 — so only finiteness is required.
608    Finite,
609    /// Finite **and strictly > 0**.  Used for the 1-σ uncertainty: a zero or
610    /// negative error bar is a singular weight (SAMMY: zero uncertainties are
611    /// never allowed).  Without this guard the old `σ.max(1e-10)` floor turned
612    /// a bad σ into a `1/(1e-10)² = 1e20` maximum-confidence bin — the
613    /// opposite of the LM core's `s <= 0.0 => 1/1e30` negligible-weight rule.
614    FinitePositive,
615    /// Finite **and ≥ 0**.  Used for raw detector counts / open-beam / flux:
616    /// non-negative by construction (zero is legitimate — "no counts in this
617    /// bin"), so a negative or non-finite value signals an upstream loader /
618    /// TOF-normalisation bug, exactly as the `validate_counts` docstring in
619    /// `nereids_fitting::joint_poisson` describes.
620    FiniteNonNegative,
621}
622
623impl CubeDomain {
624    #[inline]
625    fn accepts(self, v: f64) -> bool {
626        match self {
627            CubeDomain::Finite => v.is_finite(),
628            CubeDomain::FinitePositive => v.is_finite() && v > 0.0,
629            CubeDomain::FiniteNonNegative => v.is_finite() && v >= 0.0,
630        }
631    }
632
633    fn describe(self) -> &'static str {
634        match self {
635            CubeDomain::Finite => "finite",
636            CubeDomain::FinitePositive => "finite and > 0",
637            CubeDomain::FiniteNonNegative => "finite and >= 0",
638        }
639    }
640}
641
642/// Check every relevant element of one detector cube, returning the first
643/// violation as a typed `InvalidParameter` naming the cube and the offending
644/// `(y, x, e)`.
645///
646/// Iterates in memory order — energy plane `e` outer (a contiguous `h × w`
647/// block in the `(n_energies, height, width)` input layout), live pixels
648/// inner — and short-circuits on the first bad value.  When `active_mask` is
649/// `Some` (the transmission / uncertainty cubes), bins outside the user's
650/// `fit_energy_range` are skipped: the LM core excludes them from the fit, so
651/// a non-finite value there is irrelevant.  The raw-count cubes pass `None` to
652/// check every bin (see [`validate_spatial_data_values`] for the rationale).
653fn check_cube(
654    cube: &ArrayView3<'_, f64>,
655    field: &'static str,
656    domain: CubeDomain,
657    live_pixels: &[(usize, usize)],
658    active_mask: Option<&[bool]>,
659) -> Result<(), PipelineError> {
660    let n_energies = cube.shape()[0];
661    for e in 0..n_energies {
662        if active_mask.is_some_and(|m| !m[e]) {
663            continue;
664        }
665        for &(y, x) in live_pixels {
666            let v = cube[[e, y, x]];
667            if !domain.accepts(v) {
668                return Err(PipelineError::InvalidParameter(format!(
669                    "{field} at (y={y}, x={x}, e={e}) must be {}, got {v}",
670                    domain.describe(),
671                )));
672            }
673        }
674    }
675    Ok(())
676}
677
678/// Reject non-finite / out-of-domain detector-cube **values** up front, so bad
679/// input fails with a typed `InvalidParameter` (mapped to `PyValueError` at
680/// the Python boundary) instead of being silently transformed by the
681/// per-pixel sanitation that used to run inside the rayon closure
682/// (`v.max(0.0)` on counts, `σ.max(1e-10)` on uncertainty).  That sanitation
683/// defeated the downstream joint-Poisson `validate_counts` guard
684/// (`NaN.max(0.0) == 0.0` passes silently) and turned a bad σ into a
685/// maximum-confidence bin — concealing precisely the upstream TOF-norm /
686/// loader bugs the guards exist to surface.
687///
688/// Only **live** pixels are checked: a `dead_pixels`-masked pixel is excluded
689/// from the fit and from the averaged open-beam flux, so its data is never
690/// read and may legitimately hold detector garbage.
691///
692/// Bin scope differs by quantity, matching each path's existing downstream
693/// contract so that no currently-passing fit changes behaviour:
694/// - **transmission / uncertainty** are checked on **active bins only**.
695///   Transmission is derived (`sample / open_beam`) and is legitimately
696///   undefined where open-beam → 0; the LM core deliberately *skips* inactive
697///   bins (`nereids_fitting::lm` — "y_obs is NaN outside the user's
698///   fit-energy range"), so a NaN in an out-of-`fit_energy_range` bin is
699///   harmless and must not be rejected.
700/// - **counts / open-beam / flux** are checked on **all bins** — raw detector
701///   quantities, where a bad value anywhere is an upstream bug (matching the
702///   all-bins `validate_counts`).
703/// - **background** (CountsWithNuisance) is checked **finite, all bins**,
704///   and then rejected outright if any live pixel carries an exact nonzero
705///   in any bin. Finiteness is checked first so a NaN is reported as
706///   malformed input rather than as an unsupported background value.
707fn validate_spatial_data_values(
708    input: &InputData3D<'_>,
709    live_pixels: &[(usize, usize)],
710    active_mask: Option<&[bool]>,
711) -> Result<(), PipelineError> {
712    match input {
713        InputData3D::Transmission {
714            transmission,
715            uncertainty,
716        } => {
717            check_cube(
718                transmission,
719                "transmission",
720                CubeDomain::Finite,
721                live_pixels,
722                active_mask,
723            )?;
724            check_cube(
725                uncertainty,
726                "uncertainty",
727                CubeDomain::FinitePositive,
728                live_pixels,
729                active_mask,
730            )?;
731        }
732        InputData3D::Counts {
733            sample_counts,
734            open_beam_counts,
735        } => {
736            check_cube(
737                sample_counts,
738                "sample_counts",
739                CubeDomain::FiniteNonNegative,
740                live_pixels,
741                None,
742            )?;
743            check_cube(
744                open_beam_counts,
745                "open_beam_counts",
746                CubeDomain::FiniteNonNegative,
747                live_pixels,
748                None,
749            )?;
750        }
751        InputData3D::CountsWithNuisance {
752            sample_counts,
753            flux,
754            background,
755        } => {
756            check_cube(
757                sample_counts,
758                "sample_counts",
759                CubeDomain::FiniteNonNegative,
760                live_pixels,
761                None,
762            )?;
763            check_cube(
764                flux,
765                "flux",
766                CubeDomain::FiniteNonNegative,
767                live_pixels,
768                None,
769            )?;
770            check_cube(
771                background,
772                "background",
773                CubeDomain::Finite,
774                live_pixels,
775                None,
776            )?;
777            // `B_det` is not wired into the fit at all, so a nonzero
778            // background is an unsupported model choice rather than a
779            // per-pixel data defect. Left inside the per-pixel closure it
780            // fails every live pixel and the swallow at the bottom of the
781            // loop turns that into an all-NaN map returned as `Ok` — the
782            // exact degradation this preflight exists to prevent. Checked
783            // after the finiteness pass above, so NaN is reported as
784            // malformed input rather than as a nonzero value.
785            if live_pixels.iter().any(|&(y, x)| {
786                (0..background.shape()[0]).any(|energy| background[[energy, y, x]] != 0.0)
787            }) {
788                return Err(PipelineError::InvalidParameter(
789                    "joint-Poisson solver with non-zero detector_background is not yet \
790                     supported (B_det wiring is deferred)."
791                        .into(),
792                ));
793            }
794        }
795    }
796    Ok(())
797}
798
799/// Fit every pixel of a 3-D data cube and return per-pixel maps — the
800/// spatial-mapping entry point of the pipeline.
801///
802/// Runs the single-spectrum fitter once per `(y, x)` pixel of `input`
803/// (shape `(n_energies, height, width)`), in parallel over pixels with
804/// rayon, and assembles the results into [`SpatialResult`]: one areal
805/// density map and uncertainty map per fitted isotope/group, the χ²
806/// (or deviance-per-dof) map, the convergence mask, and any optional
807/// maps the configuration enables (temperature, normalization,
808/// background terms, t0 / flight-path scale).
809///
810/// # Input modes
811///
812/// `input` selects the per-pixel objective: pre-normalized
813/// [`InputData3D::Transmission`] (+ per-bin uncertainty),
814/// [`InputData3D::Counts`] (sample + open-beam), or
815/// [`InputData3D::CountsWithNuisance`] (legacy compatibility input; a nonzero
816/// background is rejected because it is not connected to the physical
817/// two-arm likelihood).
818///
819/// # Validation (all up-front, before any pixel is fitted)
820///
821/// * The cube's spectral axis must match `config.energies()`, the
822///   mode's companion cubes must match the primary cube's shape, and
823///   `dead_pixels` (when given) must be `(height, width)`.
824/// * Cube *values* are validated on live pixels, each against its
825///   domain — transmission finite, uncertainty finite and strictly
826///   positive, counts/flux finite and non-negative, background finite —
827///   so a corrupt cube fails loudly instead of producing a quietly-NaN
828///   map.  For transmission inputs
829///   with a `fit_energy_range`, the value checks are scoped to the
830///   active bins — out-of-range bins may contain NaN by design.
831/// * Invalid domain/engine configurations are rejected with a diagnostic
832///   rather than letting every pixel fail into an all-NaN map. Raw counts use
833///   the joint-Poisson engine; LM is reserved for normalized transmission.
834///   `transmission_background` settings are validated here for the
835///   same reason.  (`fit_energy_scale` together with `fit_temperature`
836///   is SUPPORTED since issue #634 — the energy-scale model carries a
837///   fitted temperature column.)
838///
839/// Per-pixel fit *failures* after validation are not errors: the pixel
840/// is recorded as NaN in the maps, `converged_map` is `false` there,
841/// and `n_failed` counts it.
842///
843/// # Cancellation and progress
844///
845/// `cancel` is polled before the sweep and at every pixel; once set,
846/// remaining pixels are skipped and the call returns
847/// [`PipelineError::Cancelled`] (partial results are discarded).
848/// `progress` is incremented once per completed live pixel, so a UI
849/// thread can poll it against the number of live pixels
850/// (`height × width` minus the `dead_pixels`-masked count).
851///
852/// # Errors
853///
854/// [`PipelineError::ShapeMismatch`] for axis/shape disagreements,
855/// [`PipelineError::InvalidParameter`] for rejected configurations and
856/// invalid cube values, [`PipelineError::Transmission`] when the shared
857/// cross-section / resolution-plan precompute fails (e.g. a
858/// resolution-kernel or working-grid build error), and
859/// [`PipelineError::Cancelled`] when `cancel` was set.
860pub fn spatial_map_typed(
861    input: &InputData3D<'_>,
862    config: &UnifiedFitConfig,
863    dead_pixels: Option<&Array2<bool>>,
864    cancel: Option<&AtomicBool>,
865    progress: Option<&AtomicUsize>,
866) -> Result<SpatialResult, PipelineError> {
867    let (n_energies, height, width) = input.shape();
868    // n_maps = number of density maps to return (one per group or per isotope).
869    let n_maps = config.n_density_params();
870
871    // Validate shapes
872    if n_energies != config.energies().len() {
873        return Err(PipelineError::ShapeMismatch(format!(
874            "input spectral axis ({n_energies}) != config.energies length ({})",
875            config.energies().len(),
876        )));
877    }
878    match input {
879        InputData3D::Transmission {
880            transmission,
881            uncertainty,
882        } => {
883            if uncertainty.shape() != transmission.shape() {
884                return Err(PipelineError::ShapeMismatch(format!(
885                    "uncertainty shape {:?} != transmission shape {:?}",
886                    uncertainty.shape(),
887                    transmission.shape(),
888                )));
889            }
890        }
891        InputData3D::Counts {
892            sample_counts,
893            open_beam_counts,
894        } => {
895            if open_beam_counts.shape() != sample_counts.shape() {
896                return Err(PipelineError::ShapeMismatch(format!(
897                    "open_beam shape {:?} != sample shape {:?}",
898                    open_beam_counts.shape(),
899                    sample_counts.shape(),
900                )));
901            }
902        }
903        InputData3D::CountsWithNuisance {
904            sample_counts,
905            flux,
906            background,
907        } => {
908            if flux.shape() != sample_counts.shape() {
909                return Err(PipelineError::ShapeMismatch(format!(
910                    "flux shape {:?} != sample shape {:?}",
911                    flux.shape(),
912                    sample_counts.shape(),
913                )));
914            }
915            if background.shape() != sample_counts.shape() {
916                return Err(PipelineError::ShapeMismatch(format!(
917                    "background shape {:?} != sample shape {:?}",
918                    background.shape(),
919                    sample_counts.shape(),
920                )));
921            }
922        }
923    }
924    if let Some(dp) = dead_pixels
925        && dp.shape() != [height, width]
926    {
927        return Err(PipelineError::ShapeMismatch(format!(
928            "dead_pixels shape {:?} != spatial dimensions ({height}, {width})",
929            dp.shape(),
930        )));
931    }
932
933    // Raw counts are not silently divided into transmission for LM. Hoist the
934    // single-spectrum rejection so a spatial call cannot degrade into an
935    // all-NaN success-shaped result after every pixel fails. This subsumes
936    // both the historical #458 B3 counts+LM+energy-scale instability guard
937    // and the CountsWithNuisance+LM hoist: every count variant plus LM is
938    // rejected at the boundary now.
939    //
940    // Issue #634: `fit_energy_scale` + `fit_temperature` is supported —
941    // `EnergyScaleTransmissionModel` wires a fitted temperature column, so
942    // per-pixel `fit_spectrum_typed` handles the combination and no spatial
943    // guard is needed.
944    if input.is_counts() && matches!(config.solver(), SolverConfig::LevenbergMarquardt(_)) {
945        return Err(PipelineError::InvalidParameter(
946            "spatial_map_typed: separate open/sample counts cannot use the LM \
947             least-squares transmission engine because silent ratio conversion loses \
948             open-beam uncertainty and count statistics; use the Poisson/KL count \
949             engine or SolverConfig::Auto"
950                .into(),
951        ));
952    }
953
954    // Validate `transmission_background` BackD/BackF here rather than
955    // per-pixel.  Invalid configs (unpaired flags, non-finite or non-
956    // positive init values, counts-KL plus exponential tail) would
957    // otherwise be swallowed as `n_failed` per pixel and produce an
958    // all-NaN map with no diagnostic.
959    if let Some(bg) = config.transmission_background() {
960        // SAMMY pairs BackD/BackF — enabling only one leaves the other
961        // registered but unused.  Already enforced per-pixel in the LM
962        // solver; surface up-front for the spatial dispatch.
963        validate_transmission_background(bg)?;
964        // BackF's Jacobian column zeros out at BackD ≈ 0 (and BackD
965        // becomes a constant duplicate of BackA at BackF ≈ 0).  Reject
966        // non-positive initial values so the LM solver does not silently
967        // produce all-NaN maps via a degenerate Jacobian.  Also reject
968        // `NaN` / `+inf` — both pass `<= 0.0` (NaN comparisons are
969        // always false; +inf is > 0) but propagate into the fit
970        // parameters and silently corrupt the result.
971        if bg.fit_back_d && (!bg.back_d_init.is_finite() || bg.back_d_init <= 0.0) {
972            return Err(PipelineError::InvalidParameter(format!(
973                "transmission_background.back_d_init must be finite and strictly \
974                 positive when fit_back_d=true (got {}). BackF's Jacobian column \
975                 zeros out at BackD ≈ 0; non-finite or non-positive initial values \
976                 produce a degenerate fit that LM cannot recover.",
977                bg.back_d_init,
978            )));
979        }
980        if bg.fit_back_f && (!bg.back_f_init.is_finite() || bg.back_f_init <= 0.0) {
981            return Err(PipelineError::InvalidParameter(format!(
982                "transmission_background.back_f_init must be finite and strictly \
983                 positive when fit_back_f=true (got {}). BackD becomes a constant \
984                 duplicate of BackA at BackF ≈ 0; non-finite or non-positive initial \
985                 values produce a degenerate fit that LM cannot recover.",
986                bg.back_f_init,
987            )));
988        }
989        // The joint-Poisson (counts-KL) dispatch never fits the SAMMY
990        // exponential tail — `fit_counts_joint_poisson` rejects
991        // `fit_back_d || fit_back_f` per pixel.  Surface up-front so the
992        // user gets a clear diagnostic instead of an all-NaN map.
993        if (bg.fit_back_d || bg.fit_back_f)
994            && input.is_counts()
995            && !matches!(config.solver(), SolverConfig::LevenbergMarquardt(_))
996        {
997            return Err(PipelineError::InvalidParameter(
998                "spatial_map_typed: transmission_background with fit_back_d=true / \
999                 fit_back_f=true cannot be combined with the counts-KL (joint-Poisson) \
1000                 dispatch. The joint-Poisson solver does not fit the SAMMY exponential \
1001                 tail. Disable the exponential tail (fit_back_d=false, \
1002                 fit_back_f=false), or fit pre-normalized transmission with \
1003                 uncertainties via SolverConfig::LevenbergMarquardt (raw counts \
1004                 cannot use LM)."
1005                    .into(),
1006            ));
1007        }
1008    }
1009
1010    // Hoist whole-config `InvalidParameter` rejections so they surface as
1011    // a single boundary error instead of being swallowed pixel-by-pixel
1012    // into an all-NaN `SpatialResult`.  See
1013    // `validate_spatial_fit_preflight` for the full gate list and
1014    // per-gate rationale.  Must run before any rayon work.
1015    //
1016    // Ordering note: the preflight runs *after* the dispatch /
1017    // solver-compatibility guards above (CountsWithNuisance + LM,
1018    // transmission_background BackD/BackF interlocks, …).  The
1019    // fit-range, temperature and
1020    // alpha gates inside the preflight only meaningfully apply once
1021    // the input → solver dispatch is known to be valid; otherwise
1022    // a downstream "LM transmission active-bin" message would
1023    // shadow the more fundamental "CountsWithNuisance requires a
1024    // counts-domain solver" diagnostic.
1025    validate_spatial_fit_preflight(input, config)?;
1026
1027    // Reject a malformed caller-supplied precomputed cross-section stack once,
1028    // before the per-pixel rayon loop (and before the σ_eff group-collapse
1029    // below, which indexes `xs[0]`).  A freshly-computed stack carries no
1030    // `precomputed_cross_sections`, so this is a no-op on the common path.
1031    crate::pipeline::validate_precomputed_cross_sections(config)?;
1032
1033    // Collect live pixel coordinates
1034    let mut pixel_coords: Vec<(usize, usize)> = Vec::new();
1035    for y in 0..height {
1036        for x in 0..width {
1037            let is_dead = dead_pixels.is_some_and(|m| m[[y, x]]);
1038            if !is_dead {
1039                pixel_coords.push((y, x));
1040            }
1041        }
1042    }
1043
1044    let isotope_labels = config.isotope_names().to_vec();
1045    let has_background_outputs =
1046        config.transmission_background().is_some() || config.counts_background().is_some();
1047    // The exponential `BackD` / `BackF` terms are LM-transmission-only:
1048    // `fit_counts_joint_poisson` rejects `fit_back_d || fit_back_f` for
1049    // the counts-KL path.  Gate both maps on the transmission-background
1050    // config carrying the per-term `fit_back_d` / `fit_back_f` flags so
1051    // callers can distinguish "map full of NaN because no pixel
1052    // converged" (`Some([NaN, ...])`) from "the exponential tail was
1053    // never engaged" (`None`).
1054    let has_back_d_map = config
1055        .transmission_background()
1056        .is_some_and(|bg| bg.fit_back_d);
1057    let has_back_f_map = config
1058        .transmission_background()
1059        .is_some_and(|bg| bg.fit_back_f);
1060
1061    // Whether the per-pixel dispatch routes through the counts-KL
1062    // (joint-Poisson) solver.  True iff the input is counts AND the
1063    // effective solver is either explicit `PoissonKL` or `Auto`
1064    // (Auto resolves to PoissonKL on counts input; counts + LM was
1065    // rejected at preflight).  When false (a transmission LM input),
1066    // per-pixel SpectrumFitResult.deviance_per_dof is `None`, so the
1067    // spatial deviance_per_dof_map should also be `None` — otherwise
1068    // GUI / Python consumers using `is_some()` to label GOF as "D/dof"
1069    // would mislabel an all-NaN map.
1070    let dispatches_to_counts_kl =
1071        input.is_counts() && !matches!(config.solver(), SolverConfig::LevenbergMarquardt(_));
1072
1073    // Issue #635: baseline output shape.  Global mode → scalar
1074    // `baseline_global`; per-pixel mode → `baseline_maps`.
1075    let baseline_global_mode = config
1076        .multiplicative_baseline()
1077        .is_some_and(|bl| bl.spatial_global);
1078    let has_baseline_maps = config.multiplicative_baseline().is_some() && !baseline_global_mode;
1079    let baseline_e_ref_ev = config
1080        .multiplicative_baseline()
1081        .map(|_| config.baseline_reference_energy());
1082
1083    if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
1084        return Err(PipelineError::Cancelled);
1085    }
1086    if pixel_coords.is_empty() {
1087        // All pixels filtered out (typically by `dead_pixels` mask).  Per
1088        // the NaN-on-failure contract (issue #458 B1),
1089        // every parameter map must be NaN at every pixel — including
1090        // density, which was previously initialised with zeros here.
1091        // `converged_map` is all `false`, which is the caller's signal
1092        // that no fits ran.
1093        return Ok(SpatialResult {
1094            density_maps: (0..n_maps)
1095                .map(|_| Array2::from_elem((height, width), f64::NAN))
1096                .collect(),
1097            uncertainty_maps: (0..n_maps)
1098                .map(|_| Array2::from_elem((height, width), f64::NAN))
1099                .collect(),
1100            chi_squared_map: Array2::from_elem((height, width), f64::NAN),
1101            deviance_per_dof_map: if dispatches_to_counts_kl {
1102                Some(Array2::from_elem((height, width), f64::NAN))
1103            } else {
1104                None
1105            },
1106            converged_map: Array2::from_elem((height, width), false),
1107            temperature_map: if config.fit_temperature() {
1108                Some(Array2::from_elem((height, width), f64::NAN))
1109            } else {
1110                None
1111            },
1112            temperature_uncertainty_map: if config.fit_temperature() {
1113                Some(Array2::from_elem((height, width), f64::NAN))
1114            } else {
1115                None
1116            },
1117            isotope_labels,
1118            anorm_map: if has_background_outputs {
1119                Some(Array2::from_elem((height, width), f64::NAN))
1120            } else {
1121                None
1122            },
1123            background_maps: if has_background_outputs {
1124                Some([
1125                    Array2::from_elem((height, width), f64::NAN),
1126                    Array2::from_elem((height, width), f64::NAN),
1127                    Array2::from_elem((height, width), f64::NAN),
1128                ])
1129            } else {
1130                None
1131            },
1132            back_d_map: if has_back_d_map {
1133                Some(Array2::from_elem((height, width), f64::NAN))
1134            } else {
1135                None
1136            },
1137            back_f_map: if has_back_f_map {
1138                Some(Array2::from_elem((height, width), f64::NAN))
1139            } else {
1140                None
1141            },
1142            t0_us_map: if config.fit_energy_scale() {
1143                Some(Array2::from_elem((height, width), f64::NAN))
1144            } else {
1145                None
1146            },
1147            l_scale_map: if config.fit_energy_scale() {
1148                Some(Array2::from_elem((height, width), f64::NAN))
1149            } else {
1150                None
1151            },
1152            energy_scale_flight_path_m: config.fit_energy_scale().then(|| config.flight_path_m()),
1153            // No live pixels → stage 1 never ran, so a FITTED global
1154            // baseline is absent.  A fully FROZEN global baseline involves
1155            // no fitting, though — the caller's inits ARE the baseline —
1156            // so mirror the main path and echo them (review R4: the same
1157            // config must not report Some(inits) on a live map but None on
1158            // an all-dead one).
1159            baseline_global: config
1160                .multiplicative_baseline()
1161                .filter(|bl| bl.spatial_global && !bl.fit_b0 && !bl.fit_b1 && !bl.fit_b2)
1162                .map(|bl| [bl.b0_init, bl.b1_init, bl.b2_init]),
1163            baseline_e_ref_ev,
1164            baseline_maps: if has_baseline_maps {
1165                Some([
1166                    Array2::from_elem((height, width), f64::NAN),
1167                    Array2::from_elem((height, width), f64::NAN),
1168                    Array2::from_elem((height, width), f64::NAN),
1169                ])
1170            } else {
1171                None
1172            },
1173            warnings: degenerate_normalization_warning(config)
1174                .into_iter()
1175                .collect(),
1176            n_converged: 0,
1177            n_total: 0,
1178            n_failed: 0,
1179        });
1180    }
1181
1182    // Reject non-finite / out-of-domain detector-cube VALUES up front —
1183    // before the (potentially multi-GB) transpose below and the shared
1184    // cross-section precompute — so bad input fails with a clear
1185    // `InvalidParameter` instead of being silently sanitised per-pixel.
1186    // `pixel_coords` is non-empty here (the all-dead case returned above), so
1187    // only live pixels are checked.  The mask scopes the transmission /
1188    // uncertainty check to the user's fit-energy range; see
1189    // `validate_spatial_data_values` for the per-cube domains and rationale.
1190    let value_active_mask = nereids_fitting::active_mask::build_active_mask(
1191        config.energies(),
1192        config.fit_energy_range(),
1193    );
1194    validate_spatial_data_values(input, &pixel_coords, value_active_mask.as_deref())?;
1195
1196    // Transpose data to (height, width, n_energies) for cache locality.
1197    let (data_a, data_b, data_c) = match input {
1198        InputData3D::Transmission {
1199            transmission,
1200            uncertainty,
1201        } => {
1202            let a = transmission
1203                .permuted_axes([1, 2, 0])
1204                .as_standard_layout()
1205                .into_owned();
1206            let b = uncertainty
1207                .permuted_axes([1, 2, 0])
1208                .as_standard_layout()
1209                .into_owned();
1210            (a, b, None)
1211        }
1212        InputData3D::Counts {
1213            sample_counts,
1214            open_beam_counts,
1215        } => {
1216            let a = sample_counts
1217                .permuted_axes([1, 2, 0])
1218                .as_standard_layout()
1219                .into_owned();
1220            let b = open_beam_counts
1221                .permuted_axes([1, 2, 0])
1222                .as_standard_layout()
1223                .into_owned();
1224            (a, b, None)
1225        }
1226        InputData3D::CountsWithNuisance {
1227            sample_counts,
1228            flux,
1229            background,
1230        } => {
1231            let a = sample_counts
1232                .permuted_axes([1, 2, 0])
1233                .as_standard_layout()
1234                .into_owned();
1235            let b = flux
1236                .permuted_axes([1, 2, 0])
1237                .as_standard_layout()
1238                .into_owned();
1239            let c = background
1240                .permuted_axes([1, 2, 0])
1241                .as_standard_layout()
1242                .into_owned();
1243            (a, b, Some(c))
1244        }
1245    };
1246
1247    let instrument = config.resolution().map(|r| InstrumentParams {
1248        resolution: r.clone(),
1249    });
1250    let xs: PrecomputedXs = match config.precomputed_cross_sections() {
1251        Some(cached) => cached.clone(),
1252        None => PrecomputedXs::from(broadened_cross_sections_on_working_grid(
1253            config.energies(),
1254            config.resonance_data(),
1255            config.temperature_k(),
1256            instrument.as_ref(),
1257            cancel,
1258        )?),
1259    };
1260
1261    let sigma = if !config.fit_temperature()
1262        && let (Some(di), Some(dr)) = (&config.density_indices, &config.density_ratios)
1263        && xs.sigma.len() == di.len()
1264        && di.len() == dr.len()
1265    {
1266        let n_e = xs.sigma[0].len();
1267        let mut eff = vec![vec![0.0f64; n_e]; n_maps];
1268        for ((&idx, &ratio), member_xs) in di.iter().zip(dr.iter()).zip(xs.sigma.iter()) {
1269            for (j, &sigma) in member_xs.iter().enumerate() {
1270                eff[idx][j] += ratio * sigma;
1271            }
1272        }
1273        Arc::new(eff)
1274    } else {
1275        Arc::clone(&xs.sigma)
1276    };
1277    let xs = PrecomputedXs {
1278        sigma,
1279        layout: xs.layout,
1280    };
1281
1282    let plan_grid: &[f64] = &xs.layout.energies;
1283    let plan_xs: &Arc<Vec<Vec<f64>>> = &xs.sigma;
1284
1285    // The resolution plan once for the shared working grid; the energy-scale
1286    // path rebuilds its grid per trial and takes the non-plan path.
1287    let resolution_plan: Option<Arc<nereids_physics::resolution::ResolutionPlan>> =
1288        if !config.fit_energy_scale() {
1289            match config.resolution() {
1290                Some(res) => build_resolution_plan(plan_grid, res)
1291                    .map_err(|e| {
1292                        PipelineError::Transmission(
1293                            nereids_physics::transmission::TransmissionError::from(e),
1294                        )
1295                    })?
1296                    .map(Arc::new),
1297                None => None,
1298            }
1299        } else {
1300            None
1301        };
1302
1303    // Build the sparse empirical cubature plan (epic #472) when the
1304    // fit is on the k ≥ 2 multi-isotope fixed-calibration path.  The
1305    // plan compiles the exact ResolutionMatrix from the resolution
1306    // plan above, then runs a per-row feasibility LP to collapse each
1307    // row to ≤ `S + k + 1` atoms.  One-shot cost per spatial_map
1308    // call, amortized across every pixel.  Falls back to `None` when:
1309    //   * no resolution plan (Gaussian or missing);
1310    //   * temperature or energy-scale fitting is active (σ / grid
1311    //     can change at runtime, invalidating atoms);
1312    //   * k == 1 (handled by the separate scalar surrogate plan below);
1313    //   * xs is not pre-collapsed to per-group σ (cubature needs the
1314    //     final σ stack, not per-isotope σ × ratios).
1315    // Capture any caller-supplied cubature plan BEFORE the local
1316    // rebuild pathway — the `with_precomputed_cross_sections` setter
1317    // clears `precomputed_sparse_cubature_plan` as a defence against
1318    // stale-XS dispatch, so without this snapshot a plan the caller
1319    // attached via `UnifiedFitConfig::with_precomputed_sparse_cubature_plan`
1320    // would be dropped and lost on every call.
1321    let caller_cubature = config.precomputed_sparse_cubature_plan().cloned();
1322    let sparse_cubature_plan: Option<Arc<nereids_physics::surrogate::SparseEmpiricalCubaturePlan>> =
1323        if !config.fit_temperature()
1324            && !config.fit_energy_scale()
1325            && resolution_plan.is_some()
1326            && plan_xs.len() >= 2
1327        {
1328            let plan = resolution_plan.as_deref().expect("guarded above");
1329            let matrix = plan.compile_to_matrix();
1330            let k = plan_xs.len();
1331            let n_rows = matrix.len();
1332            // Flatten σ (Vec<Vec<f64>> of shape [k][n_rows]) into the
1333            // row-major `sigmas[j * n_rows + ℓ]` layout the cubature
1334            // builder expects.
1335            let mut sigmas_flat = Vec::with_capacity(k * n_rows);
1336            for row in plan_xs.iter() {
1337                if row.len() != n_rows {
1338                    // Shape mismatch — surrender cubature, fall back.
1339                    sigmas_flat.clear();
1340                    break;
1341                }
1342                sigmas_flat.extend_from_slice(row);
1343            }
1344            if sigmas_flat.len() == k * n_rows {
1345                // Invariant pinning: the caller (this function's xs
1346                // assembly above) must have pre-aggregated σ by
1347                // isotope-group ratios so `xs[j]` already stores the
1348                // per-density-param effective σ that the cubature
1349                // builder needs.  If a future refactor inserts a
1350                // different σ mutation after this point, or the
1351                // collapse stops running first, the builder will
1352                // receive wrong σ and this assertion catches it in
1353                // debug builds.
1354                debug_assert_eq!(
1355                    sigmas_flat.len(),
1356                    k * n_rows,
1357                    "cubature σ dimensions: expected {k} × {n_rows} = {}, got {}",
1358                    k * n_rows,
1359                    sigmas_flat.len(),
1360                );
1361                // Training box: 2 × the initial density — same convention
1362                // the design study's reference implementation uses.
1363                // Anchor at the midpoint (0.5 × train_max).
1364                //
1365                let train_max: Vec<f64> = config
1366                    .initial_densities()
1367                    .iter()
1368                    .map(|&n0| 2.0 * n0.max(1e-6))
1369                    .collect();
1370                let training =
1371                nereids_physics::surrogate::SparseEmpiricalCubaturePlan::default_training_points(
1372                    &train_max,
1373                );
1374                let anchor =
1375                nereids_physics::surrogate::SparseEmpiricalCubaturePlan::default_jacobian_anchor(
1376                    &train_max,
1377                );
1378                match nereids_physics::surrogate::SparseEmpiricalCubaturePlan::build(
1379                    &matrix,
1380                    &sigmas_flat,
1381                    k,
1382                    &training,
1383                    &anchor,
1384                ) {
1385                    Ok(plan) => {
1386                        // Record the training box on the plan so
1387                        // the per-pixel dispatch can safely refuse
1388                        // to fire when a fit iterate escapes the
1389                        // trained region — rather than silently
1390                        // running the surrogate out-of-domain.
1391                        Some(Arc::new(plan.with_density_box(train_max.clone())))
1392                    }
1393                    Err(e) => {
1394                        // Surface the build failure to stderr rather
1395                        // than silently swallow it — downstream fits
1396                        // continue via the exact path, but a missing
1397                        // cubature on a supposedly-eligible call is
1398                        // a debugging signal that deserves
1399                        // visibility.
1400                        eprintln!(
1401                            "spatial_map_typed: sparse cubature build failed ({e}); \
1402                             falling back to exact ResolutionPlan path for this call",
1403                        );
1404                        None
1405                    }
1406                }
1407            } else {
1408                None
1409            }
1410        } else {
1411            None
1412        };
1413
1414    // Caller-fallback: if we didn't build a local plan (build
1415    // failed, or conditions weren't met), but the caller supplied
1416    // one that matches the current grid + k, reuse it.  This
1417    // saves the LP build cost on repeat spatial_map calls that
1418    // share the same `(grid, isotope_set, density_box)` and
1419    // preserves explicit `with_precomputed_sparse_cubature_plan`
1420    // attachments across the setter chain below.
1421    let sparse_cubature_plan = sparse_cubature_plan.or_else(|| {
1422        caller_cubature.filter(|p| {
1423            p.len() == plan_grid.len() && p.k() == plan_xs.len() && p.target_energies() == plan_grid
1424        })
1425    });
1426
1427    // Scalar (k = 1) surrogate plan — parallels the cubature build
1428    // but dispatches on `xs.len() == 1` (grouped fits / single-
1429    // isotope).  Reuses the compiled ResolutionMatrix from the
1430    // resolution plan.  Falls back silently on build failure; no
1431    // local plan means the exact `apply_resolution_with_plan` path
1432    // runs as today.  A bench-off compared Lanczos σ-pushforward
1433    // Gauss quadrature and Chebyshev-in-density on real VENUS
1434    // (3471-bin production grid); Chebyshev won on both the
1435    // accuracy (≤ 2e-15 vs ≤ 4e-15) and wall-time axes.  Lanczos
1436    // code was deleted per the issue's "drop the loser" contract;
1437    // this build site now always returns the Chebyshev variant
1438    // via the public `ScalarSurrogatePlan` type alias
1439    // (= `ScalarChebyshevPlan`).
1440    let caller_scalar = config.precomputed_sparse_scalar_plan().cloned();
1441    let sparse_scalar_plan: Option<Arc<nereids_physics::surrogate::ScalarSurrogatePlan>> =
1442        if let Some(plan) = resolution_plan.as_ref()
1443            && !config.fit_temperature()
1444            && !config.fit_energy_scale()
1445            && plan_xs.len() == 1
1446        {
1447            let sigma_row = &plan_xs[0];
1448            // Chebyshev-in-density at M = 16 (bench-off winner).
1449            // Training box: 2 × the initial density;
1450            // Chebyshev's interpolant is exact at its nodes and
1451            // tight (≤ 1e-15 rel err) across a well-chosen box.
1452            //
1453            // If `n_max` is too wide for 16 nodes to resolve
1454            // `exp(-n · σ)` accurately (e.g. caller passes a
1455            // giant `initial_density` on a strong-peak σ), the
1456            // build's midpoint self-check fires and returns
1457            // `InsufficientAccuracyOnBox`; we log and fall back
1458            // to the exact path rather than install a plan that
1459            // could corrupt the fit.
1460            //
1461            const CHEBYSHEV_NODES: usize = 16;
1462            let n_max: f64 = 2.0 * config.initial_densities()[0].max(1e-6);
1463            match nereids_physics::surrogate::ScalarChebyshevPlan::build(
1464                Arc::clone(plan),
1465                sigma_row,
1466                n_max,
1467                CHEBYSHEV_NODES,
1468            ) {
1469                Ok(plan) => Some(Arc::new(plan)),
1470                Err(e) => {
1471                    eprintln!(
1472                        "spatial_map_typed: scalar Chebyshev build failed ({e}); \
1473                         falling back to exact ResolutionPlan path",
1474                    );
1475                    None
1476                }
1477            }
1478        } else {
1479            None
1480        };
1481    // Preserve caller-supplied scalar plan if local build didn't run.
1482    // Grid-identity check uses `to_bits()` per element (matches
1483    // `scalar_eligible` / `cubature_eligible`), not `==`, so `-0.0`
1484    // vs `+0.0` and NaN-bit mismatches can't silently slip through
1485    // the caller-fallback pre-filter.
1486    let sparse_scalar_plan = sparse_scalar_plan.or_else(|| {
1487        caller_scalar.filter(|p| {
1488            if p.len() != plan_grid.len() {
1489                return false;
1490            }
1491            p.target_energies()
1492                .iter()
1493                .zip(plan_grid)
1494                .all(|(a, b)| a.to_bits() == b.to_bits())
1495        })
1496    });
1497
1498    // Precompute unbroadened (base) cross-sections for temperature fitting.
1499    // This avoids 74× overhead from redundant Reich-Moore evaluation per
1500    // KL iteration (112ms Reich-Moore vs 1.5ms Doppler rebroadening).
1501    let fast_config = if config.fit_temperature() {
1502        // Bare `?`: the `From<TransmissionError>` impl maps
1503        // `TransmissionError::Cancelled` to `PipelineError::Cancelled`,
1504        // keeping the documented uniform-Cancelled contract when the user
1505        // cancels during this (expensive) Reich-Moore precompute.  A
1506        // `.map_err(PipelineError::Transmission)` here would bypass that
1507        // conversion and surface cancellation as an error.
1508        let base_xs: Vec<Vec<f64>> =
1509            unbroadened_cross_sections(config.energies(), config.resonance_data(), cancel)?;
1510        let mut cfg = config
1511            .clone()
1512            .with_precomputed_cross_sections(xs.clone())
1513            .with_precomputed_base_xs(Arc::new(base_xs))
1514            .with_compute_covariance(true);
1515        if let Some(plan) = resolution_plan.clone() {
1516            cfg = cfg.with_precomputed_resolution_plan(plan);
1517        }
1518        // Cubature / scalar plans stay None on the temperature path
1519        // (builder guards above).  No-op here but explicit for
1520        // future readers.
1521        cfg
1522    } else {
1523        // For non-temperature path: xs is already collapsed to σ_eff when
1524        // groups are active, so clear group mapping to prevent double-collapse
1525        // inside build_transmission_model.
1526        let mut cfg = config.clone();
1527        if cfg.density_indices.is_some() {
1528            cfg.density_indices = None;
1529            cfg.density_ratios = None;
1530        }
1531        let mut cfg = cfg
1532            .with_precomputed_cross_sections(xs.clone())
1533            .with_compute_covariance(true);
1534        if let Some(plan) = resolution_plan.clone() {
1535            cfg = cfg.with_precomputed_resolution_plan(plan);
1536        }
1537        if let Some(plan) = sparse_cubature_plan.clone() {
1538            cfg = cfg.with_precomputed_sparse_cubature_plan(plan);
1539        }
1540        if let Some(plan) = sparse_scalar_plan.clone() {
1541            cfg = cfg.with_precomputed_sparse_scalar_plan(plan);
1542        }
1543        cfg
1544    };
1545
1546    // Auto-disable Nelder-Mead polish for multi-pixel counts-KL spatial
1547    // maps.  Polish is a single-spectrum
1548    // research knob — on the VENUS Hf 120min aggregated fit it took
1549    // ~1 000 s; at 512 × 512 pixels that is untenable even with rayon.
1550    // Per-pixel fits also rarely hit the over-parameterized stall regime
1551    // polish targets.  The caller can force polish back on via
1552    // [`UnifiedFitConfig::with_counts_enable_polish(Some(true))`].
1553    let fast_config = apply_spatial_polish_default(fast_config, pixel_coords.len());
1554    crate::pipeline::validate_precomputed_cross_sections(&fast_config)?;
1555
1556    // ── Modeling choice: spatially-averaged open-beam flux ──
1557    //
1558    // For `InputData3D::Counts`, every pixel's sample spectrum is paired
1559    // with the **same** open-beam spectrum: the spatial average across
1560    // all live pixels (`pixel_coords`).  This is INTENTIONAL, not a
1561    // per-pixel paired observation.  The rationale:
1562    //
1563    // 1. The open-beam counts `O(E)` are a *reference flux* that is
1564    //    approximately spatially uniform (the sample casts a shadow
1565    //    on an otherwise flat beam profile).  Averaging reduces the
1566    //    shot-noise contamination of the flux estimate by √n_pixels.
1567    // 2. In the joint-Poisson profile-deviance form
1568    //    (`λ̂_i = c·(O_i + S_i) / (1 + c·T_i)`), a noisy per-pixel
1569    //    `O_i` propagates directly into `λ̂_i`, which in turn inflates
1570    //    the deviance without improving density recovery.
1571    //
1572    //
1573    // **If this isn't the right assumption for your data** — e.g. you
1574    // have a genuinely spatially-varying beam profile and pre-estimated
1575    // per-pixel flux + detector-background spectra — use
1576    // [`InputData3D::CountsWithNuisance`] instead.  That variant
1577    // bypasses the averaging and pairs each pixel's sample with the
1578    // caller-supplied per-pixel flux and bg spectra.
1579    //
1580    let averaged_flux: Option<Vec<f64>> = if matches!(input, InputData3D::Counts { .. }) {
1581        let n_e = data_b.shape()[2]; // data_b is transposed: (h, w, n_e)
1582        let mut flux = vec![0.0f64; n_e];
1583        let n_live = pixel_coords.len() as f64;
1584        if n_live > 0.0 {
1585            for &(y, x) in &pixel_coords {
1586                let ob_spectrum = data_b.slice(s![y, x, ..]);
1587                for (e, &v) in ob_spectrum.iter().enumerate() {
1588                    flux[e] += v;
1589                }
1590            }
1591            for v in &mut flux {
1592                *v /= n_live;
1593            }
1594            // Each open-beam bin is individually finite and non-negative
1595            // (validated above), but summing many large finite values can
1596            // still overflow to +inf.  Surface that as the same up-front
1597            // `InvalidParameter` rather than letting a non-finite averaged
1598            // flux degrade silently into all-NaN pixels downstream.
1599            if let Some(e) = flux.iter().position(|v| !v.is_finite()) {
1600                return Err(PipelineError::InvalidParameter(format!(
1601                    "spatially-averaged open-beam flux is non-finite at energy \
1602                     bin e={e} (got {}); summed open-beam counts overflowed. \
1603                     Check the open-beam cube magnitude.",
1604                    flux[e],
1605                )));
1606            }
1607        }
1608        Some(flux)
1609    } else {
1610        None
1611    };
1612    let background_zeros: Vec<f64> = if matches!(input, InputData3D::Counts { .. }) {
1613        vec![0.0f64; data_b.shape()[2]]
1614    } else {
1615        Vec::new()
1616    };
1617
1618    // ── Issue #635: two-stage global multiplicative baseline ──
1619    //
1620    // Surface the degenerate-normalization warning once, up front (spatial
1621    // runs are long; a warning buried after the rayon loop is useless), and
1622    // carry it on the result for GUI / Python consumers.
1623    let warnings: Vec<String> = degenerate_normalization_warning(config)
1624        .into_iter()
1625        .inspect(|w| eprintln!("spatial_map_typed: warning: {w}"))
1626        .collect();
1627
1628    // Stage 1 (global mode): fit the baseline ONCE on the aggregated mean
1629    // spectrum, then FREEZE it into the per-pixel config (the same
1630    // fixed-parameter substrate as frozen densities).  Non-convergence is a
1631    // HARD error: silently falling back to per-pixel baselines would
1632    // reintroduce the +150 K low-count temperature bias the global mode
1633    // exists to remove.
1634    let (fast_config, baseline_global) = match fast_config.multiplicative_baseline().cloned() {
1635        Some(bl) if bl.spatial_global => {
1636            let b_global = if bl.fit_b0 || bl.fit_b1 || bl.fit_b2 {
1637                fit_global_baseline_stage1(
1638                    input,
1639                    &fast_config,
1640                    &data_a,
1641                    &data_b,
1642                    data_c.as_ref(),
1643                    &pixel_coords,
1644                    averaged_flux.as_deref(),
1645                )?
1646            } else {
1647                // Caller froze every coefficient — stage 1 has nothing to
1648                // fit; the frozen inits ARE the global baseline.
1649                [bl.b0_init, bl.b1_init, bl.b2_init]
1650            };
1651            let frozen = MultiplicativeBaselineConfig {
1652                b0_init: b_global[0],
1653                b1_init: b_global[1],
1654                b2_init: b_global[2],
1655                fit_b0: false,
1656                fit_b1: false,
1657                fit_b2: false,
1658                ..bl
1659            };
1660            (
1661                fast_config.with_multiplicative_baseline(frozen),
1662                Some(b_global),
1663            )
1664        }
1665        // Per-pixel mode (or no baseline): pass the config through.
1666        _ => (fast_config, None),
1667    };
1668
1669    // Fit all pixels in parallel
1670    let failed_count = AtomicUsize::new(0);
1671    let results: Vec<((usize, usize), SpectrumFitResult)> = pixel_coords
1672        .par_iter()
1673        .filter_map(|&(y, x)| {
1674            if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
1675                return None;
1676            }
1677
1678            let spectrum_a: Vec<f64> = data_a.slice(s![y, x, ..]).to_vec();
1679
1680            // Build per-pixel 1D InputData
1681            let pixel_input = match input {
1682                InputData3D::Counts { .. } => {
1683                    let ob_spectrum: Vec<f64> = data_b.slice(s![y, x, ..]).to_vec();
1684
1685                    // Sample counts flow through unsanitised: NaN / negative
1686                    // values are rejected up-front by
1687                    // `validate_spatial_data_values`, so the per-pixel
1688                    // `v.max(0.0)` clamp that used to conceal them (and pass a
1689                    // bogus 0 through the joint-Poisson `validate_counts`
1690                    // guard) is gone.
1691                    //
1692                    // Check effective solver: KL uses CountsWithNuisance
1693                    // (averaged flux). The non-KL arm is unreachable for a
1694                    // counts cube now that counts + LM is rejected outright,
1695                    // and is kept only so the match stays total.
1696                    let effective = fast_config.effective_solver(&InputData::Counts {
1697                        sample_counts: spectrum_a.clone(),
1698                        open_beam_counts: ob_spectrum.clone(),
1699                    });
1700                    match effective {
1701                        SolverConfig::PoissonKL(_) => InputData::CountsWithNuisance {
1702                            sample_counts: spectrum_a,
1703                            flux: averaged_flux.as_ref().unwrap().clone(),
1704                            // Raw-count spatial path currently assumes zero
1705                            // detector background unless the caller provides
1706                            // explicit nuisance spectra.
1707                            background: background_zeros.clone(),
1708                        },
1709                        _ => InputData::Counts {
1710                            sample_counts: spectrum_a,
1711                            open_beam_counts: ob_spectrum,
1712                        },
1713                    }
1714                }
1715                InputData3D::CountsWithNuisance { .. } => InputData::CountsWithNuisance {
1716                    // Sample flows through unsanitised — bad values are
1717                    // rejected up-front by `validate_spatial_data_values`.
1718                    sample_counts: spectrum_a,
1719                    flux: data_b.slice(s![y, x, ..]).to_vec(),
1720                    background: data_c
1721                        .as_ref()
1722                        .expect("CountsWithNuisance requires background cube")
1723                        .slice(s![y, x, ..])
1724                        .to_vec(),
1725                },
1726                InputData3D::Transmission { .. } => {
1727                    // Uncertainty flows through unsanitised: a zero / negative
1728                    // / non-finite σ in an active bin is rejected up-front by
1729                    // `validate_spatial_data_values`, so the per-pixel
1730                    // `σ.max(1e-10)` floor (which turned a bad σ into a 1e20
1731                    // maximum-confidence weight) is gone.  This matches the
1732                    // single-spectrum path, which passes σ straight to the LM
1733                    // core (`pipeline::fit_transmission_lm`).
1734                    let spectrum_b: Vec<f64> = data_b.slice(s![y, x, ..]).to_vec();
1735                    InputData::Transmission {
1736                        transmission: spectrum_a,
1737                        uncertainty: spectrum_b,
1738                    }
1739                }
1740            };
1741
1742            let out = match fit_spectrum_validated(&pixel_input, &fast_config) {
1743                Ok(result) => Some(((y, x), result)),
1744                Err(_) => {
1745                    failed_count.fetch_add(1, Ordering::Relaxed);
1746                    None
1747                }
1748            };
1749            if let Some(p) = progress {
1750                p.fetch_add(1, Ordering::Relaxed);
1751            }
1752            out
1753        })
1754        .collect();
1755
1756    // If cancellation was requested at any point, return `Err(Cancelled)` —
1757    // NOT a partial `Ok(SpatialResult)`. The rayon closure stops launching new
1758    // pixel fits once `cancel` is set, so by the time we get here `results`
1759    // holds only the pixels that finished before cancellation; every other
1760    // pixel would be left as a NaN hole, indistinguishable from a genuinely
1761    // failed fit. A non-GUI caller (e.g. the Python binding) has no other
1762    // signal that the map is incomplete, so a partial map is silently wrong.
1763    // The previous `&& results.is_empty()` guard only caught the rare case
1764    // where cancellation beat *every* pixel; mid-run cancellation slipped
1765    // through and produced a partial map.
1766    if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
1767        return Err(PipelineError::Cancelled);
1768    }
1769
1770    // Assemble output maps
1771    let mut density_maps: Vec<Array2<f64>> = (0..n_maps)
1772        .map(|_| Array2::from_elem((height, width), f64::NAN))
1773        .collect();
1774    let mut uncertainty_maps: Vec<Array2<f64>> = (0..n_maps)
1775        .map(|_| Array2::from_elem((height, width), f64::NAN))
1776        .collect();
1777    let mut chi_squared_map = Array2::from_elem((height, width), f64::NAN);
1778    let mut deviance_per_dof_map: Option<Array2<f64>> = if dispatches_to_counts_kl {
1779        Some(Array2::from_elem((height, width), f64::NAN))
1780    } else {
1781        None
1782    };
1783    let mut converged_map = Array2::from_elem((height, width), false);
1784    let mut anorm_map: Option<Array2<f64>> = if has_background_outputs {
1785        Some(Array2::from_elem((height, width), f64::NAN))
1786    } else {
1787        None
1788    };
1789    let mut background_maps: Option<[Array2<f64>; 3]> = if has_background_outputs {
1790        Some([
1791            Array2::from_elem((height, width), f64::NAN),
1792            Array2::from_elem((height, width), f64::NAN),
1793            Array2::from_elem((height, width), f64::NAN),
1794        ])
1795    } else {
1796        None
1797    };
1798    let mut back_d_map: Option<Array2<f64>> = if has_back_d_map {
1799        Some(Array2::from_elem((height, width), f64::NAN))
1800    } else {
1801        None
1802    };
1803    let mut back_f_map: Option<Array2<f64>> = if has_back_f_map {
1804        Some(Array2::from_elem((height, width), f64::NAN))
1805    } else {
1806        None
1807    };
1808    let mut t0_us_map: Option<Array2<f64>> = if config.fit_energy_scale() {
1809        Some(Array2::from_elem((height, width), f64::NAN))
1810    } else {
1811        None
1812    };
1813    let mut l_scale_map: Option<Array2<f64>> = if config.fit_energy_scale() {
1814        Some(Array2::from_elem((height, width), f64::NAN))
1815    } else {
1816        None
1817    };
1818    let mut baseline_maps: Option<[Array2<f64>; 3]> = if has_baseline_maps {
1819        Some([
1820            Array2::from_elem((height, width), f64::NAN),
1821            Array2::from_elem((height, width), f64::NAN),
1822            Array2::from_elem((height, width), f64::NAN),
1823        ])
1824    } else {
1825        None
1826    };
1827    let mut n_converged = 0;
1828    let mut temperature_map: Option<Array2<f64>> = if config.fit_temperature() {
1829        Some(Array2::from_elem((height, width), f64::NAN))
1830    } else {
1831        None
1832    };
1833    let mut temperature_uncertainty_map: Option<Array2<f64>> = if config.fit_temperature() {
1834        Some(Array2::from_elem((height, width), f64::NAN))
1835    } else {
1836        None
1837    };
1838
1839    // Aggregate per-pixel fit results into 2-D maps.
1840    //
1841    // **Only the `converged_map` entry is written unconditionally.**
1842    // All other per-pixel parameter writes are gated on
1843    // `result.converged`, so un-converged pixels keep their initial
1844    // `NaN` value from the allocation above.
1845    //
1846    // Rationale (issue #458 B1/B2): the LM solver's
1847    // `LAMBDA_BREAKOUT` and stagnation paths restore `params` to the
1848    // last-accepted trial step and return `converged = false`.  That
1849    // "last accepted" state can be arbitrarily far from optimal if
1850    // LM walked astray before getting stuck — e.g., on real VENUS
1851    // per-pixel counts with TZERO enabled, LM pins `t0` at the
1852    // ±10 µs bound and lets `density` absorb the drift, producing
1853    // densities 4 orders of magnitude off.  Writing those garbage
1854    // values into the density/t0/L/background maps masked an 8 %
1855    // convergence rate as "map of mostly-sensible numbers with a
1856    // few outliers" rather than "map of NaN holes with a few fits".
1857    //
1858    // NaN-on-failure is also the convention asserted by
1859    // `test_spatial_unconverged_pixels_are_nan`; this block makes
1860    // it hold for *every* non-converged pixel, not only the hard
1861    // failure path.
1862    for ((y, x), result) in &results {
1863        // Always record the convergence flag — this is how callers
1864        // discover that a pixel failed.
1865        converged_map[[*y, *x]] = result.converged;
1866        if !result.converged {
1867            continue;
1868        }
1869
1870        n_converged += 1;
1871
1872        for i in 0..n_maps {
1873            density_maps[i][[*y, *x]] = result.densities[i];
1874            if let Some(ref unc) = result.uncertainties {
1875                uncertainty_maps[i][[*y, *x]] = unc[i];
1876            }
1877        }
1878        chi_squared_map[[*y, *x]] = result.reduced_chi_squared;
1879        if let (Some(dpd), Some(v)) = (&mut deviance_per_dof_map, result.deviance_per_dof) {
1880            dpd[[*y, *x]] = v;
1881        }
1882        if let (Some(t_map), Some(t)) = (&mut temperature_map, result.temperature_k) {
1883            t_map[[*y, *x]] = t;
1884        }
1885        if let (Some(tu_map), Some(tu)) =
1886            (&mut temperature_uncertainty_map, result.temperature_k_unc)
1887        {
1888            tu_map[[*y, *x]] = tu;
1889        }
1890        if let Some(ref mut a_map) = anorm_map {
1891            a_map[[*y, *x]] = result.anorm;
1892        }
1893        if let Some(ref mut bg_maps) = background_maps {
1894            bg_maps[0][[*y, *x]] = result.background[0];
1895            bg_maps[1][[*y, *x]] = result.background[1];
1896            bg_maps[2][[*y, *x]] = result.background[2];
1897        }
1898        // `SpectrumFitResult` carries `back_d` / `back_f` as
1899        // `Option<f64>` — `None` when the bg model never fit the
1900        // exponential tail.  Maps here are only materialised when LM
1901        // actually fit them (gated via `has_back_d_map` /
1902        // `has_back_f_map`), so a converged pixel should always carry
1903        // `Some(value)`.  Fall back to NaN for the rare case of `None`
1904        // at a converged pixel — that surfaces an upstream bug via the
1905        // NaN-on-failure contract rather than a misleading sentinel
1906        // `0.0`.
1907        if let Some(ref mut map) = back_d_map {
1908            map[[*y, *x]] = result.back_d.unwrap_or(f64::NAN);
1909        }
1910        if let Some(ref mut map) = back_f_map {
1911            map[[*y, *x]] = result.back_f.unwrap_or(f64::NAN);
1912        }
1913        if let (Some(map), Some(v)) = (&mut t0_us_map, result.t0_us) {
1914            map[[*y, *x]] = v;
1915        }
1916        if let (Some(map), Some(v)) = (&mut l_scale_map, result.l_scale) {
1917            map[[*y, *x]] = v;
1918        }
1919        // Per-pixel baseline mode (issue #635): each converged pixel
1920        // carries its own fitted coefficients.
1921        if let (Some(maps), Some(b)) = (&mut baseline_maps, result.baseline) {
1922            maps[0][[*y, *x]] = b[0];
1923            maps[1][[*y, *x]] = b[1];
1924            maps[2][[*y, *x]] = b[2];
1925        }
1926    }
1927
1928    Ok(SpatialResult {
1929        density_maps,
1930        uncertainty_maps,
1931        chi_squared_map,
1932        deviance_per_dof_map,
1933        converged_map,
1934        temperature_map,
1935        temperature_uncertainty_map,
1936        isotope_labels,
1937        anorm_map,
1938        background_maps,
1939        back_d_map,
1940        back_f_map,
1941        t0_us_map,
1942        l_scale_map,
1943        energy_scale_flight_path_m: config.fit_energy_scale().then(|| config.flight_path_m()),
1944        baseline_global,
1945        baseline_e_ref_ev,
1946        baseline_maps,
1947        warnings,
1948        n_converged,
1949        n_total: pixel_coords.len(),
1950        n_failed: failed_count.load(Ordering::Relaxed),
1951    })
1952}
1953
1954// ── End Phase 3 ──────────────────────────────────────────────────────────
1955
1956#[cfg(test)]
1957mod tests {
1958    use super::*;
1959    use ndarray::{Array2, Array3};
1960    use nereids_fitting::lm::{FitModel, LmConfig};
1961    use nereids_fitting::poisson::PoissonConfig;
1962    use nereids_fitting::transmission_model::PrecomputedTransmissionModel;
1963
1964    use crate::pipeline::{SolverConfig, UnifiedFitConfig};
1965
1966    fn table_on_data_grid(config: &UnifiedFitConfig, sigma: Vec<Vec<f64>>) -> PrecomputedXs {
1967        PrecomputedXs {
1968            sigma: Arc::new(sigma),
1969            layout: Arc::new(nereids_physics::transmission::WorkingGridLayout::identity(
1970                config.energies(),
1971            )),
1972        }
1973    }
1974    use nereids_endf::resonance::test_support::{
1975        synthetic_single_resonance, u238_single_resonance,
1976    };
1977
1978    /// Build a synthetic transmission stack of shape `(n_e, height, width)`
1979    /// where every pixel holds the same spectrum for a known density.
1980    fn synthetic_grid_transmission(
1981        res_data: &nereids_endf::resonance::ResonanceData,
1982        true_density: f64,
1983        energies: &[f64],
1984        height: usize,
1985        width: usize,
1986    ) -> (Array3<f64>, Array3<f64>) {
1987        let n_e = energies.len();
1988        let xs = nereids_physics::transmission::broadened_cross_sections(
1989            energies,
1990            std::slice::from_ref(res_data),
1991            0.0,
1992            None,
1993            None,
1994        )
1995        .unwrap();
1996        let model = PrecomputedTransmissionModel {
1997            cross_sections: Arc::new(xs),
1998            density_indices: Arc::new(vec![0]),
1999            instrument: None,
2000            resolution_plan: None,
2001            sparse_cubature_plan: None,
2002            sparse_scalar_plan: None,
2003            layout: Arc::new(nereids_physics::transmission::WorkingGridLayout::identity(
2004                energies,
2005            )),
2006        };
2007        let t_1d = model.evaluate(&[true_density]).unwrap();
2008        let sigma_1d: Vec<f64> = t_1d.iter().map(|&v| 0.01 * v.max(0.01)).collect();
2009
2010        let mut t_3d = Array3::zeros((n_e, height, width));
2011        let mut u_3d = Array3::zeros((n_e, height, width));
2012        for y in 0..height {
2013            for x in 0..width {
2014                for (i, (&t, &s)) in t_1d.iter().zip(sigma_1d.iter()).enumerate() {
2015                    t_3d[[i, y, x]] = t;
2016                    u_3d[[i, y, x]] = s;
2017                }
2018            }
2019        }
2020        (t_3d, u_3d)
2021    }
2022
2023    /// Build a 4x4 synthetic transmission stack from known density.
2024    fn synthetic_4x4_transmission(
2025        res_data: &nereids_endf::resonance::ResonanceData,
2026        true_density: f64,
2027        energies: &[f64],
2028    ) -> (Array3<f64>, Array3<f64>) {
2029        synthetic_grid_transmission(res_data, true_density, energies, 4, 4)
2030    }
2031
2032    /// As [`synthetic_4x4_transmission`], with the truth Doppler-broadened
2033    /// at `temperature_k`.
2034    ///
2035    /// A fixture that fits temperature has to put the true value INSIDE the
2036    /// parameter's bounds. Generating truth at 0 K while the fit searches
2037    /// from 1 K upward leaves the optimum outside the search range, so LM
2038    /// pins temperature to the bound and never reports convergence — with
2039    /// every other parameter recovered to six digits.
2040    fn synthetic_4x4_transmission_at(
2041        res_data: &nereids_endf::resonance::ResonanceData,
2042        true_density: f64,
2043        energies: &[f64],
2044        temperature_k: f64,
2045    ) -> (Array3<f64>, Array3<f64>) {
2046        let xs = nereids_physics::transmission::broadened_cross_sections(
2047            energies,
2048            std::slice::from_ref(res_data),
2049            temperature_k,
2050            None,
2051            None,
2052        )
2053        .unwrap();
2054        let n_e = energies.len();
2055        let mut t_3d = Array3::zeros((n_e, 4, 4));
2056        let mut u_3d = Array3::zeros((n_e, 4, 4));
2057        for i in 0..n_e {
2058            let t = (-true_density * xs[0][i]).exp();
2059            for y in 0..4 {
2060                for x in 0..4 {
2061                    t_3d[[i, y, x]] = t;
2062                    u_3d[[i, y, x]] = 0.01;
2063                }
2064            }
2065        }
2066        (t_3d, u_3d)
2067    }
2068
2069    /// Build a 4x4 synthetic counts stack from known density.
2070    fn synthetic_4x4_counts(
2071        res_data: &nereids_endf::resonance::ResonanceData,
2072        true_density: f64,
2073        energies: &[f64],
2074        i0: f64,
2075    ) -> (Array3<f64>, Array3<f64>) {
2076        let (t_3d, _) = synthetic_4x4_transmission(res_data, true_density, energies);
2077        let n_e = energies.len();
2078        let mut sample = Array3::zeros((n_e, 4, 4));
2079        let mut ob = Array3::zeros((n_e, 4, 4));
2080        for y in 0..4 {
2081            for x in 0..4 {
2082                for i in 0..n_e {
2083                    ob[[i, y, x]] = i0;
2084                    sample[[i, y, x]] = (t_3d[[i, y, x]] * i0).round().max(0.0);
2085                }
2086            }
2087        }
2088        (sample, ob)
2089    }
2090
2091    #[test]
2092    fn test_spatial_map_typed_transmission_lm() {
2093        let data = u238_single_resonance();
2094        let true_density = 0.0005;
2095        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2096        let (t_3d, u_3d) = synthetic_4x4_transmission(&data, true_density, &energies);
2097
2098        let config = UnifiedFitConfig::new(
2099            energies,
2100            vec![data],
2101            vec!["U-238".into()],
2102            0.0,
2103            None,
2104            vec![0.001],
2105        )
2106        .unwrap()
2107        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2108
2109        let input = InputData3D::Transmission {
2110            transmission: t_3d.view(),
2111            uncertainty: u_3d.view(),
2112        };
2113
2114        let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2115        assert_eq!(result.n_total, 16);
2116        assert!(result.n_converged >= 14, "Most pixels should converge");
2117
2118        // Check mean density of converged pixels
2119        let d = &result.density_maps[0];
2120        let conv = &result.converged_map;
2121        let mean: f64 = d
2122            .iter()
2123            .zip(conv.iter())
2124            .filter(|(_, c)| **c)
2125            .map(|(d, _)| *d)
2126            .sum::<f64>()
2127            / result.n_converged as f64;
2128        assert!(
2129            (mean - true_density).abs() / true_density < 0.05,
2130            "mean density: {mean}, true: {true_density}"
2131        );
2132    }
2133
2134    #[test]
2135    fn test_spatial_map_typed_counts_kl() {
2136        let data = u238_single_resonance();
2137        let true_density = 0.0005;
2138        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2139        let (sample, ob) = synthetic_4x4_counts(&data, true_density, &energies, 1000.0);
2140
2141        let config = UnifiedFitConfig::new(
2142            energies,
2143            vec![data],
2144            vec!["U-238".into()],
2145            0.0,
2146            None,
2147            vec![0.001],
2148        )
2149        .unwrap()
2150        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
2151
2152        let input = InputData3D::Counts {
2153            sample_counts: sample.view(),
2154            open_beam_counts: ob.view(),
2155        };
2156
2157        let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2158        assert_eq!(result.n_total, 16);
2159        assert!(
2160            result.n_converged >= 14,
2161            "Most pixels should converge with KL"
2162        );
2163
2164        let d = &result.density_maps[0];
2165        let conv = &result.converged_map;
2166        let mean: f64 = d
2167            .iter()
2168            .zip(conv.iter())
2169            .filter(|(_, c)| **c)
2170            .map(|(d, _)| *d)
2171            .sum::<f64>()
2172            / result.n_converged.max(1) as f64;
2173        assert!(
2174            (mean - true_density).abs() / true_density < 0.10,
2175            "KL mean density: {mean}, true: {true_density}"
2176        );
2177    }
2178
2179    /// A caller-supplied precomputed cross-section stack with the wrong shape
2180    /// must be rejected up front (before the rayon loop), not panic on
2181    /// `xs[0]` in the σ_eff collapse / forward-model builder or be swallowed
2182    /// per-pixel as `n_failed`.
2183    #[test]
2184    fn test_spatial_map_rejects_wrong_shape_precomputed_cross_sections() {
2185        let data = u238_single_resonance();
2186        let energies: Vec<f64> = (0..21).map(|i| 1.0 + (i as f64) * 0.1).collect();
2187        let (t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
2188
2189        // 1 isotope → 1 σ row expected; inject 2 rows of the right length.
2190        let n_e = energies.len();
2191        let bad_xs = vec![vec![1.0; n_e], vec![1.0; n_e]];
2192        let config = UnifiedFitConfig::new(
2193            energies,
2194            vec![data],
2195            vec!["U-238".into()],
2196            0.0,
2197            None,
2198            vec![0.001],
2199        )
2200        .unwrap()
2201        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2202        let bad_xs = table_on_data_grid(&config, bad_xs);
2203        let config = config.with_precomputed_cross_sections(bad_xs);
2204
2205        let input = InputData3D::Transmission {
2206            transmission: t_3d.view(),
2207            uncertainty: u_3d.view(),
2208        };
2209
2210        let err = spatial_map_typed(&input, &config, None, None, None)
2211            .expect_err("wrong-shape precomputed XS must be rejected up front");
2212        assert!(
2213            matches!(err, PipelineError::ShapeMismatch(_)),
2214            "expected ShapeMismatch, got {err:?}"
2215        );
2216    }
2217
2218    /// Mid-run cancellation must return `Err(Cancelled)`, not a partial
2219    /// `Ok(SpatialResult)` whose cancelled pixels are left as NaN holes
2220    /// (indistinguishable from genuine fit failures, with no signal to a
2221    /// non-GUI caller that the map is incomplete).
2222    ///
2223    /// The previous post-loop guard only fired when cancellation beat *every*
2224    /// pixel (`results.is_empty()`); a cancellation that lands after the first
2225    /// pixel completes slipped through and produced a partial map.  This test
2226    /// reproduces exactly that: a watcher thread flips `cancel` as soon as the
2227    /// `progress` counter shows the first pixel finished, while the remaining
2228    /// pixels are still fitting.  The pre-loop guard sees `cancel == false`
2229    /// (so it does not short-circuit), pixels complete into `results`, and the
2230    /// post-loop guard then observes `cancel == true` with `results`
2231    /// non-empty.
2232    #[test]
2233    fn test_spatial_map_mid_run_cancellation_returns_err() {
2234        use std::sync::atomic::{AtomicBool, AtomicUsize};
2235
2236        let data = u238_single_resonance();
2237        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2238        // A wide grid: many real LM fits, so the watcher reliably flips
2239        // `cancel` mid-run (after pixel 1, with hundreds of pixels left to
2240        // skip).
2241        //
2242        // Sized in pixels, not seconds, because the per-pixel cost is not
2243        // stable: when the workspace gained `[profile.test] opt-level = 2`
2244        // the sweep got roughly an order of magnitude faster and a 64-pixel
2245        // grid began losing this race outright on the macOS runner — every
2246        // attempt finished before the flip landed. Pixel count is what buys
2247        // the watcher a window, so keep this generous.
2248        let (t_3d, u_3d) = synthetic_grid_transmission(&data, 0.0005, &energies, 1, 768);
2249
2250        let config = UnifiedFitConfig::new(
2251            energies,
2252            vec![data],
2253            vec!["U-238".into()],
2254            0.0,
2255            None,
2256            vec![0.001],
2257        )
2258        .unwrap()
2259        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2260
2261        let input = InputData3D::Transmission {
2262            transmission: t_3d.view(),
2263            uncertainty: u_3d.view(),
2264        };
2265
2266        // The watcher race is inherently lossy: on a fast or oversubscribed
2267        // runner the whole 64-pixel sweep (and the post-loop cancel check)
2268        // can finish before the watcher thread's store becomes visible, in
2269        // which case the run observed no cancellation at all and a COMPLETE
2270        // Ok map is the correct output.  That outcome carries no information
2271        // about the regression under test, so it retries; the regression —
2272        // a PARTIAL Ok map (cancellation observed mid-loop but swallowed) —
2273        // fails immediately on any attempt.
2274        let mut saw_cancelled = false;
2275        for _attempt in 0..5 {
2276            let cancel = AtomicBool::new(false);
2277            let progress = AtomicUsize::new(0);
2278
2279            let watcher_ready = AtomicBool::new(false);
2280
2281            let result = std::thread::scope(|s| {
2282                // Watcher: once at least one pixel has finished, request
2283                // cancellation while the rest are still being fit.
2284                s.spawn(|| {
2285                    watcher_ready.store(true, Ordering::Release);
2286                    while progress.load(Ordering::Relaxed) < 1 {
2287                        // yield instead of spinning: on a fully subscribed
2288                        // CI box a busy-spin can be starved for the whole
2289                        // sweep, losing the race every time.
2290                        std::thread::yield_now();
2291                    }
2292                    cancel.store(true, Ordering::Relaxed);
2293                });
2294                // Do not start the sweep until the watcher is actually
2295                // running. Otherwise thread-spawn latency is charged against
2296                // the race, and a fast sweep can finish before the watcher
2297                // is ever scheduled — which is a property of the runner, not
2298                // of the behaviour under test.
2299                while !watcher_ready.load(Ordering::Acquire) {
2300                    std::thread::yield_now();
2301                }
2302                spatial_map_typed(&input, &config, None, Some(&cancel), Some(&progress))
2303            });
2304
2305            match result {
2306                Err(PipelineError::Cancelled) => {
2307                    saw_cancelled = true;
2308                    break;
2309                }
2310                Ok(r) if r.n_converged == r.n_total && r.n_failed == 0 => {
2311                    // Sweep finished before the flip became visible —
2312                    // inconclusive; try again.
2313                    continue;
2314                }
2315                other => panic!(
2316                    "mid-run cancellation must return Err(Cancelled) (or lose \
2317                     the race with a COMPLETE map), got {other:?}"
2318                ),
2319            }
2320        }
2321        assert!(
2322            saw_cancelled,
2323            "all 5 attempts completed the whole sweep before the cancellation \
2324             flip became visible — enlarge the pixel grid for this runner"
2325        );
2326    }
2327
2328    /// Cancellation during the `fit_temperature` precompute must surface
2329    /// as `Err(Cancelled)`, not `Err(Transmission(Cancelled))`.
2330    ///
2331    /// The expensive Reich-Moore base-XS precompute
2332    /// (`unbroadened_cross_sections`) polls `cancel` internally and
2333    /// returns `TransmissionError::Cancelled`; the documented contract is
2334    /// that every cancellation path yields `PipelineError::Cancelled`
2335    /// (the `From<TransmissionError>` impl performs that mapping — a
2336    /// `.map_err(PipelineError::Transmission)` on the call site would
2337    /// bypass it and turn a clean user cancel into an error toast).
2338    ///
2339    /// Window engineering, so the flip deterministically lands inside
2340    /// the `unbroadened_cross_sections` call rather than some other
2341    /// (already correctly mapped) cancellation poll: the caller supplies
2342    /// precomputed broadened cross-sections, which removes the earlier
2343    /// expensive broadened-XS window entirely, and the energy grid is
2344    /// dense enough that the base-XS precompute takes tens of
2345    /// milliseconds while the watcher flips `cancel` a few ms in.
2346    /// Wherever the flip lands the correct result is `Err(Cancelled)`,
2347    /// so the assertion can never flake — only the discrimination
2348    /// margin varies.  (Mutation-checked: restoring the `map_err` makes
2349    /// this test fail.)
2350    #[test]
2351    fn test_fit_temperature_precompute_cancellation_maps_to_cancelled() {
2352        use std::sync::atomic::AtomicBool;
2353
2354        let data = u238_single_resonance();
2355        // Dense grid: make the base-XS (Reich-Moore) precompute long
2356        // enough that a few-ms cancel lands inside it on any realistic
2357        // machine.
2358        let n_e = 100_001usize;
2359        let energies: Vec<f64> = (0..n_e).map(|i| 1.0 + (i as f64) * 2e-4).collect();
2360        let (t_3d, u_3d) = synthetic_grid_transmission(&data, 0.0005, &energies, 2, 2);
2361
2362        // Caller-supplied broadened XS (values irrelevant — the fit is
2363        // cancelled before any pixel is evaluated) skip the broadened
2364        // precompute, so the only long-running pre-sweep stage left is
2365        // the fit_temperature base-XS precompute under test.
2366        let precomputed_xs = vec![vec![0.0f64; n_e]];
2367
2368        let config = UnifiedFitConfig::new(
2369            energies,
2370            vec![data],
2371            vec!["U-238".into()],
2372            293.6,
2373            None,
2374            vec![0.001],
2375        )
2376        .unwrap()
2377        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
2378        .with_fit_temperature(true);
2379        let precomputed_xs = table_on_data_grid(&config, precomputed_xs);
2380        let config = config.with_precomputed_cross_sections(precomputed_xs);
2381
2382        let input = InputData3D::Transmission {
2383            transmission: t_3d.view(),
2384            uncertainty: u_3d.view(),
2385        };
2386
2387        let cancel = AtomicBool::new(false);
2388        let result = std::thread::scope(|s| {
2389            s.spawn(|| {
2390                std::thread::sleep(std::time::Duration::from_millis(5));
2391                cancel.store(true, Ordering::Relaxed);
2392            });
2393            spatial_map_typed(&input, &config, None, Some(&cancel), None)
2394        });
2395
2396        assert!(
2397            matches!(result, Err(PipelineError::Cancelled)),
2398            "cancellation during the fit_temperature precompute must map to \
2399             Err(Cancelled), got {result:?}"
2400        );
2401    }
2402
2403    /// Build a minimal synthetic tabulated resolution kernel.  Two
2404    /// reference energies × a 5-point triangular offset-weight block
2405    /// is enough to exercise the plan build + apply hot path without
2406    /// pulling in the external VENUS resolution file.
2407    ///
2408    /// The kernel width is deliberately small (sub-microsecond) so
2409    /// broadening perturbs a non-broadened synthetic spectrum only
2410    /// slightly — keeps the spatial fit in its convergence basin
2411    /// without building a full R⊗T forward pass into the test
2412    /// fixture.
2413    fn synthetic_tabulated_text() -> String {
2414        // File format (parsed by TabulatedResolution::from_text):
2415        //   header line
2416        //   separator line
2417        //   for each block: energy marker line, then N offset/weight
2418        //   pairs, then a blank line between blocks.
2419        "header\n---\n\
2420         5.0 0.0\n\
2421         -0.01 0.0\n\
2422         -0.005 0.5\n\
2423         0.0 1.0\n\
2424         0.005 0.5\n\
2425         0.01 0.0\n\
2426         \n\
2427         200.0 0.0\n\
2428         -0.02 0.0\n\
2429         -0.01 0.5\n\
2430         0.0 1.0\n\
2431         0.01 0.5\n\
2432         0.02 0.0\n"
2433            .to_string()
2434    }
2435
2436    /// Gate: end-to-end smoke + determinism test for the per-pixel
2437    /// spatial path with an attached resolution plan (tabulated
2438    /// kernel).  Asserts that `spatial_map_typed` runs to
2439    /// completion, most pixels converge, the recovered mean density
2440    /// is sensible on the synthetic fixture, and every converged
2441    /// pixel in the 4×4 crop produces a bit-identical density (no
2442    /// plan-cache state leaks across the rayon fanout).
2443    ///
2444    /// Exact `apply_resolution` / `apply_resolution_with_plan`
2445    /// equivalence is covered bit-for-bit by the unit tests in
2446    /// `resolution.rs`; this spatial test only confirms that plan
2447    /// attachment does not disturb the higher-level dispatch.
2448    #[test]
2449    fn test_spatial_map_typed_with_resolution_plan_converges_and_is_deterministic() {
2450        use nereids_physics::resolution::{ResolutionFunction, TabulatedResolution};
2451
2452        let data = u238_single_resonance();
2453        let true_density = 0.0005;
2454        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2455        let (t_3d, u_3d) = synthetic_4x4_transmission(&data, true_density, &energies);
2456
2457        let tab = TabulatedResolution::from_text(&synthetic_tabulated_text(), 25.0).unwrap();
2458        let resolution = ResolutionFunction::Tabulated(Arc::new(tab));
2459
2460        let config = UnifiedFitConfig::new(
2461            energies.clone(),
2462            vec![data.clone()],
2463            vec!["U-238".into()],
2464            0.0,
2465            Some(resolution),
2466            vec![0.001],
2467        )
2468        .unwrap()
2469        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2470
2471        let input = InputData3D::Transmission {
2472            transmission: t_3d.view(),
2473            uncertainty: u_3d.view(),
2474        };
2475
2476        let result_with_plan = spatial_map_typed(&input, &config, None, None, None).unwrap();
2477        assert_eq!(result_with_plan.n_total, 16);
2478        assert!(
2479            result_with_plan.n_converged >= 14,
2480            "plan path: {} / 16 pixels converged",
2481            result_with_plan.n_converged,
2482        );
2483
2484        let d = &result_with_plan.density_maps[0];
2485        let conv = &result_with_plan.converged_map;
2486        let mean: f64 = d
2487            .iter()
2488            .zip(conv.iter())
2489            .filter(|(_, c)| **c)
2490            .map(|(d, _)| *d)
2491            .sum::<f64>()
2492            / result_with_plan.n_converged.max(1) as f64;
2493        assert!(
2494            (mean - true_density).abs() / true_density < 0.10,
2495            "mean density with plan: {mean}, true: {true_density}"
2496        );
2497
2498        // Every converged pixel in the 4x4 crop shares the identical
2499        // input spectrum, so every density-map entry must be bit-
2500        // equal to every other converged entry.  This catches any
2501        // plan-cache corruption that would leak pixel-specific state
2502        // across the rayon fanout.
2503        let reference = d
2504            .iter()
2505            .zip(conv.iter())
2506            .find(|(_, c)| **c)
2507            .map(|(d, _)| *d)
2508            .expect("at least one pixel converged");
2509        for (&cell, &c) in d.iter().zip(conv.iter()) {
2510            if c {
2511                assert_eq!(
2512                    cell.to_bits(),
2513                    reference.to_bits(),
2514                    "plan cache leaked pixel-specific state: density cell {cell} != reference {reference}"
2515                );
2516            }
2517        }
2518    }
2519
2520    #[test]
2521    fn test_spatial_map_typed_gaussian_aux_grid_recovers_density() {
2522        use nereids_physics::resolution::{ResolutionFunction, ResolutionParams};
2523        use nereids_physics::transmission::{SampleParams, forward_model};
2524
2525        let data = u238_single_resonance(); // resonance @ ~6.674 eV
2526        let true_density = 0.0005;
2527        let temperature = 300.0;
2528        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2529        let inst = Arc::new(InstrumentParams {
2530            resolution: ResolutionFunction::Gaussian(
2531                ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2532            ),
2533        });
2534
2535        // Synthetic data CONSISTENT with a Gaussian-broadened forward model, so
2536        // the fit (which also broadens on the aux grid) can recover the density.
2537        let sample = SampleParams::new(temperature, vec![(data.clone(), true_density)]).unwrap();
2538        let t_1d = forward_model(&energies, &sample, Some(&inst)).unwrap();
2539
2540        // ‖kernel − none‖ non-vacuity: the Gaussian must broaden the spectrum,
2541        // else the aux-grid path is a no-op and the test is vacuous.
2542        let t_none = forward_model(&energies, &sample, None).unwrap();
2543        let broaden = t_1d
2544            .iter()
2545            .zip(t_none.iter())
2546            .map(|(a, b)| (a - b).abs())
2547            .fold(0.0f64, f64::max);
2548        assert!(
2549            broaden > 1e-4,
2550            "Gaussian kernel must broaden the spectrum non-trivially (got {broaden:.3e})"
2551        );
2552
2553        // Replicate to a 4x4 cube — identical pixels double as a determinism check.
2554        let n_e = energies.len();
2555        let sigma_1d: Vec<f64> = t_1d.iter().map(|&v| 0.01 * v.max(0.01)).collect();
2556        let mut t_3d = Array3::zeros((n_e, 4, 4));
2557        let mut u_3d = Array3::zeros((n_e, 4, 4));
2558        for y in 0..4 {
2559            for x in 0..4 {
2560                for (i, (&t, &s)) in t_1d.iter().zip(sigma_1d.iter()).enumerate() {
2561                    t_3d[[i, y, x]] = t;
2562                    u_3d[[i, y, x]] = s;
2563                }
2564            }
2565        }
2566
2567        let config = UnifiedFitConfig::new(
2568            energies.clone(),
2569            vec![data],
2570            vec!["U-238".into()],
2571            temperature,
2572            Some(ResolutionFunction::Gaussian(
2573                ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2574            )),
2575            vec![0.001],
2576        )
2577        .unwrap()
2578        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2579
2580        let input = InputData3D::Transmission {
2581            transmission: t_3d.view(),
2582            uncertainty: u_3d.view(),
2583        };
2584        let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2585        assert_eq!(result.n_total, 16);
2586        assert!(
2587            result.n_converged >= 14,
2588            "Gaussian aux-grid path: {} / 16 pixels converged",
2589            result.n_converged,
2590        );
2591
2592        // Per-pixel density recovery against the forward_model-generated synthetic.
2593        let d = &result.density_maps[0];
2594        let conv = &result.converged_map;
2595        let mean: f64 = d
2596            .iter()
2597            .zip(conv.iter())
2598            .filter(|(_, c)| **c)
2599            .map(|(d, _)| *d)
2600            .sum::<f64>()
2601            / result.n_converged.max(1) as f64;
2602        assert!(
2603            (mean - true_density).abs() / true_density < 0.10,
2604            "Gaussian aux-grid mean density: {mean}, true: {true_density}"
2605        );
2606
2607        // Determinism: identical pixels ⇒ bit-equal density across the rayon
2608        // fanout (catches aux-grid work-σ / layout state leaking across pixels).
2609        let reference = d
2610            .iter()
2611            .zip(conv.iter())
2612            .find(|(_, c)| **c)
2613            .map(|(d, _)| *d)
2614            .expect("at least one pixel converged");
2615        for (&cell, &c) in d.iter().zip(conv.iter()) {
2616            if c {
2617                assert_eq!(
2618                    cell.to_bits(),
2619                    reference.to_bits(),
2620                    "aux-grid path leaked pixel-specific state: density cell {cell} != reference {reference}"
2621                );
2622            }
2623        }
2624    }
2625
2626    #[test]
2627    fn test_spatial_map_typed_gaussian_aux_grid_with_precomputed_sigma() {
2628        use nereids_physics::resolution::{ResolutionFunction, ResolutionParams};
2629        use nereids_physics::transmission::{SampleParams, forward_model};
2630
2631        let data = u238_single_resonance();
2632        let true_density = 0.0005;
2633        let temperature = 300.0;
2634        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2635        let inst = Arc::new(InstrumentParams {
2636            resolution: ResolutionFunction::Gaussian(
2637                ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2638            ),
2639        });
2640        let sample = SampleParams::new(temperature, vec![(data.clone(), true_density)]).unwrap();
2641        let t_1d = forward_model(&energies, &sample, Some(&inst)).unwrap();
2642        let n_e = energies.len();
2643        let sigma_1d: Vec<f64> = t_1d.iter().map(|&v| 0.01 * v.max(0.01)).collect();
2644        let mut t_3d = Array3::zeros((n_e, 4, 4));
2645        let mut u_3d = Array3::zeros((n_e, 4, 4));
2646        for y in 0..4 {
2647            for x in 0..4 {
2648                for (i, (&t, &s)) in t_1d.iter().zip(sigma_1d.iter()).enumerate() {
2649                    t_3d[[i, y, x]] = t;
2650                    u_3d[[i, y, x]] = s;
2651                }
2652            }
2653        }
2654        let resolution =
2655            ResolutionFunction::Gaussian(ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap());
2656        let working = broadened_cross_sections_on_working_grid(
2657            &energies,
2658            std::slice::from_ref(&data),
2659            temperature,
2660            Some(&InstrumentParams {
2661                resolution: resolution.clone(),
2662            }),
2663            None,
2664        )
2665        .unwrap();
2666        let mut working = working;
2667        for row in &mut working.sigma {
2668            for s in row.iter_mut() {
2669                *s *= 2.0;
2670            }
2671        }
2672        let config = UnifiedFitConfig::new(
2673            energies,
2674            vec![data],
2675            vec!["U-238".into()],
2676            temperature,
2677            Some(resolution),
2678            vec![0.001],
2679        )
2680        .unwrap()
2681        .with_precomputed_cross_sections(PrecomputedXs::from(working))
2682        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2683        let input = InputData3D::Transmission {
2684            transmission: t_3d.view(),
2685            uncertainty: u_3d.view(),
2686        };
2687        let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2688        assert_eq!(result.n_total, 16);
2689        assert!(
2690            result.n_converged >= 14,
2691            "Some(cached)+aux path: {} / 16 pixels converged",
2692            result.n_converged,
2693        );
2694        let d = &result.density_maps[0];
2695        let conv = &result.converged_map;
2696        let mean: f64 = d
2697            .iter()
2698            .zip(conv.iter())
2699            .filter(|(_, c)| **c)
2700            .map(|(d, _)| *d)
2701            .sum::<f64>()
2702            / result.n_converged.max(1) as f64;
2703        let expected = 0.5 * true_density;
2704        assert!(
2705            (mean - expected).abs() / expected < 0.10,
2706            "a table of 2σ must halve the fitted density: mean {mean}, expected {expected}"
2707        );
2708    }
2709
2710    #[test]
2711    fn test_spatial_map_typed_rejects_data_grid_table_under_gaussian() {
2712        use nereids_physics::resolution::{ResolutionFunction, ResolutionParams};
2713
2714        let data = u238_single_resonance();
2715        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2716        let (t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
2717        let resolution =
2718            ResolutionFunction::Gaussian(ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap());
2719        let working = nereids_physics::transmission::resolution_working_grid(
2720            &energies,
2721            Some(&InstrumentParams {
2722                resolution: resolution.clone(),
2723            }),
2724            &[&data],
2725        )
2726        .unwrap();
2727        assert!(!working.is_identity());
2728        let on_data_grid = nereids_physics::transmission::broadened_cross_sections(
2729            &energies,
2730            std::slice::from_ref(&data),
2731            300.0,
2732            None,
2733            None,
2734        )
2735        .unwrap();
2736        let config = UnifiedFitConfig::new(
2737            energies,
2738            vec![data],
2739            vec!["U-238".into()],
2740            300.0,
2741            Some(resolution),
2742            vec![0.001],
2743        )
2744        .unwrap();
2745        let config = config
2746            .clone()
2747            .with_precomputed_cross_sections(table_on_data_grid(&config, on_data_grid));
2748        let input = InputData3D::Transmission {
2749            transmission: t_3d.view(),
2750            uncertainty: u_3d.view(),
2751        };
2752        let err = spatial_map_typed(&input, &config, None, None, None).unwrap_err();
2753        assert!(matches!(err, PipelineError::ShapeMismatch(_)), "{err:?}");
2754        assert!(err.to_string().contains("is not the working grid"), "{err}");
2755    }
2756
2757    #[test]
2758    fn test_spatial_map_typed_counts_kl_low_counts() {
2759        // I0=10: the regime where KL excels
2760        let data = u238_single_resonance();
2761        let true_density = 0.0005;
2762        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2763        let (sample, ob) = synthetic_4x4_counts(&data, true_density, &energies, 10.0);
2764
2765        let config = UnifiedFitConfig::new(
2766            energies,
2767            vec![data],
2768            vec!["U-238".into()],
2769            0.0,
2770            None,
2771            vec![0.001],
2772        )
2773        .unwrap(); // Auto solver → KL for counts
2774
2775        let input = InputData3D::Counts {
2776            sample_counts: sample.view(),
2777            open_beam_counts: ob.view(),
2778        };
2779
2780        let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2781        assert_eq!(result.n_total, 16);
2782        // At I0=10, KL should still converge for most pixels
2783        assert!(
2784            result.n_converged >= 10,
2785            "KL at I0=10: only {}/{} converged",
2786            result.n_converged,
2787            result.n_total
2788        );
2789    }
2790
2791    #[test]
2792    fn test_spatial_map_typed_dead_pixels() {
2793        let data = u238_single_resonance();
2794        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
2795        let (t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
2796
2797        let config = UnifiedFitConfig::new(
2798            energies,
2799            vec![data],
2800            vec!["U-238".into()],
2801            0.0,
2802            None,
2803            vec![0.001],
2804        )
2805        .unwrap();
2806
2807        // Mask half the pixels as dead
2808        let mut dead = Array2::from_elem((4, 4), false);
2809        for y in 0..2 {
2810            for x in 0..4 {
2811                dead[[y, x]] = true;
2812            }
2813        }
2814
2815        let input = InputData3D::Transmission {
2816            transmission: t_3d.view(),
2817            uncertainty: u_3d.view(),
2818        };
2819
2820        let result = spatial_map_typed(&input, &config, Some(&dead), None, None).unwrap();
2821        assert_eq!(result.n_total, 8, "Only 8 live pixels");
2822    }
2823
2824    /// Counts-KL + `fit_alpha_2=true` (and the symmetric `fit_alpha_1`
2825    /// case) is a whole-config rejection that fires identically on
2826    /// every pixel.  Previously this test codified the silent swallow:
2827    /// the spatial layer returned `Ok(SpatialResult)` with `n_failed =
2828    /// n_total` and an all-NaN density map, hiding the actionable
2829    /// `joint-Poisson does not support fit_alpha_*` diagnostic from
2830    /// the caller.  After the preflight hoist, the spatial call
2831    /// surfaces the same `Err(InvalidParameter)` the single-spectrum
2832    /// fitter would have raised — Python maps it to `PyValueError`.
2833    #[test]
2834    fn test_spatial_map_rejects_counts_kl_alpha_up_front() {
2835        let data = u238_single_resonance();
2836        let true_density = 0.0005;
2837        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
2838        let (sample, ob) = synthetic_4x4_counts(&data, true_density, &energies, 1000.0);
2839
2840        let config = UnifiedFitConfig::new(
2841            energies,
2842            vec![data],
2843            vec!["U-238".into()],
2844            0.0,
2845            None,
2846            vec![0.001],
2847        )
2848        .unwrap()
2849        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
2850        .with_counts_background(crate::pipeline::CountsBackgroundConfig {
2851            alpha_1_init: 1.0,
2852            alpha_2_init: 1.0,
2853            fit_alpha_1: false,
2854            fit_alpha_2: true,
2855            c: 1.0,
2856        });
2857
2858        let input = InputData3D::Counts {
2859            sample_counts: sample.view(),
2860            open_beam_counts: ob.view(),
2861        };
2862
2863        let err = spatial_map_typed(&input, &config, None, None, None)
2864            .expect_err("counts-KL with fit_alpha_2 must be rejected up-front");
2865        let msg = err.to_string();
2866        assert!(
2867            matches!(err, PipelineError::InvalidParameter(_)),
2868            "expected InvalidParameter, got {err:?}"
2869        );
2870        assert!(
2871            msg.contains("fit_alpha_1") || msg.contains("fit_alpha_2"),
2872            "error must name the offending flag, got: {msg}"
2873        );
2874    }
2875
2876    /// Spatial map with isotope groups: 2 isotopes in 1 group on a 2×2 grid.
2877    /// Verifies group-level density recovery and that only 1 density map is returned.
2878    #[test]
2879    fn test_spatial_map_grouped() {
2880        let rd1 = synthetic_single_resonance(92, 235, 233.025, 5.0);
2881        let rd2 = synthetic_single_resonance(92, 238, 236.006, 7.0);
2882
2883        let iso1 = nereids_core::types::Isotope::new(92, 235).unwrap();
2884        let iso2 = nereids_core::types::Isotope::new(92, 238).unwrap();
2885        let group = nereids_core::types::IsotopeGroup::custom(
2886            "U (60/40)".into(),
2887            vec![(iso1, 0.6), (iso2, 0.4)],
2888        )
2889        .unwrap();
2890
2891        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
2892        let n_e = energies.len();
2893        let true_density = 0.0005;
2894
2895        // Generate synthetic transmission for the group
2896        let sample = nereids_physics::transmission::SampleParams::new(
2897            0.0,
2898            vec![
2899                (rd1.clone(), true_density * 0.6),
2900                (rd2.clone(), true_density * 0.4),
2901            ],
2902        )
2903        .unwrap();
2904        let t_1d = nereids_physics::transmission::forward_model(&energies, &sample, None).unwrap();
2905        let s_1d: Vec<f64> = t_1d.iter().map(|&v| 0.01 * v.max(0.01)).collect();
2906
2907        // Fill 2×2 grid
2908        let mut t_3d = Array3::zeros((n_e, 2, 2));
2909        let mut u_3d = Array3::zeros((n_e, 2, 2));
2910        for y in 0..2 {
2911            for x in 0..2 {
2912                for (i, (&t, &s)) in t_1d.iter().zip(s_1d.iter()).enumerate() {
2913                    t_3d[[i, y, x]] = t;
2914                    u_3d[[i, y, x]] = s;
2915                }
2916            }
2917        }
2918
2919        let config = UnifiedFitConfig::new(
2920            energies,
2921            vec![rd1.clone()],
2922            vec!["placeholder".into()],
2923            0.0,
2924            None,
2925            vec![0.001],
2926        )
2927        .unwrap()
2928        .with_groups(&[(&group, &[rd1, rd2])], vec![0.001])
2929        .unwrap()
2930        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2931
2932        let input = InputData3D::Transmission {
2933            transmission: t_3d.view(),
2934            uncertainty: u_3d.view(),
2935        };
2936
2937        let result = spatial_map_typed(&input, &config, None, None, None).unwrap();
2938
2939        // Should have 1 density map (1 group), not 2
2940        assert_eq!(
2941            result.density_maps.len(),
2942            1,
2943            "should have 1 group density map"
2944        );
2945        assert_eq!(result.isotope_labels, vec!["U (60/40)"]);
2946        assert_eq!(result.n_total, 4);
2947
2948        // All pixels should recover true density within 5%
2949        for y in 0..2 {
2950            for x in 0..2 {
2951                let fitted = result.density_maps[0][[y, x]];
2952                let rel_error = (fitted - true_density).abs() / true_density;
2953                assert!(
2954                    rel_error < 0.05,
2955                    "pixel ({y},{x}): fitted={fitted}, true={true_density}, rel_error={rel_error}"
2956                );
2957            }
2958        }
2959    }
2960
2961    // ── Phase 3: Spatial uncertainty propagation tests ──────────────────────
2962
2963    /// Spatial LM transmission fit populates density uncertainty maps.
2964    #[test]
2965    fn test_spatial_lm_populates_density_uncertainty() {
2966        let rd = u238_single_resonance();
2967        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
2968        let (mut t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
2969        // Add deterministic pseudo-noise so reduced chi-squared > 0
2970        // (a perfect fit gives chi2r=0, zeroing covariance).
2971        for y in 0..4 {
2972            for x in 0..4 {
2973                for e in 0..energies.len() {
2974                    let noise = 0.002 * ((e * 7 + y * 13 + x * 29) % 17) as f64 / 17.0 - 0.001;
2975                    t_3d[[e, y, x]] = (t_3d[[e, y, x]] + noise).max(0.001);
2976                }
2977            }
2978        }
2979        let data = InputData3D::Transmission {
2980            transmission: t_3d.view(),
2981            uncertainty: u_3d.view(),
2982        };
2983        let config = UnifiedFitConfig::new(
2984            energies,
2985            vec![rd],
2986            vec!["U-238".into()],
2987            0.0,
2988            None,
2989            vec![0.0005],
2990        )
2991        .unwrap()
2992        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2993
2994        let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
2995        assert!(result.n_converged > 0, "some pixels should converge");
2996        // Uncertainty maps should have finite positive values for converged pixels.
2997        let unc_map = &result.uncertainty_maps[0];
2998        let conv_map = &result.converged_map;
2999        let mut n_finite = 0;
3000        for y in 0..4 {
3001            for x in 0..4 {
3002                if conv_map[[y, x]] {
3003                    let u = unc_map[[y, x]];
3004                    assert!(
3005                        u.is_finite() && u > 0.0,
3006                        "LM density unc at ({y},{x}) should be finite+positive, got {u}"
3007                    );
3008                    n_finite += 1;
3009                }
3010            }
3011        }
3012        assert!(
3013            n_finite > 0,
3014            "at least one converged pixel should have finite unc"
3015        );
3016    }
3017
3018    /// Spatial KL counts fit populates density uncertainty maps.
3019    #[test]
3020    fn test_spatial_kl_populates_density_uncertainty() {
3021        let rd = u238_single_resonance();
3022        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3023        let (t_3d, _) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3024        // Convert to counts: OB=1000, sample = OB * T
3025        let ob_3d = Array3::from_elem(t_3d.raw_dim(), 1000.0);
3026        let sample_3d = &t_3d * &ob_3d;
3027        let data = InputData3D::Counts {
3028            sample_counts: sample_3d.view(),
3029            open_beam_counts: ob_3d.view(),
3030        };
3031        let config = UnifiedFitConfig::new(
3032            energies,
3033            vec![rd],
3034            vec!["U-238".into()],
3035            0.0,
3036            None,
3037            vec![0.0005],
3038        )
3039        .unwrap()
3040        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
3041
3042        let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3043        assert!(result.n_converged > 0);
3044        let unc_map = &result.uncertainty_maps[0];
3045        let conv_map = &result.converged_map;
3046        let mut n_finite = 0;
3047        for y in 0..4 {
3048            for x in 0..4 {
3049                if conv_map[[y, x]] {
3050                    let u = unc_map[[y, x]];
3051                    assert!(
3052                        u.is_finite() && u > 0.0,
3053                        "KL density unc at ({y},{x}) should be finite+positive, got {u}"
3054                    );
3055                    n_finite += 1;
3056                }
3057            }
3058        }
3059        assert!(n_finite > 0);
3060    }
3061
3062    /// Spatial temperature-fitting populates temperature_uncertainty_map.
3063    #[test]
3064    fn test_spatial_temperature_uncertainty_map() {
3065        let rd = u238_single_resonance();
3066        let energies: Vec<f64> = (0..101).map(|i| 4.0 + (i as f64) * 0.05).collect();
3067        let (mut t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3068        // Add pseudo-noise for nonzero chi2r.
3069        for y in 0..4 {
3070            for x in 0..4 {
3071                for e in 0..energies.len() {
3072                    let noise = 0.002 * ((e * 7 + y * 13 + x * 29) % 17) as f64 / 17.0 - 0.001;
3073                    t_3d[[e, y, x]] = (t_3d[[e, y, x]] + noise).max(0.001);
3074                }
3075            }
3076        }
3077        let data = InputData3D::Transmission {
3078            transmission: t_3d.view(),
3079            uncertainty: u_3d.view(),
3080        };
3081        let config = UnifiedFitConfig::new(
3082            energies,
3083            vec![rd],
3084            vec!["U-238".into()],
3085            300.0,
3086            None,
3087            vec![0.0005],
3088        )
3089        .unwrap()
3090        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
3091        .with_fit_temperature(true);
3092
3093        let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3094        assert!(result.temperature_map.is_some());
3095        let tu_map = result
3096            .temperature_uncertainty_map
3097            .as_ref()
3098            .expect("temperature_uncertainty_map should be Some when fit_temperature=true");
3099        assert_eq!(tu_map.shape(), [4, 4]);
3100        // At least some converged pixels should have finite temperature uncertainty.
3101        let mut n_finite = 0;
3102        for y in 0..4 {
3103            for x in 0..4 {
3104                if result.converged_map[[y, x]] {
3105                    let tu = tu_map[[y, x]];
3106                    if tu.is_finite() && tu > 0.0 {
3107                        n_finite += 1;
3108                    }
3109                }
3110            }
3111        }
3112        assert!(
3113            n_finite > 0,
3114            "at least one converged pixel should have finite temperature uncertainty"
3115        );
3116    }
3117
3118    /// Unconverged pixels remain NaN across **every** output map
3119    /// (density, uncertainty, chi², t0, l_scale, temperature, anorm,
3120    /// background) — not just uncertainty.  Issue #458 B1/B2:
3121    /// previously, failed LM fits that restored to their last-accepted
3122    /// trial step wrote those drifted parameter values into the maps
3123    /// with `converged=false`, producing a "4096 pixels with sensible
3124    /// densities, 92 % of which are converged=false" result that
3125    /// masked catastrophic fit failure.
3126    #[test]
3127    fn test_spatial_unconverged_pixels_are_nan() {
3128        let rd = u238_single_resonance();
3129        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3130        // Pick a deliberately wrong initial density (100× true) and cap
3131        // LM at one iteration so the fit MUST return with
3132        // `converged=false` and `params = last_walked_step` ≠ initial.
3133        // This mimics the real-world pattern the bug produced: a fit
3134        // that walked partway toward the optimum, then ran out of
3135        // iterations.
3136        let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3137        let data = InputData3D::Transmission {
3138            transmission: t_3d.view(),
3139            uncertainty: u_3d.view(),
3140        };
3141        let config = UnifiedFitConfig::new(
3142            energies,
3143            vec![rd],
3144            vec!["U-238".into()],
3145            0.0,
3146            None,
3147            vec![0.1], // 100× true — LM can't reach optimum in 1 iter.
3148        )
3149        .unwrap()
3150        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
3151            max_iter: 1,
3152            ..Default::default()
3153        }))
3154        .with_transmission_background(crate::pipeline::BackgroundConfig::default());
3155
3156        let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3157
3158        // At least one pixel must fail to converge under this setup —
3159        // the point of the test is to verify NaN-on-failure for the
3160        // aggregation path, so we locate an unconverged pixel and
3161        // check every map at that pixel.
3162        let unconverged_pixel = (0..4)
3163            .flat_map(|y| (0..4).map(move |x| (y, x)))
3164            .find(|(y, x)| !result.converged_map[[*y, *x]]);
3165        let (uy, ux) = match unconverged_pixel {
3166            Some(p) => p,
3167            None => panic!(
3168                "every pixel converged in max_iter=1 + 100×-off initial density setup — \
3169                 test is no longer exercising the un-converged aggregation path; \
3170                 tighten the setup (larger offset or fewer iterations)"
3171            ),
3172        };
3173
3174        // Every output map must be NaN at that pixel.
3175        for (i, m) in result.density_maps.iter().enumerate() {
3176            let v = m[[uy, ux]];
3177            assert!(
3178                v.is_nan(),
3179                "density_maps[{i}] at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3180            );
3181        }
3182        for (i, m) in result.uncertainty_maps.iter().enumerate() {
3183            let v = m[[uy, ux]];
3184            assert!(
3185                v.is_nan(),
3186                "uncertainty_maps[{i}] at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3187            );
3188        }
3189        let chi2 = result.chi_squared_map[[uy, ux]];
3190        assert!(
3191            chi2.is_nan(),
3192            "chi_squared_map at unconverged pixel ({uy},{ux}) must be NaN, got {chi2}"
3193        );
3194        if let Some(ref a_map) = result.anorm_map {
3195            let v = a_map[[uy, ux]];
3196            assert!(
3197                v.is_nan(),
3198                "anorm_map at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3199            );
3200        }
3201        if let Some(ref bg) = result.background_maps {
3202            for (i, m) in bg.iter().enumerate() {
3203                let v = m[[uy, ux]];
3204                assert!(
3205                    v.is_nan(),
3206                    "background_maps[{i}] at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3207                );
3208            }
3209        }
3210        if let Some(ref m) = result.back_d_map {
3211            let v = m[[uy, ux]];
3212            assert!(
3213                v.is_nan(),
3214                "back_d_map at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3215            );
3216        }
3217        if let Some(ref m) = result.back_f_map {
3218            let v = m[[uy, ux]];
3219            assert!(
3220                v.is_nan(),
3221                "back_f_map at unconverged pixel ({uy},{ux}) must be NaN, got {v}"
3222            );
3223        }
3224    }
3225
3226    /// `back_d_map` / `back_f_map` stay `None` whenever `fit_back_d` /
3227    /// `fit_back_f` are left at their defaults, even when a
3228    /// transmission background config is attached.  This is the
3229    /// "exponential tail never engaged" arm of the gating contract.
3230    #[test]
3231    fn test_spatial_map_back_d_f_maps_none_when_fit_disabled() {
3232        let rd = u238_single_resonance();
3233        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3234        let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3235        let data = InputData3D::Transmission {
3236            transmission: t_3d.view(),
3237            uncertainty: u_3d.view(),
3238        };
3239        let config = UnifiedFitConfig::new(
3240            energies,
3241            vec![rd],
3242            vec!["U-238".into()],
3243            0.0,
3244            None,
3245            vec![0.001],
3246        )
3247        .unwrap()
3248        // background=true but fit_back_d/fit_back_f are left at their
3249        // default `false` — back_*_map must remain None.
3250        .with_transmission_background(crate::pipeline::BackgroundConfig::default());
3251
3252        let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3253        assert!(
3254            result.background_maps.is_some(),
3255            "background_maps should be Some when transmission_background is attached"
3256        );
3257        assert!(
3258            result.back_d_map.is_none(),
3259            "back_d_map must be None when fit_back_d=false"
3260        );
3261        assert!(
3262            result.back_f_map.is_none(),
3263            "back_f_map must be None when fit_back_f=false"
3264        );
3265    }
3266
3267    /// `back_d_map` / `back_f_map` are `Some` (and carry finite values
3268    /// at converged pixels) when the LM transmission background is fit
3269    /// with both exponential-tail flags set.  Synthesises a 4×4 cube
3270    /// with a known exponential tail on top of U-238 absorption so the
3271    /// BackD/BackF Jacobian columns are not degenerate (a smooth
3272    /// resonance-only model is unidentifiable in BackD/BackF — `anorm`
3273    /// absorbs them — so the fitter stagnates and converges = false on
3274    /// every pixel).  Mirrors the single-spectrum coverage in
3275    /// `fitting::transmission_model::tests::exponential_fit_recovers_all_params`
3276    /// while exercising the spatial aggregation path.
3277    #[test]
3278    fn test_spatial_map_back_d_f_maps_some_when_fit_enabled() {
3279        let rd = u238_single_resonance();
3280        // 101-bin grid (matches `test_spatial_map_typed_transmission_lm`).
3281        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3282        let true_density = 0.0005;
3283        let true_back_d = 0.03;
3284        let true_back_f = 2.0;
3285        // Build the resonance-only transmission first, then add the
3286        // exponential tail in-place so the fitter sees a model whose
3287        // BackD/BackF columns carry non-degenerate signal.  The 1/√E
3288        // factor (NormalizedTransmissionModel exponential wrapper)
3289        // makes BackD/BackF identifiable across the [1, 11] eV range.
3290        let (mut t_3d, u_3d) = synthetic_4x4_transmission(&rd, true_density, &energies);
3291        for (i, &e) in energies.iter().enumerate() {
3292            let inv_sqrt_e = 1.0 / e.sqrt();
3293            let tail = true_back_d * (-true_back_f * inv_sqrt_e).exp();
3294            for y in 0..4 {
3295                for x in 0..4 {
3296                    t_3d[[i, y, x]] += tail;
3297                }
3298            }
3299        }
3300        let data = InputData3D::Transmission {
3301            transmission: t_3d.view(),
3302            uncertainty: u_3d.view(),
3303        };
3304        // SAMMY pairs BackD/BackF — `validate_transmission_background`
3305        // rejects fitting only one.  Both initial values must stay
3306        // strictly positive (the BackF Jacobian column zeros out when
3307        // BackD ≈ 0; see BackgroundConfig docstring).
3308        let bg = crate::pipeline::BackgroundConfig {
3309            fit_back_d: true,
3310            fit_back_f: true,
3311            back_d_init: 0.01,
3312            back_f_init: 1.0,
3313            ..crate::pipeline::BackgroundConfig::default()
3314        };
3315        let config = UnifiedFitConfig::new(
3316            energies,
3317            vec![rd],
3318            vec!["U-238".into()],
3319            0.0,
3320            None,
3321            vec![true_density],
3322        )
3323        .unwrap()
3324        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
3325            max_iter: 500,
3326            ..LmConfig::default()
3327        }))
3328        .with_transmission_background(bg);
3329
3330        let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3331        let bd = result
3332            .back_d_map
3333            .as_ref()
3334            .expect("back_d_map should be Some when fit_back_d=true");
3335        let bf = result
3336            .back_f_map
3337            .as_ref()
3338            .expect("back_f_map should be Some when fit_back_f=true");
3339        assert_eq!(bd.shape(), [4, 4]);
3340        assert_eq!(bf.shape(), [4, 4]);
3341        assert!(
3342            result.n_converged > 0,
3343            "no pixels converged with LM + 7-param transmission background \
3344             on synthetic data carrying an exponential tail — test fixture \
3345             is no longer exercising the gating contract"
3346        );
3347        // At converged pixels both must be finite; at unconverged pixels
3348        // the NaN-on-failure contract leaves them NaN.
3349        let mut n_finite_d = 0;
3350        let mut n_finite_f = 0;
3351        for y in 0..4 {
3352            for x in 0..4 {
3353                if result.converged_map[[y, x]] {
3354                    if bd[[y, x]].is_finite() {
3355                        n_finite_d += 1;
3356                    }
3357                    if bf[[y, x]].is_finite() {
3358                        n_finite_f += 1;
3359                    }
3360                } else {
3361                    assert!(
3362                        bd[[y, x]].is_nan(),
3363                        "back_d_map at unconverged ({y},{x}) must be NaN"
3364                    );
3365                    assert!(
3366                        bf[[y, x]].is_nan(),
3367                        "back_f_map at unconverged ({y},{x}) must be NaN"
3368                    );
3369                }
3370            }
3371        }
3372        // At least one converged pixel must populate finite back_d/back_f
3373        // — otherwise the gating is vacuous.
3374        assert!(
3375            n_finite_d > 0 && n_finite_f > 0,
3376            "at least one converged pixel must produce finite back_d/back_f \
3377             (n_converged={}, n_finite_d={n_finite_d}, n_finite_f={n_finite_f})",
3378            result.n_converged
3379        );
3380    }
3381
3382    /// Counts-KL never fits the exponential tail, so `back_d_map` /
3383    /// `back_f_map` must remain `None` even when the counts-KL
3384    /// background is attached.  Keeps the joint-Poisson dispatch from
3385    /// accidentally surfacing a map of sentinel zeros.
3386    #[test]
3387    fn test_spatial_map_counts_kl_back_d_f_maps_are_none() {
3388        let rd = u238_single_resonance();
3389        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3390        let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3391        let data = InputData3D::Counts {
3392            sample_counts: sample.view(),
3393            open_beam_counts: ob.view(),
3394        };
3395        let config = UnifiedFitConfig::new(
3396            energies,
3397            vec![rd],
3398            vec!["U-238".into()],
3399            0.0,
3400            None,
3401            vec![0.001],
3402        )
3403        .unwrap()
3404        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
3405        .with_counts_background(crate::pipeline::CountsBackgroundConfig::default());
3406        let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
3407        assert!(
3408            result.back_d_map.is_none(),
3409            "back_d_map must be None on the counts-KL path"
3410        );
3411        assert!(
3412            result.back_f_map.is_none(),
3413            "back_f_map must be None on the counts-KL path"
3414        );
3415    }
3416
3417    /// Unpaired `fit_back_d` / `fit_back_f` must be rejected up-front
3418    /// by `spatial_map_typed`, not just per-pixel.  Without this guard
3419    /// the per-pixel solver errors are swallowed as `n_failed` and the
3420    /// caller sees an all-NaN map with no diagnostic.
3421    #[test]
3422    fn test_spatial_map_back_d_f_unpaired_rejected_up_front() {
3423        let rd = u238_single_resonance();
3424        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3425        let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3426        let data = InputData3D::Transmission {
3427            transmission: t_3d.view(),
3428            uncertainty: u_3d.view(),
3429        };
3430        let bg = crate::pipeline::BackgroundConfig {
3431            fit_back_d: true,
3432            fit_back_f: false, // unpaired — must be rejected
3433            back_d_init: 0.01,
3434            back_f_init: 1.0,
3435            ..crate::pipeline::BackgroundConfig::default()
3436        };
3437        let config = UnifiedFitConfig::new(
3438            energies,
3439            vec![rd],
3440            vec!["U-238".into()],
3441            0.0,
3442            None,
3443            vec![0.001],
3444        )
3445        .unwrap()
3446        .with_transmission_background(bg);
3447        let err = spatial_map_typed(&data, &config, None, None, None)
3448            .expect_err("unpaired fit_back_d/fit_back_f must be rejected up-front");
3449        let msg = err.to_string();
3450        assert!(
3451            msg.contains("fit_back_d") && msg.contains("fit_back_f"),
3452            "error message must reference both fit flags, got: {msg}"
3453        );
3454    }
3455
3456    /// Non-positive `back_d_init` is rejected up-front so the LM
3457    /// solver does not silently produce a degenerate Jacobian (BackF's
3458    /// column zeros out at BackD ≈ 0).
3459    #[test]
3460    fn test_spatial_map_back_d_init_non_positive_rejected() {
3461        let rd = u238_single_resonance();
3462        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3463        let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3464        let data = InputData3D::Transmission {
3465            transmission: t_3d.view(),
3466            uncertainty: u_3d.view(),
3467        };
3468        let bg = crate::pipeline::BackgroundConfig {
3469            fit_back_d: true,
3470            fit_back_f: true,
3471            back_d_init: 0.0, // non-positive — must be rejected
3472            back_f_init: 1.0,
3473            ..crate::pipeline::BackgroundConfig::default()
3474        };
3475        let config = UnifiedFitConfig::new(
3476            energies,
3477            vec![rd],
3478            vec!["U-238".into()],
3479            0.0,
3480            None,
3481            vec![0.001],
3482        )
3483        .unwrap()
3484        .with_transmission_background(bg);
3485        let err = spatial_map_typed(&data, &config, None, None, None)
3486            .expect_err("back_d_init=0.0 with fit_back_d=true must be rejected up-front");
3487        assert!(
3488            err.to_string().contains("back_d_init"),
3489            "error must reference back_d_init, got: {err}"
3490        );
3491    }
3492
3493    /// Non-positive `back_f_init` is rejected up-front for the same
3494    /// reason as `back_d_init` (BackD becomes a duplicate of BackA at
3495    /// BackF ≈ 0).
3496    #[test]
3497    fn test_spatial_map_back_f_init_non_positive_rejected() {
3498        let rd = u238_single_resonance();
3499        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3500        let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3501        let data = InputData3D::Transmission {
3502            transmission: t_3d.view(),
3503            uncertainty: u_3d.view(),
3504        };
3505        let bg = crate::pipeline::BackgroundConfig {
3506            fit_back_d: true,
3507            fit_back_f: true,
3508            back_d_init: 0.01,
3509            back_f_init: -1.0, // negative — must be rejected
3510            ..crate::pipeline::BackgroundConfig::default()
3511        };
3512        let config = UnifiedFitConfig::new(
3513            energies,
3514            vec![rd],
3515            vec!["U-238".into()],
3516            0.0,
3517            None,
3518            vec![0.001],
3519        )
3520        .unwrap()
3521        .with_transmission_background(bg);
3522        let err = spatial_map_typed(&data, &config, None, None, None)
3523            .expect_err("back_f_init=-1.0 with fit_back_f=true must be rejected up-front");
3524        assert!(
3525            err.to_string().contains("back_f_init"),
3526            "error must reference back_f_init, got: {err}"
3527        );
3528    }
3529
3530    /// NaN `back_d_init` is rejected up-front.  Without the
3531    /// `is_finite()` guard, NaN passes the `<= 0.0` check (NaN
3532    /// comparisons are always false) and propagates into the fit
3533    /// parameters.
3534    #[test]
3535    fn test_spatial_map_back_d_init_nan_rejected() {
3536        let rd = u238_single_resonance();
3537        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3538        let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3539        let data = InputData3D::Transmission {
3540            transmission: t_3d.view(),
3541            uncertainty: u_3d.view(),
3542        };
3543        let bg = crate::pipeline::BackgroundConfig {
3544            fit_back_d: true,
3545            fit_back_f: true,
3546            back_d_init: f64::NAN, // NaN — must be rejected
3547            back_f_init: 1.0,
3548            ..crate::pipeline::BackgroundConfig::default()
3549        };
3550        let config = UnifiedFitConfig::new(
3551            energies,
3552            vec![rd],
3553            vec!["U-238".into()],
3554            0.0,
3555            None,
3556            vec![0.001],
3557        )
3558        .unwrap()
3559        .with_transmission_background(bg);
3560        let err = spatial_map_typed(&data, &config, None, None, None)
3561            .expect_err("NaN back_d_init must be rejected up-front");
3562        let msg = err.to_string();
3563        assert!(
3564            msg.contains("back_d_init") && (msg.contains("finite") || msg.contains("NaN")),
3565            "error must mention finite/NaN for back_d_init, got: {msg}"
3566        );
3567    }
3568
3569    /// +inf `back_f_init` is rejected up-front.  Without the
3570    /// `is_finite()` guard, +inf passes the `<= 0.0` check (positive
3571    /// infinity is > 0) and propagates into the fit parameters.
3572    #[test]
3573    fn test_spatial_map_back_f_init_inf_rejected() {
3574        let rd = u238_single_resonance();
3575        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3576        let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
3577        let data = InputData3D::Transmission {
3578            transmission: t_3d.view(),
3579            uncertainty: u_3d.view(),
3580        };
3581        let bg = crate::pipeline::BackgroundConfig {
3582            fit_back_d: true,
3583            fit_back_f: true,
3584            back_d_init: 0.01,
3585            back_f_init: f64::INFINITY, // +inf — must be rejected
3586            ..crate::pipeline::BackgroundConfig::default()
3587        };
3588        let config = UnifiedFitConfig::new(
3589            energies,
3590            vec![rd],
3591            vec!["U-238".into()],
3592            0.0,
3593            None,
3594            vec![0.001],
3595        )
3596        .unwrap()
3597        .with_transmission_background(bg);
3598        let err = spatial_map_typed(&data, &config, None, None, None)
3599            .expect_err("+inf back_f_init must be rejected up-front");
3600        let msg = err.to_string();
3601        assert!(
3602            msg.contains("back_f_init") && (msg.contains("finite") || msg.contains("inf")),
3603            "error must mention finite/inf for back_f_init, got: {msg}"
3604        );
3605    }
3606
3607    /// The joint-Poisson (counts-KL) dispatch combined with a
3608    /// `transmission_background` carrying `fit_back_d=true` /
3609    /// `fit_back_f=true` is rejected up-front so the user gets a clear
3610    /// diagnostic instead of an all-NaN map from per-pixel `n_failed`
3611    /// swallowing.
3612    #[test]
3613    fn test_spatial_map_counts_kl_plus_back_d_rejected_up_front() {
3614        let rd = u238_single_resonance();
3615        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3616        let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3617        let data = InputData3D::Counts {
3618            sample_counts: sample.view(),
3619            open_beam_counts: ob.view(),
3620        };
3621        let bg = crate::pipeline::BackgroundConfig {
3622            fit_back_d: true,
3623            fit_back_f: true,
3624            back_d_init: 0.01,
3625            back_f_init: 1.0,
3626            ..crate::pipeline::BackgroundConfig::default()
3627        };
3628        let config = UnifiedFitConfig::new(
3629            energies,
3630            vec![rd],
3631            vec!["U-238".into()],
3632            0.0,
3633            None,
3634            vec![0.001],
3635        )
3636        .unwrap()
3637        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
3638        .with_transmission_background(bg);
3639        let err = spatial_map_typed(&data, &config, None, None, None)
3640            .expect_err("counts-KL + fit_back_d/fit_back_f must be rejected up-front");
3641        let msg = err.to_string();
3642        assert!(
3643            msg.contains("counts-KL") || msg.contains("joint-Poisson"),
3644            "error must reference the counts-KL incompatibility, got: {msg}"
3645        );
3646    }
3647
3648    /// Gate 1: spatial count fitting must surface the unsupported response
3649    /// model once at the public boundary.  Returning an all-NaN map would hide
3650    /// the fact that every pixel tried to use R[T] instead of the physical
3651    /// separate-arm response R[Phi*T] / R[Phi].
3652    #[test]
3653    fn spatial_counts_resolution_requires_exact_count_response() {
3654        use nereids_physics::resolution::{ResolutionFunction, ResolutionParams};
3655
3656        let rd = u238_single_resonance();
3657        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3658        let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3659        let flux = Array3::from_elem((energies.len(), 4, 4), 1000.0);
3660        let background = Array3::zeros((energies.len(), 4, 4));
3661        let config = UnifiedFitConfig::new(
3662            energies,
3663            vec![rd],
3664            vec!["U-238".into()],
3665            293.6,
3666            Some(ResolutionFunction::Gaussian(
3667                ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
3668            )),
3669            vec![0.001],
3670        )
3671        .unwrap();
3672
3673        let inputs = [
3674            InputData3D::Counts {
3675                sample_counts: sample.view(),
3676                open_beam_counts: ob.view(),
3677            },
3678            InputData3D::CountsWithNuisance {
3679                sample_counts: sample.view(),
3680                flux: flux.view(),
3681                background: background.view(),
3682            },
3683        ];
3684        for input in inputs {
3685            let err = spatial_map_typed(&input, &config, None, None, None)
3686                .expect_err("spatial counts + resolution must fail at preflight");
3687            let msg = err.to_string();
3688            assert!(
3689                msg.contains("instrument resolution") && msg.contains("separate-arm model"),
3690                "expected physical counts-response rejection, got: {msg}"
3691            );
3692        }
3693    }
3694
3695    /// Every count variant plus LM is rejected up-front so the caller
3696    /// does not get an all-NaN spatial result from per-pixel `n_failed`
3697    /// swallowing.  `fit_spectrum_typed` rejects this combo per-pixel;
3698    /// the hoisted spatial-level rejection surfaces the same diagnostic
3699    /// at the boundary instead of pretending the fit ran.
3700    #[test]
3701    fn test_spatial_map_counts_with_nuisance_plus_lm_rejected_up_front() {
3702        use ndarray::Array3;
3703        let rd = u238_single_resonance();
3704        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3705        let (sample, _ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3706        // `CountsWithNuisance` carries (sample, flux, background) per
3707        // pixel.  The validation under test fires before any field is
3708        // consumed, so synthetic flat 4x4 arrays suffice.
3709        let flux: Array3<f64> = Array3::from_elem((energies.len(), 4, 4), 1000.0);
3710        let background: Array3<f64> = Array3::from_elem((energies.len(), 4, 4), 0.0);
3711        let data = InputData3D::CountsWithNuisance {
3712            sample_counts: sample.view(),
3713            flux: flux.view(),
3714            background: background.view(),
3715        };
3716        let config = UnifiedFitConfig::new(
3717            energies,
3718            vec![rd],
3719            vec!["U-238".into()],
3720            0.0,
3721            None,
3722            vec![0.001],
3723        )
3724        .unwrap()
3725        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3726        let err = spatial_map_typed(&data, &config, None, None, None)
3727            .expect_err("CountsWithNuisance + LM must be rejected up-front");
3728        let msg = err.to_string();
3729        assert!(
3730            msg.contains("counts") && msg.contains("least-squares") && msg.contains("Poisson"),
3731            "error must explain the count-domain engine requirement, got: {msg}"
3732        );
3733    }
3734
3735    /// Diagnostic-priority regression: when a config violates both a
3736    /// dispatch-level guard (e.g. `CountsWithNuisance + LM` is
3737    /// rejected because LM cannot consume the nuisance arm) AND a
3738    /// downstream preflight gate (e.g. `fit_energy_range` selects too
3739    /// few active bins), the user must see the *dispatch* mismatch
3740    /// first — the fit-range / temperature gates only meaningfully
3741    /// apply once the dispatch is known to be valid.  Otherwise an
3742    /// "LM transmission active-bin" message shadows the more
3743    /// fundamental "requires a counts-domain solver" diagnostic.
3744    #[test]
3745    fn test_spatial_map_reports_solver_mismatch_before_fit_range_gate() {
3746        use ndarray::Array3;
3747        let rd = u238_single_resonance();
3748        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
3749        let (sample, _ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
3750        let flux: Array3<f64> = Array3::from_elem((energies.len(), 4, 4), 1000.0);
3751        let background: Array3<f64> = Array3::from_elem((energies.len(), 4, 4), 0.0);
3752        let data = InputData3D::CountsWithNuisance {
3753            sample_counts: sample.view(),
3754            flux: flux.view(),
3755            background: background.view(),
3756        };
3757        // Combine the dispatch-level violation (LM + CountsWithNuisance)
3758        // with a downstream preflight violation (too-narrow
3759        // `fit_energy_range` selecting < 2 active bins on the configured
3760        // 0.2 eV grid).  Either guard could fire, but the dispatch
3761        // mismatch is the actionable cause; the fit-range gate would
3762        // never matter because the dispatch never reaches LM with this
3763        // input.
3764        let config = UnifiedFitConfig::new(
3765            energies,
3766            vec![rd],
3767            vec!["U-238".into()],
3768            0.0,
3769            None,
3770            vec![0.001],
3771        )
3772        .unwrap()
3773        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
3774        .with_fit_energy_range(Some((5.0, 5.05)))
3775        .unwrap();
3776
3777        let err = spatial_map_typed(&data, &config, None, None, None)
3778            .expect_err("CountsWithNuisance + LM + narrow fit_energy_range must be rejected");
3779        let msg = err.to_string();
3780        assert!(
3781            matches!(err, PipelineError::InvalidParameter(_)),
3782            "expected InvalidParameter, got {err:?}"
3783        );
3784        assert!(
3785            msg.contains("counts") && msg.contains("least-squares") && msg.contains("Poisson"),
3786            "error must surface the solver mismatch (not the fit-range gate), got: {msg}"
3787        );
3788        assert!(
3789            !msg.contains("active bin"),
3790            "error must not be the downstream fit-range diagnostic, got: {msg}"
3791        );
3792    }
3793
3794    // ── Counts-KL spatial path (post-collapse) ────────────────────────
3795
3796    /// Spatial counts-KL dispatch routes through `fit_counts_joint_poisson`
3797    /// and populates `deviance_per_dof_map`.  Polish auto-disable makes
3798    /// the per-pixel fits fast enough to run in a unit test; the result
3799    /// still recovers density on noise-free synthetic.
3800    #[test]
3801    fn test_spatial_map_typed_counts_kl_populates_deviance_per_dof_map() {
3802        let data = u238_single_resonance();
3803        let true_density = 0.0005;
3804        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
3805        let (t_3d, _) = synthetic_4x4_transmission(&data, true_density, &energies);
3806        let n_e = energies.len();
3807
3808        // Synthesize counts: c=2.0, lam_ob=500.  E[O]=lam_ob, E[S]=c·lam_ob·T.
3809        let c_val = 2.0_f64;
3810        let lam_ob = 500.0_f64;
3811        let mut sample = Array3::zeros((n_e, 4, 4));
3812        let mut open_beam = Array3::from_elem((n_e, 4, 4), lam_ob);
3813        for y in 0..4 {
3814            for x in 0..4 {
3815                for (i, _) in energies.iter().enumerate() {
3816                    open_beam[[i, y, x]] = lam_ob;
3817                    sample[[i, y, x]] = c_val * lam_ob * t_3d[[i, y, x]];
3818                }
3819            }
3820        }
3821
3822        let config = UnifiedFitConfig::new(
3823            energies,
3824            vec![data],
3825            vec!["U-238".into()],
3826            0.0,
3827            None,
3828            vec![0.001],
3829        )
3830        .unwrap()
3831        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
3832        .with_counts_background(crate::pipeline::CountsBackgroundConfig {
3833            c: c_val,
3834            ..Default::default()
3835        });
3836
3837        let input = InputData3D::Counts {
3838            sample_counts: sample.view(),
3839            open_beam_counts: open_beam.view(),
3840        };
3841        let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
3842        // Deviance map populated (counts-KL path).
3843        let dpd = r
3844            .deviance_per_dof_map
3845            .as_ref()
3846            .expect("counts-KL spatial should populate deviance_per_dof_map");
3847        assert_eq!(dpd.shape(), &[4, 4]);
3848        let sample_val = dpd[[0, 0]];
3849        assert!(
3850            sample_val.is_finite(),
3851            "deviance_per_dof_map[0,0] = {sample_val} (should be finite)"
3852        );
3853        // Density recovery (noise-free).
3854        let density_mean: f64 = r.density_maps[0].iter().copied().sum::<f64>() / 16.0;
3855        assert!(
3856            (density_mean - true_density).abs() / true_density < 0.05,
3857            "mean density {density_mean} vs truth {true_density}",
3858        );
3859    }
3860
3861    /// Polish auto-disable: the `apply_spatial_polish_default` helper
3862    /// sets `counts_enable_polish = Some(false)` for multi-pixel fits
3863    /// when the caller has not overridden it.  This asserts the decision
3864    /// directly (no timing-based heuristics — tested by checking the
3865    /// resolved config).
3866    #[test]
3867    fn test_apply_spatial_polish_default_multi_pixel_auto_disables() {
3868        // Minimal UnifiedFitConfig — the helper only reads
3869        // `counts_enable_polish`, so the rest can be stub data.
3870        let data = u238_single_resonance();
3871        let energies: Vec<f64> = (0..10).map(|i| 1.0 + i as f64).collect();
3872        let cfg = UnifiedFitConfig::new(
3873            energies,
3874            vec![data],
3875            vec!["U-238".into()],
3876            0.0,
3877            None,
3878            vec![0.001],
3879        )
3880        .unwrap();
3881
3882        // Multi-pixel (n > 1), no caller override → auto-disabled.
3883        assert_eq!(cfg.counts_enable_polish(), None);
3884        let resolved = apply_spatial_polish_default(cfg.clone(), 16);
3885        assert_eq!(
3886            resolved.counts_enable_polish(),
3887            Some(false),
3888            "multi-pixel with no override should auto-disable polish"
3889        );
3890
3891        // Single-pixel (n = 1) → no change (let the library default decide).
3892        let resolved = apply_spatial_polish_default(cfg.clone(), 1);
3893        assert_eq!(
3894            resolved.counts_enable_polish(),
3895            None,
3896            "single-pixel should preserve the caller's unset state"
3897        );
3898
3899        // Caller explicitly turned polish on → multi-pixel must respect it.
3900        let cfg_forced_on = cfg.clone().with_counts_enable_polish(Some(true));
3901        let resolved = apply_spatial_polish_default(cfg_forced_on, 16);
3902        assert_eq!(
3903            resolved.counts_enable_polish(),
3904            Some(true),
3905            "caller override Some(true) must be preserved for multi-pixel"
3906        );
3907
3908        // Caller explicitly turned polish off → still off.
3909        let cfg_forced_off = cfg.with_counts_enable_polish(Some(false));
3910        let resolved = apply_spatial_polish_default(cfg_forced_off, 16);
3911        assert_eq!(resolved.counts_enable_polish(), Some(false));
3912    }
3913
3914    /// End-to-end: counts-KL spatial map populates `deviance_per_dof_map`
3915    /// and completes without hitting the polish maxiter cap.  No
3916    /// wall-clock assertion — relies on the helper test above for the
3917    /// auto-disable decision.
3918    #[test]
3919    fn test_spatial_map_typed_counts_kl_populates_map_without_polish_regression() {
3920        let data = u238_single_resonance();
3921        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.1).collect();
3922        let (t_3d, _) = synthetic_4x4_transmission(&data, 0.0005, &energies);
3923        let n_e = energies.len();
3924
3925        let mut sample = Array3::zeros((n_e, 4, 4));
3926        let open_beam = Array3::from_elem((n_e, 4, 4), 500.0);
3927        for y in 0..4 {
3928            for x in 0..4 {
3929                for i in 0..n_e {
3930                    sample[[i, y, x]] = 500.0 * t_3d[[i, y, x]];
3931                }
3932            }
3933        }
3934
3935        let config = UnifiedFitConfig::new(
3936            energies,
3937            vec![data],
3938            vec!["U-238".into()],
3939            0.0,
3940            None,
3941            vec![0.001],
3942        )
3943        .unwrap()
3944        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
3945
3946        let input = InputData3D::Counts {
3947            sample_counts: sample.view(),
3948            open_beam_counts: open_beam.view(),
3949        };
3950        let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
3951        assert!(r.deviance_per_dof_map.is_some());
3952        // All 16 live pixels should have a finite D/dof value.
3953        let dpd = r.deviance_per_dof_map.as_ref().unwrap();
3954        assert!(dpd.iter().all(|v| v.is_finite()));
3955    }
3956
3957    /// `(Counts, LM)` is rejected at the spatial boundary rather than
3958    /// returning a success-shaped map from a lossy ratio conversion.
3959    #[test]
3960    fn test_spatial_map_typed_counts_lm_is_rejected() {
3961        let data = u238_single_resonance();
3962        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.1).collect();
3963        let (t_3d, _) = synthetic_4x4_transmission(&data, 0.0005, &energies);
3964        let n_e = energies.len();
3965        let mut sample = Array3::zeros((n_e, 4, 4));
3966        let open_beam = Array3::from_elem((n_e, 4, 4), 500.0);
3967        for y in 0..4 {
3968            for x in 0..4 {
3969                for i in 0..n_e {
3970                    sample[[i, y, x]] = 500.0 * t_3d[[i, y, x]];
3971                }
3972            }
3973        }
3974
3975        let config = UnifiedFitConfig::new(
3976            energies,
3977            vec![data],
3978            vec!["U-238".into()],
3979            0.0,
3980            None,
3981            vec![0.001],
3982        )
3983        .unwrap()
3984        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
3985
3986        let input = InputData3D::Counts {
3987            sample_counts: sample.view(),
3988            open_beam_counts: open_beam.view(),
3989        };
3990        let err = spatial_map_typed(&input, &config, None, None, None)
3991            .expect_err("counts + LM must be rejected at the spatial boundary");
3992        let msg = err.to_string();
3993        assert!(
3994            msg.contains("counts") && msg.contains("least-squares") && msg.contains("Poisson"),
3995            "error must explain the count-domain engine requirement, got: {msg}"
3996        );
3997    }
3998
3999    /// Transmission input must never produce a `deviance_per_dof_map`
4000    /// (regardless of solver — the counts-KL dispatch isn't reached).
4001    #[test]
4002    fn test_spatial_map_typed_transmission_no_deviance_map() {
4003        let data = u238_single_resonance();
4004        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.1).collect();
4005        let (t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4006
4007        let config = UnifiedFitConfig::new(
4008            energies,
4009            vec![data],
4010            vec!["U-238".into()],
4011            0.0,
4012            None,
4013            vec![0.001],
4014        )
4015        .unwrap();
4016        let input = InputData3D::Transmission {
4017            transmission: t_3d.view(),
4018            uncertainty: u_3d.view(),
4019        };
4020        let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
4021        assert!(r.deviance_per_dof_map.is_none());
4022    }
4023
4024    /// `fit_energy_scale=True` on the spatial path routes per-pixel TZERO
4025    /// calibration through the same config used by single-spectrum fits,
4026    /// populates `t0_us_map` and `l_scale_map`, and leaves them `None`
4027    /// when the flag is off.  Regression against the prior gap where
4028    /// the Python binding accepted `fit_energy_scale` for single
4029    /// spectra but not for spatial, forcing callers to pre-calibrate.
4030    #[test]
4031    fn test_spatial_map_typed_fit_energy_scale_populates_maps() {
4032        let rd = u238_single_resonance();
4033        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
4034        let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4035        let data = InputData3D::Transmission {
4036            transmission: t_3d.view(),
4037            uncertainty: u_3d.view(),
4038        };
4039        let config = UnifiedFitConfig::new(
4040            energies,
4041            vec![rd],
4042            vec!["U-238".into()],
4043            0.0,
4044            None,
4045            vec![0.0005],
4046        )
4047        .unwrap()
4048        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4049        .with_energy_scale(0.0, 1.0, 25.0);
4050
4051        let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
4052        let t0_map = result
4053            .t0_us_map
4054            .as_ref()
4055            .expect("t0_us_map must be Some when fit_energy_scale=true");
4056        let l_map = result
4057            .l_scale_map
4058            .as_ref()
4059            .expect("l_scale_map must be Some when fit_energy_scale=true");
4060        assert_eq!(t0_map.shape(), [4, 4]);
4061        assert_eq!(l_map.shape(), [4, 4]);
4062        // Post-#458 B1 semantics:
4063        //   * Converged pixel  → finite t0 / L_scale in the maps
4064        //   * Un-converged pixel → NaN in the maps (the LM last-walked
4065        //     value is NOT leaked)
4066        // Parameter-value correctness (t0 ≈ 0, L ≈ 1 on noise-free
4067        // nominal-grid data) is tested at the fitting layer, not here;
4068        // this test only exercises wiring + aggregation gating.
4069        for y in 0..4 {
4070            for x in 0..4 {
4071                let converged = result.converged_map[[y, x]];
4072                let t0 = t0_map[[y, x]];
4073                let ls = l_map[[y, x]];
4074                if converged {
4075                    assert!(
4076                        t0.is_finite() && ls.is_finite(),
4077                        "converged pixel ({y},{x}) must have finite t0/L, got t0={t0}, L={ls}"
4078                    );
4079                } else {
4080                    assert!(
4081                        t0.is_nan() && ls.is_nan(),
4082                        "un-converged pixel ({y},{x}) must have NaN t0/L (B1 gating), got t0={t0}, L={ls}"
4083                    );
4084                }
4085            }
4086        }
4087    }
4088
4089    /// Without `fit_energy_scale`, the TZERO maps are `None` — gate check.
4090    #[test]
4091    fn test_spatial_map_typed_no_energy_scale_no_maps() {
4092        let rd = u238_single_resonance();
4093        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4094        let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4095        let data = InputData3D::Transmission {
4096            transmission: t_3d.view(),
4097            uncertainty: u_3d.view(),
4098        };
4099        let config = UnifiedFitConfig::new(
4100            energies,
4101            vec![rd],
4102            vec!["U-238".into()],
4103            0.0,
4104            None,
4105            vec![0.0005],
4106        )
4107        .unwrap()
4108        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
4109
4110        let result = spatial_map_typed(&data, &config, None, None, None).unwrap();
4111        assert!(result.t0_us_map.is_none());
4112        assert!(result.l_scale_map.is_none());
4113    }
4114
4115    /// `(Counts + LM + fit_energy_scale=true)` stays rejected — historically
4116    /// as the #458 B3 instability guard, now subsumed by the blanket
4117    /// counts+LM route rejection.
4118    #[test]
4119    fn test_spatial_map_typed_rejects_counts_lm_with_energy_scale() {
4120        let rd = u238_single_resonance();
4121        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4122        let (sample, ob) = synthetic_4x4_counts(&rd, 0.001, &energies, 1000.0);
4123        let data = InputData3D::Counts {
4124            sample_counts: sample.view(),
4125            open_beam_counts: ob.view(),
4126        };
4127        let config = UnifiedFitConfig::new(
4128            energies,
4129            vec![rd],
4130            vec!["U-238".into()],
4131            0.0,
4132            None,
4133            vec![0.0005],
4134        )
4135        .unwrap()
4136        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4137        .with_energy_scale(0.0, 1.0, 25.0);
4138
4139        let err = spatial_map_typed(&data, &config, None, None, None)
4140            .expect_err("LM + counts + fit_energy_scale must be rejected");
4141        let msg = err.to_string();
4142        assert!(
4143            msg.contains("counts") && msg.contains("least-squares") && msg.contains("Poisson"),
4144            "error must explain the count-domain engine requirement, got: {msg}"
4145        );
4146    }
4147
4148    /// `(Counts + KL + fit_energy_scale=true)` is allowed — KL is
4149    /// robust per-pixel even with energy-scale on real data.
4150    #[test]
4151    fn test_spatial_map_typed_allows_counts_kl_with_energy_scale() {
4152        let rd = u238_single_resonance();
4153        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4154        let (sample, ob) = synthetic_4x4_counts(&rd, 0.001, &energies, 1000.0);
4155        let data = InputData3D::Counts {
4156            sample_counts: sample.view(),
4157            open_beam_counts: ob.view(),
4158        };
4159        let config = UnifiedFitConfig::new(
4160            energies,
4161            vec![rd],
4162            vec!["U-238".into()],
4163            0.0,
4164            None,
4165            vec![0.0005],
4166        )
4167        .unwrap()
4168        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4169        .with_energy_scale(0.0, 1.0, 25.0);
4170
4171        let result = spatial_map_typed(&data, &config, None, None, None)
4172            .expect("KL + counts + fit_energy_scale must be allowed");
4173        assert!(result.t0_us_map.is_some());
4174    }
4175
4176    /// Issue #634: `fit_energy_scale + fit_temperature` is now SUPPORTED at
4177    /// spatial entry (the per-pixel fitter wires a temperature column into the
4178    /// energy-scale model). `spatial_map_typed` must run without the old guard
4179    /// error, actually CONVERGE per pixel, and write finite values into both
4180    /// the temperature and t0/L_scale maps.  Some-ness alone is vacuous — the
4181    /// maps are pre-allocated as `Some(NaN-filled)` from the config flags, so
4182    /// an all-pixels-failed run (the exact hazard the replaced guard's doc
4183    /// comment warned about) would still pass a Some-only assertion.
4184    #[test]
4185    fn test_spatial_map_typed_allows_energy_scale_with_temperature() {
4186        let rd = u238_single_resonance();
4187        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4188        // Truth at the temperature the fit searches for, not at 0 K.
4189        let (t_3d, u_3d) = synthetic_4x4_transmission_at(&rd, 0.001, &energies, 300.0);
4190        let data = InputData3D::Transmission {
4191            transmission: t_3d.view(),
4192            uncertainty: u_3d.view(),
4193        };
4194        let config = UnifiedFitConfig::new(
4195            energies,
4196            vec![rd],
4197            vec!["U-238".into()],
4198            300.0,
4199            None,
4200            vec![0.0005],
4201        )
4202        .unwrap()
4203        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4204        .with_fit_temperature(true)
4205        .with_energy_scale(0.0, 1.0, 25.0);
4206
4207        let result = spatial_map_typed(&data, &config, None, None, None)
4208            .expect("fit_energy_scale + fit_temperature is now supported (#634)");
4209        assert_eq!(result.n_total, 16, "4×4 map");
4210        // Real acceptance: the joint per-pixel fits must actually converge
4211        // (neighbouring-spatial-test convention), not merely be dispatched.
4212        assert!(
4213            result.n_converged >= 14,
4214            "joint fit should converge on (nearly) all pixels, got {}/16",
4215            result.n_converged
4216        );
4217        // Converged pixels write FINITE values into all three maps — this is
4218        // what distinguishes success from the pre-allocated NaN fill.
4219        let finite_count = |m: &Option<ndarray::Array2<f64>>| {
4220            m.as_ref()
4221                .expect("map allocated when its flag is set")
4222                .iter()
4223                .filter(|v| v.is_finite())
4224                .count()
4225        };
4226        for (name, map) in [
4227            ("temperature_map", &result.temperature_map),
4228            ("t0_us_map", &result.t0_us_map),
4229            ("l_scale_map", &result.l_scale_map),
4230        ] {
4231            let n_finite = finite_count(map);
4232            assert!(
4233                n_finite >= result.n_converged,
4234                "{name}: {n_finite} finite entries < {} converged pixels — \
4235                 converged pixels must write finite values",
4236                result.n_converged
4237            );
4238        }
4239    }
4240
4241    /// `(Transmission + LM + fit_energy_scale=true)` is allowed —
4242    /// per-pixel transmission has higher SNR per bin than raw counts
4243    /// and this combination is sometimes useful for calibration
4244    /// crosschecks.  NaN-on-failure gating (B1) still protects
4245    /// downstream consumers.
4246    #[test]
4247    fn test_spatial_map_typed_allows_transmission_lm_with_energy_scale() {
4248        let rd = u238_single_resonance();
4249        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4250        let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4251        let data = InputData3D::Transmission {
4252            transmission: t_3d.view(),
4253            uncertainty: u_3d.view(),
4254        };
4255        let config = UnifiedFitConfig::new(
4256            energies,
4257            vec![rd],
4258            vec!["U-238".into()],
4259            0.0,
4260            None,
4261            vec![0.0005],
4262        )
4263        .unwrap()
4264        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4265        .with_energy_scale(0.0, 1.0, 25.0);
4266
4267        let result = spatial_map_typed(&data, &config, None, None, None)
4268            .expect("LM + transmission + fit_energy_scale must be allowed");
4269        assert!(result.t0_us_map.is_some());
4270    }
4271
4272    // ── NV-6 preflight hoist regression tests ────────────────────────
4273    //
4274    // Each of these constructs a whole-config rejection that the
4275    // single-spectrum fitter would raise per-pixel.  Before the
4276    // hoist, `spatial_map_typed` swallowed those errors at the rayon
4277    // closure and returned `Ok(SpatialResult)` with `n_failed =
4278    // n_total`, an all-NaN density map, and no diagnostic.  The fix
4279    // wires `validate_spatial_fit_preflight` immediately after shape
4280    // validation so every gate below surfaces as a single
4281    // `Err(PipelineError::InvalidParameter)`.
4282
4283    #[test]
4284    fn test_spatial_map_rejects_fit_temperature_below_one_up_front() {
4285        let rd = u238_single_resonance();
4286        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4287        let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4288        let data = InputData3D::Transmission {
4289            transmission: t_3d.view(),
4290            uncertainty: u_3d.view(),
4291        };
4292        // Sub-1 K initial temperature with `fit_temperature=true` is
4293        // rejected by `fit_spectrum_typed` per-pixel.  Pick 0.5 K
4294        // (the canonical "user wrote 25 meV instead of 25 K" case).
4295        let config = UnifiedFitConfig::new(
4296            energies,
4297            vec![rd],
4298            vec!["U-238".into()],
4299            0.5,
4300            None,
4301            vec![0.001],
4302        )
4303        .unwrap()
4304        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4305        .with_fit_temperature(true);
4306
4307        let err = spatial_map_typed(&data, &config, None, None, None)
4308            .expect_err("fit_temperature with temperature_k < 1.0 must be rejected up-front");
4309        let msg = err.to_string();
4310        assert!(
4311            matches!(err, PipelineError::InvalidParameter(_)),
4312            "expected InvalidParameter, got {err:?}"
4313        );
4314        assert!(
4315            msg.contains("temperature") && msg.contains("1.0"),
4316            "error must mention the 1.0 K floor, got: {msg}"
4317        );
4318    }
4319
4320    #[test]
4321    fn test_spatial_map_transmission_poisson_rejected_up_front() {
4322        let rd = u238_single_resonance();
4323        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4324        let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4325        let data = InputData3D::Transmission {
4326            transmission: t_3d.view(),
4327            uncertainty: u_3d.view(),
4328        };
4329        // Fractional transmission is not Poisson count data; the per-pixel
4330        // rejection would otherwise silently produce an all-NaN map.
4331        let config = UnifiedFitConfig::new(
4332            energies,
4333            vec![rd],
4334            vec!["U-238".into()],
4335            0.0,
4336            None,
4337            vec![0.001],
4338        )
4339        .unwrap()
4340        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
4341
4342        let err = spatial_map_typed(&data, &config, None, None, None)
4343            .expect_err("transmission + Poisson-KL must be rejected up-front");
4344        let msg = err.to_string();
4345        assert!(
4346            matches!(err, PipelineError::InvalidParameter(_)),
4347            "expected InvalidParameter, got {err:?}"
4348        );
4349        assert!(
4350            msg.contains("normalized transmission") && msg.contains("Poisson"),
4351            "error must name the incompatibility, got: {msg}"
4352        );
4353    }
4354
4355    #[test]
4356    fn test_spatial_map_lm_rejects_too_narrow_fit_energy_range_up_front() {
4357        let rd = u238_single_resonance();
4358        // Energies 1, 1.2, 1.4, ..., 11.0 → 51 bins on a 0.2 eV grid.
4359        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4360        let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4361        let data = InputData3D::Transmission {
4362            transmission: t_3d.view(),
4363            uncertainty: u_3d.view(),
4364        };
4365        // Window narrower than one bin → at most one active bin on the
4366        // grid; LM transmission needs at least 2.
4367        let config = UnifiedFitConfig::new(
4368            energies,
4369            vec![rd],
4370            vec!["U-238".into()],
4371            0.0,
4372            None,
4373            vec![0.001],
4374        )
4375        .unwrap()
4376        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4377        .with_fit_energy_range(Some((5.0, 5.05)))
4378        .unwrap();
4379
4380        let err = spatial_map_typed(&data, &config, None, None, None)
4381            .expect_err("LM with too-narrow fit_energy_range must be rejected up-front");
4382        let msg = err.to_string();
4383        assert!(
4384            matches!(err, PipelineError::InvalidParameter(_)),
4385            "expected InvalidParameter, got {err:?}"
4386        );
4387        assert!(
4388            msg.contains("active bin") && msg.contains("LM transmission"),
4389            "error must mention narrow active-bin count for the LM path, got: {msg}"
4390        );
4391    }
4392
4393    #[test]
4394    fn test_spatial_map_counts_kl_rejects_too_narrow_fit_energy_range_up_front() {
4395        let rd = u238_single_resonance();
4396        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4397        let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
4398        let data = InputData3D::Counts {
4399            sample_counts: sample.view(),
4400            open_beam_counts: ob.view(),
4401        };
4402        let config = UnifiedFitConfig::new(
4403            energies,
4404            vec![rd],
4405            vec!["U-238".into()],
4406            0.0,
4407            None,
4408            vec![0.001],
4409        )
4410        .unwrap()
4411        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4412        .with_fit_energy_range(Some((5.0, 5.05)))
4413        .unwrap();
4414
4415        let err = spatial_map_typed(&data, &config, None, None, None)
4416            .expect_err("counts-KL with too-narrow fit_energy_range must be rejected up-front");
4417        let msg = err.to_string();
4418        assert!(
4419            matches!(err, PipelineError::InvalidParameter(_)),
4420            "expected InvalidParameter, got {err:?}"
4421        );
4422        assert!(
4423            msg.contains("active bin") && msg.contains("joint-Poisson"),
4424            "error must mention narrow active-bin count for the joint-Poisson path, got: {msg}"
4425        );
4426    }
4427
4428    #[test]
4429    fn test_spatial_map_counts_kl_rejects_invalid_c_up_front() {
4430        let rd = u238_single_resonance();
4431        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4432        let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
4433        let data = InputData3D::Counts {
4434            sample_counts: sample.view(),
4435            open_beam_counts: ob.view(),
4436        };
4437        // Non-positive `c` (`Q_s/Q_ob`) is invalid for the counts-KL
4438        // dispatch.  Python pre-validates this at the binding
4439        // boundary, but Rust core callers reach the per-pixel
4440        // rejection — which the spatial layer used to swallow.
4441        let config = UnifiedFitConfig::new(
4442            energies,
4443            vec![rd],
4444            vec!["U-238".into()],
4445            0.0,
4446            None,
4447            vec![0.001],
4448        )
4449        .unwrap()
4450        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4451        .with_counts_background(crate::pipeline::CountsBackgroundConfig {
4452            c: -1.0,
4453            ..Default::default()
4454        });
4455
4456        let err = spatial_map_typed(&data, &config, None, None, None)
4457            .expect_err("counts-KL with non-positive c must be rejected up-front");
4458        let msg = err.to_string();
4459        assert!(
4460            matches!(err, PipelineError::InvalidParameter(_)),
4461            "expected InvalidParameter, got {err:?}"
4462        );
4463        assert!(
4464            msg.contains("finite c > 0"),
4465            "error must mention the c > 0 requirement, got: {msg}"
4466        );
4467    }
4468
4469    #[test]
4470    fn test_spatial_map_counts_kl_requires_back_a_for_back_b_c_up_front() {
4471        let rd = u238_single_resonance();
4472        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4473        let (sample, ob) = synthetic_4x4_counts(&rd, 0.0005, &energies, 1000.0);
4474        let data = InputData3D::Counts {
4475            sample_counts: sample.view(),
4476            open_beam_counts: ob.view(),
4477        };
4478        // B_B fitted but B_A not fitted is rejected by the
4479        // joint-Poisson dispatch: A_n alone cannot
4480        // absorb a constant offset.  Test the B_B branch; the B_C
4481        // branch shares the same code path.
4482        let bg = crate::pipeline::BackgroundConfig {
4483            fit_back_a: false,
4484            fit_back_b: true,
4485            fit_back_c: false,
4486            fit_back_d: false,
4487            fit_back_f: false,
4488            ..crate::pipeline::BackgroundConfig::default()
4489        };
4490        let config = UnifiedFitConfig::new(
4491            energies,
4492            vec![rd],
4493            vec!["U-238".into()],
4494            0.0,
4495            None,
4496            vec![0.001],
4497        )
4498        .unwrap()
4499        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4500        .with_transmission_background(bg);
4501
4502        let err = spatial_map_typed(&data, &config, None, None, None)
4503            .expect_err("counts-KL with B_B but no B_A must be rejected up-front");
4504        let msg = err.to_string();
4505        assert!(
4506            matches!(err, PipelineError::InvalidParameter(_)),
4507            "expected InvalidParameter, got {err:?}"
4508        );
4509        assert!(
4510            msg.contains("B_A") && msg.contains("fit_back_a"),
4511            "error must name the B_A requirement, got: {msg}"
4512        );
4513    }
4514
4515    /// Underdetermined-system rejection: a `fit_energy_range` window
4516    /// that selects fewer active bins than the dispatch has free
4517    /// parameters must be rejected up-front with a diagnostic that
4518    /// names the underdetermined condition.  Before this guard, a
4519    /// config with many free params (densities + temperature +
4520    /// background) and a too-narrow window passed the old
4521    /// `n_active < 2` floor but every per-pixel fit returned
4522    /// non-converged, producing the silent all-NaN spatial result
4523    /// that the rest of the preflight exists to eliminate.
4524    #[test]
4525    fn test_spatial_map_rejects_underdetermined_fit_range() {
4526        let rd = u238_single_resonance();
4527        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4528        let (t_3d, u_3d) = synthetic_4x4_transmission(&rd, 0.001, &energies);
4529        let data = InputData3D::Transmission {
4530            transmission: t_3d.view(),
4531            uncertainty: u_3d.view(),
4532        };
4533        // Free-parameter count for this config:
4534        //   1 density + fit_temperature (=1) + fit_anorm + fit_back_a
4535        //   + fit_back_b + fit_back_c (=4 background flags from the
4536        //   BackgroundConfig::default())  →  n_free = 6.
4537        // The fit_energy_range window [5.0, 5.5] picks up the grid
4538        // points 5.0, 5.2, 5.4 → 3 active bins, comfortably above
4539        // the legacy `n_active < 2` floor but below `n_free`, so the
4540        // problem is structurally underdetermined and the LM core
4541        // would return `converged=false` for every pixel.
4542        let bg = crate::pipeline::BackgroundConfig::default();
4543        let config = UnifiedFitConfig::new(
4544            energies,
4545            vec![rd],
4546            vec!["U-238".into()],
4547            // fit_temperature requires temperature_k >= 1.0; pick a
4548            // physically reasonable value so the temperature gate
4549            // does not pre-empt the underdetermined-system gate.
4550            293.0,
4551            None,
4552            vec![0.001],
4553        )
4554        .unwrap()
4555        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4556        .with_fit_temperature(true)
4557        .with_transmission_background(bg)
4558        .with_fit_energy_range(Some((5.0, 5.5)))
4559        .unwrap();
4560
4561        let err = spatial_map_typed(&data, &config, None, None, None)
4562            .expect_err("underdetermined fit_energy_range must be rejected up-front");
4563        let msg = err.to_string();
4564        assert!(
4565            matches!(err, PipelineError::InvalidParameter(_)),
4566            "expected InvalidParameter, got {err:?}"
4567        );
4568        // Diagnostic must name both the active-bin count and the
4569        // free-parameter requirement so the user can see *why* their
4570        // window is too narrow.
4571        assert!(
4572            msg.contains("active bin")
4573                && msg.contains("free parameter")
4574                && msg.contains("underdetermined"),
4575            "error must explain the underdetermined condition, got: {msg}"
4576        );
4577    }
4578
4579    // ── Up-front detector-cube VALUE validation ─────────────────────────
4580    //
4581    // These tests exercise bad *values* (NaN / +inf / negative / zero σ) in
4582    // each detector cube — the path `validate_spatial_data_values` guards.
4583    // Before that guard existed the per-pixel `v.max(0.0)` / `σ.max(1e-10)`
4584    // clamps silently transformed bad input into a plausible-but-wrong or
4585    // all-NaN map; the asserts below lock in a hard `InvalidParameter`
4586    // instead.  The earlier spatial tests cover only bad *config*.
4587
4588    fn lm_transmission_config(
4589        energies: Vec<f64>,
4590        data: nereids_endf::resonance::ResonanceData,
4591    ) -> UnifiedFitConfig {
4592        UnifiedFitConfig::new(
4593            energies,
4594            vec![data],
4595            vec!["U-238".into()],
4596            0.0,
4597            None,
4598            vec![0.001],
4599        )
4600        .unwrap()
4601        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
4602    }
4603
4604    fn kl_counts_config(
4605        energies: Vec<f64>,
4606        data: nereids_endf::resonance::ResonanceData,
4607    ) -> UnifiedFitConfig {
4608        UnifiedFitConfig::new(
4609            energies,
4610            vec![data],
4611            vec!["U-238".into()],
4612            0.0,
4613            None,
4614            vec![0.001],
4615        )
4616        .unwrap()
4617        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
4618    }
4619
4620    #[test]
4621    fn test_spatial_rejects_bad_transmission_value() {
4622        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4623        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
4624            let data = u238_single_resonance();
4625            let (mut t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4626            t_3d[[10, 1, 2]] = bad;
4627            let config = lm_transmission_config(energies.clone(), data);
4628            let input = InputData3D::Transmission {
4629                transmission: t_3d.view(),
4630                uncertainty: u_3d.view(),
4631            };
4632            let err = spatial_map_typed(&input, &config, None, None, None)
4633                .expect_err("non-finite transmission value must be rejected up-front");
4634            assert!(
4635                matches!(err, PipelineError::InvalidParameter(_)),
4636                "got {err:?}"
4637            );
4638            let msg = err.to_string();
4639            assert!(
4640                msg.contains("transmission") && msg.contains("(y="),
4641                "error must name the cube and (y, x, e): {msg}"
4642            );
4643        }
4644    }
4645
4646    #[test]
4647    fn test_spatial_rejects_bad_uncertainty() {
4648        // NaN / +inf / zero / negative σ are all rejected (finite and > 0):
4649        // a zero σ is a singular weight, and the old floor turned it into a
4650        // 1e20 maximum-confidence bin.
4651        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4652        for bad in [f64::NAN, f64::INFINITY, 0.0, -1.0] {
4653            let data = u238_single_resonance();
4654            let (t_3d, mut u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4655            u_3d[[9, 1, 0]] = bad;
4656            let config = lm_transmission_config(energies.clone(), data);
4657            let input = InputData3D::Transmission {
4658                transmission: t_3d.view(),
4659                uncertainty: u_3d.view(),
4660            };
4661            let err = spatial_map_typed(&input, &config, None, None, None)
4662                .expect_err("bad uncertainty must be rejected up-front");
4663            assert!(
4664                matches!(err, PipelineError::InvalidParameter(_)),
4665                "got {err:?}"
4666            );
4667            assert!(
4668                err.to_string().contains("uncertainty"),
4669                "error must name the uncertainty cube, got: {err}"
4670            );
4671        }
4672    }
4673
4674    #[test]
4675    fn test_spatial_accepts_negative_transmission_value() {
4676        // SAMMY does not reject negative transmission (noise / open-beam
4677        // over-subtraction); only finiteness is required.
4678        let data = u238_single_resonance();
4679        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4680        let (mut t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4681        t_3d[[12, 2, 2]] = -0.05;
4682        let config = lm_transmission_config(energies, data);
4683        let input = InputData3D::Transmission {
4684            transmission: t_3d.view(),
4685            uncertainty: u_3d.view(),
4686        };
4687        let result = spatial_map_typed(&input, &config, None, None, None)
4688            .expect("a finite negative transmission value must not be rejected");
4689        assert_eq!(result.n_total, 16);
4690    }
4691
4692    #[test]
4693    fn test_spatial_rejects_bad_sample_counts() {
4694        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4695        for bad in [f64::NAN, f64::INFINITY, -1.0] {
4696            let data = u238_single_resonance();
4697            let (mut sample, ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4698            sample[[8, 0, 3]] = bad;
4699            let config = kl_counts_config(energies.clone(), data);
4700            let input = InputData3D::Counts {
4701                sample_counts: sample.view(),
4702                open_beam_counts: ob.view(),
4703            };
4704            let err = spatial_map_typed(&input, &config, None, None, None)
4705                .expect_err("bad sample count must be rejected up-front");
4706            assert!(
4707                matches!(err, PipelineError::InvalidParameter(_)),
4708                "got {err:?}"
4709            );
4710            assert!(
4711                err.to_string().contains("sample_counts"),
4712                "error must name the sample_counts cube, got: {err}"
4713            );
4714        }
4715    }
4716
4717    #[test]
4718    fn test_spatial_rejects_bad_open_beam() {
4719        // A single bad open-beam bin would otherwise poison the spatially-
4720        // averaged flux for ALL pixels (KL path); it must surface as a hard
4721        // error rather than a silently all-NaN map.
4722        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4723        for bad in [f64::NAN, f64::INFINITY, -1.0] {
4724            let data = u238_single_resonance();
4725            let (sample, mut ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4726            ob[[6, 3, 1]] = bad;
4727            let config = kl_counts_config(energies.clone(), data);
4728            let input = InputData3D::Counts {
4729                sample_counts: sample.view(),
4730                open_beam_counts: ob.view(),
4731            };
4732            let err = spatial_map_typed(&input, &config, None, None, None)
4733                .expect_err("bad open-beam must be rejected up-front");
4734            assert!(
4735                matches!(err, PipelineError::InvalidParameter(_)),
4736                "got {err:?}"
4737            );
4738            assert!(
4739                err.to_string().contains("open_beam_counts"),
4740                "error must name the open_beam_counts cube, got: {err}"
4741            );
4742        }
4743    }
4744
4745    #[test]
4746    fn test_spatial_counts_with_nuisance_rejects_bad_flux() {
4747        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4748        for bad in [f64::NAN, -1.0] {
4749            let data = u238_single_resonance();
4750            let (sample, _ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4751            let mut flux = Array3::from_elem((energies.len(), 4, 4), 1000.0);
4752            let background = Array3::from_elem((energies.len(), 4, 4), 0.0);
4753            flux[[4, 2, 1]] = bad;
4754            let config = kl_counts_config(energies.clone(), data);
4755            let input = InputData3D::CountsWithNuisance {
4756                sample_counts: sample.view(),
4757                flux: flux.view(),
4758                background: background.view(),
4759            };
4760            let err = spatial_map_typed(&input, &config, None, None, None)
4761                .expect_err("bad flux must be rejected up-front");
4762            assert!(
4763                matches!(err, PipelineError::InvalidParameter(_)),
4764                "got {err:?}"
4765            );
4766            assert!(
4767                err.to_string().contains("flux"),
4768                "error must name the flux cube, got: {err}"
4769            );
4770        }
4771    }
4772
4773    #[test]
4774    fn test_spatial_counts_with_nuisance_rejects_nonfinite_background() {
4775        // Background is validated finite before the nonzero-background gate,
4776        // so a NaN is reported as malformed input rather than slipping into
4777        // the `NaN != 0.0` comparison and being named an unsupported value.
4778        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4779        for bad in [f64::NAN, f64::INFINITY] {
4780            let data = u238_single_resonance();
4781            let (sample, _ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4782            let flux = Array3::from_elem((energies.len(), 4, 4), 1000.0);
4783            let mut background = Array3::from_elem((energies.len(), 4, 4), 0.0);
4784            background[[2, 3, 3]] = bad;
4785            let config = kl_counts_config(energies.clone(), data);
4786            let input = InputData3D::CountsWithNuisance {
4787                sample_counts: sample.view(),
4788                flux: flux.view(),
4789                background: background.view(),
4790            };
4791            let err = spatial_map_typed(&input, &config, None, None, None)
4792                .expect_err("non-finite background must be rejected up-front");
4793            assert!(
4794                matches!(err, PipelineError::InvalidParameter(_)),
4795                "got {err:?}"
4796            );
4797            assert!(
4798                err.to_string().contains("background"),
4799                "error must name the background cube, got: {err}"
4800            );
4801        }
4802    }
4803
4804    /// `B_det` is unwired, so a nonzero background cannot be honoured for any
4805    /// pixel. Left per-pixel it failed every live pixel and the `n_failed`
4806    /// swallow returned an all-NaN map as `Ok`; it must surface as one
4807    /// boundary error instead. 5e-13 is below the magnitude tolerance the
4808    /// guard used to carry.
4809    #[test]
4810    fn test_spatial_counts_with_nuisance_rejects_nonzero_live_background() {
4811        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4812        for nonzero_background in [1.0, 5.0e-13] {
4813            let data = u238_single_resonance();
4814            let (sample, _ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4815            let flux = Array3::from_elem((energies.len(), 4, 4), 1000.0);
4816            let mut background = Array3::from_elem((energies.len(), 4, 4), 0.0);
4817            background[[2, 3, 3]] = nonzero_background;
4818            let config = kl_counts_config(energies.clone(), data);
4819            let input = InputData3D::CountsWithNuisance {
4820                sample_counts: sample.view(),
4821                flux: flux.view(),
4822                background: background.view(),
4823            };
4824
4825            let err = spatial_map_typed(&input, &config, None, None, None)
4826                .expect_err("unsupported detector background must fail before pixel fitting");
4827            assert!(
4828                matches!(err, PipelineError::InvalidParameter(_)),
4829                "got {err:?}"
4830            );
4831            assert!(
4832                err.to_string().contains("non-zero detector_background"),
4833                "error must name the unsupported detector background, got: {err}"
4834            );
4835        }
4836    }
4837
4838    /// The ordinary case: a background cube nonzero *everywhere*. This is what
4839    /// previously produced `Ok(SpatialResult)` with every pixel NaN, which is
4840    /// indistinguishable from a converged map that simply fit badly.
4841    #[test]
4842    fn test_spatial_uniform_nonzero_background_errors_instead_of_all_nan_map() {
4843        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4844        let data = u238_single_resonance();
4845        let (sample, _ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4846        let flux = Array3::from_elem((energies.len(), 4, 4), 1000.0);
4847        let background = Array3::from_elem((energies.len(), 4, 4), 2.0);
4848        let config = kl_counts_config(energies.clone(), data);
4849        let input = InputData3D::CountsWithNuisance {
4850            sample_counts: sample.view(),
4851            flux: flux.view(),
4852            background: background.view(),
4853        };
4854
4855        let err = spatial_map_typed(&input, &config, None, None, None)
4856            .expect_err("a wholly unsupported background must not report success");
4857        assert!(
4858            err.to_string().contains("non-zero detector_background"),
4859            "got: {err}"
4860        );
4861    }
4862
4863    #[test]
4864    fn test_spatial_transmission_tolerates_nan_in_inactive_bin() {
4865        // A NaN in an out-of-`fit_energy_range` (inactive) bin is legitimate
4866        // (transmission is undefined where open-beam → 0) and is skipped by
4867        // the LM core, so it must NOT be rejected — the canonical "set
4868        // fit_energy_range to exclude a bad region" workflow.
4869        let data = u238_single_resonance();
4870        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4871        let (mut t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4872        // energies[0] = 1.0 eV is below E_min = 3.0 → inactive.
4873        t_3d[[0, 1, 1]] = f64::NAN;
4874        let config = lm_transmission_config(energies, data)
4875            .with_fit_energy_range(Some((3.0, 9.0)))
4876            .unwrap();
4877        let input = InputData3D::Transmission {
4878            transmission: t_3d.view(),
4879            uncertainty: u_3d.view(),
4880        };
4881        let result = spatial_map_typed(&input, &config, None, None, None)
4882            .expect("NaN in an inactive (out-of-range) bin must be tolerated");
4883        assert!(
4884            result.n_converged > 0,
4885            "the active-bin fit should still converge"
4886        );
4887    }
4888
4889    #[test]
4890    fn test_spatial_rejects_nan_transmission_in_active_bin_with_range() {
4891        // The mirror of the previous test: a NaN inside the active window
4892        // must still be rejected.
4893        let data = u238_single_resonance();
4894        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4895        let (mut t_3d, u_3d) = synthetic_4x4_transmission(&data, 0.0005, &energies);
4896        // energies[20] = 5.0 eV is inside [3.0, 9.0] → active.
4897        t_3d[[20, 0, 0]] = f64::NAN;
4898        let config = lm_transmission_config(energies, data)
4899            .with_fit_energy_range(Some((3.0, 9.0)))
4900            .unwrap();
4901        let input = InputData3D::Transmission {
4902            transmission: t_3d.view(),
4903            uncertainty: u_3d.view(),
4904        };
4905        let err = spatial_map_typed(&input, &config, None, None, None)
4906            .expect_err("NaN in an active bin must be rejected up-front");
4907        assert!(
4908            matches!(err, PipelineError::InvalidParameter(_)),
4909            "got {err:?}"
4910        );
4911        assert!(
4912            err.to_string().contains("transmission"),
4913            "error must name the transmission cube, got: {err}"
4914        );
4915    }
4916
4917    #[test]
4918    fn test_spatial_accepts_bad_value_in_dead_pixel() {
4919        // A `dead_pixels`-masked pixel is never read, so detector garbage in
4920        // it must not reject the whole map (live-pixels-only validation).
4921        let data = u238_single_resonance();
4922        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4923        let (mut sample, ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4924        sample[[5, 0, 0]] = f64::NAN;
4925        let config = kl_counts_config(energies, data);
4926        let mut dead = Array2::from_elem((4, 4), false);
4927        dead[[0, 0]] = true;
4928        let input = InputData3D::Counts {
4929            sample_counts: sample.view(),
4930            open_beam_counts: ob.view(),
4931        };
4932        let result = spatial_map_typed(&input, &config, Some(&dead), None, None)
4933            .expect("a bad value in a dead-masked pixel must be tolerated");
4934        assert!(
4935            result.n_converged > 0,
4936            "the remaining live pixels should still fit"
4937        );
4938    }
4939
4940    #[test]
4941    fn test_spatial_accepts_zero_counts_and_open_beam() {
4942        // Zero is legitimate ("no counts in this bin"); the joint-Poisson
4943        // xlogy_ratio zero-branch handles it.  Must not be rejected.
4944        let data = u238_single_resonance();
4945        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4946        let (mut sample, mut ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4947        sample[[3, 2, 2]] = 0.0;
4948        ob[[7, 1, 1]] = 0.0;
4949        let config = kl_counts_config(energies, data);
4950        let input = InputData3D::Counts {
4951            sample_counts: sample.view(),
4952            open_beam_counts: ob.view(),
4953        };
4954        let result = spatial_map_typed(&input, &config, None, None, None)
4955            .expect("zero counts / zero open-beam are legitimate and must not be rejected");
4956        assert_eq!(result.n_total, 16);
4957    }
4958
4959    #[test]
4960    fn test_spatial_rejects_open_beam_flux_overflow() {
4961        // Each open-beam bin is individually finite (passes the up-front
4962        // FiniteNonNegative check), but summing `f64::MAX` across live pixels
4963        // overflows the spatially-averaged flux to +inf.  That must surface as
4964        // an up-front `InvalidParameter` rather than a silently all-NaN map
4965        // (the averaged flux would otherwise fail inside each per-pixel fit and
4966        // be swallowed as `n_failed`).
4967        let data = u238_single_resonance();
4968        let energies: Vec<f64> = (0..51).map(|i| 1.0 + (i as f64) * 0.2).collect();
4969        let (sample, mut ob) = synthetic_4x4_counts(&data, 0.0005, &energies, 1000.0);
4970        for y in 0..4 {
4971            for x in 0..4 {
4972                ob[[5, y, x]] = f64::MAX;
4973            }
4974        }
4975        let config = kl_counts_config(energies, data);
4976        let input = InputData3D::Counts {
4977            sample_counts: sample.view(),
4978            open_beam_counts: ob.view(),
4979        };
4980        let err = spatial_map_typed(&input, &config, None, None, None)
4981            .expect_err("an overflowing averaged open-beam flux must be rejected up-front");
4982        assert!(
4983            matches!(err, PipelineError::InvalidParameter(_)),
4984            "got {err:?}"
4985        );
4986        assert!(
4987            err.to_string().contains("averaged open-beam flux"),
4988            "error must name the averaged-flux overflow, got: {err}"
4989        );
4990    }
4991
4992    // ── Issue #635: spatial multiplicative-baseline tests ────────────────
4993
4994    /// Truth baseline for the spatial closed loops (shared with the
4995    /// pipeline-level tests): a few % off unity, curved, strictly positive
4996    /// on the test grids, inside the DEFAULT bounds.
4997    const SPATIAL_BL_TRUE: [f64; 3] = [1.02, -0.03, 0.01];
4998
4999    fn spatial_baseline_at(e: f64, e_ref: f64) -> f64 {
5000        let z = (e / e_ref).ln();
5001        SPATIAL_BL_TRUE[0] + SPATIAL_BL_TRUE[1] * z + SPATIAL_BL_TRUE[2] * z * z
5002    }
5003
5004    /// Low-count 3x3 thermometry cube: counts follow
5005    /// `lambda(e) = i0 * B(e) * T_600K(e)` with deterministic ~1-sigma
5006    /// pseudo-Poisson noise (no rand dep; `round(lambda + sqrt(lambda)*g)`
5007    /// with a sin-hash g).  This is the regime where PER-PIXEL baselines
5008    /// biased fitted temperatures on real data and the global mode fixed it.
5009    fn baseline_thermometry_cube(
5010        energies: &[f64],
5011        true_density: f64,
5012        true_temp: f64,
5013        i0: f64,
5014    ) -> (Array3<f64>, Array3<f64>) {
5015        let data = u238_single_resonance();
5016        let xs = nereids_physics::transmission::broadened_cross_sections(
5017            energies,
5018            std::slice::from_ref(&data),
5019            true_temp,
5020            None,
5021            None,
5022        )
5023        .unwrap();
5024        let model = PrecomputedTransmissionModel {
5025            cross_sections: Arc::new(xs),
5026            density_indices: Arc::new(vec![0]),
5027            instrument: None,
5028            resolution_plan: None,
5029            sparse_cubature_plan: None,
5030            sparse_scalar_plan: None,
5031            layout: Arc::new(nereids_physics::transmission::WorkingGridLayout::identity(
5032                energies,
5033            )),
5034        };
5035        let t_1d = model.evaluate(&[true_density]).unwrap();
5036        let e_ref = nereids_fitting::transmission_model::baseline_reference_energy(energies);
5037        let n_e = energies.len();
5038        let mut sample = Array3::zeros((n_e, 3, 3));
5039        let mut ob = Array3::zeros((n_e, 3, 3));
5040        for y in 0..3 {
5041            for x in 0..3 {
5042                for (i, (&t, &e)) in t_1d.iter().zip(energies.iter()).enumerate() {
5043                    let lam = i0 * spatial_baseline_at(e, e_ref) * t;
5044                    // Deterministic ~1-sigma pseudo-noise.
5045                    let g = (1.7 * (i as f64) + 7.9 * (y as f64) + 13.3 * (x as f64)).sin();
5046                    sample[[i, y, x]] = (lam + lam.sqrt() * g).round().max(0.0);
5047                    ob[[i, y, x]] = i0;
5048                }
5049            }
5050        }
5051        (sample, ob)
5052    }
5053
5054    #[test]
5055    fn spatial_global_baseline_recovers_truth_and_beats_unmodeled_control() {
5056        let true_density = 0.002;
5057        let true_temp = 600.0;
5058        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
5059        let (sample, ob) = baseline_thermometry_cube(&energies, true_density, true_temp, 400.0);
5060
5061        // The production thermometry pattern: counts-KL, density frozen at
5062        // the known areal density, temperature free (seeded 100 K low).
5063        let base_config = UnifiedFitConfig::new(
5064            energies.clone(),
5065            vec![u238_single_resonance()],
5066            vec!["U-238".into()],
5067            500.0,
5068            None,
5069            vec![true_density],
5070        )
5071        .unwrap()
5072        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
5073        .with_fit_temperature(true)
5074        .with_fix_densities(true);
5075
5076        let input = InputData3D::Counts {
5077            sample_counts: sample.view(),
5078            open_beam_counts: ob.view(),
5079        };
5080
5081        // ── Global-baseline run ──
5082        let with_bl = base_config
5083            .clone()
5084            .with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig::default());
5085        let r = spatial_map_typed(&input, &with_bl, None, None, None).unwrap();
5086        assert_eq!(r.n_converged, 9, "all 9 pixels converge in global mode");
5087        assert!(
5088            r.warnings.is_empty(),
5089            "no degenerate trio here: {:?}",
5090            r.warnings
5091        );
5092        assert!(
5093            r.baseline_maps.is_none(),
5094            "global mode reports a scalar baseline, not maps"
5095        );
5096
5097        // Stage-1 recovery of the injected baseline (probe run measured
5098        // |error| <= 2e-4 per coefficient at this noise level; 0.01 leaves
5099        // a 50x margin without admitting a shape-blind fit).
5100        let bg = r.baseline_global.expect("global baseline populated");
5101        for (i, (&fitted, &truth)) in bg.iter().zip(SPATIAL_BL_TRUE.iter()).enumerate() {
5102            assert!(
5103                (fitted - truth).abs() < 0.01,
5104                "baseline_global[{i}] = {fitted} vs truth {truth}"
5105            );
5106        }
5107        let e_ref_expected =
5108            nereids_fitting::transmission_model::baseline_reference_energy(&energies);
5109        let e_ref = r.baseline_e_ref_ev.expect("E_ref reported");
5110        assert!(
5111            (e_ref - e_ref_expected).abs() < 1e-12,
5112            "E_ref {e_ref} != geometric midpoint {e_ref_expected}"
5113        );
5114
5115        // Temperature recovery through the frozen per-pixel baseline
5116        // (probe: median 603.9 at ~1-sigma pseudo-noise, i0 = 400).
5117        let t_map = r.temperature_map.as_ref().unwrap();
5118        let mut temps: Vec<f64> = t_map.iter().copied().filter(|v| v.is_finite()).collect();
5119        temps.sort_by(|a, b| a.partial_cmp(b).unwrap());
5120        let median_t = temps[temps.len() / 2];
5121        assert!(
5122            (median_t - true_temp).abs() < 15.0,
5123            "median fitted T = {median_t} vs truth {true_temp}"
5124        );
5125
5126        // ── Non-vacuity: the baseline is genuinely in the data ──
5127        // A control fit WITHOUT the baseline on the SAME cube must show the
5128        // model mismatch as a strictly worse per-pixel deviance (the 2 %
5129        // multiplicative distortion contributes ~0.16 per bin at 400 counts,
5130        // well above the D/dof ~ 1 noise floor).  Without this check the
5131        // recovery assertions above could pass on data where the baseline
5132        // injection silently no-opped.
5133        let control = spatial_map_typed(&input, &base_config, None, None, None).unwrap();
5134        let mean_dpd = |res: &SpatialResult| -> f64 {
5135            let m = res.deviance_per_dof_map.as_ref().unwrap();
5136            let v: Vec<f64> = m.iter().copied().filter(|v| v.is_finite()).collect();
5137            v.iter().sum::<f64>() / v.len() as f64
5138        };
5139        let dpd_baseline = mean_dpd(&r);
5140        let dpd_control = mean_dpd(&control);
5141        assert!(
5142            dpd_baseline < dpd_control,
5143            "modeling the baseline must improve the fit: D/dof {dpd_baseline} \
5144             (baseline) vs {dpd_control} (unmodeled control)"
5145        );
5146    }
5147
5148    #[test]
5149    fn spatial_per_pixel_baseline_mode_populates_maps() {
5150        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
5151        let (sample, ob) = baseline_thermometry_cube(&energies, 0.002, 600.0, 400.0);
5152        let config = UnifiedFitConfig::new(
5153            energies,
5154            vec![u238_single_resonance()],
5155            vec!["U-238".into()],
5156            500.0,
5157            None,
5158            vec![0.002],
5159        )
5160        .unwrap()
5161        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
5162        .with_fit_temperature(true)
5163        .with_fix_densities(true)
5164        .with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig {
5165            spatial_global: false,
5166            ..Default::default()
5167        });
5168        let input = InputData3D::Counts {
5169            sample_counts: sample.view(),
5170            open_beam_counts: ob.view(),
5171        };
5172        let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
5173        assert!(
5174            r.baseline_global.is_none(),
5175            "per-pixel mode has no global baseline"
5176        );
5177        assert!(
5178            r.baseline_e_ref_ev.is_some(),
5179            "E_ref reported in both modes"
5180        );
5181        let maps = r.baseline_maps.as_ref().expect("per-pixel baseline maps");
5182        for y in 0..3 {
5183            for x in 0..3 {
5184                if !r.converged_map[[y, x]] {
5185                    continue;
5186                }
5187                let b0 = maps[0][[y, x]];
5188                assert!(
5189                    (b0 - SPATIAL_BL_TRUE[0]).abs() < 0.05,
5190                    "per-pixel b0[{y},{x}] = {b0} vs truth {}",
5191                    SPATIAL_BL_TRUE[0]
5192                );
5193                assert!(maps[1][[y, x]].is_finite() && maps[2][[y, x]].is_finite());
5194            }
5195        }
5196        assert!(r.n_converged > 0, "at least some pixels converge");
5197    }
5198
5199    /// Review R1 P0: a config whose ONLY free parameters are the global
5200    /// baseline coefficients must be rejected up front.  Pre-fix, preflight
5201    /// counted the (still-free) baseline flags, stage 1 fitted the global
5202    /// baseline, and then the stage-2 freeze left every per-pixel fit with
5203    /// zero free parameters — each pixel's "no free parameters" error was
5204    /// swallowed as a per-pixel failure and the call returned
5205    /// Ok(SpatialResult) with all-NaN maps and n_failed == n_total.
5206    #[test]
5207    fn spatial_global_baseline_as_only_free_block_rejected_up_front() {
5208        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
5209        let (sample, ob) = baseline_thermometry_cube(&energies, 0.002, 600.0, 400.0);
5210        // Densities frozen, NO temperature / energy-scale / background —
5211        // the baseline is the only free block, and global mode will freeze
5212        // it before the per-pixel stage.
5213        let config = UnifiedFitConfig::new(
5214            energies,
5215            vec![u238_single_resonance()],
5216            vec!["U-238".into()],
5217            600.0,
5218            None,
5219            vec![0.002],
5220        )
5221        .unwrap()
5222        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
5223        .with_fix_densities(true)
5224        .with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig::default());
5225        let input = InputData3D::Counts {
5226            sample_counts: sample.view(),
5227            open_beam_counts: ob.view(),
5228        };
5229        let err = spatial_map_typed(&input, &config, None, None, None).expect_err(
5230            "global-baseline-only config must be a whole-map rejection, not \
5231             an Ok(all-NaN) result",
5232        );
5233        let msg = err.to_string();
5234        assert!(
5235            msg.contains("only free parameter block"),
5236            "error must explain the stage-2 freeze consequence, got: {msg}"
5237        );
5238
5239        // Per-pixel mode with the SAME parameter set stays legal: the
5240        // baseline coefficients remain free in every pixel fit.
5241        let per_pixel =
5242            config.with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig {
5243                spatial_global: false,
5244                ..Default::default()
5245            });
5246        let r = spatial_map_typed(&input, &per_pixel, None, None, None)
5247            .expect("per-pixel baseline-only fits are well-posed");
5248        assert!(r.n_converged > 0, "per-pixel baseline-only fits converge");
5249    }
5250
5251    #[test]
5252    fn spatial_stage1_nonconvergence_is_hard_error() {
5253        // LM with max_iter = 1 cannot converge from the identity baseline
5254        // seed on baseline-distorted data — stage 1 must surface a HARD
5255        // error rather than silently falling back to per-pixel baselines.
5256        let data = u238_single_resonance();
5257        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
5258        let (t_3d, sigma_3d) = synthetic_grid_transmission(&data, 0.002, &energies, 2, 2);
5259        let e_ref = nereids_fitting::transmission_model::baseline_reference_energy(&energies);
5260        let mut t_bl = t_3d.clone();
5261        for y in 0..2 {
5262            for x in 0..2 {
5263                for (i, &e) in energies.iter().enumerate() {
5264                    t_bl[[i, y, x]] = t_3d[[i, y, x]] * spatial_baseline_at(e, e_ref);
5265                }
5266            }
5267        }
5268        let config = UnifiedFitConfig::new(
5269            energies,
5270            vec![data],
5271            vec!["U-238".into()],
5272            0.0,
5273            None,
5274            vec![0.001],
5275        )
5276        .unwrap()
5277        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
5278            max_iter: 1,
5279            ..LmConfig::default()
5280        }))
5281        .with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig::default());
5282        let input = InputData3D::Transmission {
5283            transmission: t_bl.view(),
5284            uncertainty: sigma_3d.view(),
5285        };
5286        let err = spatial_map_typed(&input, &config, None, None, None)
5287            .expect_err("non-converged stage 1 must be a hard error");
5288        assert!(
5289            err.to_string().contains("stage 1 did not converge"),
5290            "error must name stage 1, got: {err}"
5291        );
5292    }
5293
5294    #[test]
5295    fn spatial_rejects_free_anorm_with_baseline_up_front() {
5296        let data = u238_single_resonance();
5297        let energies: Vec<f64> = (0..11).map(|i| 1.0 + (i as f64) * 0.1).collect();
5298        let (t_3d, sigma_3d) = synthetic_grid_transmission(&data, 0.002, &energies, 2, 2);
5299        let config = UnifiedFitConfig::new(
5300            energies,
5301            vec![data],
5302            vec!["U-238".into()],
5303            0.0,
5304            None,
5305            vec![0.001],
5306        )
5307        .unwrap()
5308        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()))
5309        // fit_anorm defaults to true — the rejected degenerate combination.
5310        .with_transmission_background(crate::pipeline::BackgroundConfig::default())
5311        .with_multiplicative_baseline(crate::pipeline::MultiplicativeBaselineConfig::default());
5312        let input = InputData3D::Transmission {
5313            transmission: t_3d.view(),
5314            uncertainty: sigma_3d.view(),
5315        };
5316        let err = spatial_map_typed(&input, &config, None, None, None)
5317            .expect_err("free Anorm + baseline must be hoisted to a whole-map rejection");
5318        assert!(
5319            err.to_string().contains("Anorm"),
5320            "rejection must name the degeneracy, got: {err}"
5321        );
5322    }
5323
5324    #[test]
5325    fn spatial_result_carries_degenerate_trio_warning() {
5326        // Free Anorm + free temperature + free density (NO baseline — that
5327        // combination is rejected outright) must surface the structured
5328        // warning on the SpatialResult even when pixels fail to converge.
5329        let data = u238_single_resonance();
5330        let energies: Vec<f64> = (0..101).map(|i| 1.0 + (i as f64) * 0.1).collect();
5331        let (t_3d, sigma_3d) = synthetic_grid_transmission(&data, 0.002, &energies, 2, 2);
5332        let config = UnifiedFitConfig::new(
5333            energies,
5334            vec![data],
5335            vec!["U-238".into()],
5336            300.0,
5337            None,
5338            vec![0.001],
5339        )
5340        .unwrap()
5341        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
5342            max_iter: 2,
5343            ..LmConfig::default()
5344        }))
5345        .with_fit_temperature(true)
5346        .with_transmission_background(crate::pipeline::BackgroundConfig::default());
5347        let input = InputData3D::Transmission {
5348            transmission: t_3d.view(),
5349            uncertainty: sigma_3d.view(),
5350        };
5351        let r = spatial_map_typed(&input, &config, None, None, None).unwrap();
5352        assert!(
5353            r.warnings.iter().any(|w| w.contains("degenerate")),
5354            "spatial result must carry the degenerate-trio warning, got {:?}",
5355            r.warnings
5356        );
5357    }
5358}