1use 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; #[derive(Clone, Copy, Debug, PartialEq)]
14pub struct Peak {
15 pub position: f64,
17 pub intensity: f64,
19 pub fwhm: f64,
21 pub eta: f64,
23}
24
25#[derive(Clone, Copy, Debug)]
27pub struct GridView<'a> {
28 values: &'a [f64],
29}
30
31impl<'a> GridView<'a> {
32 pub fn new(values: &'a [f64]) -> Result<Self, ProfileError> {
39 validate_grid(values)?;
40 Ok(Self { values })
41 }
42
43 #[must_use]
45 pub const fn as_slice(self) -> &'a [f64] {
46 self.values
47 }
48}
49
50#[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 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 #[must_use]
98 pub const fn len(self) -> usize {
99 self.positions.len()
100 }
101
102 #[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#[derive(Clone, Copy, Debug, PartialEq)]
120pub enum SupportPolicy {
121 FwhmMultiple(f64),
123}
124
125#[derive(Clone, Copy, Debug, PartialEq)]
127pub struct SupportRange {
128 pub left: f64,
130 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#[derive(Clone, Copy, Debug, Default, PartialEq)]
163pub struct ProfilePoint {
164 pub value: f64,
166 pub d_delta: f64,
168 pub d_fwhm: f64,
170 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#[derive(Clone, Debug, PartialEq)]
185pub struct SupportJacobian {
186 pub starts: Vec<usize>,
188 pub offsets: Vec<usize>,
190 pub values: Vec<f64>,
192 pub parameter_count: usize,
194}
195
196impl SupportJacobian {
197 #[must_use]
199 pub fn peak_count(&self) -> usize {
200 self.starts.len()
201 }
202
203 #[must_use]
205 pub fn active_sample_count(&self) -> usize {
206 self.offsets.last().copied().unwrap_or(0)
207 }
208
209 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#[derive(Clone, Debug, PartialEq)]
271pub struct PatternDerivatives {
272 pub local: SupportJacobian,
274 pub global: Option<DenseJacobian>,
276}
277
278#[derive(Clone, Debug, PartialEq)]
280pub struct DenseJacobian {
281 pub values: Vec<f64>,
283 pub parameter_count: usize,
285 pub sample_count: usize,
287}
288
289#[derive(Clone, Debug, PartialEq)]
291pub struct Accumulation {
292 pub y: Vec<f64>,
294 pub derivatives: PatternDerivatives,
296 pub sample_count: usize,
298}
299
300impl Accumulation {
301 pub fn dense_local_jacobian(&self) -> Result<Vec<f64>, ProfileError> {
307 self.derivatives.local.to_dense(self.sample_count)
308 }
309}
310
311#[derive(Clone, Debug, PartialEq, Eq)]
313pub enum ProfileError {
314 NonFiniteGrid {
316 index: usize,
318 },
319 UnsortedGrid {
321 index: usize,
323 },
324 NonFinitePeak {
326 peak: usize,
328 },
329 NonPositiveFwhm {
331 peak: usize,
333 },
334 InvalidEta {
336 peak: usize,
338 },
339 InvalidSupport,
341 PeakLengthMismatch,
343 TchPeakLengthMismatch,
345 InvalidTchPeak {
347 peak: usize,
349 reason: TchError,
351 },
352 AllocationOverflow,
354 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#[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
458pub 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
484pub 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
517pub 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}