Skip to main content

nereids_fitting/
transmission_model.rs

1//! Transmission forward model adapter for fitting.
2//!
3//! Wraps the physics `forward_model` function into a `FitModel` trait object
4//! that the LM optimizer can call. The fit parameters are the areal densities
5//! (thicknesses) of each isotope in the sample.
6
7use std::cell::{Cell, RefCell};
8use std::rc::Rc;
9use std::sync::Arc;
10
11use nereids_endf::resonance::ResonanceData;
12use nereids_physics::resolution;
13use nereids_physics::transmission::{self, InstrumentParams, SampleParams};
14
15use crate::error::FittingError;
16use crate::lm::{FitModel, FlatMatrix};
17
18/// Transmission model backed by precomputed Doppler-broadened cross-sections.
19///
20/// The expensive physics steps (resonance → σ(E), Doppler broadening) are
21/// computed once and stored.  Each `evaluate()` call performs Beer-Lambert
22/// and, when `instrument` is present, resolution broadening on the total
23/// transmission:
24///
25///   T(E) = R ⊗ exp(−Σᵢ nᵢ · σ_{D,i}(E))
26///
27/// Issue #442: resolution broadening is applied to T(E) after Beer-Lambert,
28/// not to σ(E) before.
29///
30/// Construct via `nereids_physics::transmission::broadened_cross_sections`,
31/// then wrap in `Arc` so the same precomputed data is shared read-only
32/// across all rayon worker threads.
33pub struct PrecomputedTransmissionModel {
34    /// Doppler-broadened cross-sections σ_D(E) per isotope,
35    /// shape \[n_isotopes\]\[n_energies\].
36    pub cross_sections: Arc<Vec<Vec<f64>>>,
37    /// Mapping: `params[density_indices[i]]` is the density of isotope `i`.
38    ///
39    /// Wrapped in `Arc` so that parallel pixel loops can share one copy
40    /// via cheap reference-count increments instead of deep-cloning per pixel.
41    ///
42    /// Kept `pub` (not `pub(crate)`) because the Python bindings
43    /// (`nereids-python`) construct and access this field directly.
44    pub density_indices: Arc<Vec<usize>>,
45    /// Energy grid (eV), required for resolution broadening.
46    /// `None` when resolution is disabled — Beer-Lambert only.
47    pub energies: Option<Arc<Vec<f64>>>,
48    /// Instrument resolution parameters.
49    /// When `Some`, resolution broadening is applied to the total
50    /// transmission after Beer-Lambert in `evaluate()`.
51    pub instrument: Option<Arc<InstrumentParams>>,
52}
53
54impl FitModel for PrecomputedTransmissionModel {
55    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
56        if self.cross_sections.is_empty() {
57            return Err(FittingError::InvalidConfig(
58                "PrecomputedTransmissionModel.cross_sections must not be empty".into(),
59            ));
60        }
61        let n_e = self.cross_sections[0].len();
62        let mut neg_opt = vec![0.0f64; n_e];
63        // #109.1: No density > 0 guard — let Beer-Lambert handle all densities
64        // naturally.  exp(−n·σ) is well-defined for negative n (gives T > 1,
65        // which is unphysical but the optimizer will reject it via chi2
66        // increase).  Removing the guard makes evaluate() consistent with
67        // the analytical Jacobian, which always computes ∂T/∂n = −σ·T
68        // regardless of the sign of n.
69        for (i, xs) in self.cross_sections.iter().enumerate() {
70            let density = params[self.density_indices[i]];
71            for (j, &sigma) in xs.iter().enumerate() {
72                neg_opt[j] -= density * sigma;
73            }
74        }
75        let transmission: Vec<f64> = neg_opt.iter().map(|&d| d.exp()).collect();
76
77        // Issue #442: apply resolution broadening to total transmission
78        // AFTER Beer-Lambert.  This is the SAMMY-correct ordering.
79        if let (Some(inst), Some(energies)) = (&self.instrument, &self.energies) {
80            let t_broadened =
81                resolution::apply_resolution(energies, &transmission, &inst.resolution).map_err(
82                    |e| FittingError::EvaluationFailed(format!("resolution broadening: {e}")),
83                )?;
84            Ok(t_broadened)
85        } else {
86            Ok(transmission)
87        }
88    }
89
90    /// Analytical Jacobian for the Beer-Lambert transmission model.
91    ///
92    /// Without resolution:
93    ///   T(E) = exp(-Σᵢ nᵢ · σᵢ(E))
94    ///   ∂T/∂nᵢ = -σᵢ(E) · T(E)
95    ///
96    /// With resolution (R is a linear operator):
97    ///   T_obs(E) = R\[T\](E) = R\[exp(-Σᵢ nᵢ · σᵢ)\](E)
98    ///   ∂T_obs/∂nᵢ = R\[-σᵢ(E) · T(E)\]
99    ///
100    /// For grouped isotopes sharing density parameter N_g:
101    ///   ∂T_obs/∂N_g = R\[-(Σ_{i∈g} σᵢ(E)) · T(E)\]
102    fn analytical_jacobian(
103        &self,
104        params: &[f64],
105        free_param_indices: &[usize],
106        y_current: &[f64],
107    ) -> Option<FlatMatrix> {
108        let n_e = if self.cross_sections.is_empty() {
109            return None;
110        } else {
111            self.cross_sections[0].len()
112        };
113        let n_free = free_param_indices.len();
114
115        // For each free parameter, sum the cross-sections of every isotope
116        // tied to that parameter index.
117        //   ∂T/∂N_g = -(Σ_{iso∈g} σ_iso(E)) · T(E)
118        let fp_xs_sums: Vec<Vec<f64>> = free_param_indices
119            .iter()
120            .map(|&fp_idx| {
121                let mut sum = vec![0.0f64; n_e];
122                for (iso, &di) in self.density_indices.iter().enumerate() {
123                    if di == fp_idx {
124                        for (j, &sigma) in self.cross_sections[iso].iter().enumerate() {
125                            sum[j] += sigma;
126                        }
127                    }
128                }
129                sum
130            })
131            .collect();
132
133        // When resolution is enabled, we need the UNRESOLVED T(E) = exp(-Σnσ)
134        // to form the inner derivative -σ·T, then apply resolution.
135        // y_current is T_obs = R[T], which is NOT the same as T.
136        if let (Some(inst), Some(energies)) = (&self.instrument, &self.energies) {
137            // Recompute unresolved T from cross-sections and current params.
138            let mut neg_opt = vec![0.0f64; n_e];
139            for (i, xs) in self.cross_sections.iter().enumerate() {
140                let density = params[self.density_indices[i]];
141                for (j, &sigma) in xs.iter().enumerate() {
142                    neg_opt[j] -= density * sigma;
143                }
144            }
145            let t_unresolved: Vec<f64> = neg_opt.iter().map(|&d| d.exp()).collect();
146
147            // ∂T_obs/∂N_g = R[-σ_sum(E) · T_unresolved(E)]
148            let mut jacobian = FlatMatrix::zeros(n_e, n_free);
149            for (col, xs_sum) in fp_xs_sums.iter().enumerate() {
150                let inner_deriv: Vec<f64> =
151                    (0..n_e).map(|i| -xs_sum[i] * t_unresolved[i]).collect();
152                let resolved_deriv =
153                    resolution::apply_resolution(energies, &inner_deriv, &inst.resolution).ok()?;
154                for (i, &val) in resolved_deriv.iter().enumerate() {
155                    *jacobian.get_mut(i, col) = val;
156                }
157            }
158            Some(jacobian)
159        } else {
160            // No resolution: ∂T/∂N_g = -σ_sum(E) · T(E)
161            // y_current IS T(E) directly.
162            let mut jacobian = FlatMatrix::zeros(n_e, n_free);
163            for i in 0..n_e {
164                for (j, xs_sum) in fp_xs_sums.iter().enumerate() {
165                    *jacobian.get_mut(i, j) = -xs_sum[i] * y_current[i];
166                }
167            }
168            Some(jacobian)
169        }
170    }
171}
172
173/// Forward model for fitting isotopic areal densities from transmission data.
174///
175/// The model computes T(E) for a set of isotopes with variable areal densities.
176/// Each isotope's resonance data and the energy grid are fixed; only the
177/// areal densities are adjusted during fitting.
178///
179/// Optionally, the sample temperature can also be fitted by setting
180/// `temperature_index` to the parameter slot holding the temperature value.
181/// When `temperature_index` is `Some(idx)`, the Doppler broadening kernel
182/// is recomputed at `params[idx]` when the temperature changes (cached
183/// across calls at the same temperature), and the analytical Jacobian
184/// provides density columns directly plus a single FD column for temperature.
185///
186/// `instrument` uses `Arc` so that parallel pixel loops can share one copy
187/// of a potentially large tabulated resolution kernel via cheap
188/// reference-count increments instead of deep-cloning per pixel.
189pub struct TransmissionFitModel {
190    /// Energy grid (eV), ascending.
191    energies: Vec<f64>,
192    /// Resonance data for each isotope.
193    resonance_data: Vec<ResonanceData>,
194    /// Sample temperature in Kelvin (used when `temperature_index` is `None`).
195    temperature_k: f64,
196    /// Optional instrument resolution parameters (Arc-shared for parallel use).
197    instrument: Option<Arc<InstrumentParams>>,
198    /// Index mapping: which `params` indices correspond to areal densities.
199    /// params[density_indices[i]] = areal density of isotope i.
200    ///
201    /// Uses `Vec<usize>` (not `Arc<Vec<usize>>`) because `TransmissionFitModel`
202    /// is constructed fresh per pixel (via `fit_spectrum`) and never shared
203    /// across threads.  `PrecomputedTransmissionModel` uses `Arc<Vec<usize>>`
204    /// for its density_indices because it _is_ shared across rayon workers.
205    density_indices: Vec<usize>,
206    /// Fractional ratio of each member isotope within its group.
207    /// For ungrouped isotopes, all values are 1.0.
208    /// When groups are active: `effective_density_i = params[density_indices[i]] * density_ratios[i]`
209    density_ratios: Vec<f64>,
210    /// If `Some(idx)`, `params[idx]` is treated as the sample temperature (K)
211    /// and included as a free parameter in the fit. The Doppler broadening
212    /// kernel is recomputed at each `evaluate()` call.
213    temperature_index: Option<usize>,
214    /// Cached unbroadened (Reich-Moore) cross-sections, computed once in
215    /// `new()` when `temperature_index` is `Some`. Eliminates redundant
216    /// O(N_energy × N_resonances) computation on every `evaluate()` call.
217    /// Wrapped in `Arc` so `spatial_map` can share a single allocation across
218    /// all per-pixel `TransmissionFitModel` instances without deep cloning.
219    base_xs: Option<Arc<Vec<Vec<f64>>>>,
220    /// Cached broadened cross-sections from the last `evaluate()` call.
221    /// Used by `analytical_jacobian()` to provide density columns without
222    /// rebroadening.  Interior mutability via `RefCell` is needed because
223    /// `FitModel::evaluate` takes `&self`.  Safe because `TransmissionFitModel`
224    /// is constructed per-pixel and never shared across threads.
225    cached_broadened_xs: RefCell<Option<Rc<Vec<Vec<f64>>>>>,
226    /// Cached analytical temperature derivative ∂σ/∂T, computed on-demand
227    /// by `analytical_jacobian()` when the temperature column is needed.
228    /// Invalidated when temperature changes (cleared in `evaluate()`).
229    cached_dxs_dt: RefCell<Option<Rc<Vec<Vec<f64>>>>>,
230    /// Temperature at which `cached_broadened_xs` was computed.
231    /// `Cell` is sufficient because `f64` is `Copy`.
232    cached_temperature: Cell<f64>,
233}
234
235impl TransmissionFitModel {
236    /// Create a validated `TransmissionFitModel`.
237    ///
238    /// When `external_base_xs` is `Some`, uses those precomputed unbroadened
239    /// cross-sections instead of computing them (expensive Reich-Moore).
240    /// `spatial_map` precomputes once for all pixels and passes them here.
241    ///
242    /// # Errors
243    /// Returns `FittingError::InvalidConfig` if `temperature_index` overlaps
244    /// with `density_indices`, or if `external_base_xs` has a mismatched shape.
245    pub fn new(
246        energies: Vec<f64>,
247        resonance_data: Vec<ResonanceData>,
248        temperature_k: f64,
249        instrument: Option<Arc<InstrumentParams>>,
250        density_mapping: (Vec<usize>, Vec<f64>),
251        temperature_index: Option<usize>,
252        external_base_xs: Option<Arc<Vec<Vec<f64>>>>,
253    ) -> Result<Self, FittingError> {
254        let (density_indices, density_ratios) = density_mapping;
255        if density_indices.len() != resonance_data.len() {
256            return Err(FittingError::InvalidConfig(format!(
257                "density_indices has {} entries but resonance_data has {}",
258                density_indices.len(),
259                resonance_data.len(),
260            )));
261        }
262        if density_ratios.len() != resonance_data.len() {
263            return Err(FittingError::InvalidConfig(format!(
264                "density_ratios has {} entries but resonance_data has {}",
265                density_ratios.len(),
266                resonance_data.len(),
267            )));
268        }
269        if let Some(ti) = temperature_index
270            && density_indices.contains(&ti)
271        {
272            return Err(FittingError::InvalidConfig(
273                "temperature_index must not overlap with density_indices".into(),
274            ));
275        }
276        // Validate external base XS shape before accepting.
277        if let Some(ref xs) = external_base_xs {
278            if xs.len() != resonance_data.len() {
279                return Err(FittingError::InvalidConfig(format!(
280                    "external_base_xs has {} isotopes but resonance_data has {}",
281                    xs.len(),
282                    resonance_data.len(),
283                )));
284            }
285            for (i, row) in xs.iter().enumerate() {
286                if row.len() != energies.len() {
287                    return Err(FittingError::InvalidConfig(format!(
288                        "external_base_xs[{i}] has {} energies but expected {}",
289                        row.len(),
290                        energies.len(),
291                    )));
292                }
293            }
294        }
295        let base_xs = match external_base_xs {
296            Some(xs) => Some(xs),
297            None if temperature_index.is_some() => Some(Arc::new(
298                transmission::unbroadened_cross_sections(&energies, &resonance_data, None)
299                    .map_err(|e| {
300                        FittingError::InvalidConfig(format!(
301                            "failed to compute unbroadened cross-sections: {e}"
302                        ))
303                    })?,
304            )),
305            None => None,
306        };
307        Ok(Self {
308            energies,
309            resonance_data,
310            temperature_k,
311            instrument,
312            density_indices,
313            density_ratios,
314            temperature_index,
315            base_xs,
316            cached_broadened_xs: RefCell::new(None),
317            cached_dxs_dt: RefCell::new(None),
318            cached_temperature: Cell::new(f64::NAN),
319        })
320    }
321}
322
323impl FitModel for TransmissionFitModel {
324    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
325        debug_assert!(
326            self.density_indices.iter().all(|&i| i < params.len()),
327            "density_indices out of bounds for params (len={})",
328            params.len(),
329        );
330        debug_assert!(
331            self.temperature_index.is_none_or(|i| i < params.len()),
332            "temperature_index out of bounds for params (len={})",
333            params.len(),
334        );
335
336        let temperature_k = match self.temperature_index {
337            Some(idx) => params[idx],
338            None => self.temperature_k,
339        };
340
341        if let Some(ref base_xs) = self.base_xs {
342            // Fast path: reuse cached unbroadened XS, only redo Doppler + Beer-Lambert.
343            // Validate temperature (same rules as SampleParams::new in the slow path)
344            // so the optimizer can't silently evaluate an unphysical model.
345            if !temperature_k.is_finite() || temperature_k < 0.0 {
346                return Err(FittingError::EvaluationFailed(format!(
347                    "Invalid temperature: {temperature_k} K (must be finite and non-negative)"
348                )));
349            }
350
351            // Compute broadened XS (or reuse cache if temperature unchanged).
352            // Caching avoids redundant Doppler broadening on rejected LM steps
353            // (same T, different lambda) and enables analytical_jacobian() to
354            // read the broadened σ for density columns.
355            //
356            // Derivative ∂σ/∂T is computed on-demand in analytical_jacobian(),
357            // NOT here — evaluate() is called many times during line search
358            // trials, and the derivative overhead would dominate.
359            let broadened_xs = if (temperature_k - self.cached_temperature.get()).abs() < 1e-15 {
360                Rc::clone(self.cached_broadened_xs.borrow().as_ref().unwrap())
361            } else {
362                let xs = Rc::new(
363                    transmission::broadened_cross_sections_from_base(
364                        &self.energies,
365                        base_xs,
366                        &self.resonance_data,
367                        temperature_k,
368                        self.instrument.as_deref(),
369                    )
370                    .map_err(|e| FittingError::EvaluationFailed(e.to_string()))?,
371                );
372                *self.cached_broadened_xs.borrow_mut() = Some(Rc::clone(&xs));
373                // Invalidate derivative cache — temperature changed, old ∂σ/∂T stale.
374                *self.cached_dxs_dt.borrow_mut() = None;
375                self.cached_temperature.set(temperature_k);
376                xs
377            };
378
379            // Beer-Lambert: T(E) = exp(-Σᵢ nᵢ · rᵢ · σᵢ(E))
380            // where rᵢ is the fractional ratio (1.0 for ungrouped isotopes).
381            let n_e = self.energies.len();
382            let mut neg_opt = vec![0.0f64; n_e];
383            for (i, xs) in broadened_xs.iter().enumerate() {
384                let density = params[self.density_indices[i]];
385                let ratio = self.density_ratios[i];
386                for (j, &sigma) in xs.iter().enumerate() {
387                    neg_opt[j] -= density * ratio * sigma;
388                }
389            }
390            let transmission: Vec<f64> = neg_opt.iter().map(|&d| d.exp()).collect();
391
392            // Issue #442: apply resolution broadening to total transmission
393            // AFTER Beer-Lambert.
394            if let Some(ref inst) = self.instrument {
395                resolution::apply_resolution(&self.energies, &transmission, &inst.resolution)
396                    .map_err(|e| {
397                        FittingError::EvaluationFailed(format!("resolution broadening: {e}"))
398                    })
399            } else {
400                Ok(transmission)
401            }
402        } else {
403            // Original path: full forward model (no temperature fitting).
404            // Apply ratio weights: effective density = params[idx] * ratio.
405            let isotopes: Vec<(ResonanceData, f64)> = self
406                .resonance_data
407                .iter()
408                .enumerate()
409                .map(|(i, rd)| {
410                    (
411                        rd.clone(),
412                        params[self.density_indices[i]] * self.density_ratios[i],
413                    )
414                })
415                .collect();
416
417            let sample = SampleParams::new(temperature_k, isotopes)
418                .map_err(|e| FittingError::EvaluationFailed(e.to_string()))?;
419
420            transmission::forward_model(&self.energies, &sample, self.instrument.as_deref())
421                .map_err(|e| FittingError::EvaluationFailed(e.to_string()))
422        }
423    }
424
425    /// Analytical Jacobian for the transmission model with temperature fitting.
426    ///
427    /// When `base_xs` is available (temperature fitting path):
428    /// - **Density columns**: `∂T/∂nᵢ = -σᵢ(E)·T(E)` using cached broadened XS
429    ///   from the most recent `evaluate()` call.  Same formula as
430    ///   `PrecomputedTransmissionModel`, zero extra broadening calls.
431    /// - **Temperature column**: analytical chain rule via on-demand `∂σ/∂T`.
432    ///   `∂T/∂T_temp = -T(E) · Σᵢ nᵢ·rᵢ·∂σᵢ/∂T`.  The derivative is
433    ///   computed once per temperature via
434    ///   `broadened_cross_sections_with_analytical_derivative_from_base()`
435    ///   and cached until temperature changes.  Costs one broadening call
436    ///   per Jacobian (same as the old FD approach, but exact).
437    ///
438    /// Returns `None` for the no-base_xs path (full forward model), which
439    /// falls back to finite-difference in the LM solver.
440    /// Analytical Jacobian for density and temperature fitting.
441    ///
442    /// Without resolution:
443    ///   ∂T/∂N_g = -(Σ_{i∈g} rᵢ σᵢ) · T
444    ///   ∂T/∂Temp = -T · Σᵢ nᵢ rᵢ ∂σᵢ/∂T
445    ///
446    /// With resolution (R is a linear operator):
447    ///   ∂T_obs/∂N_g = R\[-(Σ_{i∈g} rᵢ σᵢ) · T\]
448    ///   ∂T_obs/∂Temp = R\[-T · Σᵢ nᵢ rᵢ ∂σᵢ/∂T\]
449    ///
450    /// Returns `None` only when `base_xs` is not available (full forward
451    /// model path falls back to FD) or when the temperature cache is stale.
452    fn analytical_jacobian(
453        &self,
454        params: &[f64],
455        free_param_indices: &[usize],
456        y_current: &[f64],
457    ) -> Option<FlatMatrix> {
458        // Only provide analytical Jacobian when base_xs is available
459        // (temperature-fitting fast path with cached broadened XS).
460        let _base_xs_guard = self.base_xs.as_ref()?;
461        let cached_xs = self.cached_broadened_xs.borrow();
462        let broadened_xs = cached_xs.as_ref()?;
463
464        // Guard: verify the cache matches the current parameter temperature.
465        if let Some(ti) = self.temperature_index {
466            let param_temp = params[ti];
467            if (param_temp - self.cached_temperature.get()).abs() > 1e-15 {
468                return None;
469            }
470        }
471
472        let n_e = y_current.len();
473        let n_free = free_param_indices.len();
474        let mut jacobian = FlatMatrix::zeros(n_e, n_free);
475
476        let temp_col = self
477            .temperature_index
478            .and_then(|ti| free_param_indices.iter().position(|&fp| fp == ti));
479
480        // When resolution is enabled, we need the UNRESOLVED T to form
481        // inner derivatives, then apply resolution.  y_current is T_obs = R[T].
482        let t_unresolved: Option<Vec<f64>> = if self.instrument.is_some() {
483            let mut neg_opt = vec![0.0f64; n_e];
484            for (iso, xs) in broadened_xs.iter().enumerate() {
485                let density = params[self.density_indices[iso]];
486                let ratio = self.density_ratios[iso];
487                for (j, &sigma) in xs.iter().enumerate() {
488                    neg_opt[j] -= density * ratio * sigma;
489                }
490            }
491            Some(neg_opt.iter().map(|&d| d.exp()).collect())
492        } else {
493            None
494        };
495
496        // Helper: the T(E) to use for inner derivatives.
497        // With resolution: unresolved T.  Without: y_current IS T.
498        let t_for_deriv: &[f64] = t_unresolved.as_deref().unwrap_or(y_current);
499
500        // ── Density columns: ∂T/∂N_g or ∂T_obs/∂N_g ──
501        for (col, &fp_idx) in free_param_indices.iter().enumerate() {
502            if temp_col == Some(col) {
503                continue;
504            }
505            let mut sigma_sum = vec![0.0f64; n_e];
506            for (iso, &di) in self.density_indices.iter().enumerate() {
507                if di == fp_idx {
508                    let ratio = self.density_ratios[iso];
509                    for (j, &sigma) in broadened_xs[iso].iter().enumerate() {
510                        sigma_sum[j] += ratio * sigma;
511                    }
512                }
513            }
514            // Inner derivative: -σ_sum · T_unresolved
515            let inner: Vec<f64> = (0..n_e).map(|i| -sigma_sum[i] * t_for_deriv[i]).collect();
516
517            if let Some(ref inst) = self.instrument {
518                // ∂T_obs/∂N_g = R[inner]
519                let resolved =
520                    resolution::apply_resolution(&self.energies, &inner, &inst.resolution).ok()?;
521                for (i, &val) in resolved.iter().enumerate() {
522                    *jacobian.get_mut(i, col) = val;
523                }
524            } else {
525                for (i, &val) in inner.iter().enumerate() {
526                    *jacobian.get_mut(i, col) = val;
527                }
528            }
529        }
530
531        // ── Temperature column: ∂T/∂Temp or ∂T_obs/∂Temp ──
532        if let Some(col) = temp_col {
533            // Compute ∂σ/∂T on-demand if not cached.
534            {
535                let needs_compute = self.cached_dxs_dt.borrow().as_ref().is_none();
536                if needs_compute {
537                    let base_xs = self.base_xs.as_ref()?;
538                    let temperature_k = self.cached_temperature.get();
539                    let (_, dxs_vec) =
540                        transmission::broadened_cross_sections_with_analytical_derivative_from_base(
541                            &self.energies,
542                            base_xs,
543                            &self.resonance_data,
544                            temperature_k,
545                            self.instrument.as_deref(),
546                        )
547                        .ok()?;
548                    *self.cached_dxs_dt.borrow_mut() = Some(Rc::new(dxs_vec));
549                }
550            }
551            let cached_dxs = self.cached_dxs_dt.borrow();
552            let dxs_dt = cached_dxs.as_ref()?;
553
554            // Inner derivative: -T · Σᵢ nᵢ rᵢ ∂σᵢ/∂T
555            let inner: Vec<f64> = (0..n_e)
556                .map(|i| {
557                    let mut sum_n_dsigma = 0.0f64;
558                    for (iso, dxs) in dxs_dt.iter().enumerate() {
559                        let density = params[self.density_indices[iso]];
560                        let ratio = self.density_ratios[iso];
561                        sum_n_dsigma += density * ratio * dxs[i];
562                    }
563                    -t_for_deriv[i] * sum_n_dsigma
564                })
565                .collect();
566
567            if let Some(ref inst) = self.instrument {
568                let resolved =
569                    resolution::apply_resolution(&self.energies, &inner, &inst.resolution).ok()?;
570                for (i, &val) in resolved.iter().enumerate() {
571                    *jacobian.get_mut(i, col) = val;
572                }
573            } else {
574                for (i, &val) in inner.iter().enumerate() {
575                    *jacobian.get_mut(i, col) = val;
576                }
577            }
578        }
579
580        Some(jacobian)
581    }
582}
583
584/// Wraps a transmission model with SAMMY-style normalization and background.
585///
586/// T_out(E) = Anorm × T_inner(E) + BackA + BackB / √E + BackC × √E
587///          + BackD × exp(−BackF / √E)
588///
589/// The normalization and background parameters are additional entries in the
590/// parameter vector, appended after the density (and optional temperature)
591/// parameters of the inner model.
592///
593/// The exponential tail (BackD, BackF) is optional.  When
594/// `back_d_index` and `back_f_index` are `None`, the model reduces to
595/// the 4-parameter form.
596///
597/// ## SAMMY Reference
598/// SAMMY manual Sec III.E.2 — NORMAlization and BACKGround cards.
599/// SAMMY fits up to 6 background terms; we implement all 6:
600///   Anorm, constant BackA, 1/√E term BackB, √E term BackC,
601///   exponential amplitude BackD, exponential decay BackF.
602pub struct NormalizedTransmissionModel<M: FitModel> {
603    /// The inner (pure Beer-Lambert) transmission model.
604    inner: M,
605    /// Precomputed √E for each energy bin.  Computed once in `new()`.
606    sqrt_energies: Vec<f64>,
607    /// Precomputed 1/√E for each energy bin.  Computed once in `new()`.
608    inv_sqrt_energies: Vec<f64>,
609    /// Index of the Anorm parameter in the full parameter vector.
610    anorm_index: usize,
611    /// Index of the BackA (constant background) parameter.
612    back_a_index: usize,
613    /// Index of the BackB (1/√E background) parameter.
614    back_b_index: usize,
615    /// Index of the BackC (√E background) parameter.
616    back_c_index: usize,
617    /// Index of BackD (exponential amplitude) in the parameter vector.
618    /// `None` disables the exponential tail term.
619    back_d_index: Option<usize>,
620    /// Index of BackF (exponential decay constant) in the parameter vector.
621    /// `None` disables the exponential tail term.
622    back_f_index: Option<usize>,
623}
624
625impl<M: FitModel> NormalizedTransmissionModel<M> {
626    /// Create a new normalized transmission model (4-parameter, no exponential tail).
627    ///
628    /// # Arguments
629    /// * `inner` — The inner transmission model (Beer-Lambert).
630    /// * `energies` — Energy grid in eV (must be positive).
631    /// * `anorm_index` — Index of Anorm in the parameter vector.
632    /// * `back_a_index` — Index of BackA in the parameter vector.
633    /// * `back_b_index` — Index of BackB in the parameter vector.
634    /// * `back_c_index` — Index of BackC in the parameter vector.
635    pub fn new(
636        inner: M,
637        energies: &[f64],
638        anorm_index: usize,
639        back_a_index: usize,
640        back_b_index: usize,
641        back_c_index: usize,
642    ) -> Self {
643        let sqrt_energies: Vec<f64> = energies.iter().map(|&e| e.sqrt()).collect();
644        let inv_sqrt_energies: Vec<f64> = sqrt_energies
645            .iter()
646            .map(|&se| if se > 0.0 { 1.0 / se } else { 0.0 })
647            .collect();
648        Self {
649            inner,
650            sqrt_energies,
651            inv_sqrt_energies,
652            anorm_index,
653            back_a_index,
654            back_b_index,
655            back_c_index,
656            back_d_index: None,
657            back_f_index: None,
658        }
659    }
660
661    /// Create a normalized transmission model with the SAMMY exponential tail.
662    ///
663    /// Adds BackD × exp(−BackF / √E) to the 4-parameter background model.
664    ///
665    /// # Arguments
666    /// * `back_d_index` — Index of BackD (exponential amplitude) in the parameter vector.
667    /// * `back_f_index` — Index of BackF (exponential decay constant) in the parameter vector.
668    #[allow(clippy::too_many_arguments)]
669    pub fn new_with_exponential(
670        inner: M,
671        energies: &[f64],
672        anorm_index: usize,
673        back_a_index: usize,
674        back_b_index: usize,
675        back_c_index: usize,
676        back_d_index: usize,
677        back_f_index: usize,
678    ) -> Self {
679        let sqrt_energies: Vec<f64> = energies.iter().map(|&e| e.sqrt()).collect();
680        let inv_sqrt_energies: Vec<f64> = sqrt_energies
681            .iter()
682            .map(|&se| if se > 0.0 { 1.0 / se } else { 0.0 })
683            .collect();
684        Self {
685            inner,
686            sqrt_energies,
687            inv_sqrt_energies,
688            anorm_index,
689            back_a_index,
690            back_b_index,
691            back_c_index,
692            back_d_index: Some(back_d_index),
693            back_f_index: Some(back_f_index),
694        }
695    }
696}
697
698impl<M: FitModel> FitModel for NormalizedTransmissionModel<M> {
699    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
700        let t_inner = self.inner.evaluate(params)?;
701        let anorm = params[self.anorm_index];
702        let back_a = params[self.back_a_index];
703        let back_b = params[self.back_b_index];
704        let back_c = params[self.back_c_index];
705
706        // Optional exponential tail: BackD × exp(−BackF / √E)
707        let (back_d, back_f) = match (self.back_d_index, self.back_f_index) {
708            (Some(di), Some(fi)) => (params[di], params[fi]),
709            _ => (0.0, 0.0),
710        };
711        let has_exp = self.back_d_index.is_some();
712
713        let result: Vec<f64> = t_inner
714            .iter()
715            .enumerate()
716            .map(|(i, &t)| {
717                let mut val = anorm * t
718                    + back_a
719                    + back_b * self.inv_sqrt_energies[i]
720                    + back_c * self.sqrt_energies[i];
721                if has_exp {
722                    val += back_d * (-back_f * self.inv_sqrt_energies[i]).exp();
723                }
724                val
725            })
726            .collect();
727        Ok(result)
728    }
729
730    /// Analytical Jacobian for the normalized transmission model.
731    ///
732    /// For each free parameter:
733    /// - If it belongs to the inner model (density or temperature):
734    ///   ∂T_out/∂p = Anorm × ∂T_inner/∂p  (inner Jacobian scaled by Anorm)
735    /// - ∂T_out/∂Anorm  = T_inner(E)
736    /// - ∂T_out/∂BackA  = 1
737    /// - ∂T_out/∂BackB  = 1/√E
738    /// - ∂T_out/∂BackC  = √E
739    /// - ∂T_out/∂BackD  = exp(−BackF / √E)
740    /// - ∂T_out/∂BackF  = −BackD × exp(−BackF / √E) / √E
741    fn analytical_jacobian(
742        &self,
743        params: &[f64],
744        free_param_indices: &[usize],
745        y_current: &[f64],
746    ) -> Option<FlatMatrix> {
747        let n_e = y_current.len();
748        let n_free = free_param_indices.len();
749
750        // Compute T_inner for Anorm column and for scaling inner Jacobian.
751        // T_inner = (T_out - BackA - BackB/√E - BackC×√E) / Anorm
752        // But to avoid numerical issues, recompute from the inner model.
753        let t_inner = self.inner.evaluate(params).ok()?;
754
755        let anorm = params[self.anorm_index];
756
757        // Identify which free params are background params vs inner params.
758        let mut bg_indices_set = vec![
759            self.anorm_index,
760            self.back_a_index,
761            self.back_b_index,
762            self.back_c_index,
763        ];
764        if let Some(di) = self.back_d_index {
765            bg_indices_set.push(di);
766        }
767        if let Some(fi) = self.back_f_index {
768            bg_indices_set.push(fi);
769        }
770
771        // Collect inner model's free param indices (those not in bg_indices).
772        let inner_free_indices: Vec<usize> = free_param_indices
773            .iter()
774            .copied()
775            .filter(|idx| !bg_indices_set.contains(idx))
776            .collect();
777
778        // Get inner Jacobian if there are inner free params.
779        // y_current for the inner model is t_inner, not the outer y_current.
780        let inner_jac = if !inner_free_indices.is_empty() {
781            self.inner
782                .analytical_jacobian(params, &inner_free_indices, &t_inner)
783        } else {
784            None
785        };
786
787        // Precompute exp(−BackF / √E) for the exponential tail columns.
788        let exp_terms: Vec<f64> =
789            if let (Some(di), Some(fi)) = (self.back_d_index, self.back_f_index) {
790                let _back_d = params[di];
791                let back_f = params[fi];
792                self.inv_sqrt_energies
793                    .iter()
794                    .map(|&inv_se| (-back_f * inv_se).exp())
795                    .collect()
796            } else {
797                vec![]
798            };
799
800        let mut jacobian = FlatMatrix::zeros(n_e, n_free);
801
802        // Map inner free param index → column in inner Jacobian.
803        let mut inner_col_map = std::collections::HashMap::new();
804        for (col, &idx) in inner_free_indices.iter().enumerate() {
805            inner_col_map.insert(idx, col);
806        }
807
808        for (col, &fp_idx) in free_param_indices.iter().enumerate() {
809            if fp_idx == self.anorm_index {
810                // ∂T_out/∂Anorm = T_inner(E)
811                for (i, &ti) in t_inner.iter().enumerate() {
812                    *jacobian.get_mut(i, col) = ti;
813                }
814            } else if fp_idx == self.back_a_index {
815                // ∂T_out/∂BackA = 1
816                for i in 0..n_e {
817                    *jacobian.get_mut(i, col) = 1.0;
818                }
819            } else if fp_idx == self.back_b_index {
820                // ∂T_out/∂BackB = 1/√E
821                for (i, &inv_se) in self.inv_sqrt_energies.iter().enumerate() {
822                    *jacobian.get_mut(i, col) = inv_se;
823                }
824            } else if fp_idx == self.back_c_index {
825                // ∂T_out/∂BackC = √E
826                for (i, &se) in self.sqrt_energies.iter().enumerate() {
827                    *jacobian.get_mut(i, col) = se;
828                }
829            } else if self.back_d_index == Some(fp_idx) {
830                // ∂T_out/∂BackD = exp(−BackF / √E)
831                for (i, &et) in exp_terms.iter().enumerate() {
832                    *jacobian.get_mut(i, col) = et;
833                }
834            } else if self.back_f_index == Some(fp_idx) {
835                // ∂T_out/∂BackF = −BackD × exp(−BackF / √E) / √E
836                let back_d = params[self.back_d_index.unwrap()];
837                for (i, (&et, &inv_se)) in exp_terms
838                    .iter()
839                    .zip(self.inv_sqrt_energies.iter())
840                    .enumerate()
841                {
842                    *jacobian.get_mut(i, col) = -back_d * et * inv_se;
843                }
844            } else if let Some(&inner_col) = inner_col_map.get(&fp_idx) {
845                // Inner model parameter: ∂T_out/∂p = Anorm × ∂T_inner/∂p
846                if let Some(ref jac) = inner_jac {
847                    for i in 0..n_e {
848                        *jacobian.get_mut(i, col) = anorm * jac.get(i, inner_col);
849                    }
850                } else {
851                    // Inner model did not provide analytical Jacobian —
852                    // fall back to finite-difference for the whole thing.
853                    return None;
854                }
855            } else {
856                // Unknown parameter — should not happen, but fall back to FD.
857                return None;
858            }
859        }
860
861        Some(jacobian)
862    }
863}
864
865// ── Energy-scale transmission model (SAMMY TZERO equivalent) ─────────────
866
867/// Transmission model with energy-scale calibration parameters (t₀, L_scale).
868///
869/// Wraps precomputed cross-sections and re-maps the energy grid at each
870/// evaluation:
871///   1. Convert nominal energy → TOF: `t = TOF_FACTOR * L / √E_nom`
872///   2. Apply calibration: `t_corr = t - t₀`, `E_corr = (TOF_FACTOR * L * L_scale / t_corr)²`
873///   3. Interpolate cross-sections σ(E_nom) → σ(E_corr)
874///   4. Beer-Lambert + resolution on the corrected grid
875///
876/// This is equivalent to SAMMY's TZERO parameters.
877///
878/// The Jacobian for t₀ and L_scale is computed via finite differences
879/// (the energy-grid remapping is nonlinear and not easily differentiable
880/// analytically through the interpolation + resolution pipeline).
881pub struct EnergyScaleTransmissionModel {
882    /// Precomputed cross-sections on the NOMINAL energy grid.
883    cross_sections: Arc<Vec<Vec<f64>>>,
884    /// Density parameter indices (same as PrecomputedTransmissionModel).
885    density_indices: Arc<Vec<usize>>,
886    /// Nominal energy grid (eV, ascending).
887    nominal_energies: Vec<f64>,
888    /// Flight path length in meters (used for TOF↔energy conversion).
889    flight_path_m: f64,
890    /// TOF factor: sqrt(m_n / (2 * eV)) in μs·√eV/m.
891    tof_factor: f64,
892    /// Index of t₀ (μs) in the parameter vector.
893    t0_index: usize,
894    /// Index of L_scale (dimensionless) in the parameter vector.
895    l_scale_index: usize,
896    /// Instrument resolution parameters (applied after Beer-Lambert).
897    instrument: Option<Arc<transmission::InstrumentParams>>,
898}
899
900impl EnergyScaleTransmissionModel {
901    /// Create a new energy-scale transmission model.
902    ///
903    /// # Arguments
904    /// * `cross_sections` — Precomputed σ(E) on the nominal grid.
905    /// * `density_indices` — Maps isotope index → parameter index.
906    /// * `nominal_energies` — Energy grid in eV (ascending).
907    /// * `flight_path_m` — Nominal flight path in meters.
908    /// * `t0_index` — Index of t₀ parameter.
909    /// * `l_scale_index` — Index of L_scale parameter.
910    /// * `instrument` — Optional resolution function.
911    #[allow(clippy::too_many_arguments)]
912    pub fn new(
913        cross_sections: Arc<Vec<Vec<f64>>>,
914        density_indices: Arc<Vec<usize>>,
915        nominal_energies: Vec<f64>,
916        flight_path_m: f64,
917        t0_index: usize,
918        l_scale_index: usize,
919        instrument: Option<Arc<transmission::InstrumentParams>>,
920    ) -> Self {
921        // TOF_FACTOR = sqrt(m_n / (2 * eV)) * 1e6 [μs·√eV/m]
922        // m_n = 1.6749e-27 kg, eV = 1.602e-19 J
923        let tof_factor = (0.5 * 1.6749e-27 / 1.602e-19_f64).sqrt() * 1.0e6;
924        Self {
925            cross_sections,
926            density_indices,
927            nominal_energies,
928            flight_path_m,
929            tof_factor,
930            t0_index,
931            l_scale_index,
932            instrument,
933        }
934    }
935
936    /// Compute the corrected energy grid for given (t₀, L_scale).
937    ///
938    /// **Physical bound on `t0_us`.**  The corrected TOF is `tof - t0_us`,
939    /// where `tof = tof_factor · L / √E_nom`.  For the corrected grid to
940    /// remain physical, `tof_corr > 0` must hold for every bin — i.e.
941    /// `t0_us < min_i(tof_i) = tof_factor · L / √(max E_nom)`.  The
942    /// `EnergyScaleTransmissionModel` pipeline registers `t0_us` with
943    /// bounds of ±10 μs, which safely satisfies this invariant for VENUS
944    /// (L = 25 m, E ≤ 200 eV gives `min_tof ≈ 17.7 μs`).
945    ///
946    /// As a defensive measure — if a caller ever invokes this function
947    /// with a `t0_us` that would push any bin's `tof_corr` below zero —
948    /// we clamp `t0_us` to just under `min_tof` so the corrected grid
949    /// stays monotone and physical.  This is a safety net; the expected
950    /// path is that the optimizer's parameter bounds keep `t0_us` well
951    /// below the clamp threshold.
952    fn corrected_energies(&self, t0_us: f64, l_scale: f64) -> Vec<f64> {
953        if self.nominal_energies.is_empty() {
954            return Vec::new();
955        }
956        let l_eff = self.flight_path_m * l_scale;
957        // min(tof) over the grid = tof_factor * L / sqrt(max E_nom).
958        let min_tof = self
959            .nominal_energies
960            .iter()
961            .copied()
962            .fold(f64::INFINITY, |acc, e| {
963                acc.min(self.tof_factor * self.flight_path_m / e.sqrt())
964            });
965        let t0_limit = min_tof * (1.0 - 1.0e-12);
966        let t0_clamped = t0_us.min(t0_limit);
967        self.nominal_energies
968            .iter()
969            .map(|&e_nom| {
970                let tof = self.tof_factor * self.flight_path_m / e_nom.sqrt();
971                let tof_corr = tof - t0_clamped;
972                (self.tof_factor * l_eff / tof_corr).powi(2)
973            })
974            .collect()
975    }
976
977    /// Interpolate a cross-section array from nominal grid to corrected grid.
978    fn interpolate_xs(nominal_e: &[f64], xs: &[f64], corrected_e: &[f64]) -> Vec<f64> {
979        corrected_e
980            .iter()
981            .map(|&e_corr| {
982                // Binary search in the nominal (ascending) grid
983                let n = nominal_e.len();
984                if e_corr <= nominal_e[0] {
985                    return xs[0];
986                }
987                if e_corr >= nominal_e[n - 1] {
988                    return xs[n - 1];
989                }
990                let pos = nominal_e.partition_point(|&e| e < e_corr);
991                let i = if pos == 0 { 0 } else { pos - 1 };
992                let frac = (e_corr - nominal_e[i]) / (nominal_e[i + 1] - nominal_e[i]);
993                xs[i] + frac * (xs[i + 1] - xs[i])
994            })
995            .collect()
996    }
997
998    /// Evaluate transmission at given parameters (densities + t0 + l_scale).
999    fn evaluate_at(&self, params: &[f64], e_corr: &[f64]) -> Result<Vec<f64>, FittingError> {
1000        let n_e = self.nominal_energies.len();
1001
1002        // Interpolate cross-sections to corrected energy grid
1003        let mut neg_opt = vec![0.0f64; n_e];
1004        for (i, xs) in self.cross_sections.iter().enumerate() {
1005            let density = params[self.density_indices[i]];
1006            let xs_interp = Self::interpolate_xs(&self.nominal_energies, xs, e_corr);
1007            for (j, &sigma) in xs_interp.iter().enumerate() {
1008                neg_opt[j] -= density * sigma;
1009            }
1010        }
1011        let transmission: Vec<f64> = neg_opt.iter().map(|&d| d.exp()).collect();
1012
1013        // Resolution broadening on the corrected grid
1014        if let Some(inst) = &self.instrument {
1015            let t_broadened = resolution::apply_resolution(e_corr, &transmission, &inst.resolution)
1016                .map_err(|e| {
1017                    FittingError::EvaluationFailed(format!("resolution broadening: {e}"))
1018                })?;
1019            Ok(t_broadened)
1020        } else {
1021            Ok(transmission)
1022        }
1023    }
1024}
1025
1026impl FitModel for EnergyScaleTransmissionModel {
1027    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1028        let t0 = params[self.t0_index];
1029        let l_scale = params[self.l_scale_index];
1030        let e_corr = self.corrected_energies(t0, l_scale);
1031        self.evaluate_at(params, &e_corr)
1032    }
1033
1034    /// Jacobian: analytical for density parameters, finite-difference for t₀ and L_scale.
1035    fn analytical_jacobian(
1036        &self,
1037        params: &[f64],
1038        free_param_indices: &[usize],
1039        _y_current: &[f64],
1040    ) -> Option<FlatMatrix> {
1041        let n_e = self.nominal_energies.len();
1042        let n_free = free_param_indices.len();
1043        let mut jacobian = FlatMatrix::zeros(n_e, n_free);
1044
1045        let t0 = params[self.t0_index];
1046        let l_scale = params[self.l_scale_index];
1047        let e_corr = self.corrected_energies(t0, l_scale);
1048
1049        // Compute unresolved T and interpolated cross-sections for density derivatives
1050        let mut neg_opt = vec![0.0f64; n_e];
1051        let mut interp_xs_all: Vec<Vec<f64>> = Vec::new();
1052        for (i, xs) in self.cross_sections.iter().enumerate() {
1053            let density = params[self.density_indices[i]];
1054            let xs_interp = Self::interpolate_xs(&self.nominal_energies, xs, &e_corr);
1055            for (j, &sigma) in xs_interp.iter().enumerate() {
1056                neg_opt[j] -= density * sigma;
1057            }
1058            interp_xs_all.push(xs_interp);
1059        }
1060        let t_unresolved: Vec<f64> = neg_opt.iter().map(|&d| d.exp()).collect();
1061
1062        for (col, &fp_idx) in free_param_indices.iter().enumerate() {
1063            if fp_idx == self.t0_index || fp_idx == self.l_scale_index {
1064                // Finite difference for energy-scale parameters
1065                let h = if fp_idx == self.t0_index { 1e-4 } else { 1e-7 };
1066                let mut p_plus = params.to_vec();
1067                let mut p_minus = params.to_vec();
1068                p_plus[fp_idx] += h;
1069                p_minus[fp_idx] -= h;
1070                let y_plus = match self.evaluate(&p_plus) {
1071                    Ok(v) => v,
1072                    Err(_) => return None,
1073                };
1074                let y_minus = match self.evaluate(&p_minus) {
1075                    Ok(v) => v,
1076                    Err(_) => return None,
1077                };
1078                for i in 0..n_e {
1079                    *jacobian.get_mut(i, col) = (y_plus[i] - y_minus[i]) / (2.0 * h);
1080                }
1081            } else {
1082                // Density parameter: analytical derivative
1083                // ∂T/∂n_g = -(Σ_{iso∈g} σ_iso(E)) · T_unresolved(E)
1084                let mut sigma_sum = vec![0.0f64; n_e];
1085                for (iso, &di) in self.density_indices.iter().enumerate() {
1086                    if di == fp_idx {
1087                        for (j, &sigma) in interp_xs_all[iso].iter().enumerate() {
1088                            sigma_sum[j] += sigma;
1089                        }
1090                    }
1091                }
1092                let inner_deriv: Vec<f64> =
1093                    (0..n_e).map(|i| -sigma_sum[i] * t_unresolved[i]).collect();
1094
1095                // Apply resolution to derivative if enabled
1096                if let Some(inst) = &self.instrument {
1097                    let resolved_deriv =
1098                        match resolution::apply_resolution(&e_corr, &inner_deriv, &inst.resolution)
1099                        {
1100                            Ok(v) => v,
1101                            Err(_) => return None,
1102                        };
1103                    for (i, &val) in resolved_deriv.iter().enumerate() {
1104                        *jacobian.get_mut(i, col) = val;
1105                    }
1106                } else {
1107                    for (i, &val) in inner_deriv.iter().enumerate() {
1108                        *jacobian.get_mut(i, col) = val;
1109                    }
1110                }
1111            }
1112        }
1113
1114        Some(jacobian)
1115    }
1116}
1117
1118// ── ForwardModel implementations (Phase 1) ──────────────────────────────
1119//
1120// Each implementation delegates to the existing FitModel logic.
1121// `predict` == `evaluate`, `jacobian` converts FlatMatrix → Vec<Vec<f64>>.
1122
1123use crate::forward_model::ForwardModel;
1124
1125impl ForwardModel for PrecomputedTransmissionModel {
1126    fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1127        self.evaluate(params)
1128    }
1129
1130    fn jacobian(
1131        &self,
1132        params: &[f64],
1133        free_param_indices: &[usize],
1134        y_current: &[f64],
1135    ) -> Option<Vec<Vec<f64>>> {
1136        let fm = self.analytical_jacobian(params, free_param_indices, y_current)?;
1137        Some(flat_matrix_to_vecs(&fm, free_param_indices.len()))
1138    }
1139
1140    fn n_data(&self) -> usize {
1141        if self.cross_sections.is_empty() {
1142            0
1143        } else {
1144            self.cross_sections[0].len()
1145        }
1146    }
1147
1148    fn n_params(&self) -> usize {
1149        // Max index in density_indices + 1
1150        self.density_indices
1151            .iter()
1152            .copied()
1153            .max()
1154            .map_or(0, |m| m + 1)
1155    }
1156}
1157
1158impl ForwardModel for TransmissionFitModel {
1159    fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1160        self.evaluate(params)
1161    }
1162
1163    fn jacobian(
1164        &self,
1165        params: &[f64],
1166        free_param_indices: &[usize],
1167        y_current: &[f64],
1168    ) -> Option<Vec<Vec<f64>>> {
1169        let fm = self.analytical_jacobian(params, free_param_indices, y_current)?;
1170        Some(flat_matrix_to_vecs(&fm, free_param_indices.len()))
1171    }
1172
1173    fn n_data(&self) -> usize {
1174        self.energies.len()
1175    }
1176
1177    fn n_params(&self) -> usize {
1178        let max_density = self.density_indices.iter().copied().max().unwrap_or(0);
1179        let max_temp = self.temperature_index.unwrap_or(0);
1180        max_density.max(max_temp) + 1
1181    }
1182}
1183
1184impl<M: FitModel> ForwardModel for NormalizedTransmissionModel<M> {
1185    fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1186        self.evaluate(params)
1187    }
1188
1189    fn jacobian(
1190        &self,
1191        params: &[f64],
1192        free_param_indices: &[usize],
1193        y_current: &[f64],
1194    ) -> Option<Vec<Vec<f64>>> {
1195        let fm = self.analytical_jacobian(params, free_param_indices, y_current)?;
1196        Some(flat_matrix_to_vecs(&fm, free_param_indices.len()))
1197    }
1198
1199    fn n_data(&self) -> usize {
1200        self.sqrt_energies.len()
1201    }
1202
1203    fn n_params(&self) -> usize {
1204        // The background indices are the highest parameter indices.
1205        let mut max_idx = self
1206            .anorm_index
1207            .max(self.back_a_index)
1208            .max(self.back_b_index)
1209            .max(self.back_c_index);
1210        if let Some(di) = self.back_d_index {
1211            max_idx = max_idx.max(di);
1212        }
1213        if let Some(fi) = self.back_f_index {
1214            max_idx = max_idx.max(fi);
1215        }
1216        max_idx + 1
1217    }
1218}
1219
1220impl ForwardModel for EnergyScaleTransmissionModel {
1221    fn predict(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
1222        self.evaluate(params)
1223    }
1224
1225    fn jacobian(
1226        &self,
1227        params: &[f64],
1228        free_param_indices: &[usize],
1229        y_current: &[f64],
1230    ) -> Option<Vec<Vec<f64>>> {
1231        let fm = self.analytical_jacobian(params, free_param_indices, y_current)?;
1232        Some(flat_matrix_to_vecs(&fm, free_param_indices.len()))
1233    }
1234
1235    fn n_data(&self) -> usize {
1236        self.nominal_energies.len()
1237    }
1238
1239    fn n_params(&self) -> usize {
1240        self.t0_index.max(self.l_scale_index) + 1
1241    }
1242}
1243
1244/// Convert a `FlatMatrix` (row-major) to `Vec<Vec<f64>>` (column-major).
1245///
1246/// Returns `cols` vectors, each of length `fm.nrows()`.
1247fn flat_matrix_to_vecs(fm: &FlatMatrix, cols: usize) -> Vec<Vec<f64>> {
1248    let nrows = fm.nrows;
1249    (0..cols)
1250        .map(|j| (0..nrows).map(|i| fm.get(i, j)).collect())
1251        .collect()
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256    use super::*;
1257    use crate::lm::{self, FitModel, LmConfig};
1258    use crate::parameters::{FitParameter, ParameterSet};
1259    use nereids_core::types::Isotope;
1260    use nereids_endf::resonance::{LGroup, Resonance, ResonanceFormalism, ResonanceRange};
1261
1262    // ── PrecomputedTransmissionModel ─────────────────────────────────────────
1263
1264    /// Verify Beer-Lambert: T(E) = exp(-Σᵢ nᵢ·σᵢ(E)).
1265    #[test]
1266    fn precomputed_evaluate_matches_beer_lambert() {
1267        let model = make_precomputed(
1268            vec![
1269                vec![1.0, 2.0, 3.0], // isotope 0
1270                vec![0.5, 0.5, 0.5], // isotope 1
1271            ],
1272            vec![0, 1],
1273        );
1274
1275        let params = [0.2f64, 0.4f64];
1276        let y = model.evaluate(&params).unwrap();
1277
1278        let expected: Vec<f64> = (0..3)
1279            .map(|i| {
1280                let s0 = [1.0, 2.0, 3.0][i];
1281                let s1 = [0.5, 0.5, 0.5][i];
1282                (-params[0] * s0 - params[1] * s1).exp()
1283            })
1284            .collect();
1285
1286        assert_eq!(y.len(), 3);
1287        for (yi, ei) in y.iter().zip(expected.iter()) {
1288            assert!(
1289                (yi - ei).abs() < 1e-12,
1290                "evaluate mismatch: got {yi}, expected {ei}"
1291            );
1292        }
1293    }
1294
1295    /// Analytical Jacobian ∂T/∂nᵢ = -σᵢ(E)·T(E) must match central-difference FD.
1296    #[test]
1297    fn precomputed_analytical_jacobian_matches_finite_difference() {
1298        let model = make_precomputed(
1299            vec![
1300                vec![1.0, 2.0, 3.0], // isotope 0
1301                vec![0.5, 0.5, 0.5], // isotope 1
1302            ],
1303            vec![0, 1],
1304        );
1305
1306        let params = [0.2f64, 0.4f64];
1307        let y = model.evaluate(&params).unwrap();
1308        let free = vec![0usize, 1usize];
1309
1310        let jac = model
1311            .analytical_jacobian(&params, &free, &y)
1312            .expect("analytical_jacobian should return Some(_)");
1313
1314        assert_eq!(jac.nrows, 3); // n_energies
1315        assert_eq!(jac.ncols, 2); // n_free_params
1316
1317        // Central-difference reference.
1318        let h = 1e-6f64;
1319        for (col, &p_idx) in free.iter().enumerate() {
1320            let mut p_plus = params;
1321            let mut p_minus = params;
1322            p_plus[p_idx] += h;
1323            p_minus[p_idx] -= h;
1324
1325            let y_plus = model.evaluate(&p_plus).unwrap();
1326            let y_minus = model.evaluate(&p_minus).unwrap();
1327
1328            for i in 0..3 {
1329                let fd = (y_plus[i] - y_minus[i]) / (2.0 * h);
1330                let ana = jac.get(i, col);
1331                assert!(
1332                    (fd - ana).abs() < 1e-6,
1333                    "Jacobian mismatch (row {i}, col {col}): FD={fd:.8}, analytical={ana:.8}"
1334                );
1335            }
1336        }
1337    }
1338
1339    /// When two isotopes share a density parameter, the Jacobian column must
1340    /// equal -T(E) * (σ₀(E) + σ₁(E)), not just the first isotope's σ.
1341    #[test]
1342    fn precomputed_jacobian_tied_parameters_sums_both_isotopes() {
1343        // Two isotopes mapped to the same density parameter (index 0).
1344        let model = make_precomputed(
1345            vec![
1346                vec![1.0, 2.0, 3.0], // isotope 0
1347                vec![0.5, 1.0, 1.5], // isotope 1 — tied to same param
1348            ],
1349            vec![0, 0], // both isotopes share param[0]
1350        );
1351
1352        let params = [0.1f64];
1353        let y = model.evaluate(&params).unwrap();
1354        let free = vec![0usize];
1355
1356        let jac = model
1357            .analytical_jacobian(&params, &free, &y)
1358            .expect("analytical_jacobian should return Some(_)");
1359
1360        // Expected: ∂T/∂n = -T(E) * (σ₀(E) + σ₁(E))
1361        for i in 0..3 {
1362            let sigma_sum = [1.0, 2.0, 3.0][i] + [0.5, 1.0, 1.5][i];
1363            let expected = -y[i] * sigma_sum;
1364            assert!(
1365                (jac.get(i, 0) - expected).abs() < 1e-12,
1366                "Tied Jacobian mismatch at E[{i}]: got {}, expected {expected}",
1367                jac.get(i, 0)
1368            );
1369        }
1370    }
1371
1372    // ── TransmissionFitModel ─────────────────────────────────────────────────
1373
1374    fn u238_single_resonance() -> ResonanceData {
1375        ResonanceData {
1376            isotope: Isotope::new(92, 238).unwrap(),
1377            za: 92238,
1378            awr: 236.006,
1379            ranges: vec![ResonanceRange {
1380                energy_low: 1e-5,
1381                energy_high: 1e4,
1382                resolved: true,
1383                formalism: ResonanceFormalism::ReichMoore,
1384                target_spin: 0.0,
1385                scattering_radius: 9.4285,
1386                naps: 1,
1387                l_groups: vec![LGroup {
1388                    l: 0,
1389                    awr: 236.006,
1390                    apl: 0.0,
1391                    qx: 0.0,
1392                    lrx: 0,
1393                    resonances: vec![Resonance {
1394                        energy: 6.674,
1395                        j: 0.5,
1396                        gn: 1.493e-3,
1397                        gg: 23.0e-3,
1398                        gfa: 0.0,
1399                        gfb: 0.0,
1400                    }],
1401                }],
1402                rml: None,
1403                urr: None,
1404                ap_table: None,
1405                r_external: vec![],
1406            }],
1407        }
1408    }
1409
1410    #[test]
1411    fn test_recover_single_isotope_thickness() {
1412        let data = u238_single_resonance();
1413        let true_thickness = 0.0005;
1414
1415        // Generate synthetic data
1416        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1417
1418        let model = TransmissionFitModel::new(
1419            energies.clone(),
1420            vec![data],
1421            0.0,
1422            None,
1423            (vec![0], vec![1.0]),
1424            None,
1425            None,
1426        )
1427        .unwrap();
1428
1429        let y_obs = model.evaluate(&[true_thickness]).unwrap();
1430        let sigma = vec![0.01; y_obs.len()]; // 1% uncertainty
1431
1432        let mut params = ParameterSet::new(vec![
1433            FitParameter::non_negative("thickness", 0.001), // initial guess 2× off
1434        ]);
1435
1436        let result =
1437            lm::levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default())
1438                .unwrap();
1439
1440        assert!(result.converged, "Fit did not converge");
1441        let fitted = result.params[0];
1442        assert!(
1443            (fitted - true_thickness).abs() / true_thickness < 0.01,
1444            "Fitted thickness = {}, true = {}, error = {:.1}%",
1445            fitted,
1446            true_thickness,
1447            (fitted - true_thickness).abs() / true_thickness * 100.0,
1448        );
1449    }
1450
1451    #[test]
1452    fn test_recover_two_isotope_thicknesses() {
1453        let u238 = u238_single_resonance();
1454
1455        // Second isotope with resonance at 20 eV
1456        let other = ResonanceData {
1457            isotope: Isotope::new(1, 10).unwrap(),
1458            za: 1010,
1459            awr: 10.0,
1460            ranges: vec![ResonanceRange {
1461                energy_low: 0.0,
1462                energy_high: 100.0,
1463                resolved: true,
1464                formalism: ResonanceFormalism::ReichMoore,
1465                target_spin: 0.0,
1466                scattering_radius: 5.0,
1467                naps: 1,
1468                l_groups: vec![LGroup {
1469                    l: 0,
1470                    awr: 10.0,
1471                    apl: 5.0,
1472                    qx: 0.0,
1473                    lrx: 0,
1474                    resonances: vec![Resonance {
1475                        energy: 20.0,
1476                        j: 0.5,
1477                        gn: 0.1,
1478                        gg: 0.05,
1479                        gfa: 0.0,
1480                        gfb: 0.0,
1481                    }],
1482                }],
1483                rml: None,
1484                urr: None,
1485                ap_table: None,
1486                r_external: vec![],
1487            }],
1488        };
1489
1490        let true_t1 = 0.0003;
1491        let true_t2 = 0.0001;
1492
1493        let energies: Vec<f64> = (0..301).map(|i| 1.0 + (i as f64) * 0.1).collect();
1494
1495        let model = TransmissionFitModel::new(
1496            energies.clone(),
1497            vec![u238, other],
1498            0.0,
1499            None,
1500            (vec![0, 1], vec![1.0, 1.0]),
1501            None,
1502            None,
1503        )
1504        .unwrap();
1505
1506        let y_obs = model.evaluate(&[true_t1, true_t2]).unwrap();
1507        let sigma = vec![0.01; y_obs.len()];
1508
1509        let mut params = ParameterSet::new(vec![
1510            FitParameter::non_negative("U-238 thickness", 0.001),
1511            FitParameter::non_negative("Other thickness", 0.001),
1512        ]);
1513
1514        let result =
1515            lm::levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &LmConfig::default())
1516                .unwrap();
1517
1518        assert!(
1519            result.converged,
1520            "Fit did not converge after {} iterations",
1521            result.iterations
1522        );
1523
1524        let (fit_t1, fit_t2) = (result.params[0], result.params[1]);
1525        assert!(
1526            (fit_t1 - true_t1).abs() / true_t1 < 0.05,
1527            "U-238: fitted={}, true={}, error={:.1}%",
1528            fit_t1,
1529            true_t1,
1530            (fit_t1 - true_t1).abs() / true_t1 * 100.0,
1531        );
1532        assert!(
1533            (fit_t2 - true_t2).abs() / true_t2 < 0.05,
1534            "Other: fitted={}, true={}, error={:.1}%",
1535            fit_t2,
1536            true_t2,
1537            (fit_t2 - true_t2).abs() / true_t2 * 100.0,
1538        );
1539    }
1540
1541    // ── Temperature fitting ──────────────────────────────────────────────────
1542
1543    /// Verify that temperature_index makes evaluate() read T from the
1544    /// parameter vector instead of the fixed `temperature_k` field.
1545    #[test]
1546    fn temperature_index_overrides_fixed_temperature() {
1547        let data = u238_single_resonance();
1548        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1549
1550        // Model with fixed temperature = 0 K but temperature_index pointing
1551        // to params[1].
1552        let model = TransmissionFitModel::new(
1553            energies.clone(),
1554            vec![data.clone()],
1555            0.0,
1556            None,
1557            (vec![0], vec![1.0]),
1558            Some(1),
1559            None,
1560        )
1561        .unwrap();
1562
1563        // Model with fixed temperature = 300 K (no temperature_index).
1564        let model_fixed = TransmissionFitModel::new(
1565            energies.clone(),
1566            vec![data],
1567            300.0,
1568            None,
1569            (vec![0], vec![1.0]),
1570            None,
1571            None,
1572        )
1573        .unwrap();
1574
1575        let density = 0.0005;
1576        let y_via_index = model.evaluate(&[density, 300.0]).unwrap();
1577        let y_via_fixed = model_fixed.evaluate(&[density]).unwrap();
1578
1579        for (a, b) in y_via_index.iter().zip(y_via_fixed.iter()) {
1580            assert!(
1581                (a - b).abs() < 1e-12,
1582                "temperature_index path disagrees with fixed path: {} vs {}",
1583                a,
1584                b
1585            );
1586        }
1587    }
1588
1589    /// Recover temperature from Doppler-broadened synthetic data.
1590    ///
1591    /// Generates transmission at T_true with known density, then fits both
1592    /// density and temperature simultaneously.
1593    #[test]
1594    fn test_recover_temperature() {
1595        let data = u238_single_resonance();
1596        let true_density = 0.0005;
1597        let true_temp = 300.0; // K
1598
1599        // Energy grid around the 6.674 eV resonance.
1600        let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.025).collect();
1601
1602        // Generate synthetic data at the true temperature.
1603        let model = TransmissionFitModel::new(
1604            energies.clone(),
1605            vec![data],
1606            0.0, // ignored — temperature_index is set
1607            None,
1608            (vec![0], vec![1.0]),
1609            Some(1), // params[1] = temperature
1610            None,
1611        )
1612        .unwrap();
1613
1614        let mut y_obs = model.evaluate(&[true_density, true_temp]).unwrap();
1615        // Add tiny deterministic noise so reduced_chi2 stays positive.
1616        // Without noise, the analytical Jacobian converges to exact parameters,
1617        // yielding chi2 ≈ 0, which makes covariance ≈ 0 and uncertainty NaN.
1618        for (i, y) in y_obs.iter_mut().enumerate() {
1619            *y *= 1.0 + 1e-5 * ((i % 7) as f64 - 3.0);
1620        }
1621        let sigma = vec![0.005; y_obs.len()];
1622
1623        // Fit with initial guesses offset from truth.
1624        let mut params = ParameterSet::new(vec![
1625            FitParameter::non_negative("density", 0.001),
1626            FitParameter {
1627                name: "temperature_k".into(),
1628                value: 200.0, // initial guess 100 K off
1629                lower: 1.0,
1630                upper: 2000.0,
1631                fixed: false,
1632            },
1633        ]);
1634
1635        let config = LmConfig {
1636            max_iter: 200,
1637            ..LmConfig::default()
1638        };
1639
1640        let result = lm::levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
1641
1642        assert!(
1643            result.converged,
1644            "Temperature fit did not converge after {} iterations",
1645            result.iterations
1646        );
1647
1648        let fit_density = result.params[0];
1649        let fit_temp = result.params[1];
1650
1651        // Tiny deterministic noise (max ±3e-5): optimizer should converge to within 0.1%.
1652        assert!(
1653            (fit_density - true_density).abs() / true_density < 0.001,
1654            "Density: fitted={}, true={}, error={:.1}%",
1655            fit_density,
1656            true_density,
1657            (fit_density - true_density).abs() / true_density * 100.0,
1658        );
1659        assert!(
1660            (fit_temp - true_temp).abs() / true_temp < 0.001,
1661            "Temperature: fitted={:.1} K, true={:.1} K, error={:.1}%",
1662            fit_temp,
1663            true_temp,
1664            (fit_temp - true_temp).abs() / true_temp * 100.0,
1665        );
1666
1667        // Verify uncertainty is reported.
1668        let unc = result
1669            .uncertainties
1670            .expect("uncertainties should be available");
1671        assert!(
1672            unc.len() == 2,
1673            "expected 2 uncertainties, got {}",
1674            unc.len()
1675        );
1676        assert!(
1677            unc[1] > 0.0 && unc[1].is_finite(),
1678            "temperature uncertainty should be positive and finite, got {}",
1679            unc[1]
1680        );
1681    }
1682
1683    /// Analytical Jacobian for TransmissionFitModel (with temperature) must
1684    /// agree with central-difference finite-difference Jacobian.
1685    ///
1686    /// This validates both the density columns (∂T/∂nᵢ = -σᵢ·T) and the
1687    /// temperature column (forward FD at T+dT).
1688    #[test]
1689    fn transmission_fit_model_analytical_jacobian_matches_fd() {
1690        let data = u238_single_resonance();
1691        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1692
1693        let model = TransmissionFitModel::new(
1694            energies,
1695            vec![data],
1696            0.0,
1697            None,
1698            (vec![0], vec![1.0]),
1699            Some(1), // params[1] = temperature
1700            None,
1701        )
1702        .unwrap();
1703
1704        let params = [0.0005f64, 300.0f64]; // density, temperature
1705        let y = model.evaluate(&params).unwrap();
1706        let free = vec![0usize, 1usize];
1707
1708        let jac = model
1709            .analytical_jacobian(&params, &free, &y)
1710            .expect("analytical_jacobian should return Some(_)");
1711
1712        assert_eq!(jac.nrows, y.len());
1713        assert_eq!(jac.ncols, 2);
1714
1715        // Central-difference reference.
1716        let h = 1e-6f64;
1717        for (col, &p_idx) in free.iter().enumerate() {
1718            let mut p_plus = params;
1719            let mut p_minus = params;
1720            p_plus[p_idx] += h * (1.0 + params[p_idx].abs());
1721            p_minus[p_idx] -= h * (1.0 + params[p_idx].abs());
1722
1723            let y_plus = model.evaluate(&p_plus).unwrap();
1724            let y_minus = model.evaluate(&p_minus).unwrap();
1725
1726            let actual_2h = p_plus[p_idx] - p_minus[p_idx];
1727            for i in 0..y.len() {
1728                let fd = (y_plus[i] - y_minus[i]) / actual_2h;
1729                let ana = jac.get(i, col);
1730                let err = (fd - ana).abs();
1731                // Use a meaningful floor: when both FD and analytical values
1732                // are below 1e-10, relative error comparisons are dominated
1733                // by floating-point noise and are not physically meaningful.
1734                //
1735                // The floor was raised from 1e-15 to 1e-10 alongside the
1736                // B=S_l boundary condition fix in the Reich-Moore U-matrix.
1737                // That fix shifted near-zero cross-section values from
1738                // O(1e-15) to O(1e-10), making the old floor too tight for
1739                // floating-point comparison at those magnitudes.
1740                let scale = fd.abs().max(ana.abs()).max(1e-10);
1741                assert!(
1742                    err / scale < 0.01,
1743                    "Jacobian mismatch (row {i}, col {col}): FD={fd:.8}, analytical={ana:.8}, \
1744                     rel_err={:.4}",
1745                    err / scale,
1746                );
1747            }
1748        }
1749    }
1750
1751    /// Verify that the broadened-XS cache avoids redundant recomputation.
1752    /// Calling evaluate() twice with the same temperature should produce
1753    /// identical results and reuse the cache.
1754    #[test]
1755    fn transmission_fit_model_cache_reuse() {
1756        let data = u238_single_resonance();
1757        let energies: Vec<f64> = (0..201).map(|i| 1.0 + (i as f64) * 0.05).collect();
1758
1759        let model = TransmissionFitModel::new(
1760            energies,
1761            vec![data],
1762            0.0,
1763            None,
1764            (vec![0], vec![1.0]),
1765            Some(1),
1766            None,
1767        )
1768        .unwrap();
1769
1770        // First call populates the cache.
1771        let y1 = model.evaluate(&[0.0005, 300.0]).unwrap();
1772        assert!(model.cached_broadened_xs.borrow().is_some());
1773        assert!((model.cached_temperature.get() - 300.0).abs() < 1e-15);
1774
1775        // Second call with same temperature but different density should
1776        // reuse cached broadened XS (no rebroadening).
1777        let y2 = model.evaluate(&[0.001, 300.0]).unwrap();
1778        assert!((model.cached_temperature.get() - 300.0).abs() < 1e-15);
1779
1780        // Results must differ (different density) but cache temperature unchanged.
1781        assert!(
1782            (y1[100] - y2[100]).abs() > 1e-10,
1783            "different densities should produce different transmission"
1784        );
1785
1786        // Change temperature — cache should update.
1787        let _y3 = model.evaluate(&[0.0005, 600.0]).unwrap();
1788        assert!((model.cached_temperature.get() - 600.0).abs() < 1e-15);
1789    }
1790
1791    // ── NormalizedTransmissionModel ─────────────────────────────────────────
1792
1793    /// Helper: make a PrecomputedTransmissionModel with given cross-sections
1794    /// and no resolution (Beer-Lambert only).
1795    fn make_precomputed(
1796        xs: Vec<Vec<f64>>,
1797        density_indices: Vec<usize>,
1798    ) -> PrecomputedTransmissionModel {
1799        PrecomputedTransmissionModel {
1800            cross_sections: Arc::new(xs),
1801            density_indices: Arc::new(density_indices),
1802            energies: None,
1803            instrument: None,
1804        }
1805    }
1806
1807    /// Verify that NormalizedTransmissionModel with identity normalization
1808    /// (Anorm=1, all background=0) gives the same result as the inner model.
1809    #[test]
1810    fn normalized_identity_matches_inner() {
1811        let xs = vec![
1812            vec![1.0, 2.0, 3.0], // isotope 0
1813            vec![0.5, 0.5, 0.5], // isotope 1
1814        ];
1815        let inner_ref = make_precomputed(xs.clone(), vec![0, 1]);
1816        let inner_wrap = make_precomputed(xs, vec![0, 1]);
1817
1818        let energies = [4.0, 9.0, 16.0];
1819        // params: [density0, density1, Anorm, BackA, BackB, BackC]
1820        let model = NormalizedTransmissionModel::new(inner_wrap, &energies, 2, 3, 4, 5);
1821
1822        let params = [0.2, 0.4, 1.0, 0.0, 0.0, 0.0];
1823        let y_norm = model.evaluate(&params).unwrap();
1824        let y_inner = inner_ref.evaluate(&params).unwrap();
1825
1826        for (a, b) in y_norm.iter().zip(y_inner.iter()) {
1827            assert!(
1828                (a - b).abs() < 1e-12,
1829                "identity normalization should match inner: {} vs {}",
1830                a,
1831                b
1832            );
1833        }
1834    }
1835
1836    /// Verify the normalization formula:
1837    /// T_out = Anorm * T_inner + BackA + BackB/sqrt(E) + BackC*sqrt(E)
1838    #[test]
1839    fn normalized_formula_correct() {
1840        let xs = vec![vec![1.0, 2.0, 3.0]];
1841        let inner_ref = make_precomputed(xs.clone(), vec![0]);
1842        let inner_wrap = make_precomputed(xs, vec![0]);
1843
1844        let energies = [4.0, 9.0, 16.0]; // sqrt = [2, 3, 4]
1845        let model = NormalizedTransmissionModel::new(inner_wrap, &energies, 1, 2, 3, 4);
1846
1847        // params: [density, Anorm, BackA, BackB, BackC]
1848        let anorm = 0.95;
1849        let back_a = 0.01;
1850        let back_b = 0.02;
1851        let back_c = 0.005;
1852        let density = 0.3;
1853        let params = [density, anorm, back_a, back_b, back_c];
1854
1855        let y = model.evaluate(&params).unwrap();
1856        let t_inner = inner_ref.evaluate(&params).unwrap();
1857
1858        for (i, (&yi, &ti)) in y.iter().zip(t_inner.iter()).enumerate() {
1859            let sqrt_e = energies[i].sqrt();
1860            let expected = anorm * ti + back_a + back_b / sqrt_e + back_c * sqrt_e;
1861            assert!(
1862                (yi - expected).abs() < 1e-12,
1863                "E[{i}]: got {yi}, expected {expected}"
1864            );
1865        }
1866    }
1867
1868    /// Analytical Jacobian of NormalizedTransmissionModel must match
1869    /// central-difference finite-difference.
1870    #[test]
1871    fn normalized_analytical_jacobian_matches_fd() {
1872        let xs = vec![
1873            vec![1.0, 2.0, 3.0], // isotope 0
1874            vec![0.5, 0.5, 0.5], // isotope 1
1875        ];
1876        let inner = make_precomputed(xs, vec![0, 1]);
1877
1878        let energies = [4.0, 9.0, 16.0];
1879        // params: [density0, density1, Anorm, BackA, BackB, BackC]
1880        let model = NormalizedTransmissionModel::new(inner, &energies, 2, 3, 4, 5);
1881
1882        let params = [0.2, 0.4, 0.95, 0.01, 0.02, 0.005];
1883        let y = model.evaluate(&params).unwrap();
1884        let free: Vec<usize> = (0..6).collect();
1885
1886        let jac = model
1887            .analytical_jacobian(&params, &free, &y)
1888            .expect("analytical_jacobian should return Some");
1889
1890        assert_eq!(jac.nrows, 3);
1891        assert_eq!(jac.ncols, 6);
1892
1893        // Central-difference reference
1894        let h = 1e-7;
1895        for (col, &p_idx) in free.iter().enumerate() {
1896            let mut p_plus = params;
1897            let mut p_minus = params;
1898            p_plus[p_idx] += h;
1899            p_minus[p_idx] -= h;
1900
1901            let y_plus = model.evaluate(&p_plus).unwrap();
1902            let y_minus = model.evaluate(&p_minus).unwrap();
1903
1904            for i in 0..3 {
1905                let fd = (y_plus[i] - y_minus[i]) / (2.0 * h);
1906                let ana = jac.get(i, col);
1907                let err = (fd - ana).abs();
1908                let scale = fd.abs().max(ana.abs()).max(1e-10);
1909                assert!(
1910                    err / scale < 1e-4,
1911                    "Jacobian mismatch (row {i}, col {col}): FD={fd:.8}, analytical={ana:.8}, \
1912                     rel_err={:.6}",
1913                    err / scale,
1914                );
1915            }
1916        }
1917    }
1918
1919    /// Verify that when some background params are fixed (not in
1920    /// free_param_indices), the Jacobian columns are correct.
1921    #[test]
1922    fn normalized_jacobian_partial_free() {
1923        let xs = vec![vec![1.0, 2.0, 3.0]];
1924        let inner = make_precomputed(xs, vec![0]);
1925
1926        let energies = [4.0, 9.0, 16.0];
1927        let model = NormalizedTransmissionModel::new(inner, &energies, 1, 2, 3, 4);
1928
1929        // params: [density, Anorm, BackA, BackB, BackC]
1930        let params = [0.3, 0.95, 0.01, 0.0, 0.0];
1931        let y = model.evaluate(&params).unwrap();
1932        // Only density and Anorm are free
1933        let free = vec![0usize, 1usize];
1934
1935        let jac = model
1936            .analytical_jacobian(&params, &free, &y)
1937            .expect("should return Some for partial free");
1938
1939        assert_eq!(jac.nrows, 3);
1940        assert_eq!(jac.ncols, 2);
1941
1942        // Central-difference reference
1943        let h = 1e-7;
1944        for (col, &p_idx) in free.iter().enumerate() {
1945            let mut p_plus = params;
1946            let mut p_minus = params;
1947            p_plus[p_idx] += h;
1948            p_minus[p_idx] -= h;
1949
1950            let y_plus = model.evaluate(&p_plus).unwrap();
1951            let y_minus = model.evaluate(&p_minus).unwrap();
1952
1953            for i in 0..3 {
1954                let fd = (y_plus[i] - y_minus[i]) / (2.0 * h);
1955                let ana = jac.get(i, col);
1956                let err = (fd - ana).abs();
1957                let scale = fd.abs().max(ana.abs()).max(1e-10);
1958                assert!(
1959                    err / scale < 1e-4,
1960                    "Jacobian mismatch (row {i}, col {col}): FD={fd:.8}, analytical={ana:.8}"
1961                );
1962            }
1963        }
1964    }
1965
1966    /// End-to-end: fit recovers known Anorm + BackA from synthetic data.
1967    #[test]
1968    fn normalized_fit_recovers_anorm_and_backa() {
1969        let xs = vec![vec![1.0, 2.0, 3.0, 2.0, 1.5]];
1970        let inner = make_precomputed(xs, vec![0]);
1971
1972        let energies = [4.0, 9.0, 16.0, 25.0, 36.0];
1973        let model = NormalizedTransmissionModel::new(inner, &energies, 1, 2, 3, 4);
1974
1975        // True parameters
1976        let true_density = 0.2;
1977        let true_anorm = 0.95;
1978        let true_back_a = 0.02;
1979        let true_params = [true_density, true_anorm, true_back_a, 0.0, 0.0];
1980
1981        let y_obs = model.evaluate(&true_params).unwrap();
1982        let sigma = vec![0.001; y_obs.len()];
1983
1984        // Initial guesses offset from truth
1985        let mut params = ParameterSet::new(vec![
1986            FitParameter::non_negative("density", 0.1),
1987            FitParameter {
1988                name: "anorm".into(),
1989                value: 1.0,
1990                lower: 0.5,
1991                upper: 1.5,
1992                fixed: false,
1993            },
1994            FitParameter::unbounded("back_a", 0.0),
1995            FitParameter::fixed("back_b", 0.0),
1996            FitParameter::fixed("back_c", 0.0),
1997        ]);
1998
1999        let config = LmConfig {
2000            max_iter: 200,
2001            ..LmConfig::default()
2002        };
2003
2004        let result = lm::levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
2005
2006        assert!(result.converged, "Fit should converge");
2007
2008        let fit_density = result.params[0];
2009        let fit_anorm = result.params[1];
2010        let fit_back_a = result.params[2];
2011
2012        assert!(
2013            (fit_density - true_density).abs() / true_density < 0.01,
2014            "density: fitted={fit_density}, true={true_density}"
2015        );
2016        assert!(
2017            (fit_anorm - true_anorm).abs() / true_anorm < 0.01,
2018            "anorm: fitted={fit_anorm}, true={true_anorm}"
2019        );
2020        assert!(
2021            (fit_back_a - true_back_a).abs() < 0.001,
2022            "back_a: fitted={fit_back_a}, true={true_back_a}"
2023        );
2024    }
2025
2026    // ── Phase 1: ForwardModel tests ──
2027
2028    #[test]
2029    fn forward_model_predict_equals_fit_model_evaluate_precomputed() {
2030        use crate::forward_model::ForwardModel;
2031        let xs = vec![vec![1.0, 2.0, 3.0, 2.0, 1.5]];
2032        let model = make_precomputed(xs, vec![0]);
2033        let params = [0.001];
2034        let fm_result = model.evaluate(&params).unwrap();
2035        let fwd_result = model.predict(&params).unwrap();
2036        assert_eq!(fm_result, fwd_result);
2037        assert_eq!(model.n_data(), 5);
2038        assert_eq!(model.n_params(), 1);
2039    }
2040
2041    #[test]
2042    fn forward_model_predict_equals_fit_model_evaluate_normalized() {
2043        use crate::forward_model::ForwardModel;
2044        let xs = vec![vec![1.0, 2.0, 3.0, 2.0, 1.5]];
2045        let inner = make_precomputed(xs, vec![0]);
2046        let energies = [4.0, 9.0, 16.0, 25.0, 36.0];
2047        let model = NormalizedTransmissionModel::new(inner, &energies, 1, 2, 3, 4);
2048        let params = [0.001, 0.95, 0.01, 0.0, 0.0];
2049        let fm_result = model.evaluate(&params).unwrap();
2050        let fwd_result = model.predict(&params).unwrap();
2051        assert_eq!(fm_result, fwd_result);
2052        assert_eq!(model.n_data(), 5);
2053        assert_eq!(model.n_params(), 5);
2054    }
2055
2056    #[test]
2057    fn forward_model_jacobian_columns_match_precomputed() {
2058        use crate::forward_model::ForwardModel;
2059        let xs = vec![vec![1.0, 2.0, 3.0], vec![0.5, 1.5, 2.5]];
2060        let model = make_precomputed(xs, vec![0, 1]);
2061        let params = [0.001, 0.002];
2062        let y = model.predict(&params).unwrap();
2063        let free_indices = vec![0, 1];
2064        let jac = model
2065            .jacobian(&params, &free_indices, &y)
2066            .expect("analytical jacobian should be available");
2067        assert_eq!(jac.len(), 2); // 2 columns (one per free param)
2068        assert_eq!(jac[0].len(), 3); // 3 rows (one per energy bin)
2069    }
2070
2071    // ── Issue #442 Step 3 regression tests ─────────────────────────────────
2072
2073    /// Issue #442: PrecomputedTransmissionModel with resolution must match
2074    /// forward_model() with resolution for the same single-isotope sample.
2075    #[test]
2076    fn precomputed_with_resolution_matches_forward_model() {
2077        use nereids_physics::resolution::ResolutionFunction;
2078
2079        let data = u238_single_resonance();
2080        let thickness = 0.0005;
2081        let temperature = 300.0;
2082        let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
2083
2084        let inst = Arc::new(InstrumentParams {
2085            resolution: ResolutionFunction::Gaussian(
2086                nereids_physics::resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2087            ),
2088        });
2089
2090        // Reference: forward_model() (already fixed in Step 1).
2091        let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
2092        let t_forward = transmission::forward_model(&energies, &sample, Some(&inst)).unwrap();
2093
2094        // Precomputed path: Doppler-only XS → PrecomputedTransmissionModel.
2095        let xs = transmission::broadened_cross_sections(
2096            &energies,
2097            std::slice::from_ref(&data),
2098            temperature,
2099            Some(&inst), // aux grid for Doppler accuracy
2100            None,
2101        )
2102        .unwrap();
2103        let model = PrecomputedTransmissionModel {
2104            cross_sections: Arc::new(xs),
2105            density_indices: Arc::new(vec![0]),
2106            energies: Some(Arc::new(energies.clone())),
2107            instrument: Some(Arc::clone(&inst)),
2108        };
2109        let t_precomputed = model.evaluate(&[thickness]).unwrap();
2110
2111        // Both should agree closely on the interior grid.
2112        // Small differences are expected from extended-grid Doppler
2113        // in forward_model vs data-grid Doppler in broadened_cross_sections.
2114        let interior = 20..energies.len() - 20;
2115        let mut max_err = 0.0f64;
2116        for i in interior {
2117            let err = (t_forward[i] - t_precomputed[i]).abs();
2118            max_err = max_err.max(err);
2119        }
2120        assert!(
2121            max_err < 0.02,
2122            "PrecomputedTransmissionModel with resolution should match \
2123             forward_model.  Max error = {max_err}"
2124        );
2125    }
2126
2127    /// Issue #442: PrecomputedTransmissionModel without resolution must
2128    /// behave identically to the pre-fix version (pure Beer-Lambert).
2129    #[test]
2130    fn precomputed_without_resolution_unchanged() {
2131        let model_no_res = make_precomputed(
2132            vec![vec![100.0, 200.0, 50.0]], // one isotope
2133            vec![0],
2134        );
2135        let params = [0.001f64]; // density
2136        let t = model_no_res.evaluate(&params).unwrap();
2137
2138        // Expected: pure Beer-Lambert.
2139        let expected: Vec<f64> = [100.0, 200.0, 50.0]
2140            .iter()
2141            .map(|&sigma| (-params[0] * sigma).exp())
2142            .collect();
2143
2144        for (i, (&ti, &ei)) in t.iter().zip(expected.iter()).enumerate() {
2145            assert!(
2146                (ti - ei).abs() < 1e-14,
2147                "No-resolution mismatch at bin {i}: got {ti}, expected {ei}"
2148            );
2149        }
2150
2151        // Analytical Jacobian should still be available when instrument is None.
2152        let y = model_no_res.evaluate(&params).unwrap();
2153        assert!(
2154            model_no_res
2155                .analytical_jacobian(&params, &[0], &y)
2156                .is_some(),
2157            "Analytical Jacobian must be available when instrument is None"
2158        );
2159    }
2160
2161    /// PrecomputedTransmissionModel with resolution: analytical Jacobian
2162    /// exists and density derivative matches finite difference.
2163    #[test]
2164    fn precomputed_jacobian_with_resolution_matches_fd() {
2165        use nereids_physics::resolution::ResolutionFunction;
2166
2167        let data = u238_single_resonance();
2168        let temperature = 300.0;
2169        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2170        let inst = Arc::new(InstrumentParams {
2171            resolution: ResolutionFunction::Gaussian(
2172                nereids_physics::resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2173            ),
2174        });
2175
2176        let xs = transmission::broadened_cross_sections(
2177            &energies,
2178            std::slice::from_ref(&data),
2179            temperature,
2180            Some(&inst),
2181            None,
2182        )
2183        .unwrap();
2184        let model = PrecomputedTransmissionModel {
2185            cross_sections: Arc::new(xs),
2186            density_indices: Arc::new(vec![0]),
2187            energies: Some(Arc::new(energies.clone())),
2188            instrument: Some(Arc::clone(&inst)),
2189        };
2190
2191        let params = [0.0005f64];
2192        let y = model.evaluate(&params).unwrap();
2193
2194        let jac = model
2195            .analytical_jacobian(&params, &[0], &y)
2196            .expect("analytical Jacobian must be available with resolution");
2197
2198        // Finite-difference reference.
2199        let h = 1e-7;
2200        let y_plus = model.evaluate(&[params[0] + h]).unwrap();
2201        let y_minus = model.evaluate(&[params[0] - h]).unwrap();
2202
2203        let interior = 20..energies.len() - 20;
2204        let mut max_rel_err = 0.0f64;
2205        for i in interior {
2206            let fd = (y_plus[i] - y_minus[i]) / (2.0 * h);
2207            let ana = jac.get(i, 0);
2208            let denom = fd.abs().max(ana.abs()).max(1e-30);
2209            max_rel_err = max_rel_err.max((ana - fd).abs() / denom);
2210        }
2211        assert!(
2212            max_rel_err < 0.01,
2213            "PrecomputedTM analytical Jacobian with resolution vs FD: \
2214             max relative error = {max_rel_err}"
2215        );
2216    }
2217
2218    /// PrecomputedTransmissionModel with resolution + shared density param:
2219    /// grouped isotope Jacobian matches FD.
2220    #[test]
2221    fn precomputed_jacobian_grouped_with_resolution_matches_fd() {
2222        use nereids_physics::resolution::ResolutionFunction;
2223
2224        let energies: Vec<f64> = (0..100).map(|i| 1.0 + i as f64 * 0.1).collect();
2225        let inst = Arc::new(InstrumentParams {
2226            resolution: ResolutionFunction::Gaussian(
2227                nereids_physics::resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2228            ),
2229        });
2230        // Two isotopes sharing one density parameter.
2231        let xs = vec![vec![10.0; 100], vec![5.0; 100]];
2232        let model = PrecomputedTransmissionModel {
2233            cross_sections: Arc::new(xs),
2234            density_indices: Arc::new(vec![0, 0]), // both share param[0]
2235            energies: Some(Arc::new(energies.clone())),
2236            instrument: Some(Arc::clone(&inst)),
2237        };
2238
2239        let params = [0.001f64];
2240        let y = model.evaluate(&params).unwrap();
2241        let jac = model
2242            .analytical_jacobian(&params, &[0], &y)
2243            .expect("analytical Jacobian must be available");
2244
2245        let h = 1e-7;
2246        let y_plus = model.evaluate(&[params[0] + h]).unwrap();
2247        let y_minus = model.evaluate(&[params[0] - h]).unwrap();
2248
2249        let mut max_rel_err = 0.0f64;
2250        for i in 10..energies.len() - 10 {
2251            let fd = (y_plus[i] - y_minus[i]) / (2.0 * h);
2252            let ana = jac.get(i, 0);
2253            let denom = fd.abs().max(ana.abs()).max(1e-30);
2254            max_rel_err = max_rel_err.max((ana - fd).abs() / denom);
2255        }
2256        assert!(
2257            max_rel_err < 0.01,
2258            "Grouped PrecomputedTM analytical Jacobian with resolution vs FD: \
2259             max relative error = {max_rel_err}"
2260        );
2261    }
2262
2263    // ── TransmissionFitModel Jacobian with resolution ──────────────────────
2264
2265    /// TransmissionFitModel with resolution: analytical Jacobian exists and
2266    /// density + temperature columns match finite difference.
2267    #[test]
2268    fn transmission_fit_model_jacobian_with_resolution_matches_fd() {
2269        use nereids_physics::resolution::ResolutionFunction;
2270
2271        let data = u238_single_resonance();
2272        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2273        let inst = Arc::new(InstrumentParams {
2274            resolution: ResolutionFunction::Gaussian(
2275                nereids_physics::resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2276            ),
2277        });
2278
2279        let model = TransmissionFitModel::new(
2280            energies.clone(),
2281            vec![data],
2282            300.0,
2283            Some(inst),
2284            (vec![0], vec![1.0]),
2285            Some(1), // temperature_index = 1
2286            None,
2287        )
2288        .unwrap();
2289
2290        let params = [0.0005f64, 300.0];
2291        let y = model.evaluate(&params).unwrap();
2292        let free = vec![0usize, 1usize];
2293
2294        let jac = model
2295            .analytical_jacobian(&params, &free, &y)
2296            .expect("analytical Jacobian must be available with resolution");
2297
2298        // FD for each free param.
2299        let h_density = 1e-7;
2300        let h_temp = 0.01; // temperature needs larger step
2301
2302        for (col, (&fp_idx, &h)) in free.iter().zip([h_density, h_temp].iter()).enumerate() {
2303            let mut p_plus = params;
2304            let mut p_minus = params;
2305            p_plus[fp_idx] += h;
2306            p_minus[fp_idx] -= h;
2307            let y_plus = model.evaluate(&p_plus).unwrap();
2308            let y_minus = model.evaluate(&p_minus).unwrap();
2309
2310            let interior = 20..energies.len() - 20;
2311            let mut max_rel_err = 0.0f64;
2312            for i in interior {
2313                let fd = (y_plus[i] - y_minus[i]) / (2.0 * h);
2314                let ana = jac.get(i, col);
2315                let denom = fd.abs().max(ana.abs()).max(1e-30);
2316                max_rel_err = max_rel_err.max((ana - fd).abs() / denom);
2317            }
2318            let label = if col == 0 { "density" } else { "temperature" };
2319            assert!(
2320                max_rel_err < 0.05,
2321                "TransmissionFitModel {label} column with resolution vs FD: \
2322                 max relative error = {max_rel_err}"
2323            );
2324        }
2325    }
2326
2327    /// TransmissionFitModel without resolution: analytical Jacobian still
2328    /// available and unchanged.
2329    #[test]
2330    fn transmission_fit_model_jacobian_available_without_resolution() {
2331        let data = u238_single_resonance();
2332        let energies: Vec<f64> = (0..101).map(|i| 4.0 + (i as f64) * 0.05).collect();
2333
2334        let model = TransmissionFitModel::new(
2335            energies,
2336            vec![data],
2337            300.0,
2338            None,
2339            (vec![0], vec![1.0]),
2340            Some(1),
2341            None,
2342        )
2343        .unwrap();
2344
2345        let params = [0.0005, 300.0];
2346        let y = model.evaluate(&params).unwrap();
2347
2348        assert!(
2349            model.analytical_jacobian(&params, &[0, 1], &y).is_some(),
2350            "TransmissionFitModel analytical Jacobian must be available \
2351             when resolution is disabled"
2352        );
2353    }
2354
2355    // ── Issue #442: TransmissionFitModel temperature-path resolution fix ───
2356
2357    /// TransmissionFitModel::evaluate() with fit_temperature=true and
2358    /// resolution enabled must match forward_model() for the same sample.
2359    #[test]
2360    fn transmission_fit_model_temp_path_with_resolution_matches_forward_model() {
2361        use nereids_physics::resolution::ResolutionFunction;
2362
2363        let data = u238_single_resonance();
2364        let thickness = 0.0005;
2365        let temperature = 300.0;
2366        let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
2367
2368        let inst = Arc::new(InstrumentParams {
2369            resolution: ResolutionFunction::Gaussian(
2370                nereids_physics::resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2371            ),
2372        });
2373
2374        // Reference: forward_model() (corrected in Step 1).
2375        let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
2376        let t_ref = transmission::forward_model(&energies, &sample, Some(&inst)).unwrap();
2377
2378        // Temperature-fitting path through TransmissionFitModel.
2379        let model = TransmissionFitModel::new(
2380            energies.clone(),
2381            vec![data],
2382            temperature,
2383            Some(Arc::clone(&inst)),
2384            (vec![0], vec![1.0]),
2385            Some(1), // temperature_index
2386            None,
2387        )
2388        .unwrap();
2389
2390        // params = [density, temperature]
2391        let t_model = model.evaluate(&[thickness, temperature]).unwrap();
2392
2393        // Compare on interior (skip boundary effects from extended grid
2394        // differences between forward_model and broadened_cross_sections_from_base).
2395        let interior = 20..energies.len() - 20;
2396        let mut max_err = 0.0f64;
2397        for i in interior {
2398            max_err = max_err.max((t_ref[i] - t_model[i]).abs());
2399        }
2400        assert!(
2401            max_err < 0.02,
2402            "TransmissionFitModel temperature path with resolution should match \
2403             forward_model.  Max error = {max_err}"
2404        );
2405    }
2406
2407    /// TransmissionFitModel temperature path without resolution must be
2408    /// unchanged (pure Doppler + Beer-Lambert).
2409    #[test]
2410    fn transmission_fit_model_temp_path_no_resolution_unchanged() {
2411        let data = u238_single_resonance();
2412        let thickness = 0.0005;
2413        let temperature = 300.0;
2414        let energies: Vec<f64> = (0..201).map(|i| 4.0 + (i as f64) * 0.025).collect();
2415
2416        // Reference: forward_model without resolution.
2417        let sample = SampleParams::new(temperature, vec![(data.clone(), thickness)]).unwrap();
2418        let t_ref = transmission::forward_model(&energies, &sample, None).unwrap();
2419
2420        // TransmissionFitModel, no resolution.
2421        let model = TransmissionFitModel::new(
2422            energies.clone(),
2423            vec![data],
2424            temperature,
2425            None,
2426            (vec![0], vec![1.0]),
2427            Some(1),
2428            None,
2429        )
2430        .unwrap();
2431
2432        let t_model = model.evaluate(&[thickness, temperature]).unwrap();
2433
2434        for (i, (&r, &m)) in t_ref.iter().zip(t_model.iter()).enumerate() {
2435            assert!(
2436                (r - m).abs() < 1e-12,
2437                "No-resolution mismatch at E[{i}]={}: ref={r}, model={m}",
2438                energies[i]
2439            );
2440        }
2441    }
2442
2443    /// Resolution-enabled temperature path must produce measurably different
2444    /// results from the unresolved path (verifies resolution is being applied).
2445    #[test]
2446    fn transmission_fit_model_temp_path_resolution_makes_difference() {
2447        use nereids_physics::resolution::ResolutionFunction;
2448
2449        let data = u238_single_resonance();
2450        let thickness = 0.0005;
2451        let temperature = 300.0;
2452        let energies: Vec<f64> = (0..401).map(|i| 4.0 + (i as f64) * 0.015).collect();
2453
2454        let inst = Arc::new(InstrumentParams {
2455            resolution: ResolutionFunction::Gaussian(
2456                nereids_physics::resolution::ResolutionParams::new(25.0, 0.5, 0.005, 0.0).unwrap(),
2457            ),
2458        });
2459
2460        // With resolution.
2461        let model_res = TransmissionFitModel::new(
2462            energies.clone(),
2463            vec![data.clone()],
2464            temperature,
2465            Some(inst),
2466            (vec![0], vec![1.0]),
2467            Some(1),
2468            None,
2469        )
2470        .unwrap();
2471        let t_res = model_res.evaluate(&[thickness, temperature]).unwrap();
2472
2473        // Without resolution.
2474        let model_no = TransmissionFitModel::new(
2475            energies.clone(),
2476            vec![data],
2477            temperature,
2478            None,
2479            (vec![0], vec![1.0]),
2480            Some(1),
2481            None,
2482        )
2483        .unwrap();
2484        let t_no = model_no.evaluate(&[thickness, temperature]).unwrap();
2485
2486        let interior = 20..energies.len() - 20;
2487        let max_diff: f64 = interior
2488            .map(|i| (t_res[i] - t_no[i]).abs())
2489            .fold(0.0f64, f64::max);
2490        assert!(
2491            max_diff > 1e-4,
2492            "Resolution should make a measurable difference in the temperature \
2493             path, but max diff = {max_diff}"
2494        );
2495    }
2496
2497    // ── Exponential background (BackD, BackF) tests ──
2498
2499    /// Verify that new_with_exponential evaluate() matches the formula:
2500    /// T_out = Anorm*T_inner + BackA + BackB/√E + BackC*√E + BackD*exp(-BackF/√E)
2501    #[test]
2502    fn exponential_evaluate_formula_correct() {
2503        let xs = vec![vec![1.0, 2.0, 3.0]];
2504        let inner = make_precomputed(xs, vec![0]);
2505        let energies = [4.0, 9.0, 25.0]; // sqrt = [2, 3, 5]
2506
2507        let model =
2508            NormalizedTransmissionModel::new_with_exponential(inner, &energies, 1, 2, 3, 4, 5, 6);
2509
2510        // params: [density, anorm, back_a, back_b, back_c, back_d, back_f]
2511        let density = 0.1;
2512        let anorm = 1.02;
2513        let back_a = 0.01;
2514        let back_b = 0.005;
2515        let back_c = 0.002;
2516        let back_d = 0.05;
2517        let back_f = 3.0;
2518        let params = [density, anorm, back_a, back_b, back_c, back_d, back_f];
2519
2520        let y = model.evaluate(&params).unwrap();
2521
2522        // Manually compute expected
2523        let xs_vals = [1.0, 2.0, 3.0];
2524        let sqrt_e = [2.0, 3.0, 5.0];
2525        for i in 0..3 {
2526            let t_inner = (-density * xs_vals[i]).exp();
2527            let expected = anorm * t_inner
2528                + back_a
2529                + back_b / sqrt_e[i]
2530                + back_c * sqrt_e[i]
2531                + back_d * (-back_f / sqrt_e[i]).exp();
2532            assert!(
2533                (y[i] - expected).abs() < 1e-12,
2534                "bin {i}: got {}, expected {expected}",
2535                y[i]
2536            );
2537        }
2538    }
2539
2540    /// Analytical Jacobian for BackD and BackF columns must match central FD.
2541    #[test]
2542    fn exponential_jacobian_matches_finite_difference() {
2543        let xs = vec![vec![1.0, 2.0, 3.0, 0.5, 1.5]];
2544        let inner = make_precomputed(xs, vec![0]);
2545        let energies = [0.1, 1.0, 4.0, 25.0, 100.0]; // span 0.1–100 eV
2546
2547        let model =
2548            NormalizedTransmissionModel::new_with_exponential(inner, &energies, 1, 2, 3, 4, 5, 6);
2549
2550        // params: [density, anorm, back_a, back_b, back_c, back_d, back_f]
2551        let params = [0.1, 1.02, 0.01, 0.005, 0.002, 0.05, 3.0];
2552        let y = model.evaluate(&params).unwrap();
2553        let free_indices: Vec<usize> = (0..7).collect();
2554
2555        let jac = model
2556            .analytical_jacobian(&params, &free_indices, &y)
2557            .expect("analytical Jacobian should be available");
2558
2559        // Central finite difference for all parameters
2560        let h = 1e-7;
2561        for (col, &pidx) in free_indices.iter().enumerate() {
2562            let mut p_plus = params.to_vec();
2563            let mut p_minus = params.to_vec();
2564            p_plus[pidx] += h;
2565            p_minus[pidx] -= h;
2566            let y_plus = model.evaluate(&p_plus).unwrap();
2567            let y_minus = model.evaluate(&p_minus).unwrap();
2568
2569            for row in 0..energies.len() {
2570                let fd = (y_plus[row] - y_minus[row]) / (2.0 * h);
2571                let anal = jac.get(row, col);
2572                let abs_err = (anal - fd).abs();
2573                let rel_err = abs_err / fd.abs().max(1e-15);
2574                assert!(
2575                    rel_err < 1e-5 || abs_err < 1e-10,
2576                    "param {pidx} (col {col}), bin {row}: analytical={anal:.10e}, \
2577                     fd={fd:.10e}, rel_err={rel_err:.2e}"
2578                );
2579            }
2580        }
2581    }
2582
2583    /// Round-trip: fit recovers all 6 background + density from noiseless data.
2584    #[test]
2585    fn exponential_fit_recovers_all_params() {
2586        let xs = vec![vec![1.0, 2.0, 3.0, 2.0, 1.5, 0.8, 1.2, 2.5]];
2587        let inner = make_precomputed(xs, vec![0]);
2588        let energies = [0.5, 1.0, 4.0, 9.0, 16.0, 25.0, 36.0, 64.0];
2589
2590        let model =
2591            NormalizedTransmissionModel::new_with_exponential(inner, &energies, 1, 2, 3, 4, 5, 6);
2592
2593        // True parameters
2594        let true_density = 0.15;
2595        let true_anorm = 1.02;
2596        let true_back_a = 0.01;
2597        let true_back_b = 0.005;
2598        let true_back_c = 0.002;
2599        let true_back_d = 0.03;
2600        let true_back_f = 2.0;
2601        let true_params = [
2602            true_density,
2603            true_anorm,
2604            true_back_a,
2605            true_back_b,
2606            true_back_c,
2607            true_back_d,
2608            true_back_f,
2609        ];
2610
2611        let y_obs = model.evaluate(&true_params).unwrap();
2612        let sigma = vec![0.001; y_obs.len()];
2613
2614        let mut params = ParameterSet::new(vec![
2615            FitParameter::non_negative("density", 0.1),
2616            FitParameter {
2617                name: "anorm".into(),
2618                value: 1.0,
2619                lower: 0.5,
2620                upper: 1.5,
2621                fixed: false,
2622            },
2623            FitParameter {
2624                name: "back_a".into(),
2625                value: 0.0,
2626                lower: -0.5,
2627                upper: 0.5,
2628                fixed: false,
2629            },
2630            FitParameter {
2631                name: "back_b".into(),
2632                value: 0.0,
2633                lower: -0.5,
2634                upper: 0.5,
2635                fixed: false,
2636            },
2637            FitParameter {
2638                name: "back_c".into(),
2639                value: 0.0,
2640                lower: -0.5,
2641                upper: 0.5,
2642                fixed: false,
2643            },
2644            FitParameter {
2645                name: "back_d".into(),
2646                value: 0.01,
2647                lower: 0.0,
2648                upper: 1.0,
2649                fixed: false,
2650            },
2651            FitParameter {
2652                name: "back_f".into(),
2653                value: 1.0,
2654                lower: 0.0,
2655                upper: 100.0,
2656                fixed: false,
2657            },
2658        ]);
2659
2660        let config = LmConfig {
2661            max_iter: 500,
2662            ..LmConfig::default()
2663        };
2664
2665        let result = lm::levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
2666
2667        assert!(result.converged, "Fit should converge");
2668
2669        let fitted = &result.params;
2670        let check = |name, fitted_val: f64, true_val: f64, tol: f64| {
2671            let err = (fitted_val - true_val).abs();
2672            let rel = err / true_val.abs().max(1e-10);
2673            assert!(
2674                rel < tol || err < 1e-6,
2675                "{name}: fitted={fitted_val:.6}, true={true_val:.6}, rel_err={rel:.4}"
2676            );
2677        };
2678
2679        check("density", fitted[0], true_density, 0.10);
2680        check("anorm", fitted[1], true_anorm, 0.10);
2681        check("back_a", fitted[2], true_back_a, 0.10);
2682        check("back_b", fitted[3], true_back_b, 0.10);
2683        check("back_c", fitted[4], true_back_c, 0.10);
2684        check("back_d", fitted[5], true_back_d, 0.10);
2685        check("back_f", fitted[6], true_back_f, 0.10);
2686    }
2687
2688    // ── EnergyScaleTransmissionModel tests ──
2689
2690    /// Verify that corrected_energies shifts the grid correctly.
2691    #[test]
2692    fn energy_scale_corrected_energies() {
2693        let xs = vec![vec![1.0; 5]];
2694        let energies = vec![10.0, 20.0, 50.0, 100.0, 200.0];
2695        let model = EnergyScaleTransmissionModel::new(
2696            std::sync::Arc::new(xs),
2697            std::sync::Arc::new(vec![0]),
2698            energies.clone(),
2699            25.0,
2700            1, // t0_index
2701            2, // l_scale_index
2702            None,
2703        );
2704
2705        // With t0=0, l_scale=1: corrected energies should equal nominal
2706        let e_corr = model.corrected_energies(0.0, 1.0);
2707        for (i, (&nom, &corr)) in energies.iter().zip(e_corr.iter()).enumerate() {
2708            assert!(
2709                (nom - corr).abs() / nom < 1e-10,
2710                "bin {i}: nominal={nom}, corrected={corr}"
2711            );
2712        }
2713
2714        // With l_scale > 1: all corrected energies should increase
2715        let e_corr_ls = model.corrected_energies(0.0, 1.005);
2716        for (i, (&nom, &corr)) in energies.iter().zip(e_corr_ls.iter()).enumerate() {
2717            assert!(
2718                corr > nom,
2719                "bin {i}: l_scale=1.005 should increase energy, got nom={nom}, corr={corr}"
2720            );
2721        }
2722
2723        // With t0 > 0: energies should increase (shorter effective TOF)
2724        let e_corr_t0 = model.corrected_energies(1.0, 1.0);
2725        for (i, (&nom, &corr)) in energies.iter().zip(e_corr_t0.iter()).enumerate() {
2726            assert!(
2727                corr > nom,
2728                "bin {i}: t0=1.0 should increase energy, got nom={nom}, corr={corr}"
2729            );
2730        }
2731    }
2732
2733    /// Verify interpolate_xs produces correct results.
2734    #[test]
2735    fn energy_scale_interpolate_xs() {
2736        let nominal_e = vec![1.0, 2.0, 3.0, 4.0, 5.0];
2737        let xs = vec![10.0, 20.0, 30.0, 40.0, 50.0];
2738
2739        // Exact grid points
2740        let result =
2741            EnergyScaleTransmissionModel::interpolate_xs(&nominal_e, &xs, &[1.0, 3.0, 5.0]);
2742        assert!((result[0] - 10.0).abs() < 1e-10);
2743        assert!((result[1] - 30.0).abs() < 1e-10);
2744        assert!((result[2] - 50.0).abs() < 1e-10);
2745
2746        // Midpoints
2747        let result = EnergyScaleTransmissionModel::interpolate_xs(&nominal_e, &xs, &[1.5, 2.5]);
2748        assert!((result[0] - 15.0).abs() < 1e-10);
2749        assert!((result[1] - 25.0).abs() < 1e-10);
2750
2751        // Out of range: clamp
2752        let result = EnergyScaleTransmissionModel::interpolate_xs(&nominal_e, &xs, &[0.5, 6.0]);
2753        assert!((result[0] - 10.0).abs() < 1e-10); // below range
2754        assert!((result[1] - 50.0).abs() < 1e-10); // above range
2755    }
2756
2757    /// Verify evaluate returns identity at t0=0, l_scale=1.
2758    #[test]
2759    fn energy_scale_evaluate_identity() {
2760        let xs = vec![vec![1.0, 2.0, 3.0, 2.0, 1.5]];
2761        let energies = vec![4.0, 9.0, 16.0, 25.0, 36.0];
2762        let model_es = EnergyScaleTransmissionModel::new(
2763            std::sync::Arc::new(xs.clone()),
2764            std::sync::Arc::new(vec![0]),
2765            energies.clone(),
2766            25.0,
2767            1, // t0_index
2768            2, // l_scale_index
2769            None,
2770        );
2771
2772        let model_pre = make_precomputed(xs, vec![0]);
2773
2774        let density = 0.1;
2775        let params_es = [density, 0.0, 1.0]; // t0=0, l_scale=1
2776        let params_pre = [density];
2777
2778        let y_es = model_es.evaluate(&params_es).unwrap();
2779        let y_pre = model_pre.evaluate(&params_pre).unwrap();
2780
2781        for (i, (&a, &b)) in y_es.iter().zip(y_pre.iter()).enumerate() {
2782            assert!(
2783                (a - b).abs() < 1e-10,
2784                "bin {i}: energy_scale={a}, precomputed={b}"
2785            );
2786        }
2787    }
2788
2789    /// Jacobian for energy-scale model: density columns must match FD.
2790    #[test]
2791    fn energy_scale_jacobian_density_matches_fd() {
2792        let xs = vec![vec![1.0, 2.0, 3.0, 2.0, 1.5]];
2793        let energies = vec![4.0, 9.0, 16.0, 25.0, 36.0];
2794        let model = EnergyScaleTransmissionModel::new(
2795            std::sync::Arc::new(xs),
2796            std::sync::Arc::new(vec![0]),
2797            energies.clone(),
2798            25.0,
2799            1,
2800            2,
2801            None,
2802        );
2803
2804        let params = [0.1, 0.5, 1.002]; // density, t0, l_scale
2805        let y = model.evaluate(&params).unwrap();
2806        let free = vec![0, 1, 2];
2807        let jac = model
2808            .analytical_jacobian(&params, &free, &y)
2809            .expect("Jacobian should be available");
2810
2811        let h = 1e-7;
2812        for (col, &pidx) in free.iter().enumerate() {
2813            let mut pp = params.to_vec();
2814            let mut pm = params.to_vec();
2815            pp[pidx] += h;
2816            pm[pidx] -= h;
2817            let yp = model.evaluate(&pp).unwrap();
2818            let ym = model.evaluate(&pm).unwrap();
2819            for row in 0..energies.len() {
2820                let fd = (yp[row] - ym[row]) / (2.0 * h);
2821                let anal = jac.get(row, col);
2822                let abs_err = (anal - fd).abs();
2823                let rel_err = abs_err / fd.abs().max(1e-15);
2824                assert!(
2825                    rel_err < 1e-3 || abs_err < 1e-8,
2826                    "param {pidx} col {col} bin {row}: anal={anal:.6e} fd={fd:.6e} rel={rel_err:.2e}"
2827                );
2828            }
2829        }
2830    }
2831
2832    /// LM fit with energy-scale model recovers l_scale from shifted data.
2833    ///
2834    /// Uses a sharp Breit-Wigner-like resonance on a dense grid so the
2835    /// energy shift is unambiguous.  Only l_scale is varied (t0 fixed
2836    /// at 0) to avoid degenerate local minima.
2837    #[test]
2838    fn energy_scale_fit_recovers_l_scale() {
2839        let n = 200;
2840        let energies: Vec<f64> = (0..n).map(|i| 10.0 + (i as f64) * 0.5).collect();
2841        // Breit-Wigner-like cross-section: σ = 100 / ((E-50)^2 + 1)
2842        let xs: Vec<f64> = energies
2843            .iter()
2844            .map(|&e| 100.0 / ((e - 50.0).powi(2) + 1.0))
2845            .collect();
2846
2847        let true_density = 0.001;
2848        let true_ls = 1.003;
2849
2850        let model = EnergyScaleTransmissionModel::new(
2851            std::sync::Arc::new(vec![xs]),
2852            std::sync::Arc::new(vec![0]),
2853            energies,
2854            25.0,
2855            1, // t0_index (fixed at 0)
2856            2, // l_scale_index
2857            None,
2858        );
2859        let true_params = [true_density, 0.0, true_ls];
2860        let y_obs = model.evaluate(&true_params).unwrap();
2861        let sigma = vec![0.001; y_obs.len()];
2862
2863        let mut params = ParameterSet::new(vec![
2864            FitParameter::non_negative("density", 0.0005),
2865            FitParameter::fixed("t0", 0.0),
2866            FitParameter {
2867                name: "l_scale".into(),
2868                value: 1.0,
2869                lower: 0.99,
2870                upper: 1.01,
2871                fixed: false,
2872            },
2873        ]);
2874
2875        let config = LmConfig {
2876            max_iter: 200,
2877            ..LmConfig::default()
2878        };
2879
2880        let result = lm::levenberg_marquardt(&model, &y_obs, &sigma, &mut params, &config).unwrap();
2881
2882        assert!(result.converged, "Fit should converge");
2883        let f = &result.params;
2884        assert!(
2885            (f[0] - true_density).abs() / true_density < 0.05,
2886            "density: fitted={}, true={true_density}",
2887            f[0]
2888        );
2889        assert!(
2890            (f[2] - true_ls).abs() < 0.001,
2891            "l_scale: fitted={}, true={true_ls}",
2892            f[2]
2893        );
2894    }
2895}