nereids_pipeline/
pipeline.rs

1//! Single-spectrum analysis pipeline.
2//!
3//! Orchestrates the full analysis chain for a single transmission spectrum:
4//! ENDF loading → cross-section calculation → broadening → fitting.
5//!
6//! This is the building block for the spatial mapping pipeline.
7//!
8//! Uses `UnifiedFitConfig` with `SolverConfig` and typed `InputData` variants.
9
10use std::fmt;
11use std::sync::Arc;
12
13use nereids_endf::resonance::ResonanceData;
14use nereids_fitting::lm::{self, FitModel, LmConfig, LmResult};
15use nereids_fitting::parameters::{FitParameter, ParameterSet};
16use nereids_fitting::poisson::{self, PoissonConfig};
17use nereids_fitting::transmission_model::{
18    NormalizedTransmissionModel, PrecomputedTransmissionModel, TransmissionFitModel,
19};
20use nereids_physics::resolution::ResolutionFunction;
21use nereids_physics::transmission::InstrumentParams;
22
23use crate::error::PipelineError;
24
25/// SAMMY-style normalization and background configuration.
26///
27/// When enabled, the transmission model becomes:
28///   T_out(E) = Anorm × T_inner(E) + BackA + BackB / √E + BackC × √E
29///
30/// These 4 parameters are fitted jointly with the isotope densities.
31///
32/// ## SAMMY Reference
33/// SAMMY manual Sec III.E.2 — NORMAlization and BACKGround cards.
34#[derive(Debug, Clone)]
35pub struct BackgroundConfig {
36    /// Initial value for the normalization factor (default 1.0).
37    pub anorm_init: f64,
38    /// Initial value for the constant background (default 0.0).
39    pub back_a_init: f64,
40    /// Initial value for the 1/√E background term (default 0.0).
41    pub back_b_init: f64,
42    /// Initial value for the √E background term (default 0.0).
43    pub back_c_init: f64,
44    /// Whether Anorm is free (true) or fixed (false).
45    pub fit_anorm: bool,
46    /// Whether BackA is free (true) or fixed (false).
47    pub fit_back_a: bool,
48    /// Whether BackB is free (true) or fixed (false).
49    pub fit_back_b: bool,
50    /// Whether BackC is free (true) or fixed (false).
51    pub fit_back_c: bool,
52}
53
54impl Default for BackgroundConfig {
55    fn default() -> Self {
56        Self {
57            anorm_init: 1.0,
58            back_a_init: 0.0,
59            back_b_init: 0.0,
60            back_c_init: 0.0,
61            fit_anorm: true,
62            fit_back_a: true,
63            fit_back_b: true,
64            fit_back_c: true,
65        }
66    }
67}
68
69// ── New typed pipeline API (Phase 0) ─────────────────────────────────────
70
71/// Typed input data — makes the data format explicit at the API boundary.
72///
73/// The two variants carry genuinely different data:
74/// - **Counts**: raw detector counts + open beam → Poisson statistics native
75/// - **Transmission**: normalized T = sample/open_beam + uncertainty → Gaussian statistics
76///
77/// `spatial_map_typed` dispatches to the correct fitting engine based on
78/// which variant is provided.  This eliminates the overloaded positional
79/// arguments that caused silent misinterpretation in the old API.
80#[derive(Debug, Clone)]
81pub enum InputData {
82    /// Pre-normalized transmission with Gaussian uncertainties.
83    ///
84    /// Use with LM (default) or Poisson KL (opt-in for low-count T data).
85    Transmission {
86        /// Measured transmission T(E), values typically in [0, 2].
87        transmission: Vec<f64>,
88        /// Per-bin uncertainty σ_T(E).
89        uncertainty: Vec<f64>,
90    },
91    /// Raw detector counts with open beam reference.
92    ///
93    /// Always uses Poisson KL (statistically optimal for count data).
94    /// The fitting engine works directly on counts — no information-losing
95    /// normalization to transmission.
96    Counts {
97        /// Sample counts per energy bin.
98        sample_counts: Vec<f64>,
99        /// Open-beam counts per energy bin (normalization reference).
100        open_beam_counts: Vec<f64>,
101    },
102    /// Counts with pre-estimated nuisance parameters (power users).
103    ///
104    /// Use when you want to inspect or override the flux estimate before fitting.
105    CountsWithNuisance {
106        /// Sample counts per energy bin.
107        sample_counts: Vec<f64>,
108        /// Estimated flux spectrum (from open beam spatial average).
109        flux: Vec<f64>,
110        /// Estimated detector background spectrum.
111        background: Vec<f64>,
112    },
113}
114
115impl InputData {
116    /// Number of energy bins.
117    pub fn n_energies(&self) -> usize {
118        match self {
119            Self::Transmission { transmission, .. } => transmission.len(),
120            Self::Counts { sample_counts, .. } => sample_counts.len(),
121            Self::CountsWithNuisance { sample_counts, .. } => sample_counts.len(),
122        }
123    }
124
125    /// Whether this is count data (Poisson-native).
126    pub fn is_counts(&self) -> bool {
127        matches!(self, Self::Counts { .. } | Self::CountsWithNuisance { .. })
128    }
129}
130
131/// Solver-specific configuration.
132///
133/// Carries the full solver config inside each variant, making invalid
134/// combinations unrepresentable.
135#[derive(Debug, Clone, Default)]
136pub enum SolverConfig {
137    /// Levenberg-Marquardt chi-squared minimizer.
138    LevenbergMarquardt(LmConfig),
139    /// Poisson KL divergence minimizer (projected gradient + Armijo).
140    PoissonKL(PoissonConfig),
141    /// Automatic: Counts → PoissonKL, Transmission → LM.
142    #[default]
143    Auto,
144}
145
146/// Background model for the counts fitting engine.
147///
148/// In the counts domain, the forward model is:
149///   Y(E) = α₁ · [Φ(E) · exp(-Σ nᵢσᵢ(E))] + α₂ · B(E)
150///
151/// where Φ(E) is the incident flux and B(E) is detector/gamma background.
152/// The reference Φ(E) / B(E) spectra are supplied by the caller or by
153/// spatial pre-processing; this config only controls the fitted scale factors.
154///
155/// Important distinction:
156/// - This is a detector-space counts background model `B(E)`.
157/// - It is NOT the same as the transmission-lift background used by
158///   `BackgroundConfig`, which models additive uplift of the apparent
159///   transmission curve (for example gamma-tail structure that pushes
160///   transmission upward).
161///
162/// For VENUS MCP/TPX event detectors, the current working assumption is:
163/// - raw/open-beam is the correct normalization baseline
164/// - dark-current / CCD-style electronic offset is not modeled
165/// - rare ghost counts may exist at the hardware level, but are currently
166///   treated as negligible unless a detector-background reference spectrum
167///   is explicitly provided
168///
169/// This is structurally different from the transmission background model
170/// ([`BackgroundConfig`]) because:
171/// - Φ and B are reference spectra, not fitted per pixel
172/// - α₁ and α₂ are optional per-pixel scale corrections
173/// - All terms are non-negative (required for valid Poisson NLL)
174#[derive(Debug, Clone)]
175pub struct CountsBackgroundConfig {
176    /// Initial normalization scale (default 1.0).
177    pub alpha_1_init: f64,
178    /// Initial background scale (default 1.0).
179    pub alpha_2_init: f64,
180    /// Whether α₁ is free (true) or fixed (false).
181    pub fit_alpha_1: bool,
182    /// Whether α₂ is free (true) or fixed (false).
183    pub fit_alpha_2: bool,
184}
185
186impl Default for CountsBackgroundConfig {
187    fn default() -> Self {
188        Self {
189            alpha_1_init: 1.0,
190            alpha_2_init: 1.0,
191            fit_alpha_1: false,
192            fit_alpha_2: false,
193        }
194    }
195}
196
197// ── Phase 2: UnifiedFitConfig + fit_spectrum_typed ───────────────────────
198
199/// Unified fit configuration for all data types and solvers.
200///
201/// Carries both transmission and counts background configs, and uses
202/// [`SolverConfig`] (which embeds solver-specific tuning).
203#[derive(Debug, Clone)]
204pub struct UnifiedFitConfig {
205    // ── Physics (shared by both engines) ──
206    energies: Vec<f64>,
207    resonance_data: Vec<ResonanceData>,
208    isotope_names: Vec<String>,
209    temperature_k: f64,
210    resolution: Option<ResolutionFunction>,
211    initial_densities: Vec<f64>,
212    fit_temperature: bool,
213    compute_covariance: bool,
214
215    // ── Solver ──
216    solver: SolverConfig,
217
218    // ── Background models (engine-specific) ──
219    /// SAMMY-style background for the transmission engine.
220    transmission_background: Option<BackgroundConfig>,
221    /// Counts-domain background for the counts engine.
222    counts_background: Option<CountsBackgroundConfig>,
223
224    // ── Precomputed caches (injected by spatial_map_typed) ──
225    precomputed_cross_sections: Option<Arc<Vec<Vec<f64>>>>,
226    precomputed_base_xs: Option<Arc<Vec<Vec<f64>>>>,
227
228    // ── Isotope group mapping (optional) ──
229    /// Maps member isotope index → density parameter index.
230    /// `None` = identity mapping (one param per isotope, backward compat).
231    pub(crate) density_indices: Option<Vec<usize>>,
232    /// Fractional ratio per member isotope.
233    /// `None` = all 1.0 (backward compat).
234    pub(crate) density_ratios: Option<Vec<f64>>,
235    /// Number of density parameters (groups or isotopes).
236    /// `None` = `resonance_data.len()` (backward compat).
237    n_density_params: Option<usize>,
238}
239
240impl UnifiedFitConfig {
241    /// Construct a new config with validation.
242    pub fn new(
243        energies: Vec<f64>,
244        resonance_data: Vec<ResonanceData>,
245        isotope_names: Vec<String>,
246        temperature_k: f64,
247        resolution: Option<ResolutionFunction>,
248        initial_densities: Vec<f64>,
249    ) -> Result<Self, FitConfigError> {
250        if energies.is_empty() {
251            return Err(FitConfigError::EmptyEnergies);
252        }
253        if resonance_data.is_empty() {
254            return Err(FitConfigError::EmptyResonanceData);
255        }
256        if initial_densities.len() != resonance_data.len() {
257            return Err(FitConfigError::DensityCountMismatch {
258                densities: initial_densities.len(),
259                isotopes: resonance_data.len(),
260            });
261        }
262        if isotope_names.len() != resonance_data.len() {
263            return Err(FitConfigError::NameCountMismatch {
264                names: isotope_names.len(),
265                isotopes: resonance_data.len(),
266            });
267        }
268        if !temperature_k.is_finite() {
269            return Err(FitConfigError::NonFiniteTemperature(temperature_k));
270        }
271        if temperature_k < 0.0 {
272            return Err(FitConfigError::NegativeTemperature(temperature_k));
273        }
274        Ok(Self {
275            energies,
276            resonance_data,
277            isotope_names,
278            temperature_k,
279            resolution,
280            initial_densities,
281            fit_temperature: false,
282            compute_covariance: true,
283            solver: SolverConfig::Auto,
284            transmission_background: None,
285            counts_background: None,
286            precomputed_cross_sections: None,
287            precomputed_base_xs: None,
288            density_indices: None,
289            density_ratios: None,
290            n_density_params: None,
291        })
292    }
293
294    // ── Builder methods ──
295
296    #[must_use]
297    pub fn with_solver(mut self, solver: SolverConfig) -> Self {
298        self.solver = solver;
299        self
300    }
301
302    #[must_use]
303    pub fn with_fit_temperature(mut self, v: bool) -> Self {
304        self.fit_temperature = v;
305        self
306    }
307
308    #[must_use]
309    pub fn with_compute_covariance(mut self, v: bool) -> Self {
310        self.compute_covariance = v;
311        self
312    }
313
314    #[must_use]
315    pub fn with_transmission_background(mut self, bg: BackgroundConfig) -> Self {
316        self.transmission_background = Some(bg);
317        self
318    }
319
320    #[must_use]
321    pub fn with_counts_background(mut self, bg: CountsBackgroundConfig) -> Self {
322        self.counts_background = Some(bg);
323        self
324    }
325
326    #[must_use]
327    pub fn with_precomputed_cross_sections(mut self, xs: Arc<Vec<Vec<f64>>>) -> Self {
328        self.precomputed_cross_sections = Some(xs);
329        self
330    }
331
332    #[must_use]
333    pub fn with_precomputed_base_xs(mut self, xs: Arc<Vec<Vec<f64>>>) -> Self {
334        self.precomputed_base_xs = Some(xs);
335        self
336    }
337
338    /// Configure isotope groups with ratio constraints.
339    ///
340    /// Each group binds multiple isotopes to one fitted density parameter.
341    /// `groups` is a slice of `(IsotopeGroup, member_resonance_data)` pairs.
342    /// `initial_densities` must have one entry per group.
343    ///
344    /// Replaces the existing per-isotope configuration with the expanded
345    /// group mapping (flattened resonance_data + density_indices + density_ratios).
346    pub fn with_groups(
347        mut self,
348        groups: &[(&nereids_core::types::IsotopeGroup, &[ResonanceData])],
349        initial_densities: Vec<f64>,
350    ) -> Result<Self, FitConfigError> {
351        if groups.is_empty() {
352            return Err(FitConfigError::EmptyResonanceData);
353        }
354        if initial_densities.len() != groups.len() {
355            return Err(FitConfigError::DensityCountMismatch {
356                densities: initial_densities.len(),
357                isotopes: groups.len(),
358            });
359        }
360        let mut all_resonance_data = Vec::new();
361        let mut all_indices = Vec::new();
362        let mut all_ratios = Vec::new();
363        let mut names = Vec::new();
364        for (g_idx, (group, rd_list)) in groups.iter().enumerate() {
365            if rd_list.len() != group.n_members() {
366                return Err(FitConfigError::GroupMemberCountMismatch {
367                    group_name: group.name().to_string(),
368                    rd_count: rd_list.len(),
369                    member_count: group.n_members(),
370                });
371            }
372            names.push(group.name().to_string());
373            for ((isotope, ratio), rd) in group.members().iter().zip(rd_list.iter()) {
374                // Validate that the ResonanceData matches the expected member isotope.
375                if rd.isotope != *isotope {
376                    return Err(FitConfigError::GroupMemberIsotopeMismatch {
377                        group_name: group.name().to_string(),
378                        expected_z: isotope.z(),
379                        expected_a: isotope.a(),
380                        got_z: rd.isotope.z(),
381                        got_a: rd.isotope.a(),
382                    });
383                }
384                all_resonance_data.push(rd.clone());
385                all_indices.push(g_idx);
386                all_ratios.push(*ratio);
387            }
388        }
389        self.resonance_data = all_resonance_data;
390        self.isotope_names = names;
391        self.initial_densities = initial_densities;
392        self.n_density_params = Some(groups.len());
393        self.density_indices = Some(all_indices);
394        self.density_ratios = Some(all_ratios);
395        // Clear stale caches — the isotope set changed.
396        self.precomputed_cross_sections = None;
397        self.precomputed_base_xs = None;
398        Ok(self)
399    }
400
401    // ── Accessors ──
402
403    pub fn energies(&self) -> &[f64] {
404        &self.energies
405    }
406    pub fn resonance_data(&self) -> &[ResonanceData] {
407        &self.resonance_data
408    }
409    pub fn isotope_names(&self) -> &[String] {
410        &self.isotope_names
411    }
412    pub fn temperature_k(&self) -> f64 {
413        self.temperature_k
414    }
415    pub fn resolution(&self) -> Option<&ResolutionFunction> {
416        self.resolution.as_ref()
417    }
418    pub fn initial_densities(&self) -> &[f64] {
419        &self.initial_densities
420    }
421    pub fn solver(&self) -> &SolverConfig {
422        &self.solver
423    }
424    pub fn fit_temperature(&self) -> bool {
425        self.fit_temperature
426    }
427    pub fn transmission_background(&self) -> Option<&BackgroundConfig> {
428        self.transmission_background.as_ref()
429    }
430    pub fn counts_background(&self) -> Option<&CountsBackgroundConfig> {
431        self.counts_background.as_ref()
432    }
433    pub fn precomputed_cross_sections(&self) -> Option<&Arc<Vec<Vec<f64>>>> {
434        self.precomputed_cross_sections.as_ref()
435    }
436    /// Number of density parameters (one per group or per isotope).
437    pub fn n_density_params(&self) -> usize {
438        self.n_density_params.unwrap_or(self.resonance_data.len())
439    }
440
441    /// Resolve `SolverConfig::Auto` into a concrete solver for the given input.
442    pub(crate) fn effective_solver(&self, input: &InputData) -> SolverConfig {
443        match &self.solver {
444            SolverConfig::Auto => {
445                if input.is_counts() {
446                    SolverConfig::PoissonKL(PoissonConfig::default())
447                } else {
448                    SolverConfig::LevenbergMarquardt(LmConfig::default())
449                }
450            }
451            other => other.clone(),
452        }
453    }
454}
455
456/// Fit a single spectrum using the typed input data API.
457///
458/// Dispatches to the correct fitting engine based on the `InputData` variant
459/// and solver configuration:
460///
461/// | Input | Solver | Path |
462/// |-------|--------|------|
463/// | Transmission | LM | LM chi-squared with optional SAMMY background |
464/// | Transmission | KL | Poisson NLL on transmission with optional background |
465/// | Counts | KL | Poisson NLL on raw counts (statistically optimal) |
466/// | Counts | LM | Convert to T internally and route to LM |
467/// | CountsWithNuisance | KL | Direct Poisson with user-supplied nuisance |
468pub fn fit_spectrum_typed(
469    input: &InputData,
470    config: &UnifiedFitConfig,
471) -> Result<SpectrumFitResult, PipelineError> {
472    let n_e = config.energies().len();
473
474    // Validate temperature when fitting is requested
475    if config.fit_temperature && config.temperature_k < 1.0 {
476        return Err(PipelineError::InvalidParameter(format!(
477            "temperature must be >= 1.0 K when fit_temperature is true, got {}",
478            config.temperature_k,
479        )));
480    }
481
482    // Validate input length matches energy grid
483    if input.n_energies() != n_e {
484        return Err(PipelineError::ShapeMismatch(format!(
485            "input data has {} energy bins but config.energies has {}",
486            input.n_energies(),
487            n_e,
488        )));
489    }
490
491    // Validate auxiliary array lengths match the primary data
492    match input {
493        InputData::Transmission {
494            transmission,
495            uncertainty,
496        } => {
497            if uncertainty.len() != transmission.len() {
498                return Err(PipelineError::ShapeMismatch(format!(
499                    "uncertainty length {} != transmission length {}",
500                    uncertainty.len(),
501                    transmission.len(),
502                )));
503            }
504        }
505        InputData::Counts {
506            sample_counts,
507            open_beam_counts,
508        } => {
509            if open_beam_counts.len() != sample_counts.len() {
510                return Err(PipelineError::ShapeMismatch(format!(
511                    "open_beam_counts length {} != sample_counts length {}",
512                    open_beam_counts.len(),
513                    sample_counts.len(),
514                )));
515            }
516        }
517        InputData::CountsWithNuisance {
518            sample_counts,
519            flux,
520            background,
521        } => {
522            if flux.len() != sample_counts.len() {
523                return Err(PipelineError::ShapeMismatch(format!(
524                    "flux length {} != sample_counts length {}",
525                    flux.len(),
526                    sample_counts.len(),
527                )));
528            }
529            if background.len() != sample_counts.len() {
530                return Err(PipelineError::ShapeMismatch(format!(
531                    "background length {} != sample_counts length {}",
532                    background.len(),
533                    sample_counts.len(),
534                )));
535            }
536        }
537    }
538
539    let effective_solver = config.effective_solver(input);
540
541    match (input, &effective_solver) {
542        // ── Transmission + LM: the well-tested path ──
543        (
544            InputData::Transmission {
545                transmission,
546                uncertainty,
547            },
548            SolverConfig::LevenbergMarquardt(lm_cfg),
549        ) => fit_transmission_lm(transmission, uncertainty, config, lm_cfg),
550
551        // ── Transmission + KL: Poisson NLL on transmission values ──
552        (
553            InputData::Transmission {
554                transmission,
555                uncertainty,
556            },
557            SolverConfig::PoissonKL(poisson_cfg),
558        ) => fit_transmission_poisson(transmission, uncertainty, config, poisson_cfg),
559
560        // ── Counts + KL: statistically optimal path ──
561        (
562            InputData::Counts {
563                sample_counts,
564                open_beam_counts,
565            },
566            SolverConfig::PoissonKL(poisson_cfg),
567        ) => {
568            // Convenience counts path:
569            // - open beam is used as the flux reference Φ(E)
570            // - detector-space counts background B_det(E) is currently
571            //   assumed zero unless the caller explicitly supplies nuisance
572            //   spectra via CountsWithNuisance
573            //
574            // This does NOT disable transmission background fitting.
575            // If config.transmission_background is enabled, fit_counts_poisson
576            // still fits the additive transmission-lift terms [b0, b1] inside
577            // the bracketed transmission model:
578            //   Y(E) = Φ(E) * [T(E) + b0 + b1/sqrt(E)] + B_det(E)
579            //
580            // That transmission-lift background is the mechanism currently
581            // used to absorb gamma-tail structure in VENUS-style data.
582            let flux: Vec<f64> = open_beam_counts.to_vec();
583            let background = vec![0.0f64; n_e];
584            fit_counts_poisson(sample_counts, &flux, &background, config, poisson_cfg)
585        }
586
587        // ── CountsWithNuisance + KL: user-supplied nuisance ──
588        (
589            InputData::CountsWithNuisance {
590                sample_counts,
591                flux,
592                background,
593            },
594            SolverConfig::PoissonKL(poisson_cfg),
595        ) => fit_counts_poisson(sample_counts, flux, background, config, poisson_cfg),
596
597        // ── Counts + LM: convert to transmission (approximate path) ──
598        //
599        // This is NOT a native counts-domain LM engine.  Counts are divided
600        // (sample/OB) to produce transmission, with σ ≈ √max(sample,1)/OB
601        // as a simplified Poisson-to-Gaussian conversion.  Poisson structure
602        // is lost.  For statistically correct low-count fitting, use the
603        // Poisson KL solver (`solver="kl"` or `SolverConfig::Auto`).
604        (
605            InputData::Counts {
606                sample_counts,
607                open_beam_counts,
608            },
609            SolverConfig::LevenbergMarquardt(lm_cfg),
610        ) => {
611            let (transmission, uncertainty) =
612                counts_to_transmission(sample_counts, open_beam_counts);
613            fit_transmission_lm(&transmission, &uncertainty, config, lm_cfg)
614        }
615
616        // ── CountsWithNuisance + LM: not meaningful ──
617        (InputData::CountsWithNuisance { .. }, SolverConfig::LevenbergMarquardt(_)) => {
618            Err(PipelineError::InvalidParameter(
619                "CountsWithNuisance requires PoissonKL solver (LM cannot use nuisance parameters)"
620                    .into(),
621            ))
622        }
623
624        // Auto should be resolved by effective_solver
625        (_, SolverConfig::Auto) => unreachable!("Auto should be resolved before dispatch"),
626    }
627}
628
629/// Convert counts to transmission: T = sample/open_beam, σ = √(max(sample,1))/open_beam.
630///
631/// Zero-count bins (sample == 0) get σ = 1e10 so the fitter effectively ignores them.
632/// Near-zero open beam bins use a floor of 1e-10 to avoid division by zero.
633/// Convert raw counts to transmission with approximate Poisson uncertainty.
634///
635/// This is a simplified conversion for the Counts+LM fallback path.
636/// The uncertainty σ ≈ √max(sample,1)/OB is a Gaussian approximation of
637/// Poisson statistics, valid when counts are high (≥ ~20).  At low counts,
638/// this overestimates confidence relative to the Poisson KL solver.
639///
640/// Zero-count and zero-OB bins are marked with sentinel uncertainties
641/// (1e10 and 1e30 respectively) so the LM solver effectively ignores them.
642fn counts_to_transmission(sample: &[f64], open_beam: &[f64]) -> (Vec<f64>, Vec<f64>) {
643    let transmission: Vec<f64> = sample
644        .iter()
645        .zip(open_beam.iter())
646        .map(|(&s, &ob)| if ob > 0.0 { s / ob } else { 0.0 })
647        .collect();
648    let uncertainty: Vec<f64> = sample
649        .iter()
650        .zip(open_beam.iter())
651        .map(|(&s, &ob)| {
652            if ob <= 0.0 {
653                // No open beam signal — treat as dead bin
654                1e30
655            } else if s <= 0.0 {
656                // Zero sample counts — large σ so the fitter ignores this bin
657                1e10
658            } else {
659                s.max(1.0).sqrt() / ob.max(1e-10)
660            }
661        })
662        .collect();
663    (transmission, uncertainty)
664}
665
666/// Transmission + LM path.
667fn fit_transmission_lm(
668    measured_t: &[f64],
669    sigma: &[f64],
670    config: &UnifiedFitConfig,
671    lm_config: &LmConfig,
672) -> Result<SpectrumFitResult, PipelineError> {
673    let n_density_params = config.n_density_params();
674
675    // Build parameter vector
676    let mut param_vec = build_density_params(config);
677
678    // Temperature parameter
679    let _temperature_index = if config.fit_temperature {
680        param_vec.push(FitParameter {
681            name: "temperature_k".into(),
682            value: config.temperature_k,
683            lower: 1.0,
684            upper: 5000.0,
685            fixed: false,
686        });
687        Some(n_density_params)
688    } else {
689        None
690    };
691
692    // Background parameters
693    let bg_base = param_vec.len();
694    let bg_indices = if let Some(bg) = &config.transmission_background {
695        append_background_params(&mut param_vec, bg);
696        Some((bg_base, bg_base + 1, bg_base + 2, bg_base + 3))
697    } else {
698        None
699    };
700
701    let mut params = ParameterSet::new(param_vec);
702    let mut lm_cfg = lm_config.clone();
703    lm_cfg.compute_covariance = config.compute_covariance;
704
705    // Build model
706    let model = build_transmission_model(config, n_density_params, _temperature_index)?;
707
708    // Dispatch with optional background wrapping
709    let result = if let Some((ai, bai, bbi, bci)) = bg_indices {
710        let wrapped =
711            NormalizedTransmissionModel::new(&*model, config.energies(), ai, bai, bbi, bci);
712        lm::levenberg_marquardt(&wrapped, measured_t, sigma, &mut params, &lm_cfg)?
713    } else {
714        lm::levenberg_marquardt(&*model, measured_t, sigma, &mut params, &lm_cfg)?
715    };
716
717    extract_result(config, &result, n_density_params, bg_indices)
718}
719
720/// Transmission + Poisson KL path.
721fn fit_transmission_poisson(
722    measured_t: &[f64],
723    sigma: &[f64],
724    config: &UnifiedFitConfig,
725    poisson_cfg: &PoissonConfig,
726) -> Result<SpectrumFitResult, PipelineError> {
727    // Forward covariance flag from UnifiedFitConfig to PoissonConfig.
728    let mut poisson_cfg = poisson_cfg.clone();
729    poisson_cfg.compute_covariance = config.compute_covariance;
730    let poisson_cfg = &poisson_cfg;
731
732    let n_density_params = config.n_density_params();
733
734    let mut param_vec = build_density_params(config);
735
736    // Temperature parameter (appended after densities, before background).
737    let temperature_index = if config.fit_temperature {
738        let idx = param_vec.len();
739        param_vec.push(FitParameter {
740            name: "temperature_k".into(),
741            value: config.temperature_k,
742            lower: 1.0,
743            upper: 5000.0,
744            fixed: false,
745        });
746        Some(idx)
747    } else {
748        None
749    };
750
751    // KL transmission background model: T_out = T_inner + b₀ + b₁/√E
752    let bg_base = param_vec.len();
753    let kl_bg = if config.transmission_background.is_some() {
754        param_vec.push(FitParameter {
755            name: "kl_b0".into(),
756            value: 0.0,
757            lower: 0.0,
758            upper: 0.5,
759            fixed: false,
760        });
761        param_vec.push(FitParameter {
762            name: "kl_b1".into(),
763            value: 0.0,
764            lower: 0.0,
765            upper: 0.5,
766            fixed: false,
767        });
768        Some((bg_base, bg_base + 1))
769    } else {
770        None
771    };
772
773    let mut params = ParameterSet::new(param_vec);
774
775    let model = build_transmission_model(config, n_density_params, temperature_index)?;
776
777    let polish_cfg = if kl_bg.is_some() && temperature_index.is_some() {
778        let mut cfg = poisson_cfg.clone();
779        cfg.max_iter = cfg.max_iter.clamp(20, 80);
780        cfg.tol_param = (cfg.tol_param * 1e-3).max(1e-12);
781        cfg.gauss_newton_lambda = (cfg.gauss_newton_lambda * 0.1).max(1e-8);
782        Some(cfg)
783    } else {
784        None
785    };
786
787    // Dispatch with KL-native background model or bare model.
788    let result = if let Some((b0_idx, b1_idx)) = kl_bg {
789        let inv_sqrt_e: Vec<f64> = config
790            .energies()
791            .iter()
792            .map(|&e| 1.0 / e.max(1e-10).sqrt())
793            .collect();
794        let wrapped = poisson::TransmissionKLBackgroundModel {
795            inner: &*model,
796            inv_sqrt_energies: inv_sqrt_e,
797            b0_index: b0_idx,
798            b1_index: b1_idx,
799            n_params: params.params.len(),
800        };
801        // When polish is planned, skip covariance on the first fit (it will
802        // be discarded). Only the polish computes covariance.
803        let first_cfg = if polish_cfg.is_some() {
804            let mut cfg = poisson_cfg.clone();
805            cfg.compute_covariance = false;
806            cfg
807        } else {
808            poisson_cfg.clone()
809        };
810        let mut pr = poisson::poisson_fit(&wrapped, measured_t, &mut params, &first_cfg)?;
811        if pr.converged
812            && let Some(ref polish) = polish_cfg
813        {
814            let polish_result = poisson::poisson_fit(&wrapped, measured_t, &mut params, polish)?;
815            if polish_result.converged {
816                pr = poisson::PoissonResult {
817                    iterations: pr.iterations + polish_result.iterations,
818                    ..polish_result
819                };
820            }
821        }
822        poisson_to_lm_result(&wrapped, measured_t, sigma, &pr, &params)
823    } else {
824        let pr = poisson::poisson_fit(&*model, measured_t, &mut params, poisson_cfg)?;
825        poisson_to_lm_result(&*model, measured_t, sigma, &pr, &params)
826    }?;
827
828    let densities: Vec<f64> = (0..n_density_params).map(|i| result.params[i]).collect();
829    let uncertainties = if result.converged {
830        result.covariance.as_ref().map(|cov| {
831            (0..n_density_params)
832                .map(|i| cov.get(i, i).sqrt())
833                .collect::<Vec<_>>()
834        })
835    } else {
836        None
837    };
838
839    // Extract temperature from result if fitted.
840    let fitted_temp = temperature_index.map(|idx| result.params[idx]);
841    let fitted_temp_unc = temperature_index.and_then(|idx| {
842        result
843            .uncertainties
844            .as_ref()
845            .and_then(|u| u.get(idx).copied())
846    });
847
848    if let Some((b0_idx, b1_idx)) = kl_bg {
849        let b0 = result.params[b0_idx];
850        let b1 = result.params[b1_idx];
851        Ok(SpectrumFitResult {
852            densities,
853            uncertainties,
854            reduced_chi_squared: result.reduced_chi_squared,
855            converged: result.converged,
856            iterations: result.iterations,
857            temperature_k: fitted_temp,
858            temperature_k_unc: fitted_temp_unc,
859            anorm: 1.0,
860            background: [b0, b1, 0.0],
861        })
862    } else {
863        let mut sr = extract_result(config, &result, n_density_params, None)?;
864        if let Some(t) = fitted_temp {
865            sr.temperature_k = Some(t);
866        }
867        Ok(sr)
868    }
869}
870
871/// Counts + Poisson KL path (statistically optimal).
872fn fit_counts_poisson(
873    sample_counts: &[f64],
874    flux: &[f64],
875    background: &[f64],
876    config: &UnifiedFitConfig,
877    poisson_cfg: &PoissonConfig,
878) -> Result<SpectrumFitResult, PipelineError> {
879    // Forward covariance flag from UnifiedFitConfig to PoissonConfig.
880    let mut poisson_cfg = poisson_cfg.clone();
881    poisson_cfg.compute_covariance = config.compute_covariance;
882    let poisson_cfg = &poisson_cfg;
883
884    let n_density_params = config.n_density_params();
885
886    let mut param_vec = build_density_params(config);
887
888    // Temperature parameter (after densities).
889    let temperature_index = if config.fit_temperature {
890        let idx = param_vec.len();
891        param_vec.push(FitParameter {
892            name: "temperature_k".into(),
893            value: config.temperature_k,
894            lower: 1.0,
895            upper: 5000.0,
896            fixed: false,
897        });
898        Some(idx)
899    } else {
900        None
901    };
902
903    // KL background model: T_out = T_inner + b₀ + b₁/√E
904    let bg_base = param_vec.len();
905    let kl_bg = if config.transmission_background.is_some() {
906        param_vec.push(FitParameter {
907            name: "kl_b0".into(),
908            value: 0.0,
909            lower: 0.0,
910            upper: 0.5,
911            fixed: false,
912        });
913        param_vec.push(FitParameter {
914            name: "kl_b1".into(),
915            value: 0.0,
916            lower: 0.0,
917            upper: 0.5,
918            fixed: false,
919        });
920        Some((bg_base, bg_base + 1))
921    } else {
922        None
923    };
924
925    let counts_bg = if let Some(bg) = config.counts_background() {
926        // CountsBackgroundConfig only scales a supplied detector/background
927        // reference spectrum. It does not invent one from the open beam.
928        //
929        // If the caller wants to fit alpha_2, they must provide a nonzero
930        // background reference. This is deliberate: for MCP/TPX event data,
931        // any residual ghost/detector counts are currently treated as
932        // negligible unless independently characterized.
933        if bg.fit_alpha_1 && flux.iter().all(|&v| v.abs() <= 1e-12) {
934            return Err(PipelineError::InvalidParameter(
935                "counts background alpha_1 cannot be fitted with zero flux reference".into(),
936            ));
937        }
938        if bg.fit_alpha_2 && background.iter().all(|&v| v.abs() <= 1e-12) {
939            return Err(PipelineError::InvalidParameter(
940                "counts background alpha_2 cannot be fitted with zero detector background reference"
941                    .into(),
942            ));
943        }
944
945        let alpha1_idx = param_vec.len();
946        param_vec.push(if bg.fit_alpha_1 {
947            FitParameter {
948                name: "alpha_1".into(),
949                value: bg.alpha_1_init,
950                lower: 0.0,
951                upper: 10.0,
952                fixed: false,
953            }
954        } else {
955            FitParameter::fixed("alpha_1", bg.alpha_1_init)
956        });
957
958        let alpha2_idx = param_vec.len();
959        param_vec.push(if bg.fit_alpha_2 {
960            FitParameter {
961                name: "alpha_2".into(),
962                value: bg.alpha_2_init,
963                lower: 0.0,
964                upper: 10.0,
965                fixed: false,
966            }
967        } else {
968            FitParameter::fixed("alpha_2", bg.alpha_2_init)
969        });
970
971        Some((alpha1_idx, alpha2_idx))
972    } else {
973        None
974    };
975
976    let mut params = ParameterSet::new(param_vec);
977
978    let t_model = build_transmission_model(config, n_density_params, temperature_index)?;
979    let n_free = params.n_free();
980    let dof = sample_counts.len().saturating_sub(n_free).max(1);
981
982    let (pr, y_model) = if let Some((b0_idx, b1_idx)) = kl_bg {
983        let inv_sqrt_e: Vec<f64> = config
984            .energies()
985            .iter()
986            .map(|&e| 1.0 / e.max(1e-10).sqrt())
987            .collect();
988        let wrapped = poisson::TransmissionKLBackgroundModel {
989            inner: &*t_model,
990            inv_sqrt_energies: inv_sqrt_e,
991            b0_index: b0_idx,
992            b1_index: b1_idx,
993            n_params: params.params.len(),
994        };
995        let (pr, y_model) = if let Some((alpha1_idx, alpha2_idx)) = counts_bg {
996            let counts_model = poisson::CountsBackgroundScaleModel {
997                transmission_model: &wrapped,
998                flux,
999                background,
1000                alpha1_index: alpha1_idx,
1001                alpha2_index: alpha2_idx,
1002                n_params: params.params.len(),
1003            };
1004            let pr = poisson::poisson_fit(&counts_model, sample_counts, &mut params, poisson_cfg)?;
1005            let y_model = counts_model.evaluate(&pr.params)?;
1006            (pr, y_model)
1007        } else {
1008            let counts_model = poisson::CountsModel {
1009                transmission_model: &wrapped,
1010                flux,
1011                background,
1012                n_params: params.params.len(),
1013            };
1014            let pr = poisson::poisson_fit(&counts_model, sample_counts, &mut params, poisson_cfg)?;
1015            let y_model = counts_model.evaluate(&pr.params)?;
1016            (pr, y_model)
1017        };
1018        (pr, y_model)
1019    } else {
1020        let (pr, y_model) = if let Some((alpha1_idx, alpha2_idx)) = counts_bg {
1021            let counts_model = poisson::CountsBackgroundScaleModel {
1022                transmission_model: &*t_model,
1023                flux,
1024                background,
1025                alpha1_index: alpha1_idx,
1026                alpha2_index: alpha2_idx,
1027                n_params: params.params.len(),
1028            };
1029            let pr = poisson::poisson_fit(&counts_model, sample_counts, &mut params, poisson_cfg)?;
1030            let y_model = counts_model.evaluate(&pr.params)?;
1031            (pr, y_model)
1032        } else {
1033            // Wrap in counts model: Y = flux * T(theta) + background
1034            let counts_model = poisson::CountsModel {
1035                transmission_model: &*t_model,
1036                flux,
1037                background,
1038                n_params: params.params.len(),
1039            };
1040            let pr = poisson::poisson_fit(&counts_model, sample_counts, &mut params, poisson_cfg)?;
1041            let y_model = counts_model.evaluate(&pr.params)?;
1042            (pr, y_model)
1043        };
1044        (pr, y_model)
1045    };
1046
1047    // Compute Pearson chi-squared for display
1048    let chi_sq: f64 = sample_counts
1049        .iter()
1050        .zip(y_model.iter())
1051        .map(|(&obs, &mdl)| {
1052            let expected = mdl.max(1e-10);
1053            (obs - expected).powi(2) / expected
1054        })
1055        .sum();
1056
1057    let densities: Vec<f64> = (0..n_density_params).map(|i| pr.params[i]).collect();
1058    let fitted_temp = temperature_index.map(|idx| pr.params[idx]);
1059    let fitted_alpha1 = counts_bg.map_or(1.0, |(alpha1_idx, _)| pr.params[alpha1_idx]);
1060    let fitted_alpha2 = counts_bg.map_or(0.0, |(_, alpha2_idx)| pr.params[alpha2_idx]);
1061    let fitted_background = if let Some((b0_idx, b1_idx)) = kl_bg {
1062        [pr.params[b0_idx], pr.params[b1_idx], fitted_alpha2]
1063    } else {
1064        [0.0, 0.0, fitted_alpha2]
1065    };
1066
1067    // Extract per-parameter uncertainties from the Poisson Fisher covariance.
1068    // The uncertainty vector from PoissonResult is indexed by free-parameter
1069    // position.  Map to density and temperature slots.
1070    let (uncertainties, temperature_k_unc) = if let Some(ref unc_all) = pr.uncertainties {
1071        let dens_unc: Vec<f64> = (0..n_density_params)
1072            .map(|i| *unc_all.get(i).unwrap_or(&f64::NAN))
1073            .collect();
1074        let t_unc = temperature_index.and_then(|idx| {
1075            // Temperature is at free-param position = idx within the param vector.
1076            // Find its position in the free-parameter list.
1077            params
1078                .free_indices()
1079                .iter()
1080                .position(|&fi| fi == idx)
1081                .and_then(|pos| unc_all.get(pos).copied())
1082        });
1083        (Some(dens_unc), t_unc)
1084    } else {
1085        (None, None)
1086    };
1087
1088    Ok(SpectrumFitResult {
1089        densities,
1090        uncertainties,
1091        reduced_chi_squared: chi_sq / dof as f64,
1092        converged: pr.converged,
1093        iterations: pr.iterations,
1094        temperature_k: fitted_temp,
1095        temperature_k_unc,
1096        anorm: fitted_alpha1,
1097        background: fitted_background,
1098    })
1099}
1100
1101// ── Shared helpers for fit_spectrum_typed ──
1102
1103fn build_density_params(config: &UnifiedFitConfig) -> Vec<FitParameter> {
1104    config
1105        .initial_densities
1106        .iter()
1107        .enumerate()
1108        .map(|(i, &d)| {
1109            FitParameter::non_negative(
1110                config
1111                    .isotope_names
1112                    .get(i)
1113                    .cloned()
1114                    .unwrap_or_else(|| format!("isotope_{i}")),
1115                d,
1116            )
1117        })
1118        .collect()
1119}
1120
1121fn append_background_params(param_vec: &mut Vec<FitParameter>, bg: &BackgroundConfig) {
1122    // Anorm bounded to [0.5, 2.0] — physically reasonable normalization range.
1123    // Previously unbounded [0, ∞), which allowed the fitter to absorb signal
1124    // into anorm (e.g., anorm=15.9 with density=0.03×true).
1125    // SAMMY also bounds normalization to a reasonable range.
1126    param_vec.push(if bg.fit_anorm {
1127        FitParameter {
1128            name: "anorm".into(),
1129            value: bg.anorm_init,
1130            lower: 0.5,
1131            upper: 2.0,
1132            fixed: false,
1133        }
1134    } else {
1135        FitParameter::fixed("anorm", bg.anorm_init)
1136    });
1137    // Background terms bounded to [-0.5, 0.5].
1138    // These are small corrections to the transmission baseline.
1139    // Unbounded background allows the fitter to absorb resonance signal
1140    // into the background polynomial, producing meaningless densities.
1141    // SAMMY also constrains background to reasonable ranges.
1142    param_vec.push(if bg.fit_back_a {
1143        FitParameter {
1144            name: "back_a".into(),
1145            value: bg.back_a_init,
1146            lower: -0.5,
1147            upper: 0.5,
1148            fixed: false,
1149        }
1150    } else {
1151        FitParameter::fixed("back_a", bg.back_a_init)
1152    });
1153    param_vec.push(if bg.fit_back_b {
1154        FitParameter {
1155            name: "back_b".into(),
1156            value: bg.back_b_init,
1157            lower: -0.5,
1158            upper: 0.5,
1159            fixed: false,
1160        }
1161    } else {
1162        FitParameter::fixed("back_b", bg.back_b_init)
1163    });
1164    param_vec.push(if bg.fit_back_c {
1165        FitParameter {
1166            name: "back_c".into(),
1167            value: bg.back_c_init,
1168            lower: -0.5,
1169            upper: 0.5,
1170            fixed: false,
1171        }
1172    } else {
1173        FitParameter::fixed("back_c", bg.back_c_init)
1174    });
1175}
1176
1177/// Build the transmission forward model, selecting precomputed or full path.
1178fn build_transmission_model(
1179    config: &UnifiedFitConfig,
1180    n_density_params: usize,
1181    temperature_index: Option<usize>,
1182) -> Result<Box<dyn FitModel>, PipelineError> {
1183    let n_params = config.n_density_params();
1184    if !config.fit_temperature
1185        && let Some(xs) = &config.precomputed_cross_sections
1186    {
1187        // When groups are active, compute σ_eff per group from member XS.
1188        // For ungrouped isotopes, this is a no-op (identity mapping, ratio=1.0).
1189        let effective_xs =
1190            if let (Some(di), Some(dr)) = (&config.density_indices, &config.density_ratios) {
1191                // Only collapse when XS is per-member (shape matches mapping).
1192                // If XS is already group-collapsed (len == n_params), skip.
1193                if xs.len() == di.len() && di.len() == dr.len() {
1194                    let n_e = xs[0].len();
1195                    let mut eff = vec![vec![0.0f64; n_e]; n_params];
1196                    for ((&idx, &ratio), member_xs) in di.iter().zip(dr.iter()).zip(xs.iter()) {
1197                        for (j, &sigma) in member_xs.iter().enumerate() {
1198                            eff[idx][j] += ratio * sigma;
1199                        }
1200                    }
1201                    Arc::new(eff)
1202                } else {
1203                    Arc::clone(xs)
1204                }
1205            } else {
1206                Arc::clone(xs)
1207            };
1208        // Issue #442: pass energies + instrument so evaluate() applies
1209        // resolution after Beer-Lambert on total transmission.
1210        let instrument = config
1211            .resolution
1212            .clone()
1213            .map(|r| Arc::new(InstrumentParams { resolution: r }));
1214        return Ok(Box::new(PrecomputedTransmissionModel {
1215            cross_sections: effective_xs,
1216            density_indices: Arc::new((0..n_params).collect()),
1217            energies: instrument
1218                .as_ref()
1219                .map(|_| Arc::new(config.energies.clone())),
1220            instrument,
1221        }));
1222    }
1223
1224    let instrument = config
1225        .resolution
1226        .clone()
1227        .map(|r| Arc::new(InstrumentParams { resolution: r }));
1228
1229    let base_xs = config.precomputed_base_xs.clone();
1230    let density_ratios = config
1231        .density_ratios
1232        .clone()
1233        .unwrap_or_else(|| vec![1.0; n_density_params]);
1234    let density_indices = config
1235        .density_indices
1236        .clone()
1237        .unwrap_or_else(|| (0..n_density_params).collect());
1238    Ok(Box::new(TransmissionFitModel::new(
1239        config.energies.clone(),
1240        config.resonance_data.clone(),
1241        config.temperature_k,
1242        instrument,
1243        (density_indices, density_ratios),
1244        temperature_index,
1245        base_xs,
1246    )?))
1247}
1248
1249/// Convert PoissonResult to LmResult with Pearson chi-squared.
1250fn poisson_to_lm_result(
1251    model: &dyn FitModel,
1252    measured_t: &[f64],
1253    sigma: &[f64],
1254    pr: &poisson::PoissonResult,
1255    params: &ParameterSet,
1256) -> Result<LmResult, PipelineError> {
1257    let n_free = params.n_free();
1258    let dof = measured_t.len().saturating_sub(n_free).max(1);
1259    let y_model = model.evaluate(&pr.params)?;
1260    let chi_sq: f64 = measured_t
1261        .iter()
1262        .zip(y_model.iter())
1263        .zip(sigma.iter())
1264        .map(|((obs, mdl), s)| {
1265            let residual = obs - mdl;
1266            (residual * residual) / (s * s).max(1e-30)
1267        })
1268        .sum();
1269    Ok(LmResult {
1270        chi_squared: chi_sq,
1271        reduced_chi_squared: chi_sq / dof as f64,
1272        iterations: pr.iterations,
1273        converged: pr.converged,
1274        params: pr.params.clone(),
1275        covariance: pr.covariance.clone(),
1276        uncertainties: pr.uncertainties.clone(),
1277    })
1278}
1279
1280/// Extract SpectrumFitResult from solver output.
1281fn extract_result(
1282    config: &UnifiedFitConfig,
1283    result: &LmResult,
1284    n_density_params: usize,
1285    bg_indices: Option<(usize, usize, usize, usize)>,
1286) -> Result<SpectrumFitResult, PipelineError> {
1287    let densities: Vec<f64> = (0..n_density_params).map(|i| result.params[i]).collect();
1288
1289    let (anorm, background) = if let Some((ai, bai, bbi, bci)) = bg_indices {
1290        (
1291            result.params[ai],
1292            [result.params[bai], result.params[bbi], result.params[bci]],
1293        )
1294    } else {
1295        (1.0, [0.0, 0.0, 0.0])
1296    };
1297
1298    let (uncertainties, temperature_k, temperature_k_unc) = if result.converged {
1299        match &result.uncertainties {
1300            Some(unc_all) => {
1301                let (temp_k, temp_unc) = if config.fit_temperature {
1302                    (
1303                        Some(result.params[n_density_params]),
1304                        Some(*unc_all.get(n_density_params).unwrap_or(&f64::NAN)),
1305                    )
1306                } else {
1307                    (None, None)
1308                };
1309                let unc = unc_all
1310                    .get(..n_density_params)
1311                    .map(|s| s.to_vec())
1312                    .unwrap_or_else(|| vec![f64::NAN; n_density_params]);
1313                (Some(unc), temp_k, temp_unc)
1314            }
1315            None => {
1316                let temp_k = if config.fit_temperature {
1317                    Some(result.params[n_density_params])
1318                } else {
1319                    None
1320                };
1321                (None, temp_k, None)
1322            }
1323        }
1324    } else {
1325        let temp_k = if config.fit_temperature {
1326            Some(result.params[n_density_params])
1327        } else {
1328            None
1329        };
1330        (None, temp_k, None)
1331    };
1332
1333    Ok(SpectrumFitResult {
1334        densities,
1335        uncertainties,
1336        reduced_chi_squared: result.reduced_chi_squared,
1337        converged: result.converged,
1338        iterations: result.iterations,
1339        temperature_k,
1340        temperature_k_unc,
1341        anorm,
1342        background,
1343    })
1344}
1345
1346// ── End Phase 2 ──────────────────────────────────────────────────────────
1347
1348/// Errors from `FitConfig` construction.
1349#[derive(Debug, PartialEq)]
1350pub enum FitConfigError {
1351    /// Energy grid must be non-empty.
1352    EmptyEnergies,
1353    /// Resonance data must be non-empty.
1354    EmptyResonanceData,
1355    /// initial_densities length must match resonance_data length.
1356    DensityCountMismatch { densities: usize, isotopes: usize },
1357    /// isotope_names length must match resonance_data length.
1358    NameCountMismatch { names: usize, isotopes: usize },
1359    /// resonance_data count must match group member count.
1360    GroupMemberCountMismatch {
1361        group_name: String,
1362        rd_count: usize,
1363        member_count: usize,
1364    },
1365    /// ResonanceData isotope doesn't match expected group member.
1366    GroupMemberIsotopeMismatch {
1367        group_name: String,
1368        expected_z: u32,
1369        expected_a: u32,
1370        got_z: u32,
1371        got_a: u32,
1372    },
1373    /// Temperature must be finite.
1374    NonFiniteTemperature(f64),
1375    /// Temperature must be non-negative.
1376    NegativeTemperature(f64),
1377}
1378
1379impl fmt::Display for FitConfigError {
1380    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1381        match self {
1382            Self::EmptyEnergies => write!(f, "energy grid must be non-empty"),
1383            Self::EmptyResonanceData => write!(f, "resonance_data must be non-empty"),
1384            Self::DensityCountMismatch {
1385                densities,
1386                isotopes,
1387            } => write!(
1388                f,
1389                "initial_densities length ({densities}) must match number of density parameters ({isotopes})"
1390            ),
1391            Self::NameCountMismatch { names, isotopes } => write!(
1392                f,
1393                "isotope_names length ({names}) must match resonance_data length ({isotopes})"
1394            ),
1395            Self::GroupMemberCountMismatch {
1396                group_name,
1397                rd_count,
1398                member_count,
1399            } => write!(
1400                f,
1401                "group '{group_name}': provided {rd_count} ResonanceData but group has {member_count} members"
1402            ),
1403            Self::GroupMemberIsotopeMismatch {
1404                group_name,
1405                expected_z,
1406                expected_a,
1407                got_z,
1408                got_a,
1409            } => write!(
1410                f,
1411                "group '{group_name}': expected Z={expected_z} A={expected_a} but got Z={got_z} A={got_a}"
1412            ),
1413            Self::NonFiniteTemperature(v) => {
1414                write!(f, "temperature must be finite, got {v}")
1415            }
1416            Self::NegativeTemperature(v) => {
1417                write!(f, "temperature must be non-negative, got {v}")
1418            }
1419        }
1420    }
1421}
1422
1423impl std::error::Error for FitConfigError {}
1424
1425/// Result of fitting a single spectrum.
1426#[derive(Debug, Clone)]
1427pub struct SpectrumFitResult {
1428    /// Fitted areal densities (atoms/barn), one per isotope.
1429    pub densities: Vec<f64>,
1430    /// Uncertainty on each density.
1431    ///
1432    /// `None` when covariance computation was skipped.
1433    pub uncertainties: Option<Vec<f64>>,
1434    /// Reduced chi-squared of the fit.
1435    pub reduced_chi_squared: f64,
1436    /// Whether the fit converged.
1437    pub converged: bool,
1438    /// Number of iterations.
1439    pub iterations: usize,
1440    /// Fitted temperature in Kelvin (only when `fit_temperature` is true).
1441    pub temperature_k: Option<f64>,
1442    /// 1-sigma uncertainty on the fitted temperature (from covariance matrix).
1443    pub temperature_k_unc: Option<f64>,
1444    /// Fitted normalization / signal-scale parameter.
1445    /// Transmission LM uses `Anorm`; counts background scaling uses `alpha_1`.
1446    pub anorm: f64,
1447    /// Fitted background parameter triplet.
1448    /// Transmission LM uses `[BackA, BackB, BackC]`.
1449    /// Counts KL background uses `[b0, b1, alpha_2]`.
1450    pub background: [f64; 3],
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455    use super::*;
1456    use crate::test_helpers::synthetic_single_resonance;
1457    use nereids_fitting::lm::FitModel;
1458    use nereids_physics::transmission as phys_transmission;
1459
1460    use crate::test_helpers::u238_single_resonance;
1461
1462    // ── Phase 0: InputData + SolverConfig + CountsBackgroundConfig tests ──
1463
1464    #[test]
1465    fn test_input_data_transmission_n_energies() {
1466        let data = InputData::Transmission {
1467            transmission: vec![0.9, 0.8, 0.7],
1468            uncertainty: vec![0.01, 0.01, 0.01],
1469        };
1470        assert_eq!(data.n_energies(), 3);
1471        assert!(!data.is_counts());
1472    }
1473
1474    #[test]
1475    fn test_input_data_counts_n_energies() {
1476        let data = InputData::Counts {
1477            sample_counts: vec![10.0, 20.0, 30.0, 40.0],
1478            open_beam_counts: vec![100.0, 100.0, 100.0, 100.0],
1479        };
1480        assert_eq!(data.n_energies(), 4);
1481        assert!(data.is_counts());
1482    }
1483
1484    #[test]
1485    fn test_input_data_counts_with_nuisance() {
1486        let data = InputData::CountsWithNuisance {
1487            sample_counts: vec![5.0, 6.0],
1488            flux: vec![100.0, 100.0],
1489            background: vec![0.5, 0.5],
1490        };
1491        assert_eq!(data.n_energies(), 2);
1492        assert!(data.is_counts());
1493    }
1494
1495    #[test]
1496    fn test_solver_config_default_is_auto() {
1497        let cfg = SolverConfig::default();
1498        assert!(matches!(cfg, SolverConfig::Auto));
1499    }
1500
1501    #[test]
1502    fn test_counts_background_config_default() {
1503        let cfg = CountsBackgroundConfig::default();
1504        assert!((cfg.alpha_1_init - 1.0).abs() < f64::EPSILON);
1505        assert!((cfg.alpha_2_init - 1.0).abs() < f64::EPSILON);
1506        assert!(!cfg.fit_alpha_1);
1507        assert!(!cfg.fit_alpha_2);
1508    }
1509
1510    // ── Phase 2: fit_spectrum_typed tests ──
1511
1512    /// Helper: build synthetic transmission data from known density.
1513    fn synthetic_transmission(
1514        data: &ResonanceData,
1515        true_density: f64,
1516        energies: &[f64],
1517    ) -> (Vec<f64>, Vec<f64>) {
1518        let model = PrecomputedTransmissionModel {
1519            cross_sections: Arc::new(vec![
1520                phys_transmission::broadened_cross_sections(
1521                    energies,
1522                    std::slice::from_ref(data),
1523                    0.0,
1524                    None,
1525                    None,
1526                )
1527                .unwrap()
1528                .into_iter()
1529                .next()
1530                .unwrap(),
1531            ]),
1532            density_indices: Arc::new(vec![0]),
1533            energies: None,
1534            instrument: None,
1535        };
1536        let t = model.evaluate(&[true_density]).unwrap();
1537        let sigma: Vec<f64> = t.iter().map(|&v| 0.01 * v.max(0.01)).collect();
1538        (t, sigma)
1539    }
1540
1541    /// Helper: build synthetic counts from known density.
1542    fn synthetic_counts(
1543        data: &ResonanceData,
1544        true_density: f64,
1545        energies: &[f64],
1546        i0: f64,
1547    ) -> (Vec<f64>, Vec<f64>) {
1548        let (t, _) = synthetic_transmission(data, true_density, energies);
1549        let open_beam: Vec<f64> = vec![i0; energies.len()];
1550        let sample: Vec<f64> = t.iter().map(|&v| (v * i0).round().max(0.0)).collect();
1551        (sample, open_beam)
1552    }
1553
1554    #[test]
1555    fn test_typed_transmission_lm_recovers_density() {
1556        let data = u238_single_resonance();
1557        let true_density = 0.002;
1558        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1559        let (t, sigma) = synthetic_transmission(&data, true_density, &energies);
1560
1561        let config = UnifiedFitConfig::new(
1562            energies,
1563            vec![data],
1564            vec!["U-238".into()],
1565            0.0,
1566            None,
1567            vec![0.001],
1568        )
1569        .unwrap()
1570        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
1571
1572        let input = InputData::Transmission {
1573            transmission: t,
1574            uncertainty: sigma,
1575        };
1576
1577        let result = fit_spectrum_typed(&input, &config).unwrap();
1578        assert!(result.converged, "LM should converge");
1579        let fitted = result.densities[0];
1580        assert!(
1581            (fitted - true_density).abs() / true_density < 0.05,
1582            "density: fitted={fitted}, true={true_density}"
1583        );
1584    }
1585
1586    #[test]
1587    fn test_extract_result_drops_uncertainties_when_unconverged() {
1588        let data = u238_single_resonance();
1589        let energies: Vec<f64> = (0..21).map(|i| 1.0 + (i as f64) * 0.1).collect();
1590        let config = UnifiedFitConfig::new(
1591            energies,
1592            vec![data],
1593            vec!["U-238".into()],
1594            293.6,
1595            None,
1596            vec![0.001],
1597        )
1598        .unwrap();
1599
1600        let result = LmResult {
1601            chi_squared: 1.0,
1602            reduced_chi_squared: 1.0,
1603            iterations: 5,
1604            converged: false,
1605            params: vec![0.001],
1606            covariance: Some(lm::FlatMatrix::zeros(1, 1)),
1607            uncertainties: Some(vec![0.123]),
1608        };
1609
1610        let extracted = extract_result(&config, &result, 1, None).unwrap();
1611        assert!(!extracted.converged);
1612        assert!(
1613            extracted.uncertainties.is_none(),
1614            "pipeline must not surface uncertainties from an unconverged fit"
1615        );
1616        assert!(extracted.temperature_k_unc.is_none());
1617    }
1618
1619    #[test]
1620    fn test_typed_counts_kl_recovers_density() {
1621        let data = u238_single_resonance();
1622        let true_density = 0.002;
1623        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1624        let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 1000.0);
1625
1626        let config = UnifiedFitConfig::new(
1627            energies,
1628            vec![data],
1629            vec!["U-238".into()],
1630            0.0,
1631            None,
1632            vec![0.001],
1633        )
1634        .unwrap()
1635        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
1636
1637        let input = InputData::Counts {
1638            sample_counts: sample,
1639            open_beam_counts: open_beam,
1640        };
1641
1642        let result = fit_spectrum_typed(&input, &config).unwrap();
1643        assert!(result.converged, "KL on counts should converge");
1644        let fitted = result.densities[0];
1645        assert!(
1646            (fitted - true_density).abs() / true_density < 0.10,
1647            "density: fitted={fitted}, true={true_density}"
1648        );
1649    }
1650
1651    #[test]
1652    fn test_typed_counts_kl_low_counts_recovers_density() {
1653        // I0=10 counts per bin — the regime where KL excels and LM fails
1654        let data = u238_single_resonance();
1655        let true_density = 0.0005;
1656        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1657        let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 10.0);
1658
1659        let config = UnifiedFitConfig::new(
1660            energies,
1661            vec![data],
1662            vec!["U-238".into()],
1663            0.0,
1664            None,
1665            vec![0.001],
1666        )
1667        .unwrap()
1668        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
1669
1670        let input = InputData::Counts {
1671            sample_counts: sample,
1672            open_beam_counts: open_beam,
1673        };
1674
1675        let result = fit_spectrum_typed(&input, &config).unwrap();
1676        assert!(result.converged, "KL on low counts should converge");
1677        let fitted = result.densities[0];
1678        // Wider tolerance for low counts
1679        assert!(
1680            (fitted - true_density).abs() / true_density < 0.30,
1681            "density: fitted={fitted}, true={true_density}"
1682        );
1683    }
1684
1685    #[test]
1686    fn test_typed_transmission_kl_recovers_density() {
1687        let data = u238_single_resonance();
1688        let true_density = 0.0005;
1689        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1690        let (t, sigma) = synthetic_transmission(&data, true_density, &energies);
1691
1692        let config = UnifiedFitConfig::new(
1693            energies,
1694            vec![data],
1695            vec!["U-238".into()],
1696            0.0,
1697            None,
1698            vec![0.001],
1699        )
1700        .unwrap()
1701        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
1702
1703        let input = InputData::Transmission {
1704            transmission: t,
1705            uncertainty: sigma,
1706        };
1707
1708        let result = fit_spectrum_typed(&input, &config).unwrap();
1709        assert!(result.converged, "KL on transmission should converge");
1710        let fitted = result.densities[0];
1711        assert!(
1712            (fitted - true_density).abs() / true_density < 0.05,
1713            "density: fitted={fitted}, true={true_density}"
1714        );
1715    }
1716
1717    #[test]
1718    fn test_typed_counts_lm_auto_converts() {
1719        // Counts + LM should auto-convert to transmission and fit
1720        let data = u238_single_resonance();
1721        let true_density = 0.0005;
1722        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1723        let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 1000.0);
1724
1725        let config = UnifiedFitConfig::new(
1726            energies,
1727            vec![data],
1728            vec!["U-238".into()],
1729            0.0,
1730            None,
1731            vec![0.001],
1732        )
1733        .unwrap()
1734        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
1735
1736        let input = InputData::Counts {
1737            sample_counts: sample,
1738            open_beam_counts: open_beam,
1739        };
1740
1741        let result = fit_spectrum_typed(&input, &config).unwrap();
1742        assert!(
1743            result.converged,
1744            "LM on auto-converted counts should converge"
1745        );
1746        let fitted = result.densities[0];
1747        assert!(
1748            (fitted - true_density).abs() / true_density < 0.10,
1749            "density: fitted={fitted}, true={true_density}"
1750        );
1751    }
1752
1753    #[test]
1754    fn test_typed_auto_solver_selects_kl_for_counts() {
1755        let data = u238_single_resonance();
1756        let true_density = 0.0005;
1757        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1758        let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 1000.0);
1759
1760        // Auto solver (default)
1761        let config = UnifiedFitConfig::new(
1762            energies,
1763            vec![data],
1764            vec!["U-238".into()],
1765            0.0,
1766            None,
1767            vec![0.001],
1768        )
1769        .unwrap(); // SolverConfig::Auto by default
1770
1771        let input = InputData::Counts {
1772            sample_counts: sample,
1773            open_beam_counts: open_beam,
1774        };
1775
1776        let result = fit_spectrum_typed(&input, &config).unwrap();
1777        assert!(
1778            result.converged,
1779            "Auto solver on counts should use KL and converge"
1780        );
1781    }
1782
1783    #[test]
1784    fn test_typed_transmission_with_background_lm() {
1785        let data = u238_single_resonance();
1786        let true_density = 0.0005;
1787        let true_anorm = 0.95;
1788        let true_back_a = 0.02;
1789        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1790
1791        // Generate synthetic data with background
1792        let (t_pure, _) = synthetic_transmission(&data, true_density, &energies);
1793        let t_bg: Vec<f64> = t_pure
1794            .iter()
1795            .map(|&v| true_anorm * v + true_back_a)
1796            .collect();
1797        let sigma: Vec<f64> = t_bg.iter().map(|&v| 0.01 * v.max(0.01)).collect();
1798
1799        let config = UnifiedFitConfig::new(
1800            energies,
1801            vec![data],
1802            vec!["U-238".into()],
1803            0.0,
1804            None,
1805            vec![0.001],
1806        )
1807        .unwrap()
1808        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig {
1809            max_iter: 500,
1810            ..LmConfig::default()
1811        }))
1812        .with_transmission_background(BackgroundConfig::default());
1813
1814        let input = InputData::Transmission {
1815            transmission: t_bg,
1816            uncertainty: sigma,
1817        };
1818
1819        let result = fit_spectrum_typed(&input, &config).unwrap();
1820        assert!(
1821            result.converged,
1822            "LM+BG should converge on noiseless synthetic data (chi2r={}, iter={})",
1823            result.reduced_chi_squared, result.iterations
1824        );
1825        assert!(
1826            (result.densities[0] - true_density).abs() / true_density < 0.05,
1827            "density: fitted={}, true={true_density}",
1828            result.densities[0]
1829        );
1830        assert!(
1831            (result.anorm - true_anorm).abs() / true_anorm < 0.05,
1832            "anorm: fitted={}, true={true_anorm}",
1833            result.anorm
1834        );
1835    }
1836
1837    #[test]
1838    fn test_typed_counts_with_nuisance_rejects_lm() {
1839        let data = u238_single_resonance();
1840        let energies: Vec<f64> = (0..10).map(|i| 1.0 + (i as f64) * 0.5).collect();
1841
1842        let config = UnifiedFitConfig::new(
1843            energies,
1844            vec![data],
1845            vec!["U-238".into()],
1846            0.0,
1847            None,
1848            vec![0.001],
1849        )
1850        .unwrap()
1851        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
1852
1853        let input = InputData::CountsWithNuisance {
1854            sample_counts: vec![10.0; 10],
1855            flux: vec![100.0; 10],
1856            background: vec![0.0; 10],
1857        };
1858
1859        let result = fit_spectrum_typed(&input, &config);
1860        assert!(result.is_err(), "CountsWithNuisance + LM should error");
1861    }
1862
1863    /// Helper: build synthetic transmission at a given temperature.
1864    fn synthetic_transmission_at_temp(
1865        data: &ResonanceData,
1866        true_density: f64,
1867        temperature_k: f64,
1868        energies: &[f64],
1869    ) -> (Vec<f64>, Vec<f64>) {
1870        let xs = phys_transmission::broadened_cross_sections(
1871            energies,
1872            std::slice::from_ref(data),
1873            temperature_k,
1874            None,
1875            None,
1876        )
1877        .unwrap();
1878        let model = PrecomputedTransmissionModel {
1879            cross_sections: Arc::new(xs),
1880            density_indices: Arc::new(vec![0]),
1881            energies: None,
1882            instrument: None,
1883        };
1884        let t = model.evaluate(&[true_density]).unwrap();
1885        let sigma: Vec<f64> = t.iter().map(|&v| 0.01 * v.max(0.01)).collect();
1886        (t, sigma)
1887    }
1888
1889    #[test]
1890    fn test_typed_poisson_kl_with_temperature() {
1891        let data = u238_single_resonance();
1892        let true_density = 0.0005;
1893        let true_temp = 350.0;
1894        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1895        let (t, sigma) = synthetic_transmission_at_temp(&data, true_density, true_temp, &energies);
1896
1897        let config = UnifiedFitConfig::new(
1898            energies,
1899            vec![data],
1900            vec!["U-238".into()],
1901            300.0, // initial guess (off by 50 K)
1902            None,
1903            vec![0.001],
1904        )
1905        .unwrap()
1906        .with_solver(SolverConfig::PoissonKL(PoissonConfig {
1907            max_iter: 500,
1908            ..PoissonConfig::default()
1909        }))
1910        .with_fit_temperature(true);
1911
1912        let input = InputData::Transmission {
1913            transmission: t,
1914            uncertainty: sigma,
1915        };
1916
1917        let result = fit_spectrum_typed(&input, &config).unwrap();
1918
1919        // Check density recovery (within 1%)
1920        let fitted_density = result.densities[0];
1921        assert!(
1922            (fitted_density - true_density).abs() / true_density < 0.01,
1923            "density: fitted={fitted_density}, true={true_density}, ratio={}",
1924            (fitted_density - true_density).abs() / true_density,
1925        );
1926
1927        // Check temperature recovery (within 1 K)
1928        let fitted_temp = result
1929            .temperature_k
1930            .expect("temperature_k should be Some when fit_temperature=true");
1931        assert!(
1932            (fitted_temp - true_temp).abs() < 1.0,
1933            "temperature: fitted={fitted_temp}, true={true_temp}, delta={}",
1934            (fitted_temp - true_temp).abs(),
1935        );
1936    }
1937
1938    #[test]
1939    fn test_typed_poisson_kl_with_temperature_and_background() {
1940        let data = u238_single_resonance();
1941        let true_density = 0.0005;
1942        let true_temp = 350.0;
1943        let true_b0 = 0.012;
1944        let true_b1 = 0.008;
1945        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1946        let (t, sigma) = synthetic_transmission_at_temp(&data, true_density, true_temp, &energies);
1947        let measured_t: Vec<f64> = t
1948            .iter()
1949            .zip(energies.iter())
1950            .map(|(&ti, &e)| ti + true_b0 + true_b1 / e.sqrt())
1951            .collect();
1952
1953        let config = UnifiedFitConfig::new(
1954            energies,
1955            vec![data],
1956            vec!["U-238".into()],
1957            300.0,
1958            None,
1959            vec![0.001],
1960        )
1961        .unwrap()
1962        .with_solver(SolverConfig::PoissonKL(PoissonConfig {
1963            max_iter: 120,
1964            gauss_newton_lambda: 1e-4,
1965            ..PoissonConfig::default()
1966        }))
1967        .with_fit_temperature(true)
1968        .with_transmission_background(BackgroundConfig::default());
1969
1970        let input = InputData::Transmission {
1971            transmission: measured_t,
1972            uncertainty: sigma,
1973        };
1974
1975        let result = fit_spectrum_typed(&input, &config).unwrap();
1976
1977        assert!(result.converged, "fit did not converge: {result:?}");
1978        assert!(
1979            result.iterations <= 80,
1980            "expected KL background+temperature fit to converge well before max_iter; got {}",
1981            result.iterations,
1982        );
1983
1984        let fitted_density = result.densities[0];
1985        assert!(
1986            (fitted_density - true_density).abs() / true_density < 0.02,
1987            "density: fitted={fitted_density}, true={true_density}, ratio={}",
1988            (fitted_density - true_density).abs() / true_density,
1989        );
1990
1991        let fitted_temp = result
1992            .temperature_k
1993            .expect("temperature_k should be Some when fit_temperature=true");
1994        assert!(
1995            (fitted_temp - true_temp).abs() < 3.0,
1996            "temperature: fitted={fitted_temp}, true={true_temp}, delta={}",
1997            (fitted_temp - true_temp).abs(),
1998        );
1999
2000        assert!(
2001            (result.background[0] - true_b0).abs() < 5e-3,
2002            "background b0: fitted={}, true={}",
2003            result.background[0],
2004            true_b0,
2005        );
2006        assert!(
2007            (result.background[1] - true_b1).abs() < 5e-3,
2008            "background b1: fitted={}, true={}",
2009            result.background[1],
2010            true_b1,
2011        );
2012    }
2013
2014    #[test]
2015    fn test_typed_counts_poisson_kl_with_background() {
2016        let data = u238_single_resonance();
2017        let true_density = 0.0005;
2018        let true_b0 = 0.012;
2019        let true_b1 = 0.008;
2020        let detector_bg = 2.0;
2021        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
2022        let (t, _) = synthetic_transmission(&data, true_density, &energies);
2023        let flux = vec![1000.0; energies.len()];
2024        let background = vec![detector_bg; energies.len()];
2025        let sample_counts: Vec<f64> = t
2026            .iter()
2027            .zip(energies.iter())
2028            .zip(flux.iter())
2029            .zip(background.iter())
2030            .map(|(((&ti, &e), &f), &bg)| f * (ti + true_b0 + true_b1 / e.sqrt()) + bg)
2031            .collect();
2032
2033        let config = UnifiedFitConfig::new(
2034            energies,
2035            vec![data],
2036            vec!["U-238".into()],
2037            0.0,
2038            None,
2039            vec![0.001],
2040        )
2041        .unwrap()
2042        .with_solver(SolverConfig::PoissonKL(PoissonConfig {
2043            max_iter: 120,
2044            gauss_newton_lambda: 1e-4,
2045            ..PoissonConfig::default()
2046        }))
2047        .with_transmission_background(BackgroundConfig::default());
2048
2049        let input = InputData::CountsWithNuisance {
2050            sample_counts,
2051            flux,
2052            background,
2053        };
2054
2055        let result = fit_spectrum_typed(&input, &config).unwrap();
2056
2057        assert!(result.converged, "fit did not converge: {result:?}");
2058        assert!(
2059            (result.densities[0] - true_density).abs() / true_density < 0.02,
2060            "density: fitted={}, true={true_density}",
2061            result.densities[0]
2062        );
2063        assert!(
2064            (result.background[0] - true_b0).abs() < 5e-3,
2065            "background b0: fitted={}, true={true_b0}",
2066            result.background[0]
2067        );
2068        assert!(
2069            (result.background[1] - true_b1).abs() < 5e-3,
2070            "background b1: fitted={}, true={true_b1}",
2071            result.background[1]
2072        );
2073    }
2074
2075    #[test]
2076    fn test_typed_counts_poisson_kl_with_temperature_and_background() {
2077        let data = u238_single_resonance();
2078        let true_density = 0.0005;
2079        let true_temp = 350.0;
2080        let true_b0 = 0.012;
2081        let true_b1 = 0.008;
2082        let detector_bg = 2.0;
2083        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
2084        let (t, _) = synthetic_transmission_at_temp(&data, true_density, true_temp, &energies);
2085        let flux = vec![1000.0; energies.len()];
2086        let background = vec![detector_bg; energies.len()];
2087        let sample_counts: Vec<f64> = t
2088            .iter()
2089            .zip(energies.iter())
2090            .zip(flux.iter())
2091            .zip(background.iter())
2092            .map(|(((&ti, &e), &f), &bg)| f * (ti + true_b0 + true_b1 / e.sqrt()) + bg)
2093            .collect();
2094
2095        let config = UnifiedFitConfig::new(
2096            energies,
2097            vec![data],
2098            vec!["U-238".into()],
2099            300.0,
2100            None,
2101            vec![0.001],
2102        )
2103        .unwrap()
2104        .with_solver(SolverConfig::PoissonKL(PoissonConfig {
2105            max_iter: 120,
2106            gauss_newton_lambda: 1e-4,
2107            ..PoissonConfig::default()
2108        }))
2109        .with_fit_temperature(true)
2110        .with_transmission_background(BackgroundConfig::default());
2111
2112        let input = InputData::CountsWithNuisance {
2113            sample_counts,
2114            flux,
2115            background,
2116        };
2117
2118        let result = fit_spectrum_typed(&input, &config).unwrap();
2119
2120        assert!(result.converged, "fit did not converge: {result:?}");
2121        assert!(
2122            (result.densities[0] - true_density).abs() / true_density < 0.02,
2123            "density: fitted={}, true={true_density}",
2124            result.densities[0]
2125        );
2126
2127        let fitted_temp = result
2128            .temperature_k
2129            .expect("temperature_k should be Some when fit_temperature=true");
2130        assert!(
2131            (fitted_temp - true_temp).abs() < 3.0,
2132            "temperature: fitted={fitted_temp}, true={true_temp}, delta={}",
2133            (fitted_temp - true_temp).abs(),
2134        );
2135        assert!(
2136            (result.background[0] - true_b0).abs() < 5e-3,
2137            "background b0: fitted={}, true={true_b0}",
2138            result.background[0]
2139        );
2140        assert!(
2141            (result.background[1] - true_b1).abs() < 5e-3,
2142            "background b1: fitted={}, true={true_b1}",
2143            result.background[1]
2144        );
2145    }
2146
2147    #[test]
2148    fn test_typed_counts_poisson_kl_with_counts_background_scales() {
2149        let data = u238_single_resonance();
2150        let true_density = 0.002;
2151        let true_alpha1 = 0.92;
2152        let true_alpha2 = 1.35;
2153        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
2154        let (t, _) = synthetic_transmission(&data, true_density, &energies);
2155        let flux = vec![120.0; energies.len()];
2156        let background: Vec<f64> = energies.iter().map(|&e| 30.0 + 8.0 / e.sqrt()).collect();
2157        let sample_counts: Vec<f64> = t
2158            .iter()
2159            .zip(flux.iter())
2160            .zip(background.iter())
2161            .map(|((&ti, &f), &bg)| true_alpha1 * f * ti + true_alpha2 * bg)
2162            .collect();
2163
2164        let config = UnifiedFitConfig::new(
2165            energies,
2166            vec![data],
2167            vec!["U-238".into()],
2168            0.0,
2169            None,
2170            vec![0.001],
2171        )
2172        .unwrap()
2173        .with_solver(SolverConfig::PoissonKL(PoissonConfig {
2174            max_iter: 120,
2175            gauss_newton_lambda: 1e-4,
2176            ..PoissonConfig::default()
2177        }))
2178        .with_counts_background(CountsBackgroundConfig {
2179            alpha_1_init: 1.0,
2180            alpha_2_init: 1.0,
2181            fit_alpha_1: true,
2182            fit_alpha_2: true,
2183        });
2184
2185        let input = InputData::CountsWithNuisance {
2186            sample_counts,
2187            flux,
2188            background,
2189        };
2190
2191        let result = fit_spectrum_typed(&input, &config).unwrap();
2192
2193        assert!(result.converged, "fit did not converge: {result:?}");
2194        assert!(
2195            (result.densities[0] - true_density).abs() / true_density < 0.02,
2196            "density: fitted={}, true={true_density}",
2197            result.densities[0]
2198        );
2199        assert!(
2200            (result.anorm - true_alpha1).abs() < 5e-3,
2201            "alpha_1/anorm: fitted={}, true={true_alpha1}",
2202            result.anorm
2203        );
2204        assert!(
2205            (result.background[2] - true_alpha2).abs() < 5e-3,
2206            "alpha_2/background[2]: fitted={}, true={true_alpha2}",
2207            result.background[2]
2208        );
2209    }
2210
2211    /// Round-trip test: create a group of 2 isotopes with known ratios,
2212    /// generate synthetic transmission, fit with group constraints,
2213    /// verify the fitted group density matches the true value.
2214    #[test]
2215    fn test_grouped_fit_spectrum_round_trip() {
2216        use nereids_core::types::IsotopeGroup;
2217
2218        // Two synthetic isotopes with resonances at different energies
2219        let rd1 = synthetic_single_resonance(92, 235, 233.025, 5.0);
2220        let rd2 = synthetic_single_resonance(92, 238, 236.006, 7.0);
2221
2222        // Group with 60/40 ratio
2223        let iso1 = nereids_core::types::Isotope::new(92, 235).unwrap();
2224        let iso2 = nereids_core::types::Isotope::new(92, 238).unwrap();
2225        let group =
2226            IsotopeGroup::custom("U (60/40)".into(), vec![(iso1, 0.6), (iso2, 0.4)]).unwrap();
2227
2228        let energies: Vec<f64> = (0..301).map(|i| 1.0 + (i as f64) * 0.05).collect();
2229        let true_density = 0.0005;
2230
2231        // Generate synthetic transmission using effective densities
2232        let sample = nereids_physics::transmission::SampleParams::new(
2233            0.0,
2234            vec![
2235                (rd1.clone(), true_density * 0.6),
2236                (rd2.clone(), true_density * 0.4),
2237            ],
2238        )
2239        .unwrap();
2240        let transmission =
2241            nereids_physics::transmission::forward_model(&energies, &sample, None).unwrap();
2242        let uncertainty: Vec<f64> = transmission.iter().map(|&t| 0.01 * t.max(0.01)).collect();
2243
2244        // Build config with group
2245        let config = UnifiedFitConfig::new(
2246            energies.clone(),
2247            vec![rd1.clone()],
2248            vec!["placeholder".into()],
2249            0.0,
2250            None,
2251            vec![0.001],
2252        )
2253        .unwrap()
2254        .with_groups(&[(&group, &[rd1, rd2])], vec![0.001])
2255        .unwrap()
2256        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2257
2258        let input = InputData::Transmission {
2259            transmission,
2260            uncertainty,
2261        };
2262
2263        let result = fit_spectrum_typed(&input, &config).unwrap();
2264
2265        // Should recover true density within 1%
2266        assert_eq!(result.densities.len(), 1, "should have 1 group density");
2267        let fitted = result.densities[0];
2268        let rel_error = (fitted - true_density).abs() / true_density;
2269        assert!(
2270            rel_error < 0.01,
2271            "group density: fitted={fitted}, true={true_density}, rel_error={rel_error}"
2272        );
2273        assert!(result.converged, "fit should converge");
2274    }
2275
2276    #[test]
2277    fn test_grouped_poisson_kl_with_temperature_and_background_noiseless() {
2278        use nereids_core::types::IsotopeGroup;
2279
2280        let rd1 = synthetic_single_resonance(72, 176, 8.5, 5.0);
2281        let rd2 = synthetic_single_resonance(72, 178, 17.0, 7.5);
2282        let rd3 = synthetic_single_resonance(72, 180, 29.0, 6.0);
2283
2284        let hf176 = nereids_core::types::Isotope::new(72, 176).unwrap();
2285        let hf178 = nereids_core::types::Isotope::new(72, 178).unwrap();
2286        let hf180 = nereids_core::types::Isotope::new(72, 180).unwrap();
2287        let group = IsotopeGroup::custom(
2288            "Hf-like (3 member)".into(),
2289            vec![(hf176, 0.2), (hf178, 0.5), (hf180, 0.3)],
2290        )
2291        .unwrap();
2292
2293        let energies: Vec<f64> = (0..300).map(|i| 1.0 + (49.0 * i as f64) / 299.0).collect();
2294        let true_density = 0.001;
2295        let true_temp = 400.0;
2296        let true_b0 = 0.012;
2297        let true_b1 = 0.008;
2298
2299        let sample = nereids_physics::transmission::SampleParams::new(
2300            true_temp,
2301            vec![
2302                (rd1.clone(), true_density * 0.2),
2303                (rd2.clone(), true_density * 0.5),
2304                (rd3.clone(), true_density * 0.3),
2305            ],
2306        )
2307        .unwrap();
2308        let pure_t =
2309            nereids_physics::transmission::forward_model(&energies, &sample, None).unwrap();
2310        let measured_t: Vec<f64> = pure_t
2311            .iter()
2312            .zip(energies.iter())
2313            .map(|(&t, &e)| t + true_b0 + true_b1 / e.sqrt())
2314            .collect();
2315        let sigma = vec![0.001; energies.len()];
2316
2317        let config = UnifiedFitConfig::new(
2318            energies.clone(),
2319            vec![rd1.clone()],
2320            vec!["placeholder".into()],
2321            293.6,
2322            None,
2323            vec![0.0008],
2324        )
2325        .unwrap()
2326        .with_groups(&[(&group, &[rd1, rd2, rd3])], vec![0.0008])
2327        .unwrap()
2328        .with_solver(SolverConfig::PoissonKL(PoissonConfig {
2329            max_iter: 200,
2330            gauss_newton_lambda: 1e-4,
2331            ..PoissonConfig::default()
2332        }))
2333        .with_fit_temperature(true)
2334        .with_transmission_background(BackgroundConfig::default());
2335
2336        let input = InputData::Transmission {
2337            transmission: measured_t,
2338            uncertainty: sigma,
2339        };
2340
2341        let result = fit_spectrum_typed(&input, &config).unwrap();
2342
2343        assert!(result.converged, "fit did not converge: {result:?}");
2344        assert!(
2345            (result.densities[0] - true_density).abs() / true_density < 0.005,
2346            "density: fitted={}, true={true_density}",
2347            result.densities[0]
2348        );
2349        let fitted_temp = result
2350            .temperature_k
2351            .expect("temperature_k should be Some when fit_temperature=true");
2352        assert!(
2353            (fitted_temp - true_temp).abs() < 8.0,
2354            "temperature: fitted={fitted_temp}, true={true_temp}",
2355        );
2356        assert!(
2357            (result.background[0] - true_b0).abs() < 5e-3,
2358            "background b0: fitted={}, true={true_b0}",
2359            result.background[0]
2360        );
2361        assert!(
2362            (result.background[1] - true_b1).abs() < 5e-3,
2363            "background b1: fitted={}, true={true_b1}",
2364            result.background[1]
2365        );
2366    }
2367
2368    // ── Phase 2: KL fitting uncertainty tests ──────────────────────────────
2369
2370    #[test]
2371    fn test_kl_counts_returns_density_uncertainty() {
2372        let data = u238_single_resonance();
2373        let true_density = 0.002;
2374        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
2375        let (sample, open_beam) = synthetic_counts(&data, true_density, &energies, 1000.0);
2376
2377        let config = UnifiedFitConfig::new(
2378            energies,
2379            vec![data],
2380            vec!["U-238".into()],
2381            0.0,
2382            None,
2383            vec![0.001],
2384        )
2385        .unwrap()
2386        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()));
2387
2388        let input = InputData::Counts {
2389            sample_counts: sample,
2390            open_beam_counts: open_beam,
2391        };
2392        let result = fit_spectrum_typed(&input, &config).unwrap();
2393        assert!(result.converged);
2394        let unc = result
2395            .uncertainties
2396            .as_ref()
2397            .expect("KL 1D fit should return density uncertainties");
2398        assert_eq!(unc.len(), 1);
2399        assert!(
2400            unc[0].is_finite() && unc[0] > 0.0,
2401            "density unc = {}",
2402            unc[0]
2403        );
2404        assert!(
2405            unc[0] < result.densities[0],
2406            "unc ({}) should be < density ({}) for high-count data",
2407            unc[0],
2408            result.densities[0]
2409        );
2410    }
2411
2412    #[test]
2413    fn test_kl_counts_returns_temperature_uncertainty() {
2414        let data = u238_single_resonance();
2415        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.05).collect();
2416        let (sample, open_beam) = synthetic_counts(&data, 0.001, &energies, 1000.0);
2417
2418        let config = UnifiedFitConfig::new(
2419            energies,
2420            vec![data],
2421            vec!["U-238".into()],
2422            350.0,
2423            None,
2424            vec![0.0005],
2425        )
2426        .unwrap()
2427        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
2428        .with_fit_temperature(true);
2429
2430        let input = InputData::Counts {
2431            sample_counts: sample,
2432            open_beam_counts: open_beam,
2433        };
2434        let result = fit_spectrum_typed(&input, &config).unwrap();
2435        assert!(result.converged);
2436        let unc = result
2437            .uncertainties
2438            .as_ref()
2439            .expect("KL+temp fit should return density uncertainties");
2440        assert!(
2441            unc[0].is_finite() && unc[0] > 0.0,
2442            "density unc = {}",
2443            unc[0]
2444        );
2445        let t_unc = result
2446            .temperature_k_unc
2447            .expect("KL+temp fit should return temperature uncertainty");
2448        assert!(
2449            t_unc.is_finite() && t_unc > 0.0,
2450            "temperature unc = {t_unc}"
2451        );
2452    }
2453
2454    #[test]
2455    fn test_kl_counts_with_background_returns_uncertainty() {
2456        let data = u238_single_resonance();
2457        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.05).collect();
2458        let (sample, open_beam) = synthetic_counts(&data, 0.001, &energies, 1000.0);
2459
2460        let config = UnifiedFitConfig::new(
2461            energies,
2462            vec![data],
2463            vec!["U-238".into()],
2464            300.0,
2465            None,
2466            vec![0.0005],
2467        )
2468        .unwrap()
2469        .with_solver(SolverConfig::PoissonKL(PoissonConfig::default()))
2470        .with_fit_temperature(true)
2471        .with_transmission_background(BackgroundConfig::default());
2472
2473        let input = InputData::Counts {
2474            sample_counts: sample,
2475            open_beam_counts: open_beam,
2476        };
2477        let result = fit_spectrum_typed(&input, &config).unwrap();
2478        assert!(result.converged);
2479        let unc = result
2480            .uncertainties
2481            .as_ref()
2482            .expect("KL+bg fit should return density uncertainties");
2483        assert!(
2484            unc[0].is_finite() && unc[0] > 0.0,
2485            "density unc = {}",
2486            unc[0]
2487        );
2488    }
2489
2490    #[test]
2491    fn test_lm_uncertainty_not_regressed() {
2492        let data = u238_single_resonance();
2493        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
2494        let (t, sigma) = synthetic_transmission(&data, 0.001, &energies);
2495
2496        let config = UnifiedFitConfig::new(
2497            energies,
2498            vec![data],
2499            vec!["U-238".into()],
2500            0.0,
2501            None,
2502            vec![0.0005],
2503        )
2504        .unwrap()
2505        .with_solver(SolverConfig::LevenbergMarquardt(LmConfig::default()));
2506
2507        let input = InputData::Transmission {
2508            transmission: t,
2509            uncertainty: sigma,
2510        };
2511        let result = fit_spectrum_typed(&input, &config).unwrap();
2512        assert!(result.converged);
2513        let unc = result
2514            .uncertainties
2515            .as_ref()
2516            .expect("LM should still return uncertainties");
2517        assert!(unc[0].is_finite() && unc[0] > 0.0);
2518    }
2519}