1use std::sync::Arc;
5
6use nereids_fitting::error::FittingError;
7use nereids_fitting::lm::{FitModel, FlatMatrix};
8use nereids_fitting::parameters::{FitParameter, ParameterSet};
9use nereids_fitting::poisson::{PoissonConfig, PoissonResult, poisson_fit};
10use nereids_physics::flight_time_grid::{FlightTimeGrid, FlightTimeGridError};
11use nereids_physics::ikeda_carpenter::IkedaCarpenter;
12
13use crate::beam::BeamSpline;
14use crate::error::PipelineError;
15
16pub const BOUND: f64 = 0.01;
20
21#[derive(Debug, Clone)]
24pub struct Calibration {
25 pub t0_us: f64,
26 pub pulse: Arc<IkedaCarpenter>,
27}
28
29#[derive(Debug, Clone)]
31pub struct OpenBeamFit {
32 pub beam: BeamSpline,
36 pub deviance: f64,
38 pub converged: bool,
41 pub covariance: Option<FlatMatrix>,
46 pub overdispersion: Option<f64>,
54 pub at_limit: bool,
58 pub step_us: f64,
61 pub points: usize,
63 pub halvings: usize,
65}
66
67pub fn fit_open_beam(
110 time_edges_us: &[f64],
111 open_counts: &[f64],
112 calibration: &Calibration,
113 open_live: Option<&[f64]>,
114) -> Result<OpenBeamFit, PipelineError> {
115 let grid = Arc::new(FlightTimeGrid::new(
116 time_edges_us,
117 calibration.t0_us,
118 &calibration.pulse,
119 )?);
120 validate_counts("open-beam", open_counts, time_edges_us.len() - 1)?;
121 let live = validate_live("open-beam", open_live, time_edges_us.len() - 1)?;
122
123 let coefficients = |intervals: usize| intervals + 3;
124 let admits = |intervals: usize| 2 * coefficients(intervals) <= open_counts.len();
125 if !admits(1) {
126 return Err(PipelineError::InvalidParameter(format!(
127 "the open-beam fit needs at least {} time bins, one interval's {} coefficients \
128 and as many again to measure the noise; got {}",
129 2 * coefficients(1),
130 coefficients(1),
131 open_counts.len()
132 )));
133 }
134
135 let u_first = time_edges_us[0] - calibration.t0_us;
136 let u_last = time_edges_us[time_edges_us.len() - 1] - calibration.t0_us;
137 let per_unit_beam = grid.predict(&vec![1.0; grid.flight_times_us().len()])?;
138 let recorded: f64 = per_unit_beam.iter().zip(&live).map(|(c, l)| l * c).sum();
139 let per_us = open_counts.iter().sum::<f64>() / recorded;
140 let mut ladder = vec![fit_beam(
141 &grid,
142 &BeamSpline::constant(u_first, u_last, per_us),
143 open_counts,
144 &live,
145 )?];
146 while let Some(start) = ladder
147 .last()
148 .filter(|candidate| candidate.fit.converged && admits(2 * candidate.beam.intervals()))
149 .map(|candidate| candidate.beam.refined())
150 {
151 match fit_beam(&grid, &start, open_counts, &live) {
152 Ok(candidate) if candidate.fit.converged => ladder.push(candidate),
153 Ok(_)
154 | Err(PipelineError::FlightTimeGrid(FlightTimeGridError::TooManyPoints { .. })) => {
155 break;
156 }
157 Err(error) => return Err(error),
158 }
159 }
160
161 let richest = &ladder[ladder.len() - 1].fit;
162 let overdispersion = overdispersion(open_counts, richest);
163 let scale = overdispersion.unwrap_or(1.0);
164 let criterion = |candidate: &Candidate| {
165 2.0 * candidate.fit.result.deviance / scale
166 + 2.0 * candidate.beam.coefficients().len() as f64
167 };
168 let (chosen, _) =
169 ladder
170 .iter()
171 .map(criterion)
172 .enumerate()
173 .fold(
174 (0, f64::INFINITY),
175 |best, (i, q)| {
176 if q < best.1 { (i, q) } else { best }
177 },
178 );
179 let at_limit = chosen + 1 == ladder.len();
180 let Candidate { beam, fit } = ladder.swap_remove(chosen);
181 Ok(OpenBeamFit {
182 beam,
183 deviance: fit.result.deviance,
184 converged: fit.converged,
185 covariance: fit
186 .result
187 .covariance
188 .filter(|_| fit.converged)
189 .map(|mut covariance| {
190 covariance.data.iter_mut().for_each(|v| *v *= scale);
191 covariance
192 }),
193 overdispersion,
194 at_limit,
195 step_us: fit.step_us,
196 points: fit.points,
197 halvings: fit.halvings,
198 })
199}
200
201pub(crate) fn validate_counts(run: &str, counts: &[f64], bins: usize) -> Result<(), PipelineError> {
202 if counts.len() != bins {
203 return Err(PipelineError::ShapeMismatch(format!(
204 "{} {run} counts for {bins} time bins",
205 counts.len()
206 )));
207 }
208 if let Some((bin, count)) = counts
209 .iter()
210 .enumerate()
211 .find(|(_, c)| !(c.is_finite() && **c >= 0.0 && c.fract() == 0.0))
212 {
213 return Err(PipelineError::InvalidParameter(format!(
214 "{run} counts must be whole non-negative numbers, got {count} in bin {bin}"
215 )));
216 }
217 if counts.iter().all(|&c| c == 0.0) {
218 return Err(PipelineError::InvalidParameter(format!(
219 "the {run} run has no counts"
220 )));
221 }
222 Ok(())
223}
224
225pub(crate) fn validate_live(
226 run: &str,
227 live: Option<&[f64]>,
228 bins: usize,
229) -> Result<Vec<f64>, PipelineError> {
230 let live = live.map_or_else(|| vec![1.0; bins], <[f64]>::to_vec);
231 if live.len() != bins {
232 return Err(PipelineError::ShapeMismatch(format!(
233 "{} {run} live fractions for {bins} time bins",
234 live.len()
235 )));
236 }
237 if let Some((bin, fraction)) = live
238 .iter()
239 .enumerate()
240 .find(|(_, l)| !(**l > 0.0 && **l <= 1.0))
241 {
242 return Err(PipelineError::InvalidParameter(format!(
243 "{run} live fractions must be in (0, 1], got {fraction} in bin {bin}"
244 )));
245 }
246 Ok(live)
247}
248
249const COUNTS_TO_MEASURE_NOISE: f64 = 1.0;
250
251pub(crate) fn overdispersion(observed: &[f64], fit: &GridFit) -> Option<f64> {
252 let (pearson, bins) = observed
253 .iter()
254 .zip(&fit.predicted)
255 .filter(|(_, mu)| **mu >= COUNTS_TO_MEASURE_NOISE)
256 .fold((0.0, 0_usize), |(sum, bins), (y, mu)| {
257 (sum + (y - mu).powi(2) / mu, bins + 1)
258 });
259 let parameters = fit.result.on_bound.iter().filter(|&&on| !on).count();
260 let freedom = bins.checked_sub(parameters).filter(|&f| f > 0)?;
261 fit.converged
262 .then(|| (pearson / freedom as f64).clamp(1.0, f64::INFINITY))
263}
264
265struct Candidate {
266 beam: BeamSpline,
267 fit: GridFit,
268}
269
270fn fit_beam(
271 first_grid: &Arc<FlightTimeGrid>,
272 start: &BeamSpline,
273 open_counts: &[f64],
274 live: &[f64],
275) -> Result<Candidate, PipelineError> {
276 let mut parameters = ParameterSet::new(
277 start
278 .coefficients()
279 .iter()
280 .enumerate()
281 .map(|(i, &c)| FitParameter::unbounded(format!("beam {i}"), c))
282 .collect(),
283 );
284 let fit = fit_on_halved_grids(first_grid, &mut parameters, open_counts, |grid| {
285 Ok(Recorded {
286 model: OpenBeamModel::new(grid, start),
287 live,
288 })
289 })?;
290 Ok(Candidate {
291 beam: start.with_coefficients(&fit.result.params),
292 fit,
293 })
294}
295
296pub(crate) struct GridFit {
297 pub(crate) result: PoissonResult,
298 pub(crate) converged: bool,
299 pub(crate) predicted: Vec<f64>,
300 pub(crate) step_us: f64,
301 pub(crate) points: usize,
302 pub(crate) halvings: usize,
303}
304
305pub(crate) fn fit_on_halved_grids<M: FitModel>(
306 first_grid: &Arc<FlightTimeGrid>,
307 parameters: &mut ParameterSet,
308 observed: &[f64],
309 model_on: impl Fn(&Arc<FlightTimeGrid>) -> Result<M, PipelineError>,
310) -> Result<GridFit, PipelineError> {
311 let mut grid = Arc::clone(first_grid);
312 let mut coarse = model_on(&grid)?;
313 let mut halvings = 0;
314 loop {
315 let finer = Arc::new(grid.halved()?);
316 let fine = model_on(&finer)?;
317 let result = poisson_fit(&fine, observed, parameters, &PoissonConfig::default())?;
318 let converged = result.converged && result.params.iter().all(|p| p.is_finite());
319 let predicted = fine.evaluate(&result.params)?;
320 let spread: f64 = predicted
321 .iter()
322 .zip(coarse.evaluate(&result.params)?)
323 .filter(|(fine, _)| **fine > 0.0)
324 .map(|(fine, coarse)| (fine - coarse).powi(2) / fine)
325 .sum();
326 grid = finer;
327 coarse = fine;
328 halvings += 1;
329 if spread <= BOUND || !converged {
330 return Ok(GridFit {
331 result,
332 converged,
333 predicted,
334 step_us: grid.step_us(),
335 points: grid.flight_times_us().len(),
336 halvings,
337 });
338 }
339 }
340}
341
342pub(crate) struct Recorded<'a, M> {
343 pub(crate) model: M,
344 pub(crate) live: &'a [f64],
345}
346
347impl<M: FitModel> FitModel for Recorded<'_, M> {
348 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
349 let counts = self.model.evaluate(params)?;
350 Ok(counts.iter().zip(self.live).map(|(c, l)| l * c).collect())
351 }
352
353 fn analytical_jacobian(
354 &self,
355 params: &[f64],
356 free_param_indices: &[usize],
357 y_current: &[f64],
358 ) -> Option<FlatMatrix> {
359 let mut jacobian = self
360 .model
361 .analytical_jacobian(params, free_param_indices, y_current)?;
362 for (row, l) in self.live.iter().enumerate() {
363 for col in 0..free_param_indices.len() {
364 *jacobian.get_mut(row, col) *= l;
365 }
366 }
367 Some(jacobian)
368 }
369}
370
371pub(crate) struct OpenBeamModel {
372 grid: Arc<FlightTimeGrid>,
373 basis: Vec<[(usize, f64); 5]>,
374}
375
376impl OpenBeamModel {
377 pub(crate) fn new(grid: &Arc<FlightTimeGrid>, beam: &BeamSpline) -> Self {
378 Self {
379 grid: Arc::clone(grid),
380 basis: grid
381 .flight_times_us()
382 .iter()
383 .map(|&u| beam.basis(u))
384 .collect(),
385 }
386 }
387
388 pub(crate) fn beam(&self, coefficients: &[f64]) -> Vec<f64> {
389 self.basis
390 .iter()
391 .map(|pairs| {
392 pairs
393 .iter()
394 .map(|&(i, w)| w * coefficients[i])
395 .sum::<f64>()
396 .exp()
397 })
398 .collect()
399 }
400
401 pub(crate) fn log_slope(&self, index: usize) -> Vec<f64> {
402 self.basis
403 .iter()
404 .map(|pairs| {
405 pairs
406 .iter()
407 .filter(|&&(i, _)| i == index)
408 .map(|&(_, w)| w)
409 .sum()
410 })
411 .collect()
412 }
413
414 pub(crate) fn counts(&self, values: &[f64]) -> Result<Vec<f64>, FittingError> {
415 self.grid
416 .predict(values)
417 .map_err(|e| FittingError::EvaluationFailed(e.to_string()))
418 }
419}
420
421impl FitModel for OpenBeamModel {
422 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
423 self.counts(&self.beam(params))
424 }
425
426 fn analytical_jacobian(
427 &self,
428 params: &[f64],
429 free_param_indices: &[usize],
430 y_current: &[f64],
431 ) -> Option<FlatMatrix> {
432 let beam = self.beam(params);
433 let mut jacobian = FlatMatrix::zeros(y_current.len(), free_param_indices.len());
434 for (col, &index) in free_param_indices.iter().enumerate() {
435 let values: Vec<f64> = self
436 .log_slope(index)
437 .iter()
438 .zip(&beam)
439 .map(|(slope, phi)| slope * phi)
440 .collect();
441 for (row, value) in self.counts(&values).ok()?.into_iter().enumerate() {
442 *jacobian.get_mut(row, col) = value;
443 }
444 }
445 Some(jacobian)
446 }
447}
448
449#[cfg(test)]
450pub(crate) mod tests {
451 use nereids_physics::ikeda_carpenter::{EnergyLaw, IkedaCarpenterParams, SynthesisGrid};
452
453 use super::*;
454
455 pub(crate) const FLIGHT_PATH_M: f64 = 25.0;
456 pub(crate) const T0_US: f64 = 3.0;
457 pub(crate) const EDGES_US: std::ops::RangeInclusive<u32> = 350..=470;
458 pub(crate) const ALPHA: EnergyLaw = EnergyLaw::SqrtE { a0: 0.35, a1: 0.05 };
459 pub(crate) const BETA: EnergyLaw = EnergyLaw::Const(0.25);
460 pub(crate) const R: EnergyLaw = EnergyLaw::Const(0.15);
461
462 pub(crate) fn grid(channel_fwhm_us: Option<f64>) -> Arc<FlightTimeGrid> {
463 let pulse = IkedaCarpenter::new(
464 IkedaCarpenterParams {
465 alpha: ALPHA,
466 beta: BETA,
467 r: R,
468 burst_sigma_us: None,
469 channel_fwhm_us,
470 },
471 FLIGHT_PATH_M,
472 &SynthesisGrid {
473 e_min_ev: 1.0,
474 e_max_ev: 200.0,
475 n_energies: 32,
476 n_tau: 256,
477 },
478 )
479 .expect("valid IC model");
480 let edges: Vec<f64> = EDGES_US.map(f64::from).collect();
481 Arc::new(FlightTimeGrid::new(&edges, T0_US, &Arc::new(pulse)).expect("grid"))
482 }
483
484 #[test]
485 fn the_jacobian_is_the_slope_of_the_counts() {
486 for channel_fwhm_us in [None, Some(2.0)] {
487 let grid = grid(channel_fwhm_us);
488 let (_, u_hi) = grid.range_us();
489 for beam in [
490 BeamSpline::constant(347.0, u_hi, 1.0e4),
491 BeamSpline::constant(347.0, u_hi, 1.0e4).refined().refined(),
492 ] {
493 let model = OpenBeamModel::new(&grid, &beam);
494 let coefficients: Vec<f64> = (0..beam.coefficients().len())
495 .map(|i| 9.0 + 0.3 * (i as f64).sin())
496 .collect();
497 let counts = model.evaluate(&coefficients).expect("counts");
498 let indices: Vec<usize> = (0..coefficients.len()).collect();
499 let jacobian = model
500 .analytical_jacobian(&coefficients, &indices, &counts)
501 .expect("jacobian");
502 for index in indices {
503 let h = 1e-4;
504 let shifted = |d: f64| {
505 let mut c = coefficients.clone();
506 c[index] += d;
507 model.evaluate(&c).expect("counts")
508 };
509 let (up, down) = (shifted(h), shifted(-h));
510 let slopes: Vec<f64> = up
511 .iter()
512 .zip(&down)
513 .map(|(u, d)| (u - d) / (2.0 * h))
514 .collect();
515 let column = slopes.iter().fold(0.0_f64, |m, s| m.max(s.abs()));
516 for (row, &slope) in slopes.iter().enumerate() {
517 let analytic = jacobian.get(row, index);
518 assert!(
519 (analytic - slope).abs() <= 1e-6 * column,
520 "{channel_fwhm_us:?} {} {index} {row}: {analytic} vs {slope}",
521 beam.intervals()
522 );
523 }
524 }
525 }
526 }
527 }
528}