Skip to main content

nereids_pipeline/
counts_map.rs

1//! Maps of a material's areal densities and temperature over the detector,
2//! fitted as one counts fit whose regions are patches of pixels.
3
4use std::sync::RwLock;
5
6use faer::linalg::solvers::DenseSolveCore;
7use faer::{Mat, Side};
8use ndarray::{Array2, ArrayView2, ArrayView3, s};
9use nereids_fitting::error::FittingError;
10use nereids_fitting::lm::FlatMatrix;
11use nereids_fitting::poisson::{
12    InformationInverse, NEWTON_DECREMENT_TOL, Prior, half_deviance, information_inverse,
13};
14use nereids_fitting::statistics::{Consistency, consistency};
15use rayon::prelude::*;
16
17use crate::counts_fit::TEMPERATURE_BOUNDS_K;
18use crate::counts_fit::{
19    BACKGROUND_TERMS, BeamStart, BeamStarts, Bypass, MOST_PASSES, Material, Measurement, Region,
20    RegionFit, RegionTerms, Role, RoomBackground, SHARED, Starts, Value, checked, excluded_bins,
21    fit_counts_answer, labelled, open_frames, shared_parameters, starting_bypass,
22    starts_and_widths_us,
23};
24use crate::error::PipelineError;
25use crate::open_beam::{
26    BOUND, Calibration, PULSE_NUMBERS, Pulse, open_beam_with_intervals, validate_live, whole,
27};
28
29const MOST_STEPS: usize = 20;
30
31const MOST_HALVINGS: i32 = 10;
32
33/// The counts of every pixel of an open-beam run and a sample run recorded in
34/// the same time bins, which pixels to fit, and the material behind the
35/// sample.
36#[derive(Debug, Clone)]
37pub struct MapMeasurement<'a> {
38    /// Time-bin edges in µs.
39    pub time_edges_us: Vec<f64>,
40    /// `c_q`, the sample run's proton charge over the open-beam run's.
41    pub charge_ratio: f64,
42    /// `a`, the normalization of the sample run, the same in every pixel.
43    pub normalization: Value,
44    /// `[C, A, τ]` of the time background per pixel, as
45    /// [`Measurement::time_background`], the same in every pixel;
46    /// [`NO_TIME_BACKGROUND`](crate::counts_fit::NO_TIME_BACKGROUND) gives
47    /// none.
48    pub time_background: [Value; 3],
49    /// Counts that follow each run's duration, as
50    /// [`Measurement::room_background`], the same in every pixel.
51    pub room_background: Option<RoomBackground>,
52    /// Raw counts of the open-beam run, in (time bin, row, column).
53    pub open_counts: ArrayView3<'a, f64>,
54    /// Raw counts of the sample run, in (time bin, row, column).
55    pub sample_counts: ArrayView3<'a, f64>,
56    /// The fraction of the open-beam run's neutrons arriving in each bin that
57    /// every pixel records, in (0, 1]; `None` records every one.
58    pub open_live: Option<Vec<f64>>,
59    /// The fraction of the sample run's neutrons arriving in each bin that
60    /// every pixel records, in (0, 1]; `None` records every one.
61    pub sample_live: Option<Vec<f64>>,
62    /// Pixels left out of both runs, in (row, column).
63    pub excluded: ArrayView2<'a, bool>,
64    /// Pixels behind the sample, in (row, column).
65    pub sample: ArrayView2<'a, bool>,
66    /// Pixels the beam reaches through nothing and the sample scatters no
67    /// neutrons into, in (row, column).
68    pub empty: ArrayView2<'a, bool>,
69    /// The side of a square patch, in pixels.
70    pub binning: usize,
71    /// The material behind the sample: its isotopes' densities and its
72    /// temperature, known, fitted or within bounds, in every patch.
73    pub material: Material,
74    /// `[b0, b1, b2, d, f]` of the background of every patch behind the
75    /// sample, as [`Region::background`], known, fitted or within bounds.
76    pub background: [Value; 5],
77    /// The fraction of the time background that reaches the pixels behind the
78    /// sample in the sample run, as [`Region::gamma_transmission`], known,
79    /// fitted or within bounds in every patch; the empty pixels take 1.
80    pub gamma_transmission: Value,
81    /// The capture gammas' `ε` of every patch behind the sample, as
82    /// [`Region::capture_gammas`], known, fitted or within bounds; the empty
83    /// pixels hold no sample and record none.
84    pub capture_gammas: Value,
85    /// The in-scatter's `Ω` of every patch behind the sample, as
86    /// [`Region::in_scatter`], known, fitted or within bounds; the empty
87    /// pixels hold no sample and record none.
88    pub in_scatter: Value,
89    /// `[b0, b1, b2, d, f]` of the background of the empty pixels.
90    pub empty_background: [Value; 5],
91}
92
93impl MapMeasurement<'_> {
94    /// Each patch's kind, in (patch row, patch column); patch `(i, j)` holds
95    /// the pixels from `(i·binning, j·binning)` up to the next patch or the
96    /// image's edge.
97    ///
98    /// # Errors
99    /// [`PipelineError::ShapeMismatch`] unless both runs' counts have one time
100    /// bin per pair of edges and the same rows and columns as the three
101    /// masks; [`PipelineError::InvalidParameter`] if `binning` is 0.
102    pub fn patches(&self) -> Result<Array2<Patch>, PipelineError> {
103        let bins = self.time_edges_us.len().saturating_sub(1);
104        let (time_bins, height, width) = self.open_counts.dim();
105        if self.sample_counts.dim() != self.open_counts.dim() || time_bins != bins {
106            return Err(PipelineError::ShapeMismatch(format!(
107                "open-beam counts of shape {:?} and sample counts of shape {:?} for {bins} \
108                 time bins",
109                self.open_counts.dim(),
110                self.sample_counts.dim()
111            )));
112        }
113        for (name, mask) in [
114            ("excluded", self.excluded),
115            ("sample", self.sample),
116            ("empty", self.empty),
117        ] {
118            if mask.dim() != (height, width) {
119                return Err(PipelineError::ShapeMismatch(format!(
120                    "the {name} mask has shape {:?} for {height}×{width} pixels",
121                    mask.dim()
122                )));
123            }
124        }
125        let b = self.binning;
126        if b == 0 {
127            return Err(PipelineError::InvalidParameter(
128                "a patch is at least one pixel wide".into(),
129            ));
130        }
131        Ok(Array2::from_shape_fn(
132            (height.div_ceil(b), width.div_ceil(b)),
133            |patch| {
134                let pixels = self.pixels(patch);
135                let behind = pixels.iter().filter(|&&pixel| self.sample[pixel]).count();
136                match (behind, pixels.len() - behind) {
137                    (0, _) => Patch::Outside,
138                    (_, 0) => Patch::Sample,
139                    _ => Patch::Mixed,
140                }
141            },
142        ))
143    }
144
145    fn pixels(&self, (i, j): (usize, usize)) -> Vec<(usize, usize)> {
146        let (height, width) = self.excluded.dim();
147        let b = self.binning;
148        (i * b..height.min((i + 1) * b))
149            .flat_map(|y| (j * b..width.min((j + 1) * b)).map(move |x| (y, x)))
150            .filter(|&pixel| !self.excluded[pixel])
151            .collect()
152    }
153}
154
155/// What the pixels of a patch that are not excluded lie behind.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum Patch {
158    /// The sample, every one: the patch is fitted.
159    Sample,
160    /// The sample and something else: the patch is not fitted.
161    Mixed,
162    /// Not the sample, or every pixel is excluded.
163    Outside,
164}
165
166/// The fitted map, one value per patch.
167#[derive(Debug, Clone)]
168pub struct CountsMap<'a> {
169    measurement: MapMeasurement<'a>,
170    /// Each patch's kind, as [`MapMeasurement::patches`].
171    pub patches: Array2<Patch>,
172    /// Each [`Patch::Sample`]'s fit, the shared quantities held at the map's
173    /// answer; `None` where the patch is not fitted or [`Self::failed`].
174    pub fits: Array2<Option<RegionFit>>,
175    /// Why the fit of a [`Patch::Sample`] left out of the shared quantities at
176    /// the end failed: an error, no convergence, or a fitted temperature at
177    /// 1 K or 5000 K, at the shared values it was last fitted at, marked when
178    /// those are a halved step not taken.  `None` where the patch is not left
179    /// out or not fitted.
180    pub failed: Array2<Option<String>>,
181    /// The empty pixels' fit; `None` when every one is excluded, or when their
182    /// refit at the map's shared values fails, which ends the map
183    /// unconverged.
184    pub empty: Option<RegionFit>,
185    /// Each isotope's areal density in atoms/barn, in the material's order:
186    /// the known one, or the fitted one; NaN where the patch has no fit.
187    pub densities: Vec<Array2<f64>>,
188    /// Standard deviation of each density, from [`PatchCovariances::covariance`]; NaN
189    /// where the density is known, held on a bound or not determined by the
190    /// counts, alone or through a shared quantity, where the patch has no fit,
191    /// or where the map did not converge.
192    pub density_sd: Vec<Array2<f64>>,
193    /// The temperature in K: the known one, or the fitted one; NaN where the
194    /// patch has no fit.
195    pub temperature_k: Array2<f64>,
196    /// Standard deviation of the temperature in K, as [`Self::density_sd`].
197    pub temperature_sd_k: Array2<f64>,
198    /// Each patch's [`RegionFit::overdispersion`] of the open-beam run, then
199    /// of the sample run; NaN where it is `None` or the patch has no fit.
200    pub overdispersion: [Array2<f64>; 2],
201    /// Whether the map converged, the patch did not fail, and the counts
202    /// determine each of its fitted densities and its fitted temperature: each
203    /// has a finite standard deviation or, for a density, is 0.  Whether the
204    /// model describes the patch's counts is its [`Self::overdispersion`];
205    /// neither says whether the values are right.
206    pub identified: Array2<bool>,
207    /// The normalization `a`: the known one, or the fitted one.
208    pub normalization: f64,
209    /// The time background's `[C, A, τ]`, each the known or the fitted one.
210    pub time_background: [f64; 3],
211    /// The room background's `ρ`: the known one, the fitted one, or 0 without
212    /// one.
213    pub room_background: f64,
214    /// The timing offset `t0` in µs: the known one, or the fitted one.
215    pub t0_us: f64,
216    /// The flight path in m: the known one, or the fitted one.
217    pub flight_path_m: f64,
218    /// The pulse's `[α₀, α₁]`, each the known or the fitted one.
219    pub alpha: [f64; 2],
220    /// The pulse's `[β₀, β₁]`, each the known or the fitted one.
221    pub beta: [f64; 2],
222    /// The pulse's `R`: the known one, or the fitted one.
223    pub r: f64,
224    /// The square of the pulse's triangle FWHM, `h²` in µs²: the known one, or
225    /// the fitted one.
226    pub fwhm_squared_us2: f64,
227    /// The pulse's `R_high`, the known or the fitted one; `None` without one.
228    pub r_high: Option<f64>,
229    /// The detector tail's `[f, τ_D]`, each the known or the fitted one;
230    /// `None` without one.
231    pub detector_tail: Option<[f64; 2]>,
232    /// The fitted shared quantities, among the normalization, the time
233    /// background's `C`, `A` and `τ`, the room background's `ρ`, `t0`, flight
234    /// path, `α₀`, `α₁`, `β₀`, `β₁`, `R`, `h²`, `R_high`, `f` and `τ_D`, in that
235    /// order.
236    pub shared: Vec<&'static str>,
237    /// Covariance of [`Self::shared`]: the inverse of the information on them
238    /// of every patch that did not fail and of the empty pixels, each with
239    /// its own quantities and beam profiled out, plus `1/sd²` for each measured
240    /// shared quantity and the pulse calibration's prior.  The row and column
241    /// of one on a bound or not determined by the counts are NaN, and the rest
242    /// are conditional on one on a bound held there.  `None` when the map did
243    /// not converge.
244    pub shared_covariance: Option<FlatMatrix>,
245    /// For each measured one of [`Self::shared`], in that order, its value
246    /// without the bounds less its measurement over `√(sd² − variance)`, the
247    /// variance also without the bounds, as
248    /// [`CountsFit::measured_pulls`](crate::counts_fit::CountsFit::measured_pulls).
249    /// `None` when the map did not converge, or when the fit of a patch or of
250    /// the empty pixels gives no Gaussian without its bounds.
251    pub measured_pulls: Option<Vec<f64>>,
252    /// Whether the counts accept the pulse's calibration, as
253    /// [`CountsFit::pulse_consistency`](crate::counts_fit::CountsFit::pulse_consistency),
254    /// from [`Self::shared`] and the quantities of every patch not
255    /// [`Self::failed`], without their bounds.  `None` without a calibration,
256    /// when [`Self::measured_pulls`] is, or when [`consistency`] gives none.
257    pub pulse_consistency: Option<Consistency>,
258    /// A patch's fitted quantities: each fitted density, in the material's
259    /// order, the temperature and the thickness variance if fitted, each fitted
260    /// background term, and the gamma transmission, capture gammas and
261    /// in-scatter if fitted.
262    pub quantities: Vec<String>,
263    /// Each patch's covariance of [`Self::quantities`] and their joint
264    /// covariance across patches.
265    pub covariances: PatchCovariances,
266    /// Each fit's half Poisson deviance over the overdispersion it weighted
267    /// each run with, summed over the patches with a fit and the empty pixels,
268    /// outside the sample runs' [`RegionFit::excluded_bins`].
269    pub deviance: f64,
270    /// Whether the Newton decrement of the shared quantities fell below
271    /// [`NEWTON_DECREMENT_TOL`] at an answer whose window bins are those the
272    /// patches left out.
273    pub converged: bool,
274    /// Why the map did not converge: the empty pixels' failure, the step
275    /// limit, or a step none of whose halvings lowered the objective.  `None`
276    /// when it converged.
277    pub unconverged: Option<String>,
278    /// Newton steps tried on the shared quantities, those not taken included.
279    pub steps: usize,
280}
281
282impl CountsMap<'_> {
283    /// For each patch row in turn, for the open-beam run, then the sample
284    /// run, the signed deviance residual `sign(y − μ)·√(2(y·ln(y/μ) + μ − y))`
285    /// of each patch's counts `y` in the measurement the map was fitted to and
286    /// its fit's predicted counts `μ` in each bin, in (time bin, patch
287    /// column): `−√(2μ)` where `y` is 0, NaN where the patch has no fit.
288    /// Their squares over twice the overdispersion each run was weighted
289    /// with, summed outside the sample runs' [`RegionFit::excluded_bins`] over
290    /// every patch row with the empty pixels' alike, are [`Self::deviance`].
291    pub fn residual_rows(&self) -> impl Iterator<Item = [Array2<f64>; 2]> + '_ {
292        let map = &self.measurement;
293        let bins = map.open_counts.dim().0;
294        let width = self.patches.ncols();
295        (0..self.patches.nrows()).map(move |i| {
296            let mut row = [
297                Array2::from_elem((bins, width), f64::NAN),
298                Array2::from_elem((bins, width), f64::NAN),
299            ];
300            for j in 0..width {
301                let Some(fit) = &self.fits[[i, j]] else {
302                    continue;
303                };
304                let counts =
305                    summed(map, &map.pixels((i, j))).expect("the fit summed this patch's counts");
306                for (run, residuals) in row.iter_mut().enumerate() {
307                    for (k, (&y, &mu)) in counts[run].iter().zip(&fit.predicted[run]).enumerate() {
308                        residuals[[k, j]] = (2.0 * half_deviance(y, mu)).sqrt().copysign(y - mu);
309                    }
310                }
311            }
312            row
313        })
314    }
315
316    fn unsettled(mut self, reason: String) -> Self {
317        self.converged = false;
318        self.unconverged = Some(reason);
319        self.shared_covariance = None;
320        self.covariances.carried = None;
321        self.measured_pulls = None;
322        self.pulse_consistency = None;
323        self.covariances.covariance.fill(None);
324        for sd in self
325            .density_sd
326            .iter_mut()
327            .chain([&mut self.temperature_sd_k])
328        {
329            sd.fill(f64::NAN);
330        }
331        self.identified.fill(false);
332        self
333    }
334}
335
336/// Each patch's covariance of a map's [`CountsMap::quantities`], their
337/// change with the shared quantities, and the shared quantities' covariance
338/// that joins the patches.
339#[derive(Debug, Clone)]
340pub struct PatchCovariances {
341    /// Each patch's covariance of [`CountsMap::quantities`], the shared
342    /// quantities' uncertainty included; the row and column of one held on a
343    /// bound, or not determined by the counts, alone or through a shared
344    /// quantity, are NaN.  `None` where the patch has no fit or the map has no
345    /// shared covariance.
346    pub covariance: Array2<Option<FlatMatrix>>,
347    /// Each patch's change of [`CountsMap::quantities`] per unit change of each
348    /// of [`CountsMap::shared`] at fixed counts, `−F_oo⁻¹ F_os` of its
349    /// information; the row of a quantity held on a bound or not determined by
350    /// the counts is NaN.  `None` where the patch has no fit.
351    pub sensitivity: Array2<Option<FlatMatrix>>,
352    carried: Option<FlatMatrix>,
353}
354
355impl PatchCovariances {
356    /// The covariance of patch `a`'s [`CountsMap::quantities`] with a different
357    /// patch `b`'s, which only the shared quantities carry, `G_a C_ss G_bᵀ` over
358    /// those not held on a bound; for `a` = `b`, the part of
359    /// [`Self::covariance`] they carry.  `None` where either patch is outside
360    /// the grid or has no [`Self::sensitivity`], or the map has no
361    /// [`CountsMap::shared_covariance`].
362    pub fn cross_covariance(&self, a: (usize, usize), b: (usize, usize)) -> Option<FlatMatrix> {
363        let left = self.sensitivity.get(a)?.as_ref()?;
364        let right = self.sensitivity.get(b)?.as_ref()?;
365        let shared = self.carried.as_ref()?;
366        let k = shared.nrows;
367        let undetermined = |patch: (usize, usize), i: usize| {
368            self.covariance[patch]
369                .as_ref()
370                .is_none_or(|covariance| covariance.get(i, i).is_nan())
371        };
372        let mut cross = FlatMatrix::zeros(left.nrows, right.nrows);
373        for i in 0..left.nrows {
374            for j in 0..right.nrows {
375                *cross.get_mut(i, j) = if undetermined(a, i) || undetermined(b, j) {
376                    f64::NAN
377                } else {
378                    (0..k)
379                        .flat_map(|s| (0..k).map(move |t| (s, t)))
380                        .map(|(s, t)| left.get(i, s) * shared.get(s, t) * right.get(j, t))
381                        .sum()
382                };
383            }
384        }
385        Some(cross)
386    }
387}
388
389/// Fit `map.material` in each patch of `binning`×`binning` pixels whose
390/// pixels not excluded all lie behind the sample, and the empty pixels not
391/// excluded as one region with no material, as one counts fit whose regions
392/// share the normalization, the time and room backgrounds per pixel, `t0`,
393/// the flight path and the pulse: one clock, one flight path, one moderator, one proton
394/// beam, and a detector efficiency that changes between the runs alike in
395/// every pixel.  A region's counts are the sums over its pixels; each has its
396/// own beam, background and gamma transmission, and its time and room
397/// backgrounds are those per pixel times its pixels.  The empty pixels pin the normalization
398/// when their `b0` is known.
399///
400/// Each region's open-beam fit, which starts its beam and weights its
401/// open-beam run, takes the time and room backgrounds' starting numbers as
402/// known; the map is fitted again with them at its fitted numbers, up to twenty times,
403/// until those change the counts the open-beam fits took as known by
404/// `Σ_r Σ_k (ΔO_rk)²/O_rk` at most [`BOUND`] over every region's open-beam
405/// bins, as [`fit_counts`](crate::counts_fit::fit_counts) does; from the
406/// third time on each region keeps the beam intervals its second open-beam
407/// fit chose.  A map that has not settled after twenty is unconverged.
408///
409/// The fit is the joint one of [`fit_counts`](crate::counts_fit::fit_counts),
410/// found region by region.  With the shared quantities held, each region is
411/// fitted alone, in parallel, leaving out the bins of its material's windows
412/// at the shared values the steps last stopped at, first the map's starting
413/// ones, and gives its information and slope on them
414/// with its own quantities and beam profiled out; their sum, with the
415/// calibration's prior and each measured shared quantity's, sets a Newton
416/// step on the shared quantities, clamped
417/// to their bounds, with one on a bound its slope pushes out of held there.
418/// From its second fit on, a region keeps the beam intervals of its first,
419/// starts from its last answer, and counts in its sample run's
420/// overdispersion the leverage the shared quantities have on its bins, as
421/// the joint fit does.  The steps stop when their Newton decrement falls
422/// below [`NEWTON_DECREMENT_TOL`] at fits that counted that leverage and left
423/// out the window bins of the values they stop at; refitting the regions
424/// with new window bins counts as a step.  A
425/// step that raises the regions' summed deviance plus those priors'
426/// penalty, each run weighted with the overdispersion of the step
427/// before, or at which a fitted patch fails, is halved, up to ten times;
428/// twenty steps, a step that cannot fall, or a failure of the empty pixels at
429/// the smallest halving of a step or at their refit at the current values end
430/// the map unconverged.  The shared information and each region's
431/// information on its own quantities are inverted as [`information_inverse`]
432/// inverts them, the shared one scaled to its diagonal before the regions'
433/// own quantities are profiled out, with the rounding of one region's bins:
434/// the steps take every direction the information spans, and a quantity
435/// along a direction it does not determine, or moving along one through the
436/// shared quantities, has a NaN covariance.  Each of the map's regions holds
437/// its grid's spread to its share of the map's counts times [`BOUND`], so
438/// their summed spread is within the `BOUND` the joint fit holds it to.
439///
440/// A patch whose fit fails, does not converge, or ends at a fitted
441/// temperature of 1 K or 5000 K, at the starting shared values, at the
442/// smallest halving of a step none of whose halvings is taken, or at a refit
443/// at the current values, adds nothing to the shared quantities; it is fitted again after every step taken and
444/// adds to them again once it fits.  A patch left out at the end is
445/// [`CountsMap::failed`].
446///
447/// A patch has one density per isotope and one temperature: where its
448/// pixels differ, these are the uniform fit of the patch's summed counts,
449/// which differs from the pixels' beam-weighted means.
450///
451/// The map holds, for each patch, its summed counts and its fits at the
452/// current and the trial shared values, so its memory is up to several
453/// times that of the counts it is given; each region's grid gets finer as
454/// the number of regions grows.
455///
456/// # Errors
457/// Everything [`MapMeasurement::patches`] refuses;
458/// [`PipelineError::InvalidParameter`] if a pixel is both behind the sample
459/// and empty, a density, the temperature, the thickness variance, a
460/// background term, the gamma transmission, the capture gammas or the
461/// in-scatter of the sample's patches is [`Value::Measured`], which would
462/// count its measurement once per patch, a term of the empty pixels' background is
463/// [`Value::Measured`], the material carries resonance covariances, which a
464/// map does not yet carry, a count of a pixel summed into a region is not a
465/// whole non-negative number, or no patch is fitted; every refusal of
466/// [`fit_counts`](crate::counts_fit::fit_counts) for the material, the
467/// backgrounds, the shared quantities and the pulse's calibration;
468/// [`PipelineError::Fitting`] if the pulse's calibration has a covariance
469/// that cannot be inverted or an eigendecomposition fails;
470/// [`PipelineError::ShapeMismatch`] or [`PipelineError::InvalidParameter`] for
471/// live fractions `fit_counts` refuses; [`PipelineError::FlightTimeGrid`] if
472/// the grid refuses the time edges at the starting shared values.
473/// [`PipelineError::InvalidParameter`] naming the first failing patch and its
474/// error when no patch fits at the starting shared values, or naming the
475/// empty pixels when their fit fails there; [`PipelineError::InvalidParameter`]
476/// with the last patch's reason when every patch is left out later.
477pub fn fit_map<'a>(
478    map: &MapMeasurement<'a>,
479    calibration: &Calibration,
480) -> Result<CountsMap<'a>, PipelineError> {
481    map.patches()?;
482    let mut open = starting_bypass(&Measurement {
483        time_edges_us: Vec::new(),
484        charge_ratio: map.charge_ratio,
485        normalization: map.normalization,
486        time_background: map.time_background,
487        room_background: map.room_background,
488        spread: None,
489        regions: Vec::new(),
490    })?;
491    let bins = starts_and_widths_us(&map.time_edges_us);
492    let frames = open_frames(map.room_background.as_ref());
493    let empty_pixels = map
494        .empty
495        .indexed_iter()
496        .filter(|&(pixel, &e)| e && !map.excluded[pixel])
497        .count();
498    let mut intervals: Option<Vec<Option<usize>>> = None;
499    for passes in 1..=MOST_PASSES {
500        let result = fit_map_from(map, calibration, open, intervals.as_deref())?;
501        let fitted = Bypass {
502            time: result.time_background,
503            room: result.room_background,
504        };
505        let used = open.open_counts_per_pixel(&bins, frames);
506        let now = fitted.open_counts_per_pixel(&bins, frames);
507        let regions = || {
508            result
509                .fits
510                .indexed_iter()
511                .filter_map(|(patch, fit)| Some((fit.as_ref()?, map.pixels(patch).len())))
512                .chain(result.empty.as_ref().map(|fit| (fit, empty_pixels)))
513        };
514        let mut spread = 0.0;
515        for (fit, pixels) in regions() {
516            for (k, &mu) in fit.predicted[0]
517                .iter()
518                .enumerate()
519                .filter(|(_, mu)| **mu > 0.0)
520            {
521                let live = map.open_live.as_ref().map_or(1.0, |live| live[k]);
522                spread += (live * pixels as f64 * (now[k] - used[k])).powi(2) / mu;
523            }
524        }
525        if !result.converged || spread <= BOUND {
526            return Ok(result);
527        }
528        if passes == MOST_PASSES {
529            return Ok(result.unsettled(format!(
530                "the open-beam fits' time and room backgrounds did not settle in {MOST_PASSES} \
531                 fits"
532            )));
533        }
534        if passes >= 2 {
535            intervals.get_or_insert_with(|| {
536                result
537                    .patches
538                    .indexed_iter()
539                    .filter(|&(_, &kind)| kind == Patch::Sample)
540                    .map(|(patch, _)| result.fits[patch].as_ref().map(|f| f.beam.intervals()))
541                    .chain(
542                        (empty_pixels > 0)
543                            .then(|| result.empty.as_ref().map(|f| f.beam.intervals())),
544                    )
545                    .collect()
546            });
547        }
548        open = fitted;
549    }
550    unreachable!("the passes end at MOST_PASSES")
551}
552
553fn fit_map_from<'a>(
554    map: &MapMeasurement<'a>,
555    calibration: &Calibration,
556    open: Bypass,
557    intervals: Option<&[Option<usize>]>,
558) -> Result<CountsMap<'a>, PipelineError> {
559    let invalid = |message: String| Err(PipelineError::InvalidParameter(message));
560    let patches = map.patches()?;
561    if map.sample.iter().zip(&map.empty).any(|(&s, &e)| s && e) {
562        return invalid("a pixel is both behind the sample and empty".into());
563    }
564    let material = &map.material;
565    if material
566        .isotopes
567        .iter()
568        .map(|(_, density)| density)
569        .chain([&material.temperature_k, &material.thickness_variance])
570        .chain(&map.background)
571        .chain([
572            &map.gamma_transmission,
573            &map.capture_gammas,
574            &map.in_scatter,
575        ])
576        .any(|value| matches!(value, Value::Measured { .. }))
577    {
578        return invalid(
579            "a measured density, temperature, thickness variance, background, gamma \
580             transmission, capture gammas or in-scatter of the sample's patches would count its \
581             measurement once per patch; give it known, fitted or within bounds"
582                .into(),
583        );
584    }
585    if !material.covariances.is_empty() {
586        return invalid("a map does not yet carry the resonance parameters' covariance".into());
587    }
588    if map
589        .empty_background
590        .iter()
591        .any(|value| matches!(value, Value::Measured { .. }))
592    {
593        return invalid(
594            "a measured background of the empty pixels is not supported by the map's region by \
595             region fit; give it known, fitted or within bounds"
596                .into(),
597        );
598    }
599    let fitted_patches: Vec<(usize, usize)> = patches
600        .indexed_iter()
601        .filter(|&(_, &kind)| kind == Patch::Sample)
602        .map(|(patch, _)| patch)
603        .collect();
604    if fitted_patches.is_empty() {
605        let b = map.binning;
606        return invalid(format!(
607            "no patch of {b}×{b} pixels has all its pixels not excluded behind the sample"
608        ));
609    }
610    let empty: Vec<(usize, usize)> = map
611        .empty
612        .indexed_iter()
613        .filter(|&(pixel, &e)| e && !map.excluded[pixel])
614        .map(|(pixel, _)| pixel)
615        .collect();
616    let region =
617        |pixels: &[(usize, usize)], background, gamma_transmission, material: Option<Material>| {
618            let [open_counts, sample_counts] = summed(map, pixels)?;
619            Ok::<_, PipelineError>(Region {
620                open_counts,
621                sample_counts,
622                open_live: map.open_live.clone(),
623                sample_live: map.sample_live.clone(),
624                background,
625                pixels: pixels.len() as f64,
626                gamma_transmission,
627                capture_gammas: if material.is_some() {
628                    map.capture_gammas
629                } else {
630                    Value::Known(0.0)
631                },
632                in_scatter: if material.is_some() {
633                    map.in_scatter
634                } else {
635                    Value::Known(0.0)
636                },
637                timing_offset_us: Value::Known(0.0),
638                material,
639            })
640        };
641    let mut regions = fitted_patches
642        .iter()
643        .map(|&patch| {
644            region(
645                &map.pixels(patch),
646                map.background,
647                map.gamma_transmission,
648                Some(material.clone()),
649            )
650        })
651        .collect::<Result<Vec<Region>, PipelineError>>()?;
652    if !empty.is_empty() {
653        regions.push(region(
654            &empty,
655            map.empty_background,
656            Value::Known(1.0),
657            None,
658        )?);
659    }
660    let measurement = |normalization, time_background, room_background, regions| Measurement {
661        time_edges_us: map.time_edges_us.clone(),
662        charge_ratio: map.charge_ratio,
663        normalization,
664        time_background,
665        room_background,
666        spread: None,
667        regions,
668    };
669
670    let template = |background, gamma_transmission, material: Option<Material>| Region {
671        open_counts: Vec::new(),
672        sample_counts: Vec::new(),
673        open_live: None,
674        sample_live: None,
675        background,
676        pixels: 1.0,
677        gamma_transmission,
678        capture_gammas: if material.is_some() {
679            map.capture_gammas
680        } else {
681            Value::Known(0.0)
682        },
683        in_scatter: if material.is_some() {
684            map.in_scatter
685        } else {
686            Value::Known(0.0)
687        },
688        timing_offset_us: Value::Known(0.0),
689        material,
690    };
691    checked(
692        &measurement(
693            map.normalization,
694            map.time_background,
695            map.room_background,
696            std::iter::once(template(
697                map.background,
698                map.gamma_transmission,
699                Some(material.clone()),
700            ))
701            .chain(
702                (!empty.is_empty())
703                    .then(|| template(map.empty_background, Value::Known(1.0), None)),
704            )
705            .collect(),
706        ),
707        calibration,
708    )?;
709    let parameters = shared_parameters(
710        &measurement(
711            map.normalization,
712            map.time_background,
713            map.room_background,
714            vec![],
715        ),
716        calibration,
717    )?;
718    let at = |role: Role| {
719        SHARED
720            .iter()
721            .position(|&shared| shared == role)
722            .expect("a shared role")
723    };
724    let (time_background, room, t0) = (
725        at(Role::TimeBackground(0)),
726        at(Role::RoomBackground),
727        at(Role::T0),
728    );
729    let instrument = t0..SHARED.len();
730    let fitted: Vec<usize> = (0..SHARED.len())
731        .filter(|&i| !parameters[i].1.fixed)
732        .collect();
733    let roles: Vec<Role> = fitted.iter().map(|&i| SHARED[i]).collect();
734    let k = fitted.len();
735    let mut prior = Mat::<f64>::zeros(k, k);
736    let mut center = vec![0.0; k];
737    for (a, &i) in fitted.iter().enumerate() {
738        if let Value::Measured { value, sd } = parameters[i].0 {
739            prior[(a, a)] = 1.0 / (sd * sd);
740            center[a] = value;
741        }
742    }
743    let mut calibrated = None;
744    if let Some(pulse) = &calibration.pulse.prior {
745        let at: Vec<usize> = pulse
746            .numbers
747            .iter()
748            .map(|&n| fitted.iter().position(|&i| SHARED[i] == Role::Pulse(n)))
749            .collect::<Option<_>>()
750            .expect("a calibrated number is fitted");
751        let numbers = Prior::correlated(&at, &pulse.mean, &pulse.covariance)?;
752        let inverse = Mat::from_fn(at.len(), at.len(), |a, b| pulse.covariance.get(a, b))
753            .llt(Side::Lower)
754            .map_err(|e| FittingError::EvaluationFailed(format!("{e:?}")))?
755            .inverse();
756        for (a, &i) in at.iter().enumerate() {
757            center[i] = pulse.mean[a];
758            for (b, &j) in at.iter().enumerate() {
759                prior[(i, j)] = inverse[(a, b)];
760            }
761        }
762        calibrated = Some((numbers, at));
763    }
764    let penalty = |values: &[f64]| -> (f64, Vec<f64>) {
765        let offset: Vec<f64> = (0..k).map(|a| values[fitted[a]] - center[a]).collect();
766        let slope: Vec<f64> = (0..k)
767            .map(|a| (0..k).map(|b| prior[(a, b)] * offset[b]).sum())
768            .collect();
769        (
770            0.5 * offset.iter().zip(&slope).map(|(o, s)| o * s).sum::<f64>(),
771            slope,
772        )
773    };
774
775    let mut values: Vec<f64> = parameters.iter().map(|(_, p)| p.value).collect();
776    let origin_us = values[t0];
777    let open_per_pixel = open.open_counts_per_pixel(
778        &starts_and_widths_us(&map.time_edges_us),
779        open_frames(map.room_background.as_ref()),
780    );
781    let bins = map.open_counts.dim().0;
782    validate_live("open-beam", map.open_live.as_deref(), bins)?;
783    validate_live("sample", map.sample_live.as_deref(), bins)?;
784    let (t_low, t_high) = TEMPERATURE_BOUNDS_K;
785    let fitted_temperature = !matches!(material.temperature_k, Value::Known(_));
786    let labels: Vec<String> = fitted_patches
787        .iter()
788        .map(|patch| format!("patch {patch:?}"))
789        .chain((!empty.is_empty()).then(|| "the empty pixels".to_string()))
790        .collect();
791    let patch_count = fitted_patches.len();
792    let held_at = |values: &[f64]| {
793        let known = |i: usize| Value::Known(values[t0 + i]);
794        Calibration {
795            t0_us: known(0),
796            flight_path_m: known(1),
797            pulse: Pulse {
798                alpha: [known(2), known(3)],
799                beta: [known(4), known(5)],
800                r: known(6),
801                fwhm_squared_us2: known(7),
802                r_high: calibration.pulse.r_high.map(|_| known(8)),
803                detector_tail: calibration
804                    .pulse
805                    .detector_tail
806                    .map(|_| [known(9), known(10)]),
807                prior: None,
808                ..calibration.pulse.clone()
809            },
810        }
811    };
812    let starting = held_at(&values);
813    let excluded_at = |values: &[f64]| -> Vec<Vec<usize>> {
814        regions
815            .iter()
816            .map(|region| {
817                excluded_bins(
818                    &map.time_edges_us,
819                    region.material.as_ref(),
820                    &values[instrument.clone()],
821                    calibration.pulse.storage_span(),
822                )
823            })
824            .collect()
825    };
826    let excluded = RwLock::new(excluded_at(&values));
827    let counted_in =
828        |region: &Region| -> f64 { region.open_counts.iter().chain(&region.sample_counts).sum() };
829    let all_counts: f64 = regions.iter().map(counted_in).sum();
830    let budgets: Vec<f64> = regions
831        .iter()
832        .map(|region| BOUND * counted_in(region) / all_counts)
833        .collect();
834    let fit_region = |r: usize,
835                      values: &[f64],
836                      previous: Option<&RegionFit>,
837                      shared_leverage: Option<f64>|
838     -> Swept {
839        let held = held_at(values);
840        let label = &labels[r];
841        let one = measurement(
842            Value::Known(values[at(Role::Normalization)]),
843            std::array::from_fn(|i| Value::Known(values[time_background + i])),
844            map.room_background.map(|room_background| RoomBackground {
845                rate: Value::Known(values[room]),
846                ..room_background
847            }),
848            vec![restarted(&regions[r], previous)],
849        );
850        let beam = match previous {
851            Some(fit) => BeamStart {
852                beam: fit.beam.clone(),
853                overdispersion: fit.overdispersion[0],
854                at_limit: fit.beam_at_limit,
855            },
856            None => {
857                let known: Vec<f64> = open_per_pixel
858                    .iter()
859                    .map(|g| regions[r].pixels * g)
860                    .collect();
861                open_beam_with_intervals(
862                    &map.time_edges_us,
863                    &regions[r].open_counts,
864                    &starting,
865                    regions[r].open_live.as_deref(),
866                    Some(&known),
867                    intervals.and_then(|chosen| chosen[r]),
868                )
869                .map_err(|e| named(label, e))?
870                .into()
871            }
872        };
873        let starts = Starts {
874            beams: vec![beam],
875            origin_us,
876            shared_leverage: shared_leverage.map(|extra| vec![extra]),
877            bound: budgets[r],
878            excluded: vec![excluded.read().expect("the bin lists")[r].clone()],
879        };
880        let (fit, answer) = fit_counts_answer(&one, &held, BeamStarts::Given(&starts), None)
881            .map_err(|e| named(label, e))?;
882        let region_fit = fit.regions.into_iter().next().expect("one region");
883        let edge = fitted_temperature
884            && region_fit
885                .temperature_k
886                .is_some_and(|t| t == t_low || t == t_high);
887        if edge {
888            return Err(PipelineError::InvalidParameter(format!(
889                "{label}: its temperature ended at {t_low} K or {t_high} K"
890            )));
891        }
892        if !fit.converged {
893            return Err(PipelineError::InvalidParameter(format!(
894                "{label}: its fit did not converge"
895            )));
896        }
897        let mut terms = answer
898            .region_terms(0, &roles)
899            .map_err(|e| named(label, e))?;
900        if fit.unbounded.is_none() {
901            terms.unbounded = None;
902        }
903        Ok(Inner {
904            fit: region_fit,
905            deviance: fit.deviance,
906            terms,
907        })
908    };
909    let sweep = |values: &[f64],
910                 previous: &[Option<RegionFit>],
911                 leverage: &[Option<f64>],
912                 failures: &[Option<String>],
913                 failed: bool|
914     -> Vec<Option<Swept>> {
915        (0..regions.len())
916            .into_par_iter()
917            .map(|r| {
918                (failures[r].is_some() == failed)
919                    .then(|| fit_region(r, values, previous[r].as_ref(), leverage[r]))
920            })
921            .collect()
922    };
923    let objective = |inner: &[Option<Inner>], weights: &[Option<Inner>]| -> f64 {
924        inner
925            .iter()
926            .zip(weights)
927            .zip(&regions)
928            .filter_map(|((inner, weights), region)| {
929                Some((inner.as_ref()?, weights.as_ref()?, region))
930            })
931            .map(|(inner, weights, region)| {
932                [&region.open_counts, &region.sample_counts]
933                    .into_iter()
934                    .zip(&inner.fit.predicted)
935                    .enumerate()
936                    .map(|(run, (counts, predicted))| {
937                        counts
938                            .iter()
939                            .zip(predicted)
940                            .enumerate()
941                            .filter(|(k, _)| run == 0 || !inner.fit.excluded_bins.contains(k))
942                            .map(|(_, (&y, &mu))| half_deviance(y, mu))
943                            .sum::<f64>()
944                            / weight(&weights.fit, run)
945                    })
946                    .sum::<f64>()
947            })
948            .sum()
949    };
950
951    let mut failures: Vec<Option<String>> = vec![None; regions.len()];
952    let mut leverage: Vec<Option<f64>> = vec![None; regions.len()];
953    let mut previous: Vec<Option<RegionFit>> = vec![None; regions.len()];
954    let mut inner: Vec<Option<Inner>> = Vec::with_capacity(regions.len());
955    let mut first_failure = None;
956    for (r, swept) in sweep(&values, &previous, &leverage, &failures, false)
957        .into_iter()
958        .enumerate()
959    {
960        match swept.expect("every region is fitted at the start") {
961            Ok(one) => inner.push(Some(one)),
962            Err(error) if r >= patch_count => return Err(error),
963            Err(error) => {
964                failures[r] = Some(error.to_string());
965                first_failure.get_or_insert(error);
966                inner.push(None);
967            }
968        }
969    }
970    if inner[..patch_count].iter().all(Option::is_none) {
971        return Err(first_failure.expect("a patch failed"));
972    }
973    let absorb = |swept: Vec<Option<Swept>>,
974                  inner: &mut [Option<Inner>],
975                  failures: &mut [Option<String>]| {
976        let mut joined = false;
977        for (r, one) in swept.into_iter().enumerate() {
978            match one {
979                Some(Ok(one)) => {
980                    joined |= failures[r].is_some();
981                    failures[r] = None;
982                    inner[r] = Some(one);
983                }
984                Some(Err(error)) => {
985                    failures[r] = Some(error.to_string());
986                    inner[r] = None;
987                }
988                None => {}
989            }
990        }
991        joined
992    };
993    let none_left = |failures: &[Option<String>]| {
994        let reason = failures[..patch_count].iter().flatten().last();
995        invalid(format!(
996            "every patch of the map failed; the last: {}",
997            reason.map_or("", String::as_str)
998        ))
999    };
1000    let counted = 2 * bins;
1001    let inverse_over = |total: &Mat<f64>, diagonal: &[f64], set: &[usize]| {
1002        let mut block = FlatMatrix::zeros(set.len(), set.len());
1003        for (a, &i) in set.iter().enumerate() {
1004            for (b, &j) in set.iter().enumerate() {
1005                *block.get_mut(a, b) = total[(i, j)];
1006            }
1007        }
1008        let diagonal: Vec<f64> = set.iter().map(|&i| diagonal[i]).collect();
1009        information_inverse(&block, &diagonal, counted)
1010    };
1011    let mut steps = 0;
1012    let mut converged = false;
1013    let mut unconverged = None;
1014    let mut weighted = false;
1015    let information = loop {
1016        for (kept, inner) in previous.iter_mut().zip(&inner) {
1017            if let Some(inner) = inner {
1018                *kept = Some(inner.fit.clone());
1019            }
1020        }
1021        let (_, prior_slope) = penalty(&values);
1022        let mut gradient = prior_slope;
1023        let mut total = prior.clone();
1024        let mut diagonal: Vec<f64> = (0..k).map(|a| prior[(a, a)]).collect();
1025        for terms in inner.iter().flatten().map(|inner| &inner.terms) {
1026            for a in 0..k {
1027                gradient[a] += terms.gradient[a];
1028                diagonal[a] += terms.diagonal[a];
1029                for b in 0..k {
1030                    total[(a, b)] += terms.information.get(a, b);
1031                }
1032            }
1033        }
1034        let free: Vec<usize> = (0..k)
1035            .filter(|&a| {
1036                let (value, parameter) = (values[fitted[a]], &parameters[fitted[a]].1);
1037                !(value == parameter.lower && gradient[a] > 0.0
1038                    || value == parameter.upper && gradient[a] < 0.0)
1039            })
1040            .collect();
1041        let off_bounds: Vec<usize> = (0..k)
1042            .filter(|&a| {
1043                let (value, parameter) = (values[fitted[a]], &parameters[fitted[a]].1);
1044                value != parameter.lower && value != parameter.upper
1045            })
1046            .collect();
1047        let spread = inverse_over(&total, &diagonal, &off_bounds)?.spanned;
1048        for (shared_leverage, inner) in leverage.iter_mut().zip(&inner) {
1049            if let Some(Inner { terms, .. }) = inner {
1050                *shared_leverage = Some(
1051                    (0..off_bounds.len())
1052                        .flat_map(|a| (0..off_bounds.len()).map(move |b| (a, b)))
1053                        .map(|(a, b)| {
1054                            terms.counted_information.get(off_bounds[a], off_bounds[b])
1055                                * spread.get(a, b)
1056                        })
1057                        .sum(),
1058                );
1059            }
1060        }
1061        let covariance = inverse_over(&total, &diagonal, &free)?.spanned;
1062        let step: Vec<f64> = (0..free.len())
1063            .map(|a| {
1064                -(0..free.len())
1065                    .map(|b| covariance.get(a, b) * gradient[free[b]])
1066                    .sum::<f64>()
1067            })
1068            .collect();
1069        let decrement: f64 = -0.5
1070            * (0..free.len())
1071                .map(|a| gradient[free[a]] * step[a])
1072                .sum::<f64>();
1073        if decrement < NEWTON_DECREMENT_TOL {
1074            let at_answer = excluded_at(&values);
1075            if weighted && at_answer != *excluded.read().expect("the bin lists") {
1076                if steps == MOST_STEPS {
1077                    unconverged = Some(format!("{MOST_STEPS} Newton steps did not converge"));
1078                    break None;
1079                }
1080                steps += 1;
1081                *excluded.write().expect("the bin lists") = at_answer;
1082                let retried = sweep(&values, &previous, &leverage, &failures, true);
1083                absorb(retried, &mut inner, &mut failures);
1084            } else if weighted {
1085                converged = true;
1086                let mut unbounded = Some((prior.clone(), penalty(&values).1));
1087                for terms in inner.iter().flatten().map(|inner| &inner.terms) {
1088                    match (&mut unbounded, &terms.unbounded) {
1089                        (Some((total, slope)), Some((information, region_slope))) => {
1090                            for a in 0..k {
1091                                slope[a] += region_slope[a];
1092                                for b in 0..k {
1093                                    total[(a, b)] += information.get(a, b);
1094                                }
1095                            }
1096                        }
1097                        _ => unbounded = None,
1098                    }
1099                }
1100                break Some((total, diagonal, unbounded));
1101            }
1102            weighted = true;
1103            let swept = sweep(&values, &previous, &leverage, &failures, false);
1104            absorb(swept, &mut inner, &mut failures);
1105            if let Some(reason) = failures[patch_count..].iter().flatten().next() {
1106                unconverged = Some(reason.clone());
1107                break None;
1108            }
1109            if inner[..patch_count].iter().all(Option::is_none) {
1110                return none_left(&failures);
1111            }
1112            continue;
1113        }
1114        if steps == MOST_STEPS {
1115            unconverged = Some(format!("{MOST_STEPS} Newton steps did not converge"));
1116            break None;
1117        }
1118        steps += 1;
1119        let before = objective(&inner, &inner) + penalty(&values).0;
1120        let mut lost: Vec<(usize, String)> = Vec::new();
1121        let accepted = (0..=MOST_HALVINGS).find_map(|halving| {
1122            let mut trial = values.clone();
1123            for (a, &f) in free.iter().enumerate() {
1124                let parameter = &parameters[fitted[f]].1;
1125                trial[fitted[f]] = (values[fitted[f]] + step[a] / f64::powi(2.0, halving))
1126                    .clamp(parameter.lower, parameter.upper);
1127            }
1128            let swept = sweep(&trial, &previous, &leverage, &failures, false);
1129            lost = swept
1130                .iter()
1131                .enumerate()
1132                .filter_map(|(r, one)| match one {
1133                    Some(Err(error)) => Some((r, error.to_string())),
1134                    _ => None,
1135                })
1136                .collect();
1137            if !lost.is_empty() {
1138                return None;
1139            }
1140            let candidate: Vec<Option<Inner>> = swept
1141                .into_iter()
1142                .map(|one| one.and_then(Result::ok))
1143                .collect();
1144            (objective(&candidate, &inner) + penalty(&trial).0 <= before)
1145                .then_some((trial, candidate))
1146        });
1147        match accepted {
1148            Some((trial, candidate)) => {
1149                values = trial;
1150                inner = candidate;
1151                let retried = sweep(&values, &previous, &leverage, &failures, true);
1152                weighted = !absorb(retried, &mut inner, &mut failures);
1153            }
1154            None if !lost.is_empty() && lost.iter().all(|&(r, _)| r < patch_count) => {
1155                for (r, reason) in lost {
1156                    failures[r] = Some(format!("{reason} (at a halved step not taken)"));
1157                    inner[r] = None;
1158                }
1159                weighted = false;
1160                if inner[..patch_count].iter().all(Option::is_none) {
1161                    return none_left(&failures);
1162                }
1163            }
1164            None => {
1165                let empty_first = lost
1166                    .iter()
1167                    .position(|&(r, _)| r >= patch_count)
1168                    .unwrap_or(0);
1169                unconverged = Some(lost.into_iter().nth(empty_first).map_or_else(
1170                    || "no halving of a Newton step lowered the objective".to_string(),
1171                    |(_, reason)| reason,
1172                ));
1173                break None;
1174            }
1175        }
1176    };
1177
1178    let interior: Vec<usize> = (0..k)
1179        .filter(|&a| {
1180            let (value, parameter) = (values[fitted[a]], &parameters[fitted[a]].1);
1181            value != parameter.lower && value != parameter.upper
1182        })
1183        .collect();
1184    let embedded = |set: &[usize], inverse: &InformationInverse| {
1185        let mut covariance = FlatMatrix::zeros(k, k);
1186        covariance.data.fill(f64::NAN);
1187        for (a, &i) in set.iter().enumerate() {
1188            for (b, &j) in set.iter().enumerate() {
1189                if inverse.resolved[a] && inverse.resolved[b] {
1190                    *covariance.get_mut(i, j) = inverse.determined.get(a, b);
1191                }
1192            }
1193        }
1194        covariance
1195    };
1196    let interior_inverse = match &information {
1197        Some((total, diagonal, _)) => Some(inverse_over(total, diagonal, &interior)?),
1198        None => None,
1199    };
1200    let shared_covariance = interior_inverse
1201        .as_ref()
1202        .map(|inverse| embedded(&interior, inverse));
1203    let propagated = interior_inverse
1204        .as_ref()
1205        .map(|inverse| propagated(k, &interior, inverse));
1206    let null: Vec<Vec<f64>> = interior_inverse
1207        .iter()
1208        .flat_map(|inverse| &inverse.undetermined)
1209        .map(|direction| {
1210            let mut along = vec![0.0; k];
1211            for (a, &i) in interior.iter().enumerate() {
1212                along[i] = direction[a];
1213            }
1214            along
1215        })
1216        .collect();
1217    let every: Vec<usize> = (0..k).collect();
1218    let unbounded = match &information {
1219        Some((_, diagonal, Some((total, gradient)))) => {
1220            let inverse = inverse_over(total, diagonal, &every)?;
1221            let mean: Vec<f64> = (0..k)
1222                .map(|a| {
1223                    if inverse.resolved[a] {
1224                        values[fitted[a]]
1225                            - (0..k)
1226                                .map(|b| inverse.determined.get(a, b) * gradient[b])
1227                                .sum::<f64>()
1228                    } else {
1229                        f64::NAN
1230                    }
1231                })
1232                .collect();
1233            Some((mean, embedded(&every, &inverse)))
1234        }
1235        _ => None,
1236    };
1237    let pulse_consistency = match (&calibrated, &unbounded) {
1238        (Some((numbers, at)), Some((mean, inverse))) => {
1239            let mut posterior = FlatMatrix::zeros(at.len(), at.len());
1240            for (a, &i) in at.iter().enumerate() {
1241                for (b, &j) in at.iter().enumerate() {
1242                    *posterior.get_mut(a, b) = inverse.get(i, j);
1243                }
1244            }
1245            let estimate: Vec<f64> = at.iter().map(|&i| mean[i]).collect();
1246            consistency(numbers, &estimate, &posterior)?
1247        }
1248        _ => None,
1249    };
1250    let measured_pulls = unbounded.as_ref().map(|(mean, inverse)| {
1251        (0..k)
1252            .filter_map(|a| match parameters[fitted[a]].0 {
1253                Value::Measured { value, sd } => {
1254                    Some((mean[a] - value) / (sd * sd - inverse.get(a, a)).sqrt())
1255                }
1256                _ => None,
1257            })
1258            .collect()
1259    });
1260    let template: Vec<(Role, String)> = material
1261        .isotopes
1262        .iter()
1263        .enumerate()
1264        .filter(|(_, (_, density))| !matches!(density, Value::Known(_)))
1265        .map(|(isotope, (data, _))| {
1266            (
1267                Role::Density { region: 0, isotope },
1268                format!("density of {}", data.isotope),
1269            )
1270        })
1271        .chain(
1272            (!matches!(material.temperature_k, Value::Known(_)))
1273                .then(|| (Role::Temperature { region: 0 }, "temperature".to_string())),
1274        )
1275        .chain(
1276            (!matches!(material.thickness_variance, Value::Known(_))).then(|| {
1277                (
1278                    Role::ThicknessVariance { region: 0 },
1279                    "thickness variance".to_string(),
1280                )
1281            }),
1282        )
1283        .chain(
1284            map.background
1285                .iter()
1286                .enumerate()
1287                .filter(|(_, value)| !matches!(value, Value::Known(_)))
1288                .map(|(term, _)| {
1289                    (
1290                        Role::Background { region: 0, term },
1291                        BACKGROUND_TERMS[term].to_string(),
1292                    )
1293                }),
1294        )
1295        .chain(
1296            (!matches!(map.gamma_transmission, Value::Known(_))).then(|| {
1297                (
1298                    Role::GammaTransmission { region: 0 },
1299                    "gamma transmission".to_string(),
1300                )
1301            }),
1302        )
1303        .chain((!matches!(map.capture_gammas, Value::Known(_))).then(|| {
1304            (
1305                Role::CaptureGammas { region: 0 },
1306                "capture gammas".to_string(),
1307            )
1308        }))
1309        .chain(
1310            (!matches!(map.in_scatter, Value::Known(_)))
1311                .then(|| (Role::InScatter { region: 0 }, "in-scatter".to_string())),
1312        )
1313        .collect();
1314    let q = template.len();
1315
1316    let (rows, cols) = patches.dim();
1317    let blank = Array2::from_elem((rows, cols), f64::NAN);
1318    let isotopes = material.isotopes.len();
1319    let mut densities = vec![blank.clone(); isotopes];
1320    let mut density_sd = vec![blank.clone(); isotopes];
1321    let mut temperature_k = blank.clone();
1322    let mut temperature_sd_k = blank.clone();
1323    let mut overdispersion = [blank.clone(), blank];
1324    let mut identified = Array2::from_elem((rows, cols), false);
1325    let mut failed = Array2::from_elem((rows, cols), None);
1326    let mut fits = Array2::from_elem((rows, cols), None);
1327    let mut covariance = Array2::from_elem((rows, cols), None);
1328    let mut sensitivity = Array2::from_elem((rows, cols), None);
1329    for (r, &patch) in fitted_patches.iter().enumerate() {
1330        let Some(Inner { fit, terms, .. }) = &inner[r] else {
1331            failed[patch] = failures[r].clone();
1332            continue;
1333        };
1334        let rows_of: Vec<Option<usize>> = template
1335            .iter()
1336            .map(|(role, _)| terms.quantities.iter().position(|q| q == role))
1337            .collect();
1338        let mut slopes = FlatMatrix::zeros(q, k);
1339        for (a, row) in rows_of.iter().enumerate() {
1340            for s in 0..k {
1341                *slopes.get_mut(a, s) = row.map_or(f64::NAN, |i| terms.sensitivity.get(i, s));
1342            }
1343        }
1344        let marginal = propagated.as_ref().map(|shared| {
1345            let mut marginal = FlatMatrix::zeros(q, q);
1346            for (a, row_a) in rows_of.iter().enumerate() {
1347                for (b, row_b) in rows_of.iter().enumerate() {
1348                    *marginal.get_mut(a, b) = match (row_a, row_b) {
1349                        (Some(i), Some(j)) => marginal_entry(
1350                            &terms.covariance,
1351                            &terms.sensitivity,
1352                            shared,
1353                            &null,
1354                            (*i, *j),
1355                        ),
1356                        _ => f64::NAN,
1357                    };
1358                }
1359            }
1360            marginal
1361        });
1362        let sd = |role: Role| {
1363            template
1364                .iter()
1365                .position(|(r, _)| *r == role)
1366                .zip(marginal.as_ref())
1367                .map_or(f64::NAN, |(a, marginal)| marginal.get(a, a).sqrt())
1368        };
1369        for (m, &density) in fit.densities.iter().enumerate() {
1370            densities[m][patch] = density;
1371            density_sd[m][patch] = sd(Role::Density {
1372                region: 0,
1373                isotope: m,
1374            });
1375        }
1376        temperature_k[patch] = fit.temperature_k.unwrap_or(f64::NAN);
1377        temperature_sd_k[patch] = sd(Role::Temperature { region: 0 });
1378        for (run, phi) in overdispersion.iter_mut().zip(fit.overdispersion) {
1379            run[patch] = phi.unwrap_or(f64::NAN);
1380        }
1381        identified[patch] = converged
1382            && template.iter().all(|&(role, _)| match role {
1383                Role::Density { isotope, .. } => {
1384                    sd(role).is_finite() || fit.densities[isotope] == 0.0
1385                }
1386                Role::Temperature { .. } => sd(role).is_finite(),
1387                _ => true,
1388            });
1389        fits[patch] = Some(fit.clone());
1390        covariance[patch] = marginal;
1391        sensitivity[patch] = Some(slopes);
1392    }
1393
1394    Ok(CountsMap {
1395        measurement: map.clone(),
1396        patches,
1397        fits,
1398        failed,
1399        empty: (!empty.is_empty())
1400            .then(|| inner.last().and_then(|i| i.as_ref().map(|i| i.fit.clone())))
1401            .flatten(),
1402        densities,
1403        density_sd,
1404        temperature_k,
1405        temperature_sd_k,
1406        overdispersion,
1407        identified,
1408        normalization: values[at(Role::Normalization)],
1409        time_background: std::array::from_fn(|i| values[time_background + i]),
1410        room_background: values[room],
1411        t0_us: values[t0],
1412        flight_path_m: values[t0 + 1],
1413        alpha: [values[t0 + 2], values[t0 + 3]],
1414        beta: [values[t0 + 4], values[t0 + 5]],
1415        r: values[t0 + 6],
1416        fwhm_squared_us2: values[t0 + 7],
1417        r_high: calibration.pulse.r_high.map(|_| values[t0 + 8]),
1418        detector_tail: calibration
1419            .pulse
1420            .detector_tail
1421            .map(|_| [values[t0 + 9], values[t0 + 10]]),
1422        shared: fitted
1423            .iter()
1424            .map(|&i| {
1425                [
1426                    "normalization",
1427                    "time background C",
1428                    "time background A",
1429                    "time background τ",
1430                    "room background ρ",
1431                    "t0",
1432                    "flight path",
1433                ]
1434                .into_iter()
1435                .chain(PULSE_NUMBERS)
1436                .nth(i)
1437                .expect("a shared quantity")
1438            })
1439            .collect(),
1440        shared_covariance,
1441        measured_pulls,
1442        pulse_consistency,
1443        quantities: template.into_iter().map(|(_, name)| name).collect(),
1444        covariances: PatchCovariances {
1445            covariance,
1446            sensitivity,
1447            carried: propagated,
1448        },
1449        deviance: inner.iter().flatten().map(|i| i.deviance).sum(),
1450        converged,
1451        unconverged,
1452        steps,
1453    })
1454}
1455
1456type Swept = Result<Inner, PipelineError>;
1457
1458struct Inner {
1459    fit: RegionFit,
1460    deviance: f64,
1461    terms: RegionTerms,
1462}
1463
1464fn propagated(k: usize, interior: &[usize], inverse: &InformationInverse) -> FlatMatrix {
1465    let mut propagated = FlatMatrix::zeros(k, k);
1466    for (a, &i) in interior.iter().enumerate() {
1467        for (b, &j) in interior.iter().enumerate() {
1468            *propagated.get_mut(i, j) = inverse.determined.get(a, b);
1469        }
1470    }
1471    propagated
1472}
1473
1474fn marginal_entry(
1475    covariance: &FlatMatrix,
1476    sensitivity: &FlatMatrix,
1477    shared: &FlatMatrix,
1478    null: &[Vec<f64>],
1479    (i, j): (usize, usize),
1480) -> f64 {
1481    let coupled = |i: usize| moves_along(|s| sensitivity.get(i, s), null);
1482    if coupled(i) || coupled(j) {
1483        return f64::NAN;
1484    }
1485    let k = shared.nrows;
1486    covariance.get(i, j)
1487        + (0..k)
1488            .flat_map(|s| (0..k).map(move |t| (s, t)))
1489            .map(|(s, t)| sensitivity.get(i, s) * shared.get(s, t) * sensitivity.get(j, t))
1490            .sum::<f64>()
1491}
1492
1493fn moves_along(sensitivity: impl Fn(usize) -> f64, null: &[Vec<f64>]) -> bool {
1494    null.iter().any(|direction| {
1495        let terms: Vec<f64> = direction
1496            .iter()
1497            .enumerate()
1498            .map(|(s, &v)| sensitivity(s) * v)
1499            .collect();
1500        let magnitude: f64 = terms.iter().map(|t| t.abs()).sum();
1501        terms.iter().sum::<f64>().abs() > f64::EPSILON.sqrt() * magnitude
1502    })
1503}
1504
1505fn named(label: &str, error: PipelineError) -> PipelineError {
1506    match error {
1507        PipelineError::InvalidParameter(_) | PipelineError::ShapeMismatch(_) => {
1508            labelled(label, error)
1509        }
1510        other => PipelineError::InvalidParameter(format!("{label}: {other}")),
1511    }
1512}
1513
1514fn weight(fit: &RegionFit, run: usize) -> f64 {
1515    fit.overdispersion[run]
1516        .or(fit.overdispersion[0])
1517        .unwrap_or(1.0)
1518}
1519
1520fn restarted(region: &Region, previous: Option<&RegionFit>) -> Region {
1521    let mut region = region.clone();
1522    let Some(fit) = previous else {
1523        return region;
1524    };
1525    if let Some(material) = &mut region.material {
1526        for ((_, density), &at) in material.isotopes.iter_mut().zip(&fit.densities) {
1527            *density = restart(*density, at);
1528        }
1529        if let Some(t) = fit.temperature_k {
1530            material.temperature_k = restart(material.temperature_k, t);
1531        }
1532        if let Some(v) = fit.thickness_variance {
1533            material.thickness_variance = restart(material.thickness_variance, v);
1534        }
1535    }
1536    for (term, &at) in region.background.iter_mut().zip(&fit.background) {
1537        *term = restart(*term, at);
1538    }
1539    region.gamma_transmission = restart(region.gamma_transmission, fit.gamma_transmission);
1540    region.capture_gammas = restart(region.capture_gammas, fit.capture_gammas);
1541    region.in_scatter = restart(region.in_scatter, fit.in_scatter);
1542    region
1543}
1544
1545fn restart(value: Value, at: f64) -> Value {
1546    match value {
1547        Value::Fitted(_) => Value::Fitted(at),
1548        Value::Within { lower, upper, .. } => Value::Within {
1549            start: at,
1550            lower,
1551            upper,
1552        },
1553        Value::Known(_) | Value::Measured { .. } => value,
1554    }
1555}
1556
1557fn summed(
1558    map: &MapMeasurement<'_>,
1559    pixels: &[(usize, usize)],
1560) -> Result<[Vec<f64>; 2], PipelineError> {
1561    let bins = map.open_counts.dim().0;
1562    let mut sums = [vec![0.0; bins], vec![0.0; bins]];
1563    for ((run, counts), sum) in [
1564        ("open-beam", map.open_counts),
1565        ("sample", map.sample_counts),
1566    ]
1567    .into_iter()
1568    .zip(&mut sums)
1569    {
1570        for &(y, x) in pixels {
1571            for (k, (&count, total)) in counts
1572                .slice(s![.., y, x])
1573                .iter()
1574                .zip(sum.iter_mut())
1575                .enumerate()
1576            {
1577                if !whole(count) {
1578                    return Err(PipelineError::InvalidParameter(format!(
1579                        "{run} counts must be whole non-negative numbers, got {count} in bin \
1580                         {k} of pixel ({y}, {x})"
1581                    )));
1582                }
1583                *total += count;
1584            }
1585        }
1586    }
1587    Ok(sums)
1588}
1589
1590#[cfg(test)]
1591mod tests {
1592    use super::*;
1593
1594    #[test]
1595    fn a_patch_quantity_has_the_joint_fits_variance_under_an_oblique_shared_degeneracy() {
1596        let own = [1.0, 2.0, 0.0, 1.0, 3.0];
1597        let first = [1.0, 0.0, 1.0, 2.0, 1.0];
1598        for (column, resolved) in [(own, true), (first, false)] {
1599            let jacobian: Vec<[f64; 3]> = (0..5)
1600                .map(|row| [column[row], first[row], 2.0 * first[row]])
1601                .collect();
1602            let information =
1603                |a: usize, b: usize| -> f64 { jacobian.iter().map(|row| row[a] * row[b]).sum() };
1604            let joint = FlatMatrix {
1605                data: (0..9).map(|e| information(e / 3, e % 3)).collect(),
1606                nrows: 3,
1607                ncols: 3,
1608            };
1609            let diagonal: Vec<f64> = (0..3).map(|a| information(a, a)).collect();
1610            let oracle = information_inverse(&joint, &diagonal, 5).unwrap();
1611            let sensitivity = FlatMatrix {
1612                data: (1..3)
1613                    .map(|s| -information(0, s) / information(0, 0))
1614                    .collect(),
1615                nrows: 1,
1616                ncols: 2,
1617            };
1618            let profiled = FlatMatrix {
1619                data: (0..4)
1620                    .map(|e| {
1621                        let (s, t) = (1 + e / 2, 1 + e % 2);
1622                        information(s, t)
1623                            - information(s, 0) * information(0, t) / information(0, 0)
1624                    })
1625                    .collect(),
1626                nrows: 2,
1627                ncols: 2,
1628            };
1629            let shared = information_inverse(&profiled, &diagonal[1..], 5).unwrap();
1630            let own_covariance = FlatMatrix {
1631                data: vec![1.0 / information(0, 0)],
1632                nrows: 1,
1633                ncols: 1,
1634            };
1635            let variance = marginal_entry(
1636                &own_covariance,
1637                &sensitivity,
1638                &propagated(2, &[0, 1], &shared),
1639                &shared.undetermined,
1640                (0, 0),
1641            );
1642            assert_eq!(oracle.resolved[0], resolved);
1643            if resolved {
1644                let expected = oracle.determined.get(0, 0);
1645                assert!(
1646                    (variance / expected - 1.0).abs() <= 1e-9,
1647                    "{variance} vs {expected}"
1648                );
1649            } else {
1650                assert!(variance.is_nan(), "{variance}");
1651            }
1652        }
1653    }
1654
1655    #[test]
1656    fn a_quantity_moves_along_a_null_direction_unless_its_slopes_cancel_on_it() {
1657        let null = vec![vec![1.0, -2.0]];
1658        assert!(moves_along(|s| [1.0, 0.0][s], &null));
1659        assert!(!moves_along(|s| [2.0, 1.0][s], &null));
1660        assert!(!moves_along(|_| 0.0, &null));
1661    }
1662}