Skip to main content

nereids_pipeline/
pulse_calibration.rs

1//! A pulse calibrated on a foil, carried to experiments as a correlated
2//! prior on the pulse numbers the foil fitted.
3
4use nereids_core::types::Isotope;
5use nereids_endf::resonance::ResonanceData;
6use nereids_endf::retrieval::EndfLibrary;
7use nereids_fitting::lm::FlatMatrix;
8use nereids_fitting::poisson::Prior;
9use nereids_fitting::statistics::{Consistency, agreement};
10use nereids_physics::resolution::TOF_FACTOR;
11use nereids_physics::transmission::resonance_center_energies;
12use serde::{Deserialize, Serialize};
13
14use crate::counts_fit::TEMPERATURE_BOUNDS_K;
15use crate::counts_fit::{CountsFit, Material, Measurement, Region, Value, fit_counts, quantities};
16use crate::error::PipelineError;
17use crate::open_beam::{Calibration, PULSE_NUMBERS, Pulse};
18
19/// The pulse numbers a calibration fitted, as indices into
20/// `(α₀, α₁, β₀, β₁, R, h²)`, with the mean and covariance of its likelihood
21/// in them without their bounds.
22#[derive(Debug, Clone)]
23pub struct PulsePrior {
24    pub(crate) numbers: Vec<usize>,
25    pub(crate) mean: Vec<f64>,
26    pub(crate) covariance: FlatMatrix,
27}
28
29impl Pulse {
30    pub(crate) fn uncalibrated_line(
31        &self,
32        isotopes: &[(ResonanceData, Value)],
33        time_edges_us: &[f64],
34        t0_us: f64,
35        flight_path_m: f64,
36    ) -> Option<f64> {
37        let (low, high) = self.line_span_ev?;
38        let present = isotopes
39            .iter()
40            .filter(|(_, density)| *density != Value::Known(0.0))
41            .map(|(isotope, _)| isotope);
42        lines_in_window(present, time_edges_us, t0_us, flight_path_m)
43            .into_iter()
44            .find(|e| !(low..=high).contains(e))
45    }
46}
47
48const FORMAT_VERSION: u32 = 3;
49
50const PULSE_MODEL: &str = "Ikeda–Carpenter pulse with α = α₀√E + α₁ and β = β₀√E + β₁ in 1/µs, \
51     E in eV, a storage fraction R constant over the energy span, folded with the proton \
52     pulse's triangle of FWHM h";
53
54const NUMBERS: [(&str, &str); 6] = [
55    ("alpha0", "1/(µs·√eV)"),
56    ("alpha1", "1/µs"),
57    ("beta0", "1/(µs·√eV)"),
58    ("beta1", "1/µs"),
59    ("r", "1"),
60    ("fwhm_squared", "µs²"),
61];
62
63/// Where a calibration's counts came from: the identifiers of the physical
64/// foil and of its open-beam and sample runs, none empty and the two runs
65/// different.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Provenance {
68    pub foil: String,
69    pub open: String,
70    pub sample: String,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75enum Status {
76    Fitted,
77    Known,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82struct Stated {
83    value: f64,
84    #[serde(deserialize_with = "Option::deserialize")]
85    sd: Option<f64>,
86}
87
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90struct FoilIsotope {
91    #[serde(with = "nuclide")]
92    isotope: Isotope,
93    density: Stated,
94}
95
96mod nuclide {
97    use nereids_core::types::Isotope;
98    use serde::{Deserialize, Deserializer, Serialize, Serializer};
99
100    #[derive(Serialize, Deserialize)]
101    #[serde(deny_unknown_fields)]
102    struct Nuclide {
103        z: u32,
104        a: u32,
105    }
106
107    pub(super) fn serialize<S: Serializer>(isotope: &Isotope, s: S) -> Result<S::Ok, S::Error> {
108        Nuclide {
109            z: isotope.z(),
110            a: isotope.a(),
111        }
112        .serialize(s)
113    }
114
115    pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Isotope, D::Error> {
116        let nuclide = Nuclide::deserialize(d)?;
117        Isotope::new(nuclide.z, nuclide.a).map_err(serde::de::Error::custom)
118    }
119}
120
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123struct Foil {
124    isotopes: Vec<FoilIsotope>,
125    temperature_k: Stated,
126    library: EndfLibrary,
127}
128
129/// A pulse calibrated on a foil by [`fit_counts`], as its calibration file
130/// holds it.
131///
132/// A pulse number the calibration fitted keeps its fitted value and enters
133/// the prior; a number known in the calibration stays known.  The prior is
134/// the calibration's [`CountsFit::unbounded`] Gaussian in the fitted numbers,
135/// marginal over the other quantities, so a number that ended on a bound
136/// keeps its uncertainty and the experiment fit applies the bound.  The
137/// bounds of the calibration foil's other quantities do not reach the prior
138/// either: its mean and covariance are those without them.  When the
139/// calibration fitted a pulse number, an experiment's pulse carries the
140/// foil's [`line_span_ev`](Pulse::line_span_ev).  When an experiment ends a
141/// calibrated pulse number on its bound, or near it, its error bars on `t0`,
142/// the flight path and the pulse numbers are not standard errors; those on
143/// the densities and the temperature are.
144#[derive(Debug, Clone)]
145pub struct PulseCalibration {
146    numbers: [f64; 6],
147    status: [Status; 6],
148    prior: Option<PulsePrior>,
149    t0_us: f64,
150    flight_path_m: f64,
151    energy_span_ev: (f64, f64),
152    n_tau: usize,
153    line_span_ev: Option<(f64, f64)>,
154    foil: Foil,
155    provenance: Provenance,
156    sample_overdispersion: Option<f64>,
157    transfer: Option<TransferRecord>,
158}
159
160impl PulseCalibration {
161    /// The pulse calibrated by fitting the counts of a foil, `measurement`,
162    /// from `provenance`, with `calibration`, and that fit.  The measurement
163    /// is the foil alone, one region holding it.  Each isotope of the foil
164    /// not known to be absent (`Value::Known(0.0)`) has a measured density,
165    /// and the foil's effective temperature is measured.  The prior over the
166    /// pulse numbers takes the resonance parameters as exact.
167    ///
168    /// # Errors
169    /// [`PipelineError::InvalidParameter`] if the measurement is not one
170    /// region holding a material, a density or the temperature of the foil is
171    /// not measured, as above, an identifier of `provenance` is
172    /// empty or its runs are the same, or `calibration`'s pulse carries a
173    /// prior or a pulse number measured or boxed, since a calibration foil is
174    /// calibrated alone over the numbers' physical ranges, or the pulse has
175    /// an `R_high` or a detector tail, since the calibration records a storage
176    /// fraction constant in energy and no tail; everything [`fit_counts`]
177    /// refuses;
178    /// [`PipelineError::InvalidParameter`] if the fit did not converge, or
179    /// fitted a pulse number and gives no [`CountsFit::unbounded`] Gaussian, a
180    /// pulse number it fitted has no finite positive variance without its
181    /// bounds, as when the counts do not determine it, a number that ended on
182    /// a bound carries no information, or a fitted temperature ended at 1 K or
183    /// 5000 K,
184    /// or the fit fitted a pulse number and no isotope of the foil fitted or
185    /// known to a positive density has a resonance between the energies of its
186    /// last and first time edges at the fitted `t0` and flight path;
187    /// [`PipelineError::Fitting`] if the fitted numbers' covariance is refused
188    /// by [`Prior::correlated`].
189    pub fn new(
190        measurement: &Measurement,
191        calibration: &Calibration,
192        provenance: Provenance,
193    ) -> Result<(Self, CountsFit), PipelineError> {
194        let foil = foil(measurement)?;
195        check_provenance(&provenance)?;
196        let pulse = &calibration.pulse;
197        let measured = [pulse.r, pulse.fwhm_squared_us2]
198            .iter()
199            .chain(&pulse.alpha)
200            .chain(&pulse.beta)
201            .any(|value| matches!(value, Value::Measured { .. } | Value::Within { .. }));
202        if pulse.prior.is_some() || measured {
203            return Err(PipelineError::InvalidParameter(
204                "a calibration foil is calibrated alone, its pulse numbers fitted over their \
205                 physical ranges or known, with no pulse prior"
206                    .into(),
207            ));
208        }
209        if let Some(high) = pulse.r_high {
210            return Err(PipelineError::InvalidParameter(format!(
211                "a pulse calibration records a storage fraction constant in energy, so the \
212                 pulse has no R_high; got {high:?}"
213            )));
214        }
215        if pulse.detector_tail.is_some() {
216            return Err(PipelineError::InvalidParameter(
217                "a pulse calibration records a pulse without a detector tail".into(),
218            ));
219        }
220        if foil_material(measurement)?.lattice.is_some() {
221            return Err(PipelineError::InvalidParameter(
222                "a calibration foil is broadened by the free gas, without a lattice".into(),
223            ));
224        }
225        let fit = fit_counts(measurement, calibration)?;
226        let pulse = Self::from_fit(measurement, calibration, foil, provenance, &fit)?;
227        Ok((pulse, fit))
228    }
229
230    fn from_fit(
231        measurement: &Measurement,
232        calibration: &Calibration,
233        foil: Foil,
234        provenance: Provenance,
235        fit: &CountsFit,
236    ) -> Result<Self, PipelineError> {
237        let invalid = |message: String| Err(PipelineError::InvalidParameter(message));
238        if !fit.converged {
239            return invalid("a pulse calibration needs a converged fit".into());
240        }
241        let fitted: Vec<bool> = quantities(measurement, calibration)
242            .map(|(_, value)| !matches!(value, Value::Known(_)))
243            .collect();
244        let first_number = fitted.len() - PULSE_NUMBERS.len();
245        let mut status = [Status::Known; 6];
246        let mut covered = Vec::with_capacity(6);
247        for (number, name) in PULSE_NUMBERS.into_iter().take(NUMBERS.len()).enumerate() {
248            let quantity = first_number + number;
249            let i = fitted[..quantity].iter().filter(|&&f| f).count();
250            if !fitted[quantity] {
251                continue;
252            }
253            let Some(unbounded) = fit.unbounded.as_ref() else {
254                return invalid(
255                    "the calibration's fit gives no Gaussian without its bounds, as when a bin \
256                     predicted zero pulls a quantity on its bound"
257                        .into(),
258                );
259            };
260            let variance = unbounded.covariance.get(i, i);
261            if !(variance.is_finite() && variance > 0.0) {
262                return invalid(format!(
263                    "the calibration fitted {name} without determining it: its variance is \
264                     {variance}"
265                ));
266            }
267            status[number] = Status::Fitted;
268            covered.push((number, i));
269        }
270        let numbers = [
271            fit.alpha[0],
272            fit.alpha[1],
273            fit.beta[0],
274            fit.beta[1],
275            fit.r,
276            fit.fwhm_squared_us2,
277        ];
278        let line_span_ev = if fitted[first_number..].contains(&true) {
279            let present = foil_material(measurement)?
280                .isotopes
281                .iter()
282                .zip(&fit.regions[0].densities)
283                .filter(|(_, density)| **density > 0.0)
284                .map(|((isotope, _), _)| isotope);
285            let lines = lines_in_window(
286                present,
287                &measurement.time_edges_us,
288                fit.t0_us + fit.regions[0].timing_offset_us,
289                fit.flight_path_m,
290            );
291            if lines.is_empty() {
292                return invalid(
293                    "the calibration foil has no resonance in its window to bound the energies \
294                     its pulse holds over"
295                        .into(),
296                );
297            }
298            Some(
299                lines
300                    .iter()
301                    .fold((f64::INFINITY, f64::NEG_INFINITY), |(low, high), &e| {
302                        (low.min(e), high.max(e))
303                    }),
304            )
305        } else {
306            None
307        };
308        let prior = if let Some(unbounded) = fit.unbounded.as_ref().filter(|_| !covered.is_empty())
309        {
310            let n = covered.len();
311            let covariance = &unbounded.covariance;
312            let mut block = FlatMatrix::zeros(n, n);
313            for (a, &(_, i)) in covered.iter().enumerate() {
314                for (b, &(_, j)) in covered.iter().enumerate() {
315                    *block.get_mut(a, b) = 0.5 * (covariance.get(i, j) + covariance.get(j, i));
316                }
317            }
318            let numbers: Vec<usize> = covered.iter().map(|&(number, _)| number).collect();
319            let mean: Vec<f64> = covered.iter().map(|&(_, i)| unbounded.mean[i]).collect();
320            Prior::correlated(&numbers, &mean, &block)?;
321            Some(PulsePrior {
322                numbers,
323                mean,
324                covariance: block,
325            })
326        } else {
327            None
328        };
329        Ok(Self {
330            numbers,
331            status,
332            prior,
333            t0_us: fit.t0_us,
334            flight_path_m: fit.flight_path_m,
335            energy_span_ev: calibration.pulse.energy_span_ev,
336            n_tau: calibration.pulse.n_tau,
337            line_span_ev,
338            foil,
339            provenance,
340            sample_overdispersion: fit.regions[0].overdispersion[1],
341            transfer: None,
342        })
343    }
344
345    /// The agreement of this calibration's pulse with `other`'s, a physically
346    /// different foil calibrated alone: `d² = dᵀ(C_a + C_b)⁻¹d` over the pulse
347    /// numbers both fitted, with `d` the difference of their priors' means and
348    /// `C_a`, `C_b` their covariances, against `χ²` with as many degrees of
349    /// freedom.
350    ///
351    /// `d²` follows `χ²` when both foils see the same pulse, to the order the
352    /// priors are Gaussian.  Each run's information is divided by its
353    /// overdispersion, which is at least 1, so with Poisson counts the test is
354    /// conservative, and it weakens as the overdispersion grows.  Two foils
355    /// that share an open-beam run, or whose stated temperatures share an
356    /// error, are not independent, which the test does not account for.
357    ///
358    /// # Errors
359    /// [`PipelineError::InvalidParameter`] if the two calibrations name the
360    /// same foil, as the same foil re-measured tests only repeatability,
361    /// share a run other than the open-beam run, fit different pulse numbers
362    /// or know one at different values, or fit none;
363    /// [`PipelineError::Fitting`] if a decomposition fails.
364    pub fn transfer(&self, other: &PulseCalibration) -> Result<Transfer, PipelineError> {
365        let invalid = |message: String| Err(PipelineError::InvalidParameter(message));
366        if !separate(&self.provenance, &other.provenance) {
367            return invalid(format!(
368                "a transfer is to a physically different foil that shares no run but the \
369                 open-beam run; got {:?} and {:?}",
370                self.provenance, other.provenance
371            ));
372        }
373        for (n, name) in PULSE_NUMBERS.into_iter().take(NUMBERS.len()).enumerate() {
374            let (a, b) = (self.status[n], other.status[n]);
375            if a != b || (a == Status::Known && self.numbers[n] != other.numbers[n]) {
376                return invalid(format!(
377                    "the two calibrations hold {name} differently, {a:?} at {} and {b:?} at {}, \
378                     so they describe different pulse models",
379                    self.numbers[n], other.numbers[n]
380                ));
381            }
382        }
383        let (Some(this), Some(that)) = (&self.prior, &other.prior) else {
384            return invalid("the two calibrations fit no pulse number".into());
385        };
386        let prior = |p: &PulsePrior| Prior::correlated(&p.numbers, &p.mean, &p.covariance);
387        let result = agreement(&prior(this)?, &prior(that)?)?;
388        Ok(Transfer {
389            record: TransferRecord {
390                foil: other.foil.clone(),
391                provenance: FileProvenance::from(&other.provenance),
392                sample_overdispersion: other.sample_overdispersion,
393                d2: result.q,
394                dof: result.dof,
395                p: result.p,
396            },
397        })
398    }
399
400    /// This calibration with its [`Self::transfer`] to `other` recorded, in
401    /// place of "transfer unchecked" or an earlier transfer, when its `p` is
402    /// above 0.01.
403    ///
404    /// # Errors
405    /// Those of [`Self::transfer`]; [`PipelineError::InvalidParameter`] if `p`
406    /// is 0.01 or less: the pulse does not transfer, and the calibration is
407    /// consumed, since it is not to be written.
408    pub fn record_transfer(mut self, other: &PulseCalibration) -> Result<Self, PipelineError> {
409        let record = self.transfer(other)?.record;
410        if record.p <= TRANSFER_P {
411            return Err(PipelineError::InvalidParameter(format!(
412                "the pulse does not transfer: d² = {} on {} degrees of freedom gives p = {}, at \
413                 most {TRANSFER_P}; the calibration is not written",
414                record.d2, record.dof, record.p
415            )));
416        }
417        self.transfer = Some(record);
418        Ok(self)
419    }
420
421    /// The calibration of an experiment: `t0` and the flight path fitted from
422    /// the calibrated ones, the fitted pulse numbers fitted from theirs under
423    /// the calibration's prior, and the others known.
424    pub fn calibration(&self) -> Calibration {
425        let value = |number: usize| match self.status[number] {
426            Status::Fitted => Value::Fitted(self.numbers[number]),
427            Status::Known => Value::Known(self.numbers[number]),
428        };
429        Calibration {
430            t0_us: Value::Fitted(self.t0_us),
431            flight_path_m: Value::Fitted(self.flight_path_m),
432            pulse: Pulse {
433                alpha: [value(0), value(1)],
434                beta: [value(2), value(3)],
435                r: value(4),
436                fwhm_squared_us2: value(5),
437                r_high: None,
438                detector_tail: None,
439                energy_span_ev: self.energy_span_ev,
440                n_tau: self.n_tau,
441                line_span_ev: self.line_span_ev,
442                prior: self.prior.clone(),
443            },
444        }
445    }
446
447    /// The calibration file: JSON of format version 3 that names the pulse
448    /// model and, in the order `α₀, α₁, β₀, β₁, R, h²`, each pulse number with
449    /// its unit, value and status (fitted or known), then the prior over the
450    /// fitted numbers: its mean, which may lie past a number's bound, its
451    /// covariance and rank; then `t0` and the flight path, the pulse's energy
452    /// span and `n_tau`, the line span, the foil's isotopes and effective
453    /// temperature with their stated uncertainties and its library, the foil and run
454    /// identifiers, the sample run's overdispersion, and the transfer to
455    /// another foil: `"unchecked"`, or the other foil and its identifiers, its
456    /// sample run's overdispersion, `d2`, `dof` and `p`.
457    pub fn to_json(&self) -> String {
458        let covariance = self.prior.as_ref().map_or_else(Vec::new, |prior| {
459            let k = prior.numbers.len();
460            (0..k)
461                .map(|a| (0..k).map(|b| prior.covariance.get(a, b)).collect())
462                .collect()
463        });
464        let mean = self
465            .prior
466            .as_ref()
467            .map_or_else(Vec::new, |prior| prior.mean.clone());
468        let file = File {
469            format_version: FORMAT_VERSION,
470            pulse_model: PULSE_MODEL.into(),
471            numbers: NUMBERS
472                .iter()
473                .zip(self.numbers)
474                .zip(self.status)
475                .map(|((&(name, unit), value), status)| Number {
476                    name: name.into(),
477                    unit: unit.into(),
478                    value,
479                    status,
480                })
481                .collect(),
482            mean,
483            rank: covariance.len(),
484            covariance,
485            t0_us: self.t0_us,
486            flight_path_m: self.flight_path_m,
487            energy_span_ev: self.energy_span_ev,
488            n_tau: self.n_tau,
489            line_span_ev: self.line_span_ev,
490            foil: self.foil.clone(),
491            provenance: FileProvenance::from(&self.provenance),
492            sample_overdispersion: self.sample_overdispersion,
493            transfer: self.transfer.clone().map_or(
494                FileTransfer::Unchecked(Unchecked::Unchecked),
495                FileTransfer::Checked,
496            ),
497        };
498        serde_json::to_string_pretty(&file).expect("these field types always serialize")
499    }
500
501    /// The calibration a file from [`Self::to_json`] holds.
502    ///
503    /// # Errors
504    /// [`PipelineError::InvalidParameter`] if `text` is not such a file: not
505    /// format version 3 of this pulse model, a field missing or unknown, the
506    /// pulse numbers' names or units not in their order, the mean, the
507    /// covariance or the rank not over the fitted numbers, the line span
508    /// missing while a pulse number
509    /// was fitted, present when none was, or not `low ≤ high` within the
510    /// energy span, a provenance [`Self::new`] refuses, the overdispersion
511    /// below 1, the foil without isotopes, with one listed twice, or not as
512    /// [`Self::new`] takes it, a value outside its quantity's range or one
513    /// [`DetectorPulse::new`](nereids_physics::ikeda_carpenter::DetectorPulse::new)
514    /// refuses, or a recorded transfer [`Self::record_transfer`] could not
515    /// have written: to this calibration's foil or sharing a run other than
516    /// the open-beam run, with a
517    /// provenance or foil [`Self::new`] refuses, an overdispersion below 1,
518    /// degrees of freedom not the number of fitted numbers, `d2` negative, or
519    /// `p` not within a relative 1e-12 of the χ² survival of `d2` or 0.01 or
520    /// less; [`PipelineError::Fitting`] if [`Prior::correlated`] refuses the
521    /// mean or the covariance.
522    pub fn from_json(text: &str) -> Result<Self, PipelineError> {
523        let invalid = |message: String| Err(PipelineError::InvalidParameter(message));
524        let header: Header = match serde_json::from_str(text) {
525            Ok(header) => header,
526            Err(e) => return invalid(format!("not a pulse calibration file: {e}")),
527        };
528        if header.format_version != FORMAT_VERSION || header.pulse_model != PULSE_MODEL {
529            return invalid(format!(
530                "a pulse calibration file of format version {FORMAT_VERSION} and the pulse model \
531                 {PULSE_MODEL:?} is read; got version {} and the model {:?}",
532                header.format_version, header.pulse_model
533            ));
534        }
535        let file: File = match serde_json::from_str(text) {
536            Ok(file) => file,
537            Err(e) => return invalid(format!("not a pulse calibration file: {e}")),
538        };
539        if file.numbers.len() != NUMBERS.len()
540            || NUMBERS
541                .iter()
542                .zip(&file.numbers)
543                .any(|(&(name, unit), number)| number.name != name || number.unit != unit)
544        {
545            return invalid(format!(
546                "the pulse numbers are (name, unit) {NUMBERS:?}, in that order"
547            ));
548        }
549        let numbers: [f64; 6] = std::array::from_fn(|n| file.numbers[n].value);
550        let status: [Status; 6] = std::array::from_fn(|n| file.numbers[n].status);
551        let covered: Vec<usize> = (0..6).filter(|&n| status[n] == Status::Fitted).collect();
552        let k = covered.len();
553        if file.rank != k
554            || file.mean.len() != k
555            || file.covariance.len() != k
556            || file.covariance.iter().any(|row| row.len() != k)
557        {
558            return invalid(format!(
559                "the mean has {k} entries and the covariance is {k}×{k}, of rank {k}, over the {k} \
560                 fitted numbers; got rank {}, {} entries and {} rows",
561                file.rank,
562                file.mean.len(),
563                file.covariance.len()
564            ));
565        }
566        let fitted = k > 0;
567        let (first, last) = file.energy_span_ev;
568        let span_holds = match file.line_span_ev {
569            Some((low, high)) => fitted && first <= low && low <= high && high <= last,
570            None => !fitted,
571        };
572        if !span_holds {
573            return invalid(format!(
574                "the line span is present, within the energy span {:?}, exactly when a pulse \
575                 number was fitted; got {:?}",
576                file.energy_span_ev, file.line_span_ev
577            ));
578        }
579        let provenance = Provenance::from(file.provenance);
580        check_provenance(&provenance)?;
581        if file
582            .sample_overdispersion
583            .is_some_and(|phi| !(phi.is_finite() && phi >= 1.0))
584        {
585            return invalid(format!(
586                "the overdispersion is 1 or more; got {:?}",
587                file.sample_overdispersion
588            ));
589        }
590        check_foil(&file.foil)?;
591        let prior = if fitted {
592            let mut covariance = FlatMatrix::zeros(k, k);
593            for (a, row) in file.covariance.iter().enumerate() {
594                for (b, &entry) in row.iter().enumerate() {
595                    *covariance.get_mut(a, b) = entry;
596                }
597            }
598            Prior::correlated(&covered, &file.mean, &covariance)?;
599            Some(PulsePrior {
600                numbers: covered,
601                mean: file.mean,
602                covariance,
603            })
604        } else {
605            None
606        };
607        let transfer = match file.transfer {
608            FileTransfer::Unchecked(_) => None,
609            FileTransfer::Checked(record) => {
610                check_record(&record, &provenance, k)?;
611                Some(record)
612            }
613        };
614        let pulse = Self {
615            numbers,
616            status,
617            prior,
618            t0_us: file.t0_us,
619            flight_path_m: file.flight_path_m,
620            energy_span_ev: file.energy_span_ev,
621            n_tau: file.n_tau,
622            line_span_ev: file.line_span_ev,
623            foil: file.foil,
624            provenance,
625            sample_overdispersion: file.sample_overdispersion,
626            transfer,
627        };
628        let experiment = pulse.calibration();
629        let start: Vec<f64> = experiment
630            .instrument()?
631            .iter()
632            .map(|parameter| parameter.value)
633            .collect();
634        experiment.pulse.at(&start[2..])?;
635        Ok(pulse)
636    }
637}
638
639const TRANSFER_P: f64 = 0.01;
640
641/// The transfer of a pulse calibration to another, physically different foil
642/// calibrated alone, from [`PulseCalibration::transfer`].
643#[derive(Debug, Clone, PartialEq)]
644pub struct Transfer {
645    record: TransferRecord,
646}
647
648impl Transfer {
649    /// `d²`, its degrees of freedom and `p`.
650    pub fn agreement(&self) -> Consistency {
651        Consistency {
652            q: self.record.d2,
653            dof: self.record.dof,
654            p: self.record.p,
655        }
656    }
657}
658
659#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
660#[serde(untagged)]
661enum FileTransfer {
662    Unchecked(Unchecked),
663    Checked(TransferRecord),
664}
665
666#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
667#[serde(rename_all = "snake_case")]
668enum Unchecked {
669    Unchecked,
670}
671
672#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
673#[serde(deny_unknown_fields)]
674struct TransferRecord {
675    foil: Foil,
676    provenance: FileProvenance,
677    #[serde(deserialize_with = "Option::deserialize")]
678    sample_overdispersion: Option<f64>,
679    d2: f64,
680    dof: usize,
681    p: f64,
682}
683
684#[derive(Deserialize)]
685struct Header {
686    format_version: u32,
687    pulse_model: String,
688}
689
690#[derive(Serialize, Deserialize)]
691#[serde(deny_unknown_fields)]
692struct File {
693    format_version: u32,
694    pulse_model: String,
695    numbers: Vec<Number>,
696    mean: Vec<f64>,
697    covariance: Vec<Vec<f64>>,
698    rank: usize,
699    t0_us: f64,
700    flight_path_m: f64,
701    energy_span_ev: (f64, f64),
702    n_tau: usize,
703    #[serde(deserialize_with = "Option::deserialize")]
704    line_span_ev: Option<(f64, f64)>,
705    foil: Foil,
706    provenance: FileProvenance,
707    #[serde(deserialize_with = "Option::deserialize")]
708    sample_overdispersion: Option<f64>,
709    transfer: FileTransfer,
710}
711
712#[derive(Serialize, Deserialize)]
713#[serde(deny_unknown_fields)]
714struct Number {
715    name: String,
716    unit: String,
717    value: f64,
718    status: Status,
719}
720
721#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
722#[serde(deny_unknown_fields)]
723struct FileProvenance {
724    foil: String,
725    open: String,
726    sample: String,
727}
728
729impl From<&Provenance> for FileProvenance {
730    fn from(provenance: &Provenance) -> Self {
731        Self {
732            foil: provenance.foil.clone(),
733            open: provenance.open.clone(),
734            sample: provenance.sample.clone(),
735        }
736    }
737}
738
739impl From<FileProvenance> for Provenance {
740    fn from(file: FileProvenance) -> Self {
741        Self {
742            foil: file.foil,
743            open: file.open,
744            sample: file.sample,
745        }
746    }
747}
748
749fn check_record(
750    record: &TransferRecord,
751    provenance: &Provenance,
752    rank: usize,
753) -> Result<(), PipelineError> {
754    let other = Provenance::from(record.provenance.clone());
755    check_provenance(&other)?;
756    check_foil(&record.foil)?;
757    let statistic = record.dof == rank
758        && Consistency::new(record.d2, record.dof)
759            .is_ok_and(|test| (record.p / test.p - 1.0).abs() <= 1e-12)
760        && record.p > TRANSFER_P;
761    if !separate(provenance, &other)
762        || record
763            .sample_overdispersion
764            .is_some_and(|phi| !(phi.is_finite() && phi >= 1.0))
765        || !statistic
766    {
767        return Err(PipelineError::InvalidParameter(format!(
768            "a recorded transfer is to another foil sharing no run but the open-beam run, on as \
769             many degrees of freedom as numbers fitted, with d² of 0 or more and p its χ² \
770             survival and above {TRANSFER_P}; got {record:?}"
771        )));
772    }
773    Ok(())
774}
775
776fn separate(a: &Provenance, b: &Provenance) -> bool {
777    a.foil != b.foil && a.sample != b.sample && a.sample != b.open && a.open != b.sample
778}
779
780fn check_provenance(provenance: &Provenance) -> Result<(), PipelineError> {
781    if provenance.foil.is_empty()
782        || provenance.open.is_empty()
783        || provenance.sample.is_empty()
784        || provenance.open == provenance.sample
785    {
786        return Err(PipelineError::InvalidParameter(format!(
787            "a calibration names its foil and its open-beam and sample runs, the two runs \
788             different; got {provenance:?}"
789        )));
790    }
791    Ok(())
792}
793
794fn foil_material(measurement: &Measurement) -> Result<&Material, PipelineError> {
795    let [
796        Region {
797            material: Some(material),
798            ..
799        },
800    ] = measurement.regions.as_slice()
801    else {
802        return Err(PipelineError::InvalidParameter(
803            "a pulse calibration fits the foil alone, as one region holding it".into(),
804        ));
805    };
806    Ok(material)
807}
808
809fn foil(measurement: &Measurement) -> Result<Foil, PipelineError> {
810    let material = foil_material(measurement)?;
811    let unmeasured = |what: &str, value: &Value| {
812        Err(PipelineError::InvalidParameter(format!(
813            "a calibration foil's {what} is measured, with its stated uncertainty; got {value:?}"
814        )))
815    };
816    let mut isotopes = Vec::with_capacity(material.isotopes.len());
817    for (data, density) in &material.isotopes {
818        let density = match *density {
819            Value::Measured { value, sd } => Stated {
820                value,
821                sd: Some(sd),
822            },
823            Value::Known(value) if value == 0.0 => Stated { value, sd: None },
824            other => return unmeasured(&format!("density of {}", data.isotope), &other),
825        };
826        isotopes.push(FoilIsotope {
827            isotope: data.isotope,
828            density,
829        });
830    }
831    let Value::Measured { value, sd } = material.temperature_k else {
832        return unmeasured("temperature", &material.temperature_k);
833    };
834    Ok(Foil {
835        isotopes,
836        temperature_k: Stated {
837            value,
838            sd: Some(sd),
839        },
840        library: material.library,
841    })
842}
843
844fn check_foil(foil: &Foil) -> Result<(), PipelineError> {
845    let value = |stated: Stated| match stated.sd {
846        Some(sd) => Value::Measured {
847            value: stated.value,
848            sd,
849        },
850        None => Value::Known(stated.value),
851    };
852    let isotopes = &foil.isotopes;
853    if isotopes.is_empty()
854        || isotopes
855            .iter()
856            .any(|isotope| isotope.density.sd.is_none() && isotope.density.value != 0.0)
857        || (0..isotopes.len()).any(|i| {
858            isotopes[..i]
859                .iter()
860                .any(|other| other.isotope == isotopes[i].isotope)
861        })
862        || foil.temperature_k.sd.is_none()
863    {
864        return Err(PipelineError::InvalidParameter(format!(
865            "a calibration foil has distinct isotopes, each measured or known to be absent, and \
866             a measured temperature; got {foil:?}"
867        )));
868    }
869    for isotope in &foil.isotopes {
870        value(isotope.density).parameter("density", 0.0..=f64::INFINITY, "0 or more")?;
871    }
872    let (low, high) = TEMPERATURE_BOUNDS_K;
873    value(foil.temperature_k).parameter(
874        "temperature",
875        low..=high,
876        &format!("within {low}–{high} K"),
877    )?;
878    Ok(())
879}
880
881fn lines_in_window<'a>(
882    isotopes: impl Iterator<Item = &'a ResonanceData>,
883    time_edges_us: &[f64],
884    t0_us: f64,
885    flight_path_m: f64,
886) -> Vec<f64> {
887    let energy = |t: f64| (TOF_FACTOR * flight_path_m / (t - t0_us).max(0.0)).powi(2);
888    let window = energy(time_edges_us[time_edges_us.len() - 1])..=energy(time_edges_us[0]);
889    isotopes
890        .flat_map(|isotope| resonance_center_energies(&[isotope]))
891        .filter(|e| window.contains(e))
892        .collect()
893}
894
895#[cfg(test)]
896mod tests {
897    use nereids_endf::resonance::test_support::{synthetic_isotope, synthetic_isotope_multi};
898
899    use nereids_fitting::poisson::Unbounded;
900
901    use super::*;
902    use crate::beam::BeamSpline;
903    use crate::counts_fit::{NO_TIME_BACKGROUND, RegionFit};
904
905    type Edit = (&'static str, fn(&mut serde_json::Value));
906
907    fn measurement(absent: Option<f64>) -> Measurement {
908        let lines = [(10.0, 0.05, 0.06), (25.0, 0.005, 0.06), (50.0, 0.01, 0.06)];
909        let foil = (
910            synthetic_isotope_multi(73, 181, &lines),
911            Value::Measured {
912                value: 2e-3,
913                sd: 2e-5,
914            },
915        );
916        let impurity = absent.map(|energy| {
917            (
918                synthetic_isotope(74, 184, energy, 0.01, 0.06),
919                Value::Known(0.0),
920            )
921        });
922        let edges: Vec<f64> = (0..=45).map(|i| 240.0 + 8.0 * f64::from(i)).collect();
923        let bins = edges.len() - 1;
924        Measurement {
925            time_edges_us: edges,
926            charge_ratio: 1.0,
927            normalization: Value::Known(1.0),
928            time_background: NO_TIME_BACKGROUND,
929            room_background: None,
930            spread: None,
931            regions: vec![Region {
932                open_counts: vec![100.0; bins],
933                sample_counts: vec![100.0; bins],
934                open_live: None,
935                sample_live: None,
936                background: [Value::Known(0.0); 5],
937                pixels: 1.0,
938                gamma_transmission: Value::Known(1.0),
939                capture_gammas: Value::Known(0.0),
940                in_scatter: Value::Known(0.0),
941                timing_offset_us: Value::Known(0.0),
942                material: Some(Material {
943                    covariances: Vec::new(),
944                    library: EndfLibrary::EndfB8_0,
945                    isotopes: std::iter::once(foil).chain(impurity).collect(),
946                    temperature_k: Value::Measured {
947                        value: 300.0,
948                        sd: 10.0,
949                    },
950                    thickness_variance: Value::Known(0.0),
951                    lattice: None,
952                }),
953            }],
954        }
955    }
956
957    fn material(m: &mut Measurement) -> &mut Material {
958        m.regions[0].material.as_mut().expect("the foil")
959    }
960
961    fn provenance() -> Provenance {
962        Provenance {
963            foil: "foil-a".into(),
964            open: "open-1".into(),
965            sample: "sample-1".into(),
966        }
967    }
968
969    fn calibrated(
970        m: &Measurement,
971        c: &Calibration,
972        fit: CountsFit,
973    ) -> Result<PulseCalibration, PipelineError> {
974        PulseCalibration::from_fit(m, c, foil(m)?, provenance(), &fit)
975    }
976
977    fn calibration(beta1: Value) -> Calibration {
978        Calibration {
979            t0_us: Value::Fitted(3.0),
980            flight_path_m: Value::Fitted(25.0),
981            pulse: Pulse {
982                alpha: [Value::Known(0.5), Value::Fitted(1.0)],
983                beta: [Value::Fitted(0.08), beta1],
984                r: Value::Fitted(0.2),
985                fwhm_squared_us2: Value::Fitted(0.1),
986                r_high: None,
987                detector_tail: None,
988                energy_span_ev: (1.0, 200.0),
989                n_tau: 256,
990                line_span_ev: None,
991                prior: None,
992            },
993        }
994    }
995
996    fn fit(free: usize) -> CountsFit {
997        let mut covariance = FlatMatrix::zeros(free, free);
998        for i in 0..free {
999            *covariance.get_mut(i, i) = 0.01 * (i + 1) as f64;
1000        }
1001        let (r, h) = (free - 2, free - 1);
1002        let pairs = [(4, r, 0.01), (4, h, -0.005), (r, h, 0.02), (2, 4, 0.003)];
1003        for (i, j, c) in pairs.into_iter().filter(|&(i, j, _)| i.max(j) < free) {
1004            *covariance.get_mut(i, j) = c;
1005            *covariance.get_mut(j, i) = c;
1006        }
1007        let unbounded = Unbounded {
1008            mean: (0..free).map(|i| -0.002 * (i + 1) as f64).collect(),
1009            covariance: FlatMatrix {
1010                data: covariance.data.iter().map(|c| 2.0 * c).collect(),
1011                ..covariance.clone()
1012            },
1013        };
1014        CountsFit {
1015            regions: vec![RegionFit {
1016                excluded_bins: Vec::new(),
1017                densities: vec![2e-3],
1018                temperature_k: Some(300.0),
1019                thickness_variance: Some(0.0),
1020                background: [0.0; 5],
1021                gamma_transmission: 1.0,
1022                capture_gammas: 0.0,
1023                in_scatter: 0.0,
1024                timing_offset_us: 0.0,
1025                beam: BeamSpline::constant(200.0, 600.0, 1.0),
1026                beam_at_limit: false,
1027                predicted: [Vec::new(), Vec::new()],
1028                overdispersion: [None; 2],
1029            }],
1030            normalization: 1.0,
1031            t0_us: 3.0,
1032            flight_path_m: 25.0,
1033            alpha: [0.5, 1.1],
1034            beta: [0.0, 0.01],
1035            r: 0.21,
1036            fwhm_squared_us2: 0.12,
1037            r_high: None,
1038            detector_tail: None,
1039            time_background: [0.0, 0.0, 1.0],
1040            room_background: 0.0,
1041            spread_share: 0.0,
1042            covariance: Some(covariance),
1043            unbounded: Some(unbounded),
1044            on_bound: vec![false; free],
1045            deviance: 0.0,
1046            converged: true,
1047            measured_pulls: None,
1048            pulse_consistency: None,
1049            step_us: 0.1,
1050            points: 10,
1051            halvings: 0,
1052        }
1053    }
1054
1055    #[test]
1056    fn a_calibration_keeps_every_fitted_number_with_its_unbounded_uncertainty() {
1057        let (m, c) = (measurement(Some(55.0)), calibration(Value::Known(0.01)));
1058        let mut with_impurity = fit(8);
1059        with_impurity.regions[0].densities = vec![2e-3, 0.0];
1060        let experiment = calibrated(&m, &c, with_impurity).unwrap().calibration();
1061        let pulse = &experiment.pulse;
1062        assert_eq!(pulse.alpha, [Value::Known(0.5), Value::Fitted(1.1)]);
1063        assert_eq!(pulse.beta, [Value::Fitted(0.0), Value::Known(0.01)]);
1064        assert_eq!(
1065            [pulse.r, pulse.fwhm_squared_us2],
1066            [Value::Fitted(0.21), Value::Fitted(0.12)]
1067        );
1068        assert_eq!(
1069            [experiment.t0_us, experiment.flight_path_m],
1070            [Value::Fitted(3.0), Value::Fitted(25.0)]
1071        );
1072        let prior = pulse.prior.as_ref().expect("prior");
1073        assert_eq!(prior.numbers, [1, 2, 4, 5]);
1074        let unbounded = fit(8).unbounded.expect("unbounded");
1075        let at = [4, 5, 6, 7];
1076        assert_eq!(prior.mean, at.map(|i| unbounded.mean[i]));
1077        for (a, i) in at.into_iter().enumerate() {
1078            for (b, j) in at.into_iter().enumerate() {
1079                assert_eq!(prior.covariance.get(a, b), unbounded.covariance.get(i, j));
1080            }
1081        }
1082        assert_eq!(pulse.line_span_ev, Some((10.0, 50.0)));
1083
1084        let mut undetermined = fit(8);
1085        let mut withheld = fit(8);
1086        let covariance = &mut undetermined
1087            .unbounded
1088            .as_mut()
1089            .expect("unbounded")
1090            .covariance;
1091        for i in 0..8 {
1092            *covariance.get_mut(5, i) = f64::NAN;
1093            *covariance.get_mut(i, 5) = f64::NAN;
1094        }
1095        withheld
1096            .unbounded
1097            .as_mut()
1098            .expect("unbounded")
1099            .covariance
1100            .data
1101            .fill(f64::NAN);
1102        for (refused, refusal) in [
1103            (undetermined, "without determining it"),
1104            (withheld, "without determining it"),
1105            (
1106                CountsFit {
1107                    converged: false,
1108                    ..fit(8)
1109                },
1110                "converged fit",
1111            ),
1112            (
1113                CountsFit {
1114                    unbounded: None,
1115                    ..fit(8)
1116                },
1117                "no Gaussian without its bounds",
1118            ),
1119        ] {
1120            match calibrated(&m, &c, refused) {
1121                Err(PipelineError::InvalidParameter(message)) => {
1122                    assert!(message.contains(refusal), "{message}")
1123                }
1124                other => panic!("{other:?}"),
1125            }
1126        }
1127    }
1128
1129    #[test]
1130    fn a_calibration_without_lines_or_with_a_singular_block_is_refused() {
1131        let c = calibration(Value::Known(0.01));
1132        let mut beyond = measurement(None);
1133        material(&mut beyond).isotopes[0].0 = synthetic_isotope(73, 181, 100.0, 0.05, 0.06);
1134        assert!(matches!(
1135            calibrated(&beyond, &c, fit(8)),
1136            Err(PipelineError::InvalidParameter(_))
1137        ));
1138        let mut singular = fit(8);
1139        let covariance = &mut singular.unbounded.as_mut().expect("unbounded").covariance;
1140        let (a, b) = (covariance.get(4, 4), covariance.get(6, 6));
1141        *covariance.get_mut(4, 6) = (a * b).sqrt();
1142        *covariance.get_mut(6, 4) = (a * b).sqrt();
1143        assert!(matches!(
1144            calibrated(&measurement(None), &c, singular),
1145            Err(PipelineError::Fitting(_))
1146        ));
1147    }
1148
1149    #[test]
1150    fn only_isotopes_that_may_be_present_must_lie_within_the_calibrated_lines() {
1151        let pulse = Pulse {
1152            line_span_ev: Some((10.0, 50.0)),
1153            ..calibration(Value::Known(0.01)).pulse
1154        };
1155        let mut m = measurement(Some(55.0));
1156        let line = |m: &Measurement| {
1157            let isotopes = &foil_material(m).expect("the foil").isotopes;
1158            pulse.uncalibrated_line(isotopes, &m.time_edges_us, 3.0, 25.0)
1159        };
1160        assert_eq!(line(&m), None);
1161        material(&mut m).isotopes[1].1 = Value::Fitted(1e-4);
1162        assert_eq!(line(&m), Some(55.0));
1163    }
1164
1165    #[test]
1166    fn a_calibration_carries_its_line_span_exactly_when_it_fits_a_number() {
1167        let c = calibration(Value::Known(0.01));
1168        let moderated = CountsFit {
1169            beta: [0.08, 0.01],
1170            ..fit(8)
1171        };
1172        let fitted = calibrated(&measurement(None), &c, moderated.clone())
1173            .unwrap()
1174            .calibration();
1175        assert_eq!(fitted.pulse.line_span_ev, Some((10.0, 50.0)));
1176        let mut earlier = moderated;
1177        earlier.regions[0].timing_offset_us = -20.0;
1178        let earlier = calibrated(&measurement(None), &c, earlier)
1179            .unwrap()
1180            .calibration();
1181        assert_eq!(earlier.pulse.line_span_ev, Some((10.0, 25.0)));
1182        let mut wider = measurement(Some(55.0));
1183        material(&mut wider).isotopes[1].1 = Value::Fitted(1e-4);
1184        match fit_counts(&wider, &fitted) {
1185            Err(PipelineError::InvalidParameter(message)) => {
1186                assert!(message.contains("55 eV, outside the"), "{message}")
1187            }
1188            other => panic!("{other:?}"),
1189        }
1190
1191        let mut known = c;
1192        let pulse = &mut known.pulse;
1193        pulse.alpha[1] = Value::Known(1.0);
1194        pulse.beta[0] = Value::Known(0.08);
1195        pulse.r = Value::Known(0.2);
1196        pulse.fwhm_squared_us2 = Value::Known(0.1);
1197        let without = CountsFit {
1198            unbounded: None,
1199            ..fit(4)
1200        };
1201        let unfitted = calibrated(&measurement(None), &known, without)
1202            .unwrap()
1203            .calibration();
1204        assert_eq!(unfitted.pulse.line_span_ev, None);
1205    }
1206
1207    fn original() -> PulseCalibration {
1208        let mut fit = CountsFit {
1209            r: 0.012_537_345_881_063_615,
1210            ..fit(8)
1211        };
1212        fit.regions[0].overdispersion = [Some(1.25), Some(1.5)];
1213        calibrated(
1214            &measurement(Some(55.0)),
1215            &calibration(Value::Known(0.01)),
1216            fit,
1217        )
1218        .unwrap()
1219    }
1220
1221    fn file() -> String {
1222        original().to_json()
1223    }
1224
1225    #[test]
1226    fn a_calibration_reads_back_from_its_file_bit_for_bit() {
1227        let original = original();
1228        let text = original.to_json();
1229        let read = PulseCalibration::from_json(&text).unwrap();
1230        assert_eq!(format!("{read:?}"), format!("{original:?}"));
1231        assert_eq!(read.to_json(), text);
1232        assert_eq!(
1233            read.numbers[4].to_bits(),
1234            0.012_537_345_881_063_615_f64.to_bits()
1235        );
1236        for expected in [
1237            "\"format_version\": 3",
1238            "\"name\": \"alpha0\"",
1239            "\"unit\": \"1/(µs·√eV)\"",
1240            "\"status\": \"fitted\"",
1241            "\"rank\": 4",
1242            "\"sample_overdispersion\": 1.5",
1243            "\"transfer\": \"unchecked\"",
1244        ] {
1245            assert!(text.contains(expected), "{expected} in {text}");
1246        }
1247    }
1248
1249    #[test]
1250    fn files_that_are_not_such_a_calibration_are_refused() {
1251        let original: serde_json::Value = serde_json::from_str(&file()).unwrap();
1252        let edits: [Edit; 22] = [
1253            ("version", |v| v["format_version"] = 1.into()),
1254            ("mean", |v| {
1255                v["mean"].as_array_mut().unwrap().pop();
1256            }),
1257            ("model", |v| v["pulse_model"] = "Gaussian".into()),
1258            ("transfer", |v| v["transfer"] = "checked".into()),
1259            ("unknown field", |v| v["comment"] = "added".into()),
1260            ("name", |v| v["numbers"][1]["name"] = "alpha2".into()),
1261            ("unit", |v| v["numbers"][2]["unit"] = "1/µs".into()),
1262            ("rank", |v| v["rank"] = 2.into()),
1263            ("row", |v| {
1264                v["covariance"].as_array_mut().unwrap().pop();
1265            }),
1266            ("span missing", |v| {
1267                v["line_span_ev"] = serde_json::Value::Null
1268            }),
1269            ("span reversed", |v| {
1270                v["line_span_ev"] = serde_json::json!([50.0, 10.0])
1271            }),
1272            ("runs", |v| v["provenance"]["sample"] = "open-1".into()),
1273            ("overdispersion", |v| {
1274                v["sample_overdispersion"] = 0.5.into()
1275            }),
1276            ("negative rate", |v| {
1277                v["numbers"][0]["value"] = (-0.01).into()
1278            }),
1279            ("missing key", |v| {
1280                v.as_object_mut().unwrap().remove("sample_overdispersion");
1281            }),
1282            ("nuclide key", |v| {
1283                v["foil"]["isotopes"][0]["isotope"]["n"] = 1.into();
1284            }),
1285            ("same isotope", |v| {
1286                let isotope = v["foil"]["isotopes"][0].clone();
1287                v["foil"]["isotopes"].as_array_mut().unwrap().push(isotope);
1288            }),
1289            ("unknown library", |v| {
1290                v["foil"]["library"] = "ENDF/B-IX".into()
1291            }),
1292            ("temperature sd", |v| {
1293                v["foil"]["temperature_k"]["sd"] = serde_json::Value::Null;
1294            }),
1295            ("span beyond the pulse", |v| {
1296                v["line_span_ev"] = serde_json::json!([10.0, 300.0]);
1297            }),
1298            ("singular covariance", |v| {
1299                let c = &v["covariance"];
1300                let r = (c[0][0].as_f64().unwrap() * c[1][1].as_f64().unwrap()).sqrt();
1301                v["covariance"][0][1] = r.into();
1302                v["covariance"][1][0] = r.into();
1303            }),
1304            ("n_tau", |v| v["n_tau"] = 4.into()),
1305        ];
1306        for (what, edit) in edits {
1307            let mut value = original.clone();
1308            edit(&mut value);
1309            assert!(
1310                PulseCalibration::from_json(&value.to_string()).is_err(),
1311                "{what}"
1312            );
1313        }
1314        assert!(PulseCalibration::from_json(&original.to_string()).is_ok());
1315        let mut known = calibration(Value::Known(0.01));
1316        let pulse = &mut known.pulse;
1317        pulse.alpha[1] = Value::Known(1.1);
1318        pulse.beta[0] = Value::Known(0.0);
1319        pulse.r = Value::Known(0.21);
1320        pulse.fwhm_squared_us2 = Value::Known(0.12);
1321        let unfitted = calibrated(&measurement(None), &known, fit(4)).unwrap();
1322        let mut stray: serde_json::Value = serde_json::from_str(&unfitted.to_json()).unwrap();
1323        assert!(PulseCalibration::from_json(&stray.to_string()).is_ok());
1324        stray["mean"] = serde_json::json!([1.0]);
1325        assert!(PulseCalibration::from_json(&stray.to_string()).is_err());
1326        match PulseCalibration::from_json(r#"{"format_version": 1, "pulse_model": "", "runs": {}}"#)
1327        {
1328            Err(PipelineError::InvalidParameter(message)) => {
1329                assert!(message.contains("got version 1"), "{message}")
1330            }
1331            other => panic!("{other:?}"),
1332        }
1333    }
1334
1335    #[test]
1336    fn a_foil_whose_density_or_temperature_is_not_measured_is_refused() {
1337        let c = calibration(Value::Known(0.01));
1338        let mut fitted = measurement(None);
1339        material(&mut fitted).isotopes[0].1 = Value::Fitted(2e-3);
1340        let mut warm = measurement(None);
1341        material(&mut warm).temperature_k = Value::Fitted(300.0);
1342        for m in [fitted, warm] {
1343            match PulseCalibration::new(&m, &c, provenance()) {
1344                Err(PipelineError::InvalidParameter(message)) => {
1345                    assert!(message.contains("is measured"), "{message}")
1346                }
1347                other => panic!("{other:?}"),
1348            }
1349        }
1350        let mut two = measurement(None);
1351        two.regions.push(two.regions[0].clone());
1352        let mut empty = measurement(None);
1353        empty.regions[0].material = None;
1354        for m in [two, empty] {
1355            match PulseCalibration::new(&m, &c, provenance()) {
1356                Err(PipelineError::InvalidParameter(message)) => {
1357                    assert!(message.contains("the foil alone"), "{message}")
1358                }
1359                other => panic!("{other:?}"),
1360            }
1361        }
1362        let same = Provenance {
1363            open: "sample-1".into(),
1364            ..provenance()
1365        };
1366        let unnamed = Provenance {
1367            foil: String::new(),
1368            ..provenance()
1369        };
1370        let mut measured = c.clone();
1371        measured.pulse.r = Value::Measured {
1372            value: 0.2,
1373            sd: 0.01,
1374        };
1375        let mut boxed = c.clone();
1376        boxed.pulse.r = Value::Within {
1377            start: 0.2,
1378            lower: 0.15,
1379            upper: 0.25,
1380        };
1381        let mut rising = c.clone();
1382        rising.pulse.r_high = Some(Value::Fitted(0.3));
1383        let mut tailed = c.clone();
1384        tailed.pulse.detector_tail = Some([Value::Fitted(0.1), Value::Known(2.0)]);
1385        for (c, provenance, refusal) in [
1386            (&c, same, "names its foil"),
1387            (&c, unnamed, "names its foil"),
1388            (&original().calibration(), provenance(), "calibrated alone"),
1389            (&measured, provenance(), "calibrated alone"),
1390            (&boxed, provenance(), "calibrated alone"),
1391            (&rising, provenance(), "no R_high"),
1392            (&tailed, provenance(), "without a detector tail"),
1393        ] {
1394            match PulseCalibration::new(&measurement(None), c, provenance) {
1395                Err(PipelineError::InvalidParameter(message)) => {
1396                    assert!(message.contains(refusal), "{message}")
1397                }
1398                other => panic!("{other:?}"),
1399            }
1400        }
1401        let mut lattice = measurement(None);
1402        material(&mut lattice).lattice = Some(
1403            nereids_physics::lattice_doppler::PhononSpectrum::debye(240.0, 100).expect("Debye"),
1404        );
1405        match PulseCalibration::new(&lattice, &c, provenance()) {
1406            Err(PipelineError::InvalidParameter(message)) => {
1407                assert!(message.contains("without a lattice"), "{message}")
1408            }
1409            other => panic!("{other:?}"),
1410        }
1411    }
1412
1413    const VERSION_3: &str = r#"{
1414  "format_version": 3,
1415  "pulse_model": "Ikeda–Carpenter pulse with α = α₀√E + α₁ and β = β₀√E + β₁ in 1/µs, E in eV, a storage fraction R constant over the energy span, folded with the proton pulse's triangle of FWHM h",
1416  "numbers": [
1417    {
1418      "name": "alpha0",
1419      "unit": "1/(µs·√eV)",
1420      "value": 0.5,
1421      "status": "known"
1422    },
1423    {
1424      "name": "alpha1",
1425      "unit": "1/µs",
1426      "value": 1.1,
1427      "status": "fitted"
1428    },
1429    {
1430      "name": "beta0",
1431      "unit": "1/(µs·√eV)",
1432      "value": 0.0,
1433      "status": "fitted"
1434    },
1435    {
1436      "name": "beta1",
1437      "unit": "1/µs",
1438      "value": 0.01,
1439      "status": "known"
1440    },
1441    {
1442      "name": "r",
1443      "unit": "1",
1444      "value": 0.012537345881063615,
1445      "status": "fitted"
1446    },
1447    {
1448      "name": "fwhm_squared",
1449      "unit": "µs²",
1450      "value": 0.12,
1451      "status": "fitted"
1452    }
1453  ],
1454  "mean": [
1455    -0.01,
1456    -0.012,
1457    -0.014,
1458    -0.016
1459  ],
1460  "covariance": [
1461    [
1462      0.1,
1463      0.0,
1464      0.02,
1465      -0.01
1466    ],
1467    [
1468      0.0,
1469      0.12,
1470      0.0,
1471      0.0
1472    ],
1473    [
1474      0.02,
1475      0.0,
1476      0.14,
1477      0.04
1478    ],
1479    [
1480      -0.01,
1481      0.0,
1482      0.04,
1483      0.16
1484    ]
1485  ],
1486  "rank": 4,
1487  "t0_us": 3.0,
1488  "flight_path_m": 25.0,
1489  "energy_span_ev": [
1490    1.0,
1491    200.0
1492  ],
1493  "n_tau": 256,
1494  "line_span_ev": [
1495    10.0,
1496    50.0
1497  ],
1498  "foil": {
1499    "isotopes": [
1500      {
1501        "isotope": {
1502          "z": 73,
1503          "a": 181
1504        },
1505        "density": {
1506          "value": 0.002,
1507          "sd": 0.00002
1508        }
1509      },
1510      {
1511        "isotope": {
1512          "z": 74,
1513          "a": 184
1514        },
1515        "density": {
1516          "value": 0.0,
1517          "sd": null
1518        }
1519      }
1520    ],
1521    "temperature_k": {
1522      "value": 300.0,
1523      "sd": 10.0
1524    },
1525    "library": "ENDF/B-VIII.0"
1526  },
1527  "provenance": {
1528    "foil": "foil-a",
1529    "open": "open-1",
1530    "sample": "sample-1"
1531  },
1532  "sample_overdispersion": 1.5,
1533  "transfer": "unchecked"
1534}"#;
1535
1536    #[test]
1537    fn the_file_is_format_version_3_byte_for_byte() {
1538        let original = original();
1539        assert_eq!(original.to_json(), VERSION_3);
1540        let read = PulseCalibration::from_json(VERSION_3).unwrap();
1541        assert_eq!(format!("{read:?}"), format!("{original:?}"));
1542    }
1543
1544    fn other_foil(density: f64) -> Measurement {
1545        let mut m = measurement(None);
1546        material(&mut m).isotopes[0].1 = Value::Measured {
1547            value: density,
1548            sd: 0.01 * density,
1549        };
1550        m
1551    }
1552
1553    fn other_provenance() -> Provenance {
1554        Provenance {
1555            foil: "foil-b".into(),
1556            open: "open-2".into(),
1557            sample: "sample-2".into(),
1558        }
1559    }
1560
1561    fn other(alpha1: f64, alpha0: f64) -> PulseCalibration {
1562        let m = other_foil(3e-3);
1563        let mut fit = CountsFit {
1564            alpha: [alpha0, alpha1],
1565            beta: [0.004, 0.01],
1566            r: 0.205,
1567            fwhm_squared_us2: 0.11,
1568            ..fit(8)
1569        };
1570        let unbounded = fit.unbounded.as_mut().expect("unbounded");
1571        unbounded.mean[4] += alpha1 - 1.1;
1572        for (i, j, c) in [(4, 5, 0.02), (5, 6, 0.015)] {
1573            *unbounded.covariance.get_mut(i, j) = c;
1574            *unbounded.covariance.get_mut(j, i) = c;
1575        }
1576        let c = calibration(Value::Known(0.01));
1577        PulseCalibration::from_fit(&m, &c, foil(&m).unwrap(), other_provenance(), &fit).unwrap()
1578    }
1579
1580    fn solve(mut m: Vec<Vec<f64>>, mut v: Vec<f64>) -> Vec<f64> {
1581        let k = v.len();
1582        for i in 0..k {
1583            let pivot = m[i].clone();
1584            for r in i + 1..k {
1585                let factor = m[r][i] / pivot[i];
1586                for (entry, above) in m[r].iter_mut().zip(&pivot).skip(i) {
1587                    *entry -= factor * above;
1588                }
1589                v[r] -= factor * v[i];
1590            }
1591        }
1592        for i in (0..k).rev() {
1593            v[i] = (v[i] - (i + 1..k).map(|c| m[i][c] * v[c]).sum::<f64>()) / m[i][i];
1594        }
1595        v
1596    }
1597
1598    #[test]
1599    fn a_transfer_compares_every_number_both_foils_fitted() {
1600        let (a, b) = (original(), other(1.15, 0.5));
1601        let result = a.transfer(&b).unwrap().agreement();
1602        let (pa, pb) = (a.prior.as_ref().unwrap(), b.prior.as_ref().unwrap());
1603        let k = pa.numbers.len();
1604        let d: Vec<f64> = (0..k).map(|i| pa.mean[i] - pb.mean[i]).collect();
1605        let sum = (0..k)
1606            .map(|i| {
1607                (0..k)
1608                    .map(|j| pa.covariance.get(i, j) + pb.covariance.get(i, j))
1609                    .collect()
1610            })
1611            .collect();
1612        let q: f64 = d
1613            .iter()
1614            .zip(solve(sum, d.clone()))
1615            .map(|(x, y)| x * y)
1616            .sum();
1617        assert!(q > 0.0);
1618        assert_eq!(result.dof, 4);
1619        assert!((result.q / q - 1.0).abs() <= 1e-12, "{} vs {q}", result.q);
1620        assert_eq!(result, Consistency::new(result.q, 4).unwrap());
1621    }
1622
1623    #[test]
1624    fn a_transfer_needs_another_foil_with_the_same_pulse_model() {
1625        let a = original();
1626        let mut same_foil = other(1.15, 0.5);
1627        same_foil.provenance.foil = "foil-a".into();
1628        let mut shared = other(1.15, 0.5);
1629        shared.provenance.sample = "sample-1".into();
1630        let mut open_as_sample = other(1.15, 0.5);
1631        open_as_sample.provenance.sample = "open-1".into();
1632        let mut sample_as_open = other(1.15, 0.5);
1633        sample_as_open.provenance.open = "sample-1".into();
1634        let m = other_foil(3e-3);
1635        let calibrated_other = |c: &Calibration, fit: CountsFit| {
1636            PulseCalibration::from_fit(&m, c, foil(&m).unwrap(), other_provenance(), &fit).unwrap()
1637        };
1638        let mut known_r = calibration(Value::Known(0.01));
1639        known_r.pulse.r = Value::Known(0.21);
1640        let mut known = known_r.clone();
1641        let pulse = &mut known.pulse;
1642        pulse.alpha[1] = Value::Known(1.1);
1643        pulse.beta[0] = Value::Known(0.0);
1644        pulse.fwhm_squared_us2 = Value::Known(0.12);
1645        let unfitted = calibrated(&measurement(None), &known, fit(4)).unwrap();
1646        for (a, b, refusal) in [
1647            (&a, same_foil, "physically different foil"),
1648            (&a, shared, "physically different foil"),
1649            (&a, open_as_sample, "physically different foil"),
1650            (&a, sample_as_open, "physically different foil"),
1651            (&a, other(1.15, 0.6), "different pulse models"),
1652            (
1653                &a,
1654                calibrated_other(&known_r, fit(7)),
1655                "different pulse models",
1656            ),
1657            (
1658                &unfitted,
1659                calibrated_other(&known, fit(4)),
1660                "fit no pulse number",
1661            ),
1662        ] {
1663            match a.transfer(&b) {
1664                Err(PipelineError::InvalidParameter(message)) => {
1665                    assert!(message.contains(refusal), "{message}")
1666                }
1667                other => panic!("{other:?}"),
1668            }
1669        }
1670    }
1671
1672    #[test]
1673    fn only_a_transfer_that_passes_is_recorded_and_read_back() {
1674        let failing = other(3.0, 0.5);
1675        assert!(original().transfer(&failing).unwrap().agreement().p <= 0.01);
1676        match original().record_transfer(&failing) {
1677            Err(PipelineError::InvalidParameter(message)) => {
1678                assert!(message.contains("does not transfer"), "{message}")
1679            }
1680            other => panic!("{other:?}"),
1681        }
1682
1683        let b = other(1.15, 0.5);
1684        let a = original().record_transfer(&b).unwrap();
1685        assert_eq!(a.transfer, Some(original().transfer(&b).unwrap().record));
1686        let text = a.to_json();
1687        let read = PulseCalibration::from_json(&text).unwrap();
1688        assert_eq!(format!("{read:?}"), format!("{a:?}"));
1689        assert_eq!(read.to_json(), text);
1690
1691        let original: serde_json::Value = serde_json::from_str(&text).unwrap();
1692        let mut rounded = original.clone();
1693        let p = rounded["transfer"]["p"].as_f64().unwrap();
1694        rounded["transfer"]["p"] = (p * (1.0 + 1e-13)).into();
1695        assert!(PulseCalibration::from_json(&rounded.to_string()).is_ok());
1696        let edits: [Edit; 10] = [
1697            ("p not its d2's", |v| v["transfer"]["p"] = 0.5.into()),
1698            ("failed", |v| {
1699                v["transfer"]["d2"] = 50.0.into();
1700                v["transfer"]["p"] = Consistency::new(50.0, 4).unwrap().p.into();
1701            }),
1702            ("dof not the numbers fitted", |v| {
1703                let d2 = v["transfer"]["d2"].as_f64().unwrap();
1704                v["transfer"]["dof"] = 3.into();
1705                v["transfer"]["p"] = Consistency::new(d2, 3).unwrap().p.into();
1706            }),
1707            ("same foil", |v| {
1708                v["transfer"]["provenance"]["foil"] = v["provenance"]["foil"].clone();
1709            }),
1710            ("same sample run", |v| {
1711                v["transfer"]["provenance"]["sample"] = v["provenance"]["sample"].clone();
1712            }),
1713            ("this open run as the other's sample", |v| {
1714                v["transfer"]["provenance"]["sample"] = v["provenance"]["open"].clone();
1715            }),
1716            ("this sample run as the other's open", |v| {
1717                v["transfer"]["provenance"]["open"] = v["provenance"]["sample"].clone();
1718            }),
1719            ("other overdispersion", |v| {
1720                v["transfer"]["sample_overdispersion"] = 0.5.into();
1721            }),
1722            ("other runs the same", |v| {
1723                v["transfer"]["provenance"]["open"] = v["transfer"]["provenance"]["sample"].clone();
1724            }),
1725            ("other foil", |v| {
1726                v["transfer"]["foil"]["isotopes"] = serde_json::json!([]);
1727            }),
1728        ];
1729        for (what, edit) in edits {
1730            let mut value = original.clone();
1731            edit(&mut value);
1732            assert!(
1733                PulseCalibration::from_json(&value.to_string()).is_err(),
1734                "{what}"
1735            );
1736        }
1737    }
1738}