1use 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#[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#[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#[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 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 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 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 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 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 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#[derive(Debug, Clone, PartialEq)]
644pub struct Transfer {
645 record: TransferRecord,
646}
647
648impl Transfer {
649 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}