Skip to main content

nereids_physics/
counts_response.rs

1//! Separate open-beam and sample count response in detector time.
2//!
3//! A detector does not observe a blurred transmission ratio. For discrete
4//! true-energy quadrature points `E_j`, the two physical arms are
5//!
6//! ```text
7//! O_i = sum_j F_j       R_i(E_j)
8//! S_i = sum_j F_j T_j  R_i(E_j)
9//! ```
10//!
11//! `F_j` is the incident fluence weight at true energy `E_j` — the incident
12//! flux density × detector efficiency × the caller's energy-integration
13//! weight, the discrete form of the contract's `Φ·ε` (pipeline-map R5·7:
14//! detector efficiency is folded into `F_j`, never a separate silent factor).
15//! `T_j` is the sample transmission before instrument response, and
16//! `R_i(E_j)` is the probability that a neutron at `E_j` lands between the
17//! actual detector time edges of bin `i`.
18//!
19//! Keeping `F_j` as an already-integrated weight is deliberate: this operator
20//! never guesses energy-bin widths from centers. A caller using a continuous
21//! source spectrum must choose and disclose its energy quadrature, then pass
22//! `F_j = w_j ε(E_j) Φ(E_j)`. The detector-time integration itself is
23//! performed by the response model over the supplied measured bin edges.
24//!
25//! The `timing_offset_us` handed to the response is convention-dependent
26//! (mode-relative for tabulated kernels, emission-onset-relative for the
27//! analytical Ikeda–Carpenter pulse); see
28//! [`ResolutionFunction::detector_bin_probabilities`] — a calibrated offset
29//! is not transferable between response variants.
30
31use std::fmt;
32
33use rayon::prelude::*;
34
35use crate::resolution::{ResolutionFunction, ResolutionParseError};
36
37type CompactResponseRow = (Vec<u32>, Vec<f64>, f64);
38
39/// Expected detector-bin counts for the open-beam and sample measurements.
40#[derive(Debug, Clone, PartialEq)]
41pub struct TwoArmCounts {
42    /// Open-beam expectation `sum_j F_j R_i(E_j)`.
43    pub open_beam: Vec<f64>,
44    /// Sample expectation `sum_j F_j T_j R_i(E_j)`.
45    pub sample: Vec<f64>,
46    /// Expected open-beam counts falling outside the supplied acquisition
47    /// window: `sum_j F_j (1 - sum_i R_i(E_j))`.
48    ///
49    /// The quantified acquisition-window loss required by the pipeline-map
50    /// contract (R5·7): window loss is disclosed here, never renormalized
51    /// back into the window.
52    pub open_beam_window_loss: f64,
53    /// Expected sample counts falling outside the supplied acquisition
54    /// window: `sum_j F_j T_j (1 - sum_i R_i(E_j))`.
55    pub sample_window_loss: f64,
56}
57
58/// Signal, additive background, and total expected counts for one measured arm.
59///
60/// Background is expressed directly in the detector bins of that measurement
61/// and is added *after* source attenuation and instrument response. It is not
62/// the SAMMY additive transmission curve, and it is not convolved a second
63/// time by the instrument response.
64#[derive(Debug, Clone, PartialEq)]
65pub struct ArmCountPrediction {
66    /// Neutron counts produced by source, sample, and instrument response.
67    pub neutron_signal: Vec<f64>,
68    /// Independently supplied additive expected counts in the measured bins.
69    pub background: Vec<f64>,
70    /// Complete expectation, `neutron_signal + background`.
71    pub total: Vec<f64>,
72    /// Expected neutron counts lost outside the acquisition window, carried
73    /// through from [`TwoArmCounts`].
74    ///
75    /// The pipeline-map contract (R5·7) requires the acquisition-window loss
76    /// to stay disclosed rather than renormalized away. Forming a total
77    /// expectation is exactly where that report would otherwise be dropped,
78    /// so it travels with the arm it belongs to. Only the neutron signal can
79    /// leave the window: a background template is supplied already binned
80    /// into the acquisition, so it contributes no additional loss.
81    pub window_loss: f64,
82}
83
84/// Complete expected counts for the open-beam and sample measurements.
85#[derive(Debug, Clone, PartialEq)]
86pub struct TwoArmCountPrediction {
87    /// Open-beam measurement components.
88    pub open_beam: ArmCountPrediction,
89    /// Sample measurement components.
90    pub sample: ArmCountPrediction,
91}
92
93/// Detector-bin probabilities for a fixed instrument response.
94///
95/// Rows correspond to `true_energies_ev`; columns correspond to consecutive
96/// intervals in `detector_time_edges_us`. Building the matrix evaluates the
97/// analytical IC/tabulated bin integrals once. Reusing it during optimization
98/// changes only the true-energy sample transmission, not the detector physics.
99///
100/// Only entries whose evaluated probability is strictly greater than zero are
101/// stored. This is lossless: there is no numerical cutoff, and every nonzero
102/// value returned by the response model is retained. Probability outside the
103/// acquisition window is likewise never renormalized into the stored rows —
104/// each row's out-of-window fraction is kept alongside it so [`Self::apply`]
105/// can report the quantified window loss exactly as
106/// [`two_arm_count_response`] does (pipeline-map R5·7).
107#[derive(Debug, Clone, PartialEq)]
108pub struct DetectorBinResponseMatrix {
109    row_offsets: Vec<usize>,
110    detector_bins: Vec<u32>,
111    probabilities: Vec<f64>,
112    /// Per-row out-of-window probability `(1 - sum_i R_i(E_j)).max(0)`.
113    lost_fractions: Vec<f64>,
114    n_true_energies: usize,
115    n_detector_bins: usize,
116}
117
118impl DetectorBinResponseMatrix {
119    /// Build a fixed detector-bin response matrix.
120    pub fn new(
121        true_energies_ev: &[f64],
122        detector_time_edges_us: &[f64],
123        timing_offset_us: f64,
124        response: &ResolutionFunction,
125    ) -> Result<Self, CountsResponseError> {
126        if true_energies_ev.is_empty() {
127            return Err(CountsResponseError::EmptyTrueEnergyGrid);
128        }
129        for (index, &energy) in true_energies_ev.iter().enumerate() {
130            if !energy.is_finite() || energy <= 0.0 {
131                return Err(CountsResponseError::InvalidTrueEnergy {
132                    index,
133                    value: energy,
134                });
135            }
136        }
137
138        let n_detector_bins = detector_time_edges_us.len().saturating_sub(1);
139        if n_detector_bins > u32::MAX as usize {
140            return Err(CountsResponseError::Resolution(
141                ResolutionParseError::InvalidFormat(format!(
142                    "detector response has {n_detector_bins} bins, exceeding the u32 storage limit"
143                )),
144            ));
145        }
146        // Each true-energy response is independent. Rayon preserves the input
147        // order of this indexed parallel collect, so rows and all subsequent
148        // accumulation orders remain deterministic.
149        let row_results: Vec<Result<CompactResponseRow, ResolutionParseError>> = true_energies_ev
150            .par_iter()
151            .map(|&energy| {
152                let row = response.detector_bin_probabilities(
153                    energy,
154                    detector_time_edges_us,
155                    timing_offset_us,
156                )?;
157                debug_assert_eq!(row.len(), n_detector_bins);
158                let mut bins = Vec::new();
159                let mut values = Vec::new();
160                let mut in_window = 0.0_f64;
161                for (detector_bin, probability) in row.into_iter().enumerate() {
162                    if !probability.is_finite() || probability < 0.0 {
163                        return Err(ResolutionParseError::InvalidFormat(format!(
164                            "detector response probability at E = {energy} eV, bin {detector_bin} must be finite and >= 0, got {probability}"
165                        )));
166                    }
167                    in_window += probability;
168                    if probability > 0.0 {
169                        bins.push(detector_bin as u32);
170                        values.push(probability);
171                    }
172                }
173                Ok::<_, ResolutionParseError>((bins, values, (1.0 - in_window).max(0.0)))
174            })
175            .collect();
176        // Resolve errors after the ordered collect so the first failing input
177        // row is reported deterministically regardless of thread scheduling.
178        let rows: Vec<CompactResponseRow> = row_results.into_iter().collect::<Result<_, _>>()?;
179        let nonzero_count = rows.iter().try_fold(0_usize, |total, (_, values, _)| {
180            total.checked_add(values.len()).ok_or_else(|| {
181                CountsResponseError::Resolution(ResolutionParseError::InvalidFormat(
182                    "detector response nonzero count overflows usize".into(),
183                ))
184            })
185        })?;
186
187        let mut row_offsets = Vec::with_capacity(true_energies_ev.len() + 1);
188        let mut detector_bins = Vec::with_capacity(nonzero_count);
189        let mut probabilities = Vec::with_capacity(nonzero_count);
190        let mut lost_fractions = Vec::with_capacity(true_energies_ev.len());
191        row_offsets.push(0);
192        for (mut bins, mut values, lost) in rows {
193            detector_bins.append(&mut bins);
194            probabilities.append(&mut values);
195            row_offsets.push(probabilities.len());
196            lost_fractions.push(lost);
197        }
198
199        Ok(Self {
200            row_offsets,
201            detector_bins,
202            probabilities,
203            lost_fractions,
204            n_true_energies: true_energies_ev.len(),
205            n_detector_bins,
206        })
207    }
208
209    /// Number of true-energy quadrature points.
210    pub fn n_true_energies(&self) -> usize {
211        self.n_true_energies
212    }
213
214    /// Number of measured detector-time bins.
215    pub fn n_detector_bins(&self) -> usize {
216        self.n_detector_bins
217    }
218
219    /// Number of stored nonzero probabilities.
220    pub fn nnz(&self) -> usize {
221        self.probabilities.len()
222    }
223
224    /// Heap bytes used by the compact probability storage.
225    ///
226    /// This excludes the small fixed-size `Self` value, the per-row window-loss
227    /// fractions, and allocator overhead.
228    pub fn storage_bytes(&self) -> usize {
229        self.row_offsets.capacity() * std::mem::size_of::<usize>()
230            + self.detector_bins.capacity() * std::mem::size_of::<u32>()
231            + self.probabilities.capacity() * std::mem::size_of::<f64>()
232    }
233
234    /// Stored `(detector_bin, probability)` pairs for one true-energy row.
235    pub fn row_entries(&self, true_index: usize) -> impl Iterator<Item = (usize, f64)> + '_ {
236        assert!(
237            true_index < self.n_true_energies,
238            "true-energy row out of range"
239        );
240        let start = self.row_offsets[true_index];
241        let end = self.row_offsets[true_index + 1];
242        self.detector_bins[start..end]
243            .iter()
244            .map(|&bin| bin as usize)
245            .zip(self.probabilities[start..end].iter().copied())
246    }
247
248    /// Probability that true-energy row `true_index` lands in detector bin
249    /// `detector_bin`.
250    pub fn probability(&self, true_index: usize, detector_bin: usize) -> f64 {
251        assert!(
252            true_index < self.n_true_energies,
253            "true-energy row out of range"
254        );
255        assert!(
256            detector_bin < self.n_detector_bins,
257            "detector bin out of range"
258        );
259        let start = self.row_offsets[true_index];
260        let end = self.row_offsets[true_index + 1];
261        match self.detector_bins[start..end].binary_search(&(detector_bin as u32)) {
262            Ok(offset) => self.probabilities[start + offset],
263            Err(_) => 0.0,
264        }
265    }
266
267    /// Apply the fixed response separately to open and sample arms.
268    ///
269    /// Numerically equivalent to [`two_arm_count_response`] on the same
270    /// inputs, including the per-arm acquisition-window loss report.
271    pub fn apply(
272        &self,
273        incident_fluence_weights: &[f64],
274        transmission: &[f64],
275    ) -> Result<TwoArmCounts, CountsResponseError> {
276        if incident_fluence_weights.len() != self.n_true_energies
277            || transmission.len() != self.n_true_energies
278        {
279            return Err(CountsResponseError::LengthMismatch {
280                energies: self.n_true_energies,
281                incident_fluence: incident_fluence_weights.len(),
282                transmission: transmission.len(),
283            });
284        }
285        for (index, &fluence) in incident_fluence_weights.iter().enumerate() {
286            if !fluence.is_finite() || fluence < 0.0 {
287                return Err(CountsResponseError::InvalidIncidentFluence {
288                    index,
289                    value: fluence,
290                });
291            }
292        }
293        for (index, &value) in transmission.iter().enumerate() {
294            if !value.is_finite() || !(0.0..=1.0).contains(&value) {
295                return Err(CountsResponseError::InvalidTransmission { index, value });
296            }
297        }
298
299        let mut open_beam = vec![0.0; self.n_detector_bins];
300        let mut sample = vec![0.0; self.n_detector_bins];
301        let mut open_compensation = vec![0.0; self.n_detector_bins];
302        let mut sample_compensation = vec![0.0; self.n_detector_bins];
303        let mut open_window_loss = 0.0;
304        let mut open_window_loss_compensation = 0.0;
305        let mut sample_window_loss = 0.0;
306        let mut sample_window_loss_compensation = 0.0;
307        for true_index in 0..self.n_true_energies {
308            let fluence = incident_fluence_weights[true_index];
309            let sample_weight = fluence * transmission[true_index];
310            let lost = self.lost_fractions[true_index];
311            compensated_add(
312                &mut open_window_loss,
313                &mut open_window_loss_compensation,
314                fluence * lost,
315            );
316            compensated_add(
317                &mut sample_window_loss,
318                &mut sample_window_loss_compensation,
319                sample_weight * lost,
320            );
321            for (detector_bin, probability) in self.row_entries(true_index) {
322                compensated_add(
323                    &mut open_beam[detector_bin],
324                    &mut open_compensation[detector_bin],
325                    fluence * probability,
326                );
327                compensated_add(
328                    &mut sample[detector_bin],
329                    &mut sample_compensation[detector_bin],
330                    sample_weight * probability,
331                );
332            }
333        }
334        for detector_bin in 0..self.n_detector_bins {
335            open_beam[detector_bin] += open_compensation[detector_bin];
336            sample[detector_bin] += sample_compensation[detector_bin];
337        }
338        Ok(TwoArmCounts {
339            open_beam,
340            sample,
341            open_beam_window_loss: open_window_loss + open_window_loss_compensation,
342            sample_window_loss: sample_window_loss + sample_window_loss_compensation,
343        })
344    }
345}
346
347/// Invalid inputs or unsupported response models for [`two_arm_count_response`].
348#[derive(Debug)]
349pub enum CountsResponseError {
350    /// At least one true-energy point is required.
351    EmptyTrueEnergyGrid,
352    /// True-energy, fluence, and transmission arrays must have equal lengths.
353    LengthMismatch {
354        energies: usize,
355        incident_fluence: usize,
356        transmission: usize,
357    },
358    /// A true energy was non-positive or non-finite.
359    InvalidTrueEnergy { index: usize, value: f64 },
360    /// An incident fluence weight was negative or non-finite.
361    InvalidIncidentFluence { index: usize, value: f64 },
362    /// A physical sample transmission was outside `[0, 1]` or non-finite.
363    InvalidTransmission { index: usize, value: f64 },
364    /// A signal or background expected count was negative or non-finite.
365    InvalidExpectedCount {
366        field: &'static str,
367        index: usize,
368        value: f64,
369    },
370    /// Signal and background arrays did not describe the same detector bins.
371    DetectorBinCountMismatch {
372        open_signal: usize,
373        sample_signal: usize,
374        open_background: usize,
375        sample_background: usize,
376    },
377    /// The response could not evaluate detector-bin probabilities.
378    Resolution(ResolutionParseError),
379}
380
381impl fmt::Display for CountsResponseError {
382    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
383        match self {
384            Self::EmptyTrueEnergyGrid => write!(f, "true_energies_ev must not be empty"),
385            Self::LengthMismatch {
386                energies,
387                incident_fluence,
388                transmission,
389            } => write!(
390                f,
391                "true_energies_ev ({energies}), incident_fluence_weights ({incident_fluence}), and transmission ({transmission}) must have equal lengths"
392            ),
393            Self::InvalidTrueEnergy { index, value } => write!(
394                f,
395                "true_energies_ev[{index}] must be positive and finite, got {value}"
396            ),
397            Self::InvalidIncidentFluence { index, value } => write!(
398                f,
399                "incident_fluence_weights[{index}] must be finite and >= 0, got {value}"
400            ),
401            Self::InvalidTransmission { index, value } => write!(
402                f,
403                "transmission[{index}] must be finite and in [0, 1], got {value}"
404            ),
405            Self::InvalidExpectedCount {
406                field,
407                index,
408                value,
409            } => write!(
410                f,
411                "{field}[{index}] must be finite and >= 0 expected counts, got {value}"
412            ),
413            Self::DetectorBinCountMismatch {
414                open_signal,
415                sample_signal,
416                open_background,
417                sample_background,
418            } => write!(
419                f,
420                "open neutron signal ({open_signal} bins), sample neutron signal ({sample_signal}), open_background_counts ({open_background}), and sample_background_counts ({sample_background}) must have equal lengths"
421            ),
422            Self::Resolution(error) => write!(f, "detector-time response failed: {error}"),
423        }
424    }
425}
426
427impl std::error::Error for CountsResponseError {
428    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
429        match self {
430            Self::Resolution(error) => Some(error),
431            _ => None,
432        }
433    }
434}
435
436impl From<ResolutionParseError> for CountsResponseError {
437    fn from(value: ResolutionParseError) -> Self {
438        Self::Resolution(value)
439    }
440}
441
442/// Add detector-bin backgrounds to an already evaluated two-arm neutron signal.
443///
444/// Both background arrays are expected counts for the corresponding complete
445/// acquisition, already mapped into the same detector-time bins as `signal`.
446/// A measured dark or blocked-beam reference may therefore be supplied
447/// directly after applying its independently justified run normalization.
448/// This function does not guess an exposure, fit a smooth curve, or
449/// reinterpret a SAMMY transmission-level background as detector counts.
450///
451/// A sample-scattering calculation that starts from a true-energy spectrum is
452/// not an input here: it must first be propagated through the instrument
453/// response. Only a scattering reference already measured in the detector
454/// bins can enter directly.
455///
456/// The per-arm acquisition-window loss carried by `signal` travels into the
457/// returned prediction unchanged (pipeline-map R5·7). Backgrounds are already
458/// binned into the acquisition, so they add no further loss.
459pub fn add_count_backgrounds(
460    signal: TwoArmCounts,
461    open_background_counts: &[f64],
462    sample_background_counts: &[f64],
463) -> Result<TwoArmCountPrediction, CountsResponseError> {
464    let n_bins = signal.open_beam.len();
465    if signal.sample.len() != n_bins
466        || open_background_counts.len() != n_bins
467        || sample_background_counts.len() != n_bins
468    {
469        return Err(CountsResponseError::DetectorBinCountMismatch {
470            open_signal: n_bins,
471            sample_signal: signal.sample.len(),
472            open_background: open_background_counts.len(),
473            sample_background: sample_background_counts.len(),
474        });
475    }
476
477    validate_expected_counts("open_neutron_signal", &signal.open_beam)?;
478    validate_expected_counts("sample_neutron_signal", &signal.sample)?;
479    validate_expected_counts("open_background_counts", open_background_counts)?;
480    validate_expected_counts("sample_background_counts", sample_background_counts)?;
481    for (field, value) in [
482        ("open_beam_window_loss", signal.open_beam_window_loss),
483        ("sample_window_loss", signal.sample_window_loss),
484    ] {
485        if !value.is_finite() || value < 0.0 {
486            return Err(CountsResponseError::InvalidExpectedCount {
487                field,
488                index: 0,
489                value,
490            });
491        }
492    }
493
494    let open_total = sum_expected_counts(
495        "open_total_expected_counts",
496        &signal.open_beam,
497        open_background_counts,
498    )?;
499    let sample_total = sum_expected_counts(
500        "sample_total_expected_counts",
501        &signal.sample,
502        sample_background_counts,
503    )?;
504
505    Ok(TwoArmCountPrediction {
506        open_beam: ArmCountPrediction {
507            neutron_signal: signal.open_beam,
508            background: open_background_counts.to_vec(),
509            total: open_total,
510            window_loss: signal.open_beam_window_loss,
511        },
512        sample: ArmCountPrediction {
513            neutron_signal: signal.sample,
514            background: sample_background_counts.to_vec(),
515            total: sample_total,
516            window_loss: signal.sample_window_loss,
517        },
518    })
519}
520
521fn validate_expected_counts(
522    field: &'static str,
523    values: &[f64],
524) -> Result<(), CountsResponseError> {
525    for (index, &value) in values.iter().enumerate() {
526        if !value.is_finite() || value < 0.0 {
527            return Err(CountsResponseError::InvalidExpectedCount {
528                field,
529                index,
530                value,
531            });
532        }
533    }
534    Ok(())
535}
536
537fn sum_expected_counts(
538    field: &'static str,
539    neutron_signal: &[f64],
540    background: &[f64],
541) -> Result<Vec<f64>, CountsResponseError> {
542    neutron_signal
543        .iter()
544        .zip(background)
545        .enumerate()
546        .map(|(index, (&neutron, &background))| {
547            let total = neutron + background;
548            if total.is_finite() {
549                Ok(total)
550            } else {
551                Err(CountsResponseError::InvalidExpectedCount {
552                    field,
553                    index,
554                    value: total,
555                })
556            }
557        })
558        .collect()
559}
560
561/// Apply one instrument response to the open-beam and sample arms separately.
562///
563/// `incident_fluence_weights[j]` is an expected neutron count integrated over
564/// the true-energy quadrature element represented by `true_energies_ev[j]`,
565/// with detector efficiency already folded in (`F_j = w_j ε(E_j) Φ(E_j)` —
566/// see the module docs and pipeline-map R5·7). Probability outside
567/// `detector_time_edges_us` is intentionally not renormalized into the
568/// acquisition window; the expected counts lost outside it are reported per
569/// arm on the returned [`TwoArmCounts`].
570///
571/// This function models only source fluence, detector efficiency (inside
572/// `F_j`), sample attenuation, and the instrument response. Detector/gamma/
573/// scattering backgrounds and exposure normalization are separate physical
574/// terms and must be added by a higher layer that states their measurement
575/// location.
576pub fn two_arm_count_response(
577    true_energies_ev: &[f64],
578    incident_fluence_weights: &[f64],
579    transmission: &[f64],
580    detector_time_edges_us: &[f64],
581    timing_offset_us: f64,
582    response: &ResolutionFunction,
583) -> Result<TwoArmCounts, CountsResponseError> {
584    if true_energies_ev.is_empty() {
585        return Err(CountsResponseError::EmptyTrueEnergyGrid);
586    }
587    if incident_fluence_weights.len() != true_energies_ev.len()
588        || transmission.len() != true_energies_ev.len()
589    {
590        return Err(CountsResponseError::LengthMismatch {
591            energies: true_energies_ev.len(),
592            incident_fluence: incident_fluence_weights.len(),
593            transmission: transmission.len(),
594        });
595    }
596    for (index, &energy) in true_energies_ev.iter().enumerate() {
597        if !energy.is_finite() || energy <= 0.0 {
598            return Err(CountsResponseError::InvalidTrueEnergy {
599                index,
600                value: energy,
601            });
602        }
603    }
604    for (index, &fluence) in incident_fluence_weights.iter().enumerate() {
605        if !fluence.is_finite() || fluence < 0.0 {
606            return Err(CountsResponseError::InvalidIncidentFluence {
607                index,
608                value: fluence,
609            });
610        }
611    }
612    for (index, &value) in transmission.iter().enumerate() {
613        if !value.is_finite() || !(0.0..=1.0).contains(&value) {
614            return Err(CountsResponseError::InvalidTransmission { index, value });
615        }
616    }
617
618    // Calling the response once even when every fluence weight is zero is
619    // important: malformed detector edges and unsupported Gaussian responses
620    // must still fail clearly instead of appearing to succeed with all zeros.
621    let n_bins = detector_time_edges_us.len().saturating_sub(1);
622    let mut open_beam = vec![0.0; n_bins];
623    let mut sample = vec![0.0; n_bins];
624    let mut open_compensation = vec![0.0; n_bins];
625    let mut sample_compensation = vec![0.0; n_bins];
626    let mut open_window_loss = 0.0;
627    let mut open_window_loss_compensation = 0.0;
628    let mut sample_window_loss = 0.0;
629    let mut sample_window_loss_compensation = 0.0;
630
631    for ((&energy, &fluence), &sample_transmission) in true_energies_ev
632        .iter()
633        .zip(incident_fluence_weights)
634        .zip(transmission)
635    {
636        let probabilities = response.detector_bin_probabilities(
637            energy,
638            detector_time_edges_us,
639            timing_offset_us,
640        )?;
641        debug_assert_eq!(probabilities.len(), n_bins);
642
643        // Same validity contract as `DetectorBinResponseMatrix::new`, so the
644        // two paths cannot disagree on what a valid response is: without it
645        // a NaN probability would propagate into the arms AND be reported as
646        // zero window loss (`NaN.max(0.0) == 0.0`), i.e. a silent corruption
647        // where the matrix path raises.
648        for (detector_bin, &probability) in probabilities.iter().enumerate() {
649            if !probability.is_finite() || probability < 0.0 {
650                return Err(CountsResponseError::Resolution(
651                    ResolutionParseError::InvalidFormat(format!(
652                        "detector response probability at E = {energy} eV, bin \
653                         {detector_bin} must be finite and >= 0, got {probability}"
654                    )),
655                ));
656            }
657        }
658
659        // The bin probabilities come from a normalized CDF, so the in-window
660        // total is <= 1; the remainder is the quantified acquisition-window
661        // loss disclosed on the result (pipeline-map R5·7).
662        let in_window: f64 = probabilities.iter().sum();
663        let lost = (1.0 - in_window).max(0.0);
664        compensated_add(
665            &mut open_window_loss,
666            &mut open_window_loss_compensation,
667            fluence * lost,
668        );
669        compensated_add(
670            &mut sample_window_loss,
671            &mut sample_window_loss_compensation,
672            fluence * sample_transmission * lost,
673        );
674
675        // Neumaier-compensated accumulation keeps a weak high-energy tail from
676        // being lost when the same bin also contains a much larger prompt term.
677        for (bin, probability) in probabilities.into_iter().enumerate() {
678            compensated_add(
679                &mut open_beam[bin],
680                &mut open_compensation[bin],
681                fluence * probability,
682            );
683            compensated_add(
684                &mut sample[bin],
685                &mut sample_compensation[bin],
686                fluence * sample_transmission * probability,
687            );
688        }
689    }
690    for bin in 0..n_bins {
691        open_beam[bin] += open_compensation[bin];
692        sample[bin] += sample_compensation[bin];
693    }
694
695    Ok(TwoArmCounts {
696        open_beam,
697        sample,
698        open_beam_window_loss: open_window_loss + open_window_loss_compensation,
699        sample_window_loss: sample_window_loss + sample_window_loss_compensation,
700    })
701}
702
703#[inline]
704fn compensated_add(sum: &mut f64, compensation: &mut f64, value: f64) {
705    let next = *sum + value;
706    if sum.abs() >= value.abs() {
707        *compensation += (*sum - next) + value;
708    } else {
709        *compensation += (value - next) + *sum;
710    }
711    *sum = next;
712}