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;
19/// Stable names matching the dense global derivative-row order.
20pub const TOF_GLOBAL_PARAMETER_NAMES: [&str; TOF_GLOBAL_PARAMETER_COUNT] = [
21    "zero", "difc", "difa", "difb", "alpha", "beta0", "beta1", "betaq", "sigma0", "sigma1",
22    "sigma2", "sigmaq", "x", "y", "z",
23];
24/// Number of coefficients in the Maxwellian-plus-Chebyshev incident spectrum.
25pub const TOF_INCIDENT_SPECTRUM_COEFFICIENT_COUNT: usize = 12;
26
27/// One selectable TOF calibration/profile coefficient.
28#[repr(usize)]
29#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub enum TofInstrumentParameter {
31    /// Additive time zero.
32    Zero,
33    /// Linear d-to-TOF calibration.
34    Difc,
35    /// Quadratic d-to-TOF calibration.
36    Difa,
37    /// Reciprocal d-to-TOF calibration.
38    Difb,
39    /// Leading-edge rate numerator.
40    Alpha,
41    /// Constant trailing-edge rate.
42    Beta0,
43    /// Inverse-fourth-power trailing-edge rate.
44    Beta1,
45    /// Inverse-square trailing-edge rate.
46    Betaq,
47    /// Constant Gaussian variance.
48    Sigma0,
49    /// Quadratic-d Gaussian variance.
50    Sigma1,
51    /// Quartic-d Gaussian variance.
52    Sigma2,
53    /// Linear-d Gaussian variance.
54    Sigmaq,
55    /// Linear-d Lorentzian width.
56    X,
57    /// Quadratic-d Lorentzian width.
58    Y,
59    /// Constant Lorentzian width.
60    Z,
61}
62
63impl TofInstrumentParameter {
64    /// Every parameter in the fused dense-Jacobian order.
65    pub const ALL: [Self; TOF_GLOBAL_PARAMETER_COUNT] = [
66        Self::Zero,
67        Self::Difc,
68        Self::Difa,
69        Self::Difb,
70        Self::Alpha,
71        Self::Beta0,
72        Self::Beta1,
73        Self::Betaq,
74        Self::Sigma0,
75        Self::Sigma1,
76        Self::Sigma2,
77        Self::Sigmaq,
78        Self::X,
79        Self::Y,
80        Self::Z,
81    ];
82
83    /// Dense global derivative-row index.
84    #[must_use]
85    pub const fn index(self) -> usize {
86        self as usize
87    }
88
89    /// Stable short name.
90    #[must_use]
91    pub const fn name(self) -> &'static str {
92        TOF_GLOBAL_PARAMETER_NAMES[self.index()]
93    }
94}
95const LOCAL_PARAMETER_COUNT: usize = 2;
96const TOF_QUADRATURE_PANELS: usize = 8;
97const TOF_QUADRATURE_PANELS_F64: f64 = 8.0;
98const TOF_SUPPORT_QUADRATURE_PANELS: usize = 4;
99const TOF_SUPPORT_QUADRATURE_PANELS_F64: f64 = 4.0;
100const TOF_QUADRATURE_COUNT: usize = TOF_QUADRATURE_PANELS * QUADRATURE_ORDER;
101
102/// One calibrated TOF incident-spectrum value and coordinate derivative.
103#[derive(Clone, Copy, Debug, PartialEq)]
104pub struct TofIncidentSpectrumPoint {
105    /// Positive incident intensity at the requested TOF.
106    pub value: f64,
107    /// Analytical derivative with respect to TOF in microseconds.
108    pub d_value_d_tof_us: f64,
109}
110
111/// Facility-neutral Maxwellian-plus-Chebyshev TOF incident spectrum.
112///
113/// The twelve coefficients are `P1..P12`. With TOF `t` in milliseconds and
114/// `x = 2/t - 1`, the calibrated intensity is
115///
116/// `P1 + P2 t^-5 exp(-P3/t^2) + sum(Pj T_(j-3)(x), j=4..12)`.
117#[derive(Clone, Copy, Debug, PartialEq)]
118pub struct TofIncidentSpectrum {
119    /// Inclusive lower validity bound in microseconds.
120    pub min_tof_us: f64,
121    /// Inclusive upper validity bound in microseconds.
122    pub max_tof_us: f64,
123    /// Coefficients `P1..P12` in the documented function order.
124    pub coefficients: [f64; TOF_INCIDENT_SPECTRUM_COEFFICIENT_COUNT],
125}
126
127impl TofIncidentSpectrum {
128    /// Construct and validate a fixed incident-spectrum calibration.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`TofIncidentSpectrumError`] for an invalid range or
133    /// non-finite coefficient.
134    pub fn new(
135        min_tof_us: f64,
136        max_tof_us: f64,
137        coefficients: [f64; TOF_INCIDENT_SPECTRUM_COEFFICIENT_COUNT],
138    ) -> Result<Self, TofIncidentSpectrumError> {
139        if !min_tof_us.is_finite()
140            || !max_tof_us.is_finite()
141            || min_tof_us <= 0.0
142            || max_tof_us <= min_tof_us
143        {
144            return Err(TofIncidentSpectrumError::InvalidRange);
145        }
146        if coefficients.iter().any(|value| !value.is_finite()) {
147            return Err(TofIncidentSpectrumError::NonFiniteCoefficient);
148        }
149        Ok(Self {
150            min_tof_us,
151            max_tof_us,
152            coefficients,
153        })
154    }
155
156    /// Evaluate the calibrated intensity and analytical TOF derivative.
157    ///
158    /// The validity interval is inclusive at both ends.
159    ///
160    /// # Errors
161    ///
162    /// Returns [`TofIncidentSpectrumError`] when TOF lies outside the
163    /// calibration interval or the fitted function is not positive and finite.
164    pub fn evaluate(
165        self,
166        tof_us: f64,
167    ) -> Result<TofIncidentSpectrumPoint, TofIncidentSpectrumError> {
168        if !tof_us.is_finite() || tof_us < self.min_tof_us || tof_us > self.max_tof_us {
169            return Err(TofIncidentSpectrumError::TofOutsideRange);
170        }
171        let time_milliseconds = tof_us / 1_000.0;
172        let inverse_t = time_milliseconds.recip();
173        let inverse_t2 = inverse_t * inverse_t;
174        let x = 2.0 * inverse_t - 1.0;
175        let d_x_d_t_ms = -2.0 * inverse_t2;
176        let maxwell =
177            self.coefficients[1] * inverse_t.powi(5) * (-self.coefficients[2] * inverse_t2).exp();
178        let mut value = self.coefficients[0] + maxwell;
179        let mut d_value_d_t_ms =
180            maxwell * (-5.0 * inverse_t + 2.0 * self.coefficients[2] * inverse_t.powi(3));
181
182        let mut previous = 1.0;
183        let mut d_previous = 0.0;
184        let mut current = x;
185        let mut d_current = d_x_d_t_ms;
186        for (index, &coefficient) in self.coefficients[3..].iter().enumerate() {
187            if index > 0 {
188                let next = 2.0 * x * current - previous;
189                let d_next = 2.0 * (d_x_d_t_ms * current + x * d_current) - d_previous;
190                previous = current;
191                d_previous = d_current;
192                current = next;
193                d_current = d_next;
194            }
195            value += coefficient * current;
196            d_value_d_t_ms += coefficient * d_current;
197        }
198        let d_value_d_tof_us = d_value_d_t_ms / 1_000.0;
199        if !value.is_finite() || value <= 0.0 || !d_value_d_tof_us.is_finite() {
200            return Err(TofIncidentSpectrumError::NonPositiveIntensity);
201        }
202        Ok(TofIncidentSpectrumPoint {
203            value,
204            d_value_d_tof_us,
205        })
206    }
207}
208
209/// Invalid TOF incident-spectrum calibration or evaluation.
210#[derive(Clone, Copy, Debug, PartialEq, Eq)]
211pub enum TofIncidentSpectrumError {
212    /// The inclusive validity interval is not finite, positive, and increasing.
213    InvalidRange,
214    /// At least one fitted coefficient is non-finite.
215    NonFiniteCoefficient,
216    /// Requested TOF is non-finite or outside the inclusive validity interval.
217    TofOutsideRange,
218    /// Evaluation produced an incident intensity that is not positive and finite.
219    NonPositiveIntensity,
220}
221
222impl Display for TofIncidentSpectrumError {
223    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
224        formatter.write_str(match self {
225            Self::InvalidRange => {
226                "TOF incident-spectrum range must be finite, positive, and increasing"
227            }
228            Self::NonFiniteCoefficient => "TOF incident-spectrum coefficients must be finite",
229            Self::TofOutsideRange => "TOF lies outside the incident-spectrum validity interval",
230            Self::NonPositiveIntensity => {
231                "TOF incident-spectrum intensity must be positive and finite"
232            }
233        })
234    }
235}
236
237impl Error for TofIncidentSpectrumError {}
238
239/// Facility-neutral fixed geometry for one focused TOF detector bank.
240#[derive(Clone, Copy, Debug, PartialEq)]
241pub struct TofBankGeometry {
242    /// Nominal bank scattering angle in degrees `2theta`.
243    pub two_theta_deg: f64,
244}
245
246impl TofBankGeometry {
247    /// Validate the strict physical scattering-angle domain.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`TofError::InvalidBankTwoTheta`] unless the angle is finite and
252    /// strictly within `0 < 2theta < 180°`.
253    pub fn validate(self) -> Result<(), TofError> {
254        if !self.two_theta_deg.is_finite()
255            || self.two_theta_deg <= 0.0
256            || self.two_theta_deg >= 180.0
257        {
258            return Err(TofError::InvalidBankTwoTheta);
259        }
260        Ok(())
261    }
262
263    /// Return the half scattering angle in radians after validation.
264    ///
265    /// # Errors
266    ///
267    /// Returns [`TofError::InvalidBankTwoTheta`] for invalid geometry.
268    pub fn theta_radians(self) -> Result<f64, TofError> {
269        self.validate()?;
270        Ok((0.5 * self.two_theta_deg).to_radians())
271    }
272}
273
274/// TOF calibration and d-dependent profile coefficients in public units.
275#[derive(Clone, Copy, Debug, PartialEq)]
276pub struct TofInstrument {
277    /// Additive time zero in microseconds.
278    pub zero_us: f64,
279    /// Linear calibration coefficient in microseconds per ångström.
280    pub difc_us_per_angstrom: f64,
281    /// Quadratic calibration coefficient in microseconds per ångström squared.
282    pub difa_us_per_angstrom2: f64,
283    /// Reciprocal calibration coefficient in microsecond ångströms.
284    pub difb_us_angstrom: f64,
285    /// Exponential-rise numerator; `alpha = alpha_coefficient / d`.
286    pub alpha_coefficient: f64,
287    /// Constant exponential-decay rate term in inverse microseconds.
288    pub beta0_per_us: f64,
289    /// `d^-4` exponential-decay coefficient.
290    pub beta1_angstrom4_per_us: f64,
291    /// `d^-2` exponential-decay coefficient.
292    pub betaq_angstrom2_per_us: f64,
293    /// Constant Gaussian variance term in microseconds squared.
294    pub sigma0_us2: f64,
295    /// `d^2` Gaussian variance coefficient.
296    pub sigma1_us2_per_angstrom2: f64,
297    /// `d^4` Gaussian variance coefficient.
298    pub sigma2_us2_per_angstrom4: f64,
299    /// Linear-d Gaussian variance coefficient.
300    pub sigmaq_us2_per_angstrom: f64,
301    /// Linear-d Lorentzian FWHM coefficient.
302    pub x_us_per_angstrom: f64,
303    /// Quadratic-d Lorentzian FWHM coefficient.
304    pub y_us_per_angstrom2: f64,
305    /// Constant Lorentzian FWHM in microseconds.
306    pub z_us: f64,
307}
308
309impl TofInstrument {
310    /// Coefficients in the stable dense global derivative-row order.
311    #[must_use]
312    pub const fn values(self) -> [f64; TOF_GLOBAL_PARAMETER_COUNT] {
313        [
314            self.zero_us,
315            self.difc_us_per_angstrom,
316            self.difa_us_per_angstrom2,
317            self.difb_us_angstrom,
318            self.alpha_coefficient,
319            self.beta0_per_us,
320            self.beta1_angstrom4_per_us,
321            self.betaq_angstrom2_per_us,
322            self.sigma0_us2,
323            self.sigma1_us2_per_angstrom2,
324            self.sigma2_us2_per_angstrom4,
325            self.sigmaq_us2_per_angstrom,
326            self.x_us_per_angstrom,
327            self.y_us_per_angstrom2,
328            self.z_us,
329        ]
330    }
331
332    /// Construct from coefficients in the stable dense global-row order.
333    ///
334    /// # Errors
335    ///
336    /// Returns [`TofError`] when the complete instrument is invalid.
337    pub fn from_values(values: [f64; TOF_GLOBAL_PARAMETER_COUNT]) -> Result<Self, TofError> {
338        let result = Self {
339            zero_us: values[0],
340            difc_us_per_angstrom: values[1],
341            difa_us_per_angstrom2: values[2],
342            difb_us_angstrom: values[3],
343            alpha_coefficient: values[4],
344            beta0_per_us: values[5],
345            beta1_angstrom4_per_us: values[6],
346            betaq_angstrom2_per_us: values[7],
347            sigma0_us2: values[8],
348            sigma1_us2_per_angstrom2: values[9],
349            sigma2_us2_per_angstrom4: values[10],
350            sigmaq_us2_per_angstrom: values[11],
351            x_us_per_angstrom: values[12],
352            y_us_per_angstrom2: values[13],
353            z_us: values[14],
354        };
355        result.validate()?;
356        Ok(result)
357    }
358
359    /// Replace one coefficient without bypassing instrument validation.
360    ///
361    /// # Errors
362    ///
363    /// Returns [`TofError`] when the replacement makes the instrument invalid.
364    pub fn with_parameter(
365        self,
366        parameter: TofInstrumentParameter,
367        value: f64,
368    ) -> Result<Self, TofError> {
369        let mut values = self.values();
370        values[parameter.index()] = value;
371        Self::from_values(values)
372    }
373
374    /// Validate finite coefficients and a positive linear calibration scale.
375    ///
376    /// # Errors
377    ///
378    /// Returns [`TofError`] for non-finite coefficients or non-positive
379    /// linear calibration.
380    pub fn validate(self) -> Result<(), TofError> {
381        let values = [
382            self.zero_us,
383            self.difc_us_per_angstrom,
384            self.difa_us_per_angstrom2,
385            self.difb_us_angstrom,
386            self.alpha_coefficient,
387            self.beta0_per_us,
388            self.beta1_angstrom4_per_us,
389            self.betaq_angstrom2_per_us,
390            self.sigma0_us2,
391            self.sigma1_us2_per_angstrom2,
392            self.sigma2_us2_per_angstrom4,
393            self.sigmaq_us2_per_angstrom,
394            self.x_us_per_angstrom,
395            self.y_us_per_angstrom2,
396            self.z_us,
397        ];
398        if values.iter().any(|value| !value.is_finite()) {
399            return Err(TofError::NonFiniteInstrumentParameter);
400        }
401        if self.difc_us_per_angstrom <= 0.0 {
402            return Err(TofError::NonPositiveDifc);
403        }
404        Ok(())
405    }
406}
407
408/// Derived TOF position, widths, exponential rates, and analytical chains.
409#[derive(Clone, Copy, Debug, PartialEq)]
410pub struct TofProfileParameters {
411    /// Calibrated position in microseconds.
412    pub position_us: f64,
413    /// Leading-edge exponential rate in inverse microseconds.
414    pub alpha_per_us: f64,
415    /// Trailing-edge exponential rate in inverse microseconds.
416    pub beta_per_us: f64,
417    /// Gaussian variance in microseconds squared.
418    pub gaussian_variance_us2: f64,
419    /// Gaussian component FWHM in microseconds.
420    pub gaussian_fwhm_us: f64,
421    /// Lorentzian component FWHM in microseconds.
422    pub lorentzian_fwhm_us: f64,
423    /// TCH transform of the component widths.
424    pub tch: TchShape,
425    /// Direct-input derivatives with respect to d-spacing.
426    pub d_position_d_d: f64,
427    /// Alpha-rate derivative with respect to d-spacing.
428    pub d_alpha_d_d: f64,
429    /// Beta-rate derivative with respect to d-spacing.
430    pub d_beta_d_d: f64,
431    /// Gaussian-FWHM derivative with respect to d-spacing.
432    pub d_gaussian_fwhm_d_d: f64,
433    /// Lorentzian-FWHM derivative with respect to d-spacing.
434    pub d_lorentzian_fwhm_d_d: f64,
435    /// Parameter-major chains in [`TOF_GLOBAL_PARAMETER_COUNT`] order.
436    pub d_position_d_instrument: [f64; TOF_GLOBAL_PARAMETER_COUNT],
437    /// Alpha-rate chains in global instrument order.
438    pub d_alpha_d_instrument: [f64; TOF_GLOBAL_PARAMETER_COUNT],
439    /// Beta-rate chains in global instrument order.
440    pub d_beta_d_instrument: [f64; TOF_GLOBAL_PARAMETER_COUNT],
441    /// Gaussian-FWHM chains in global instrument order.
442    pub d_gaussian_fwhm_d_instrument: [f64; TOF_GLOBAL_PARAMETER_COUNT],
443    /// Lorentzian-FWHM chains in global instrument order.
444    pub d_lorentzian_fwhm_d_instrument: [f64; TOF_GLOBAL_PARAMETER_COUNT],
445}
446
447impl TofProfileParameters {
448    /// Derive all profile quantities for one positive d-spacing.
449    ///
450    /// # Errors
451    ///
452    /// Returns [`TofError`] for invalid inputs or nonphysical derived profile
453    /// parameters.
454    pub fn from_instrument(d: f64, instrument: TofInstrument) -> Result<Self, TofError> {
455        instrument.validate()?;
456        if !d.is_finite() || d <= 0.0 {
457            return Err(TofError::InvalidDSpacing);
458        }
459        let d2 = d * d;
460        let d3 = d2 * d;
461        let d4 = d2 * d2;
462        let inverse_d = d.recip();
463        let inverse_d2 = inverse_d * inverse_d;
464        let inverse_d3 = inverse_d2 * inverse_d;
465        let inverse_d4 = inverse_d2 * inverse_d2;
466        let inverse_d5 = inverse_d4 * inverse_d;
467        let position_us = instrument.zero_us
468            + instrument.difc_us_per_angstrom * d
469            + instrument.difa_us_per_angstrom2 * d2
470            + instrument.difb_us_angstrom * inverse_d;
471        let alpha_per_us = instrument.alpha_coefficient * inverse_d;
472        let beta_per_us = instrument.beta0_per_us
473            + instrument.beta1_angstrom4_per_us * inverse_d4
474            + instrument.betaq_angstrom2_per_us * inverse_d2;
475        let gaussian_variance_us2 = instrument.sigma0_us2
476            + instrument.sigma1_us2_per_angstrom2 * d2
477            + instrument.sigma2_us2_per_angstrom4 * d4
478            + instrument.sigmaq_us2_per_angstrom * d;
479        let lorentzian_fwhm_us =
480            instrument.z_us + instrument.x_us_per_angstrom * d + instrument.y_us_per_angstrom2 * d2;
481        if !position_us.is_finite() {
482            return Err(TofError::InvalidPosition);
483        }
484        if !alpha_per_us.is_finite() || alpha_per_us <= 0.0 {
485            return Err(TofError::NonPositiveAlpha);
486        }
487        if !beta_per_us.is_finite() || beta_per_us <= 0.0 {
488            return Err(TofError::NonPositiveBeta);
489        }
490        if !gaussian_variance_us2.is_finite() || gaussian_variance_us2 <= 0.0 {
491            return Err(TofError::NonPositiveGaussianVariance);
492        }
493        if !lorentzian_fwhm_us.is_finite() || lorentzian_fwhm_us < 0.0 {
494            return Err(TofError::NegativeLorentzianFwhm);
495        }
496        let sigma = gaussian_variance_us2.sqrt();
497        let gaussian_fwhm_us = GAUSSIAN_FWHM_PER_SIGMA * sigma;
498        let tch = TchShape::from_component_fwhm(TchWidths {
499            gaussian_fwhm: gaussian_fwhm_us,
500            lorentzian_fwhm: lorentzian_fwhm_us,
501        })
502        .map_err(|reason| TofError::InvalidTch { reason })?;
503        let d_gaussian_d_variance = GAUSSIAN_FWHM_PER_SIGMA / (2.0 * sigma);
504        let d_variance_d_d = 2.0 * instrument.sigma1_us2_per_angstrom2 * d
505            + 4.0 * instrument.sigma2_us2_per_angstrom4 * d3
506            + instrument.sigmaq_us2_per_angstrom;
507
508        let mut d_position_d_instrument = [0.0; TOF_GLOBAL_PARAMETER_COUNT];
509        d_position_d_instrument[..4].copy_from_slice(&[1.0, d, d2, inverse_d]);
510        let mut d_alpha_d_instrument = [0.0; TOF_GLOBAL_PARAMETER_COUNT];
511        d_alpha_d_instrument[4] = inverse_d;
512        let mut d_beta_d_instrument = [0.0; TOF_GLOBAL_PARAMETER_COUNT];
513        d_beta_d_instrument[5..8].copy_from_slice(&[1.0, inverse_d4, inverse_d2]);
514        let mut d_gaussian_fwhm_d_instrument = [0.0; TOF_GLOBAL_PARAMETER_COUNT];
515        d_gaussian_fwhm_d_instrument[8..12].copy_from_slice(&[
516            d_gaussian_d_variance,
517            d_gaussian_d_variance * d2,
518            d_gaussian_d_variance * d4,
519            d_gaussian_d_variance * d,
520        ]);
521        let mut d_lorentzian_fwhm_d_instrument = [0.0; TOF_GLOBAL_PARAMETER_COUNT];
522        d_lorentzian_fwhm_d_instrument[12..15].copy_from_slice(&[d, d2, 1.0]);
523        Ok(Self {
524            position_us,
525            alpha_per_us,
526            beta_per_us,
527            gaussian_variance_us2,
528            gaussian_fwhm_us,
529            lorentzian_fwhm_us,
530            tch,
531            d_position_d_d: instrument.difc_us_per_angstrom
532                + 2.0 * instrument.difa_us_per_angstrom2 * d
533                - instrument.difb_us_angstrom * inverse_d2,
534            d_alpha_d_d: -instrument.alpha_coefficient * inverse_d2,
535            d_beta_d_d: -4.0 * instrument.beta1_angstrom4_per_us * inverse_d5
536                - 2.0 * instrument.betaq_angstrom2_per_us * inverse_d3,
537            d_gaussian_fwhm_d_d: d_gaussian_d_variance * d_variance_d_d,
538            d_lorentzian_fwhm_d_d: instrument.x_us_per_angstrom
539                + 2.0 * instrument.y_us_per_angstrom2 * d,
540            d_position_d_instrument,
541            d_alpha_d_instrument,
542            d_beta_d_instrument,
543            d_gaussian_fwhm_d_instrument,
544            d_lorentzian_fwhm_d_instrument,
545        })
546    }
547}
548
549/// One asymmetric TOF profile value and direct-input derivatives.
550#[derive(Clone, Copy, Debug, Default, PartialEq)]
551pub struct TofProfilePoint {
552    /// Unit-area profile density in inverse microseconds.
553    pub value: f64,
554    /// Derivative with respect to ideal position in microseconds.
555    pub d_position: f64,
556    /// Derivative with respect to the leading-edge alpha rate.
557    pub d_alpha: f64,
558    /// Derivative with respect to the trailing-edge beta rate.
559    pub d_beta: f64,
560    /// Derivative with respect to Gaussian component FWHM.
561    pub d_gaussian_fwhm: f64,
562    /// Derivative with respect to Lorentzian component FWHM.
563    pub d_lorentzian_fwhm: f64,
564}
565
566const SUPPORTED_DELTA: usize = 0;
567const SUPPORTED_ALPHA: usize = 1;
568const SUPPORTED_BETA: usize = 2;
569const SUPPORTED_GAUSSIAN: usize = 3;
570const SUPPORTED_LORENTZIAN: usize = 4;
571const SUPPORTED_VARIABLE_COUNT: usize = 5;
572
573#[derive(Clone, Copy, Default)]
574struct SupportedScalar {
575    value: f64,
576    derivative: [f64; SUPPORTED_VARIABLE_COUNT],
577}
578
579impl SupportedScalar {
580    fn clamped(self, lower: f64, upper: f64) -> Self {
581        if lower < self.value && self.value < upper {
582            self
583        } else {
584            Self {
585                value: self.value.clamp(lower, upper),
586                derivative: [0.0; SUPPORTED_VARIABLE_COUNT],
587            }
588        }
589    }
590}
591
592/// Prepared truncated double-exponential convolution of a TCH profile.
593#[derive(Clone, Debug)]
594pub struct TofProfile {
595    shape: TchShape,
596    alpha: f64,
597    beta: f64,
598    quadrature: Arc<TofQuadrature>,
599}
600
601#[derive(Debug)]
602struct TofQuadrature {
603    tail_log: f64,
604    nodes: [f64; TOF_QUADRATURE_COUNT],
605    weights: [f64; TOF_QUADRATURE_COUNT],
606}
607
608impl TofQuadrature {
609    fn new(tail_log: f64) -> Result<Self, TofError> {
610        if !tail_log.is_finite() || tail_log <= 0.0 {
611            return Err(TofError::InvalidTailLog);
612        }
613        let mut nodes = [0.0; TOF_QUADRATURE_COUNT];
614        let mut weights = [0.0; TOF_QUADRATURE_COUNT];
615        let mut normalization = 0.0;
616        let panel_scale = TOF_QUADRATURE_PANELS_F64.recip();
617        let mut panel_offset = 0.0;
618        for panel in 0..TOF_QUADRATURE_PANELS {
619            for quadrature in 0..QUADRATURE_ORDER {
620                let index = panel * QUADRATURE_ORDER + quadrature;
621                let unit_node = panel_offset + panel_scale * QUADRATURE_NODES[quadrature];
622                nodes[index] = tail_log * unit_node;
623                weights[index] =
624                    tail_log * panel_scale * QUADRATURE_WEIGHTS[quadrature] * (-nodes[index]).exp();
625                normalization += weights[index];
626            }
627            panel_offset += panel_scale;
628        }
629        if !normalization.is_finite() || normalization <= 0.0 {
630            return Err(TofError::InvalidQuadrature);
631        }
632        for weight in &mut weights {
633            *weight /= normalization;
634        }
635        Ok(Self {
636            tail_log,
637            nodes,
638            weights,
639        })
640    }
641}
642
643impl TofProfile {
644    /// Prepare a unit-area profile. Both exponential tails are truncated at
645    /// `exp(-tail_log)` and renormalized before convolution.
646    ///
647    /// # Errors
648    ///
649    /// Returns [`TofError`] for invalid rates, widths, tail cutoff, or
650    /// quadrature normalization.
651    pub fn new(
652        alpha_per_us: f64,
653        beta_per_us: f64,
654        widths: TchWidths,
655        tail_log: f64,
656    ) -> Result<Self, TofError> {
657        Self::validate_rates(alpha_per_us, beta_per_us)?;
658        let quadrature = Arc::new(TofQuadrature::new(tail_log)?);
659        Self::from_validated_rates(alpha_per_us, beta_per_us, widths, quadrature)
660    }
661
662    fn validate_rates(alpha_per_us: f64, beta_per_us: f64) -> Result<(), TofError> {
663        if !alpha_per_us.is_finite() || alpha_per_us <= 0.0 {
664            return Err(TofError::NonPositiveAlpha);
665        }
666        if !beta_per_us.is_finite() || beta_per_us <= 0.0 {
667            return Err(TofError::NonPositiveBeta);
668        }
669        Ok(())
670    }
671
672    fn from_validated_rates(
673        alpha_per_us: f64,
674        beta_per_us: f64,
675        widths: TchWidths,
676        quadrature: Arc<TofQuadrature>,
677    ) -> Result<Self, TofError> {
678        let shape = TchShape::from_component_fwhm(widths)
679            .map_err(|reason| TofError::InvalidTch { reason })?;
680        Ok(Self {
681            shape,
682            alpha: alpha_per_us,
683            beta: beta_per_us,
684            quadrature,
685        })
686    }
687
688    /// Evaluate without finite TCH support truncation.
689    #[must_use]
690    pub fn evaluate(&self, x_minus_position_us: f64) -> TofProfilePoint {
691        self.evaluate_with_radius(x_minus_position_us, f64::INFINITY)
692    }
693
694    fn evaluate_with_radius(&self, delta: f64, base_radius: f64) -> TofProfilePoint {
695        if base_radius.is_finite() {
696            return self.evaluate_supported(delta, base_radius);
697        }
698        let sum = self.alpha + self.beta;
699        let left_fraction = self.beta / sum;
700        let right_fraction = self.alpha / sum;
701        let d_left_d_alpha = -self.beta / (sum * sum);
702        let d_left_d_beta = self.alpha / (sum * sum);
703        let mut left = TofProfilePoint::default();
704        let mut right = TofProfilePoint::default();
705        let mut left_alpha_shift = 0.0;
706        let mut right_beta_shift = 0.0;
707        for index in 0..TOF_QUADRATURE_COUNT {
708            let node = self.quadrature.nodes[index];
709            let weight = self.quadrature.weights[index];
710            let left_delta = delta + node / self.alpha;
711            if left_delta.abs() <= base_radius {
712                let point = self.shape.evaluate(left_delta);
713                left.value += weight * point.value;
714                left.d_position += weight * point.d_delta;
715                left.d_gaussian_fwhm += weight * point.d_gaussian_fwhm;
716                left.d_lorentzian_fwhm += weight * point.d_lorentzian_fwhm;
717                left_alpha_shift += weight * point.d_delta * (-node / self.alpha.powi(2));
718            }
719            let right_delta = delta - node / self.beta;
720            if right_delta.abs() <= base_radius {
721                let point = self.shape.evaluate(right_delta);
722                right.value += weight * point.value;
723                right.d_position += weight * point.d_delta;
724                right.d_gaussian_fwhm += weight * point.d_gaussian_fwhm;
725                right.d_lorentzian_fwhm += weight * point.d_lorentzian_fwhm;
726                right_beta_shift += weight * point.d_delta * (node / self.beta.powi(2));
727            }
728        }
729        TofProfilePoint {
730            value: left_fraction * left.value + right_fraction * right.value,
731            d_position: -(left_fraction * left.d_position + right_fraction * right.d_position),
732            d_alpha: d_left_d_alpha * left.value + left_fraction * left_alpha_shift
733                - d_left_d_alpha * right.value,
734            d_beta: d_left_d_beta * left.value + right_fraction * right_beta_shift
735                - d_left_d_beta * right.value,
736            d_gaussian_fwhm: left_fraction * left.d_gaussian_fwhm
737                + right_fraction * right.d_gaussian_fwhm,
738            d_lorentzian_fwhm: left_fraction * left.d_lorentzian_fwhm
739                + right_fraction * right.d_lorentzian_fwhm,
740        }
741    }
742
743    #[allow(clippy::too_many_lines)]
744    fn evaluate_supported(&self, delta: f64, base_radius: f64) -> TofProfilePoint {
745        let sum = self.alpha + self.beta;
746        let left_fraction = self.beta / sum;
747        let right_fraction = self.alpha / sum;
748        let d_left_d_alpha = -self.beta / (sum * sum);
749        let d_left_d_beta = self.alpha / (sum * sum);
750        let tail_log = self.quadrature.tail_log;
751        let normalization = 1.0 - (-tail_log).exp();
752        let support_multiple = base_radius / self.shape.total_fwhm;
753        let mut d_radius = [0.0; SUPPORTED_VARIABLE_COUNT];
754        d_radius[SUPPORTED_GAUSSIAN] = support_multiple * self.shape.d_total_fwhm_d_gaussian_fwhm;
755        d_radius[SUPPORTED_LORENTZIAN] =
756            support_multiple * self.shape.d_total_fwhm_d_lorentzian_fwhm;
757        let (left_low, left_high) = self.supported_bounds(delta, base_radius, d_radius, true);
758        let (right_low, right_high) = self.supported_bounds(delta, base_radius, d_radius, false);
759        let mut left = TofProfilePoint::default();
760        let mut right = TofProfilePoint::default();
761        let mut left_alpha_shift = 0.0;
762        let mut right_beta_shift = 0.0;
763
764        if left_low.value < left_high.value {
765            let panel_width =
766                (left_high.value - left_low.value) / TOF_SUPPORT_QUADRATURE_PANELS_F64;
767            let mut panel_left = left_low.value;
768            for _ in 0..TOF_SUPPORT_QUADRATURE_PANELS {
769                for quadrature in 0..QUADRATURE_ORDER {
770                    let node = panel_left + panel_width * QUADRATURE_NODES[quadrature];
771                    let weight = panel_width * QUADRATURE_WEIGHTS[quadrature] * (-node).exp()
772                        / normalization;
773                    let point = self.shape.evaluate(delta + node / self.alpha);
774                    left.value += weight * point.value;
775                    left.d_position += weight * point.d_delta;
776                    left.d_gaussian_fwhm += weight * point.d_gaussian_fwhm;
777                    left.d_lorentzian_fwhm += weight * point.d_lorentzian_fwhm;
778                    left_alpha_shift += weight * point.d_delta * (-node / self.alpha.powi(2));
779                }
780                panel_left += panel_width;
781            }
782        }
783        if right_low.value < right_high.value {
784            let panel_width =
785                (right_high.value - right_low.value) / TOF_SUPPORT_QUADRATURE_PANELS_F64;
786            let mut panel_left = right_low.value;
787            for _ in 0..TOF_SUPPORT_QUADRATURE_PANELS {
788                for quadrature in 0..QUADRATURE_ORDER {
789                    let node = panel_left + panel_width * QUADRATURE_NODES[quadrature];
790                    let weight = panel_width * QUADRATURE_WEIGHTS[quadrature] * (-node).exp()
791                        / normalization;
792                    let point = self.shape.evaluate(delta - node / self.beta);
793                    right.value += weight * point.value;
794                    right.d_position += weight * point.d_delta;
795                    right.d_gaussian_fwhm += weight * point.d_gaussian_fwhm;
796                    right.d_lorentzian_fwhm += weight * point.d_lorentzian_fwhm;
797                    right_beta_shift += weight * point.d_delta * (node / self.beta.powi(2));
798                }
799                panel_left += panel_width;
800            }
801        }
802        let left_boundary =
803            self.supported_boundary_chain(delta, left_low, left_high, true, normalization);
804        let right_boundary =
805            self.supported_boundary_chain(delta, right_low, right_high, false, normalization);
806        TofProfilePoint {
807            value: left_fraction * left.value + right_fraction * right.value,
808            d_position: -(left_fraction * (left.d_position + left_boundary[SUPPORTED_DELTA])
809                + right_fraction * (right.d_position + right_boundary[SUPPORTED_DELTA])),
810            d_alpha: d_left_d_alpha * left.value
811                + left_fraction * (left_alpha_shift + left_boundary[SUPPORTED_ALPHA])
812                - d_left_d_alpha * right.value
813                + right_fraction * right_boundary[SUPPORTED_ALPHA],
814            d_beta: d_left_d_beta * left.value
815                + left_fraction * left_boundary[SUPPORTED_BETA]
816                + right_fraction * (right_beta_shift + right_boundary[SUPPORTED_BETA])
817                - d_left_d_beta * right.value,
818            d_gaussian_fwhm: left_fraction
819                * (left.d_gaussian_fwhm + left_boundary[SUPPORTED_GAUSSIAN])
820                + right_fraction * (right.d_gaussian_fwhm + right_boundary[SUPPORTED_GAUSSIAN]),
821            d_lorentzian_fwhm: left_fraction
822                * (left.d_lorentzian_fwhm + left_boundary[SUPPORTED_LORENTZIAN])
823                + right_fraction * (right.d_lorentzian_fwhm + right_boundary[SUPPORTED_LORENTZIAN]),
824        }
825    }
826
827    fn supported_bounds(
828        &self,
829        delta: f64,
830        base_radius: f64,
831        d_radius: [f64; SUPPORTED_VARIABLE_COUNT],
832        left_side: bool,
833    ) -> (SupportedScalar, SupportedScalar) {
834        let tail_log = self.quadrature.tail_log;
835        let rate = if left_side { self.alpha } else { self.beta };
836        let (low_sign, high_sign) = if left_side {
837            (-base_radius - delta, base_radius - delta)
838        } else {
839            (delta - base_radius, delta + base_radius)
840        };
841        let mut low_derivative = [0.0; SUPPORTED_VARIABLE_COUNT];
842        let mut high_derivative = [0.0; SUPPORTED_VARIABLE_COUNT];
843        if left_side {
844            low_derivative[SUPPORTED_DELTA] = -rate;
845            high_derivative[SUPPORTED_DELTA] = -rate;
846            low_derivative[SUPPORTED_ALPHA] = low_sign;
847            high_derivative[SUPPORTED_ALPHA] = high_sign;
848            for parameter in [SUPPORTED_GAUSSIAN, SUPPORTED_LORENTZIAN] {
849                low_derivative[parameter] = -rate * d_radius[parameter];
850                high_derivative[parameter] = rate * d_radius[parameter];
851            }
852        } else {
853            low_derivative[SUPPORTED_DELTA] = rate;
854            high_derivative[SUPPORTED_DELTA] = rate;
855            low_derivative[SUPPORTED_BETA] = low_sign;
856            high_derivative[SUPPORTED_BETA] = high_sign;
857            for parameter in [SUPPORTED_GAUSSIAN, SUPPORTED_LORENTZIAN] {
858                low_derivative[parameter] = -rate * d_radius[parameter];
859                high_derivative[parameter] = rate * d_radius[parameter];
860            }
861        }
862        (
863            SupportedScalar {
864                value: rate * low_sign,
865                derivative: low_derivative,
866            }
867            .clamped(0.0, tail_log),
868            SupportedScalar {
869                value: rate * high_sign,
870                derivative: high_derivative,
871            }
872            .clamped(0.0, tail_log),
873        )
874    }
875
876    fn supported_boundary_chain(
877        &self,
878        delta: f64,
879        low: SupportedScalar,
880        high: SupportedScalar,
881        left_side: bool,
882        normalization: f64,
883    ) -> [f64; SUPPORTED_VARIABLE_COUNT] {
884        let rate = if left_side { self.alpha } else { self.beta };
885        let direction = if left_side { 1.0 } else { -1.0 };
886        let integrand = |node: f64| {
887            (-node).exp() / normalization
888                * self.shape.evaluate(delta + direction * node / rate).value
889        };
890        let low_value = integrand(low.value);
891        let high_value = integrand(high.value);
892        let mut derivative = [0.0; SUPPORTED_VARIABLE_COUNT];
893        for (parameter, value) in derivative.iter_mut().enumerate() {
894            *value =
895                high_value * high.derivative[parameter] - low_value * low.derivative[parameter];
896        }
897        derivative
898    }
899
900    fn support_range(&self, position: f64, base_radius: f64) -> SupportRange {
901        SupportRange {
902            left: position - base_radius - self.quadrature.tail_log / self.alpha,
903            right: position + base_radius + self.quadrature.tail_log / self.beta,
904        }
905    }
906}
907
908/// TOF domain or accumulation error.
909#[derive(Clone, Debug, PartialEq)]
910pub enum TofError {
911    /// Bank scattering angle is not finite or outside `0 < 2theta < 180°`.
912    InvalidBankTwoTheta,
913    /// At least one instrument coefficient is non-finite.
914    NonFiniteInstrumentParameter,
915    /// The linear calibration coefficient is not positive.
916    NonPositiveDifc,
917    /// A reflection d-spacing is not positive and finite.
918    InvalidDSpacing,
919    /// Calibration produced a non-finite position.
920    InvalidPosition,
921    /// The derived leading-edge exponential rate is invalid.
922    NonPositiveAlpha,
923    /// The derived trailing-edge exponential rate is invalid.
924    NonPositiveBeta,
925    /// The derived Gaussian variance is invalid.
926    NonPositiveGaussianVariance,
927    /// The derived Lorentzian width is invalid.
928    NegativeLorentzianFwhm,
929    /// The requested exponential tail cutoff is invalid.
930    InvalidTailLog,
931    /// Quadrature normalization failed.
932    InvalidQuadrature,
933    /// Component widths could not be transformed into a TCH shape.
934    InvalidTch {
935        /// Underlying component-width transformation failure.
936        reason: TchError,
937    },
938    /// Reflection d-spacing and intensity arrays have different lengths.
939    LengthMismatch,
940    /// One reflection intensity is non-finite.
941    NonFiniteIntensity {
942        /// Index of the invalid reflection.
943        reflection: usize,
944    },
945    /// Allocation-size arithmetic overflowed or allocation failed.
946    AllocationOverflow,
947    /// Grid or support validation failed.
948    Profile {
949        /// Underlying grid or support failure.
950        reason: ProfileError,
951    },
952}
953
954impl Display for TofError {
955    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
956        match self {
957            Self::InvalidBankTwoTheta => formatter
958                .write_str("TOF bank two_theta_deg must be finite and strictly within (0, 180)"),
959            Self::NonFiniteInstrumentParameter => {
960                write!(formatter, "TOF coefficients must be finite")
961            }
962            Self::NonPositiveDifc => write!(formatter, "difC must be positive"),
963            Self::InvalidDSpacing => write!(formatter, "d-spacing must be positive and finite"),
964            Self::InvalidPosition => write!(formatter, "derived TOF position must be finite"),
965            Self::NonPositiveAlpha => write!(formatter, "TOF alpha must be positive and finite"),
966            Self::NonPositiveBeta => write!(formatter, "TOF beta must be positive and finite"),
967            Self::NonPositiveGaussianVariance => write!(
968                formatter,
969                "derived TOF Gaussian variance must be positive and finite"
970            ),
971            Self::NegativeLorentzianFwhm => write!(
972                formatter,
973                "derived TOF Lorentzian FWHM must be non-negative and finite"
974            ),
975            Self::InvalidTailLog => write!(formatter, "tail_log must be positive and finite"),
976            Self::InvalidQuadrature => write!(formatter, "TOF quadrature normalization is invalid"),
977            Self::InvalidTch { reason } => write!(formatter, "invalid TOF TCH widths: {reason}"),
978            Self::LengthMismatch => {
979                write!(formatter, "TOF reflection arrays must have equal length")
980            }
981            Self::NonFiniteIntensity { reflection } => write!(
982                formatter,
983                "reflection {reflection} intensity must be finite"
984            ),
985            Self::AllocationOverflow => write!(formatter, "TOF allocation size overflow"),
986            Self::Profile { reason } => Display::fmt(reason, formatter),
987        }
988    }
989}
990
991impl Error for TofError {}
992
993impl From<ProfileError> for TofError {
994    fn from(reason: ProfileError) -> Self {
995        Self::Profile { reason }
996    }
997}
998
999struct PreparedReflection {
1000    parameters: TofProfileParameters,
1001    profile: TofProfile,
1002}
1003
1004struct TofReflectionBlock {
1005    start: usize,
1006    y: Vec<f64>,
1007    local: Vec<f64>,
1008    global: Vec<f64>,
1009}
1010
1011/// Fused finite-support TOF accumulation with local intensity/d-spacing rows.
1012///
1013/// # Errors
1014///
1015/// Returns [`TofError`] for invalid grids, reflection arrays, coefficients,
1016/// derived profiles, supports, or checked allocation failures.
1017#[allow(clippy::too_many_lines)]
1018pub fn accumulate_tof_batch(
1019    grid: GridView<'_>,
1020    d_spacings: &[f64],
1021    intensities: &[f64],
1022    instrument: TofInstrument,
1023    support_fwhm: f64,
1024    tail_log: f64,
1025) -> Result<Accumulation, TofError> {
1026    accumulate_tof_batch_with_context(
1027        grid,
1028        d_spacings,
1029        intensities,
1030        instrument,
1031        support_fwhm,
1032        tail_log,
1033        &ExecutionContext::serial(),
1034    )
1035}
1036
1037/// Accumulate TOF reflections with a bounded execution context.
1038///
1039/// # Errors
1040///
1041/// Returns [`TofError`] for invalid inputs, profiles, supports, or allocations.
1042#[allow(clippy::too_many_lines)]
1043pub fn accumulate_tof_batch_with_context(
1044    grid: GridView<'_>,
1045    d_spacings: &[f64],
1046    intensities: &[f64],
1047    instrument: TofInstrument,
1048    support_fwhm: f64,
1049    tail_log: f64,
1050    execution: &ExecutionContext,
1051) -> Result<Accumulation, TofError> {
1052    if d_spacings.len() != intensities.len() {
1053        return Err(TofError::LengthMismatch);
1054    }
1055    if !support_fwhm.is_finite() || support_fwhm <= 0.0 {
1056        return Err(TofError::Profile {
1057            reason: ProfileError::InvalidSupport,
1058        });
1059    }
1060    instrument.validate()?;
1061    let x = grid.as_slice();
1062    let count = d_spacings.len();
1063    let mut prepared = Vec::new();
1064    let mut starts = Vec::new();
1065    let mut offsets = Vec::new();
1066    prepared
1067        .try_reserve_exact(count)
1068        .map_err(|_| TofError::AllocationOverflow)?;
1069    starts
1070        .try_reserve_exact(count)
1071        .map_err(|_| TofError::AllocationOverflow)?;
1072    let offset_count = count.checked_add(1).ok_or(TofError::AllocationOverflow)?;
1073    offsets
1074        .try_reserve_exact(offset_count)
1075        .map_err(|_| TofError::AllocationOverflow)?;
1076    offsets.push(0usize);
1077    let mut shared_quadrature = None;
1078    for reflection in 0..count {
1079        if !intensities[reflection].is_finite() {
1080            return Err(TofError::NonFiniteIntensity { reflection });
1081        }
1082        let parameters = TofProfileParameters::from_instrument(d_spacings[reflection], instrument)?;
1083        TofProfile::validate_rates(parameters.alpha_per_us, parameters.beta_per_us)?;
1084        let quadrature = if let Some(quadrature) = &shared_quadrature {
1085            Arc::clone(quadrature)
1086        } else {
1087            let quadrature = Arc::new(TofQuadrature::new(tail_log)?);
1088            shared_quadrature = Some(Arc::clone(&quadrature));
1089            quadrature
1090        };
1091        let profile = TofProfile::from_validated_rates(
1092            parameters.alpha_per_us,
1093            parameters.beta_per_us,
1094            TchWidths {
1095                gaussian_fwhm: parameters.gaussian_fwhm_us,
1096                lorentzian_fwhm: parameters.lorentzian_fwhm_us,
1097            },
1098            quadrature,
1099        )?;
1100        let base_radius = support_fwhm * parameters.tch.total_fwhm;
1101        let range = profile.support_range(parameters.position_us, base_radius);
1102        let lower = x.partition_point(|value| *value < range.left);
1103        let upper = x.partition_point(|value| *value <= range.right);
1104        offsets.push(
1105            offsets[reflection]
1106                .checked_add(upper - lower)
1107                .ok_or(TofError::AllocationOverflow)?,
1108        );
1109        starts.push(lower);
1110        prepared.push(PreparedReflection {
1111            parameters,
1112            profile,
1113        });
1114    }
1115    let active = offsets.last().copied().unwrap_or(0);
1116    let mut y = zeroed_f64_vec(x.len())?;
1117    let mut local = zeroed_f64_vec(
1118        active
1119            .checked_mul(LOCAL_PARAMETER_COUNT)
1120            .ok_or(TofError::AllocationOverflow)?,
1121    )?;
1122    let mut global = zeroed_f64_vec(
1123        TOF_GLOBAL_PARAMETER_COUNT
1124            .checked_mul(x.len())
1125            .ok_or(TofError::AllocationOverflow)?,
1126    )?;
1127    if execution.threads() == 1 || count < 16 {
1128        for reflection in 0..count {
1129            let item = &prepared[reflection];
1130            let intensity = intensities[reflection];
1131            let base_radius = support_fwhm * item.parameters.tch.total_fwhm;
1132            let begin = offsets[reflection];
1133            let end = offsets[reflection + 1];
1134            for active_index in begin..end {
1135                let sample = starts[reflection] + active_index - begin;
1136                let point = item
1137                    .profile
1138                    .evaluate_with_radius(x[sample] - item.parameters.position_us, base_radius);
1139                y[sample] += intensity * point.value;
1140                local[active_index * LOCAL_PARAMETER_COUNT] = point.value;
1141                local[active_index * LOCAL_PARAMETER_COUNT + 1] = intensity
1142                    * (point.d_position * item.parameters.d_position_d_d
1143                        + point.d_alpha * item.parameters.d_alpha_d_d
1144                        + point.d_beta * item.parameters.d_beta_d_d
1145                        + point.d_gaussian_fwhm * item.parameters.d_gaussian_fwhm_d_d
1146                        + point.d_lorentzian_fwhm * item.parameters.d_lorentzian_fwhm_d_d);
1147                for parameter in 0..TOF_GLOBAL_PARAMETER_COUNT {
1148                    let derivative = point.d_position
1149                        * item.parameters.d_position_d_instrument[parameter]
1150                        + point.d_alpha * item.parameters.d_alpha_d_instrument[parameter]
1151                        + point.d_beta * item.parameters.d_beta_d_instrument[parameter]
1152                        + point.d_gaussian_fwhm
1153                            * item.parameters.d_gaussian_fwhm_d_instrument[parameter]
1154                        + point.d_lorentzian_fwhm
1155                            * item.parameters.d_lorentzian_fwhm_d_instrument[parameter];
1156                    global[parameter * x.len() + sample] += intensity * derivative;
1157                }
1158            }
1159        }
1160    } else {
1161        let blocks = execution.map_ordered(count, 16, |reflection| {
1162            let item = &prepared[reflection];
1163            let intensity = intensities[reflection];
1164            let base_radius = support_fwhm * item.parameters.tch.total_fwhm;
1165            let begin = offsets[reflection];
1166            let end = offsets[reflection + 1];
1167            let support_count = end - begin;
1168            let mut block = TofReflectionBlock {
1169                start: starts[reflection],
1170                y: vec![0.0; support_count],
1171                local: vec![0.0; support_count * LOCAL_PARAMETER_COUNT],
1172                global: vec![0.0; support_count * TOF_GLOBAL_PARAMETER_COUNT],
1173            };
1174            for support_index in 0..support_count {
1175                let sample = block.start + support_index;
1176                let point = item
1177                    .profile
1178                    .evaluate_with_radius(x[sample] - item.parameters.position_us, base_radius);
1179                block.y[support_index] = intensity * point.value;
1180                block.local[support_index * LOCAL_PARAMETER_COUNT] = point.value;
1181                block.local[support_index * LOCAL_PARAMETER_COUNT + 1] = intensity
1182                    * (point.d_position * item.parameters.d_position_d_d
1183                        + point.d_alpha * item.parameters.d_alpha_d_d
1184                        + point.d_beta * item.parameters.d_beta_d_d
1185                        + point.d_gaussian_fwhm * item.parameters.d_gaussian_fwhm_d_d
1186                        + point.d_lorentzian_fwhm * item.parameters.d_lorentzian_fwhm_d_d);
1187                for parameter in 0..TOF_GLOBAL_PARAMETER_COUNT {
1188                    let derivative = point.d_position
1189                        * item.parameters.d_position_d_instrument[parameter]
1190                        + point.d_alpha * item.parameters.d_alpha_d_instrument[parameter]
1191                        + point.d_beta * item.parameters.d_beta_d_instrument[parameter]
1192                        + point.d_gaussian_fwhm
1193                            * item.parameters.d_gaussian_fwhm_d_instrument[parameter]
1194                        + point.d_lorentzian_fwhm
1195                            * item.parameters.d_lorentzian_fwhm_d_instrument[parameter];
1196                    block.global[parameter * support_count + support_index] =
1197                        intensity * derivative;
1198                }
1199            }
1200            block
1201        });
1202        for (reflection, block) in blocks.into_iter().enumerate() {
1203            let begin = offsets[reflection];
1204            let support_count = block.y.len();
1205            let local_begin = begin * LOCAL_PARAMETER_COUNT;
1206            let local_end = local_begin + block.local.len();
1207            local[local_begin..local_end].copy_from_slice(&block.local);
1208            for support_index in 0..support_count {
1209                let sample = block.start + support_index;
1210                y[sample] += block.y[support_index];
1211                for parameter in 0..TOF_GLOBAL_PARAMETER_COUNT {
1212                    global[parameter * x.len() + sample] +=
1213                        block.global[parameter * support_count + support_index];
1214                }
1215            }
1216        }
1217    }
1218    Ok(Accumulation {
1219        y,
1220        derivatives: PatternDerivatives {
1221            local: SupportJacobian {
1222                starts,
1223                offsets,
1224                values: local,
1225                parameter_count: LOCAL_PARAMETER_COUNT,
1226            },
1227            global: Some(DenseJacobian {
1228                values: global,
1229                parameter_count: TOF_GLOBAL_PARAMETER_COUNT,
1230                sample_count: x.len(),
1231            }),
1232        },
1233        sample_count: x.len(),
1234    })
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239    use super::*;
1240
1241    #[test]
1242    fn incident_spectrum_matches_closed_form_and_centered_difference() {
1243        let spectrum = TofIncidentSpectrum::new(
1244            500.0,
1245            10_000.0,
1246            [
1247                12.0, 40_000.0, 3.0, 2.0, -0.5, 0.25, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
1248            ],
1249        )
1250        .expect("spectrum");
1251        let tof_us: f64 = 2_500.0;
1252        let t = tof_us / 1_000.0;
1253        let x = 2.0 / t - 1.0;
1254        let expected = 12.0 + 40_000.0 / t.powi(5) * (-3.0 / t.powi(2)).exp() + 2.0 * x
1255            - 0.5 * (2.0 * x * x - 1.0)
1256            + 0.25 * (4.0 * x.powi(3) - 3.0 * x);
1257        let actual = spectrum.evaluate(tof_us).expect("evaluation");
1258        assert!((actual.value - expected).abs() < 1.0e-12 * expected.abs());
1259
1260        let step_us = 1.0e-3;
1261        let plus = spectrum.evaluate(tof_us + step_us).unwrap().value;
1262        let minus = spectrum.evaluate(tof_us - step_us).unwrap().value;
1263        let finite = (plus - minus) / (2.0 * step_us);
1264        assert!((actual.d_value_d_tof_us - finite).abs() < 1.0e-9);
1265    }
1266
1267    #[test]
1268    fn incident_spectrum_enforces_constructor_range_and_positive_evaluation() {
1269        assert_eq!(
1270            TofIncidentSpectrum::new(1_000.0, 1_000.0, [1.0; 12]),
1271            Err(TofIncidentSpectrumError::InvalidRange)
1272        );
1273        assert_eq!(
1274            TofIncidentSpectrum::new(1_000.0, 2_000.0, [f64::NAN; 12]),
1275            Err(TofIncidentSpectrumError::NonFiniteCoefficient)
1276        );
1277        let positive = TofIncidentSpectrum::new(
1278            1_000.0,
1279            2_000.0,
1280            [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
1281        )
1282        .unwrap();
1283        assert!(positive.evaluate(1_000.0).is_ok());
1284        assert!(positive.evaluate(2_000.0).is_ok());
1285        assert_eq!(
1286            positive.evaluate(999.0),
1287            Err(TofIncidentSpectrumError::TofOutsideRange)
1288        );
1289        let zero = TofIncidentSpectrum::new(1_000.0, 2_000.0, [0.0; 12]).unwrap();
1290        assert_eq!(
1291            zero.evaluate(1_500.0),
1292            Err(TofIncidentSpectrumError::NonPositiveIntensity)
1293        );
1294    }
1295
1296    #[test]
1297    fn bank_geometry_requires_a_strict_physical_scattering_angle() {
1298        let geometry = TofBankGeometry {
1299            two_theta_deg: 88.05,
1300        };
1301        geometry.validate().expect("valid bank geometry");
1302        assert!((geometry.theta_radians().unwrap() - 44.025_f64.to_radians()).abs() < 1.0e-15);
1303        for two_theta_deg in [f64::NAN, 0.0, 180.0, f64::INFINITY] {
1304            assert_eq!(
1305                TofBankGeometry { two_theta_deg }.validate(),
1306                Err(TofError::InvalidBankTwoTheta)
1307            );
1308        }
1309    }
1310
1311    fn instrument() -> TofInstrument {
1312        TofInstrument {
1313            zero_us: -0.773_346_536_757,
1314            difc_us_per_angstrom: 5_084.827_630_65,
1315            difa_us_per_angstrom2: -2.630_417_748_6,
1316            difb_us_angstrom: 0.0,
1317            alpha_coefficient: 5.0,
1318            beta0_per_us: 0.033_276_398_966_5,
1319            beta1_angstrom4_per_us: 0.000_964_057_827_372,
1320            betaq_angstrom2_per_us: 0.0,
1321            sigma0_us2: 0.0,
1322            sigma1_us2_per_angstrom2: 15.140_286_726_8,
1323            sigma2_us2_per_angstrom4: 0.0,
1324            sigmaq_us2_per_angstrom: 0.0,
1325            x_us_per_angstrom: 0.0,
1326            y_us_per_angstrom2: 0.0,
1327            z_us: 0.0,
1328        }
1329    }
1330
1331    #[test]
1332    fn parameter_chains_match_centered_differences() {
1333        let d = 1.7;
1334        let step = 1.0e-6;
1335        let actual = TofProfileParameters::from_instrument(d, instrument()).expect("parameters");
1336        let plus = TofProfileParameters::from_instrument(d + step, instrument()).expect("plus");
1337        let minus = TofProfileParameters::from_instrument(d - step, instrument()).expect("minus");
1338        let finite = |high: f64, low: f64| (high - low) / (2.0 * step);
1339        assert!((actual.d_position_d_d - finite(plus.position_us, minus.position_us)).abs() < 1e-6);
1340        assert!((actual.d_alpha_d_d - finite(plus.alpha_per_us, minus.alpha_per_us)).abs() < 1e-9);
1341        assert!((actual.d_beta_d_d - finite(plus.beta_per_us, minus.beta_per_us)).abs() < 1e-9);
1342        assert!(
1343            (actual.d_gaussian_fwhm_d_d - finite(plus.gaussian_fwhm_us, minus.gaussian_fwhm_us))
1344                .abs()
1345                < 1e-8
1346        );
1347    }
1348
1349    #[test]
1350    fn selectable_instrument_parameters_follow_dense_row_order() {
1351        let original = instrument();
1352        let values = original.values();
1353        for parameter in TofInstrumentParameter::ALL {
1354            assert_eq!(
1355                parameter.name(),
1356                TOF_GLOBAL_PARAMETER_NAMES[parameter.index()]
1357            );
1358            let replacement = values[parameter.index()] + 1.0e-6;
1359            let updated = original
1360                .with_parameter(parameter, replacement)
1361                .expect("valid replacement");
1362            for (index, value) in updated.values().iter().copied().enumerate() {
1363                let expected = if index == parameter.index() {
1364                    replacement
1365                } else {
1366                    values[index]
1367                };
1368                assert_eq!(value.to_bits(), expected.to_bits());
1369            }
1370        }
1371        let mut invalid = values;
1372        invalid[TofInstrumentParameter::Difc.index()] = 0.0;
1373        assert_eq!(
1374            TofInstrument::from_values(invalid),
1375            Err(TofError::NonPositiveDifc)
1376        );
1377        invalid = values;
1378        invalid[TofInstrumentParameter::Zero.index()] = f64::NAN;
1379        assert_eq!(
1380            TofInstrument::from_values(invalid),
1381            Err(TofError::NonFiniteInstrumentParameter)
1382        );
1383    }
1384
1385    #[test]
1386    fn profiles_can_share_quadrature_storage() {
1387        let quadrature = Arc::new(TofQuadrature::new(20.0).expect("quadrature"));
1388        let widths = TchWidths {
1389            gaussian_fwhm: 22.0,
1390            lorentzian_fwhm: 4.0,
1391        };
1392        let first = TofProfile::from_validated_rates(0.08, 0.03, widths, Arc::clone(&quadrature))
1393            .expect("first profile");
1394        let second = TofProfile::from_validated_rates(0.09, 0.04, widths, Arc::clone(&quadrature))
1395            .expect("second profile");
1396
1397        assert!(Arc::ptr_eq(&first.quadrature, &second.quadrature));
1398        assert!(std::mem::size_of::<TofProfile>() < 128);
1399    }
1400
1401    #[test]
1402    fn supported_bound_derivative_is_zero_at_exact_clamp() {
1403        let derivative = [1.0, 2.0, 3.0, 4.0, 5.0];
1404        for value in [0.0, 20.0] {
1405            let bounded = SupportedScalar { value, derivative }.clamped(0.0, 20.0);
1406            assert_eq!(bounded.value.to_bits(), value.to_bits());
1407            assert!(bounded.derivative.iter().all(|value| value.to_bits() == 0));
1408        }
1409        let interior = SupportedScalar {
1410            value: 10.0,
1411            derivative,
1412        }
1413        .clamped(0.0, 20.0);
1414        assert!(
1415            interior
1416                .derivative
1417                .iter()
1418                .zip(derivative)
1419                .all(|(actual, expected)| actual.to_bits() == expected.to_bits())
1420        );
1421    }
1422
1423    #[test]
1424    fn direct_profile_is_numerically_unit_area() {
1425        let profile = TofProfile::new(
1426            0.08,
1427            0.03,
1428            TchWidths {
1429                gaussian_fwhm: 22.0,
1430                lorentzian_fwhm: 4.0,
1431            },
1432            20.0,
1433        )
1434        .expect("profile");
1435        let step = 0.5;
1436        let radius = 6_000.0;
1437        let sample_count = 24_000_u32;
1438        let mut area = 0.0;
1439        let mut previous = profile.evaluate(-radius).value;
1440        for index in 1..=sample_count {
1441            let x = -radius + f64::from(index) * step;
1442            let value = profile.evaluate(x).value;
1443            area += 0.5 * step * (previous + value);
1444            previous = value;
1445        }
1446        // The remaining discrepancy is the analytically infinite Lorentzian
1447        // tail outside this deliberately finite integration interval.
1448        assert!((area - 1.0).abs() < 3.0e-4, "integrated area={area:.12}");
1449    }
1450
1451    #[test]
1452    fn accumulation_blocks_are_bitwise_identical_across_worker_counts() {
1453        let x = (0..=8_000)
1454            .map(|index| 1_000.0 + 2.0 * f64::from(index))
1455            .collect::<Vec<_>>();
1456        let d_spacings = (0..36)
1457            .map(|index| 0.5 + 0.065 * f64::from(index))
1458            .collect::<Vec<_>>();
1459        let intensities = (0..36)
1460            .map(|index| 3.0 + 0.3 * f64::from(index))
1461            .collect::<Vec<_>>();
1462        let grid = GridView::new(&x).expect("grid");
1463        let serial = ExecutionContext::serial();
1464        let expected = accumulate_tof_batch_with_context(
1465            grid,
1466            &d_spacings,
1467            &intensities,
1468            instrument(),
1469            20.0,
1470            20.0,
1471            &serial,
1472        )
1473        .expect("serial TOF");
1474        for threads in [2, 3] {
1475            let context = ExecutionContext::new(threads).expect("parallel context");
1476            assert_eq!(
1477                accumulate_tof_batch_with_context(
1478                    grid,
1479                    &d_spacings,
1480                    &intensities,
1481                    instrument(),
1482                    20.0,
1483                    20.0,
1484                    &context,
1485                )
1486                .expect("parallel TOF"),
1487                expected
1488            );
1489        }
1490    }
1491
1492    #[test]
1493    fn direct_profile_derivatives_match_centered_differences() {
1494        let alpha = 0.08;
1495        let beta = 0.03;
1496        let gaussian = 22.0;
1497        let lorentzian = 4.0;
1498        let delta = 5.0;
1499        let tail = 20.0;
1500        let point = TofProfile::new(
1501            alpha,
1502            beta,
1503            TchWidths {
1504                gaussian_fwhm: gaussian,
1505                lorentzian_fwhm: lorentzian,
1506            },
1507            tail,
1508        )
1509        .expect("profile")
1510        .evaluate(delta);
1511        let step = 1.0e-6;
1512        let value = |a, b, g, l, x| {
1513            TofProfile::new(
1514                a,
1515                b,
1516                TchWidths {
1517                    gaussian_fwhm: g,
1518                    lorentzian_fwhm: l,
1519                },
1520                tail,
1521            )
1522            .expect("profile")
1523            .evaluate(x)
1524            .value
1525        };
1526        let fd = |plus, minus| (plus - minus) / (2.0 * step);
1527        assert!(
1528            (point.d_position
1529                - fd(
1530                    value(alpha, beta, gaussian, lorentzian, delta - step),
1531                    value(alpha, beta, gaussian, lorentzian, delta + step)
1532                ))
1533            .abs()
1534                < 1e-8
1535        );
1536        assert!(
1537            (point.d_alpha
1538                - fd(
1539                    value(alpha + step, beta, gaussian, lorentzian, delta),
1540                    value(alpha - step, beta, gaussian, lorentzian, delta)
1541                ))
1542            .abs()
1543                < 1e-7
1544        );
1545        assert!(
1546            (point.d_beta
1547                - fd(
1548                    value(alpha, beta + step, gaussian, lorentzian, delta),
1549                    value(alpha, beta - step, gaussian, lorentzian, delta)
1550                ))
1551            .abs()
1552                < 1e-7
1553        );
1554        assert!(
1555            (point.d_gaussian_fwhm
1556                - fd(
1557                    value(alpha, beta, gaussian + step, lorentzian, delta),
1558                    value(alpha, beta, gaussian - step, lorentzian, delta)
1559                ))
1560            .abs()
1561                < 1e-8
1562        );
1563        assert!(
1564            (point.d_lorentzian_fwhm
1565                - fd(
1566                    value(alpha, beta, gaussian, lorentzian + step, delta),
1567                    value(alpha, beta, gaussian, lorentzian - step, delta)
1568                ))
1569            .abs()
1570                < 1e-8
1571        );
1572    }
1573
1574    #[test]
1575    fn supported_profile_derivatives_include_moving_integration_bounds() {
1576        let alpha = 0.08;
1577        let beta = 0.03;
1578        let gaussian = 22.0;
1579        let lorentzian = 4.0;
1580        let delta = 35.0;
1581        let tail = 20.0;
1582        let support_multiple = 1.25;
1583        let profile = TofProfile::new(
1584            alpha,
1585            beta,
1586            TchWidths {
1587                gaussian_fwhm: gaussian,
1588                lorentzian_fwhm: lorentzian,
1589            },
1590            tail,
1591        )
1592        .expect("profile");
1593        let point =
1594            profile.evaluate_with_radius(delta, support_multiple * profile.shape.total_fwhm);
1595        let step = 1.0e-6;
1596        let value = |a, b, g, l, x| {
1597            let profile = TofProfile::new(
1598                a,
1599                b,
1600                TchWidths {
1601                    gaussian_fwhm: g,
1602                    lorentzian_fwhm: l,
1603                },
1604                tail,
1605            )
1606            .expect("profile");
1607            profile
1608                .evaluate_with_radius(x, support_multiple * profile.shape.total_fwhm)
1609                .value
1610        };
1611        let fd = |plus, minus| (plus - minus) / (2.0 * step);
1612        assert!(
1613            (point.d_position
1614                - fd(
1615                    value(alpha, beta, gaussian, lorentzian, delta - step),
1616                    value(alpha, beta, gaussian, lorentzian, delta + step),
1617                ))
1618            .abs()
1619                < 2.0e-9
1620        );
1621        assert!(
1622            (point.d_alpha
1623                - fd(
1624                    value(alpha + step, beta, gaussian, lorentzian, delta),
1625                    value(alpha - step, beta, gaussian, lorentzian, delta),
1626                ))
1627            .abs()
1628                < 2.0e-8
1629        );
1630        assert!(
1631            (point.d_beta
1632                - fd(
1633                    value(alpha, beta + step, gaussian, lorentzian, delta),
1634                    value(alpha, beta - step, gaussian, lorentzian, delta),
1635                ))
1636            .abs()
1637                < 2.0e-8
1638        );
1639        assert!(
1640            (point.d_gaussian_fwhm
1641                - fd(
1642                    value(alpha, beta, gaussian + step, lorentzian, delta),
1643                    value(alpha, beta, gaussian - step, lorentzian, delta),
1644                ))
1645            .abs()
1646                < 2.0e-9
1647        );
1648        assert!(
1649            (point.d_lorentzian_fwhm
1650                - fd(
1651                    value(alpha, beta, gaussian, lorentzian + step, delta),
1652                    value(alpha, beta, gaussian, lorentzian - step, delta),
1653                ))
1654            .abs()
1655                < 2.0e-9
1656        );
1657    }
1658}