Skip to main content

phasesmith_workflows/
backgrounds.rs

1//! Typed analytical background models and row-major derivative bases.
2
3use std::collections::BTreeSet;
4use std::error::Error;
5use std::f64::consts::PI;
6use std::fmt::{Display, Formatter};
7
8use crate::ParameterBounds;
9
10/// Row-major sample-by-parameter analytical background derivatives.
11#[derive(Clone, Debug, PartialEq)]
12pub struct BackgroundBasis {
13    /// Sample row count.
14    pub rows: usize,
15    /// Parameter column count.
16    pub columns: usize,
17    /// Row-major values.
18    pub values: Vec<f64>,
19}
20
21impl BackgroundBasis {
22    fn zeros(rows: usize, columns: usize) -> Result<Self, BackgroundError> {
23        let count = rows
24            .checked_mul(columns)
25            .ok_or(BackgroundError::SizeOverflow)?;
26        Ok(Self {
27            rows,
28            columns,
29            values: vec![0.0; count],
30        })
31    }
32
33    /// Borrow one sample row.
34    #[must_use]
35    pub fn row(&self, index: usize) -> Option<&[f64]> {
36        let start = index.checked_mul(self.columns)?;
37        self.values.get(start..start.checked_add(self.columns)?)
38    }
39
40    /// Copy one parameter column.
41    #[must_use]
42    pub fn column(&self, index: usize) -> Option<Vec<f64>> {
43        if index >= self.columns {
44            return None;
45        }
46        Some(
47            self.values
48                .chunks_exact(self.columns)
49                .map(|row| row[index])
50                .collect(),
51        )
52    }
53}
54
55/// Shared immutable analytical-background contract.
56pub trait DifferentiableBackground {
57    /// Stable model identifier.
58    fn background_id(&self) -> &str;
59    /// Stable parameter names in coefficient order.
60    fn parameter_names(&self) -> Vec<String>;
61    /// Current physical coefficients in derivative-column order.
62    fn coefficients(&self) -> Vec<f64>;
63    /// Physical parameter bounds in coefficient order.
64    fn parameter_bounds(&self) -> Vec<ParameterBounds>;
65    /// Evaluate sample-by-parameter analytical derivatives.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`BackgroundError`] for an invalid grid or allocation overflow.
70    fn basis(&self, x_deg: &[f64]) -> Result<BackgroundBasis, BackgroundError>;
71    /// Evaluate the background on an ordered finite grid.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`BackgroundError`] for an invalid grid or allocation overflow.
76    fn calculate(&self, x_deg: &[f64]) -> Result<Vec<f64>, BackgroundError>;
77    /// Return the same model with replacement coefficients.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`BackgroundError`] for shape, finiteness, or model-bound failures.
82    fn replace_coefficients(&self, coefficients: &[f64]) -> Result<Self, BackgroundError>
83    where
84        Self: Sized;
85    /// Return whether the analytical basis is coefficient-invariant.
86    fn basis_is_invariant(&self) -> bool;
87}
88
89/// Power series on the normalized input-grid coordinate `[-1, 1]`.
90#[derive(Clone, Debug, PartialEq)]
91pub struct PolynomialBackground {
92    background_id: String,
93    coefficients: Vec<f64>,
94}
95
96impl PolynomialBackground {
97    /// Construct a non-empty finite power-series model.
98    ///
99    /// # Errors
100    ///
101    /// Returns [`BackgroundError`] for an invalid ID or coefficients.
102    pub fn new(
103        background_id: impl Into<String>,
104        coefficients: Vec<f64>,
105    ) -> Result<Self, BackgroundError> {
106        let background_id = validate_id(background_id.into())?;
107        validate_coefficients(&coefficients)?;
108        Ok(Self {
109            background_id,
110            coefficients,
111        })
112    }
113}
114
115impl DifferentiableBackground for PolynomialBackground {
116    fn background_id(&self) -> &str {
117        &self.background_id
118    }
119
120    fn parameter_names(&self) -> Vec<String> {
121        indexed_names("coefficient", self.coefficients.len())
122    }
123
124    fn coefficients(&self) -> Vec<f64> {
125        self.coefficients.clone()
126    }
127
128    fn parameter_bounds(&self) -> Vec<ParameterBounds> {
129        vec![ParameterBounds::default(); self.coefficients.len()]
130    }
131
132    fn basis(&self, x_deg: &[f64]) -> Result<BackgroundBasis, BackgroundError> {
133        validate_grid(x_deg)?;
134        let mut result = BackgroundBasis::zeros(x_deg.len(), self.coefficients.len())?;
135        for (row_index, row) in result.values.chunks_exact_mut(result.columns).enumerate() {
136            let normalized = normalized_grid_value(x_deg, row_index);
137            let mut power = 1.0;
138            for value in row {
139                *value = power;
140                power *= normalized;
141            }
142        }
143        Ok(result)
144    }
145
146    fn calculate(&self, x_deg: &[f64]) -> Result<Vec<f64>, BackgroundError> {
147        linear_calculate(&self.basis(x_deg)?, &self.coefficients)
148    }
149
150    fn replace_coefficients(&self, coefficients: &[f64]) -> Result<Self, BackgroundError> {
151        validate_replacement(coefficients, self.coefficients.len())?;
152        Self::new(self.background_id.clone(), coefficients.to_vec())
153    }
154
155    fn basis_is_invariant(&self) -> bool {
156        true
157    }
158}
159
160/// Chebyshev series on one explicit closed degree domain.
161#[derive(Clone, Debug, PartialEq)]
162pub struct ChebyshevBackground {
163    background_id: String,
164    coefficients: Vec<f64>,
165    domain_deg: [f64; 2],
166}
167
168impl ChebyshevBackground {
169    /// Construct a finite Chebyshev series on an increasing domain.
170    ///
171    /// # Errors
172    ///
173    /// Returns [`BackgroundError`] for invalid ID, coefficients, or domain.
174    pub fn new(
175        background_id: impl Into<String>,
176        coefficients: Vec<f64>,
177        domain_deg: [f64; 2],
178    ) -> Result<Self, BackgroundError> {
179        let background_id = validate_id(background_id.into())?;
180        validate_coefficients(&coefficients)?;
181        if domain_deg.iter().any(|value| !value.is_finite()) || domain_deg[0] >= domain_deg[1] {
182            return Err(BackgroundError::InvalidDomain);
183        }
184        Ok(Self {
185            background_id,
186            coefficients,
187            domain_deg,
188        })
189    }
190
191    /// Return the explicit closed domain in degrees.
192    #[must_use]
193    pub const fn domain_deg(&self) -> [f64; 2] {
194        self.domain_deg
195    }
196}
197
198impl DifferentiableBackground for ChebyshevBackground {
199    fn background_id(&self) -> &str {
200        &self.background_id
201    }
202
203    fn parameter_names(&self) -> Vec<String> {
204        indexed_names("coefficient", self.coefficients.len())
205    }
206
207    fn coefficients(&self) -> Vec<f64> {
208        self.coefficients.clone()
209    }
210
211    fn parameter_bounds(&self) -> Vec<ParameterBounds> {
212        vec![ParameterBounds::default(); self.coefficients.len()]
213    }
214
215    fn basis(&self, x_deg: &[f64]) -> Result<BackgroundBasis, BackgroundError> {
216        validate_grid(x_deg)?;
217        let lower = self.domain_deg[0];
218        let upper = self.domain_deg[1];
219        let tolerance = 64.0 * f64::EPSILON * lower.abs().max(upper.abs()).max(1.0);
220        if x_deg
221            .iter()
222            .any(|value| *value < lower - tolerance || *value > upper + tolerance)
223        {
224            return Err(BackgroundError::GridOutsideDomain);
225        }
226        let mut result = BackgroundBasis::zeros(x_deg.len(), self.coefficients.len())?;
227        for (x, row) in x_deg
228            .iter()
229            .zip(result.values.chunks_exact_mut(result.columns))
230        {
231            let normalized = 2.0 * (x - lower) / (upper - lower) - 1.0;
232            row[0] = 1.0;
233            if row.len() > 1 {
234                row[1] = normalized;
235            }
236            for order in 2..row.len() {
237                row[order] = 2.0 * normalized * row[order - 1] - row[order - 2];
238            }
239        }
240        Ok(result)
241    }
242
243    fn calculate(&self, x_deg: &[f64]) -> Result<Vec<f64>, BackgroundError> {
244        linear_calculate(&self.basis(x_deg)?, &self.coefficients)
245    }
246
247    fn replace_coefficients(&self, coefficients: &[f64]) -> Result<Self, BackgroundError> {
248        validate_replacement(coefficients, self.coefficients.len())?;
249        Self::new(
250            self.background_id.clone(),
251            coefficients.to_vec(),
252            self.domain_deg,
253        )
254    }
255
256    fn basis_is_invariant(&self) -> bool {
257        true
258    }
259}
260
261/// Linear interpolation through fixed knots with refinable values.
262#[derive(Clone, Debug, PartialEq)]
263pub struct PointBackground {
264    background_id: String,
265    knot_x: Vec<f64>,
266    values: Vec<f64>,
267}
268
269impl PointBackground {
270    /// Construct a fixed-knot piecewise-linear model with constant end extrapolation.
271    ///
272    /// # Errors
273    ///
274    /// Returns [`BackgroundError`] for invalid IDs, knots, or values.
275    pub fn new(
276        background_id: impl Into<String>,
277        knot_x: Vec<f64>,
278        values: Vec<f64>,
279    ) -> Result<Self, BackgroundError> {
280        let background_id = validate_id(background_id.into())?;
281        if knot_x.len() < 2 || values.len() != knot_x.len() {
282            return Err(BackgroundError::InvalidKnots);
283        }
284        validate_grid(&knot_x).map_err(|_| BackgroundError::InvalidKnots)?;
285        if values.iter().any(|value| !value.is_finite()) {
286            return Err(BackgroundError::NonFiniteCoefficients);
287        }
288        Ok(Self {
289            background_id,
290            knot_x,
291            values,
292        })
293    }
294
295    /// Borrow fixed interpolation knots.
296    #[must_use]
297    pub fn knot_x(&self) -> &[f64] {
298        &self.knot_x
299    }
300}
301
302impl DifferentiableBackground for PointBackground {
303    fn background_id(&self) -> &str {
304        &self.background_id
305    }
306
307    fn parameter_names(&self) -> Vec<String> {
308        indexed_names("value", self.values.len())
309    }
310
311    fn coefficients(&self) -> Vec<f64> {
312        self.values.clone()
313    }
314
315    fn parameter_bounds(&self) -> Vec<ParameterBounds> {
316        vec![ParameterBounds::default(); self.values.len()]
317    }
318
319    fn basis(&self, x_deg: &[f64]) -> Result<BackgroundBasis, BackgroundError> {
320        validate_grid(x_deg)?;
321        let mut result = BackgroundBasis::zeros(x_deg.len(), self.knot_x.len())?;
322        for (x, row) in x_deg
323            .iter()
324            .zip(result.values.chunks_exact_mut(result.columns))
325        {
326            let right = self.knot_x.partition_point(|knot| knot <= x);
327            if right == 0 {
328                row[0] = 1.0;
329            } else if right == self.knot_x.len() {
330                row[right - 1] = 1.0;
331            } else {
332                let lower = right - 1;
333                let fraction = (x - self.knot_x[lower]) / (self.knot_x[right] - self.knot_x[lower]);
334                row[lower] = 1.0 - fraction;
335                row[right] = fraction;
336            }
337        }
338        Ok(result)
339    }
340
341    fn calculate(&self, x_deg: &[f64]) -> Result<Vec<f64>, BackgroundError> {
342        linear_calculate(&self.basis(x_deg)?, &self.values)
343    }
344
345    fn replace_coefficients(&self, coefficients: &[f64]) -> Result<Self, BackgroundError> {
346        validate_replacement(coefficients, self.values.len())?;
347        Self::new(
348            self.background_id.clone(),
349            self.knot_x.clone(),
350            coefficients.to_vec(),
351        )
352    }
353
354    fn basis_is_invariant(&self) -> bool {
355        true
356    }
357}
358
359/// One area-normalized broad Gaussian component in degrees `2theta`.
360#[derive(Clone, Copy, Debug, PartialEq)]
361pub struct AmorphousPeak {
362    area: f64,
363    center_deg: f64,
364    fwhm_deg: f64,
365}
366
367impl AmorphousPeak {
368    /// Construct one finite non-negative-area, positive-width component.
369    ///
370    /// # Errors
371    ///
372    /// Returns [`BackgroundError::InvalidAmorphousPeak`] for invalid values.
373    pub fn new(area: f64, center_deg: f64, fwhm_deg: f64) -> Result<Self, BackgroundError> {
374        if !area.is_finite()
375            || !center_deg.is_finite()
376            || !fwhm_deg.is_finite()
377            || area < 0.0
378            || fwhm_deg <= 0.0
379        {
380            return Err(BackgroundError::InvalidAmorphousPeak);
381        }
382        Ok(Self {
383            area,
384            center_deg,
385            fwhm_deg,
386        })
387    }
388
389    /// Return integrated area.
390    #[must_use]
391    pub const fn area(self) -> f64 {
392        self.area
393    }
394
395    /// Return center in degrees `2theta`.
396    #[must_use]
397    pub const fn center_deg(self) -> f64 {
398        self.center_deg
399    }
400
401    /// Return FWHM in degrees `2theta`.
402    #[must_use]
403    pub const fn fwhm_deg(self) -> f64 {
404        self.fwhm_deg
405    }
406}
407
408/// Sum of broad area-normalized Gaussian amorphous components.
409#[derive(Clone, Debug, PartialEq)]
410pub struct AmorphousBackground {
411    background_id: String,
412    peaks: Vec<AmorphousPeak>,
413}
414
415impl AmorphousBackground {
416    /// Construct a non-empty broad-Gaussian background.
417    ///
418    /// # Errors
419    ///
420    /// Returns [`BackgroundError`] for an invalid ID or empty peak list.
421    pub fn new(
422        background_id: impl Into<String>,
423        peaks: Vec<AmorphousPeak>,
424    ) -> Result<Self, BackgroundError> {
425        let background_id = validate_id(background_id.into())?;
426        if peaks.is_empty() {
427            return Err(BackgroundError::EmptyComponents);
428        }
429        Ok(Self {
430            background_id,
431            peaks,
432        })
433    }
434
435    /// Borrow broad components.
436    #[must_use]
437    pub fn peaks(&self) -> &[AmorphousPeak] {
438        &self.peaks
439    }
440}
441
442impl DifferentiableBackground for AmorphousBackground {
443    fn background_id(&self) -> &str {
444        &self.background_id
445    }
446
447    fn parameter_names(&self) -> Vec<String> {
448        self.peaks
449            .iter()
450            .enumerate()
451            .flat_map(|(index, _)| {
452                ["area", "center_deg", "fwhm_deg"]
453                    .into_iter()
454                    .map(move |name| format!("peak_{index}.{name}"))
455            })
456            .collect()
457    }
458
459    fn coefficients(&self) -> Vec<f64> {
460        self.peaks
461            .iter()
462            .flat_map(|peak| [peak.area, peak.center_deg, peak.fwhm_deg])
463            .collect()
464    }
465
466    fn parameter_bounds(&self) -> Vec<ParameterBounds> {
467        self.peaks
468            .iter()
469            .flat_map(|_| {
470                [
471                    ParameterBounds::new(0.0, f64::INFINITY).expect("valid area bounds"),
472                    ParameterBounds::default(),
473                    ParameterBounds::new(f64::MIN_POSITIVE, f64::INFINITY)
474                        .expect("valid FWHM bounds"),
475                ]
476            })
477            .collect()
478    }
479
480    fn basis(&self, x_deg: &[f64]) -> Result<BackgroundBasis, BackgroundError> {
481        validate_grid(x_deg)?;
482        let columns = self
483            .peaks
484            .len()
485            .checked_mul(3)
486            .ok_or(BackgroundError::SizeOverflow)?;
487        let mut result = BackgroundBasis::zeros(x_deg.len(), columns)?;
488        let factor = 4.0 * 2.0_f64.ln();
489        let normalization = (factor / PI).sqrt();
490        for (x, row) in x_deg
491            .iter()
492            .zip(result.values.chunks_exact_mut(result.columns))
493        {
494            for (peak_index, peak) in self.peaks.iter().enumerate() {
495                let delta = x - peak.center_deg;
496                let ratio = delta / peak.fwhm_deg;
497                let gaussian = normalization / peak.fwhm_deg * (-factor * ratio * ratio).exp();
498                let value = peak.area * gaussian;
499                let offset = 3 * peak_index;
500                row[offset] = gaussian;
501                row[offset + 1] = value * 2.0 * factor * delta / (peak.fwhm_deg * peak.fwhm_deg);
502                row[offset + 2] = value
503                    * (-1.0 / peak.fwhm_deg
504                        + 2.0 * factor * delta * delta
505                            / (peak.fwhm_deg * peak.fwhm_deg * peak.fwhm_deg));
506            }
507        }
508        Ok(result)
509    }
510
511    fn calculate(&self, x_deg: &[f64]) -> Result<Vec<f64>, BackgroundError> {
512        validate_grid(x_deg)?;
513        let factor = 4.0 * 2.0_f64.ln();
514        let normalization = (factor / PI).sqrt();
515        Ok(x_deg
516            .iter()
517            .map(|x| {
518                self.peaks
519                    .iter()
520                    .map(|peak| {
521                        let ratio = (x - peak.center_deg) / peak.fwhm_deg;
522                        peak.area * normalization / peak.fwhm_deg * (-factor * ratio * ratio).exp()
523                    })
524                    .sum()
525            })
526            .collect())
527    }
528
529    fn replace_coefficients(&self, coefficients: &[f64]) -> Result<Self, BackgroundError> {
530        let expected = self
531            .peaks
532            .len()
533            .checked_mul(3)
534            .ok_or(BackgroundError::SizeOverflow)?;
535        validate_replacement(coefficients, expected)?;
536        let peaks = coefficients
537            .chunks_exact(3)
538            .map(|values| AmorphousPeak::new(values[0], values[1], values[2]))
539            .collect::<Result<Vec<_>, _>>()?;
540        Self::new(self.background_id.clone(), peaks)
541    }
542
543    fn basis_is_invariant(&self) -> bool {
544        false
545    }
546}
547
548/// Closed native union used by application-neutral request records.
549#[derive(Clone, Debug, PartialEq)]
550pub enum BackgroundModel {
551    /// Normalized-coordinate power series.
552    Polynomial(PolynomialBackground),
553    /// Explicit-domain Chebyshev series.
554    Chebyshev(ChebyshevBackground),
555    /// Fixed-knot linear interpolation.
556    Point(PointBackground),
557    /// Broad Gaussian components.
558    Amorphous(AmorphousBackground),
559    /// Ordered additive nested composition.
560    Composite(CompositeBackground),
561}
562
563/// Ordered additive composition of analytical models.
564#[derive(Clone, Debug, PartialEq)]
565pub struct CompositeBackground {
566    background_id: String,
567    components: Vec<BackgroundModel>,
568}
569
570impl CompositeBackground {
571    /// Construct a non-empty composition with unique immediate component IDs.
572    ///
573    /// # Errors
574    ///
575    /// Returns [`BackgroundError`] for an invalid ID, empty components, or
576    /// duplicate component IDs.
577    pub fn new(
578        background_id: impl Into<String>,
579        components: Vec<BackgroundModel>,
580    ) -> Result<Self, BackgroundError> {
581        let background_id = validate_id(background_id.into())?;
582        if components.is_empty() {
583            return Err(BackgroundError::EmptyComponents);
584        }
585        let mut ids = BTreeSet::new();
586        for component in &components {
587            if !ids.insert(component.background_id().to_owned()) {
588                return Err(BackgroundError::DuplicateComponentId {
589                    background_id: component.background_id().to_owned(),
590                });
591            }
592        }
593        Ok(Self {
594            background_id,
595            components,
596        })
597    }
598
599    /// Borrow ordered components.
600    #[must_use]
601    pub fn components(&self) -> &[BackgroundModel] {
602        &self.components
603    }
604}
605
606macro_rules! delegate_background {
607    ($self:ident, $method:ident $(, $argument:expr)*) => {
608        match $self {
609            Self::Polynomial(value) => value.$method($($argument),*),
610            Self::Chebyshev(value) => value.$method($($argument),*),
611            Self::Point(value) => value.$method($($argument),*),
612            Self::Amorphous(value) => value.$method($($argument),*),
613            Self::Composite(value) => value.$method($($argument),*),
614        }
615    };
616}
617
618impl DifferentiableBackground for BackgroundModel {
619    fn background_id(&self) -> &str {
620        delegate_background!(self, background_id)
621    }
622
623    fn parameter_names(&self) -> Vec<String> {
624        delegate_background!(self, parameter_names)
625    }
626
627    fn coefficients(&self) -> Vec<f64> {
628        delegate_background!(self, coefficients)
629    }
630
631    fn parameter_bounds(&self) -> Vec<ParameterBounds> {
632        delegate_background!(self, parameter_bounds)
633    }
634
635    fn basis(&self, x_deg: &[f64]) -> Result<BackgroundBasis, BackgroundError> {
636        delegate_background!(self, basis, x_deg)
637    }
638
639    fn calculate(&self, x_deg: &[f64]) -> Result<Vec<f64>, BackgroundError> {
640        delegate_background!(self, calculate, x_deg)
641    }
642
643    fn replace_coefficients(&self, coefficients: &[f64]) -> Result<Self, BackgroundError> {
644        Ok(match self {
645            Self::Polynomial(value) => Self::Polynomial(value.replace_coefficients(coefficients)?),
646            Self::Chebyshev(value) => Self::Chebyshev(value.replace_coefficients(coefficients)?),
647            Self::Point(value) => Self::Point(value.replace_coefficients(coefficients)?),
648            Self::Amorphous(value) => Self::Amorphous(value.replace_coefficients(coefficients)?),
649            Self::Composite(value) => Self::Composite(value.replace_coefficients(coefficients)?),
650        })
651    }
652
653    fn basis_is_invariant(&self) -> bool {
654        delegate_background!(self, basis_is_invariant)
655    }
656}
657
658impl BackgroundModel {
659    pub(crate) fn restart_compatible(&self, requested: &Self) -> bool {
660        match (self, requested) {
661            (Self::Polynomial(left), Self::Polynomial(right)) => {
662                left.background_id == right.background_id
663                    && left.coefficients.len() == right.coefficients.len()
664            }
665            (Self::Chebyshev(left), Self::Chebyshev(right)) => {
666                left.background_id == right.background_id
667                    && left.coefficients.len() == right.coefficients.len()
668                    && left
669                        .domain_deg
670                        .iter()
671                        .zip(right.domain_deg)
672                        .all(|(left, right)| left.to_bits() == right.to_bits())
673            }
674            (Self::Point(left), Self::Point(right)) => {
675                left.background_id == right.background_id && left.knot_x == right.knot_x
676            }
677            (Self::Amorphous(left), Self::Amorphous(right)) => {
678                left.background_id == right.background_id && left.peaks.len() == right.peaks.len()
679            }
680            (Self::Composite(left), Self::Composite(right)) => {
681                left.background_id == right.background_id
682                    && left.components.len() == right.components.len()
683                    && left
684                        .components
685                        .iter()
686                        .zip(&right.components)
687                        .all(|(left, right)| left.restart_compatible(right))
688            }
689            _ => false,
690        }
691    }
692}
693
694impl DifferentiableBackground for CompositeBackground {
695    fn background_id(&self) -> &str {
696        &self.background_id
697    }
698
699    fn parameter_names(&self) -> Vec<String> {
700        self.components
701            .iter()
702            .flat_map(|component| {
703                component
704                    .parameter_names()
705                    .into_iter()
706                    .map(move |name| format!("{}.{}", component.background_id(), name))
707            })
708            .collect()
709    }
710
711    fn coefficients(&self) -> Vec<f64> {
712        self.components
713            .iter()
714            .flat_map(DifferentiableBackground::coefficients)
715            .collect()
716    }
717
718    fn parameter_bounds(&self) -> Vec<ParameterBounds> {
719        self.components
720            .iter()
721            .flat_map(DifferentiableBackground::parameter_bounds)
722            .collect()
723    }
724
725    fn basis(&self, x_deg: &[f64]) -> Result<BackgroundBasis, BackgroundError> {
726        validate_grid(x_deg)?;
727        let component_bases = self
728            .components
729            .iter()
730            .map(|component| component.basis(x_deg))
731            .collect::<Result<Vec<_>, _>>()?;
732        let columns = component_bases.iter().try_fold(0_usize, |total, basis| {
733            total
734                .checked_add(basis.columns)
735                .ok_or(BackgroundError::SizeOverflow)
736        })?;
737        let mut result = BackgroundBasis::zeros(x_deg.len(), columns)?;
738        for row_index in 0..x_deg.len() {
739            let target = result
740                .values
741                .get_mut(row_index * columns..(row_index + 1) * columns)
742                .ok_or(BackgroundError::InternalInvariant)?;
743            let mut offset = 0;
744            for basis in &component_bases {
745                let source = basis
746                    .row(row_index)
747                    .ok_or(BackgroundError::InternalInvariant)?;
748                let end = offset + source.len();
749                target
750                    .get_mut(offset..end)
751                    .ok_or(BackgroundError::InternalInvariant)?
752                    .copy_from_slice(source);
753                offset = end;
754            }
755        }
756        Ok(result)
757    }
758
759    fn calculate(&self, x_deg: &[f64]) -> Result<Vec<f64>, BackgroundError> {
760        validate_grid(x_deg)?;
761        let mut result = vec![0.0; x_deg.len()];
762        for component in &self.components {
763            let values = component.calculate(x_deg)?;
764            for (target, value) in result.iter_mut().zip(values) {
765                *target += value;
766            }
767        }
768        Ok(result)
769    }
770
771    fn replace_coefficients(&self, coefficients: &[f64]) -> Result<Self, BackgroundError> {
772        validate_replacement(coefficients, self.coefficients().len())?;
773        let mut offset = 0_usize;
774        let mut components = Vec::with_capacity(self.components.len());
775        for component in &self.components {
776            let count = component.coefficients().len();
777            let end = offset
778                .checked_add(count)
779                .ok_or(BackgroundError::SizeOverflow)?;
780            components.push(
781                component.replace_coefficients(
782                    coefficients
783                        .get(offset..end)
784                        .ok_or(BackgroundError::InternalInvariant)?,
785                )?,
786            );
787            offset = end;
788        }
789        Self::new(self.background_id.clone(), components)
790    }
791
792    fn basis_is_invariant(&self) -> bool {
793        self.components
794            .iter()
795            .all(DifferentiableBackground::basis_is_invariant)
796    }
797}
798
799/// Invalid analytical background definition, grid, or replacement.
800#[derive(Clone, Debug, PartialEq)]
801pub enum BackgroundError {
802    /// Model ID is empty or untrimmed.
803    InvalidId,
804    /// Coefficient list is empty.
805    EmptyCoefficients,
806    /// One coefficient is non-finite.
807    NonFiniteCoefficients,
808    /// Chebyshev domain is non-finite or not increasing.
809    InvalidDomain,
810    /// Point knots are invalid or do not match values.
811    InvalidKnots,
812    /// Grid contains a non-finite sample.
813    NonFiniteGrid {
814        /// Rejected index.
815        index: usize,
816    },
817    /// Grid is not strictly increasing.
818    UnorderedGrid,
819    /// Grid lies outside an explicit Chebyshev domain.
820    GridOutsideDomain,
821    /// Broad Gaussian area/center/FWHM is invalid.
822    InvalidAmorphousPeak,
823    /// Model composition or broad peak list is empty.
824    EmptyComponents,
825    /// Composite repeats one immediate component ID.
826    DuplicateComponentId {
827        /// Repeated ID.
828        background_id: String,
829    },
830    /// Replacement coefficient vector has the wrong length.
831    CoefficientLengthMismatch {
832        /// Expected count.
833        expected: usize,
834        /// Received count.
835        actual: usize,
836    },
837    /// A checked matrix/vector size overflowed.
838    SizeOverflow,
839    /// Private validated state is unexpectedly inconsistent.
840    InternalInvariant,
841}
842
843impl Display for BackgroundError {
844    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
845        match self {
846            Self::InvalidId => formatter.write_str("background_id must be non-empty and trimmed"),
847            Self::EmptyCoefficients => {
848                formatter.write_str("background coefficients must not be empty")
849            }
850            Self::NonFiniteCoefficients => {
851                formatter.write_str("background coefficients must be finite")
852            }
853            Self::InvalidDomain => {
854                formatter.write_str("background domain must contain two increasing finite values")
855            }
856            Self::InvalidKnots => formatter.write_str(
857                "point background requires matching finite strictly increasing knots and values",
858            ),
859            Self::NonFiniteGrid { index } => {
860                write!(formatter, "background grid is non-finite at index {index}")
861            }
862            Self::UnorderedGrid => {
863                formatter.write_str("background grid must be strictly increasing")
864            }
865            Self::GridOutsideDomain => {
866                formatter.write_str("background grid lies outside the explicit domain")
867            }
868            Self::InvalidAmorphousPeak => formatter.write_str(
869                "amorphous area/center/FWHM must be finite with non-negative area and positive FWHM",
870            ),
871            Self::EmptyComponents => {
872                formatter.write_str("background components must not be empty")
873            }
874            Self::DuplicateComponentId { background_id } => {
875                write!(formatter, "duplicate background component ID {background_id:?}")
876            }
877            Self::CoefficientLengthMismatch { expected, actual } => write!(
878                formatter,
879                "replacement coefficient length {actual} does not match {expected}"
880            ),
881            Self::SizeOverflow => formatter.write_str("background matrix size overflow"),
882            Self::InternalInvariant => {
883                formatter.write_str("validated background state is inconsistent")
884            }
885        }
886    }
887}
888
889impl Error for BackgroundError {}
890
891fn validate_id(value: String) -> Result<String, BackgroundError> {
892    if value.is_empty() || value.trim() != value {
893        return Err(BackgroundError::InvalidId);
894    }
895    Ok(value)
896}
897
898fn validate_coefficients(values: &[f64]) -> Result<(), BackgroundError> {
899    if values.is_empty() {
900        return Err(BackgroundError::EmptyCoefficients);
901    }
902    if values.iter().any(|value| !value.is_finite()) {
903        return Err(BackgroundError::NonFiniteCoefficients);
904    }
905    Ok(())
906}
907
908fn validate_replacement(values: &[f64], expected: usize) -> Result<(), BackgroundError> {
909    if values.len() != expected {
910        return Err(BackgroundError::CoefficientLengthMismatch {
911            expected,
912            actual: values.len(),
913        });
914    }
915    if values.iter().any(|value| !value.is_finite()) {
916        return Err(BackgroundError::NonFiniteCoefficients);
917    }
918    Ok(())
919}
920
921fn validate_grid(x_deg: &[f64]) -> Result<(), BackgroundError> {
922    if let Some(index) = x_deg.iter().position(|value| !value.is_finite()) {
923        return Err(BackgroundError::NonFiniteGrid { index });
924    }
925    if x_deg.windows(2).any(|pair| pair[1] <= pair[0]) {
926        return Err(BackgroundError::UnorderedGrid);
927    }
928    Ok(())
929}
930
931fn indexed_names(stem: &str, count: usize) -> Vec<String> {
932    (0..count).map(|index| format!("{stem}_{index}")).collect()
933}
934
935fn normalized_grid_value(x_deg: &[f64], index: usize) -> f64 {
936    if x_deg.len() <= 1 {
937        0.0
938    } else {
939        2.0 * (x_deg[index] - x_deg[0]) / (x_deg[x_deg.len() - 1] - x_deg[0]) - 1.0
940    }
941}
942
943fn linear_calculate(
944    basis: &BackgroundBasis,
945    coefficients: &[f64],
946) -> Result<Vec<f64>, BackgroundError> {
947    if basis.columns != coefficients.len() {
948        return Err(BackgroundError::InternalInvariant);
949    }
950    Ok(basis
951        .values
952        .chunks_exact(basis.columns)
953        .map(|row| row.iter().zip(coefficients).map(|(a, b)| a * b).sum())
954        .collect())
955}