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::sync::Arc;
5
6use nereids_fitting::error::FittingError;
7use nereids_fitting::lm::{FitModel, FlatMatrix};
8use nereids_fitting::parameters::{FitParameter, ParameterSet};
9use nereids_fitting::poisson::{PoissonConfig, PoissonResult, poisson_fit};
10use nereids_physics::flight_time_grid::{FlightTimeGrid, FlightTimeGridError};
11use nereids_physics::ikeda_carpenter::IkedaCarpenter;
12
13use crate::beam::BeamSpline;
14use crate::error::PipelineError;
15
16/// Largest `Σ_k (μ_fine − μ_coarse)² / μ_fine`, over bins predicted non-empty,
17/// by which halving the grid may change the predicted counts for the finer
18/// grid to be accepted.
19pub const BOUND: f64 = 0.01;
20
21/// A neutron of flight time `u` arrives at `t0_us + u` plus a delay drawn from
22/// `pulse`, whose flight path sets `u` for each energy.
23#[derive(Debug, Clone)]
24pub struct Calibration {
25    pub t0_us: f64,
26    pub pulse: Arc<IkedaCarpenter>,
27}
28
29/// The fitted open beam.
30#[derive(Debug, Clone)]
31pub struct OpenBeamFit {
32    /// The beam per µs of flight time over the flight-time grid's range; its
33    /// knots span the flight times of the first and last edges.
34    /// `covariance` shows how well the counts determine it.
35    pub beam: BeamSpline,
36    /// Half the Poisson deviance at the fit.
37    pub deviance: f64,
38    /// Whether the fitter converged.  It certifies the minimisation, not that
39    /// the counts determine every coefficient; `covariance` shows that.
40    pub converged: bool,
41    /// Covariance of the beam's coefficients, scaled by `overdispersion`, or
42    /// at the Poisson scale when that is `None`; rows and columns of a
43    /// coefficient the counts leave undetermined are NaN.  `None` when the
44    /// fit did not converge.
45    pub covariance: Option<FlatMatrix>,
46    /// Variance of the counts over their Poisson variance, at least 1: the
47    /// richest fitted beam's Pearson χ² per degree of freedom over the bins
48    /// predicted at least one count, for independent bins.  Beam structure
49    /// finer than every candidate is counted in it as noise; such a beam is
50    /// not supported and is fitted smooth, which shows as an overdispersion
51    /// far above the detector's.  `None` when the fit did not converge or
52    /// those bins do not outnumber the coefficients.
53    pub overdispersion: Option<f64>,
54    /// Whether the chosen beam is the richest fitted; the counts may then hold
55    /// structure finer than its intervals, whose misfit `overdispersion`
56    /// includes.
57    pub at_limit: bool,
58    /// Step, in µs, of the returned fit's grid, which meets [`BOUND`] when the
59    /// fit converged.
60    pub step_us: f64,
61    /// Number of points of that grid.
62    pub points: usize,
63    /// How many times the first grid was halved to reach it.
64    pub halvings: usize,
65}
66
67/// Fit the beam to the raw open-beam counts `open_counts` of the time bins
68/// `time_edges_us` (µs), each bin recording the fraction `open_live` of the
69/// neutrons arriving in it, or every one when that is `None`.  `ln φ`, the
70/// logarithm of the beam, is a cubic spline in `ln u` with knots from the
71/// first edge's flight time to the last edge's; outside them the beam is the
72/// spline's continuation, which reaches the bins only through the pulse's
73/// delay or, for a folded pulse, its early tail.  Neutrons faster than the
74/// grid's range, each reaching the bins with less than
75/// [`NEGLIGIBLE_ARRIVAL_PROBABILITY`](nereids_physics::ikeda_carpenter::NEGLIGIBLE_ARRIVAL_PROBABILITY)
76/// chance, are left out.
77///
78/// The number of spline intervals doubles from one while the coefficients
79/// number at most half the bins.  Each candidate is fitted on a grid halved,
80/// and the beam refitted on each finer grid, until the refitted beam's
81/// predicted counts differ from the coarser grid's by at most [`BOUND`].  A
82/// candidate that does not converge, or needs more grid points than allowed,
83/// ends the ladder.  The candidate with the lowest `D / overdispersion + 2k` is
84/// returned, `D` twice its deviance and `k` its coefficients.
85///
86/// Counts too sparse to determine the beam are not supported: the fit may not
87/// converge, may leave coefficients undetermined (NaN in `covariance`), or,
88/// when its beam varies faster than a grid within the point cap resolves, end
89/// the ladder or, for the first candidate, refuse the fit.
90///
91/// Beam structure at or near the window's first edge is not supported: before
92/// the edge, where neutrons reach the bins only through the pulse's delay, the
93/// beam is the spline's continuation, which the counts cannot check.  Its error
94/// biases the fitted beam near that edge and, through the continuation's mean
95/// curvature, elsewhere; structure just before the edge can leave the first
96/// bins unmatched, which shows as `at_limit` with an overdispersion far above
97/// the detector's.  Start the window where the beam is smooth.
98///
99/// # Errors
100/// [`PipelineError::ShapeMismatch`] unless there is one count, and one live
101/// fraction when given, per bin;
102/// [`PipelineError::InvalidParameter`] if a count is not a whole non-negative
103/// number, every count is zero, a live fraction is not in (0, 1], or there are
104/// fewer than 8 bins (one interval's four coefficients and as many bins again
105/// to measure the noise);
106/// [`PipelineError::FlightTimeGrid`] for the grid's refusals, including the
107/// first candidate's halving past the point cap; [`PipelineError::Fitting`] if
108/// the fitter refuses.
109pub fn fit_open_beam(
110    time_edges_us: &[f64],
111    open_counts: &[f64],
112    calibration: &Calibration,
113    open_live: Option<&[f64]>,
114) -> Result<OpenBeamFit, PipelineError> {
115    let grid = Arc::new(FlightTimeGrid::new(
116        time_edges_us,
117        calibration.t0_us,
118        &calibration.pulse,
119    )?);
120    validate_counts("open-beam", open_counts, time_edges_us.len() - 1)?;
121    let live = validate_live("open-beam", open_live, time_edges_us.len() - 1)?;
122
123    let coefficients = |intervals: usize| intervals + 3;
124    let admits = |intervals: usize| 2 * coefficients(intervals) <= open_counts.len();
125    if !admits(1) {
126        return Err(PipelineError::InvalidParameter(format!(
127            "the open-beam fit needs at least {} time bins, one interval's {} coefficients \
128             and as many again to measure the noise; got {}",
129            2 * coefficients(1),
130            coefficients(1),
131            open_counts.len()
132        )));
133    }
134
135    let u_first = time_edges_us[0] - calibration.t0_us;
136    let u_last = time_edges_us[time_edges_us.len() - 1] - calibration.t0_us;
137    let per_unit_beam = grid.predict(&vec![1.0; grid.flight_times_us().len()])?;
138    let recorded: f64 = per_unit_beam.iter().zip(&live).map(|(c, l)| l * c).sum();
139    let per_us = open_counts.iter().sum::<f64>() / recorded;
140    let mut ladder = vec![fit_beam(
141        &grid,
142        &BeamSpline::constant(u_first, u_last, per_us),
143        open_counts,
144        &live,
145    )?];
146    while let Some(start) = ladder
147        .last()
148        .filter(|candidate| candidate.fit.converged && admits(2 * candidate.beam.intervals()))
149        .map(|candidate| candidate.beam.refined())
150    {
151        match fit_beam(&grid, &start, open_counts, &live) {
152            Ok(candidate) if candidate.fit.converged => ladder.push(candidate),
153            Ok(_)
154            | Err(PipelineError::FlightTimeGrid(FlightTimeGridError::TooManyPoints { .. })) => {
155                break;
156            }
157            Err(error) => return Err(error),
158        }
159    }
160
161    let richest = &ladder[ladder.len() - 1].fit;
162    let overdispersion = overdispersion(open_counts, richest);
163    let scale = overdispersion.unwrap_or(1.0);
164    let criterion = |candidate: &Candidate| {
165        2.0 * candidate.fit.result.deviance / scale
166            + 2.0 * candidate.beam.coefficients().len() as f64
167    };
168    let (chosen, _) =
169        ladder
170            .iter()
171            .map(criterion)
172            .enumerate()
173            .fold(
174                (0, f64::INFINITY),
175                |best, (i, q)| {
176                    if q < best.1 { (i, q) } else { best }
177                },
178            );
179    let at_limit = chosen + 1 == ladder.len();
180    let Candidate { beam, fit } = ladder.swap_remove(chosen);
181    Ok(OpenBeamFit {
182        beam,
183        deviance: fit.result.deviance,
184        converged: fit.converged,
185        covariance: fit
186            .result
187            .covariance
188            .filter(|_| fit.converged)
189            .map(|mut covariance| {
190                covariance.data.iter_mut().for_each(|v| *v *= scale);
191                covariance
192            }),
193        overdispersion,
194        at_limit,
195        step_us: fit.step_us,
196        points: fit.points,
197        halvings: fit.halvings,
198    })
199}
200
201pub(crate) fn validate_counts(run: &str, counts: &[f64], bins: usize) -> Result<(), PipelineError> {
202    if counts.len() != bins {
203        return Err(PipelineError::ShapeMismatch(format!(
204            "{} {run} counts for {bins} time bins",
205            counts.len()
206        )));
207    }
208    if let Some((bin, count)) = counts
209        .iter()
210        .enumerate()
211        .find(|(_, c)| !(c.is_finite() && **c >= 0.0 && c.fract() == 0.0))
212    {
213        return Err(PipelineError::InvalidParameter(format!(
214            "{run} counts must be whole non-negative numbers, got {count} in bin {bin}"
215        )));
216    }
217    if counts.iter().all(|&c| c == 0.0) {
218        return Err(PipelineError::InvalidParameter(format!(
219            "the {run} run has no counts"
220        )));
221    }
222    Ok(())
223}
224
225pub(crate) fn validate_live(
226    run: &str,
227    live: Option<&[f64]>,
228    bins: usize,
229) -> Result<Vec<f64>, PipelineError> {
230    let live = live.map_or_else(|| vec![1.0; bins], <[f64]>::to_vec);
231    if live.len() != bins {
232        return Err(PipelineError::ShapeMismatch(format!(
233            "{} {run} live fractions for {bins} time bins",
234            live.len()
235        )));
236    }
237    if let Some((bin, fraction)) = live
238        .iter()
239        .enumerate()
240        .find(|(_, l)| !(**l > 0.0 && **l <= 1.0))
241    {
242        return Err(PipelineError::InvalidParameter(format!(
243            "{run} live fractions must be in (0, 1], got {fraction} in bin {bin}"
244        )));
245    }
246    Ok(live)
247}
248
249const COUNTS_TO_MEASURE_NOISE: f64 = 1.0;
250
251pub(crate) fn overdispersion(observed: &[f64], fit: &GridFit) -> Option<f64> {
252    let (pearson, bins) = observed
253        .iter()
254        .zip(&fit.predicted)
255        .filter(|(_, mu)| **mu >= COUNTS_TO_MEASURE_NOISE)
256        .fold((0.0, 0_usize), |(sum, bins), (y, mu)| {
257            (sum + (y - mu).powi(2) / mu, bins + 1)
258        });
259    let parameters = fit.result.on_bound.iter().filter(|&&on| !on).count();
260    let freedom = bins.checked_sub(parameters).filter(|&f| f > 0)?;
261    fit.converged
262        .then(|| (pearson / freedom as f64).clamp(1.0, f64::INFINITY))
263}
264
265struct Candidate {
266    beam: BeamSpline,
267    fit: GridFit,
268}
269
270fn fit_beam(
271    first_grid: &Arc<FlightTimeGrid>,
272    start: &BeamSpline,
273    open_counts: &[f64],
274    live: &[f64],
275) -> Result<Candidate, PipelineError> {
276    let mut parameters = ParameterSet::new(
277        start
278            .coefficients()
279            .iter()
280            .enumerate()
281            .map(|(i, &c)| FitParameter::unbounded(format!("beam {i}"), c))
282            .collect(),
283    );
284    let fit = fit_on_halved_grids(first_grid, &mut parameters, open_counts, |grid| {
285        Ok(Recorded {
286            model: OpenBeamModel::new(grid, start),
287            live,
288        })
289    })?;
290    Ok(Candidate {
291        beam: start.with_coefficients(&fit.result.params),
292        fit,
293    })
294}
295
296pub(crate) struct GridFit {
297    pub(crate) result: PoissonResult,
298    pub(crate) converged: bool,
299    pub(crate) predicted: Vec<f64>,
300    pub(crate) step_us: f64,
301    pub(crate) points: usize,
302    pub(crate) halvings: usize,
303}
304
305pub(crate) fn fit_on_halved_grids<M: FitModel>(
306    first_grid: &Arc<FlightTimeGrid>,
307    parameters: &mut ParameterSet,
308    observed: &[f64],
309    model_on: impl Fn(&Arc<FlightTimeGrid>) -> Result<M, PipelineError>,
310) -> Result<GridFit, PipelineError> {
311    let mut grid = Arc::clone(first_grid);
312    let mut coarse = model_on(&grid)?;
313    let mut halvings = 0;
314    loop {
315        let finer = Arc::new(grid.halved()?);
316        let fine = model_on(&finer)?;
317        let result = poisson_fit(&fine, observed, parameters, &PoissonConfig::default())?;
318        let converged = result.converged && result.params.iter().all(|p| p.is_finite());
319        let predicted = fine.evaluate(&result.params)?;
320        let spread: f64 = predicted
321            .iter()
322            .zip(coarse.evaluate(&result.params)?)
323            .filter(|(fine, _)| **fine > 0.0)
324            .map(|(fine, coarse)| (fine - coarse).powi(2) / fine)
325            .sum();
326        grid = finer;
327        coarse = fine;
328        halvings += 1;
329        if spread <= BOUND || !converged {
330            return Ok(GridFit {
331                result,
332                converged,
333                predicted,
334                step_us: grid.step_us(),
335                points: grid.flight_times_us().len(),
336                halvings,
337            });
338        }
339    }
340}
341
342pub(crate) struct Recorded<'a, M> {
343    pub(crate) model: M,
344    pub(crate) live: &'a [f64],
345}
346
347impl<M: FitModel> FitModel for Recorded<'_, M> {
348    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
349        let counts = self.model.evaluate(params)?;
350        Ok(counts.iter().zip(self.live).map(|(c, l)| l * c).collect())
351    }
352
353    fn analytical_jacobian(
354        &self,
355        params: &[f64],
356        free_param_indices: &[usize],
357        y_current: &[f64],
358    ) -> Option<FlatMatrix> {
359        let mut jacobian = self
360            .model
361            .analytical_jacobian(params, free_param_indices, y_current)?;
362        for (row, l) in self.live.iter().enumerate() {
363            for col in 0..free_param_indices.len() {
364                *jacobian.get_mut(row, col) *= l;
365            }
366        }
367        Some(jacobian)
368    }
369}
370
371pub(crate) struct OpenBeamModel {
372    grid: Arc<FlightTimeGrid>,
373    basis: Vec<[(usize, f64); 5]>,
374}
375
376impl OpenBeamModel {
377    pub(crate) fn new(grid: &Arc<FlightTimeGrid>, beam: &BeamSpline) -> Self {
378        Self {
379            grid: Arc::clone(grid),
380            basis: grid
381                .flight_times_us()
382                .iter()
383                .map(|&u| beam.basis(u))
384                .collect(),
385        }
386    }
387
388    pub(crate) fn beam(&self, coefficients: &[f64]) -> Vec<f64> {
389        self.basis
390            .iter()
391            .map(|pairs| {
392                pairs
393                    .iter()
394                    .map(|&(i, w)| w * coefficients[i])
395                    .sum::<f64>()
396                    .exp()
397            })
398            .collect()
399    }
400
401    pub(crate) fn log_slope(&self, index: usize) -> Vec<f64> {
402        self.basis
403            .iter()
404            .map(|pairs| {
405                pairs
406                    .iter()
407                    .filter(|&&(i, _)| i == index)
408                    .map(|&(_, w)| w)
409                    .sum()
410            })
411            .collect()
412    }
413
414    pub(crate) fn counts(&self, values: &[f64]) -> Result<Vec<f64>, FittingError> {
415        self.grid
416            .predict(values)
417            .map_err(|e| FittingError::EvaluationFailed(e.to_string()))
418    }
419}
420
421impl FitModel for OpenBeamModel {
422    fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
423        self.counts(&self.beam(params))
424    }
425
426    fn analytical_jacobian(
427        &self,
428        params: &[f64],
429        free_param_indices: &[usize],
430        y_current: &[f64],
431    ) -> Option<FlatMatrix> {
432        let beam = self.beam(params);
433        let mut jacobian = FlatMatrix::zeros(y_current.len(), free_param_indices.len());
434        for (col, &index) in free_param_indices.iter().enumerate() {
435            let values: Vec<f64> = self
436                .log_slope(index)
437                .iter()
438                .zip(&beam)
439                .map(|(slope, phi)| slope * phi)
440                .collect();
441            for (row, value) in self.counts(&values).ok()?.into_iter().enumerate() {
442                *jacobian.get_mut(row, col) = value;
443            }
444        }
445        Some(jacobian)
446    }
447}
448
449#[cfg(test)]
450pub(crate) mod tests {
451    use nereids_physics::ikeda_carpenter::{EnergyLaw, IkedaCarpenterParams, SynthesisGrid};
452
453    use super::*;
454
455    pub(crate) const FLIGHT_PATH_M: f64 = 25.0;
456    pub(crate) const T0_US: f64 = 3.0;
457    pub(crate) const EDGES_US: std::ops::RangeInclusive<u32> = 350..=470;
458    pub(crate) const ALPHA: EnergyLaw = EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 };
459    pub(crate) const BETA: EnergyLaw = EnergyLaw::Const(0.25);
460    pub(crate) const R: EnergyLaw = EnergyLaw::Const(0.15);
461
462    pub(crate) fn grid(channel_fwhm_us: Option<f64>) -> Arc<FlightTimeGrid> {
463        let pulse = IkedaCarpenter::new(
464            IkedaCarpenterParams {
465                alpha: ALPHA,
466                beta: BETA,
467                r: R,
468                burst_sigma_us: None,
469                channel_fwhm_us,
470            },
471            FLIGHT_PATH_M,
472            &SynthesisGrid {
473                e_min_ev: 1.0,
474                e_max_ev: 200.0,
475                n_energies: 32,
476                n_tau: 256,
477            },
478        )
479        .expect("valid IC model");
480        let edges: Vec<f64> = EDGES_US.map(f64::from).collect();
481        Arc::new(FlightTimeGrid::new(&edges, T0_US, &Arc::new(pulse)).expect("grid"))
482    }
483
484    #[test]
485    fn the_jacobian_is_the_slope_of_the_counts() {
486        for channel_fwhm_us in [None, Some(2.0)] {
487            let grid = grid(channel_fwhm_us);
488            let (_, u_hi) = grid.range_us();
489            for beam in [
490                BeamSpline::constant(347.0, u_hi, 1.0e4),
491                BeamSpline::constant(347.0, u_hi, 1.0e4).refined().refined(),
492            ] {
493                let model = OpenBeamModel::new(&grid, &beam);
494                let coefficients: Vec<f64> = (0..beam.coefficients().len())
495                    .map(|i| 9.0 + 0.3 * (i as f64).sin())
496                    .collect();
497                let counts = model.evaluate(&coefficients).expect("counts");
498                let indices: Vec<usize> = (0..coefficients.len()).collect();
499                let jacobian = model
500                    .analytical_jacobian(&coefficients, &indices, &counts)
501                    .expect("jacobian");
502                for index in indices {
503                    let h = 1e-4;
504                    let shifted = |d: f64| {
505                        let mut c = coefficients.clone();
506                        c[index] += d;
507                        model.evaluate(&c).expect("counts")
508                    };
509                    let (up, down) = (shifted(h), shifted(-h));
510                    let slopes: Vec<f64> = up
511                        .iter()
512                        .zip(&down)
513                        .map(|(u, d)| (u - d) / (2.0 * h))
514                        .collect();
515                    let column = slopes.iter().fold(0.0_f64, |m, s| m.max(s.abs()));
516                    for (row, &slope) in slopes.iter().enumerate() {
517                        let analytic = jacobian.get(row, index);
518                        assert!(
519                            (analytic - slope).abs() <= 1e-6 * column,
520                            "{channel_fwhm_us:?} {} {index} {row}: {analytic} vs {slope}",
521                            beam.intervals()
522                        );
523                    }
524                }
525            }
526        }
527    }
528}