1use 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#[derive(Clone, Copy, Debug, PartialEq)]
21pub struct TchWidths {
22 pub gaussian_fwhm: f64,
24 pub lorentzian_fwhm: f64,
26}
27
28#[derive(Clone, Copy, Debug, PartialEq)]
30pub struct TchShape {
31 pub total_fwhm: f64,
33 pub eta: f64,
35 pub d_total_fwhm_d_gaussian_fwhm: f64,
37 pub d_total_fwhm_d_lorentzian_fwhm: f64,
39 pub d_eta_d_gaussian_fwhm: f64,
41 pub d_eta_d_lorentzian_fwhm: f64,
43}
44
45#[derive(Clone, Copy, Debug, PartialEq)]
47pub struct TchProfilePoint {
48 pub value: f64,
50 pub d_delta: f64,
52 pub d_gaussian_fwhm: f64,
54 pub d_lorentzian_fwhm: f64,
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum TchError {
61 NonFiniteGaussianFwhm,
63 NonFiniteLorentzianFwhm,
65 NegativeGaussianFwhm,
67 NegativeLorentzianFwhm,
69 ZeroComponentWidths,
71 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 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 #[must_use]
168 pub fn evaluate(self, delta: f64) -> TchProfilePoint {
169 tch_pseudo_voigt_from_shape(delta, self)
170 }
171}
172
173pub 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#[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 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 #[must_use]
244 pub const fn len(self) -> usize {
245 self.positions.len()
246 }
247
248 #[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
262pub 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}