Skip to main content

phasesmith_core/
tof.rs

1//! Neutron time-of-flight calibration, profile parameters, and accumulation.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5use std::sync::Arc;
6
7use crate::fcj::{QUADRATURE_NODES, QUADRATURE_ORDER, QUADRATURE_WEIGHTS};
8use crate::profile::{
9    Accumulation, DenseJacobian, GridView, PatternDerivatives, ProfileError, SupportJacobian,
10    SupportRange, zeroed_f64_vec,
11};
12use crate::tch::{TchError, TchShape, TchWidths};
13use phasesmith_execution::ExecutionContext;
14
15const GAUSSIAN_FWHM_PER_SIGMA: f64 = 2.354_820_045_030_949_3;
16/// Public order: zero, difC, difA, difB, alpha, beta0, beta1, betaq,
17/// sigma0, sigma1, sigma2, sigmaq, X, Y, Z.
18pub const TOF_GLOBAL_PARAMETER_COUNT: usize = 15;
19const LOCAL_PARAMETER_COUNT: usize = 2;
20const TOF_QUADRATURE_PANELS: usize = 8;
21const TOF_QUADRATURE_PANELS_F64: f64 = 8.0;
22const TOF_SUPPORT_QUADRATURE_PANELS: usize = 4;
23const TOF_SUPPORT_QUADRATURE_PANELS_F64: f64 = 4.0;
24const TOF_QUADRATURE_COUNT: usize = TOF_QUADRATURE_PANELS * QUADRATURE_ORDER;
25
26/// TOF calibration and d-dependent profile coefficients in public units.
27#[derive(Clone, Copy, Debug, PartialEq)]
28pub struct TofInstrument {
29    /// Additive time zero in microseconds.
30    pub zero_us: f64,
31    /// Linear calibration coefficient in microseconds per ångström.
32    pub difc_us_per_angstrom: f64,
33    /// Quadratic calibration coefficient in microseconds per ångström squared.
34    pub difa_us_per_angstrom2: f64,
35    /// Reciprocal calibration coefficient in microsecond ångströms.
36    pub difb_us_angstrom: f64,
37    /// Exponential-rise numerator; `alpha = alpha_coefficient / d`.
38    pub alpha_coefficient: f64,
39    /// Constant exponential-decay rate term in inverse microseconds.
40    pub beta0_per_us: f64,
41    /// `d^-4` exponential-decay coefficient.
42    pub beta1_angstrom4_per_us: f64,
43    /// `d^-2` exponential-decay coefficient.
44    pub betaq_angstrom2_per_us: f64,
45    /// Constant Gaussian variance term in microseconds squared.
46    pub sigma0_us2: f64,
47    /// `d^2` Gaussian variance coefficient.
48    pub sigma1_us2_per_angstrom2: f64,
49    /// `d^4` Gaussian variance coefficient.
50    pub sigma2_us2_per_angstrom4: f64,
51    /// Linear-d Gaussian variance coefficient.
52    pub sigmaq_us2_per_angstrom: f64,
53    /// Linear-d Lorentzian FWHM coefficient.
54    pub x_us_per_angstrom: f64,
55    /// Quadratic-d Lorentzian FWHM coefficient.
56    pub y_us_per_angstrom2: f64,
57    /// Constant Lorentzian FWHM in microseconds.
58    pub z_us: f64,
59}
60
61impl TofInstrument {
62    /// Validate finite coefficients and a positive linear calibration scale.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`TofError`] for non-finite coefficients or non-positive
67    /// linear calibration.
68    pub fn validate(self) -> Result<(), TofError> {
69        let values = [
70            self.zero_us,
71            self.difc_us_per_angstrom,
72            self.difa_us_per_angstrom2,
73            self.difb_us_angstrom,
74            self.alpha_coefficient,
75            self.beta0_per_us,
76            self.beta1_angstrom4_per_us,
77            self.betaq_angstrom2_per_us,
78            self.sigma0_us2,
79            self.sigma1_us2_per_angstrom2,
80            self.sigma2_us2_per_angstrom4,
81            self.sigmaq_us2_per_angstrom,
82            self.x_us_per_angstrom,
83            self.y_us_per_angstrom2,
84            self.z_us,
85        ];
86        if values.iter().any(|value| !value.is_finite()) {
87            return Err(TofError::NonFiniteInstrumentParameter);
88        }
89        if self.difc_us_per_angstrom <= 0.0 {
90            return Err(TofError::NonPositiveDifc);
91        }
92        Ok(())
93    }
94}
95
96/// Derived TOF position, widths, exponential rates, and analytical chains.
97#[derive(Clone, Copy, Debug, PartialEq)]
98pub struct TofProfileParameters {
99    /// Calibrated position in microseconds.
100    pub position_us: f64,
101    /// Leading-edge exponential rate in inverse microseconds.
102    pub alpha_per_us: f64,
103    /// Trailing-edge exponential rate in inverse microseconds.
104    pub beta_per_us: f64,
105    /// Gaussian variance in microseconds squared.
106    pub gaussian_variance_us2: f64,
107    /// Gaussian component FWHM in microseconds.
108    pub gaussian_fwhm_us: f64,
109    /// Lorentzian component FWHM in microseconds.
110    pub lorentzian_fwhm_us: f64,
111    /// TCH transform of the component widths.
112    pub tch: TchShape,
113    /// Direct-input derivatives with respect to d-spacing.
114    pub d_position_d_d: f64,
115    /// Alpha-rate derivative with respect to d-spacing.
116    pub d_alpha_d_d: f64,
117    /// Beta-rate derivative with respect to d-spacing.
118    pub d_beta_d_d: f64,
119    /// Gaussian-FWHM derivative with respect to d-spacing.
120    pub d_gaussian_fwhm_d_d: f64,
121    /// Lorentzian-FWHM derivative with respect to d-spacing.
122    pub d_lorentzian_fwhm_d_d: f64,
123    /// Parameter-major chains in [`TOF_GLOBAL_PARAMETER_COUNT`] order.
124    pub d_position_d_instrument: [f64; TOF_GLOBAL_PARAMETER_COUNT],
125    /// Alpha-rate chains in global instrument order.
126    pub d_alpha_d_instrument: [f64; TOF_GLOBAL_PARAMETER_COUNT],
127    /// Beta-rate chains in global instrument order.
128    pub d_beta_d_instrument: [f64; TOF_GLOBAL_PARAMETER_COUNT],
129    /// Gaussian-FWHM chains in global instrument order.
130    pub d_gaussian_fwhm_d_instrument: [f64; TOF_GLOBAL_PARAMETER_COUNT],
131    /// Lorentzian-FWHM chains in global instrument order.
132    pub d_lorentzian_fwhm_d_instrument: [f64; TOF_GLOBAL_PARAMETER_COUNT],
133}
134
135impl TofProfileParameters {
136    /// Derive all profile quantities for one positive d-spacing.
137    ///
138    /// # Errors
139    ///
140    /// Returns [`TofError`] for invalid inputs or nonphysical derived profile
141    /// parameters.
142    pub fn from_instrument(d: f64, instrument: TofInstrument) -> Result<Self, TofError> {
143        instrument.validate()?;
144        if !d.is_finite() || d <= 0.0 {
145            return Err(TofError::InvalidDSpacing);
146        }
147        let d2 = d * d;
148        let d3 = d2 * d;
149        let d4 = d2 * d2;
150        let inverse_d = d.recip();
151        let inverse_d2 = inverse_d * inverse_d;
152        let inverse_d3 = inverse_d2 * inverse_d;
153        let inverse_d4 = inverse_d2 * inverse_d2;
154        let inverse_d5 = inverse_d4 * inverse_d;
155        let position_us = instrument.zero_us
156            + instrument.difc_us_per_angstrom * d
157            + instrument.difa_us_per_angstrom2 * d2
158            + instrument.difb_us_angstrom * inverse_d;
159        let alpha_per_us = instrument.alpha_coefficient * inverse_d;
160        let beta_per_us = instrument.beta0_per_us
161            + instrument.beta1_angstrom4_per_us * inverse_d4
162            + instrument.betaq_angstrom2_per_us * inverse_d2;
163        let gaussian_variance_us2 = instrument.sigma0_us2
164            + instrument.sigma1_us2_per_angstrom2 * d2
165            + instrument.sigma2_us2_per_angstrom4 * d4
166            + instrument.sigmaq_us2_per_angstrom * d;
167        let lorentzian_fwhm_us =
168            instrument.z_us + instrument.x_us_per_angstrom * d + instrument.y_us_per_angstrom2 * d2;
169        if !position_us.is_finite() {
170            return Err(TofError::InvalidPosition);
171        }
172        if !alpha_per_us.is_finite() || alpha_per_us <= 0.0 {
173            return Err(TofError::NonPositiveAlpha);
174        }
175        if !beta_per_us.is_finite() || beta_per_us <= 0.0 {
176            return Err(TofError::NonPositiveBeta);
177        }
178        if !gaussian_variance_us2.is_finite() || gaussian_variance_us2 <= 0.0 {
179            return Err(TofError::NonPositiveGaussianVariance);
180        }
181        if !lorentzian_fwhm_us.is_finite() || lorentzian_fwhm_us < 0.0 {
182            return Err(TofError::NegativeLorentzianFwhm);
183        }
184        let sigma = gaussian_variance_us2.sqrt();
185        let gaussian_fwhm_us = GAUSSIAN_FWHM_PER_SIGMA * sigma;
186        let tch = TchShape::from_component_fwhm(TchWidths {
187            gaussian_fwhm: gaussian_fwhm_us,
188            lorentzian_fwhm: lorentzian_fwhm_us,
189        })
190        .map_err(|reason| TofError::InvalidTch { reason })?;
191        let d_gaussian_d_variance = GAUSSIAN_FWHM_PER_SIGMA / (2.0 * sigma);
192        let d_variance_d_d = 2.0 * instrument.sigma1_us2_per_angstrom2 * d
193            + 4.0 * instrument.sigma2_us2_per_angstrom4 * d3
194            + instrument.sigmaq_us2_per_angstrom;
195
196        let mut d_position_d_instrument = [0.0; TOF_GLOBAL_PARAMETER_COUNT];
197        d_position_d_instrument[..4].copy_from_slice(&[1.0, d, d2, inverse_d]);
198        let mut d_alpha_d_instrument = [0.0; TOF_GLOBAL_PARAMETER_COUNT];
199        d_alpha_d_instrument[4] = inverse_d;
200        let mut d_beta_d_instrument = [0.0; TOF_GLOBAL_PARAMETER_COUNT];
201        d_beta_d_instrument[5..8].copy_from_slice(&[1.0, inverse_d4, inverse_d2]);
202        let mut d_gaussian_fwhm_d_instrument = [0.0; TOF_GLOBAL_PARAMETER_COUNT];
203        d_gaussian_fwhm_d_instrument[8..12].copy_from_slice(&[
204            d_gaussian_d_variance,
205            d_gaussian_d_variance * d2,
206            d_gaussian_d_variance * d4,
207            d_gaussian_d_variance * d,
208        ]);
209        let mut d_lorentzian_fwhm_d_instrument = [0.0; TOF_GLOBAL_PARAMETER_COUNT];
210        d_lorentzian_fwhm_d_instrument[12..15].copy_from_slice(&[d, d2, 1.0]);
211        Ok(Self {
212            position_us,
213            alpha_per_us,
214            beta_per_us,
215            gaussian_variance_us2,
216            gaussian_fwhm_us,
217            lorentzian_fwhm_us,
218            tch,
219            d_position_d_d: instrument.difc_us_per_angstrom
220                + 2.0 * instrument.difa_us_per_angstrom2 * d
221                - instrument.difb_us_angstrom * inverse_d2,
222            d_alpha_d_d: -instrument.alpha_coefficient * inverse_d2,
223            d_beta_d_d: -4.0 * instrument.beta1_angstrom4_per_us * inverse_d5
224                - 2.0 * instrument.betaq_angstrom2_per_us * inverse_d3,
225            d_gaussian_fwhm_d_d: d_gaussian_d_variance * d_variance_d_d,
226            d_lorentzian_fwhm_d_d: instrument.x_us_per_angstrom
227                + 2.0 * instrument.y_us_per_angstrom2 * d,
228            d_position_d_instrument,
229            d_alpha_d_instrument,
230            d_beta_d_instrument,
231            d_gaussian_fwhm_d_instrument,
232            d_lorentzian_fwhm_d_instrument,
233        })
234    }
235}
236
237/// One asymmetric TOF profile value and direct-input derivatives.
238#[derive(Clone, Copy, Debug, Default, PartialEq)]
239pub struct TofProfilePoint {
240    /// Unit-area profile density in inverse microseconds.
241    pub value: f64,
242    /// Derivative with respect to ideal position in microseconds.
243    pub d_position: f64,
244    /// Derivative with respect to the leading-edge alpha rate.
245    pub d_alpha: f64,
246    /// Derivative with respect to the trailing-edge beta rate.
247    pub d_beta: f64,
248    /// Derivative with respect to Gaussian component FWHM.
249    pub d_gaussian_fwhm: f64,
250    /// Derivative with respect to Lorentzian component FWHM.
251    pub d_lorentzian_fwhm: f64,
252}
253
254/// Prepared truncated double-exponential convolution of a TCH profile.
255#[derive(Clone, Debug)]
256pub struct TofProfile {
257    shape: TchShape,
258    alpha: f64,
259    beta: f64,
260    quadrature: Arc<TofQuadrature>,
261}
262
263#[derive(Debug)]
264struct TofQuadrature {
265    tail_log: f64,
266    nodes: [f64; TOF_QUADRATURE_COUNT],
267    weights: [f64; TOF_QUADRATURE_COUNT],
268}
269
270impl TofQuadrature {
271    fn new(tail_log: f64) -> Result<Self, TofError> {
272        if !tail_log.is_finite() || tail_log <= 0.0 {
273            return Err(TofError::InvalidTailLog);
274        }
275        let mut nodes = [0.0; TOF_QUADRATURE_COUNT];
276        let mut weights = [0.0; TOF_QUADRATURE_COUNT];
277        let mut normalization = 0.0;
278        let panel_scale = TOF_QUADRATURE_PANELS_F64.recip();
279        let mut panel_offset = 0.0;
280        for panel in 0..TOF_QUADRATURE_PANELS {
281            for quadrature in 0..QUADRATURE_ORDER {
282                let index = panel * QUADRATURE_ORDER + quadrature;
283                let unit_node = panel_offset + panel_scale * QUADRATURE_NODES[quadrature];
284                nodes[index] = tail_log * unit_node;
285                weights[index] =
286                    tail_log * panel_scale * QUADRATURE_WEIGHTS[quadrature] * (-nodes[index]).exp();
287                normalization += weights[index];
288            }
289            panel_offset += panel_scale;
290        }
291        if !normalization.is_finite() || normalization <= 0.0 {
292            return Err(TofError::InvalidQuadrature);
293        }
294        for weight in &mut weights {
295            *weight /= normalization;
296        }
297        Ok(Self {
298            tail_log,
299            nodes,
300            weights,
301        })
302    }
303}
304
305impl TofProfile {
306    /// Prepare a unit-area profile. Both exponential tails are truncated at
307    /// `exp(-tail_log)` and renormalized before convolution.
308    ///
309    /// # Errors
310    ///
311    /// Returns [`TofError`] for invalid rates, widths, tail cutoff, or
312    /// quadrature normalization.
313    pub fn new(
314        alpha_per_us: f64,
315        beta_per_us: f64,
316        widths: TchWidths,
317        tail_log: f64,
318    ) -> Result<Self, TofError> {
319        Self::validate_rates(alpha_per_us, beta_per_us)?;
320        let quadrature = Arc::new(TofQuadrature::new(tail_log)?);
321        Self::from_validated_rates(alpha_per_us, beta_per_us, widths, quadrature)
322    }
323
324    fn validate_rates(alpha_per_us: f64, beta_per_us: f64) -> Result<(), TofError> {
325        if !alpha_per_us.is_finite() || alpha_per_us <= 0.0 {
326            return Err(TofError::NonPositiveAlpha);
327        }
328        if !beta_per_us.is_finite() || beta_per_us <= 0.0 {
329            return Err(TofError::NonPositiveBeta);
330        }
331        Ok(())
332    }
333
334    fn from_validated_rates(
335        alpha_per_us: f64,
336        beta_per_us: f64,
337        widths: TchWidths,
338        quadrature: Arc<TofQuadrature>,
339    ) -> Result<Self, TofError> {
340        let shape = TchShape::from_component_fwhm(widths)
341            .map_err(|reason| TofError::InvalidTch { reason })?;
342        Ok(Self {
343            shape,
344            alpha: alpha_per_us,
345            beta: beta_per_us,
346            quadrature,
347        })
348    }
349
350    /// Evaluate without finite TCH support truncation.
351    #[must_use]
352    pub fn evaluate(&self, x_minus_position_us: f64) -> TofProfilePoint {
353        self.evaluate_with_radius(x_minus_position_us, f64::INFINITY)
354    }
355
356    fn evaluate_with_radius(&self, delta: f64, base_radius: f64) -> TofProfilePoint {
357        if base_radius.is_finite() {
358            return self.evaluate_supported(delta, base_radius);
359        }
360        let sum = self.alpha + self.beta;
361        let left_fraction = self.beta / sum;
362        let right_fraction = self.alpha / sum;
363        let d_left_d_alpha = -self.beta / (sum * sum);
364        let d_left_d_beta = self.alpha / (sum * sum);
365        let mut left = TofProfilePoint::default();
366        let mut right = TofProfilePoint::default();
367        let mut left_alpha_shift = 0.0;
368        let mut right_beta_shift = 0.0;
369        for index in 0..TOF_QUADRATURE_COUNT {
370            let node = self.quadrature.nodes[index];
371            let weight = self.quadrature.weights[index];
372            let left_delta = delta + node / self.alpha;
373            if left_delta.abs() <= base_radius {
374                let point = self.shape.evaluate(left_delta);
375                left.value += weight * point.value;
376                left.d_position += weight * point.d_delta;
377                left.d_gaussian_fwhm += weight * point.d_gaussian_fwhm;
378                left.d_lorentzian_fwhm += weight * point.d_lorentzian_fwhm;
379                left_alpha_shift += weight * point.d_delta * (-node / self.alpha.powi(2));
380            }
381            let right_delta = delta - node / self.beta;
382            if right_delta.abs() <= base_radius {
383                let point = self.shape.evaluate(right_delta);
384                right.value += weight * point.value;
385                right.d_position += weight * point.d_delta;
386                right.d_gaussian_fwhm += weight * point.d_gaussian_fwhm;
387                right.d_lorentzian_fwhm += weight * point.d_lorentzian_fwhm;
388                right_beta_shift += weight * point.d_delta * (node / self.beta.powi(2));
389            }
390        }
391        TofProfilePoint {
392            value: left_fraction * left.value + right_fraction * right.value,
393            d_position: -(left_fraction * left.d_position + right_fraction * right.d_position),
394            d_alpha: d_left_d_alpha * left.value + left_fraction * left_alpha_shift
395                - d_left_d_alpha * right.value,
396            d_beta: d_left_d_beta * left.value + right_fraction * right_beta_shift
397                - d_left_d_beta * right.value,
398            d_gaussian_fwhm: left_fraction * left.d_gaussian_fwhm
399                + right_fraction * right.d_gaussian_fwhm,
400            d_lorentzian_fwhm: left_fraction * left.d_lorentzian_fwhm
401                + right_fraction * right.d_lorentzian_fwhm,
402        }
403    }
404
405    fn evaluate_supported(&self, delta: f64, base_radius: f64) -> TofProfilePoint {
406        let sum = self.alpha + self.beta;
407        let left_fraction = self.beta / sum;
408        let right_fraction = self.alpha / sum;
409        let d_left_d_alpha = -self.beta / (sum * sum);
410        let d_left_d_beta = self.alpha / (sum * sum);
411        let tail_log = self.quadrature.tail_log;
412        let normalization = 1.0 - (-tail_log).exp();
413        let left_low = (self.alpha * (-base_radius - delta)).clamp(0.0, tail_log);
414        let left_high = (self.alpha * (base_radius - delta)).clamp(0.0, tail_log);
415        let right_low = (self.beta * (delta - base_radius)).clamp(0.0, tail_log);
416        let right_high = (self.beta * (delta + base_radius)).clamp(0.0, tail_log);
417        let mut left = TofProfilePoint::default();
418        let mut right = TofProfilePoint::default();
419        let mut left_alpha_shift = 0.0;
420        let mut right_beta_shift = 0.0;
421
422        if left_low < left_high {
423            let panel_width = (left_high - left_low) / TOF_SUPPORT_QUADRATURE_PANELS_F64;
424            let mut panel_left = left_low;
425            for _ in 0..TOF_SUPPORT_QUADRATURE_PANELS {
426                for quadrature in 0..QUADRATURE_ORDER {
427                    let node = panel_left + panel_width * QUADRATURE_NODES[quadrature];
428                    let weight = panel_width * QUADRATURE_WEIGHTS[quadrature] * (-node).exp()
429                        / normalization;
430                    let point = self.shape.evaluate(delta + node / self.alpha);
431                    left.value += weight * point.value;
432                    left.d_position += weight * point.d_delta;
433                    left.d_gaussian_fwhm += weight * point.d_gaussian_fwhm;
434                    left.d_lorentzian_fwhm += weight * point.d_lorentzian_fwhm;
435                    left_alpha_shift += weight * point.d_delta * (-node / self.alpha.powi(2));
436                }
437                panel_left += panel_width;
438            }
439        }
440        if right_low < right_high {
441            let panel_width = (right_high - right_low) / TOF_SUPPORT_QUADRATURE_PANELS_F64;
442            let mut panel_left = right_low;
443            for _ in 0..TOF_SUPPORT_QUADRATURE_PANELS {
444                for quadrature in 0..QUADRATURE_ORDER {
445                    let node = panel_left + panel_width * QUADRATURE_NODES[quadrature];
446                    let weight = panel_width * QUADRATURE_WEIGHTS[quadrature] * (-node).exp()
447                        / normalization;
448                    let point = self.shape.evaluate(delta - node / self.beta);
449                    right.value += weight * point.value;
450                    right.d_position += weight * point.d_delta;
451                    right.d_gaussian_fwhm += weight * point.d_gaussian_fwhm;
452                    right.d_lorentzian_fwhm += weight * point.d_lorentzian_fwhm;
453                    right_beta_shift += weight * point.d_delta * (node / self.beta.powi(2));
454                }
455                panel_left += panel_width;
456            }
457        }
458        TofProfilePoint {
459            value: left_fraction * left.value + right_fraction * right.value,
460            d_position: -(left_fraction * left.d_position + right_fraction * right.d_position),
461            d_alpha: d_left_d_alpha * left.value + left_fraction * left_alpha_shift
462                - d_left_d_alpha * right.value,
463            d_beta: d_left_d_beta * left.value + right_fraction * right_beta_shift
464                - d_left_d_beta * right.value,
465            d_gaussian_fwhm: left_fraction * left.d_gaussian_fwhm
466                + right_fraction * right.d_gaussian_fwhm,
467            d_lorentzian_fwhm: left_fraction * left.d_lorentzian_fwhm
468                + right_fraction * right.d_lorentzian_fwhm,
469        }
470    }
471
472    fn support_range(&self, position: f64, base_radius: f64) -> SupportRange {
473        SupportRange {
474            left: position - base_radius - self.quadrature.tail_log / self.alpha,
475            right: position + base_radius + self.quadrature.tail_log / self.beta,
476        }
477    }
478}
479
480/// TOF domain or accumulation error.
481#[derive(Clone, Debug, PartialEq)]
482pub enum TofError {
483    /// At least one instrument coefficient is non-finite.
484    NonFiniteInstrumentParameter,
485    /// The linear calibration coefficient is not positive.
486    NonPositiveDifc,
487    /// A reflection d-spacing is not positive and finite.
488    InvalidDSpacing,
489    /// Calibration produced a non-finite position.
490    InvalidPosition,
491    /// The derived leading-edge exponential rate is invalid.
492    NonPositiveAlpha,
493    /// The derived trailing-edge exponential rate is invalid.
494    NonPositiveBeta,
495    /// The derived Gaussian variance is invalid.
496    NonPositiveGaussianVariance,
497    /// The derived Lorentzian width is invalid.
498    NegativeLorentzianFwhm,
499    /// The requested exponential tail cutoff is invalid.
500    InvalidTailLog,
501    /// Quadrature normalization failed.
502    InvalidQuadrature,
503    /// Component widths could not be transformed into a TCH shape.
504    InvalidTch {
505        /// Underlying component-width transformation failure.
506        reason: TchError,
507    },
508    /// Reflection d-spacing and intensity arrays have different lengths.
509    LengthMismatch,
510    /// One reflection intensity is non-finite.
511    NonFiniteIntensity {
512        /// Index of the invalid reflection.
513        reflection: usize,
514    },
515    /// Allocation-size arithmetic overflowed or allocation failed.
516    AllocationOverflow,
517    /// Grid or support validation failed.
518    Profile {
519        /// Underlying grid or support failure.
520        reason: ProfileError,
521    },
522}
523
524impl Display for TofError {
525    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
526        match self {
527            Self::NonFiniteInstrumentParameter => {
528                write!(formatter, "TOF coefficients must be finite")
529            }
530            Self::NonPositiveDifc => write!(formatter, "difC must be positive"),
531            Self::InvalidDSpacing => write!(formatter, "d-spacing must be positive and finite"),
532            Self::InvalidPosition => write!(formatter, "derived TOF position must be finite"),
533            Self::NonPositiveAlpha => write!(formatter, "TOF alpha must be positive and finite"),
534            Self::NonPositiveBeta => write!(formatter, "TOF beta must be positive and finite"),
535            Self::NonPositiveGaussianVariance => write!(
536                formatter,
537                "derived TOF Gaussian variance must be positive and finite"
538            ),
539            Self::NegativeLorentzianFwhm => write!(
540                formatter,
541                "derived TOF Lorentzian FWHM must be non-negative and finite"
542            ),
543            Self::InvalidTailLog => write!(formatter, "tail_log must be positive and finite"),
544            Self::InvalidQuadrature => write!(formatter, "TOF quadrature normalization is invalid"),
545            Self::InvalidTch { reason } => write!(formatter, "invalid TOF TCH widths: {reason}"),
546            Self::LengthMismatch => {
547                write!(formatter, "TOF reflection arrays must have equal length")
548            }
549            Self::NonFiniteIntensity { reflection } => write!(
550                formatter,
551                "reflection {reflection} intensity must be finite"
552            ),
553            Self::AllocationOverflow => write!(formatter, "TOF allocation size overflow"),
554            Self::Profile { reason } => Display::fmt(reason, formatter),
555        }
556    }
557}
558
559impl Error for TofError {}
560
561impl From<ProfileError> for TofError {
562    fn from(reason: ProfileError) -> Self {
563        Self::Profile { reason }
564    }
565}
566
567struct PreparedReflection {
568    parameters: TofProfileParameters,
569    profile: TofProfile,
570}
571
572struct TofReflectionBlock {
573    start: usize,
574    y: Vec<f64>,
575    local: Vec<f64>,
576    global: Vec<f64>,
577}
578
579/// Fused finite-support TOF accumulation with local intensity/d-spacing rows.
580///
581/// # Errors
582///
583/// Returns [`TofError`] for invalid grids, reflection arrays, coefficients,
584/// derived profiles, supports, or checked allocation failures.
585#[allow(clippy::too_many_lines)]
586pub fn accumulate_tof_batch(
587    grid: GridView<'_>,
588    d_spacings: &[f64],
589    intensities: &[f64],
590    instrument: TofInstrument,
591    support_fwhm: f64,
592    tail_log: f64,
593) -> Result<Accumulation, TofError> {
594    accumulate_tof_batch_with_context(
595        grid,
596        d_spacings,
597        intensities,
598        instrument,
599        support_fwhm,
600        tail_log,
601        &ExecutionContext::serial(),
602    )
603}
604
605/// Accumulate TOF reflections with a bounded execution context.
606///
607/// # Errors
608///
609/// Returns [`TofError`] for invalid inputs, profiles, supports, or allocations.
610#[allow(clippy::too_many_lines)]
611pub fn accumulate_tof_batch_with_context(
612    grid: GridView<'_>,
613    d_spacings: &[f64],
614    intensities: &[f64],
615    instrument: TofInstrument,
616    support_fwhm: f64,
617    tail_log: f64,
618    execution: &ExecutionContext,
619) -> Result<Accumulation, TofError> {
620    if d_spacings.len() != intensities.len() {
621        return Err(TofError::LengthMismatch);
622    }
623    if !support_fwhm.is_finite() || support_fwhm <= 0.0 {
624        return Err(TofError::Profile {
625            reason: ProfileError::InvalidSupport,
626        });
627    }
628    instrument.validate()?;
629    let x = grid.as_slice();
630    let count = d_spacings.len();
631    let mut prepared = Vec::new();
632    let mut starts = Vec::new();
633    let mut offsets = Vec::new();
634    prepared
635        .try_reserve_exact(count)
636        .map_err(|_| TofError::AllocationOverflow)?;
637    starts
638        .try_reserve_exact(count)
639        .map_err(|_| TofError::AllocationOverflow)?;
640    let offset_count = count.checked_add(1).ok_or(TofError::AllocationOverflow)?;
641    offsets
642        .try_reserve_exact(offset_count)
643        .map_err(|_| TofError::AllocationOverflow)?;
644    offsets.push(0usize);
645    let mut shared_quadrature = None;
646    for reflection in 0..count {
647        if !intensities[reflection].is_finite() {
648            return Err(TofError::NonFiniteIntensity { reflection });
649        }
650        let parameters = TofProfileParameters::from_instrument(d_spacings[reflection], instrument)?;
651        TofProfile::validate_rates(parameters.alpha_per_us, parameters.beta_per_us)?;
652        let quadrature = if let Some(quadrature) = &shared_quadrature {
653            Arc::clone(quadrature)
654        } else {
655            let quadrature = Arc::new(TofQuadrature::new(tail_log)?);
656            shared_quadrature = Some(Arc::clone(&quadrature));
657            quadrature
658        };
659        let profile = TofProfile::from_validated_rates(
660            parameters.alpha_per_us,
661            parameters.beta_per_us,
662            TchWidths {
663                gaussian_fwhm: parameters.gaussian_fwhm_us,
664                lorentzian_fwhm: parameters.lorentzian_fwhm_us,
665            },
666            quadrature,
667        )?;
668        let base_radius = support_fwhm * parameters.tch.total_fwhm;
669        let range = profile.support_range(parameters.position_us, base_radius);
670        let lower = x.partition_point(|value| *value < range.left);
671        let upper = x.partition_point(|value| *value <= range.right);
672        offsets.push(
673            offsets[reflection]
674                .checked_add(upper - lower)
675                .ok_or(TofError::AllocationOverflow)?,
676        );
677        starts.push(lower);
678        prepared.push(PreparedReflection {
679            parameters,
680            profile,
681        });
682    }
683    let active = offsets.last().copied().unwrap_or(0);
684    let mut y = zeroed_f64_vec(x.len())?;
685    let mut local = zeroed_f64_vec(
686        active
687            .checked_mul(LOCAL_PARAMETER_COUNT)
688            .ok_or(TofError::AllocationOverflow)?,
689    )?;
690    let mut global = zeroed_f64_vec(
691        TOF_GLOBAL_PARAMETER_COUNT
692            .checked_mul(x.len())
693            .ok_or(TofError::AllocationOverflow)?,
694    )?;
695    if execution.threads() == 1 || count < 16 {
696        for reflection in 0..count {
697            let item = &prepared[reflection];
698            let intensity = intensities[reflection];
699            let base_radius = support_fwhm * item.parameters.tch.total_fwhm;
700            let begin = offsets[reflection];
701            let end = offsets[reflection + 1];
702            for active_index in begin..end {
703                let sample = starts[reflection] + active_index - begin;
704                let point = item
705                    .profile
706                    .evaluate_with_radius(x[sample] - item.parameters.position_us, base_radius);
707                y[sample] += intensity * point.value;
708                local[active_index * LOCAL_PARAMETER_COUNT] = point.value;
709                local[active_index * LOCAL_PARAMETER_COUNT + 1] = intensity
710                    * (point.d_position * item.parameters.d_position_d_d
711                        + point.d_alpha * item.parameters.d_alpha_d_d
712                        + point.d_beta * item.parameters.d_beta_d_d
713                        + point.d_gaussian_fwhm * item.parameters.d_gaussian_fwhm_d_d
714                        + point.d_lorentzian_fwhm * item.parameters.d_lorentzian_fwhm_d_d);
715                for parameter in 0..TOF_GLOBAL_PARAMETER_COUNT {
716                    let derivative = point.d_position
717                        * item.parameters.d_position_d_instrument[parameter]
718                        + point.d_alpha * item.parameters.d_alpha_d_instrument[parameter]
719                        + point.d_beta * item.parameters.d_beta_d_instrument[parameter]
720                        + point.d_gaussian_fwhm
721                            * item.parameters.d_gaussian_fwhm_d_instrument[parameter]
722                        + point.d_lorentzian_fwhm
723                            * item.parameters.d_lorentzian_fwhm_d_instrument[parameter];
724                    global[parameter * x.len() + sample] += intensity * derivative;
725                }
726            }
727        }
728    } else {
729        let blocks = execution.map_ordered(count, 16, |reflection| {
730            let item = &prepared[reflection];
731            let intensity = intensities[reflection];
732            let base_radius = support_fwhm * item.parameters.tch.total_fwhm;
733            let begin = offsets[reflection];
734            let end = offsets[reflection + 1];
735            let support_count = end - begin;
736            let mut block = TofReflectionBlock {
737                start: starts[reflection],
738                y: vec![0.0; support_count],
739                local: vec![0.0; support_count * LOCAL_PARAMETER_COUNT],
740                global: vec![0.0; support_count * TOF_GLOBAL_PARAMETER_COUNT],
741            };
742            for support_index in 0..support_count {
743                let sample = block.start + support_index;
744                let point = item
745                    .profile
746                    .evaluate_with_radius(x[sample] - item.parameters.position_us, base_radius);
747                block.y[support_index] = intensity * point.value;
748                block.local[support_index * LOCAL_PARAMETER_COUNT] = point.value;
749                block.local[support_index * LOCAL_PARAMETER_COUNT + 1] = intensity
750                    * (point.d_position * item.parameters.d_position_d_d
751                        + point.d_alpha * item.parameters.d_alpha_d_d
752                        + point.d_beta * item.parameters.d_beta_d_d
753                        + point.d_gaussian_fwhm * item.parameters.d_gaussian_fwhm_d_d
754                        + point.d_lorentzian_fwhm * item.parameters.d_lorentzian_fwhm_d_d);
755                for parameter in 0..TOF_GLOBAL_PARAMETER_COUNT {
756                    let derivative = point.d_position
757                        * item.parameters.d_position_d_instrument[parameter]
758                        + point.d_alpha * item.parameters.d_alpha_d_instrument[parameter]
759                        + point.d_beta * item.parameters.d_beta_d_instrument[parameter]
760                        + point.d_gaussian_fwhm
761                            * item.parameters.d_gaussian_fwhm_d_instrument[parameter]
762                        + point.d_lorentzian_fwhm
763                            * item.parameters.d_lorentzian_fwhm_d_instrument[parameter];
764                    block.global[parameter * support_count + support_index] =
765                        intensity * derivative;
766                }
767            }
768            block
769        });
770        for (reflection, block) in blocks.into_iter().enumerate() {
771            let begin = offsets[reflection];
772            let support_count = block.y.len();
773            let local_begin = begin * LOCAL_PARAMETER_COUNT;
774            let local_end = local_begin + block.local.len();
775            local[local_begin..local_end].copy_from_slice(&block.local);
776            for support_index in 0..support_count {
777                let sample = block.start + support_index;
778                y[sample] += block.y[support_index];
779                for parameter in 0..TOF_GLOBAL_PARAMETER_COUNT {
780                    global[parameter * x.len() + sample] +=
781                        block.global[parameter * support_count + support_index];
782                }
783            }
784        }
785    }
786    Ok(Accumulation {
787        y,
788        derivatives: PatternDerivatives {
789            local: SupportJacobian {
790                starts,
791                offsets,
792                values: local,
793                parameter_count: LOCAL_PARAMETER_COUNT,
794            },
795            global: Some(DenseJacobian {
796                values: global,
797                parameter_count: TOF_GLOBAL_PARAMETER_COUNT,
798                sample_count: x.len(),
799            }),
800        },
801        sample_count: x.len(),
802    })
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808
809    fn instrument() -> TofInstrument {
810        TofInstrument {
811            zero_us: -0.773_346_536_757,
812            difc_us_per_angstrom: 5_084.827_630_65,
813            difa_us_per_angstrom2: -2.630_417_748_6,
814            difb_us_angstrom: 0.0,
815            alpha_coefficient: 5.0,
816            beta0_per_us: 0.033_276_398_966_5,
817            beta1_angstrom4_per_us: 0.000_964_057_827_372,
818            betaq_angstrom2_per_us: 0.0,
819            sigma0_us2: 0.0,
820            sigma1_us2_per_angstrom2: 15.140_286_726_8,
821            sigma2_us2_per_angstrom4: 0.0,
822            sigmaq_us2_per_angstrom: 0.0,
823            x_us_per_angstrom: 0.0,
824            y_us_per_angstrom2: 0.0,
825            z_us: 0.0,
826        }
827    }
828
829    #[test]
830    fn parameter_chains_match_centered_differences() {
831        let d = 1.7;
832        let step = 1.0e-6;
833        let actual = TofProfileParameters::from_instrument(d, instrument()).expect("parameters");
834        let plus = TofProfileParameters::from_instrument(d + step, instrument()).expect("plus");
835        let minus = TofProfileParameters::from_instrument(d - step, instrument()).expect("minus");
836        let finite = |high: f64, low: f64| (high - low) / (2.0 * step);
837        assert!((actual.d_position_d_d - finite(plus.position_us, minus.position_us)).abs() < 1e-6);
838        assert!((actual.d_alpha_d_d - finite(plus.alpha_per_us, minus.alpha_per_us)).abs() < 1e-9);
839        assert!((actual.d_beta_d_d - finite(plus.beta_per_us, minus.beta_per_us)).abs() < 1e-9);
840        assert!(
841            (actual.d_gaussian_fwhm_d_d - finite(plus.gaussian_fwhm_us, minus.gaussian_fwhm_us))
842                .abs()
843                < 1e-8
844        );
845    }
846
847    #[test]
848    fn profiles_can_share_quadrature_storage() {
849        let quadrature = Arc::new(TofQuadrature::new(20.0).expect("quadrature"));
850        let widths = TchWidths {
851            gaussian_fwhm: 22.0,
852            lorentzian_fwhm: 4.0,
853        };
854        let first = TofProfile::from_validated_rates(0.08, 0.03, widths, Arc::clone(&quadrature))
855            .expect("first profile");
856        let second = TofProfile::from_validated_rates(0.09, 0.04, widths, Arc::clone(&quadrature))
857            .expect("second profile");
858
859        assert!(Arc::ptr_eq(&first.quadrature, &second.quadrature));
860        assert!(std::mem::size_of::<TofProfile>() < 128);
861    }
862
863    #[test]
864    fn accumulation_blocks_are_bitwise_identical_across_worker_counts() {
865        let x = (0..=8_000)
866            .map(|index| 1_000.0 + 2.0 * f64::from(index))
867            .collect::<Vec<_>>();
868        let d_spacings = (0..36)
869            .map(|index| 0.5 + 0.065 * f64::from(index))
870            .collect::<Vec<_>>();
871        let intensities = (0..36)
872            .map(|index| 3.0 + 0.3 * f64::from(index))
873            .collect::<Vec<_>>();
874        let grid = GridView::new(&x).expect("grid");
875        let serial = ExecutionContext::serial();
876        let expected = accumulate_tof_batch_with_context(
877            grid,
878            &d_spacings,
879            &intensities,
880            instrument(),
881            20.0,
882            20.0,
883            &serial,
884        )
885        .expect("serial TOF");
886        for threads in [2, 3] {
887            let context = ExecutionContext::new(threads).expect("parallel context");
888            assert_eq!(
889                accumulate_tof_batch_with_context(
890                    grid,
891                    &d_spacings,
892                    &intensities,
893                    instrument(),
894                    20.0,
895                    20.0,
896                    &context,
897                )
898                .expect("parallel TOF"),
899                expected
900            );
901        }
902    }
903
904    #[test]
905    fn direct_profile_derivatives_match_centered_differences() {
906        let alpha = 0.08;
907        let beta = 0.03;
908        let gaussian = 22.0;
909        let lorentzian = 4.0;
910        let delta = 5.0;
911        let tail = 20.0;
912        let point = TofProfile::new(
913            alpha,
914            beta,
915            TchWidths {
916                gaussian_fwhm: gaussian,
917                lorentzian_fwhm: lorentzian,
918            },
919            tail,
920        )
921        .expect("profile")
922        .evaluate(delta);
923        let step = 1.0e-6;
924        let value = |a, b, g, l, x| {
925            TofProfile::new(
926                a,
927                b,
928                TchWidths {
929                    gaussian_fwhm: g,
930                    lorentzian_fwhm: l,
931                },
932                tail,
933            )
934            .expect("profile")
935            .evaluate(x)
936            .value
937        };
938        let fd = |plus, minus| (plus - minus) / (2.0 * step);
939        assert!(
940            (point.d_position
941                - fd(
942                    value(alpha, beta, gaussian, lorentzian, delta - step),
943                    value(alpha, beta, gaussian, lorentzian, delta + step)
944                ))
945            .abs()
946                < 1e-8
947        );
948        assert!(
949            (point.d_alpha
950                - fd(
951                    value(alpha + step, beta, gaussian, lorentzian, delta),
952                    value(alpha - step, beta, gaussian, lorentzian, delta)
953                ))
954            .abs()
955                < 1e-7
956        );
957        assert!(
958            (point.d_beta
959                - fd(
960                    value(alpha, beta + step, gaussian, lorentzian, delta),
961                    value(alpha, beta - step, gaussian, lorentzian, delta)
962                ))
963            .abs()
964                < 1e-7
965        );
966        assert!(
967            (point.d_gaussian_fwhm
968                - fd(
969                    value(alpha, beta, gaussian + step, lorentzian, delta),
970                    value(alpha, beta, gaussian - step, lorentzian, delta)
971                ))
972            .abs()
973                < 1e-8
974        );
975        assert!(
976            (point.d_lorentzian_fwhm
977                - fd(
978                    value(alpha, beta, gaussian, lorentzian + step, delta),
979                    value(alpha, beta, gaussian, lorentzian - step, delta)
980                ))
981            .abs()
982                < 1e-8
983        );
984    }
985}