Skip to main content

phasesmith_core/
profile.rs

1//! Symmetric pseudo-Voigt profile and fused peak accumulation.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5use std::mem::size_of;
6
7use crate::tch::TchError;
8
9const FOUR_LN_2: f64 = 4.0 * std::f64::consts::LN_2;
10const GAUSSIAN_NORMALIZATION: f64 = 0.939_437_278_699_651_3; // sqrt(4 ln(2) / pi)
11
12/// Parameters for one symmetric pseudo-Voigt peak.
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub struct Peak {
15    /// Peak center in the same coordinate system as the sampling grid.
16    pub position: f64,
17    /// Integrated intensity before applying the finite support window.
18    pub intensity: f64,
19    /// Full width at half maximum; must be positive.
20    pub fwhm: f64,
21    /// Lorentzian mixing fraction in the inclusive range `[0, 1]`.
22    pub eta: f64,
23}
24
25/// A validated, borrowed view of a strictly increasing sampling grid.
26#[derive(Clone, Copy, Debug)]
27pub struct GridView<'a> {
28    values: &'a [f64],
29}
30
31impl<'a> GridView<'a> {
32    /// Validate and borrow a sampling grid.
33    ///
34    /// # Errors
35    ///
36    /// Returns [`ProfileError`] if a coordinate is non-finite or the grid is
37    /// not strictly increasing.
38    pub fn new(values: &'a [f64]) -> Result<Self, ProfileError> {
39        validate_grid(values)?;
40        Ok(Self { values })
41    }
42
43    /// Return the borrowed coordinates.
44    #[must_use]
45    pub const fn as_slice(self) -> &'a [f64] {
46        self.values
47    }
48}
49
50/// A validated structure-of-arrays view of peak parameters.
51#[derive(Clone, Copy, Debug)]
52pub struct PeakBatchView<'a> {
53    positions: &'a [f64],
54    intensities: &'a [f64],
55    fwhms: &'a [f64],
56    etas: &'a [f64],
57}
58
59impl<'a> PeakBatchView<'a> {
60    /// Validate and borrow equal-length peak parameter arrays.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`ProfileError`] if the arrays differ in length or a parameter
65    /// lies outside its domain.
66    pub fn new(
67        positions: &'a [f64],
68        intensities: &'a [f64],
69        fwhms: &'a [f64],
70        etas: &'a [f64],
71    ) -> Result<Self, ProfileError> {
72        let peak_count = positions.len();
73        if intensities.len() != peak_count || fwhms.len() != peak_count || etas.len() != peak_count
74        {
75            return Err(ProfileError::PeakLengthMismatch);
76        }
77        for peak_index in 0..peak_count {
78            validate_peak(
79                peak_index,
80                Peak {
81                    position: positions[peak_index],
82                    intensity: intensities[peak_index],
83                    fwhm: fwhms[peak_index],
84                    eta: etas[peak_index],
85                },
86            )?;
87        }
88        Ok(Self {
89            positions,
90            intensities,
91            fwhms,
92            etas,
93        })
94    }
95
96    /// Number of peaks in this batch.
97    #[must_use]
98    pub const fn len(self) -> usize {
99        self.positions.len()
100    }
101
102    /// Whether this batch contains no peaks.
103    #[must_use]
104    pub const fn is_empty(self) -> bool {
105        self.positions.is_empty()
106    }
107
108    fn peak(self, index: usize) -> Peak {
109        Peak {
110            position: self.positions[index],
111            intensity: self.intensities[index],
112            fwhm: self.fwhms[index],
113            eta: self.etas[index],
114        }
115    }
116}
117
118/// Deterministic rule used to choose a peak's finite support.
119#[derive(Clone, Copy, Debug, PartialEq)]
120pub enum SupportPolicy {
121    /// Include coordinates at most this many FWHM from the peak center.
122    FwhmMultiple(f64),
123}
124
125/// Inclusive physical support limits for one peak.
126#[derive(Clone, Copy, Debug, PartialEq)]
127pub struct SupportRange {
128    /// Inclusive left coordinate.
129    pub left: f64,
130    /// Inclusive right coordinate.
131    pub right: f64,
132}
133
134impl SupportPolicy {
135    pub(crate) fn validate(self) -> Result<(), ProfileError> {
136        match self {
137            Self::FwhmMultiple(multiple) if multiple.is_finite() && multiple > 0.0 => Ok(()),
138            Self::FwhmMultiple(_) => Err(ProfileError::InvalidSupport),
139        }
140    }
141
142    pub(crate) fn range(self, position: f64, fwhm: f64) -> SupportRange {
143        match self {
144            Self::FwhmMultiple(multiple) => {
145                let radius = multiple * fwhm;
146                SupportRange {
147                    left: position - radius,
148                    right: position + radius,
149                }
150            }
151        }
152    }
153
154    pub(crate) fn radius(self, fwhm: f64) -> f64 {
155        match self {
156            Self::FwhmMultiple(multiple) => multiple * fwhm,
157        }
158    }
159}
160
161/// A profile value and its analytical first derivatives.
162#[derive(Clone, Copy, Debug, Default, PartialEq)]
163pub struct ProfilePoint {
164    /// Unit-area profile value.
165    pub value: f64,
166    /// Derivative with respect to `delta = x - position`.
167    pub d_delta: f64,
168    /// Derivative with respect to FWHM.
169    pub d_fwhm: f64,
170    /// Derivative with respect to the Lorentzian fraction.
171    pub d_eta: f64,
172}
173
174#[derive(Clone, Copy)]
175struct ProfileComponents {
176    inverse_fwhm: f64,
177    z_squared: f64,
178    gaussian: f64,
179    lorentzian_denominator: f64,
180    lorentzian: f64,
181}
182
183/// Per-peak Jacobian stored only over each peak's active support.
184#[derive(Clone, Debug, PartialEq)]
185pub struct SupportJacobian {
186    /// First active grid index for each peak.
187    pub starts: Vec<usize>,
188    /// Prefix sum of active sample counts, with length `peak_count + 1`.
189    pub offsets: Vec<usize>,
190    /// Sample-major derivative rows with `parameter_count` values per sample.
191    pub values: Vec<f64>,
192    /// Number of local derivative columns per active sample.
193    pub parameter_count: usize,
194}
195
196impl SupportJacobian {
197    /// Number of represented peaks.
198    #[must_use]
199    pub fn peak_count(&self) -> usize {
200        self.starts.len()
201    }
202
203    /// Total number of active peak/sample pairs.
204    #[must_use]
205    pub fn active_sample_count(&self) -> usize {
206        self.offsets.last().copied().unwrap_or(0)
207    }
208
209    /// Materialize a parameter-major dense `(peak, parameter, sample)` Jacobian.
210    ///
211    /// # Errors
212    ///
213    /// Returns [`ProfileError`] if the dense allocation size overflows or the
214    /// supplied sample count is inconsistent with a stored support block.
215    pub fn to_dense(&self, sample_count: usize) -> Result<Vec<f64>, ProfileError> {
216        self.validate_structure(sample_count)?;
217        let dense_length = checked_matrix_len(
218            self.peak_count()
219                .checked_mul(self.parameter_count)
220                .ok_or(ProfileError::AllocationOverflow)?,
221            sample_count,
222        )?;
223        let mut dense = zeroed_f64_vec(dense_length)?;
224        for peak_index in 0..self.peak_count() {
225            let active_begin = self.offsets[peak_index];
226            let active_end = self.offsets[peak_index + 1];
227            let active_count = active_end - active_begin;
228            let start = self.starts[peak_index];
229            for relative_index in 0..active_count {
230                let sample_index = start + relative_index;
231                let sparse_base = (active_begin + relative_index) * self.parameter_count;
232                for parameter_index in 0..self.parameter_count {
233                    let dense_index = (peak_index * self.parameter_count + parameter_index)
234                        * sample_count
235                        + sample_index;
236                    dense[dense_index] = self.values[sparse_base + parameter_index];
237                }
238            }
239        }
240        Ok(dense)
241    }
242
243    fn validate_structure(&self, sample_count: usize) -> Result<(), ProfileError> {
244        if self.offsets.len() != self.starts.len().saturating_add(1)
245            || self.offsets.first() != Some(&0)
246            || self.offsets.windows(2).any(|pair| pair[0] > pair[1])
247            || self.parameter_count == 0
248            || self
249                .offsets
250                .last()
251                .and_then(|count| count.checked_mul(self.parameter_count))
252                != Some(self.values.len())
253        {
254            return Err(ProfileError::InconsistentSupport);
255        }
256        for peak_index in 0..self.peak_count() {
257            let active_count = self.offsets[peak_index + 1] - self.offsets[peak_index];
258            if self.starts[peak_index]
259                .checked_add(active_count)
260                .is_none_or(|end| end > sample_count)
261            {
262                return Err(ProfileError::InconsistentSupport);
263            }
264        }
265        Ok(())
266    }
267}
268
269/// Derivatives of one calculated pattern.
270#[derive(Clone, Debug, PartialEq)]
271pub struct PatternDerivatives {
272    /// Sparse per-peak derivatives.
273    pub local: SupportJacobian,
274    /// Optional parameter-major dense shared derivatives.
275    pub global: Option<DenseJacobian>,
276}
277
278/// Parameter-major dense derivatives shared across many peaks.
279#[derive(Clone, Debug, PartialEq)]
280pub struct DenseJacobian {
281    /// Row-major values with shape `(parameter_count, sample_count)`.
282    pub values: Vec<f64>,
283    /// Number of global parameter rows.
284    pub parameter_count: usize,
285    /// Number of grid samples.
286    pub sample_count: usize,
287}
288
289/// Result from fused multi-peak accumulation.
290#[derive(Clone, Debug, PartialEq)]
291pub struct Accumulation {
292    /// Summed calculated profile, with one value per grid sample.
293    pub y: Vec<f64>,
294    /// Local and global analytical derivatives.
295    pub derivatives: PatternDerivatives,
296    /// Number of grid samples represented by the result.
297    pub sample_count: usize,
298}
299
300impl Accumulation {
301    /// Explicitly materialize the compatibility dense local Jacobian.
302    ///
303    /// # Errors
304    ///
305    /// Returns [`ProfileError`] if allocation arithmetic overflows.
306    pub fn dense_local_jacobian(&self) -> Result<Vec<f64>, ProfileError> {
307        self.derivatives.local.to_dense(self.sample_count)
308    }
309}
310
311/// Input validation errors for profile evaluation.
312#[derive(Clone, Debug, PartialEq, Eq)]
313pub enum ProfileError {
314    /// The sampling grid contains NaN or infinity.
315    NonFiniteGrid {
316        /// Index of the invalid grid value.
317        index: usize,
318    },
319    /// The sampling grid is not strictly increasing.
320    UnsortedGrid {
321        /// Index of the first value not greater than its predecessor.
322        index: usize,
323    },
324    /// A peak parameter contains NaN or infinity.
325    NonFinitePeak {
326        /// Index of the invalid peak.
327        peak: usize,
328    },
329    /// A peak FWHM is zero or negative.
330    NonPositiveFwhm {
331        /// Index of the invalid peak.
332        peak: usize,
333    },
334    /// A peak eta is outside `[0, 1]`.
335    InvalidEta {
336        /// Index of the invalid peak.
337        peak: usize,
338    },
339    /// The requested support is zero, negative, NaN, or infinity.
340    InvalidSupport,
341    /// Peak parameter arrays do not all have the same length.
342    PeakLengthMismatch,
343    /// TCH peak parameter arrays do not all have the same length.
344    TchPeakLengthMismatch,
345    /// A TCH peak has invalid component widths.
346    InvalidTchPeak {
347        /// Index of the invalid peak.
348        peak: usize,
349        /// Component-width validation failure.
350        reason: TchError,
351    },
352    /// A Jacobian allocation would overflow `usize`.
353    AllocationOverflow,
354    /// A stored support block does not fit the requested dense grid.
355    InconsistentSupport,
356}
357
358impl Display for ProfileError {
359    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
360        match self {
361            Self::NonFiniteGrid { index } => {
362                write!(formatter, "grid value at index {index} is not finite")
363            }
364            Self::UnsortedGrid { index } => write!(
365                formatter,
366                "grid must be strictly increasing (violation at index {index})"
367            ),
368            Self::NonFinitePeak { peak } => {
369                write!(formatter, "peak {peak} contains a non-finite parameter")
370            }
371            Self::NonPositiveFwhm { peak } => {
372                write!(formatter, "peak {peak} has a non-positive FWHM")
373            }
374            Self::InvalidEta { peak } => {
375                write!(formatter, "peak {peak} has eta outside [0, 1]")
376            }
377            Self::InvalidSupport => write!(formatter, "support_fwhm must be positive and finite"),
378            Self::PeakLengthMismatch => write!(
379                formatter,
380                "positions, intensities, fwhms, and etas must have equal length"
381            ),
382            Self::TchPeakLengthMismatch => write!(
383                formatter,
384                "positions, intensities, Gaussian FWHMs, and Lorentzian FWHMs must have equal length"
385            ),
386            Self::InvalidTchPeak { peak, reason } => {
387                write!(formatter, "TCH peak {peak} has invalid widths: {reason}")
388            }
389            Self::AllocationOverflow => write!(formatter, "requested Jacobian is too large"),
390            Self::InconsistentSupport => {
391                write!(
392                    formatter,
393                    "support block lies outside the requested sample grid"
394                )
395            }
396        }
397    }
398}
399
400impl Error for ProfileError {}
401
402/// Evaluate a unit-area symmetric pseudo-Voigt profile and derivatives.
403///
404/// `delta` is `x - position`, `fwhm` is the common full width at half maximum,
405/// and `eta` is the Lorentzian fraction. Callers should validate `fwhm` and
406/// `eta` at their API boundary; this low-level scalar function is branch-free.
407#[must_use]
408pub fn symmetric_pseudo_voigt(delta: f64, fwhm: f64, eta: f64) -> ProfilePoint {
409    let components = profile_components(delta, fwhm);
410    let ProfileComponents {
411        inverse_fwhm,
412        z_squared,
413        gaussian,
414        lorentzian_denominator,
415        lorentzian,
416    } = components;
417
418    let d_gaussian_delta = gaussian * (-2.0 * FOUR_LN_2 * delta * inverse_fwhm.powi(2));
419    let d_lorentzian_delta =
420        lorentzian * (-8.0 * delta * inverse_fwhm.powi(2) / lorentzian_denominator);
421
422    let d_gaussian_fwhm = gaussian * inverse_fwhm * (-1.0 + 2.0 * FOUR_LN_2 * z_squared);
423    let d_lorentzian_fwhm =
424        lorentzian * inverse_fwhm * (-1.0 + 8.0 * z_squared / lorentzian_denominator);
425
426    let gaussian_weight = 1.0 - eta;
427    ProfilePoint {
428        value: eta * lorentzian + gaussian_weight * gaussian,
429        d_delta: eta * d_lorentzian_delta + gaussian_weight * d_gaussian_delta,
430        d_fwhm: eta * d_lorentzian_fwhm + gaussian_weight * d_gaussian_fwhm,
431        d_eta: lorentzian - gaussian,
432    }
433}
434
435#[inline]
436fn symmetric_pseudo_voigt_value(delta: f64, fwhm: f64, eta: f64) -> f64 {
437    let components = profile_components(delta, fwhm);
438    eta * components.lorentzian + (1.0 - eta) * components.gaussian
439}
440
441#[inline]
442fn profile_components(delta: f64, fwhm: f64) -> ProfileComponents {
443    let inverse_fwhm = fwhm.recip();
444    let z = delta * inverse_fwhm;
445    let z_squared = z * z;
446    let gaussian = GAUSSIAN_NORMALIZATION * inverse_fwhm * (-FOUR_LN_2 * z_squared).exp();
447    let lorentzian_denominator = 1.0 + 4.0 * z_squared;
448    let lorentzian = 2.0 * inverse_fwhm / (std::f64::consts::PI * lorentzian_denominator);
449    ProfileComponents {
450        inverse_fwhm,
451        z_squared,
452        gaussian,
453        lorentzian_denominator,
454        lorentzian,
455    }
456}
457
458/// Accumulate a borrowed peak batch and all derivatives on a validated grid.
459///
460/// Each peak is evaluated only at samples satisfying
461/// `abs(x - position) <= support_fwhm * fwhm`. Sparse derivative values are
462/// sample-major within each peak support and use the parameter order intensity,
463/// position, FWHM, and eta. The active sample set is held fixed when
464/// differentiating.
465///
466/// # Errors
467///
468/// Returns [`ProfileError`] if support is invalid or allocation arithmetic
469/// overflows. Grid and peak validation occurs when their views are constructed.
470pub fn accumulate_batch(
471    grid: GridView<'_>,
472    peaks: PeakBatchView<'_>,
473    support: SupportPolicy,
474) -> Result<Accumulation, ProfileError> {
475    support.validate()?;
476    accumulate_source(
477        grid.as_slice(),
478        peaks.len(),
479        |index| peaks.peak(index),
480        support,
481    )
482}
483
484/// Accumulate only calculated profile values for a borrowed peak batch.
485///
486/// This values-only path preserves peak-order summation and exact support
487/// semantics while avoiding derivative evaluation and storage.
488///
489/// # Errors
490///
491/// Returns [`ProfileError`] if support is invalid or allocation fails.
492pub fn accumulate_values_batch(
493    grid: GridView<'_>,
494    peaks: PeakBatchView<'_>,
495    support: SupportPolicy,
496) -> Result<Vec<f64>, ProfileError> {
497    support.validate()?;
498    let x = grid.as_slice();
499    let mut y = zeroed_f64_vec(x.len())?;
500    for peak_index in 0..peaks.len() {
501        let peak = peaks.peak(peak_index);
502        let range = support.range(peak.position, peak.fwhm);
503        let lower = x.partition_point(|value| *value < range.left);
504        let upper = x.partition_point(|value| *value <= range.right);
505        for sample_index in lower..upper {
506            y[sample_index] += peak.intensity
507                * symmetric_pseudo_voigt_value(
508                    x[sample_index] - peak.position,
509                    peak.fwhm,
510                    peak.eta,
511                );
512        }
513    }
514    Ok(y)
515}
516
517/// Convenience accumulator for an array-of-structs peak collection.
518///
519/// The Python binding uses [`accumulate_batch`] so its `NumPy` structure-of-arrays
520/// input is borrowed directly without constructing a temporary `Vec<Peak>`.
521///
522/// # Errors
523///
524/// Returns [`ProfileError`] when an input is invalid or allocation arithmetic
525/// overflows.
526pub fn accumulate_peaks(
527    x: &[f64],
528    peaks: &[Peak],
529    support_fwhm: f64,
530) -> Result<Accumulation, ProfileError> {
531    let grid = GridView::new(x)?;
532    let support = SupportPolicy::FwhmMultiple(support_fwhm);
533    support.validate()?;
534    for (peak_index, peak) in peaks.iter().copied().enumerate() {
535        validate_peak(peak_index, peak)?;
536    }
537    accumulate_source(grid.as_slice(), peaks.len(), |index| peaks[index], support)
538}
539
540fn accumulate_source(
541    x: &[f64],
542    peak_count: usize,
543    peak_at: impl Fn(usize) -> Peak,
544    support: SupportPolicy,
545) -> Result<Accumulation, ProfileError> {
546    let mut starts: Vec<usize> = Vec::new();
547    starts
548        .try_reserve_exact(peak_count)
549        .map_err(|_| ProfileError::AllocationOverflow)?;
550    let offset_count = peak_count
551        .checked_add(1)
552        .ok_or(ProfileError::AllocationOverflow)?;
553    let mut offsets: Vec<usize> = Vec::new();
554    offsets
555        .try_reserve_exact(offset_count)
556        .map_err(|_| ProfileError::AllocationOverflow)?;
557    offsets.push(0);
558
559    for peak_index in 0..peak_count {
560        let peak = peak_at(peak_index);
561        let range = support.range(peak.position, peak.fwhm);
562        let lower = x.partition_point(|value| *value < range.left);
563        let upper = x.partition_point(|value| *value <= range.right);
564        let next_offset = offsets[peak_index]
565            .checked_add(upper - lower)
566            .ok_or(ProfileError::AllocationOverflow)?;
567        starts.push(lower);
568        offsets.push(next_offset);
569    }
570
571    let active_sample_count = offsets.last().copied().unwrap_or(0);
572    let value_count = checked_matrix_len(active_sample_count, 4)?;
573    let mut y = zeroed_f64_vec(x.len())?;
574    let mut values = zeroed_f64_vec(value_count)?;
575
576    for peak_index in 0..peak_count {
577        let peak = peak_at(peak_index);
578        let start = starts[peak_index];
579        let active_begin = offsets[peak_index];
580        let active_end = offsets[peak_index + 1];
581        for active_index in active_begin..active_end {
582            let sample_index = start + active_index - active_begin;
583            let profile =
584                symmetric_pseudo_voigt(x[sample_index] - peak.position, peak.fwhm, peak.eta);
585            y[sample_index] += peak.intensity * profile.value;
586            let value_base = active_index * 4;
587            values[value_base] = profile.value;
588            values[value_base + 1] = -peak.intensity * profile.d_delta;
589            values[value_base + 2] = peak.intensity * profile.d_fwhm;
590            values[value_base + 3] = peak.intensity * profile.d_eta;
591        }
592    }
593
594    Ok(Accumulation {
595        y,
596        derivatives: PatternDerivatives {
597            local: SupportJacobian {
598                starts,
599                offsets,
600                values,
601                parameter_count: 4,
602            },
603            global: None,
604        },
605        sample_count: x.len(),
606    })
607}
608
609fn validate_grid(x: &[f64]) -> Result<(), ProfileError> {
610    for (index, value) in x.iter().copied().enumerate() {
611        if !value.is_finite() {
612            return Err(ProfileError::NonFiniteGrid { index });
613        }
614        if index > 0 && value <= x[index - 1] {
615            return Err(ProfileError::UnsortedGrid { index });
616        }
617    }
618    Ok(())
619}
620
621fn validate_peak(peak_index: usize, peak: Peak) -> Result<(), ProfileError> {
622    if !peak.position.is_finite()
623        || !peak.intensity.is_finite()
624        || !peak.fwhm.is_finite()
625        || !peak.eta.is_finite()
626    {
627        return Err(ProfileError::NonFinitePeak { peak: peak_index });
628    }
629    if peak.fwhm <= 0.0 {
630        return Err(ProfileError::NonPositiveFwhm { peak: peak_index });
631    }
632    if !(0.0..=1.0).contains(&peak.eta) {
633        return Err(ProfileError::InvalidEta { peak: peak_index });
634    }
635    Ok(())
636}
637
638fn checked_matrix_len(rows: usize, columns: usize) -> Result<usize, ProfileError> {
639    let length = rows
640        .checked_mul(columns)
641        .ok_or(ProfileError::AllocationOverflow)?;
642    length
643        .checked_mul(size_of::<f64>())
644        .filter(|bytes| isize::try_from(*bytes).is_ok())
645        .ok_or(ProfileError::AllocationOverflow)?;
646    Ok(length)
647}
648
649pub(crate) fn zeroed_f64_vec(length: usize) -> Result<Vec<f64>, ProfileError> {
650    checked_matrix_len(length, 1)?;
651    let mut values = Vec::new();
652    values
653        .try_reserve_exact(length)
654        .map_err(|_| ProfileError::AllocationOverflow)?;
655    values.resize(length, 0.0);
656    Ok(values)
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    fn assert_close(actual: f64, expected: f64, tolerance: f64) {
664        assert!(
665            (actual - expected).abs() <= tolerance,
666            "actual={actual:.17e}, expected={expected:.17e}, tolerance={tolerance:.1e}"
667        );
668    }
669
670    #[test]
671    fn components_have_expected_height_and_half_maximum() {
672        for eta in [0.0, 0.25, 1.0] {
673            let center = symmetric_pseudo_voigt(0.0, 2.0, eta).value;
674            let half_maximum = symmetric_pseudo_voigt(1.0, 2.0, eta).value;
675            assert_close(half_maximum, center / 2.0, 1e-15);
676        }
677    }
678
679    #[test]
680    fn scalar_derivatives_match_centered_differences() {
681        let delta = 0.37;
682        let fwhm = 0.82;
683        let eta = 0.41;
684        let h = 1e-6;
685        let analytic = symmetric_pseudo_voigt(delta, fwhm, eta);
686
687        let d_delta = (symmetric_pseudo_voigt(delta + h, fwhm, eta).value
688            - symmetric_pseudo_voigt(delta - h, fwhm, eta).value)
689            / (2.0 * h);
690        let d_fwhm = (symmetric_pseudo_voigt(delta, fwhm + h, eta).value
691            - symmetric_pseudo_voigt(delta, fwhm - h, eta).value)
692            / (2.0 * h);
693        let d_eta = (symmetric_pseudo_voigt(delta, fwhm, eta + h).value
694            - symmetric_pseudo_voigt(delta, fwhm, eta - h).value)
695            / (2.0 * h);
696
697        assert_close(analytic.d_delta, d_delta, 2e-10);
698        assert_close(analytic.d_fwhm, d_fwhm, 2e-10);
699        assert_close(analytic.d_eta, d_eta, 2e-10);
700    }
701
702    #[test]
703    fn accumulation_is_exactly_support_limited() {
704        let x = [-2.0, -1.0, 0.0, 1.0, 2.0];
705        let peaks = [Peak {
706            position: 0.0,
707            intensity: 3.0,
708            fwhm: 1.0,
709            eta: 0.5,
710        }];
711        let result = accumulate_peaks(&x, &peaks, 1.0).expect("valid profile");
712        assert_close(result.y[0], 0.0, 0.0);
713        assert!(result.y[1] > 0.0);
714        assert!(result.y[2] > 0.0);
715        assert!(result.y[3] > 0.0);
716        assert_close(result.y[4], 0.0, 0.0);
717        assert_eq!(result.derivatives.local.starts, [1]);
718        assert_eq!(result.derivatives.local.offsets, [0, 3]);
719        assert_eq!(result.derivatives.local.values.len(), 12);
720        let dense = result.dense_local_jacobian().expect("dense compatibility");
721        assert_close(dense[0], 0.0, 0.0);
722    }
723
724    #[test]
725    fn overlapping_peaks_sum_without_overwriting_jacobian_rows() {
726        let x = [-0.25, 0.0, 0.25];
727        let peaks = [
728            Peak {
729                position: 0.0,
730                intensity: 2.0,
731                fwhm: 1.0,
732                eta: 0.2,
733            },
734            Peak {
735                position: 0.1,
736                intensity: 4.0,
737                fwhm: 0.7,
738                eta: 0.8,
739            },
740        ];
741        let result = accumulate_peaks(&x, &peaks, 10.0).expect("valid profile");
742        let dense = result.dense_local_jacobian().expect("dense compatibility");
743        for (sample, coordinate) in x.iter().copied().enumerate() {
744            let first = symmetric_pseudo_voigt(coordinate, 1.0, 0.2);
745            let second = symmetric_pseudo_voigt(coordinate - 0.1, 0.7, 0.8);
746            assert_close(
747                result.y[sample],
748                2.0 * first.value + 4.0 * second.value,
749                1e-14,
750            );
751            assert_close(dense[sample], first.value, 1e-14);
752            assert_close(dense[4 * x.len() + sample], second.value, 1e-14);
753        }
754    }
755
756    #[test]
757    fn invalid_inputs_are_rejected() {
758        let peak = Peak {
759            position: 0.0,
760            intensity: 1.0,
761            fwhm: 1.0,
762            eta: 0.5,
763        };
764        assert!(matches!(
765            accumulate_peaks(&[0.0, 0.0], &[peak], 5.0),
766            Err(ProfileError::UnsortedGrid { index: 1 })
767        ));
768        assert!(matches!(
769            accumulate_peaks(&[0.0], &[Peak { fwhm: 0.0, ..peak }], 5.0),
770            Err(ProfileError::NonPositiveFwhm { peak: 0 })
771        ));
772        assert!(matches!(
773            accumulate_peaks(&[0.0], &[Peak { eta: 1.1, ..peak }], 5.0),
774            Err(ProfileError::InvalidEta { peak: 0 })
775        ));
776    }
777
778    #[test]
779    fn borrowed_batch_matches_peak_convenience_api() {
780        let x = [-0.4, 0.0, 0.4];
781        let positions = [0.0, 0.2];
782        let intensities = [2.0, 3.0];
783        let fwhms = [0.5, 0.7];
784        let etas = [0.1, 0.8];
785        let peaks = [
786            Peak {
787                position: positions[0],
788                intensity: intensities[0],
789                fwhm: fwhms[0],
790                eta: etas[0],
791            },
792            Peak {
793                position: positions[1],
794                intensity: intensities[1],
795                fwhm: fwhms[1],
796                eta: etas[1],
797            },
798        ];
799        let borrowed = accumulate_batch(
800            GridView::new(&x).expect("grid"),
801            PeakBatchView::new(&positions, &intensities, &fwhms, &etas).expect("peaks"),
802            SupportPolicy::FwhmMultiple(3.0),
803        )
804        .expect("borrowed accumulation");
805        let convenience = accumulate_peaks(&x, &peaks, 3.0).expect("peak accumulation");
806        assert_eq!(borrowed, convenience);
807        let values_only = accumulate_values_batch(
808            GridView::new(&x).expect("grid"),
809            PeakBatchView::new(&positions, &intensities, &fwhms, &etas).expect("peaks"),
810            SupportPolicy::FwhmMultiple(3.0),
811        )
812        .expect("values-only accumulation");
813        assert_eq!(values_only, borrowed.y);
814    }
815
816    #[test]
817    fn empty_and_outside_support_blocks_are_well_formed() {
818        let empty = accumulate_peaks(&[], &[], 2.0).expect("empty accumulation");
819        assert!(empty.y.is_empty());
820        assert!(empty.derivatives.local.starts.is_empty());
821        assert_eq!(empty.derivatives.local.offsets, [0]);
822        assert!(empty.derivatives.local.values.is_empty());
823
824        let outside = accumulate_peaks(
825            &[0.0, 1.0],
826            &[Peak {
827                position: 10.0,
828                intensity: 1.0,
829                fwhm: 0.1,
830                eta: 0.5,
831            }],
832            1.0,
833        )
834        .expect("outside accumulation");
835        assert_eq!(outside.y, [0.0, 0.0]);
836        assert_eq!(outside.derivatives.local.starts, [2]);
837        assert_eq!(outside.derivatives.local.offsets, [0, 0]);
838        assert!(outside.derivatives.local.values.is_empty());
839    }
840
841    #[test]
842    fn checked_dense_allocation_rejects_overflow() {
843        assert_eq!(
844            checked_matrix_len(usize::MAX, 2),
845            Err(ProfileError::AllocationOverflow)
846        );
847        let sparse = SupportJacobian {
848            starts: vec![1],
849            offsets: vec![0, 1],
850            values: vec![0.0; 4],
851            parameter_count: 4,
852        };
853        assert_eq!(sparse.to_dense(1), Err(ProfileError::InconsistentSupport));
854    }
855}