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