Skip to main content

phasesmith_core/
tch.rs

1//! Thompson-Cox-Hastings component-width transform and symmetric profile.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6use crate::profile::{
7    Accumulation, GridView, PatternDerivatives, ProfileError, SupportJacobian, SupportPolicy,
8    symmetric_pseudo_voigt, zeroed_f64_vec,
9};
10
11const WIDTH_COEFFICIENT_1: f64 = 2.692_69;
12const WIDTH_COEFFICIENT_2: f64 = 2.428_43;
13const WIDTH_COEFFICIENT_3: f64 = 4.471_63;
14const WIDTH_COEFFICIENT_4: f64 = 0.078_42;
15const ETA_COEFFICIENT_1: f64 = 1.366_03;
16const ETA_COEFFICIENT_2: f64 = 0.477_19;
17const ETA_COEFFICIENT_3: f64 = 0.111_16;
18
19/// Gaussian and Lorentzian component FWHMs in one common coordinate unit.
20#[derive(Clone, Copy, Debug, PartialEq)]
21pub struct TchWidths {
22    /// Gaussian full width at half maximum.
23    pub gaussian_fwhm: f64,
24    /// Lorentzian full width at half maximum.
25    pub lorentzian_fwhm: f64,
26}
27
28/// Transformed TCH pseudo-Voigt shape and analytical width derivatives.
29#[derive(Clone, Copy, Debug, PartialEq)]
30pub struct TchShape {
31    /// Common pseudo-Voigt full width at half maximum.
32    pub total_fwhm: f64,
33    /// Lorentzian mixing fraction.
34    pub eta: f64,
35    /// Derivative of total FWHM with respect to Gaussian component FWHM.
36    pub d_total_fwhm_d_gaussian_fwhm: f64,
37    /// Derivative of total FWHM with respect to Lorentzian component FWHM.
38    pub d_total_fwhm_d_lorentzian_fwhm: f64,
39    /// Derivative of eta with respect to Gaussian component FWHM.
40    pub d_eta_d_gaussian_fwhm: f64,
41    /// Derivative of eta with respect to Lorentzian component FWHM.
42    pub d_eta_d_lorentzian_fwhm: f64,
43}
44
45/// One TCH profile value and derivatives with respect to its direct inputs.
46#[derive(Clone, Copy, Debug, PartialEq)]
47pub struct TchProfilePoint {
48    /// Unit-area profile value.
49    pub value: f64,
50    /// Derivative with respect to `delta = x - position`.
51    pub d_delta: f64,
52    /// Derivative with respect to Gaussian component FWHM.
53    pub d_gaussian_fwhm: f64,
54    /// Derivative with respect to Lorentzian component FWHM.
55    pub d_lorentzian_fwhm: f64,
56}
57
58/// Component-width domain errors for the TCH transform.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum TchError {
61    /// Gaussian FWHM is NaN or infinite.
62    NonFiniteGaussianFwhm,
63    /// Lorentzian FWHM is NaN or infinite.
64    NonFiniteLorentzianFwhm,
65    /// Gaussian FWHM is negative.
66    NegativeGaussianFwhm,
67    /// Lorentzian FWHM is negative.
68    NegativeLorentzianFwhm,
69    /// Both component widths are zero.
70    ZeroComponentWidths,
71    /// The transformed total width exceeds finite floating-point range.
72    NonFiniteTransform,
73}
74
75impl Display for TchError {
76    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
77        match self {
78            Self::NonFiniteGaussianFwhm => {
79                write!(formatter, "Gaussian FWHM must be finite")
80            }
81            Self::NonFiniteLorentzianFwhm => {
82                write!(formatter, "Lorentzian FWHM must be finite")
83            }
84            Self::NegativeGaussianFwhm => {
85                write!(formatter, "Gaussian FWHM must be non-negative")
86            }
87            Self::NegativeLorentzianFwhm => {
88                write!(formatter, "Lorentzian FWHM must be non-negative")
89            }
90            Self::ZeroComponentWidths => {
91                write!(formatter, "at least one component FWHM must be positive")
92            }
93            Self::NonFiniteTransform => {
94                write!(formatter, "transformed TCH width is outside finite range")
95            }
96        }
97    }
98}
99
100impl Error for TchError {}
101
102impl TchShape {
103    /// Transform Gaussian and Lorentzian component FWHMs into `(H, eta)`.
104    ///
105    /// # Errors
106    ///
107    /// Returns [`TchError`] for non-finite or negative widths, or when both
108    /// widths are zero.
109    pub fn from_component_fwhm(widths: TchWidths) -> Result<Self, TchError> {
110        validate_widths(widths)?;
111        let width_scale = widths.gaussian_fwhm.max(widths.lorentzian_fwhm);
112        let gaussian = widths.gaussian_fwhm / width_scale;
113        let lorentzian = widths.lorentzian_fwhm / width_scale;
114        let gaussian_2 = gaussian * gaussian;
115        let gaussian_3 = gaussian_2 * gaussian;
116        let gaussian_4 = gaussian_3 * gaussian;
117        let lorentzian_2 = lorentzian * lorentzian;
118        let lorentzian_3 = lorentzian_2 * lorentzian;
119        let lorentzian_4 = lorentzian_3 * lorentzian;
120        let width_polynomial = gaussian_4 * gaussian
121            + WIDTH_COEFFICIENT_1 * gaussian_4 * lorentzian
122            + WIDTH_COEFFICIENT_2 * gaussian_3 * lorentzian_2
123            + WIDTH_COEFFICIENT_3 * gaussian_2 * lorentzian_3
124            + WIDTH_COEFFICIENT_4 * gaussian * lorentzian_4
125            + lorentzian_4 * lorentzian;
126        let normalized_total_fwhm = width_polynomial.powf(0.2);
127        let total_fwhm = width_scale * normalized_total_fwhm;
128        if !total_fwhm.is_finite() {
129            return Err(TchError::NonFiniteTransform);
130        }
131        let normalized_total_fwhm_4 = normalized_total_fwhm.powi(4);
132
133        let d_polynomial_d_gaussian = 5.0 * gaussian_4
134            + 4.0 * WIDTH_COEFFICIENT_1 * gaussian_3 * lorentzian
135            + 3.0 * WIDTH_COEFFICIENT_2 * gaussian_2 * lorentzian_2
136            + 2.0 * WIDTH_COEFFICIENT_3 * gaussian * lorentzian_3
137            + WIDTH_COEFFICIENT_4 * lorentzian_4;
138        let d_polynomial_d_lorentzian = WIDTH_COEFFICIENT_1 * gaussian_4
139            + 2.0 * WIDTH_COEFFICIENT_2 * gaussian_3 * lorentzian
140            + 3.0 * WIDTH_COEFFICIENT_3 * gaussian_2 * lorentzian_2
141            + 4.0 * WIDTH_COEFFICIENT_4 * gaussian * lorentzian_3
142            + 5.0 * lorentzian_4;
143        let derivative_scale = (5.0 * normalized_total_fwhm_4).recip();
144        let d_total_fwhm_d_gaussian_fwhm = d_polynomial_d_gaussian * derivative_scale;
145        let d_total_fwhm_d_lorentzian_fwhm = d_polynomial_d_lorentzian * derivative_scale;
146
147        let ratio = lorentzian / normalized_total_fwhm;
148        let ratio_2 = ratio * ratio;
149        let eta = ETA_COEFFICIENT_1 * ratio - ETA_COEFFICIENT_2 * ratio_2
150            + ETA_COEFFICIENT_3 * ratio_2 * ratio;
151        let d_eta_d_ratio =
152            ETA_COEFFICIENT_1 - 2.0 * ETA_COEFFICIENT_2 * ratio + 3.0 * ETA_COEFFICIENT_3 * ratio_2;
153        let d_ratio_d_gaussian = -ratio * d_total_fwhm_d_gaussian_fwhm / total_fwhm;
154        let d_ratio_d_lorentzian = (1.0 - ratio * d_total_fwhm_d_lorentzian_fwhm) / total_fwhm;
155
156        Ok(Self {
157            total_fwhm,
158            eta,
159            d_total_fwhm_d_gaussian_fwhm,
160            d_total_fwhm_d_lorentzian_fwhm,
161            d_eta_d_gaussian_fwhm: d_eta_d_ratio * d_ratio_d_gaussian,
162            d_eta_d_lorentzian_fwhm: d_eta_d_ratio * d_ratio_d_lorentzian,
163        })
164    }
165
166    /// Evaluate a profile point using this precomputed width transform.
167    #[must_use]
168    pub fn evaluate(self, delta: f64) -> TchProfilePoint {
169        tch_pseudo_voigt_from_shape(delta, self)
170    }
171}
172
173/// Evaluate the TCH pseudo-Voigt profile and component-width derivatives.
174///
175/// # Errors
176///
177/// Returns [`TchError`] if either component width is invalid.
178pub fn tch_pseudo_voigt(delta: f64, widths: TchWidths) -> Result<TchProfilePoint, TchError> {
179    let shape = TchShape::from_component_fwhm(widths)?;
180    Ok(shape.evaluate(delta))
181}
182
183fn tch_pseudo_voigt_from_shape(delta: f64, shape: TchShape) -> TchProfilePoint {
184    let primitive = symmetric_pseudo_voigt(delta, shape.total_fwhm, shape.eta);
185    TchProfilePoint {
186        value: primitive.value,
187        d_delta: primitive.d_delta,
188        d_gaussian_fwhm: primitive.d_fwhm * shape.d_total_fwhm_d_gaussian_fwhm
189            + primitive.d_eta * shape.d_eta_d_gaussian_fwhm,
190        d_lorentzian_fwhm: primitive.d_fwhm * shape.d_total_fwhm_d_lorentzian_fwhm
191            + primitive.d_eta * shape.d_eta_d_lorentzian_fwhm,
192    }
193}
194
195/// Validated borrowed structure-of-arrays TCH peak batch.
196#[derive(Clone, Copy, Debug)]
197pub struct TchPeakBatchView<'a> {
198    positions: &'a [f64],
199    intensities: &'a [f64],
200    gaussian_fwhms: &'a [f64],
201    lorentzian_fwhms: &'a [f64],
202}
203
204impl<'a> TchPeakBatchView<'a> {
205    /// Validate and borrow equal-length TCH peak arrays.
206    ///
207    /// # Errors
208    ///
209    /// Returns [`ProfileError`] if lengths differ, positions or intensities are
210    /// non-finite, or component widths are invalid.
211    pub fn new(
212        positions: &'a [f64],
213        intensities: &'a [f64],
214        gaussian_fwhms: &'a [f64],
215        lorentzian_fwhms: &'a [f64],
216    ) -> Result<Self, ProfileError> {
217        let peak_count = positions.len();
218        if intensities.len() != peak_count
219            || gaussian_fwhms.len() != peak_count
220            || lorentzian_fwhms.len() != peak_count
221        {
222            return Err(ProfileError::TchPeakLengthMismatch);
223        }
224        for peak in 0..peak_count {
225            if !positions[peak].is_finite() || !intensities[peak].is_finite() {
226                return Err(ProfileError::NonFinitePeak { peak });
227            }
228            validate_widths(TchWidths {
229                gaussian_fwhm: gaussian_fwhms[peak],
230                lorentzian_fwhm: lorentzian_fwhms[peak],
231            })
232            .map_err(|reason| ProfileError::InvalidTchPeak { peak, reason })?;
233        }
234        Ok(Self {
235            positions,
236            intensities,
237            gaussian_fwhms,
238            lorentzian_fwhms,
239        })
240    }
241
242    /// Number of peaks in the batch.
243    #[must_use]
244    pub const fn len(self) -> usize {
245        self.positions.len()
246    }
247
248    /// Whether the batch contains no peaks.
249    #[must_use]
250    pub const fn is_empty(self) -> bool {
251        self.positions.is_empty()
252    }
253
254    fn widths(self, peak: usize) -> TchWidths {
255        TchWidths {
256            gaussian_fwhm: self.gaussian_fwhms[peak],
257            lorentzian_fwhm: self.lorentzian_fwhms[peak],
258        }
259    }
260}
261
262/// Accumulate TCH peaks and direct-input derivatives in one support-limited pass.
263///
264/// Local derivative order is intensity, position, Gaussian FWHM, and
265/// Lorentzian FWHM.
266///
267/// # Errors
268///
269/// Returns [`ProfileError`] if support is invalid or allocation fails.
270pub fn accumulate_tch_batch(
271    grid: GridView<'_>,
272    peaks: TchPeakBatchView<'_>,
273    support: SupportPolicy,
274) -> Result<Accumulation, ProfileError> {
275    support.validate()?;
276    let x = grid.as_slice();
277    let peak_count = peaks.len();
278    let mut shapes = Vec::new();
279    let mut starts: Vec<usize> = Vec::new();
280    let mut offsets: Vec<usize> = Vec::new();
281    shapes
282        .try_reserve_exact(peak_count)
283        .map_err(|_| ProfileError::AllocationOverflow)?;
284    starts
285        .try_reserve_exact(peak_count)
286        .map_err(|_| ProfileError::AllocationOverflow)?;
287    offsets
288        .try_reserve_exact(
289            peak_count
290                .checked_add(1)
291                .ok_or(ProfileError::AllocationOverflow)?,
292        )
293        .map_err(|_| ProfileError::AllocationOverflow)?;
294    offsets.push(0);
295
296    for peak in 0..peak_count {
297        let shape = TchShape::from_component_fwhm(peaks.widths(peak))
298            .map_err(|reason| ProfileError::InvalidTchPeak { peak, reason })?;
299        let range = support.range(peaks.positions[peak], shape.total_fwhm);
300        let lower = x.partition_point(|value| *value < range.left);
301        let upper = x.partition_point(|value| *value <= range.right);
302        let next_offset = offsets[peak]
303            .checked_add(upper - lower)
304            .ok_or(ProfileError::AllocationOverflow)?;
305        shapes.push(shape);
306        starts.push(lower);
307        offsets.push(next_offset);
308    }
309
310    let active_sample_count = offsets.last().copied().unwrap_or(0);
311    let value_count = active_sample_count
312        .checked_mul(4)
313        .ok_or(ProfileError::AllocationOverflow)?;
314    let mut y = zeroed_f64_vec(x.len())?;
315    let mut values = zeroed_f64_vec(value_count)?;
316    for peak in 0..peak_count {
317        let start = starts[peak];
318        let active_begin = offsets[peak];
319        let active_end = offsets[peak + 1];
320        for active_index in active_begin..active_end {
321            let sample = start + active_index - active_begin;
322            let point = shapes[peak].evaluate(x[sample] - peaks.positions[peak]);
323            let intensity = peaks.intensities[peak];
324            y[sample] += intensity * point.value;
325            let value_base = active_index * 4;
326            values[value_base] = point.value;
327            values[value_base + 1] = -intensity * point.d_delta;
328            values[value_base + 2] = intensity * point.d_gaussian_fwhm;
329            values[value_base + 3] = intensity * point.d_lorentzian_fwhm;
330        }
331    }
332
333    Ok(Accumulation {
334        y,
335        derivatives: PatternDerivatives {
336            local: SupportJacobian {
337                starts,
338                offsets,
339                values,
340                parameter_count: 4,
341            },
342            global: None,
343        },
344        sample_count: x.len(),
345    })
346}
347
348fn validate_widths(widths: TchWidths) -> Result<(), TchError> {
349    if !widths.gaussian_fwhm.is_finite() {
350        return Err(TchError::NonFiniteGaussianFwhm);
351    }
352    if !widths.lorentzian_fwhm.is_finite() {
353        return Err(TchError::NonFiniteLorentzianFwhm);
354    }
355    if widths.gaussian_fwhm < 0.0 {
356        return Err(TchError::NegativeGaussianFwhm);
357    }
358    if widths.lorentzian_fwhm < 0.0 {
359        return Err(TchError::NegativeLorentzianFwhm);
360    }
361    if widths.gaussian_fwhm == 0.0 && widths.lorentzian_fwhm == 0.0 {
362        return Err(TchError::ZeroComponentWidths);
363    }
364    Ok(())
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    fn assert_close(actual: f64, expected: f64, tolerance: f64) {
372        assert!(
373            (actual - expected).abs() <= tolerance,
374            "actual={actual:.17e}, expected={expected:.17e}, tolerance={tolerance:.1e}"
375        );
376    }
377
378    #[test]
379    fn pure_component_limits_are_exact() {
380        let gaussian = TchShape::from_component_fwhm(TchWidths {
381            gaussian_fwhm: 0.2,
382            lorentzian_fwhm: 0.0,
383        })
384        .expect("Gaussian limit");
385        assert_close(gaussian.total_fwhm, 0.2, 1e-16);
386        assert_close(gaussian.eta, 0.0, 0.0);
387
388        let lorentzian = TchShape::from_component_fwhm(TchWidths {
389            gaussian_fwhm: 0.0,
390            lorentzian_fwhm: 0.3,
391        })
392        .expect("Lorentzian limit");
393        assert_close(lorentzian.total_fwhm, 0.3, 1e-16);
394        assert_close(lorentzian.eta, 1.0, 2e-16);
395    }
396
397    #[test]
398    fn transform_derivatives_match_centered_differences() {
399        let widths = TchWidths {
400            gaussian_fwhm: 0.071,
401            lorentzian_fwhm: 0.023,
402        };
403        let shape = TchShape::from_component_fwhm(widths).expect("shape");
404        let step = 1e-7;
405        let gaussian_plus = TchShape::from_component_fwhm(TchWidths {
406            gaussian_fwhm: widths.gaussian_fwhm + step,
407            ..widths
408        })
409        .expect("plus");
410        let gaussian_minus = TchShape::from_component_fwhm(TchWidths {
411            gaussian_fwhm: widths.gaussian_fwhm - step,
412            ..widths
413        })
414        .expect("minus");
415        let lorentzian_plus = TchShape::from_component_fwhm(TchWidths {
416            lorentzian_fwhm: widths.lorentzian_fwhm + step,
417            ..widths
418        })
419        .expect("plus");
420        let lorentzian_minus = TchShape::from_component_fwhm(TchWidths {
421            lorentzian_fwhm: widths.lorentzian_fwhm - step,
422            ..widths
423        })
424        .expect("minus");
425        assert_close(
426            shape.d_total_fwhm_d_gaussian_fwhm,
427            (gaussian_plus.total_fwhm - gaussian_minus.total_fwhm) / (2.0 * step),
428            2e-10,
429        );
430        assert_close(
431            shape.d_eta_d_gaussian_fwhm,
432            (gaussian_plus.eta - gaussian_minus.eta) / (2.0 * step),
433            2e-9,
434        );
435        assert_close(
436            shape.d_total_fwhm_d_lorentzian_fwhm,
437            (lorentzian_plus.total_fwhm - lorentzian_minus.total_fwhm) / (2.0 * step),
438            2e-10,
439        );
440        assert_close(
441            shape.d_eta_d_lorentzian_fwhm,
442            (lorentzian_plus.eta - lorentzian_minus.eta) / (2.0 * step),
443            2e-9,
444        );
445    }
446
447    #[test]
448    fn invalid_component_widths_are_rejected() {
449        assert_eq!(
450            TchShape::from_component_fwhm(TchWidths {
451                gaussian_fwhm: 0.0,
452                lorentzian_fwhm: 0.0,
453            }),
454            Err(TchError::ZeroComponentWidths)
455        );
456        assert_eq!(
457            TchShape::from_component_fwhm(TchWidths {
458                gaussian_fwhm: -0.1,
459                lorentzian_fwhm: 0.2,
460            }),
461            Err(TchError::NegativeGaussianFwhm)
462        );
463    }
464
465    #[test]
466    fn normalized_polynomial_handles_extreme_finite_scales() {
467        let unit = TchShape::from_component_fwhm(TchWidths {
468            gaussian_fwhm: 0.7,
469            lorentzian_fwhm: 0.3,
470        })
471        .expect("unit-scale shape");
472        let tiny_scale = 1e-250;
473        let tiny = TchShape::from_component_fwhm(TchWidths {
474            gaussian_fwhm: 0.7 * tiny_scale,
475            lorentzian_fwhm: 0.3 * tiny_scale,
476        })
477        .expect("tiny shape");
478        assert_close(tiny.total_fwhm / tiny_scale, unit.total_fwhm, 5e-16);
479        assert_close(tiny.eta, unit.eta, 5e-16);
480        assert_close(
481            tiny.d_total_fwhm_d_gaussian_fwhm,
482            unit.d_total_fwhm_d_gaussian_fwhm,
483            5e-16,
484        );
485        assert_eq!(
486            TchShape::from_component_fwhm(TchWidths {
487                gaussian_fwhm: f64::MAX,
488                lorentzian_fwhm: f64::MAX,
489            }),
490            Err(TchError::NonFiniteTransform)
491        );
492    }
493}