1use 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;
16pub const TOF_GLOBAL_PARAMETER_COUNT: usize = 15;
19pub 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];
24pub const TOF_INCIDENT_SPECTRUM_COEFFICIENT_COUNT: usize = 12;
26
27#[repr(usize)]
29#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub enum TofInstrumentParameter {
31 Zero,
33 Difc,
35 Difa,
37 Difb,
39 Alpha,
41 Beta0,
43 Beta1,
45 Betaq,
47 Sigma0,
49 Sigma1,
51 Sigma2,
53 Sigmaq,
55 X,
57 Y,
59 Z,
61}
62
63impl TofInstrumentParameter {
64 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 #[must_use]
85 pub const fn index(self) -> usize {
86 self as usize
87 }
88
89 #[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#[derive(Clone, Copy, Debug, PartialEq)]
104pub struct TofIncidentSpectrumPoint {
105 pub value: f64,
107 pub d_value_d_tof_us: f64,
109}
110
111#[derive(Clone, Copy, Debug, PartialEq)]
118pub struct TofIncidentSpectrum {
119 pub min_tof_us: f64,
121 pub max_tof_us: f64,
123 pub coefficients: [f64; TOF_INCIDENT_SPECTRUM_COEFFICIENT_COUNT],
125}
126
127impl TofIncidentSpectrum {
128 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
211pub enum TofIncidentSpectrumError {
212 InvalidRange,
214 NonFiniteCoefficient,
216 TofOutsideRange,
218 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#[derive(Clone, Copy, Debug, PartialEq)]
241pub struct TofBankGeometry {
242 pub two_theta_deg: f64,
244}
245
246impl TofBankGeometry {
247 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 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#[derive(Clone, Copy, Debug, PartialEq)]
276pub struct TofInstrument {
277 pub zero_us: f64,
279 pub difc_us_per_angstrom: f64,
281 pub difa_us_per_angstrom2: f64,
283 pub difb_us_angstrom: f64,
285 pub alpha_coefficient: f64,
287 pub beta0_per_us: f64,
289 pub beta1_angstrom4_per_us: f64,
291 pub betaq_angstrom2_per_us: f64,
293 pub sigma0_us2: f64,
295 pub sigma1_us2_per_angstrom2: f64,
297 pub sigma2_us2_per_angstrom4: f64,
299 pub sigmaq_us2_per_angstrom: f64,
301 pub x_us_per_angstrom: f64,
303 pub y_us_per_angstrom2: f64,
305 pub z_us: f64,
307}
308
309impl TofInstrument {
310 #[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 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 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 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#[derive(Clone, Copy, Debug, PartialEq)]
410pub struct TofProfileParameters {
411 pub position_us: f64,
413 pub alpha_per_us: f64,
415 pub beta_per_us: f64,
417 pub gaussian_variance_us2: f64,
419 pub gaussian_fwhm_us: f64,
421 pub lorentzian_fwhm_us: f64,
423 pub tch: TchShape,
425 pub d_position_d_d: f64,
427 pub d_alpha_d_d: f64,
429 pub d_beta_d_d: f64,
431 pub d_gaussian_fwhm_d_d: f64,
433 pub d_lorentzian_fwhm_d_d: f64,
435 pub d_position_d_instrument: [f64; TOF_GLOBAL_PARAMETER_COUNT],
437 pub d_alpha_d_instrument: [f64; TOF_GLOBAL_PARAMETER_COUNT],
439 pub d_beta_d_instrument: [f64; TOF_GLOBAL_PARAMETER_COUNT],
441 pub d_gaussian_fwhm_d_instrument: [f64; TOF_GLOBAL_PARAMETER_COUNT],
443 pub d_lorentzian_fwhm_d_instrument: [f64; TOF_GLOBAL_PARAMETER_COUNT],
445}
446
447impl TofProfileParameters {
448 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#[derive(Clone, Copy, Debug, Default, PartialEq)]
551pub struct TofProfilePoint {
552 pub value: f64,
554 pub d_position: f64,
556 pub d_alpha: f64,
558 pub d_beta: f64,
560 pub d_gaussian_fwhm: f64,
562 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#[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 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 #[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#[derive(Clone, Debug, PartialEq)]
910pub enum TofError {
911 InvalidBankTwoTheta,
913 NonFiniteInstrumentParameter,
915 NonPositiveDifc,
917 InvalidDSpacing,
919 InvalidPosition,
921 NonPositiveAlpha,
923 NonPositiveBeta,
925 NonPositiveGaussianVariance,
927 NegativeLorentzianFwhm,
929 InvalidTailLog,
931 InvalidQuadrature,
933 InvalidTch {
935 reason: TchError,
937 },
938 LengthMismatch,
940 NonFiniteIntensity {
942 reflection: usize,
944 },
945 AllocationOverflow,
947 Profile {
949 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#[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#[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 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}