1use std::ops::RangeInclusive;
5use std::sync::Arc;
6
7use nereids_fitting::error::FittingError;
8use nereids_fitting::lm::{FitModel, FlatMatrix};
9use nereids_fitting::parameters::{FitParameter, ParameterSet};
10use nereids_fitting::poisson::{PoissonConfig, PoissonResult, Prior, poisson_fit};
11use nereids_physics::flight_time_grid::{FlightTimeGrid, FlightTimeGridError};
12use nereids_physics::ikeda_carpenter::{
13 DETECTOR_TAIL_DECAY_US, DetectorPulse, DetectorTail, EnergyLaw, IkedaCarpenterParams,
14};
15
16use crate::beam::BeamSpline;
17use crate::counts_fit::Value;
18use crate::error::PipelineError;
19use crate::pulse_calibration::PulsePrior;
20
21pub const BOUND: f64 = 0.01;
25
26pub(crate) const PULSE_NUMBERS: [&str; 9] =
27 ["α₀", "α₁", "β₀", "β₁", "R", "h²", "R_high", "f", "τ_D"];
28
29const NO_TAIL: [Value; 2] = [Value::Known(0.0), Value::Known(1.0)];
30
31const RATE: (RangeInclusive<f64>, &str) = (0.0..=f64::INFINITY, "0 or more");
32
33const FRACTION: (RangeInclusive<f64>, &str) = (0.0..=1.0, "within 0–1");
34
35const CONSTANT: Value = Value::Known(0.0);
36
37#[derive(Debug, Clone)]
43pub struct Calibration {
44 pub t0_us: Value,
45 pub flight_path_m: Value,
46 pub pulse: Pulse,
47}
48
49impl Calibration {
50 pub(crate) fn instrument(&self) -> Result<Vec<FitParameter>, PipelineError> {
51 let pulse = &self.pulse;
52 let ranges = [
53 RATE,
54 RATE,
55 RATE,
56 RATE,
57 FRACTION,
58 RATE,
59 FRACTION,
60 FRACTION,
61 (DETECTOR_TAIL_DECAY_US, "from 1e-9 to 1e9"),
62 ];
63 let mut parameters = vec![
64 self.t0_us
65 .parameter("t0", f64::NEG_INFINITY..=f64::INFINITY, "any real number")?,
66 self.flight_path_m.parameter(
67 "flight path",
68 f64::MIN_POSITIVE..=f64::INFINITY,
69 "positive",
70 )?,
71 ];
72 for ((name, value), (range, allowed)) in
73 PULSE_NUMBERS.into_iter().zip(pulse.numbers()).zip(ranges)
74 {
75 parameters.push(value.parameter(name, range, allowed)?);
76 }
77 Ok(parameters)
78 }
79}
80
81#[derive(Debug, Clone)]
89pub struct Pulse {
90 pub alpha: [Value; 2],
92 pub beta: [Value; 2],
94 pub r: Value,
97 pub fwhm_squared_us2: Value,
99 pub r_high: Option<Value>,
102 pub detector_tail: Option<[Value; 2]>,
107 pub energy_span_ev: (f64, f64),
110 pub n_tau: usize,
113 pub line_span_ev: Option<(f64, f64)>,
122 pub prior: Option<PulsePrior>,
127}
128
129impl Pulse {
130 pub(crate) fn numbers(&self) -> [&Value; 9] {
134 let [f, decay] = self.detector_tail.as_ref().unwrap_or(&NO_TAIL);
135 [
136 &self.alpha[0],
137 &self.alpha[1],
138 &self.beta[0],
139 &self.beta[1],
140 &self.r,
141 &self.fwhm_squared_us2,
142 self.r_high.as_ref().unwrap_or(&CONSTANT),
143 f,
144 decay,
145 ]
146 }
147
148 pub(crate) fn storage_span(&self) -> Option<(f64, f64)> {
151 self.r_high.map(|_| self.energy_span_ev)
152 }
153
154 pub(crate) fn at(&self, numbers: &[f64]) -> Result<DetectorPulse, PipelineError> {
155 DetectorPulse::new(
156 laws(numbers, self.storage_span()),
157 self.energy_span_ev,
158 self.n_tau,
159 )
160 .map_err(|e| PipelineError::InvalidParameter(e.to_string()))
161 }
162}
163
164pub(crate) fn laws(numbers: &[f64], storage_span: Option<(f64, f64)>) -> IkedaCarpenterParams {
167 IkedaCarpenterParams {
168 alpha: EnergyLaw::SqrtE {
169 a0: numbers[0],
170 a1: numbers[1],
171 },
172 beta: EnergyLaw::SqrtE {
173 a0: numbers[2],
174 a1: numbers[3],
175 },
176 r: storage_span.map_or(EnergyLaw::Const(numbers[4]), |span_ev| {
177 EnergyLaw::SqrtEBetween {
178 low: numbers[4],
179 high: numbers[6],
180 span_ev,
181 }
182 }),
183 burst_sigma_us: None,
184 channel_fwhm_us: Some(numbers[5].sqrt()),
185 detector_tail: Some(DetectorTail {
186 fraction: numbers[7],
187 decay_us: numbers[8],
188 }),
189 }
190}
191
192#[derive(Debug, Clone)]
194pub struct OpenBeamFit {
195 pub beam: BeamSpline,
199 pub deviance: f64,
201 pub converged: bool,
204 pub covariance: Option<FlatMatrix>,
209 pub overdispersion: Option<f64>,
220 pub at_limit: bool,
224 pub step_us: f64,
227 pub points: usize,
229 pub halvings: usize,
231}
232
233pub fn fit_open_beam(
284 time_edges_us: &[f64],
285 open_counts: &[f64],
286 calibration: &Calibration,
287 open_live: Option<&[f64]>,
288 known: Option<&[f64]>,
289) -> Result<OpenBeamFit, PipelineError> {
290 open_beam_with_intervals(
291 time_edges_us,
292 open_counts,
293 calibration,
294 open_live,
295 known,
296 None,
297 )
298}
299
300pub(crate) fn open_beam_with_intervals(
301 time_edges_us: &[f64],
302 open_counts: &[f64],
303 calibration: &Calibration,
304 open_live: Option<&[f64]>,
305 known: Option<&[f64]>,
306 intervals: Option<usize>,
307) -> Result<OpenBeamFit, PipelineError> {
308 let start: Vec<f64> = calibration
309 .instrument()?
310 .iter()
311 .map(|parameter| parameter.value)
312 .collect();
313 let t0_us = start[0];
314 let grid = Arc::new(FlightTimeGrid::new(
315 time_edges_us,
316 t0_us,
317 start[1],
318 &calibration.pulse.at(&start[2..])?,
319 )?);
320 validate_counts("open-beam", open_counts, time_edges_us.len() - 1)?;
321 let live = validate_live("open-beam", open_live, time_edges_us.len() - 1)?;
322 let known = validate_known(known, time_edges_us.len() - 1)?;
323
324 let coefficients = |intervals: usize| intervals + 3;
325 let admits = |intervals: usize| 2 * coefficients(intervals) <= open_counts.len();
326 if !admits(1) {
327 return Err(PipelineError::InvalidParameter(format!(
328 "the open-beam fit needs at least {} time bins, one interval's {} coefficients \
329 and as many again to measure the noise; got {}",
330 2 * coefficients(1),
331 coefficients(1),
332 open_counts.len()
333 )));
334 }
335
336 let u_first = time_edges_us[0] - t0_us;
337 let u_last = time_edges_us[time_edges_us.len() - 1] - t0_us;
338 let per_unit_beam = grid.predict(&vec![1.0; grid.flight_times_us().len()])?;
339 let recorded: f64 = per_unit_beam.iter().zip(&live).map(|(c, l)| l * c).sum();
340 let per_us = open_counts.iter().sum::<f64>() / recorded;
341 let mut ladder = vec![fit_beam(
342 &grid,
343 &BeamSpline::constant(u_first, u_last, per_us),
344 open_counts,
345 &live,
346 &known,
347 )?];
348 while let Some(start) = ladder
349 .last()
350 .filter(|candidate| candidate.fit.converged && admits(2 * candidate.beam.intervals()))
351 .map(|candidate| candidate.beam.refined())
352 {
353 match fit_beam(&grid, &start, open_counts, &live, &known) {
354 Ok(candidate) if candidate.fit.converged => ladder.push(candidate),
355 Ok(_)
356 | Err(PipelineError::FlightTimeGrid(FlightTimeGridError::TooManyPoints { .. })) => {
357 break;
358 }
359 Err(error) => return Err(error),
360 }
361 }
362
363 let richest = &ladder[ladder.len() - 1].fit;
364 let overdispersion = overdispersion(
365 open_counts,
366 richest,
367 &counted(richest, 0..open_counts.len()),
368 0.0,
369 );
370 let scale = overdispersion.unwrap_or(1.0);
371 let criterion = |candidate: &Candidate| {
372 2.0 * candidate.fit.result.deviance / scale
373 + 2.0 * candidate.beam.coefficients().len() as f64
374 };
375 let (chosen, _) =
376 ladder
377 .iter()
378 .map(criterion)
379 .enumerate()
380 .fold(
381 (0, f64::INFINITY),
382 |best, (i, q)| {
383 if q < best.1 { (i, q) } else { best }
384 },
385 );
386 let chosen = match intervals {
387 None => chosen,
388 Some(k) => ladder
389 .iter()
390 .position(|candidate| candidate.beam.intervals() == k)
391 .ok_or_else(|| {
392 FittingError::EvaluationFailed(format!(
393 "the open-beam fit does not converge with {k} intervals"
394 ))
395 })?,
396 };
397 let at_limit = chosen + 1 == ladder.len();
398 let Candidate { beam, fit } = ladder.swap_remove(chosen);
399 Ok(OpenBeamFit {
400 beam,
401 deviance: fit.result.deviance,
402 converged: fit.converged,
403 covariance: fit
404 .result
405 .covariance
406 .filter(|_| fit.converged)
407 .map(|mut covariance| {
408 covariance.data.iter_mut().for_each(|v| *v *= scale);
409 covariance
410 }),
411 overdispersion,
412 at_limit,
413 step_us: fit.step_us,
414 points: fit.points,
415 halvings: fit.halvings,
416 })
417}
418
419pub(crate) fn validate_counts(run: &str, counts: &[f64], bins: usize) -> Result<(), PipelineError> {
420 if counts.len() != bins {
421 return Err(PipelineError::ShapeMismatch(format!(
422 "{} {run} counts for {bins} time bins",
423 counts.len()
424 )));
425 }
426 if let Some((bin, count)) = counts.iter().enumerate().find(|(_, c)| !whole(**c)) {
427 return Err(PipelineError::InvalidParameter(format!(
428 "{run} counts must be whole non-negative numbers, got {count} in bin {bin}"
429 )));
430 }
431 if counts.iter().all(|&c| c == 0.0) {
432 return Err(PipelineError::InvalidParameter(format!(
433 "the {run} run has no counts"
434 )));
435 }
436 Ok(())
437}
438
439pub(crate) fn whole(count: f64) -> bool {
440 count.is_finite() && count >= 0.0 && count.fract() == 0.0
441}
442
443pub(crate) fn validate_live(
444 run: &str,
445 live: Option<&[f64]>,
446 bins: usize,
447) -> Result<Vec<f64>, PipelineError> {
448 let live = live.map_or_else(|| vec![1.0; bins], <[f64]>::to_vec);
449 if live.len() != bins {
450 return Err(PipelineError::ShapeMismatch(format!(
451 "{} {run} live fractions for {bins} time bins",
452 live.len()
453 )));
454 }
455 if let Some((bin, fraction)) = live
456 .iter()
457 .enumerate()
458 .find(|(_, l)| !(**l > 0.0 && **l <= 1.0))
459 {
460 return Err(PipelineError::InvalidParameter(format!(
461 "{run} live fractions must be in (0, 1], got {fraction} in bin {bin}"
462 )));
463 }
464 Ok(live)
465}
466
467fn validate_known(known: Option<&[f64]>, bins: usize) -> Result<Vec<f64>, PipelineError> {
468 let known = known.map_or_else(|| vec![0.0; bins], <[f64]>::to_vec);
469 if known.len() != bins {
470 return Err(PipelineError::ShapeMismatch(format!(
471 "{} known open-beam counts for {bins} time bins",
472 known.len()
473 )));
474 }
475 if let Some((bin, count)) = known
476 .iter()
477 .enumerate()
478 .find(|(_, g)| !(g.is_finite() && **g >= 0.0))
479 {
480 return Err(PipelineError::InvalidParameter(format!(
481 "known open-beam counts must be finite and 0 or more, got {count} in bin {bin}"
482 )));
483 }
484 Ok(known)
485}
486
487pub(crate) const COUNTS_TO_MEASURE_NOISE: f64 = 1.0;
488
489pub(crate) fn counted(fit: &GridFit, bins: std::ops::Range<usize>) -> Vec<usize> {
490 bins.filter(|&k| fit.predicted[k] >= COUNTS_TO_MEASURE_NOISE)
491 .collect()
492}
493
494pub(crate) fn overdispersion(
495 observed: &[f64],
496 fit: &GridFit,
497 counted: &[usize],
498 shared_leverage: f64,
499) -> Option<f64> {
500 let leverage = fit.result.leverage.as_ref().filter(|_| fit.converged)?;
501 let (pearson, skew, freedom) =
502 counted
503 .iter()
504 .fold((0.0, 0.0, 0.0), |(pearson, skew, freedom), &k| {
505 let (y, mu) = (observed[k], fit.predicted[k]);
506 (
507 pearson + (y - mu).powi(2) / mu,
508 skew + (y - mu) / mu,
509 freedom + 1.0 - leverage[k],
510 )
511 });
512 let fletcher = 1.0 + skew / counted.len() as f64;
513 let freedom = freedom - shared_leverage;
514 (freedom >= 1.0)
515 .then(|| (pearson / freedom / fletcher).clamp(1.0, f64::INFINITY))
516 .filter(|phi| phi.is_finite())
517}
518
519struct Candidate {
520 beam: BeamSpline,
521 fit: GridFit,
522}
523
524fn fit_beam(
525 first_grid: &Arc<FlightTimeGrid>,
526 start: &BeamSpline,
527 open_counts: &[f64],
528 live: &[f64],
529 known: &[f64],
530) -> Result<Candidate, PipelineError> {
531 let mut parameters = ParameterSet::new(
532 start
533 .coefficients()
534 .iter()
535 .enumerate()
536 .map(|(i, &c)| FitParameter::unbounded(format!("beam {i}"), c))
537 .collect(),
538 );
539 let dispersion = vec![1.0; open_counts.len()];
540 let fit = fit_on_halved_grids(
541 first_grid,
542 &mut parameters,
543 open_counts,
544 &dispersion,
545 &[],
546 |grid| {
547 Ok(Recorded {
548 model: OpenBeamModel::new(grid, start, known),
549 live,
550 })
551 },
552 |spread, _, _| Ok(spread <= BOUND),
553 )?;
554 Ok(Candidate {
555 beam: start.with_coefficients(&fit.result.params),
556 fit,
557 })
558}
559
560pub(crate) struct GridFit {
561 pub(crate) result: PoissonResult,
562 pub(crate) converged: bool,
563 pub(crate) predicted: Vec<f64>,
564 pub(crate) coarse: Arc<FlightTimeGrid>,
565 pub(crate) fine: Arc<FlightTimeGrid>,
566 pub(crate) step_us: f64,
567 pub(crate) points: usize,
568 pub(crate) halvings: usize,
569}
570
571pub(crate) fn fit_on_halved_grids<M: FitModel>(
572 first_grid: &Arc<FlightTimeGrid>,
573 parameters: &mut ParameterSet,
574 observed: &[f64],
575 dispersion: &[f64],
576 priors: &[Prior],
577 model_on: impl Fn(&Arc<FlightTimeGrid>) -> Result<M, PipelineError>,
578 done: impl Fn(f64, &FlightTimeGrid, &[f64]) -> Result<bool, PipelineError>,
579) -> Result<GridFit, PipelineError> {
580 let dispersed: Vec<f64> = observed
581 .iter()
582 .zip(dispersion)
583 .map(|(y, d)| y / d)
584 .collect();
585 let mut grid = Arc::clone(first_grid);
586 let mut coarse = model_on(&grid)?;
587 let mut halvings = 0;
588 loop {
589 let finer = Arc::new(grid.halved()?);
590 let fine = model_on(&finer)?;
591 let result = poisson_fit(
592 &Dispersed {
593 model: &fine,
594 dispersion,
595 },
596 &dispersed,
597 priors,
598 parameters,
599 &PoissonConfig::default(),
600 )?;
601 let converged = result.converged && result.params.iter().all(|p| p.is_finite());
602 let predicted = fine.evaluate(&result.params)?;
603 let spread: f64 = predicted
604 .iter()
605 .zip(coarse.evaluate(&result.params)?)
606 .filter(|(fine, _)| **fine > 0.0)
607 .map(|(fine, coarse)| (fine - coarse).powi(2) / fine)
608 .sum();
609 let coarse_grid = std::mem::replace(&mut grid, finer);
610 coarse = fine;
611 halvings += 1;
612 if !converged || done(spread, &coarse_grid, &result.params)? {
613 return Ok(GridFit {
614 result,
615 converged,
616 predicted,
617 coarse: coarse_grid,
618 fine: Arc::clone(&grid),
619 step_us: grid.step_us(),
620 points: grid.flight_times_us().len(),
621 halvings,
622 });
623 }
624 }
625}
626
627struct Dispersed<'a, M> {
628 model: &'a M,
629 dispersion: &'a [f64],
630}
631
632impl<M: FitModel> FitModel for Dispersed<'_, M> {
633 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
634 let counts = self.model.evaluate(params)?;
635 Ok(counts
636 .iter()
637 .zip(self.dispersion)
638 .map(|(&c, d)| if c < 0.0 { c } else { c / d })
639 .collect())
640 }
641
642 fn analytical_jacobian(
643 &self,
644 params: &[f64],
645 free_param_indices: &[usize],
646 y_current: &[f64],
647 ) -> Option<FlatMatrix> {
648 let mut jacobian = self
649 .model
650 .analytical_jacobian(params, free_param_indices, y_current)?;
651 for (row, d) in self.dispersion.iter().enumerate() {
652 for col in 0..free_param_indices.len() {
653 *jacobian.get_mut(row, col) /= d;
654 }
655 }
656 Some(jacobian)
657 }
658}
659
660pub(crate) struct Recorded<'a, M> {
661 pub(crate) model: M,
662 pub(crate) live: &'a [f64],
663}
664
665impl<M: FitModel> FitModel for Recorded<'_, M> {
666 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
667 let counts = self.model.evaluate(params)?;
668 Ok(counts.iter().zip(self.live).map(|(c, l)| l * c).collect())
669 }
670
671 fn analytical_jacobian(
672 &self,
673 params: &[f64],
674 free_param_indices: &[usize],
675 y_current: &[f64],
676 ) -> Option<FlatMatrix> {
677 let mut jacobian = self
678 .model
679 .analytical_jacobian(params, free_param_indices, y_current)?;
680 for (row, l) in self.live.iter().enumerate() {
681 for col in 0..free_param_indices.len() {
682 *jacobian.get_mut(row, col) *= l;
683 }
684 }
685 Some(jacobian)
686 }
687}
688
689pub(crate) fn combined(basis: &[[(usize, f64); 5]], coefficients: &[f64]) -> Vec<f64> {
690 basis
691 .iter()
692 .map(|pairs| pairs.iter().map(|&(i, w)| w * coefficients[i]).sum())
693 .collect()
694}
695
696pub(crate) fn weights_of(basis: &[[(usize, f64); 5]], index: usize) -> Vec<f64> {
697 basis
698 .iter()
699 .map(|pairs| {
700 pairs
701 .iter()
702 .filter(|&&(i, _)| i == index)
703 .map(|&(_, w)| w)
704 .sum()
705 })
706 .collect()
707}
708
709pub(crate) struct OpenBeamModel {
710 grid: Arc<FlightTimeGrid>,
711 basis: Vec<[(usize, f64); 5]>,
712 known: Vec<f64>,
713}
714
715impl OpenBeamModel {
716 pub(crate) fn new(grid: &Arc<FlightTimeGrid>, beam: &BeamSpline, known: &[f64]) -> Self {
717 Self {
718 grid: Arc::clone(grid),
719 basis: grid
720 .flight_times_us()
721 .iter()
722 .map(|&u| beam.basis(u))
723 .collect(),
724 known: known.to_vec(),
725 }
726 }
727
728 pub(crate) fn beam(&self, coefficients: &[f64]) -> Vec<f64> {
729 combined(&self.basis, coefficients)
730 .into_iter()
731 .map(f64::exp)
732 .collect()
733 }
734
735 pub(crate) fn log_slope(&self, index: usize) -> Vec<f64> {
736 weights_of(&self.basis, index)
737 }
738
739 pub(crate) fn counts(&self, values: &[f64]) -> Result<Vec<f64>, FittingError> {
740 self.grid
741 .predict(values)
742 .map_err(|e| FittingError::EvaluationFailed(e.to_string()))
743 }
744}
745
746impl FitModel for OpenBeamModel {
747 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
748 let mut counts = self.counts(&self.beam(params))?;
749 for (c, g) in counts.iter_mut().zip(&self.known) {
750 *c += g;
751 }
752 Ok(counts)
753 }
754
755 fn analytical_jacobian(
756 &self,
757 params: &[f64],
758 free_param_indices: &[usize],
759 y_current: &[f64],
760 ) -> Option<FlatMatrix> {
761 let beam = self.beam(params);
762 let mut jacobian = FlatMatrix::zeros(y_current.len(), free_param_indices.len());
763 for (col, &index) in free_param_indices.iter().enumerate() {
764 let values: Vec<f64> = self
765 .log_slope(index)
766 .iter()
767 .zip(&beam)
768 .map(|(slope, phi)| slope * phi)
769 .collect();
770 for (row, value) in self.counts(&values).ok()?.into_iter().enumerate() {
771 *jacobian.get_mut(row, col) = value;
772 }
773 }
774 Some(jacobian)
775 }
776}
777
778#[cfg(test)]
779pub(crate) mod tests {
780 use nereids_physics::ikeda_carpenter::{IkedaCarpenter, SynthesisGrid};
781
782 use super::*;
783
784 pub(crate) const FLIGHT_PATH_M: f64 = 25.0;
785 pub(crate) const T0_US: f64 = 3.0;
786 pub(crate) const EDGES_US: std::ops::RangeInclusive<u32> = 350..=470;
787 pub(crate) const ALPHA: EnergyLaw = EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 };
788 pub(crate) const BETA: EnergyLaw = EnergyLaw::Const(0.25);
789 pub(crate) const R: EnergyLaw = EnergyLaw::Const(0.15);
790
791 pub(crate) fn grid(channel_fwhm_us: Option<f64>) -> Arc<FlightTimeGrid> {
792 let pulse = IkedaCarpenter::new(
793 IkedaCarpenterParams {
794 alpha: ALPHA,
795 beta: BETA,
796 r: R,
797 burst_sigma_us: None,
798 channel_fwhm_us,
799 detector_tail: None,
800 },
801 FLIGHT_PATH_M,
802 &SynthesisGrid {
803 e_min_ev: 1.0,
804 e_max_ev: 200.0,
805 n_energies: 32,
806 n_tau: 256,
807 },
808 )
809 .expect("valid IC model");
810 let edges: Vec<f64> = EDGES_US.map(f64::from).collect();
811 Arc::new(
812 FlightTimeGrid::new(&edges, T0_US, FLIGHT_PATH_M, &pulse.detector_pulse())
813 .expect("grid"),
814 )
815 }
816
817 #[test]
818 fn the_jacobian_is_the_slope_of_the_counts() {
819 for channel_fwhm_us in [None, Some(2.0)] {
820 let grid = grid(channel_fwhm_us);
821 let (_, u_hi) = grid.range_us();
822 for beam in [
823 BeamSpline::constant(347.0, u_hi, 1.0e4),
824 BeamSpline::constant(347.0, u_hi, 1.0e4).refined().refined(),
825 ] {
826 let model = OpenBeamModel::new(&grid, &beam, &[]);
827 let coefficients: Vec<f64> = (0..beam.coefficients().len())
828 .map(|i| 9.0 + 0.3 * (i as f64).sin())
829 .collect();
830 let counts = model.evaluate(&coefficients).expect("counts");
831 let indices: Vec<usize> = (0..coefficients.len()).collect();
832 let jacobian = model
833 .analytical_jacobian(&coefficients, &indices, &counts)
834 .expect("jacobian");
835 for index in indices {
836 let h = 1e-4;
837 let shifted = |d: f64| {
838 let mut c = coefficients.clone();
839 c[index] += d;
840 model.evaluate(&c).expect("counts")
841 };
842 let (up, down) = (shifted(h), shifted(-h));
843 let slopes: Vec<f64> = up
844 .iter()
845 .zip(&down)
846 .map(|(u, d)| (u - d) / (2.0 * h))
847 .collect();
848 let column = slopes.iter().fold(0.0_f64, |m, s| m.max(s.abs()));
849 for (row, &slope) in slopes.iter().enumerate() {
850 let analytic = jacobian.get(row, index);
851 assert!(
852 (analytic - slope).abs() <= 1e-6 * column,
853 "{channel_fwhm_us:?} {} {index} {row}: {analytic} vs {slope}",
854 beam.intervals()
855 );
856 }
857 }
858 }
859 }
860 }
861}