Skip to main content

nereids_pipeline/
open_beam.rs

1//! The beam, in neutrons per µs of flight time, fitted to the open-beam counts
2//! it produces through the instrument pulse.
3
4use std::ops::RangeInclusive;
5use std::sync::Arc;
6
7use nereids_fitting::error::FittingError;
8use nereids_fitting::lm::{FitModel, FlatMatrix};
9use nereids_fitting::parameters::{FitParameter, ParameterSet};
10use nereids_fitting::poisson::{PoissonConfig, PoissonResult, Prior, poisson_fit};
11use nereids_physics::flight_time_grid::{FlightTimeGrid, FlightTimeGridError};
12use nereids_physics::ikeda_carpenter::{
13    DETECTOR_TAIL_DECAY_US, DetectorPulse, DetectorTail, EnergyLaw, IkedaCarpenterParams,
14};
15
16use crate::beam::BeamSpline;
17use crate::counts_fit::Value;
18use crate::error::PipelineError;
19use crate::pulse_calibration::PulsePrior;
20
21/// Largest `Σ_k (μ_fine − μ_coarse)² / μ_fine`, over bins predicted non-empty,
22/// by which halving the grid may change the predicted counts for the finer
23/// grid to be accepted.
24pub const BOUND: f64 = 0.01;
25
26pub(crate) const PULSE_NUMBERS: [&str; 9] =
27    ["α₀", "α₁", "β₀", "β₁", "R", "h²", "R_high", "f", "τ_D"];
28
29const NO_TAIL: [Value; 2] = [Value::Known(0.0), Value::Known(1.0)];
30
31const RATE: (RangeInclusive<f64>, &str) = (0.0..=f64::INFINITY, "0 or more");
32
33const FRACTION: (RangeInclusive<f64>, &str) = (0.0..=1.0, "within 0–1");
34
35const CONSTANT: Value = Value::Known(0.0);
36
37/// A neutron of flight time `u` over the flight path `flight_path_m` (m)
38/// arrives at `t0_us + u` (µs) plus a delay drawn from `pulse`.
39/// [`fit_counts`](crate::counts_fit::fit_counts) fits `t0_us`,
40/// `flight_path_m` and the pulse's numbers unless they are known;
41/// [`fit_open_beam`] uses their starting values.
42#[derive(Debug, Clone)]
43pub struct Calibration {
44    pub t0_us: Value,
45    pub flight_path_m: Value,
46    pub pulse: Pulse,
47}
48
49impl Calibration {
50    pub(crate) fn instrument(&self) -> Result<Vec<FitParameter>, PipelineError> {
51        let pulse = &self.pulse;
52        let ranges = [
53            RATE,
54            RATE,
55            RATE,
56            RATE,
57            FRACTION,
58            RATE,
59            FRACTION,
60            FRACTION,
61            (DETECTOR_TAIL_DECAY_US, "from 1e-9 to 1e9"),
62        ];
63        let mut parameters = vec![
64            self.t0_us
65                .parameter("t0", f64::NEG_INFINITY..=f64::INFINITY, "any real number")?,
66            self.flight_path_m.parameter(
67                "flight path",
68                f64::MIN_POSITIVE..=f64::INFINITY,
69                "positive",
70            )?,
71        ];
72        for ((name, value), (range, allowed)) in
73            PULSE_NUMBERS.into_iter().zip(pulse.numbers()).zip(ranges)
74        {
75            parameters.push(value.parameter(name, range, allowed)?);
76        }
77        Ok(parameters)
78    }
79}
80
81/// The Ikeda–Carpenter pulse with `α = α₀√E + α₁` and `β = β₀√E + β₁` in
82/// 1/µs, `E` in eV, a storage fraction `R` over `energy_span_ev`, or linear in
83/// `√E` from `R` at its low end to `R_high` at its high end, the proton
84/// pulse's triangle of FWHM `h` in µs, given by `h²`, in which the counts are
85/// smooth down to `h = 0`, and an optional detector tail.  `α` and `β` must be
86/// at least 1e-9 µs⁻¹ across the span, `β` even where the storage fraction is
87/// 0.
88#[derive(Debug, Clone)]
89pub struct Pulse {
90    /// `[α₀, α₁]`, in 1/(µs·√eV) and 1/µs, each 0 or more.
91    pub alpha: [Value; 2],
92    /// `[β₀, β₁]`, in 1/(µs·√eV) and 1/µs, each 0 or more.
93    pub beta: [Value; 2],
94    /// `R`, within 0–1: the storage fraction, at the low end of
95    /// `energy_span_ev` when [`Self::r_high`] is given.
96    pub r: Value,
97    /// `h²` in µs², 0 or more.
98    pub fwhm_squared_us2: Value,
99    /// `R_high`, within 0–1: the storage fraction at the high end of
100    /// `energy_span_ev`, linear in `√E` from `R`.  `None` holds it at `R`.
101    pub r_high: Option<Value>,
102    /// `[f, τ_D]`: the detector records a fraction `f` (0–1) of the counts
103    /// late by an exponential delay of mean `τ_D` µs (from 1e-9 to 1e9),
104    /// and the rest at their arrival; the time background is recorded
105    /// as it arrives.  `None` records every count at its arrival.
106    pub detector_tail: Option<[Value; 2]>,
107    /// `(low, high)`, the energies in eV the laws hold over; a window that
108    /// neutrons from outside them can reach is refused.
109    pub energy_span_ev: (f64, f64),
110    /// Samples across the prompt core that find the pulse's rise, which sets
111    /// the grid's first step; at least 8.
112    pub n_tau: usize,
113    /// `(low, high)`, the energies in eV of the lowest and highest resonance
114    /// between a calibration foil's time edges, those in a window its library
115    /// leaves out included.
116    /// [`fit_counts`](crate::counts_fit::fit_counts) refuses a sample with a
117    /// resonance outside them, of an isotope not known to be absent, between
118    /// the energies of its last and first time edges; a resonance outside
119    /// those energies, whose neutrons reach the window only through the
120    /// pulse's delay, is not checked.  `None` checks nothing.
121    pub line_span_ev: Option<(f64, f64)>,
122    /// A calibration of some of the numbers, from
123    /// [`PulseCalibration::calibration`](crate::pulse_calibration::PulseCalibration::calibration),
124    /// that [`fit_counts`](crate::counts_fit::fit_counts) fits them with;
125    /// [`fit_open_beam`] uses their starting values.
126    pub prior: Option<PulsePrior>,
127}
128
129impl Pulse {
130    /// `[α₀, α₁, β₀, β₁, R, h², R_high, f, τ_D]`, in the order of the fit's
131    /// pulse numbers; `R_high` known 0 and unused without one, and `f` known 0
132    /// without a detector tail.
133    pub(crate) fn numbers(&self) -> [&Value; 9] {
134        let [f, decay] = self.detector_tail.as_ref().unwrap_or(&NO_TAIL);
135        [
136            &self.alpha[0],
137            &self.alpha[1],
138            &self.beta[0],
139            &self.beta[1],
140            &self.r,
141            &self.fwhm_squared_us2,
142            self.r_high.as_ref().unwrap_or(&CONSTANT),
143            f,
144            decay,
145        ]
146    }
147
148    /// The energies in eV the storage fraction changes over, `None` when it
149    /// is constant.
150    pub(crate) fn storage_span(&self) -> Option<(f64, f64)> {
151        self.r_high.map(|_| self.energy_span_ev)
152    }
153
154    pub(crate) fn at(&self, numbers: &[f64]) -> Result<DetectorPulse, PipelineError> {
155        DetectorPulse::new(
156            laws(numbers, self.storage_span()),
157            self.energy_span_ev,
158            self.n_tau,
159        )
160        .map_err(|e| PipelineError::InvalidParameter(e.to_string()))
161    }
162}
163
164/// The laws of pulse numbers `numbers`, the storage fraction linear in `√E`
165/// from `R` to `R_high` over `storage_span`, or `R` without it.
166pub(crate) fn laws(numbers: &[f64], storage_span: Option<(f64, f64)>) -> IkedaCarpenterParams {
167    IkedaCarpenterParams {
168        alpha: EnergyLaw::SqrtE {
169            a0: numbers[0],
170            a1: numbers[1],
171        },
172        beta: EnergyLaw::SqrtE {
173            a0: numbers[2],
174            a1: numbers[3],
175        },
176        r: storage_span.map_or(EnergyLaw::Const(numbers[4]), |span_ev| {
177            EnergyLaw::SqrtEBetween {
178                low: numbers[4],
179                high: numbers[6],
180                span_ev,
181            }
182        }),
183        burst_sigma_us: None,
184        channel_fwhm_us: Some(numbers[5].sqrt()),
185        detector_tail: Some(DetectorTail {
186            fraction: numbers[7],
187            decay_us: numbers[8],
188        }),
189    }
190}
191
192/// The fitted open beam.
193#[derive(Debug, Clone)]
194pub struct OpenBeamFit {
195    /// The beam per µs of flight time over the flight-time grid's range; its
196    /// knots span the flight times of the first and last edges.
197    /// `covariance` shows how well the counts determine it.
198    pub beam: BeamSpline,
199    /// Half the Poisson deviance at the fit.
200    pub deviance: f64,
201    /// Whether the fitter converged.  It certifies the minimisation, not that
202    /// the counts determine every coefficient; `covariance` shows that.
203    pub converged: bool,
204    /// Covariance of the beam's coefficients, scaled by `overdispersion`, or
205    /// at the Poisson scale when that is `None`; rows and columns of a
206    /// coefficient the counts leave undetermined are NaN.  `None` when the
207    /// fit did not converge.
208    pub covariance: Option<FlatMatrix>,
209    /// Variance of the counts over their Poisson variance, at least 1: the
210    /// richest fitted beam's Pearson χ² over the bins predicted at least one
211    /// count, per degree of freedom (each bin's one less its leverage), divided
212    /// by one plus the mean of `(y − μ)/μ` over those bins (D. Fletcher,
213    /// Biometrika 99, 230–237, 2012), for independent bins.  Beam structure
214    /// finer than every candidate is counted in it as noise; such a beam is
215    /// not supported and is fitted smooth, which shows as an overdispersion
216    /// far above the detector's.  `None` when the fit did not converge or
217    /// those bins leave less than one degree of freedom after the
218    /// coefficients' leverage.
219    pub overdispersion: Option<f64>,
220    /// Whether the chosen beam is the richest fitted; the counts may then hold
221    /// structure finer than its intervals, whose misfit `overdispersion`
222    /// includes.
223    pub at_limit: bool,
224    /// Step, in µs, of the returned fit's grid, which meets [`BOUND`] when the
225    /// fit converged.
226    pub step_us: f64,
227    /// Number of points of that grid.
228    pub points: usize,
229    /// How many times the first grid was halved to reach it.
230    pub halvings: usize,
231}
232
233/// Fit the beam to the raw open-beam counts `open_counts` of the time bins
234/// `time_edges_us` (µs), each bin recording the fraction `open_live` of the
235/// counts arriving in it, or every one when that is `None`.  `known` gives,
236/// per bin, the counts that arrive without the beam, before the live fraction,
237/// or none when that is `None`.  `ln φ`, the
238/// logarithm of the beam, is a cubic spline in `ln u` with knots from the
239/// first edge's flight time to the last edge's; outside them the beam is the
240/// spline's continuation, which reaches the bins only through the pulse's
241/// delay or, for a folded pulse, its early tail.  Neutrons faster than the
242/// grid's range, each reaching the bins with less than
243/// [`NEGLIGIBLE_ARRIVAL_PROBABILITY`](nereids_physics::ikeda_carpenter::NEGLIGIBLE_ARRIVAL_PROBABILITY)
244/// chance, are left out.
245///
246/// The number of spline intervals doubles from one while the coefficients
247/// number at most half the bins.  Each candidate is fitted on a grid halved,
248/// and the beam refitted on each finer grid, until the refitted beam's
249/// predicted counts differ from the coarser grid's by at most [`BOUND`].  A
250/// candidate that does not converge, or needs more grid points than allowed,
251/// ends the ladder.  The candidate with the lowest `D / overdispersion + 2k` is
252/// returned, `D` twice its deviance and `k` its coefficients.
253///
254/// Counts too sparse to determine the beam are not supported: the fit may not
255/// converge, may leave coefficients undetermined (NaN in `covariance`), or,
256/// when its beam varies faster than a grid within the point cap resolves, end
257/// the ladder or, for the first candidate, refuse the fit.
258///
259/// Beam structure at or near the window's first edge is not supported: before
260/// the edge, where neutrons reach the bins only through the pulse's delay, the
261/// beam is the spline's continuation, which the counts cannot check.  Its error
262/// biases the fitted beam near that edge and, through the continuation's mean
263/// curvature, elsewhere; structure just before the edge can leave the first
264/// bins unmatched, which shows as `at_limit` with an overdispersion far above
265/// the detector's.  Start the window where the beam is smooth.
266///
267/// # Errors
268/// [`PipelineError::ShapeMismatch`] unless there is one count, and one live
269/// fraction and one known count when given, per bin;
270/// [`PipelineError::InvalidParameter`] if a count is not a whole non-negative
271/// number, every count is zero, a live fraction is not in (0, 1], a known
272/// count is not finite and 0 or more, there are
273/// fewer than 8 bins (one interval's four coefficients and as many bins again
274/// to measure the noise), a known, starting or measured value of the
275/// calibration is not finite and in its quantity's range, a measured one's sd
276/// is not finite and positive, bounds are not `lower < upper` in that range
277/// with the start between them, the pulse's energy span is not `0 < low <
278/// high`, its `n_tau` is below 8, or its starting `α` or `β` is below 1e-9
279/// µs⁻¹ at an end of the span;
280/// [`PipelineError::FlightTimeGrid`] for the grid's refusals, including the
281/// first candidate's halving past the point cap; [`PipelineError::Fitting`] if
282/// the fitter refuses.
283pub fn fit_open_beam(
284    time_edges_us: &[f64],
285    open_counts: &[f64],
286    calibration: &Calibration,
287    open_live: Option<&[f64]>,
288    known: Option<&[f64]>,
289) -> Result<OpenBeamFit, PipelineError> {
290    open_beam_with_intervals(
291        time_edges_us,
292        open_counts,
293        calibration,
294        open_live,
295        known,
296        None,
297    )
298}
299
300pub(crate) fn open_beam_with_intervals(
301    time_edges_us: &[f64],
302    open_counts: &[f64],
303    calibration: &Calibration,
304    open_live: Option<&[f64]>,
305    known: Option<&[f64]>,
306    intervals: Option<usize>,
307) -> Result<OpenBeamFit, PipelineError> {
308    let start: Vec<f64> = calibration
309        .instrument()?
310        .iter()
311        .map(|parameter| parameter.value)
312        .collect();
313    let t0_us = start[0];
314    let grid = Arc::new(FlightTimeGrid::new(
315        time_edges_us,
316        t0_us,
317        start[1],
318        &calibration.pulse.at(&start[2..])?,
319    )?);
320    validate_counts("open-beam", open_counts, time_edges_us.len() - 1)?;
321    let live = validate_live("open-beam", open_live, time_edges_us.len() - 1)?;
322    let known = validate_known(known, time_edges_us.len() - 1)?;
323
324    let coefficients = |intervals: usize| intervals + 3;
325    let admits = |intervals: usize| 2 * coefficients(intervals) <= open_counts.len();
326    if !admits(1) {
327        return Err(PipelineError::InvalidParameter(format!(
328            "the open-beam fit needs at least {} time bins, one interval's {} coefficients \
329             and as many again to measure the noise; got {}",
330            2 * coefficients(1),
331            coefficients(1),
332            open_counts.len()
333        )));
334    }
335
336    let u_first = time_edges_us[0] - t0_us;
337    let u_last = time_edges_us[time_edges_us.len() - 1] - t0_us;
338    let per_unit_beam = grid.predict(&vec![1.0; grid.flight_times_us().len()])?;
339    let recorded: f64 = per_unit_beam.iter().zip(&live).map(|(c, l)| l * c).sum();
340    let per_us = open_counts.iter().sum::<f64>() / recorded;
341    let mut ladder = vec![fit_beam(
342        &grid,
343        &BeamSpline::constant(u_first, u_last, per_us),
344        open_counts,
345        &live,
346        &known,
347    )?];
348    while let Some(start) = ladder
349        .last()
350        .filter(|candidate| candidate.fit.converged && admits(2 * candidate.beam.intervals()))
351        .map(|candidate| candidate.beam.refined())
352    {
353        match fit_beam(&grid, &start, open_counts, &live, &known) {
354            Ok(candidate) if candidate.fit.converged => ladder.push(candidate),
355            Ok(_)
356            | Err(PipelineError::FlightTimeGrid(FlightTimeGridError::TooManyPoints { .. })) => {
357                break;
358            }
359            Err(error) => return Err(error),
360        }
361    }
362
363    let richest = &ladder[ladder.len() - 1].fit;
364    let overdispersion = overdispersion(
365        open_counts,
366        richest,
367        &counted(richest, 0..open_counts.len()),
368        0.0,
369    );
370    let scale = overdispersion.unwrap_or(1.0);
371    let criterion = |candidate: &Candidate| {
372        2.0 * candidate.fit.result.deviance / scale
373            + 2.0 * candidate.beam.coefficients().len() as f64
374    };
375    let (chosen, _) =
376        ladder
377            .iter()
378            .map(criterion)
379            .enumerate()
380            .fold(
381                (0, f64::INFINITY),
382                |best, (i, q)| {
383                    if q < best.1 { (i, q) } else { best }
384                },
385            );
386    let chosen = match intervals {
387        None => chosen,
388        Some(k) => ladder
389            .iter()
390            .position(|candidate| candidate.beam.intervals() == k)
391            .ok_or_else(|| {
392                FittingError::EvaluationFailed(format!(
393                    "the open-beam fit does not converge with {k} intervals"
394                ))
395            })?,
396    };
397    let at_limit = chosen + 1 == ladder.len();
398    let Candidate { beam, fit } = ladder.swap_remove(chosen);
399    Ok(OpenBeamFit {
400        beam,
401        deviance: fit.result.deviance,
402        converged: fit.converged,
403        covariance: fit
404            .result
405            .covariance
406            .filter(|_| fit.converged)
407            .map(|mut covariance| {
408                covariance.data.iter_mut().for_each(|v| *v *= scale);
409                covariance
410            }),
411        overdispersion,
412        at_limit,
413        step_us: fit.step_us,
414        points: fit.points,
415        halvings: fit.halvings,
416    })
417}
418
419pub(crate) fn validate_counts(run: &str, counts: &[f64], bins: usize) -> Result<(), PipelineError> {
420    if counts.len() != bins {
421        return Err(PipelineError::ShapeMismatch(format!(
422            "{} {run} counts for {bins} time bins",
423            counts.len()
424        )));
425    }
426    if let Some((bin, count)) = counts.iter().enumerate().find(|(_, c)| !whole(**c)) {
427        return Err(PipelineError::InvalidParameter(format!(
428            "{run} counts must be whole non-negative numbers, got {count} in bin {bin}"
429        )));
430    }
431    if counts.iter().all(|&c| c == 0.0) {
432        return Err(PipelineError::InvalidParameter(format!(
433            "the {run} run has no counts"
434        )));
435    }
436    Ok(())
437}
438
439pub(crate) fn whole(count: f64) -> bool {
440    count.is_finite() && count >= 0.0 && count.fract() == 0.0
441}
442
443pub(crate) fn validate_live(
444    run: &str,
445    live: Option<&[f64]>,
446    bins: usize,
447) -> Result<Vec<f64>, PipelineError> {
448    let live = live.map_or_else(|| vec![1.0; bins], <[f64]>::to_vec);
449    if live.len() != bins {
450        return Err(PipelineError::ShapeMismatch(format!(
451            "{} {run} live fractions for {bins} time bins",
452            live.len()
453        )));
454    }
455    if let Some((bin, fraction)) = live
456        .iter()
457        .enumerate()
458        .find(|(_, l)| !(**l > 0.0 && **l <= 1.0))
459    {
460        return Err(PipelineError::InvalidParameter(format!(
461            "{run} live fractions must be in (0, 1], got {fraction} in bin {bin}"
462        )));
463    }
464    Ok(live)
465}
466
467fn validate_known(known: Option<&[f64]>, bins: usize) -> Result<Vec<f64>, PipelineError> {
468    let known = known.map_or_else(|| vec![0.0; bins], <[f64]>::to_vec);
469    if known.len() != bins {
470        return Err(PipelineError::ShapeMismatch(format!(
471            "{} known open-beam counts for {bins} time bins",
472            known.len()
473        )));
474    }
475    if let Some((bin, count)) = known
476        .iter()
477        .enumerate()
478        .find(|(_, g)| !(g.is_finite() && **g >= 0.0))
479    {
480        return Err(PipelineError::InvalidParameter(format!(
481            "known open-beam counts must be finite and 0 or more, got {count} in bin {bin}"
482        )));
483    }
484    Ok(known)
485}
486
487pub(crate) const COUNTS_TO_MEASURE_NOISE: f64 = 1.0;
488
489pub(crate) fn counted(fit: &GridFit, bins: std::ops::Range<usize>) -> Vec<usize> {
490    bins.filter(|&k| fit.predicted[k] >= COUNTS_TO_MEASURE_NOISE)
491        .collect()
492}
493
494pub(crate) fn overdispersion(
495    observed: &[f64],
496    fit: &GridFit,
497    counted: &[usize],
498    shared_leverage: f64,
499) -> Option<f64> {
500    let leverage = fit.result.leverage.as_ref().filter(|_| fit.converged)?;
501    let (pearson, skew, freedom) =
502        counted
503            .iter()
504            .fold((0.0, 0.0, 0.0), |(pearson, skew, freedom), &k| {
505                let (y, mu) = (observed[k], fit.predicted[k]);
506                (
507                    pearson + (y - mu).powi(2) / mu,
508                    skew + (y - mu) / mu,
509                    freedom + 1.0 - leverage[k],
510                )
511            });
512    let fletcher = 1.0 + skew / counted.len() as f64;
513    let freedom = freedom - shared_leverage;
514    (freedom >= 1.0)
515        .then(|| (pearson / freedom / fletcher).clamp(1.0, f64::INFINITY))
516        .filter(|phi| phi.is_finite())
517}
518
519struct Candidate {
520    beam: BeamSpline,
521    fit: GridFit,
522}
523
524fn fit_beam(
525    first_grid: &Arc<FlightTimeGrid>,
526    start: &BeamSpline,
527    open_counts: &[f64],
528    live: &[f64],
529    known: &[f64],
530) -> Result<Candidate, PipelineError> {
531    let mut parameters = ParameterSet::new(
532        start
533            .coefficients()
534            .iter()
535            .enumerate()
536            .map(|(i, &c)| FitParameter::unbounded(format!("beam {i}"), c))
537            .collect(),
538    );
539    let dispersion = vec![1.0; open_counts.len()];
540    let fit = fit_on_halved_grids(
541        first_grid,
542        &mut parameters,
543        open_counts,
544        &dispersion,
545        &[],
546        |grid| {
547            Ok(Recorded {
548                model: OpenBeamModel::new(grid, start, known),
549                live,
550            })
551        },
552        |spread, _, _| Ok(spread <= BOUND),
553    )?;
554    Ok(Candidate {
555        beam: start.with_coefficients(&fit.result.params),
556        fit,
557    })
558}
559
560pub(crate) struct GridFit {
561    pub(crate) result: PoissonResult,
562    pub(crate) converged: bool,
563    pub(crate) predicted: Vec<f64>,
564    pub(crate) coarse: Arc<FlightTimeGrid>,
565    pub(crate) fine: Arc<FlightTimeGrid>,
566    pub(crate) step_us: f64,
567    pub(crate) points: usize,
568    pub(crate) halvings: usize,
569}
570
571pub(crate) fn fit_on_halved_grids<M: FitModel>(
572    first_grid: &Arc<FlightTimeGrid>,
573    parameters: &mut ParameterSet,
574    observed: &[f64],
575    dispersion: &[f64],
576    priors: &[Prior],
577    model_on: impl Fn(&Arc<FlightTimeGrid>) -> Result<M, PipelineError>,
578    done: impl Fn(f64, &FlightTimeGrid, &[f64]) -> Result<bool, PipelineError>,
579) -> Result<GridFit, PipelineError> {
580    let dispersed: Vec<f64> = observed
581        .iter()
582        .zip(dispersion)
583        .map(|(y, d)| y / d)
584        .collect();
585    let mut grid = Arc::clone(first_grid);
586    let mut coarse = model_on(&grid)?;
587    let mut halvings = 0;
588    loop {
589        let finer = Arc::new(grid.halved()?);
590        let fine = model_on(&finer)?;
591        let result = poisson_fit(
592            &Dispersed {
593                model: &fine,
594                dispersion,
595            },
596            &dispersed,
597            priors,
598            parameters,
599            &PoissonConfig::default(),
600        )?;
601        let converged = result.converged && result.params.iter().all(|p| p.is_finite());
602        let predicted = fine.evaluate(&result.params)?;
603        let spread: f64 = predicted
604            .iter()
605            .zip(coarse.evaluate(&result.params)?)
606            .filter(|(fine, _)| **fine > 0.0)
607            .map(|(fine, coarse)| (fine - coarse).powi(2) / fine)
608            .sum();
609        let coarse_grid = std::mem::replace(&mut grid, finer);
610        coarse = fine;
611        halvings += 1;
612        if !converged || done(spread, &coarse_grid, &result.params)? {
613            return Ok(GridFit {
614                result,
615                converged,
616                predicted,
617                coarse: coarse_grid,
618                fine: Arc::clone(&grid),
619                step_us: grid.step_us(),
620                points: grid.flight_times_us().len(),
621                halvings,
622            });
623        }
624    }
625}
626
627struct Dispersed<'a, M> {
628    model: &'a M,
629    dispersion: &'a [f64],
630}
631
632impl<M: FitModel> FitModel for Dispersed<'_, M> {
633    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
634        let counts = self.model.evaluate(params)?;
635        Ok(counts
636            .iter()
637            .zip(self.dispersion)
638            .map(|(&c, d)| if c < 0.0 { c } else { c / d })
639            .collect())
640    }
641
642    fn analytical_jacobian(
643        &self,
644        params: &[f64],
645        free_param_indices: &[usize],
646        y_current: &[f64],
647    ) -> Option<FlatMatrix> {
648        let mut jacobian = self
649            .model
650            .analytical_jacobian(params, free_param_indices, y_current)?;
651        for (row, d) in self.dispersion.iter().enumerate() {
652            for col in 0..free_param_indices.len() {
653                *jacobian.get_mut(row, col) /= d;
654            }
655        }
656        Some(jacobian)
657    }
658}
659
660pub(crate) struct Recorded<'a, M> {
661    pub(crate) model: M,
662    pub(crate) live: &'a [f64],
663}
664
665impl<M: FitModel> FitModel for Recorded<'_, M> {
666    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
667        let counts = self.model.evaluate(params)?;
668        Ok(counts.iter().zip(self.live).map(|(c, l)| l * c).collect())
669    }
670
671    fn analytical_jacobian(
672        &self,
673        params: &[f64],
674        free_param_indices: &[usize],
675        y_current: &[f64],
676    ) -> Option<FlatMatrix> {
677        let mut jacobian = self
678            .model
679            .analytical_jacobian(params, free_param_indices, y_current)?;
680        for (row, l) in self.live.iter().enumerate() {
681            for col in 0..free_param_indices.len() {
682                *jacobian.get_mut(row, col) *= l;
683            }
684        }
685        Some(jacobian)
686    }
687}
688
689pub(crate) fn combined(basis: &[[(usize, f64); 5]], coefficients: &[f64]) -> Vec<f64> {
690    basis
691        .iter()
692        .map(|pairs| pairs.iter().map(|&(i, w)| w * coefficients[i]).sum())
693        .collect()
694}
695
696pub(crate) fn weights_of(basis: &[[(usize, f64); 5]], index: usize) -> Vec<f64> {
697    basis
698        .iter()
699        .map(|pairs| {
700            pairs
701                .iter()
702                .filter(|&&(i, _)| i == index)
703                .map(|&(_, w)| w)
704                .sum()
705        })
706        .collect()
707}
708
709pub(crate) struct OpenBeamModel {
710    grid: Arc<FlightTimeGrid>,
711    basis: Vec<[(usize, f64); 5]>,
712    known: Vec<f64>,
713}
714
715impl OpenBeamModel {
716    pub(crate) fn new(grid: &Arc<FlightTimeGrid>, beam: &BeamSpline, known: &[f64]) -> Self {
717        Self {
718            grid: Arc::clone(grid),
719            basis: grid
720                .flight_times_us()
721                .iter()
722                .map(|&u| beam.basis(u))
723                .collect(),
724            known: known.to_vec(),
725        }
726    }
727
728    pub(crate) fn beam(&self, coefficients: &[f64]) -> Vec<f64> {
729        combined(&self.basis, coefficients)
730            .into_iter()
731            .map(f64::exp)
732            .collect()
733    }
734
735    pub(crate) fn log_slope(&self, index: usize) -> Vec<f64> {
736        weights_of(&self.basis, index)
737    }
738
739    pub(crate) fn counts(&self, values: &[f64]) -> Result<Vec<f64>, FittingError> {
740        self.grid
741            .predict(values)
742            .map_err(|e| FittingError::EvaluationFailed(e.to_string()))
743    }
744}
745
746impl FitModel for OpenBeamModel {
747    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
748        let mut counts = self.counts(&self.beam(params))?;
749        for (c, g) in counts.iter_mut().zip(&self.known) {
750            *c += g;
751        }
752        Ok(counts)
753    }
754
755    fn analytical_jacobian(
756        &self,
757        params: &[f64],
758        free_param_indices: &[usize],
759        y_current: &[f64],
760    ) -> Option<FlatMatrix> {
761        let beam = self.beam(params);
762        let mut jacobian = FlatMatrix::zeros(y_current.len(), free_param_indices.len());
763        for (col, &index) in free_param_indices.iter().enumerate() {
764            let values: Vec<f64> = self
765                .log_slope(index)
766                .iter()
767                .zip(&beam)
768                .map(|(slope, phi)| slope * phi)
769                .collect();
770            for (row, value) in self.counts(&values).ok()?.into_iter().enumerate() {
771                *jacobian.get_mut(row, col) = value;
772            }
773        }
774        Some(jacobian)
775    }
776}
777
778#[cfg(test)]
779pub(crate) mod tests {
780    use nereids_physics::ikeda_carpenter::{IkedaCarpenter, SynthesisGrid};
781
782    use super::*;
783
784    pub(crate) const FLIGHT_PATH_M: f64 = 25.0;
785    pub(crate) const T0_US: f64 = 3.0;
786    pub(crate) const EDGES_US: std::ops::RangeInclusive<u32> = 350..=470;
787    pub(crate) const ALPHA: EnergyLaw = EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 };
788    pub(crate) const BETA: EnergyLaw = EnergyLaw::Const(0.25);
789    pub(crate) const R: EnergyLaw = EnergyLaw::Const(0.15);
790
791    pub(crate) fn grid(channel_fwhm_us: Option<f64>) -> Arc<FlightTimeGrid> {
792        let pulse = IkedaCarpenter::new(
793            IkedaCarpenterParams {
794                alpha: ALPHA,
795                beta: BETA,
796                r: R,
797                burst_sigma_us: None,
798                channel_fwhm_us,
799                detector_tail: None,
800            },
801            FLIGHT_PATH_M,
802            &SynthesisGrid {
803                e_min_ev: 1.0,
804                e_max_ev: 200.0,
805                n_energies: 32,
806                n_tau: 256,
807            },
808        )
809        .expect("valid IC model");
810        let edges: Vec<f64> = EDGES_US.map(f64::from).collect();
811        Arc::new(
812            FlightTimeGrid::new(&edges, T0_US, FLIGHT_PATH_M, &pulse.detector_pulse())
813                .expect("grid"),
814        )
815    }
816
817    #[test]
818    fn the_jacobian_is_the_slope_of_the_counts() {
819        for channel_fwhm_us in [None, Some(2.0)] {
820            let grid = grid(channel_fwhm_us);
821            let (_, u_hi) = grid.range_us();
822            for beam in [
823                BeamSpline::constant(347.0, u_hi, 1.0e4),
824                BeamSpline::constant(347.0, u_hi, 1.0e4).refined().refined(),
825            ] {
826                let model = OpenBeamModel::new(&grid, &beam, &[]);
827                let coefficients: Vec<f64> = (0..beam.coefficients().len())
828                    .map(|i| 9.0 + 0.3 * (i as f64).sin())
829                    .collect();
830                let counts = model.evaluate(&coefficients).expect("counts");
831                let indices: Vec<usize> = (0..coefficients.len()).collect();
832                let jacobian = model
833                    .analytical_jacobian(&coefficients, &indices, &counts)
834                    .expect("jacobian");
835                for index in indices {
836                    let h = 1e-4;
837                    let shifted = |d: f64| {
838                        let mut c = coefficients.clone();
839                        c[index] += d;
840                        model.evaluate(&c).expect("counts")
841                    };
842                    let (up, down) = (shifted(h), shifted(-h));
843                    let slopes: Vec<f64> = up
844                        .iter()
845                        .zip(&down)
846                        .map(|(u, d)| (u - d) / (2.0 * h))
847                        .collect();
848                    let column = slopes.iter().fold(0.0_f64, |m, s| m.max(s.abs()));
849                    for (row, &slope) in slopes.iter().enumerate() {
850                        let analytic = jacobian.get(row, index);
851                        assert!(
852                            (analytic - slope).abs() <= 1e-6 * column,
853                            "{channel_fwhm_us:?} {} {index} {row}: {analytic} vs {slope}",
854                            beam.intervals()
855                        );
856                    }
857                }
858            }
859        }
860    }
861}