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