Skip to main content

phasesmith_core/
cw.rs

1//! Constant-wavelength U/V/W/X/Y profile broadening.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6use crate::profile::{
7    Accumulation, DenseJacobian, GridView, PatternDerivatives, ProfileError, SupportJacobian,
8    SupportPolicy, zeroed_f64_vec,
9};
10use crate::tch::{TchShape, TchWidths};
11
12const GAUSSIAN_FWHM_PER_SIGMA: f64 = 2.354_820_045_030_949_3;
13const DEGREE_HALF_ANGLE_TO_RADIAN: f64 = std::f64::consts::PI / 360.0;
14const LOCAL_PARAMETER_COUNT: usize = 2;
15const GLOBAL_PARAMETER_COUNT: usize = 5;
16
17/// Shared constant-wavelength instrument profile parameters in public units.
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct ConstantWavelengthInstrument {
20    /// Radiation wavelength in ångströms.
21    pub wavelength_angstrom: f64,
22    /// Coefficient of `tan(theta)^2` in Gaussian variance, degrees squared.
23    pub u_deg2: f64,
24    /// Coefficient of `tan(theta)` in Gaussian variance, degrees squared.
25    pub v_deg2: f64,
26    /// Constant Gaussian variance, degrees squared.
27    pub w_deg2: f64,
28    /// Coefficient of `sec(theta)` in Lorentzian FWHM, degrees.
29    pub x_deg: f64,
30    /// Coefficient of `tan(theta)` in Lorentzian FWHM, degrees.
31    pub y_deg: f64,
32}
33
34impl ConstantWavelengthInstrument {
35    /// Validate the wavelength and finite profile coefficients.
36    ///
37    /// # Errors
38    ///
39    /// Returns [`CwError`] if the wavelength is not positive and finite or a
40    /// profile coefficient is non-finite.
41    pub fn validate(self) -> Result<(), CwError> {
42        if !self.wavelength_angstrom.is_finite() || self.wavelength_angstrom <= 0.0 {
43            return Err(CwError::InvalidWavelength);
44        }
45        for (name, value) in [
46            ("U", self.u_deg2),
47            ("V", self.v_deg2),
48            ("W", self.w_deg2),
49            ("X", self.x_deg),
50            ("Y", self.y_deg),
51        ] {
52            if !value.is_finite() {
53                return Err(CwError::NonFiniteInstrumentParameter { name });
54            }
55        }
56        Ok(())
57    }
58}
59
60/// Derived component widths, TCH shape, and width derivatives for one reflection.
61#[derive(Clone, Copy, Debug, PartialEq)]
62pub struct CwProfileParameters {
63    /// Gaussian variance in degrees squared.
64    pub gaussian_variance_deg2: f64,
65    /// Gaussian component FWHM in degrees.
66    pub gaussian_fwhm_deg: f64,
67    /// Lorentzian component FWHM in degrees.
68    pub lorentzian_fwhm_deg: f64,
69    /// Transformed TCH total width, eta, and derivatives.
70    pub tch: TchShape,
71    /// Gaussian-FWHM derivatives in `(U, V, W, X, Y)` order.
72    pub d_gaussian_fwhm_d_instrument: [f64; GLOBAL_PARAMETER_COUNT],
73    /// Lorentzian-FWHM derivatives in `(U, V, W, X, Y)` order.
74    pub d_lorentzian_fwhm_d_instrument: [f64; GLOBAL_PARAMETER_COUNT],
75    /// Gaussian-FWHM derivative with respect to reflection `two_theta` in degrees.
76    pub d_gaussian_fwhm_d_two_theta: f64,
77    /// Lorentzian-FWHM derivative with respect to reflection `two_theta` in degrees.
78    pub d_lorentzian_fwhm_d_two_theta: f64,
79}
80
81impl CwProfileParameters {
82    /// Derive profile parameters for one reflection position.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`CwError`] if the instrument, angle, or a derived component
87    /// width is outside its domain.
88    pub fn from_instrument(
89        two_theta_deg: f64,
90        instrument: ConstantWavelengthInstrument,
91    ) -> Result<Self, CwError> {
92        instrument.validate()?;
93        Self::from_validated_instrument(two_theta_deg, instrument)
94    }
95
96    pub(crate) fn from_validated_instrument(
97        two_theta_deg: f64,
98        instrument: ConstantWavelengthInstrument,
99    ) -> Result<Self, CwError> {
100        validate_two_theta(two_theta_deg)?;
101        let theta = two_theta_deg * DEGREE_HALF_ANGLE_TO_RADIAN;
102        let tangent = theta.tan();
103        let secant = theta.cos().recip();
104        let tangent_2 = tangent * tangent;
105        let gaussian_variance_deg2 =
106            instrument.u_deg2 * tangent_2 + instrument.v_deg2 * tangent + instrument.w_deg2;
107        if !gaussian_variance_deg2.is_finite() || gaussian_variance_deg2 <= 0.0 {
108            return Err(CwError::NonPositiveGaussianVariance);
109        }
110        let gaussian_sigma = gaussian_variance_deg2.sqrt();
111        let gaussian_fwhm_deg = GAUSSIAN_FWHM_PER_SIGMA * gaussian_sigma;
112        let lorentzian_fwhm_deg = instrument.x_deg * secant + instrument.y_deg * tangent;
113        if !lorentzian_fwhm_deg.is_finite() || lorentzian_fwhm_deg < 0.0 {
114            return Err(CwError::NegativeLorentzianFwhm);
115        }
116        let tch = TchShape::from_component_fwhm(TchWidths {
117            gaussian_fwhm: gaussian_fwhm_deg,
118            lorentzian_fwhm: lorentzian_fwhm_deg,
119        })
120        .map_err(|_| CwError::InvalidTchTransform)?;
121
122        let d_gaussian_d_variance = GAUSSIAN_FWHM_PER_SIGMA / (2.0 * gaussian_sigma);
123        let d_gaussian_fwhm_d_instrument = [
124            d_gaussian_d_variance * tangent_2,
125            d_gaussian_d_variance * tangent,
126            d_gaussian_d_variance,
127            0.0,
128            0.0,
129        ];
130        let d_lorentzian_fwhm_d_instrument = [0.0, 0.0, 0.0, secant, tangent];
131        let d_tangent_d_two_theta = DEGREE_HALF_ANGLE_TO_RADIAN * secant * secant;
132        let d_secant_d_two_theta = DEGREE_HALF_ANGLE_TO_RADIAN * secant * tangent;
133        let d_variance_d_two_theta =
134            (2.0 * instrument.u_deg2 * tangent + instrument.v_deg2) * d_tangent_d_two_theta;
135        let d_gaussian_fwhm_d_two_theta = d_gaussian_d_variance * d_variance_d_two_theta;
136        let d_lorentzian_fwhm_d_two_theta =
137            instrument.x_deg * d_secant_d_two_theta + instrument.y_deg * d_tangent_d_two_theta;
138
139        Ok(Self {
140            gaussian_variance_deg2,
141            gaussian_fwhm_deg,
142            lorentzian_fwhm_deg,
143            tch,
144            d_gaussian_fwhm_d_instrument,
145            d_lorentzian_fwhm_d_instrument,
146            d_gaussian_fwhm_d_two_theta,
147            d_lorentzian_fwhm_d_two_theta,
148        })
149    }
150}
151
152/// Constant-wavelength profile domain errors.
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154pub enum CwError {
155    /// Wavelength is not positive and finite.
156    InvalidWavelength,
157    /// One instrument coefficient is non-finite.
158    NonFiniteInstrumentParameter {
159        /// Conventional coefficient name.
160        name: &'static str,
161    },
162    /// Reflection position is non-finite or outside `(0, 180)` degrees.
163    InvalidTwoTheta,
164    /// Derived Gaussian variance is not positive and finite.
165    NonPositiveGaussianVariance,
166    /// Derived Lorentzian FWHM is negative or non-finite.
167    NegativeLorentzianFwhm,
168    /// The downstream TCH transform could not represent the widths.
169    InvalidTchTransform,
170}
171
172impl Display for CwError {
173    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
174        match self {
175            Self::InvalidWavelength => write!(formatter, "wavelength must be positive and finite"),
176            Self::NonFiniteInstrumentParameter { name } => {
177                write!(formatter, "instrument parameter {name} must be finite")
178            }
179            Self::InvalidTwoTheta => {
180                write!(
181                    formatter,
182                    "two_theta must be finite and within (0, 180) degrees"
183                )
184            }
185            Self::NonPositiveGaussianVariance => {
186                write!(
187                    formatter,
188                    "derived Gaussian variance must be positive and finite"
189                )
190            }
191            Self::NegativeLorentzianFwhm => {
192                write!(
193                    formatter,
194                    "derived Lorentzian FWHM must be non-negative and finite"
195                )
196            }
197            Self::InvalidTchTransform => write!(formatter, "derived widths fail the TCH transform"),
198        }
199    }
200}
201
202impl Error for CwError {}
203
204/// Errors while validating or accumulating a constant-wavelength batch.
205#[derive(Clone, Debug, PartialEq, Eq)]
206pub enum CwBatchError {
207    /// Reflection position and intensity arrays have different lengths.
208    ReflectionLengthMismatch,
209    /// An integrated intensity is non-finite.
210    NonFiniteIntensity {
211        /// Index of the invalid reflection.
212        reflection: usize,
213    },
214    /// The shared instrument model is invalid.
215    InvalidInstrument {
216        /// Instrument validation failure.
217        reason: CwError,
218    },
219    /// A reflection angle or its derived widths are invalid.
220    InvalidReflection {
221        /// Index of the invalid reflection.
222        reflection: usize,
223        /// Reflection-specific validation failure.
224        reason: CwError,
225    },
226    /// Generic grid, support, or allocation failure from the accumulator.
227    Accumulation {
228        /// Underlying generic profile error.
229        reason: ProfileError,
230    },
231}
232
233impl Display for CwBatchError {
234    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
235        match self {
236            Self::ReflectionLengthMismatch => write!(
237                formatter,
238                "two_theta positions and integrated intensities must have equal length"
239            ),
240            Self::NonFiniteIntensity { reflection } => {
241                write!(
242                    formatter,
243                    "reflection {reflection} intensity must be finite"
244                )
245            }
246            Self::InvalidInstrument { reason } => {
247                write!(
248                    formatter,
249                    "invalid constant-wavelength instrument: {reason}"
250                )
251            }
252            Self::InvalidReflection { reflection, reason } => write!(
253                formatter,
254                "constant-wavelength reflection {reflection} is invalid: {reason}"
255            ),
256            Self::Accumulation { reason } => Display::fmt(reason, formatter),
257        }
258    }
259}
260
261impl Error for CwBatchError {}
262
263impl From<ProfileError> for CwBatchError {
264    fn from(reason: ProfileError) -> Self {
265        Self::Accumulation { reason }
266    }
267}
268
269/// Validated borrowed constant-wavelength reflection arrays.
270#[derive(Clone, Copy, Debug)]
271pub struct CwReflectionBatchView<'a> {
272    two_theta_deg: &'a [f64],
273    intensities: &'a [f64],
274}
275
276impl<'a> CwReflectionBatchView<'a> {
277    /// Validate and borrow reflection positions and integrated intensities.
278    ///
279    /// # Errors
280    ///
281    /// Returns [`CwBatchError`] if array lengths differ, a position is outside
282    /// `(0, 180)` degrees, or an intensity is non-finite.
283    pub fn new(two_theta_deg: &'a [f64], intensities: &'a [f64]) -> Result<Self, CwBatchError> {
284        if two_theta_deg.len() != intensities.len() {
285            return Err(CwBatchError::ReflectionLengthMismatch);
286        }
287        for reflection in 0..two_theta_deg.len() {
288            validate_two_theta(two_theta_deg[reflection])
289                .map_err(|reason| CwBatchError::InvalidReflection { reflection, reason })?;
290            if !intensities[reflection].is_finite() {
291                return Err(CwBatchError::NonFiniteIntensity { reflection });
292            }
293        }
294        Ok(Self {
295            two_theta_deg,
296            intensities,
297        })
298    }
299
300    /// Number of reflections.
301    #[must_use]
302    pub const fn len(self) -> usize {
303        self.two_theta_deg.len()
304    }
305
306    /// Whether there are no reflections.
307    #[must_use]
308    pub const fn is_empty(self) -> bool {
309        self.two_theta_deg.is_empty()
310    }
311
312    pub(crate) const fn position(self, reflection: usize) -> f64 {
313        self.two_theta_deg[reflection]
314    }
315
316    pub(crate) const fn intensity(self, reflection: usize) -> f64 {
317        self.intensities[reflection]
318    }
319}
320
321/// Accumulate a CW reflection batch with sparse local and dense global derivatives.
322///
323/// Local derivative order is intensity and position. Dense global derivative
324/// order is U, V, W, X, and Y.
325///
326/// # Errors
327///
328/// Returns [`CwBatchError`] if the instrument produces an invalid width for a
329/// reflection, support is invalid, or allocation fails.
330pub fn accumulate_cw_batch(
331    grid: GridView<'_>,
332    reflections: CwReflectionBatchView<'_>,
333    instrument: ConstantWavelengthInstrument,
334    support: SupportPolicy,
335) -> Result<Accumulation, CwBatchError> {
336    support.validate()?;
337    instrument
338        .validate()
339        .map_err(|reason| CwBatchError::InvalidInstrument { reason })?;
340    let x = grid.as_slice();
341    let reflection_count = reflections.len();
342    let mut parameters = Vec::new();
343    let mut starts: Vec<usize> = Vec::new();
344    let mut offsets: Vec<usize> = Vec::new();
345    parameters
346        .try_reserve_exact(reflection_count)
347        .map_err(|_| ProfileError::AllocationOverflow)?;
348    starts
349        .try_reserve_exact(reflection_count)
350        .map_err(|_| ProfileError::AllocationOverflow)?;
351    offsets
352        .try_reserve_exact(
353            reflection_count
354                .checked_add(1)
355                .ok_or(ProfileError::AllocationOverflow)?,
356        )
357        .map_err(|_| ProfileError::AllocationOverflow)?;
358    offsets.push(0);
359
360    for reflection in 0..reflection_count {
361        let profile = CwProfileParameters::from_validated_instrument(
362            reflections.two_theta_deg[reflection],
363            instrument,
364        )
365        .map_err(|reason| CwBatchError::InvalidReflection { reflection, reason })?;
366        let range = support.range(
367            reflections.two_theta_deg[reflection],
368            profile.tch.total_fwhm,
369        );
370        let lower = x.partition_point(|value| *value < range.left);
371        let upper = x.partition_point(|value| *value <= range.right);
372        let next_offset = offsets[reflection]
373            .checked_add(upper - lower)
374            .ok_or(ProfileError::AllocationOverflow)?;
375        parameters.push(profile);
376        starts.push(lower);
377        offsets.push(next_offset);
378    }
379
380    let active_sample_count = offsets.last().copied().unwrap_or(0);
381    let local_value_count = active_sample_count
382        .checked_mul(LOCAL_PARAMETER_COUNT)
383        .ok_or(ProfileError::AllocationOverflow)?;
384    let global_value_count = GLOBAL_PARAMETER_COUNT
385        .checked_mul(x.len())
386        .ok_or(ProfileError::AllocationOverflow)?;
387    let mut y = zeroed_f64_vec(x.len())?;
388    let mut local_values = zeroed_f64_vec(local_value_count)?;
389    let mut global_values = zeroed_f64_vec(global_value_count)?;
390
391    for reflection in 0..reflection_count {
392        let start = starts[reflection];
393        let active_begin = offsets[reflection];
394        let active_end = offsets[reflection + 1];
395        let profile = parameters[reflection];
396        let intensity = reflections.intensities[reflection];
397        for active_index in active_begin..active_end {
398            let sample = start + active_index - active_begin;
399            let point = profile
400                .tch
401                .evaluate(x[sample] - reflections.two_theta_deg[reflection]);
402            y[sample] += intensity * point.value;
403            let local_base = active_index * LOCAL_PARAMETER_COUNT;
404            local_values[local_base] = point.value;
405            local_values[local_base + 1] = intensity
406                * (-point.d_delta
407                    + point.d_gaussian_fwhm * profile.d_gaussian_fwhm_d_two_theta
408                    + point.d_lorentzian_fwhm * profile.d_lorentzian_fwhm_d_two_theta);
409            for parameter in 0..GLOBAL_PARAMETER_COUNT {
410                let derivative = point.d_gaussian_fwhm
411                    * profile.d_gaussian_fwhm_d_instrument[parameter]
412                    + point.d_lorentzian_fwhm * profile.d_lorentzian_fwhm_d_instrument[parameter];
413                global_values[parameter * x.len() + sample] += intensity * derivative;
414            }
415        }
416    }
417
418    Ok(Accumulation {
419        y,
420        derivatives: PatternDerivatives {
421            local: SupportJacobian {
422                starts,
423                offsets,
424                values: local_values,
425                parameter_count: LOCAL_PARAMETER_COUNT,
426            },
427            global: Some(DenseJacobian {
428                values: global_values,
429                parameter_count: GLOBAL_PARAMETER_COUNT,
430                sample_count: x.len(),
431            }),
432        },
433        sample_count: x.len(),
434    })
435}
436
437fn validate_two_theta(two_theta_deg: f64) -> Result<(), CwError> {
438    if !two_theta_deg.is_finite() || !(0.0..180.0).contains(&two_theta_deg) || two_theta_deg == 0.0
439    {
440        return Err(CwError::InvalidTwoTheta);
441    }
442    Ok(())
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    fn instrument() -> ConstantWavelengthInstrument {
450        ConstantWavelengthInstrument {
451            wavelength_angstrom: 1.5406,
452            u_deg2: 2e-4,
453            v_deg2: -1e-4,
454            w_deg2: 1e-4,
455            x_deg: 1e-3,
456            y_deg: 2e-3,
457        }
458    }
459
460    fn assert_close(actual: f64, expected: f64, tolerance: f64) {
461        assert!(
462            (actual - expected).abs() <= tolerance,
463            "actual={actual:.17e}, expected={expected:.17e}, tolerance={tolerance:.1e}"
464        );
465    }
466
467    fn assert_relative_close(actual: f64, expected: f64, relative_tolerance: f64) {
468        let scale = actual.abs().max(expected.abs()).max(f64::MIN_POSITIVE);
469        assert_close(actual, expected, relative_tolerance * scale);
470    }
471
472    #[test]
473    fn gsas_unit_converted_width_formula_is_exact() {
474        let position = 19.712_609_2;
475        let profile = CwProfileParameters::from_instrument(position, instrument()).expect("valid");
476        assert_close(profile.gaussian_variance_deg2 * 1e4, 0.886_630_51, 5e-9);
477        let theta = position * DEGREE_HALF_ANGLE_TO_RADIAN;
478        let expected_lorentzian = 1e-3 / theta.cos() + 2e-3 * theta.tan();
479        assert_close(profile.lorentzian_fwhm_deg, expected_lorentzian, 1e-18);
480    }
481
482    #[test]
483    fn derived_width_derivatives_match_centered_differences() {
484        let position = 63.2;
485        let baseline = CwProfileParameters::from_instrument(position, instrument()).expect("valid");
486        let instrument_step = 1e-8;
487        for parameter in 0..GLOBAL_PARAMETER_COUNT {
488            let mut plus = instrument();
489            let mut minus = instrument();
490            let plus_parameter = match parameter {
491                0 => &mut plus.u_deg2,
492                1 => &mut plus.v_deg2,
493                2 => &mut plus.w_deg2,
494                3 => &mut plus.x_deg,
495                _ => &mut plus.y_deg,
496            };
497            *plus_parameter += instrument_step;
498            let minus_parameter = match parameter {
499                0 => &mut minus.u_deg2,
500                1 => &mut minus.v_deg2,
501                2 => &mut minus.w_deg2,
502                3 => &mut minus.x_deg,
503                _ => &mut minus.y_deg,
504            };
505            *minus_parameter -= instrument_step;
506            let plus_profile = CwProfileParameters::from_instrument(position, plus).expect("plus");
507            let minus_profile =
508                CwProfileParameters::from_instrument(position, minus).expect("minus");
509            assert_relative_close(
510                baseline.d_gaussian_fwhm_d_instrument[parameter],
511                (plus_profile.gaussian_fwhm_deg - minus_profile.gaussian_fwhm_deg)
512                    / (2.0 * instrument_step),
513                2e-8,
514            );
515            assert_close(
516                baseline.d_lorentzian_fwhm_d_instrument[parameter],
517                (plus_profile.lorentzian_fwhm_deg - minus_profile.lorentzian_fwhm_deg)
518                    / (2.0 * instrument_step),
519                2e-10,
520            );
521        }
522        let position_step = 1e-5;
523        let plus = CwProfileParameters::from_instrument(position + position_step, instrument())
524            .expect("+");
525        let minus = CwProfileParameters::from_instrument(position - position_step, instrument())
526            .expect("-");
527        assert_close(
528            baseline.d_gaussian_fwhm_d_two_theta,
529            (plus.gaussian_fwhm_deg - minus.gaussian_fwhm_deg) / (2.0 * position_step),
530            2e-10,
531        );
532        assert_close(
533            baseline.d_lorentzian_fwhm_d_two_theta,
534            (plus.lorentzian_fwhm_deg - minus.lorentzian_fwhm_deg) / (2.0 * position_step),
535            2e-11,
536        );
537    }
538
539    #[test]
540    fn invalid_derived_widths_are_errors() {
541        assert_eq!(
542            CwProfileParameters::from_instrument(
543                30.0,
544                ConstantWavelengthInstrument {
545                    w_deg2: -1.0,
546                    ..instrument()
547                },
548            ),
549            Err(CwError::NonPositiveGaussianVariance)
550        );
551        assert_eq!(
552            CwProfileParameters::from_instrument(
553                30.0,
554                ConstantWavelengthInstrument {
555                    x_deg: -1.0,
556                    y_deg: 0.0,
557                    ..instrument()
558                },
559            ),
560            Err(CwError::NegativeLorentzianFwhm)
561        );
562    }
563}