1use std::borrow::Cow;
6use std::cell::RefCell;
7use std::ops::RangeInclusive;
8use std::sync::Arc;
9
10use nereids_endf::resonance::ResonanceData;
11use nereids_fitting::error::FittingError;
12use nereids_fitting::lm::{FitModel, FlatMatrix};
13use nereids_fitting::parameters::{FitParameter, ParameterSet};
14use nereids_physics::continuous_doppler::{SUPPORT_X, broaden_with_derivative};
15use nereids_physics::doppler::DopplerParams;
16use nereids_physics::flight_time_grid::FlightTimeGrid;
17use nereids_physics::resolution::TOF_FACTOR;
18use nereids_physics::transmission::resonance_center_energies;
19use rayon::prelude::*;
20
21use crate::beam::BeamSpline;
22use crate::error::PipelineError;
23use crate::open_beam::{
24 Calibration, OpenBeamModel, Recorded, fit_on_halved_grids, fit_open_beam, overdispersion,
25 validate_counts, validate_live,
26};
27use crate::pipeline::TEMPERATURE_BOUNDS_K;
28
29pub const NEGLIGIBLE_PREDICTION: f64 = 1e-10;
32
33#[derive(Debug, Clone, Copy, PartialEq)]
37pub enum Value {
38 Known(f64),
39 Fitted(f64),
40 Within { start: f64, lower: f64, upper: f64 },
41}
42
43impl Value {
44 fn parameter(
45 self,
46 name: impl Into<Cow<'static, str>>,
47 range: RangeInclusive<f64>,
48 allowed: &str,
49 ) -> Result<FitParameter, PipelineError> {
50 let name = name.into();
51 let (value, lower, upper) = match self {
52 Self::Known(v) | Self::Fitted(v) => (v, *range.start(), *range.end()),
53 Self::Within {
54 start,
55 lower,
56 upper,
57 } => (start, lower, upper),
58 };
59 if !(value.is_finite()
60 && range.contains(&lower)
61 && lower < upper
62 && range.contains(&upper)
63 && (lower..=upper).contains(&value))
64 {
65 return Err(PipelineError::InvalidParameter(match self {
66 Self::Within { .. } => format!(
67 "{name} must be {allowed}, with bounds lower < upper and a finite start \
68 between them; got {self:?}"
69 ),
70 _ => format!("{name} must be finite and {allowed}; got {self:?}"),
71 }));
72 }
73 Ok(FitParameter {
74 name,
75 value,
76 lower,
77 upper,
78 fixed: matches!(self, Self::Known(_)),
79 })
80 }
81}
82
83#[derive(Debug, Clone)]
85pub struct Measurement {
86 pub time_edges_us: Vec<f64>,
88 pub open_counts: Vec<f64>,
90 pub sample_counts: Vec<f64>,
92 pub open_live: Option<Vec<f64>>,
95 pub sample_live: Option<Vec<f64>>,
98 pub charge_ratio: f64,
100 pub normalization: Value,
102 pub background: [Value; 3],
105 pub isotopes: Vec<(ResonanceData, Value)>,
108 pub temperature_k: Value,
110}
111
112#[derive(Debug, Clone)]
114pub struct CountsFit {
115 pub densities: Vec<f64>,
118 pub temperature_k: f64,
120 pub normalization: f64,
122 pub background: [f64; 3],
125 pub covariance: Option<FlatMatrix>,
140 pub on_bound: Vec<bool>,
143 pub beam: BeamSpline,
146 pub beam_at_limit: bool,
149 pub deviance: f64,
151 pub converged: bool,
153 pub overdispersion: Option<f64>,
158 pub step_us: f64,
160 pub points: usize,
162 pub halvings: usize,
166}
167
168pub fn fit_counts(
237 measurement: &Measurement,
238 calibration: &Calibration,
239) -> Result<CountsFit, PipelineError> {
240 let Measurement {
241 time_edges_us,
242 open_counts,
243 sample_counts,
244 open_live,
245 sample_live,
246 charge_ratio,
247 normalization,
248 background,
249 isotopes,
250 temperature_k,
251 } = measurement;
252 let invalid = |message: String| Err(PipelineError::InvalidParameter(message));
253 if !(charge_ratio.is_finite() && *charge_ratio > 0.0) {
254 return invalid(format!(
255 "the charge ratio must be finite and positive, got {charge_ratio}"
256 ));
257 }
258 let normalization = normalization.parameter(
259 "normalization",
260 f64::MIN_POSITIVE..=f64::INFINITY,
261 "positive",
262 )?;
263 let background = ["b0", "b1", "b2"]
264 .into_iter()
265 .zip(background)
266 .map(|(name, term)| term.parameter(name, f64::NEG_INFINITY..=f64::INFINITY, "of any sign"))
267 .collect::<Result<Vec<FitParameter>, PipelineError>>()?;
268 if isotopes.is_empty() {
269 return invalid("the sample has no isotopes".into());
270 }
271 let (t_low, t_high) = TEMPERATURE_BOUNDS_K;
272 let temperature = temperature_k.parameter(
273 "temperature",
274 t_low..=t_high,
275 &format!("within {t_low}–{t_high} K"),
276 )?;
277 let reach_k = if temperature.fixed {
278 temperature.value
279 } else {
280 temperature.upper
281 };
282 let mut dopplers = Vec::with_capacity(isotopes.len());
283 let mut densities = Vec::with_capacity(isotopes.len());
284 for (i, (isotope, density)) in isotopes.iter().enumerate() {
285 if isotopes[..i]
286 .iter()
287 .any(|(other, _)| other.za == isotope.za)
288 {
289 return invalid(format!(
290 "{} is listed twice; its densities cannot be told apart",
291 isotope.isotope
292 ));
293 }
294 densities.push(density.parameter(
295 format!("density of {}", isotope.isotope),
296 0.0..=f64::INFINITY,
297 "0 or more",
298 )?);
299 if !finite(isotope) {
300 return invalid(format!(
301 "the resonance data of {} are not finite",
302 isotope.isotope
303 ));
304 }
305 dopplers.push(
306 DopplerParams::new(reach_k, isotope.awr)
307 .map_err(|e| PipelineError::InvalidParameter(e.to_string()))?,
308 );
309 }
310 let grid = FlightTimeGrid::new(time_edges_us, calibration.t0_us, &calibration.pulse)?;
311 let bins = time_edges_us.len() - 1;
312 validate_counts("open-beam", open_counts, bins)?;
313 validate_counts("sample", sample_counts, bins)?;
314 let open_live = validate_live("open-beam", open_live.as_deref(), bins)?;
315 let sample_live = validate_live("sample", sample_live.as_deref(), bins)?;
316
317 let energies = grid.energies_ev();
318 let span_ev = (energies[energies.len() - 1], energies[0]);
319 let mut resonances_in_span = Vec::with_capacity(isotopes.len());
320 for ((isotope, _), doppler) in isotopes.iter().zip(&dopplers) {
321 let read = (
322 (span_ev.0.sqrt() - SUPPORT_X * doppler.u())
323 .max(0.0)
324 .powi(2),
325 (span_ev.1.sqrt() + SUPPORT_X * doppler.u()).powi(2),
326 );
327 if !isotope.ranges.iter().any(|range| {
328 range.is_evaluable() && range.energy_low <= read.0 && read.1 <= range.energy_high
329 }) {
330 return invalid(format!(
331 "the Doppler-broadened cross section of {} reads {:.6e}–{:.6e} eV, which no \
332 single one of its evaluated (SLBW, MLBW or Reich–Moore) resolved ranges holds",
333 isotope.isotope, read.0, read.1
334 ));
335 }
336 resonances_in_span.push(
337 resonance_center_energies(&[isotope])
338 .into_iter()
339 .filter(|e| (span_ev.0..=span_ev.1).contains(e))
340 .collect::<Vec<f64>>(),
341 );
342 }
343 let clock = TOF_FACTOR * calibration.pulse.flight_path_m();
344 let half_maximum = 2.0 * std::f64::consts::LN_2.sqrt();
345 let narrowest_us = |temperature_k: f64| -> Result<f64, PipelineError> {
346 let mut narrowest = f64::INFINITY;
347 for ((isotope, _), energies) in isotopes.iter().zip(&resonances_in_span) {
348 let doppler = DopplerParams::new(temperature_k, isotope.awr)
349 .map_err(|e| PipelineError::InvalidParameter(e.to_string()))?;
350 for &energy in energies {
351 let width_ev = half_maximum * doppler.doppler_width(energy);
352 narrowest = narrowest.min(clock / energy.sqrt() * width_ev / (2.0 * energy));
353 }
354 }
355 Ok(narrowest)
356 };
357 let first_grid = |temperature_k: f64| -> Result<(Arc<FlightTimeGrid>, usize), PipelineError> {
358 let rule_us = 0.5 * narrowest_us(temperature_k)?;
359 let mut first = grid.clone();
360 let mut halvings = 0;
361 while first.step_us() > rule_us {
362 first = first.halved()?;
363 halvings += 1;
364 }
365 Ok((Arc::new(first), halvings))
366 };
367
368 let open = fit_open_beam(time_edges_us, open_counts, calibration, Some(&open_live))?;
369 let layout = Layout::new(open.beam.coefficients().len(), isotopes.len());
370 let mut parameters = ParameterSet::new(
371 open.beam
372 .coefficients()
373 .iter()
374 .enumerate()
375 .map(|(i, &c)| FitParameter::unbounded(format!("beam {i}"), c))
376 .chain(densities)
377 .chain([temperature, normalization])
378 .chain(background)
379 .collect(),
380 );
381 let resonances: Arc<[ResonanceData]> = isotopes.iter().map(|(data, _)| data.clone()).collect();
382 let observed: Vec<f64> = open_counts.iter().chain(sample_counts).copied().collect();
383 let live: Vec<f64> = open_live.into_iter().chain(sample_live).collect();
384 let mut rule_k = parameters.params[layout.temperature].value;
385 let (fit, rule_halvings) = loop {
386 let (first, rule_halvings) = first_grid(rule_k)?;
387 let fit = fit_on_halved_grids(&first, &mut parameters, &observed, |grid| {
388 Ok(Recorded {
389 model: TwoRunModel::new(grid, &open.beam, &resonances, *charge_ratio),
390 live: &live,
391 })
392 })?;
393 let fitted_k = fit.result.params[layout.temperature];
394 if !fit.converged || 2.0 * fit.step_us <= 0.5 * narrowest_us(fitted_k)? {
395 break (fit, rule_halvings);
396 }
397 rule_k = fitted_k;
398 };
399
400 if let Some((k, (&counts, &predicted))) =
401 observed
402 .iter()
403 .zip(&fit.predicted)
404 .enumerate()
405 .find(|(_, (y, mu))| {
406 !mu.is_finite() || **mu < 0.0 || (**y > 0.0 && **mu < NEGLIGIBLE_PREDICTION)
407 })
408 {
409 let run = if k < bins { "open-beam" } else { "sample" };
410 return Err(PipelineError::UnmodelledCounts {
411 run,
412 bin: k % bins,
413 counts,
414 predicted,
415 });
416 }
417
418 let overdispersion = overdispersion(&observed, &fit);
419 let free = parameters.free_indices();
420 let on_edge = free.contains(&layout.temperature)
421 && [t_low, t_high].contains(&fit.result.params[layout.temperature]);
422 let scale = if on_edge {
423 f64::NAN
424 } else {
425 overdispersion.unwrap_or(1.0)
426 };
427 let sample_quantities: Vec<usize> = (0..free.len())
428 .filter(|&p| free[p] >= layout.densities)
429 .collect();
430 let covariance = fit
431 .result
432 .covariance
433 .as_ref()
434 .filter(|_| fit.converged)
435 .map(|full| {
436 let size = sample_quantities.len();
437 let mut block = FlatMatrix::zeros(size, size);
438 for (a, &p) in sample_quantities.iter().enumerate() {
439 for (b, &q) in sample_quantities.iter().enumerate() {
440 *block.get_mut(a, b) = scale * full.get(p, q);
441 }
442 }
443 block
444 });
445 let params = &fit.result.params;
446 Ok(CountsFit {
447 densities: params[layout.densities..layout.temperature].to_vec(),
448 temperature_k: params[layout.temperature],
449 normalization: params[layout.normalization],
450 background: [0, 1, 2].map(|i| params[layout.background + i]),
451 covariance,
452 on_bound: sample_quantities
453 .iter()
454 .map(|&p| fit.result.on_bound[p])
455 .collect(),
456 beam: open.beam.with_coefficients(¶ms[..layout.densities]),
457 beam_at_limit: open.at_limit,
458 deviance: fit.result.deviance,
459 converged: fit.converged,
460 overdispersion,
461 step_us: fit.step_us,
462 points: fit.points,
463 halvings: rule_halvings + fit.halvings,
464 })
465}
466
467fn finite(isotope: &ResonanceData) -> bool {
468 isotope.awr.is_finite()
469 && isotope.ranges.iter().all(|range| {
470 let radii = range
471 .ap_table
472 .iter()
473 .flat_map(|table| table.points.iter().flat_map(|&(e, r)| [e, r]));
474 let external = range.r_external.iter().flat_map(|r| {
475 [
476 r.j, r.e_low, r.e_up, r.r_con, r.r_lin, r.s_con, r.s_lin, r.r_quad,
477 ]
478 });
479 let groups = range.l_groups.iter().flat_map(|group| {
480 [group.awr, group.apl, group.qx].into_iter().chain(
481 group
482 .resonances
483 .iter()
484 .flat_map(|r| [r.energy, r.j, r.gn, r.gg, r.gfa, r.gfb]),
485 )
486 });
487 [
488 range.energy_low,
489 range.energy_high,
490 range.target_spin,
491 range.scattering_radius,
492 ]
493 .into_iter()
494 .chain(radii)
495 .chain(external)
496 .chain(groups)
497 .all(f64::is_finite)
498 })
499}
500
501#[derive(Clone, Copy)]
502struct Layout {
503 densities: usize,
504 temperature: usize,
505 normalization: usize,
506 background: usize,
507}
508
509impl Layout {
510 fn new(beam_coefficients: usize, isotopes: usize) -> Self {
511 let temperature = beam_coefficients + isotopes;
512 Self {
513 densities: beam_coefficients,
514 temperature,
515 normalization: temperature + 1,
516 background: temperature + 2,
517 }
518 }
519}
520
521struct TwoRunModel {
522 beam: OpenBeamModel,
523 isotopes: Arc<[ResonanceData]>,
524 energies: Vec<f64>,
525 shapes: [Vec<f64>; 3],
526 charge_ratio: f64,
527 layout: Layout,
528 cross_sections: RefCell<Option<CrossSections>>,
529}
530
531struct Beams {
532 open: Vec<f64>,
533 normalized: Vec<f64>,
534 transmitted: Vec<f64>,
535 sample: Vec<f64>,
536}
537
538struct CrossSections {
539 temperature_k: f64,
540 values: Vec<Vec<f64>>,
541 slopes: Vec<Vec<f64>>,
542}
543
544impl TwoRunModel {
545 fn new(
546 grid: &Arc<FlightTimeGrid>,
547 beam: &BeamSpline,
548 isotopes: &Arc<[ResonanceData]>,
549 charge_ratio: f64,
550 ) -> Self {
551 let mut energies = grid.energies_ev();
552 let shapes = [
553 vec![1.0; energies.len()],
554 energies.iter().map(|e| 1.0 / e.sqrt()).collect(),
555 energies.iter().map(|e| e.sqrt()).collect(),
556 ];
557 energies.reverse();
558 Self {
559 beam: OpenBeamModel::new(grid, beam),
560 isotopes: Arc::clone(isotopes),
561 energies,
562 shapes,
563 charge_ratio,
564 layout: Layout::new(beam.coefficients().len(), isotopes.len()),
565 cross_sections: RefCell::new(None),
566 }
567 }
568
569 fn at(&self, temperature_k: f64) -> Result<std::cell::Ref<'_, CrossSections>, FittingError> {
570 let current = self
571 .cross_sections
572 .borrow()
573 .as_ref()
574 .is_some_and(|c| c.temperature_k.to_bits() == temperature_k.to_bits());
575 if !current {
576 let energies = &self.energies;
577 let (values, slopes) = self
578 .isotopes
579 .par_iter()
580 .map(|isotope| {
581 let (mut sigma, mut slope) =
582 broaden_with_derivative(energies, isotope, temperature_k)
583 .map_err(|e| FittingError::EvaluationFailed(e.to_string()))?;
584 sigma.reverse();
585 slope.reverse();
586 Ok((sigma, slope))
587 })
588 .collect::<Result<Vec<_>, FittingError>>()?
589 .into_iter()
590 .unzip();
591 *self.cross_sections.borrow_mut() = Some(CrossSections {
592 temperature_k,
593 values,
594 slopes,
595 });
596 }
597 Ok(std::cell::Ref::map(self.cross_sections.borrow(), |c| {
598 c.as_ref().expect("computed above")
599 }))
600 }
601
602 fn beams(&self, params: &[f64]) -> Result<Beams, FittingError> {
603 let layout = self.layout;
604 let densities = ¶ms[layout.densities..layout.temperature];
605 let sigma = self.at(params[layout.temperature])?;
606 let open = self.beam.beam(¶ms[..layout.densities]);
607 let normalized: Vec<f64> = open
608 .iter()
609 .map(|phi| self.charge_ratio * params[layout.normalization] * phi)
610 .collect();
611 let (transmitted, sample) = normalized
612 .iter()
613 .enumerate()
614 .map(|(j, beam)| {
615 let depth: f64 = densities
616 .iter()
617 .zip(&sigma.values)
618 .map(|(n, sigma)| n * sigma[j])
619 .sum();
620 let background: f64 = params[layout.background..]
621 .iter()
622 .zip(&self.shapes)
623 .map(|(b, g)| b * g[j])
624 .sum();
625 (beam * (-depth).exp(), beam * ((-depth).exp() + background))
626 })
627 .unzip();
628 Ok(Beams {
629 open,
630 normalized,
631 transmitted,
632 sample,
633 })
634 }
635}
636
637impl FitModel for TwoRunModel {
638 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
639 let Beams { open, sample, .. } = self.beams(params)?;
640 let mut counts = self.beam.counts(&open)?;
641 counts.extend(self.beam.counts(&sample)?);
642 Ok(counts)
643 }
644
645 fn analytical_jacobian(
646 &self,
647 params: &[f64],
648 free_param_indices: &[usize],
649 y_current: &[f64],
650 ) -> Option<FlatMatrix> {
651 let Beams {
652 open,
653 normalized,
654 transmitted,
655 sample,
656 } = self.beams(params).ok()?;
657 let layout = self.layout;
658 let sigma = self.at(params[layout.temperature]).ok()?;
659 let mut jacobian = FlatMatrix::zeros(y_current.len(), free_param_indices.len());
660 for (col, &index) in free_param_indices.iter().enumerate() {
661 let (open_slope, (base, sample_slope)) = if index < layout.densities {
662 let slope = self.beam.log_slope(index);
663 (slope.clone(), (&sample, slope))
664 } else if index < layout.temperature {
665 let values = &sigma.values[index - layout.densities];
666 let slope = values.iter().map(|s| -s).collect();
667 (vec![0.0; open.len()], (&transmitted, slope))
668 } else if index == layout.temperature {
669 let broadening: Vec<f64> = (0..open.len())
670 .map(|j| {
671 -params[layout.densities..layout.temperature]
672 .iter()
673 .zip(&sigma.slopes)
674 .map(|(n, slope)| n * slope[j])
675 .sum::<f64>()
676 })
677 .collect();
678 (vec![0.0; open.len()], (&transmitted, broadening))
679 } else if index == layout.normalization {
680 let slope = vec![1.0 / params[index]; open.len()];
681 (vec![0.0; open.len()], (&sample, slope))
682 } else {
683 let shape = self.shapes[index - layout.background].clone();
684 (vec![0.0; open.len()], (&normalized, shape))
685 };
686 let times = |beam: &[f64], slope: &[f64]| -> Vec<f64> {
687 beam.iter().zip(slope).map(|(b, s)| b * s).collect()
688 };
689 let open_column = self.beam.counts(×(&open, &open_slope)).ok()?;
690 let sample_column = self.beam.counts(×(base, &sample_slope)).ok()?;
691 for (row, value) in open_column.into_iter().chain(sample_column).enumerate() {
692 *jacobian.get_mut(row, col) = value;
693 }
694 }
695 Some(jacobian)
696 }
697}
698
699#[cfg(test)]
700mod tests {
701 use nereids_endf::resonance::test_support::synthetic_isotope;
702
703 use super::*;
704 use crate::open_beam::tests::{ALPHA, BETA, EDGES_US, FLIGHT_PATH_M, R, T0_US, grid};
705
706 #[test]
707 fn the_jacobian_is_the_slope_of_both_runs_counts() {
708 let grid = grid(None);
709 let (_, u_hi) = grid.range_us();
710 let beam = BeamSpline::constant(347.0, u_hi, 1.0e4).refined().refined();
711 let isotopes: Arc<[ResonanceData]> = Arc::new([
712 synthetic_isotope(72, 180, 20.0, 0.01, 0.06),
713 synthetic_isotope(74, 182, 20.3, 0.01, 0.06),
714 ]);
715 let params: Vec<f64> = (0..beam.coefficients().len())
716 .map(|i| 9.0 + 0.3 * (i as f64).sin())
717 .chain([3.0e-4, 5.0e-4, 300.0, 0.93, 0.05, 0.5, -0.01])
718 .collect();
719 let model = TwoRunModel::new(&grid, &beam, &isotopes, 1.2);
720 let live: Vec<f64> = (0..model.evaluate(¶ms).expect("counts").len())
721 .map(|k| 0.9 + 0.1 * (0.3 * k as f64).sin())
722 .collect();
723 let model = Recorded { model, live: &live };
724 let counts = model.evaluate(¶ms).expect("counts");
725 let mut colder = params.clone();
726 colder[model.model.layout.temperature] = 250.0;
727 model.evaluate(&colder).expect("counts");
728 let indices: Vec<usize> = (0..params.len()).collect();
729 let jacobian = model
730 .analytical_jacobian(¶ms, &indices, &counts)
731 .expect("jacobian");
732 for index in indices {
733 let h = 1e-4 * params[index].abs();
734 let shifted = |d: f64| {
735 let mut p = params.clone();
736 p[index] += d;
737 model.evaluate(&p).expect("counts")
738 };
739 let (up, down) = (shifted(h), shifted(-h));
740 let slopes: Vec<f64> = up
741 .iter()
742 .zip(&down)
743 .map(|(u, d)| (u - d) / (2.0 * h))
744 .collect();
745 let column = slopes.iter().fold(0.0_f64, |m, s| m.max(s.abs()));
746 for (row, &slope) in slopes.iter().enumerate() {
747 let analytic = jacobian.get(row, index);
748 assert!(
749 (analytic - slope).abs() <= 1e-6 * column,
750 "{index} {row}: {analytic} vs {slope}"
751 );
752 }
753 }
754 }
755
756 #[test]
757 fn the_background_lags_sammy_s_by_the_pulse_s_mean_delay() {
758 let grid = grid(None);
759 let (_, u_hi) = grid.range_us();
760 let first_edge_us = f64::from(*EDGES_US.start());
761 let beam = BeamSpline::constant(first_edge_us - T0_US, u_hi, 1.0e4);
762 let isotopes: Arc<[ResonanceData]> = Arc::new([
763 synthetic_isotope(72, 180, 20.0, 0.01, 0.06),
764 synthetic_isotope(74, 182, 20.3, 0.01, 0.06),
765 ]);
766 let (charge_ratio, normalization) = (1.2, 0.93);
767 let model = TwoRunModel::new(&grid, &beam, &isotopes, charge_ratio);
768 let counts = |background: [f64; 3]| {
769 let params: Vec<f64> = beam
770 .coefficients()
771 .iter()
772 .copied()
773 .chain([3.0e-4, 5.0e-4, 300.0, normalization])
774 .chain(background)
775 .collect();
776 model.evaluate(¶ms).expect("counts")
777 };
778 let [b0, b1, b2] = [0.05, 0.5, -0.01];
779 let (with, without) = (counts([b0, b1, b2]), counts([0.0; 3]));
780 let bins = with.len() / 2;
781 let clock = TOF_FACTOR * FLIGHT_PATH_M;
782 for k in 0..bins {
783 let u = first_edge_us + 0.5 + k as f64 - T0_US;
784 let root_e = clock / u;
785 let b = b0 + b1 / root_e + b2 * root_e;
786 let slope = b1 / clock - b2 * clock / (u * u);
787 let curvature = 2.0 * b2 * clock / u.powi(3);
788 let energy = root_e * root_e;
789 let (alpha, beta, r) = (ALPHA.eval(energy), BETA.eval(energy), R.eval(energy));
790 let mean = 3.0 / alpha + r / beta;
791 let square = mean * mean + 3.0 / (alpha * alpha) + r * (2.0 - r) / (beta * beta);
792 let lagged = (with[bins + k] - without[bins + k]) / (charge_ratio * normalization);
793 let residual = lagged / with[k] - b + slope * mean;
794 let tolerance = 0.5 * slope.abs() + 0.5 * curvature.abs() * square;
795 assert!(
796 residual.abs() <= tolerance,
797 "{k}: {residual} vs {tolerance}"
798 );
799 }
800 }
801}