Skip to main content

nereids_pipeline/
counts_fit.rs

1//! The areal densities of a sample's isotopes, its temperature, and the
2//! sample run's normalization and background, fitted to the counts of an
3//! open-beam run and a sample run recorded in the same time bins.
4
5use std::borrow::Cow;
6use std::cell::RefCell;
7use std::ops::RangeInclusive;
8use std::sync::Arc;
9
10use nereids_endf::resonance::ResonanceData;
11use nereids_fitting::error::FittingError;
12use nereids_fitting::lm::{FitModel, FlatMatrix};
13use nereids_fitting::parameters::{FitParameter, ParameterSet};
14use nereids_physics::continuous_doppler::{SUPPORT_X, broaden_with_derivative};
15use nereids_physics::doppler::DopplerParams;
16use nereids_physics::flight_time_grid::FlightTimeGrid;
17use nereids_physics::resolution::TOF_FACTOR;
18use nereids_physics::transmission::resonance_center_energies;
19use rayon::prelude::*;
20
21use crate::beam::BeamSpline;
22use crate::error::PipelineError;
23use crate::open_beam::{
24    Calibration, OpenBeamModel, Recorded, fit_on_halved_grids, fit_open_beam, overdispersion,
25    validate_counts, validate_live,
26};
27use crate::pipeline::TEMPERATURE_BOUNDS_K;
28
29/// A count in a bin predicted fewer counts than this has a chance below it
30/// under the model.
31pub const NEGLIGIBLE_PREDICTION: f64 = 1e-10;
32
33/// A quantity the fit holds at a known value, fits from a starting value
34/// over the quantity's range, or fits from `start` within `lower..=upper`
35/// inside that range.
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub enum Value {
38    Known(f64),
39    Fitted(f64),
40    Within { start: f64, lower: f64, upper: f64 },
41}
42
43impl Value {
44    fn parameter(
45        self,
46        name: impl Into<Cow<'static, str>>,
47        range: RangeInclusive<f64>,
48        allowed: &str,
49    ) -> Result<FitParameter, PipelineError> {
50        let name = name.into();
51        let (value, lower, upper) = match self {
52            Self::Known(v) | Self::Fitted(v) => (v, *range.start(), *range.end()),
53            Self::Within {
54                start,
55                lower,
56                upper,
57            } => (start, lower, upper),
58        };
59        if !(value.is_finite()
60            && range.contains(&lower)
61            && lower < upper
62            && range.contains(&upper)
63            && (lower..=upper).contains(&value))
64        {
65            return Err(PipelineError::InvalidParameter(match self {
66                Self::Within { .. } => format!(
67                    "{name} must be {allowed}, with bounds lower < upper and a finite start \
68                     between them; got {self:?}"
69                ),
70                _ => format!("{name} must be finite and {allowed}; got {self:?}"),
71            }));
72        }
73        Ok(FitParameter {
74            name,
75            value,
76            lower,
77            upper,
78            fixed: matches!(self, Self::Known(_)),
79        })
80    }
81}
82
83/// An open-beam run and a sample run recorded in the same time bins.
84#[derive(Debug, Clone)]
85pub struct Measurement {
86    /// Time-bin edges in µs.
87    pub time_edges_us: Vec<f64>,
88    /// Raw counts of the open-beam run, one per bin.
89    pub open_counts: Vec<f64>,
90    /// Raw counts of the sample run, one per bin.
91    pub sample_counts: Vec<f64>,
92    /// The fraction of the open-beam run's neutrons arriving in each bin that
93    /// the detector records, in (0, 1]; `None` records every one.
94    pub open_live: Option<Vec<f64>>,
95    /// The fraction of the sample run's neutrons arriving in each bin that the
96    /// detector records, in (0, 1]; `None` records every one.
97    pub sample_live: Option<Vec<f64>>,
98    /// `c_q`, the sample run's proton charge over the open-beam run's.
99    pub charge_ratio: f64,
100    /// `a`, the normalization of the sample run, positive.
101    pub normalization: Value,
102    /// `b0` (dimensionless), `b1` in √eV and `b2` in 1/√eV of the background
103    /// `b(E) = b0 + b1/√E + b2·√E`, each any real number.
104    pub background: [Value; 3],
105    /// Each isotope in the sample with its areal density in atoms/barn, known
106    /// or fitted, at least 0.
107    pub isotopes: Vec<(ResonanceData, Value)>,
108    /// The sample's temperature in K, within 1–5000 K.
109    pub temperature_k: Value,
110}
111
112/// The fitted densities, temperature, normalization and background.
113#[derive(Debug, Clone)]
114pub struct CountsFit {
115    /// Areal density of each isotope in atoms/barn, in the order given: the
116    /// known one, or the fitted one.
117    pub densities: Vec<f64>,
118    /// The sample's temperature in K: the known one, or the fitted one.
119    pub temperature_k: f64,
120    /// The normalization `a`: the known one, or the fitted one.
121    pub normalization: f64,
122    /// `b0` (dimensionless), `b1` in √eV and `b2` in 1/√eV, each the known or
123    /// the fitted one.
124    pub background: [f64; 3],
125    /// Covariance of the fitted quantities among the densities, in the order
126    /// given, the temperature, the normalization, `b0`, `b1` and `b2`, in
127    /// that order: the inverse of the expected information at the fit,
128    /// scaled by `overdispersion`, or at the Poisson scale when that is
129    /// `None`.  The row and column of a quantity on one of its bounds, or
130    /// that the counts do not determine, are NaN, and the other entries are
131    /// conditional on every quantity that ended on a bound being held there;
132    /// every entry is NaN when a fitted temperature ends at 1 K or 5000 K.
133    /// `None` when the fit did not converge.
134    ///
135    /// The error bars take the [`Calibration`] passed to [`fit_counts`] as
136    /// exact.  They are not reliable where the counts barely determine a
137    /// fitted temperature or barely separate it from a density, as at few
138    /// counts or for a thin sample at modest counts.
139    pub covariance: Option<FlatMatrix>,
140    /// Whether each fitted quantity, in the covariance's order, ended on one
141    /// of its bounds.
142    pub on_bound: Vec<bool>,
143    /// The beam per µs of flight time, fitted to both runs, with the
144    /// intervals the open-beam fit chose.
145    pub beam: BeamSpline,
146    /// Whether the open-beam fit chose its richest beam; see
147    /// [`OpenBeamFit::at_limit`](crate::open_beam::OpenBeamFit::at_limit).
148    pub beam_at_limit: bool,
149    /// Half the Poisson deviance of both runs at the fit.
150    pub deviance: f64,
151    /// Whether the fitter converged.
152    pub converged: bool,
153    /// Variance of the counts of both runs over their Poisson variance, at
154    /// least 1, measured on the bins predicted at least one count.  `None`
155    /// when the fit did not converge or those bins do not outnumber the
156    /// fitted parameters.
157    pub overdispersion: Option<f64>,
158    /// Step, in µs, of the fit's grid.
159    pub step_us: f64,
160    /// Number of points of that grid.
161    pub points: usize,
162    /// How many times the grid of
163    /// [`FlightTimeGrid::new`](nereids_physics::flight_time_grid::FlightTimeGrid::new)
164    /// was halved to reach it.
165    pub halvings: usize,
166}
167
168/// Fit the areal densities, temperature, normalization and background terms
169/// of `measurement` that are not known to the raw counts of both runs.  On a
170/// uniform grid of flight times `u_i`, energies `E_i` and step `w`, the counts
171/// in bin `k` are
172///
173/// ```text
174/// O_k  = ℓ^O_k · w Σ_i φ_i P_ki
175/// S_k  = ℓ^S_k · c_q · a · w Σ_i φ_i [T_i + b(E_i)] P_ki
176/// T_i  = exp(−Σ_m n_m σ_m(E_i))
177/// b(E) = b0 + b1/√E + b2·√E
178/// ```
179///
180/// with `φ_i` the beam per µs, `P_ki` the chance of a neutron at `u_i`
181/// arriving in bin `k`, `n_m` and `σ_m` each isotope's density and
182/// Doppler-broadened total cross section, `c_q` the charge ratio, `a` the
183/// normalization and `ℓ^O_k`, `ℓ^S_k` each run's live fraction.  The
184/// background is beam neutrons that reach the detector another way, so it
185/// passes through the pulse and scales with the normalization; SAMMY's
186/// `BackA`, `BackB`, `BackC` (`cro/mnrm1.f90`) are `a·b0`, `a·b1`, `a·b2`, to
187/// within the background's change over the pulse's delay.  SAMMY's
188/// `BackD·exp(−BackF/√E)` term, and counts that bypass the pulse, such as
189/// gammas, are not modelled.  The beam `φ` has the intervals
190/// [`fit_open_beam`] chooses and is fitted with the rest to both runs,
191/// starting from the open-beam fit.
192///
193/// The grid's first step is at most half the narrowest Doppler full width at
194/// half maximum, in flight time, of any resonance inside its energy span, at
195/// the starting temperature; it is then halved until the counts of both runs
196/// meet [`BOUND`](crate::open_beam::BOUND).  When the coarser grid of that
197/// accepted pair is wider than the rule at the fitted temperature, the fit is
198/// repeated from its answer on a first grid built at the fitted temperature.
199/// The step is uniform, so a wide window whose span holds a narrow resonance
200/// at high energy, or a low fitted temperature, can exceed the grid's point
201/// cap; a temperature the counts barely determine can run to 1 K and refuse
202/// the fit that way.
203///
204/// The fitter finds a local minimum.  A thin sample hotter than about
205/// 1,500 K fitted from room temperature can end in a false one, reported
206/// converged with an overdispersion far above 1.  Where a black resonance
207/// empties bins and the background is near zero, a fitted background with no
208/// lower bound can drive a black bin's prediction to zero, and the fit ends
209/// unconverged; with `b1` and `b2` known, `b0` bounded below by 0 ends on
210/// that bound, converged.
211///
212/// The overdispersion scales the covariance; it assumes both runs share it
213/// and their bins are independent.
214///
215/// # Errors
216/// [`PipelineError::ShapeMismatch`] unless each run has one count, and one
217/// live fraction when given, per bin;
218/// [`PipelineError::InvalidParameter`] if a count is not a whole non-negative
219/// number, a run has no counts, a live fraction is not in (0, 1], the charge
220/// ratio is not finite and positive, a known or starting value is not finite
221/// and in its quantity's range, bounds are not `lower < upper` in that range
222/// with the start between them, there are no isotopes, an isotope is listed
223/// twice, an isotope's resonance data are not finite, or the energies its
224/// broadened cross section reads, at the known temperature or at the upper
225/// bound of a fitted one, down to zero for a window within the thermal
226/// spread of zero energy, are not inside a single one of its evaluated
227/// (SLBW, MLBW or Reich–Moore) resolved ranges;
228/// [`PipelineError::UnmodelledCounts`] if at the fit, converged or not, a bin
229/// is predicted negative or non-finite counts, or holds counts predicted below
230/// [`NEGLIGIBLE_PREDICTION`]: starting or known values the fitter cannot
231/// leave;
232/// everything [`fit_open_beam`] refuses; [`PipelineError::FlightTimeGrid`]
233/// for the grid's refusals, including more points than it allows;
234/// [`PipelineError::Fitting`] if the fitter fails, or the cross sections
235/// fail at the start; a failure at a trial temperature is a rejected step.
236pub fn fit_counts(
237    measurement: &Measurement,
238    calibration: &Calibration,
239) -> Result<CountsFit, PipelineError> {
240    let Measurement {
241        time_edges_us,
242        open_counts,
243        sample_counts,
244        open_live,
245        sample_live,
246        charge_ratio,
247        normalization,
248        background,
249        isotopes,
250        temperature_k,
251    } = measurement;
252    let invalid = |message: String| Err(PipelineError::InvalidParameter(message));
253    if !(charge_ratio.is_finite() && *charge_ratio > 0.0) {
254        return invalid(format!(
255            "the charge ratio must be finite and positive, got {charge_ratio}"
256        ));
257    }
258    let normalization = normalization.parameter(
259        "normalization",
260        f64::MIN_POSITIVE..=f64::INFINITY,
261        "positive",
262    )?;
263    let background = ["b0", "b1", "b2"]
264        .into_iter()
265        .zip(background)
266        .map(|(name, term)| term.parameter(name, f64::NEG_INFINITY..=f64::INFINITY, "of any sign"))
267        .collect::<Result<Vec<FitParameter>, PipelineError>>()?;
268    if isotopes.is_empty() {
269        return invalid("the sample has no isotopes".into());
270    }
271    let (t_low, t_high) = TEMPERATURE_BOUNDS_K;
272    let temperature = temperature_k.parameter(
273        "temperature",
274        t_low..=t_high,
275        &format!("within {t_low}–{t_high} K"),
276    )?;
277    let reach_k = if temperature.fixed {
278        temperature.value
279    } else {
280        temperature.upper
281    };
282    let mut dopplers = Vec::with_capacity(isotopes.len());
283    let mut densities = Vec::with_capacity(isotopes.len());
284    for (i, (isotope, density)) in isotopes.iter().enumerate() {
285        if isotopes[..i]
286            .iter()
287            .any(|(other, _)| other.za == isotope.za)
288        {
289            return invalid(format!(
290                "{} is listed twice; its densities cannot be told apart",
291                isotope.isotope
292            ));
293        }
294        densities.push(density.parameter(
295            format!("density of {}", isotope.isotope),
296            0.0..=f64::INFINITY,
297            "0 or more",
298        )?);
299        if !finite(isotope) {
300            return invalid(format!(
301                "the resonance data of {} are not finite",
302                isotope.isotope
303            ));
304        }
305        dopplers.push(
306            DopplerParams::new(reach_k, isotope.awr)
307                .map_err(|e| PipelineError::InvalidParameter(e.to_string()))?,
308        );
309    }
310    let grid = FlightTimeGrid::new(time_edges_us, calibration.t0_us, &calibration.pulse)?;
311    let bins = time_edges_us.len() - 1;
312    validate_counts("open-beam", open_counts, bins)?;
313    validate_counts("sample", sample_counts, bins)?;
314    let open_live = validate_live("open-beam", open_live.as_deref(), bins)?;
315    let sample_live = validate_live("sample", sample_live.as_deref(), bins)?;
316
317    let energies = grid.energies_ev();
318    let span_ev = (energies[energies.len() - 1], energies[0]);
319    let mut resonances_in_span = Vec::with_capacity(isotopes.len());
320    for ((isotope, _), doppler) in isotopes.iter().zip(&dopplers) {
321        let read = (
322            (span_ev.0.sqrt() - SUPPORT_X * doppler.u())
323                .max(0.0)
324                .powi(2),
325            (span_ev.1.sqrt() + SUPPORT_X * doppler.u()).powi(2),
326        );
327        if !isotope.ranges.iter().any(|range| {
328            range.is_evaluable() && range.energy_low <= read.0 && read.1 <= range.energy_high
329        }) {
330            return invalid(format!(
331                "the Doppler-broadened cross section of {} reads {:.6e}–{:.6e} eV, which no \
332                 single one of its evaluated (SLBW, MLBW or Reich–Moore) resolved ranges holds",
333                isotope.isotope, read.0, read.1
334            ));
335        }
336        resonances_in_span.push(
337            resonance_center_energies(&[isotope])
338                .into_iter()
339                .filter(|e| (span_ev.0..=span_ev.1).contains(e))
340                .collect::<Vec<f64>>(),
341        );
342    }
343    let clock = TOF_FACTOR * calibration.pulse.flight_path_m();
344    let half_maximum = 2.0 * std::f64::consts::LN_2.sqrt();
345    let narrowest_us = |temperature_k: f64| -> Result<f64, PipelineError> {
346        let mut narrowest = f64::INFINITY;
347        for ((isotope, _), energies) in isotopes.iter().zip(&resonances_in_span) {
348            let doppler = DopplerParams::new(temperature_k, isotope.awr)
349                .map_err(|e| PipelineError::InvalidParameter(e.to_string()))?;
350            for &energy in energies {
351                let width_ev = half_maximum * doppler.doppler_width(energy);
352                narrowest = narrowest.min(clock / energy.sqrt() * width_ev / (2.0 * energy));
353            }
354        }
355        Ok(narrowest)
356    };
357    let first_grid = |temperature_k: f64| -> Result<(Arc<FlightTimeGrid>, usize), PipelineError> {
358        let rule_us = 0.5 * narrowest_us(temperature_k)?;
359        let mut first = grid.clone();
360        let mut halvings = 0;
361        while first.step_us() > rule_us {
362            first = first.halved()?;
363            halvings += 1;
364        }
365        Ok((Arc::new(first), halvings))
366    };
367
368    let open = fit_open_beam(time_edges_us, open_counts, calibration, Some(&open_live))?;
369    let layout = Layout::new(open.beam.coefficients().len(), isotopes.len());
370    let mut parameters = ParameterSet::new(
371        open.beam
372            .coefficients()
373            .iter()
374            .enumerate()
375            .map(|(i, &c)| FitParameter::unbounded(format!("beam {i}"), c))
376            .chain(densities)
377            .chain([temperature, normalization])
378            .chain(background)
379            .collect(),
380    );
381    let resonances: Arc<[ResonanceData]> = isotopes.iter().map(|(data, _)| data.clone()).collect();
382    let observed: Vec<f64> = open_counts.iter().chain(sample_counts).copied().collect();
383    let live: Vec<f64> = open_live.into_iter().chain(sample_live).collect();
384    let mut rule_k = parameters.params[layout.temperature].value;
385    let (fit, rule_halvings) = loop {
386        let (first, rule_halvings) = first_grid(rule_k)?;
387        let fit = fit_on_halved_grids(&first, &mut parameters, &observed, |grid| {
388            Ok(Recorded {
389                model: TwoRunModel::new(grid, &open.beam, &resonances, *charge_ratio),
390                live: &live,
391            })
392        })?;
393        let fitted_k = fit.result.params[layout.temperature];
394        if !fit.converged || 2.0 * fit.step_us <= 0.5 * narrowest_us(fitted_k)? {
395            break (fit, rule_halvings);
396        }
397        rule_k = fitted_k;
398    };
399
400    if let Some((k, (&counts, &predicted))) =
401        observed
402            .iter()
403            .zip(&fit.predicted)
404            .enumerate()
405            .find(|(_, (y, mu))| {
406                !mu.is_finite() || **mu < 0.0 || (**y > 0.0 && **mu < NEGLIGIBLE_PREDICTION)
407            })
408    {
409        let run = if k < bins { "open-beam" } else { "sample" };
410        return Err(PipelineError::UnmodelledCounts {
411            run,
412            bin: k % bins,
413            counts,
414            predicted,
415        });
416    }
417
418    let overdispersion = overdispersion(&observed, &fit);
419    let free = parameters.free_indices();
420    let on_edge = free.contains(&layout.temperature)
421        && [t_low, t_high].contains(&fit.result.params[layout.temperature]);
422    let scale = if on_edge {
423        f64::NAN
424    } else {
425        overdispersion.unwrap_or(1.0)
426    };
427    let sample_quantities: Vec<usize> = (0..free.len())
428        .filter(|&p| free[p] >= layout.densities)
429        .collect();
430    let covariance = fit
431        .result
432        .covariance
433        .as_ref()
434        .filter(|_| fit.converged)
435        .map(|full| {
436            let size = sample_quantities.len();
437            let mut block = FlatMatrix::zeros(size, size);
438            for (a, &p) in sample_quantities.iter().enumerate() {
439                for (b, &q) in sample_quantities.iter().enumerate() {
440                    *block.get_mut(a, b) = scale * full.get(p, q);
441                }
442            }
443            block
444        });
445    let params = &fit.result.params;
446    Ok(CountsFit {
447        densities: params[layout.densities..layout.temperature].to_vec(),
448        temperature_k: params[layout.temperature],
449        normalization: params[layout.normalization],
450        background: [0, 1, 2].map(|i| params[layout.background + i]),
451        covariance,
452        on_bound: sample_quantities
453            .iter()
454            .map(|&p| fit.result.on_bound[p])
455            .collect(),
456        beam: open.beam.with_coefficients(&params[..layout.densities]),
457        beam_at_limit: open.at_limit,
458        deviance: fit.result.deviance,
459        converged: fit.converged,
460        overdispersion,
461        step_us: fit.step_us,
462        points: fit.points,
463        halvings: rule_halvings + fit.halvings,
464    })
465}
466
467fn finite(isotope: &ResonanceData) -> bool {
468    isotope.awr.is_finite()
469        && isotope.ranges.iter().all(|range| {
470            let radii = range
471                .ap_table
472                .iter()
473                .flat_map(|table| table.points.iter().flat_map(|&(e, r)| [e, r]));
474            let external = range.r_external.iter().flat_map(|r| {
475                [
476                    r.j, r.e_low, r.e_up, r.r_con, r.r_lin, r.s_con, r.s_lin, r.r_quad,
477                ]
478            });
479            let groups = range.l_groups.iter().flat_map(|group| {
480                [group.awr, group.apl, group.qx].into_iter().chain(
481                    group
482                        .resonances
483                        .iter()
484                        .flat_map(|r| [r.energy, r.j, r.gn, r.gg, r.gfa, r.gfb]),
485                )
486            });
487            [
488                range.energy_low,
489                range.energy_high,
490                range.target_spin,
491                range.scattering_radius,
492            ]
493            .into_iter()
494            .chain(radii)
495            .chain(external)
496            .chain(groups)
497            .all(f64::is_finite)
498        })
499}
500
501#[derive(Clone, Copy)]
502struct Layout {
503    densities: usize,
504    temperature: usize,
505    normalization: usize,
506    background: usize,
507}
508
509impl Layout {
510    fn new(beam_coefficients: usize, isotopes: usize) -> Self {
511        let temperature = beam_coefficients + isotopes;
512        Self {
513            densities: beam_coefficients,
514            temperature,
515            normalization: temperature + 1,
516            background: temperature + 2,
517        }
518    }
519}
520
521struct TwoRunModel {
522    beam: OpenBeamModel,
523    isotopes: Arc<[ResonanceData]>,
524    energies: Vec<f64>,
525    shapes: [Vec<f64>; 3],
526    charge_ratio: f64,
527    layout: Layout,
528    cross_sections: RefCell<Option<CrossSections>>,
529}
530
531struct Beams {
532    open: Vec<f64>,
533    normalized: Vec<f64>,
534    transmitted: Vec<f64>,
535    sample: Vec<f64>,
536}
537
538struct CrossSections {
539    temperature_k: f64,
540    values: Vec<Vec<f64>>,
541    slopes: Vec<Vec<f64>>,
542}
543
544impl TwoRunModel {
545    fn new(
546        grid: &Arc<FlightTimeGrid>,
547        beam: &BeamSpline,
548        isotopes: &Arc<[ResonanceData]>,
549        charge_ratio: f64,
550    ) -> Self {
551        let mut energies = grid.energies_ev();
552        let shapes = [
553            vec![1.0; energies.len()],
554            energies.iter().map(|e| 1.0 / e.sqrt()).collect(),
555            energies.iter().map(|e| e.sqrt()).collect(),
556        ];
557        energies.reverse();
558        Self {
559            beam: OpenBeamModel::new(grid, beam),
560            isotopes: Arc::clone(isotopes),
561            energies,
562            shapes,
563            charge_ratio,
564            layout: Layout::new(beam.coefficients().len(), isotopes.len()),
565            cross_sections: RefCell::new(None),
566        }
567    }
568
569    fn at(&self, temperature_k: f64) -> Result<std::cell::Ref<'_, CrossSections>, FittingError> {
570        let current = self
571            .cross_sections
572            .borrow()
573            .as_ref()
574            .is_some_and(|c| c.temperature_k.to_bits() == temperature_k.to_bits());
575        if !current {
576            let energies = &self.energies;
577            let (values, slopes) = self
578                .isotopes
579                .par_iter()
580                .map(|isotope| {
581                    let (mut sigma, mut slope) =
582                        broaden_with_derivative(energies, isotope, temperature_k)
583                            .map_err(|e| FittingError::EvaluationFailed(e.to_string()))?;
584                    sigma.reverse();
585                    slope.reverse();
586                    Ok((sigma, slope))
587                })
588                .collect::<Result<Vec<_>, FittingError>>()?
589                .into_iter()
590                .unzip();
591            *self.cross_sections.borrow_mut() = Some(CrossSections {
592                temperature_k,
593                values,
594                slopes,
595            });
596        }
597        Ok(std::cell::Ref::map(self.cross_sections.borrow(), |c| {
598            c.as_ref().expect("computed above")
599        }))
600    }
601
602    fn beams(&self, params: &[f64]) -> Result<Beams, FittingError> {
603        let layout = self.layout;
604        let densities = &params[layout.densities..layout.temperature];
605        let sigma = self.at(params[layout.temperature])?;
606        let open = self.beam.beam(&params[..layout.densities]);
607        let normalized: Vec<f64> = open
608            .iter()
609            .map(|phi| self.charge_ratio * params[layout.normalization] * phi)
610            .collect();
611        let (transmitted, sample) = normalized
612            .iter()
613            .enumerate()
614            .map(|(j, beam)| {
615                let depth: f64 = densities
616                    .iter()
617                    .zip(&sigma.values)
618                    .map(|(n, sigma)| n * sigma[j])
619                    .sum();
620                let background: f64 = params[layout.background..]
621                    .iter()
622                    .zip(&self.shapes)
623                    .map(|(b, g)| b * g[j])
624                    .sum();
625                (beam * (-depth).exp(), beam * ((-depth).exp() + background))
626            })
627            .unzip();
628        Ok(Beams {
629            open,
630            normalized,
631            transmitted,
632            sample,
633        })
634    }
635}
636
637impl FitModel for TwoRunModel {
638    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
639        let Beams { open, sample, .. } = self.beams(params)?;
640        let mut counts = self.beam.counts(&open)?;
641        counts.extend(self.beam.counts(&sample)?);
642        Ok(counts)
643    }
644
645    fn analytical_jacobian(
646        &self,
647        params: &[f64],
648        free_param_indices: &[usize],
649        y_current: &[f64],
650    ) -> Option<FlatMatrix> {
651        let Beams {
652            open,
653            normalized,
654            transmitted,
655            sample,
656        } = self.beams(params).ok()?;
657        let layout = self.layout;
658        let sigma = self.at(params[layout.temperature]).ok()?;
659        let mut jacobian = FlatMatrix::zeros(y_current.len(), free_param_indices.len());
660        for (col, &index) in free_param_indices.iter().enumerate() {
661            let (open_slope, (base, sample_slope)) = if index < layout.densities {
662                let slope = self.beam.log_slope(index);
663                (slope.clone(), (&sample, slope))
664            } else if index < layout.temperature {
665                let values = &sigma.values[index - layout.densities];
666                let slope = values.iter().map(|s| -s).collect();
667                (vec![0.0; open.len()], (&transmitted, slope))
668            } else if index == layout.temperature {
669                let broadening: Vec<f64> = (0..open.len())
670                    .map(|j| {
671                        -params[layout.densities..layout.temperature]
672                            .iter()
673                            .zip(&sigma.slopes)
674                            .map(|(n, slope)| n * slope[j])
675                            .sum::<f64>()
676                    })
677                    .collect();
678                (vec![0.0; open.len()], (&transmitted, broadening))
679            } else if index == layout.normalization {
680                let slope = vec![1.0 / params[index]; open.len()];
681                (vec![0.0; open.len()], (&sample, slope))
682            } else {
683                let shape = self.shapes[index - layout.background].clone();
684                (vec![0.0; open.len()], (&normalized, shape))
685            };
686            let times = |beam: &[f64], slope: &[f64]| -> Vec<f64> {
687                beam.iter().zip(slope).map(|(b, s)| b * s).collect()
688            };
689            let open_column = self.beam.counts(&times(&open, &open_slope)).ok()?;
690            let sample_column = self.beam.counts(&times(base, &sample_slope)).ok()?;
691            for (row, value) in open_column.into_iter().chain(sample_column).enumerate() {
692                *jacobian.get_mut(row, col) = value;
693            }
694        }
695        Some(jacobian)
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use nereids_endf::resonance::test_support::synthetic_isotope;
702
703    use super::*;
704    use crate::open_beam::tests::{ALPHA, BETA, EDGES_US, FLIGHT_PATH_M, R, T0_US, grid};
705
706    #[test]
707    fn the_jacobian_is_the_slope_of_both_runs_counts() {
708        let grid = grid(None);
709        let (_, u_hi) = grid.range_us();
710        let beam = BeamSpline::constant(347.0, u_hi, 1.0e4).refined().refined();
711        let isotopes: Arc<[ResonanceData]> = Arc::new([
712            synthetic_isotope(72, 180, 20.0, 0.01, 0.06),
713            synthetic_isotope(74, 182, 20.3, 0.01, 0.06),
714        ]);
715        let params: Vec<f64> = (0..beam.coefficients().len())
716            .map(|i| 9.0 + 0.3 * (i as f64).sin())
717            .chain([3.0e-4, 5.0e-4, 300.0, 0.93, 0.05, 0.5, -0.01])
718            .collect();
719        let model = TwoRunModel::new(&grid, &beam, &isotopes, 1.2);
720        let live: Vec<f64> = (0..model.evaluate(&params).expect("counts").len())
721            .map(|k| 0.9 + 0.1 * (0.3 * k as f64).sin())
722            .collect();
723        let model = Recorded { model, live: &live };
724        let counts = model.evaluate(&params).expect("counts");
725        let mut colder = params.clone();
726        colder[model.model.layout.temperature] = 250.0;
727        model.evaluate(&colder).expect("counts");
728        let indices: Vec<usize> = (0..params.len()).collect();
729        let jacobian = model
730            .analytical_jacobian(&params, &indices, &counts)
731            .expect("jacobian");
732        for index in indices {
733            let h = 1e-4 * params[index].abs();
734            let shifted = |d: f64| {
735                let mut p = params.clone();
736                p[index] += d;
737                model.evaluate(&p).expect("counts")
738            };
739            let (up, down) = (shifted(h), shifted(-h));
740            let slopes: Vec<f64> = up
741                .iter()
742                .zip(&down)
743                .map(|(u, d)| (u - d) / (2.0 * h))
744                .collect();
745            let column = slopes.iter().fold(0.0_f64, |m, s| m.max(s.abs()));
746            for (row, &slope) in slopes.iter().enumerate() {
747                let analytic = jacobian.get(row, index);
748                assert!(
749                    (analytic - slope).abs() <= 1e-6 * column,
750                    "{index} {row}: {analytic} vs {slope}"
751                );
752            }
753        }
754    }
755
756    #[test]
757    fn the_background_lags_sammy_s_by_the_pulse_s_mean_delay() {
758        let grid = grid(None);
759        let (_, u_hi) = grid.range_us();
760        let first_edge_us = f64::from(*EDGES_US.start());
761        let beam = BeamSpline::constant(first_edge_us - T0_US, u_hi, 1.0e4);
762        let isotopes: Arc<[ResonanceData]> = Arc::new([
763            synthetic_isotope(72, 180, 20.0, 0.01, 0.06),
764            synthetic_isotope(74, 182, 20.3, 0.01, 0.06),
765        ]);
766        let (charge_ratio, normalization) = (1.2, 0.93);
767        let model = TwoRunModel::new(&grid, &beam, &isotopes, charge_ratio);
768        let counts = |background: [f64; 3]| {
769            let params: Vec<f64> = beam
770                .coefficients()
771                .iter()
772                .copied()
773                .chain([3.0e-4, 5.0e-4, 300.0, normalization])
774                .chain(background)
775                .collect();
776            model.evaluate(&params).expect("counts")
777        };
778        let [b0, b1, b2] = [0.05, 0.5, -0.01];
779        let (with, without) = (counts([b0, b1, b2]), counts([0.0; 3]));
780        let bins = with.len() / 2;
781        let clock = TOF_FACTOR * FLIGHT_PATH_M;
782        for k in 0..bins {
783            let u = first_edge_us + 0.5 + k as f64 - T0_US;
784            let root_e = clock / u;
785            let b = b0 + b1 / root_e + b2 * root_e;
786            let slope = b1 / clock - b2 * clock / (u * u);
787            let curvature = 2.0 * b2 * clock / u.powi(3);
788            let energy = root_e * root_e;
789            let (alpha, beta, r) = (ALPHA.eval(energy), BETA.eval(energy), R.eval(energy));
790            let mean = 3.0 / alpha + r / beta;
791            let square = mean * mean + 3.0 / (alpha * alpha) + r * (2.0 - r) / (beta * beta);
792            let lagged = (with[bins + k] - without[bins + k]) / (charge_ratio * normalization);
793            let residual = lagged / with[k] - b + slope * mean;
794            let tolerance = 0.5 * slope.abs() + 0.5 * curvature.abs() * square;
795            assert!(
796                residual.abs() <= tolerance,
797                "{k}: {residual} vs {tolerance}"
798            );
799        }
800    }
801}