Skip to main content

phasesmith_workflows/
sample_physics.rs

1//! Built-in Python-free sample-physics records for structural workflows.
2
3use std::error::Error;
4use std::f64::consts::PI;
5use std::fmt::{Display, Formatter};
6
7use nalgebra::{Matrix3, Vector3};
8use phasesmith_core::{CwContributionsError, OwnedCwContributionArrays, OwnedCwContributions};
9use phasesmith_crystallography::UnitCell;
10
11use crate::ParameterBounds;
12
13const DEG_PER_RAD: f64 = 180.0 / PI;
14const HALF_ANGLE_RAD_PER_DEG: f64 = PI / 360.0;
15const CELL_PARAMETER_NAMES: [&str; 6] = [
16    "a_angstrom",
17    "b_angstrom",
18    "c_angstrom",
19    "alpha_deg",
20    "beta_deg",
21    "gamma_deg",
22];
23/// One closed built-in sample-physics model.
24#[derive(Clone, Debug, PartialEq)]
25pub enum RietveldSamplePhysicsModel {
26    /// Lorentzian Scherrer broadening.
27    IsotropicSize {
28        /// Coherent-domain size in nanometres.
29        crystallite_size_nm: f64,
30        /// Fixed positive Scherrer shape factor.
31        shape_factor: f64,
32    },
33    /// Gaussian broadening from RMS `delta d / d`.
34    IsotropicMicrostrain {
35        /// Non-negative dimensionless RMS microstrain.
36        rms_microstrain: f64,
37    },
38    /// Lorentzian broadening from a distribution of `delta d / d`.
39    IsotropicLorentzianMicrostrain {
40        /// Non-negative dimensionless Lorentzian microstrain.
41        microstrain: f64,
42    },
43    /// March--Dollase integrated-intensity correction around a fixed axis.
44    MarchDollase {
45        /// Positive March ratio.
46        ratio: f64,
47        /// Non-zero preferred reciprocal-lattice axis.
48        preferred_axis_hkl: [f64; 3],
49    },
50    /// Ordered multiplicative/additive composition.
51    Composite(Vec<Self>),
52}
53
54/// Evaluated contribution arrays plus stable provider derivative names.
55#[derive(Clone, Debug, PartialEq)]
56pub struct EvaluatedSamplePhysics {
57    /// Validated contribution arrays.
58    pub contributions: OwnedCwContributions,
59    /// Provider parameter names in derivative-row order.
60    pub parameter_names: Vec<String>,
61}
62
63/// One stable refinable built-in sample-physics scalar.
64#[derive(Clone, Debug, PartialEq)]
65pub struct SamplePhysicsParameter {
66    /// Stable provider-local name.
67    pub name: String,
68    /// Current physical value.
69    pub value: f64,
70    /// Physical unit label.
71    pub unit: &'static str,
72    /// Closed physical bounds.
73    pub bounds: ParameterBounds,
74    /// Positive solver scaling.
75    pub scale: f64,
76}
77
78impl RietveldSamplePhysicsModel {
79    /// Return refinable model scalars in stable composition order.
80    ///
81    /// # Errors
82    ///
83    /// Returns [`SamplePhysicsError`] for invalid or duplicate model records.
84    pub fn parameters(&self) -> Result<Vec<SamplePhysicsParameter>, SamplePhysicsError> {
85        let result = match self {
86            Self::IsotropicSize {
87                crystallite_size_nm,
88                shape_factor,
89            } => {
90                if crystallite_size_nm.is_nan()
91                    || *crystallite_size_nm <= 0.0
92                    || !crystallite_size_nm.is_finite()
93                    || !shape_factor.is_finite()
94                    || *shape_factor <= 0.0
95                {
96                    return Err(SamplePhysicsError::InvalidModel);
97                }
98                vec![SamplePhysicsParameter {
99                    name: "isotropic_size.crystallite_size_nm".to_owned(),
100                    value: *crystallite_size_nm,
101                    unit: "nanometre",
102                    bounds: ParameterBounds::new(f64::MIN_POSITIVE, f64::INFINITY)
103                        .map_err(|_| SamplePhysicsError::InvalidModel)?,
104                    scale: crystallite_size_nm.abs().max(1.0),
105                }]
106            }
107            Self::IsotropicMicrostrain { rms_microstrain } => {
108                if !rms_microstrain.is_finite() || *rms_microstrain < 0.0 {
109                    return Err(SamplePhysicsError::InvalidModel);
110                }
111                vec![SamplePhysicsParameter {
112                    name: "isotropic_microstrain.rms".to_owned(),
113                    value: *rms_microstrain,
114                    unit: "fraction",
115                    bounds: ParameterBounds::new(0.0, f64::INFINITY)
116                        .map_err(|_| SamplePhysicsError::InvalidModel)?,
117                    scale: rms_microstrain.abs().max(1.0e-4),
118                }]
119            }
120            Self::IsotropicLorentzianMicrostrain { microstrain } => {
121                if !microstrain.is_finite() || *microstrain < 0.0 {
122                    return Err(SamplePhysicsError::InvalidModel);
123                }
124                vec![SamplePhysicsParameter {
125                    name: "isotropic_lorentzian_microstrain.fraction".to_owned(),
126                    value: *microstrain,
127                    unit: "fraction",
128                    bounds: ParameterBounds::new(0.0, f64::INFINITY)
129                        .map_err(|_| SamplePhysicsError::InvalidModel)?,
130                    scale: microstrain.abs().max(1.0e-4),
131                }]
132            }
133            Self::MarchDollase {
134                ratio,
135                preferred_axis_hkl,
136            } => {
137                if !ratio.is_finite()
138                    || *ratio <= 0.0
139                    || preferred_axis_hkl.iter().any(|value| !value.is_finite())
140                    || preferred_axis_hkl.iter().all(|value| *value == 0.0)
141                {
142                    return Err(SamplePhysicsError::InvalidModel);
143                }
144                vec![SamplePhysicsParameter {
145                    name: "march_dollase.ratio".to_owned(),
146                    value: *ratio,
147                    unit: "relative",
148                    bounds: ParameterBounds::new(f64::MIN_POSITIVE, f64::INFINITY)
149                        .map_err(|_| SamplePhysicsError::InvalidModel)?,
150                    scale: ratio.abs().max(1.0),
151                }]
152            }
153            Self::Composite(models) => {
154                if models.is_empty() {
155                    return Err(SamplePhysicsError::EmptyComposite);
156                }
157                models
158                    .iter()
159                    .map(Self::parameters)
160                    .collect::<Result<Vec<_>, _>>()?
161                    .into_iter()
162                    .flatten()
163                    .collect()
164            }
165        };
166        if result
167            .iter()
168            .map(|parameter| &parameter.name)
169            .collect::<std::collections::BTreeSet<_>>()
170            .len()
171            != result.len()
172        {
173            return Err(SamplePhysicsError::DuplicateParameterName);
174        }
175        Ok(result)
176    }
177
178    /// Clone the model with replacement refinable scalar values.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`SamplePhysicsError`] for missing, unknown, or invalid values.
183    pub fn replace_parameters(
184        &self,
185        values: &std::collections::BTreeMap<String, f64>,
186    ) -> Result<Self, SamplePhysicsError> {
187        let expected = self
188            .parameters()?
189            .into_iter()
190            .map(|parameter| parameter.name)
191            .collect::<std::collections::BTreeSet<_>>();
192        if values.len() != expected.len() || values.keys().any(|name| !expected.contains(name)) {
193            return Err(SamplePhysicsError::ParameterSetMismatch);
194        }
195        let result = match self {
196            Self::IsotropicSize { shape_factor, .. } => Self::IsotropicSize {
197                crystallite_size_nm: values["isotropic_size.crystallite_size_nm"],
198                shape_factor: *shape_factor,
199            },
200            Self::IsotropicMicrostrain { .. } => Self::IsotropicMicrostrain {
201                rms_microstrain: values["isotropic_microstrain.rms"],
202            },
203            Self::IsotropicLorentzianMicrostrain { .. } => Self::IsotropicLorentzianMicrostrain {
204                microstrain: values["isotropic_lorentzian_microstrain.fraction"],
205            },
206            Self::MarchDollase {
207                preferred_axis_hkl, ..
208            } => Self::MarchDollase {
209                ratio: values["march_dollase.ratio"],
210                preferred_axis_hkl: *preferred_axis_hkl,
211            },
212            Self::Composite(models) => Self::Composite(
213                models
214                    .iter()
215                    .map(|model| {
216                        let names = model
217                            .parameters()?
218                            .into_iter()
219                            .map(|parameter| parameter.name)
220                            .collect::<std::collections::BTreeSet<_>>();
221                        let child = values
222                            .iter()
223                            .filter(|(name, _)| names.contains(*name))
224                            .map(|(name, value)| (name.clone(), *value))
225                            .collect();
226                        model.replace_parameters(&child)
227                    })
228                    .collect::<Result<Vec<_>, _>>()?,
229            ),
230        };
231        result.parameters()?;
232        Ok(result)
233    }
234
235    /// Validate and evaluate the model for one reflection batch.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`SamplePhysicsError`] for invalid model, reflection, cell, or
240    /// contribution state.
241    pub fn evaluate(
242        &self,
243        hkl: &[[i32; 3]],
244        two_theta_deg: &[f64],
245        cell: UnitCell,
246        wavelength_angstrom: f64,
247    ) -> Result<EvaluatedSamplePhysics, SamplePhysicsError> {
248        if hkl.len() != two_theta_deg.len()
249            || hkl.is_empty()
250            || two_theta_deg
251                .iter()
252                .any(|value| !value.is_finite() || !(0.0..180.0).contains(value))
253            || !wavelength_angstrom.is_finite()
254            || wavelength_angstrom <= 0.0
255        {
256            return Err(SamplePhysicsError::InvalidInput);
257        }
258        cell.geometry()
259            .map_err(|_| SamplePhysicsError::InvalidInput)?;
260        match self {
261            Self::IsotropicSize {
262                crystallite_size_nm,
263                shape_factor,
264            } => size(
265                *crystallite_size_nm,
266                *shape_factor,
267                two_theta_deg,
268                wavelength_angstrom,
269            ),
270            Self::IsotropicMicrostrain { rms_microstrain } => {
271                microstrain(*rms_microstrain, two_theta_deg)
272            }
273            Self::IsotropicLorentzianMicrostrain { microstrain } => {
274                lorentzian_microstrain(*microstrain, two_theta_deg)
275            }
276            Self::MarchDollase {
277                ratio,
278                preferred_axis_hkl,
279            } => march(*ratio, *preferred_axis_hkl, hkl, cell),
280            Self::Composite(models) => {
281                if models.is_empty() {
282                    return Err(SamplePhysicsError::EmptyComposite);
283                }
284                let evaluated = models
285                    .iter()
286                    .map(|model| model.evaluate(hkl, two_theta_deg, cell, wavelength_angstrom))
287                    .collect::<Result<Vec<_>, _>>()?;
288                compose(&evaluated)
289            }
290        }
291    }
292}
293
294fn size(
295    size_nm: f64,
296    shape_factor: f64,
297    positions: &[f64],
298    wavelength: f64,
299) -> Result<EvaluatedSamplePhysics, SamplePhysicsError> {
300    if size_nm.is_nan() || size_nm <= 0.0 || !shape_factor.is_finite() || shape_factor <= 0.0 {
301        return Err(SamplePhysicsError::InvalidModel);
302    }
303    let count = positions.len();
304    let mut lorentzian = vec![0.0; count];
305    let mut d_position = vec![0.0; count];
306    let mut d_parameter = vec![0.0; count];
307    if size_nm.is_finite() {
308        let scale = DEG_PER_RAD * shape_factor * wavelength / (10.0 * size_nm);
309        for (index, position) in positions.iter().enumerate() {
310            let theta = position * HALF_ANGLE_RAD_PER_DEG;
311            lorentzian[index] = scale / theta.cos();
312            d_position[index] = lorentzian[index] * HALF_ANGLE_RAD_PER_DEG * theta.tan();
313            d_parameter[index] = -lorentzian[index] / size_nm;
314        }
315    }
316    width_result(
317        vec![0.0; count],
318        lorentzian,
319        vec![0.0; count],
320        d_position,
321        "isotropic_size.crystallite_size_nm",
322        vec![0.0; count],
323        d_parameter,
324    )
325}
326
327fn microstrain(
328    strain: f64,
329    positions: &[f64],
330) -> Result<EvaluatedSamplePhysics, SamplePhysicsError> {
331    if !strain.is_finite() || strain < 0.0 {
332        return Err(SamplePhysicsError::InvalidModel);
333    }
334    let coefficient = (2.0 * DEG_PER_RAD).powi(2);
335    let mut variance = Vec::with_capacity(positions.len());
336    let mut d_position = Vec::with_capacity(positions.len());
337    let mut d_parameter = Vec::with_capacity(positions.len());
338    for position in positions {
339        let theta = position * HALF_ANGLE_RAD_PER_DEG;
340        let tangent = theta.tan();
341        variance.push(coefficient * strain * strain * tangent * tangent);
342        d_parameter.push(2.0 * coefficient * strain * tangent * tangent);
343        d_position.push(
344            2.0 * coefficient * strain * strain * tangent / theta.cos().powi(2)
345                * HALF_ANGLE_RAD_PER_DEG,
346        );
347    }
348    let count = positions.len();
349    width_result(
350        variance,
351        vec![0.0; count],
352        d_position,
353        vec![0.0; count],
354        "isotropic_microstrain.rms",
355        d_parameter,
356        vec![0.0; count],
357    )
358}
359
360fn lorentzian_microstrain(
361    strain: f64,
362    positions: &[f64],
363) -> Result<EvaluatedSamplePhysics, SamplePhysicsError> {
364    if !strain.is_finite() || strain < 0.0 {
365        return Err(SamplePhysicsError::InvalidModel);
366    }
367    let mut lorentzian = Vec::with_capacity(positions.len());
368    let mut d_position = Vec::with_capacity(positions.len());
369    let mut d_parameter = Vec::with_capacity(positions.len());
370    for position in positions {
371        let theta = position * HALF_ANGLE_RAD_PER_DEG;
372        lorentzian.push(DEG_PER_RAD * strain * theta.tan());
373        d_parameter.push(DEG_PER_RAD * theta.tan());
374        d_position.push(0.5 * strain / theta.cos().powi(2));
375    }
376    let count = positions.len();
377    width_result(
378        vec![0.0; count],
379        lorentzian,
380        vec![0.0; count],
381        d_position,
382        "isotropic_lorentzian_microstrain.fraction",
383        vec![0.0; count],
384        d_parameter,
385    )
386}
387
388#[allow(clippy::too_many_arguments)]
389fn width_result(
390    gaussian: Vec<f64>,
391    lorentzian: Vec<f64>,
392    d_gaussian_position: Vec<f64>,
393    d_lorentzian_position: Vec<f64>,
394    name: &str,
395    d_gaussian_parameter: Vec<f64>,
396    d_lorentzian_parameter: Vec<f64>,
397) -> Result<EvaluatedSamplePhysics, SamplePhysicsError> {
398    let count = gaussian.len();
399    Ok(EvaluatedSamplePhysics {
400        contributions: OwnedCwContributions::new(
401            count,
402            1,
403            OwnedCwContributionArrays {
404                gaussian_variance_deg2: gaussian,
405                lorentzian_fwhm_deg: lorentzian,
406                intensity_multiplier: vec![1.0; count],
407                d_gaussian_variance_d_position: d_gaussian_position,
408                d_lorentzian_fwhm_d_position: d_lorentzian_position,
409                d_intensity_multiplier_d_position: vec![0.0; count],
410                d_gaussian_variance_d_parameters: d_gaussian_parameter,
411                d_lorentzian_fwhm_d_parameters: d_lorentzian_parameter,
412                d_intensity_multiplier_d_parameters: vec![0.0; count],
413            },
414        )?,
415        parameter_names: vec![name.to_owned()],
416    })
417}
418
419fn march(
420    ratio: f64,
421    axis: [f64; 3],
422    hkl: &[[i32; 3]],
423    cell: UnitCell,
424) -> Result<EvaluatedSamplePhysics, SamplePhysicsError> {
425    if !ratio.is_finite()
426        || ratio <= 0.0
427        || axis.iter().any(|value| !value.is_finite())
428        || axis.iter().all(|value| *value == 0.0)
429    {
430        return Err(SamplePhysicsError::InvalidModel);
431    }
432    let reciprocal = cell
433        .geometry()
434        .map_err(|_| SamplePhysicsError::InvalidInput)?
435        .reciprocal_metric;
436    let metric = Matrix3::from_row_slice(&reciprocal.concat());
437    let metric_derivatives = reciprocal_metric_derivatives(cell, metric)?;
438    let axis = Vector3::from_row_slice(&axis);
439    let axis_norm = (axis.transpose() * metric * axis)[0];
440    let count = hkl.len();
441    let mut multiplier = Vec::with_capacity(count);
442    let mut d_ratio = Vec::with_capacity(count);
443    let cell_derivative_count =
444        CELL_PARAMETER_NAMES
445            .len()
446            .checked_mul(count)
447            .ok_or(SamplePhysicsError::Contributions(
448                CwContributionsError::AllocationOverflow,
449            ))?;
450    let mut d_cell = vec![0.0; cell_derivative_count];
451    for reflection in hkl {
452        let vector = Vector3::new(
453            f64::from(reflection[0]),
454            f64::from(reflection[1]),
455            f64::from(reflection[2]),
456        );
457        let reflection_norm = (vector.transpose() * metric * vector)[0];
458        let projection = (vector.transpose() * metric * axis)[0];
459        if reflection_norm <= 0.0 || axis_norm <= 0.0 {
460            return Err(SamplePhysicsError::InvalidInput);
461        }
462        let raw_cosine = projection * projection / (reflection_norm * axis_norm);
463        let tolerance = 64.0 * f64::EPSILON;
464        if !raw_cosine.is_finite() || raw_cosine < -tolerance || raw_cosine > 1.0 + tolerance {
465            return Err(SamplePhysicsError::InvalidInput);
466        }
467        let cosine = raw_cosine.clamp(0.0, 1.0);
468        let sine = 1.0 - cosine;
469        let denominator = ratio * ratio * cosine + sine / ratio;
470        multiplier.push(denominator.powf(-1.5));
471        let derivative = 2.0 * ratio * cosine - sine / (ratio * ratio);
472        d_ratio.push(-1.5 * denominator.powf(-2.5) * derivative);
473        let d_multiplier_d_cosine = -1.5 * denominator.powf(-2.5) * (ratio * ratio - ratio.recip());
474        for (parameter, derivative_metric) in metric_derivatives.iter().enumerate() {
475            let d_reflection_norm = (vector.transpose() * derivative_metric * vector)[0];
476            let d_axis_norm = (axis.transpose() * derivative_metric * axis)[0];
477            let d_projection = (vector.transpose() * derivative_metric * axis)[0];
478            let d_cosine = 2.0 * projection * d_projection / (reflection_norm * axis_norm)
479                - cosine * (d_reflection_norm / reflection_norm + d_axis_norm / axis_norm);
480            d_cell[parameter * count + multiplier.len() - 1] = d_multiplier_d_cosine * d_cosine;
481        }
482    }
483    let mut intensity_derivatives = d_ratio;
484    intensity_derivatives.extend(d_cell);
485    let zeros = vec![0.0; count];
486    Ok(EvaluatedSamplePhysics {
487        contributions: OwnedCwContributions::new(
488            count,
489            1 + CELL_PARAMETER_NAMES.len(),
490            OwnedCwContributionArrays {
491                gaussian_variance_deg2: zeros.clone(),
492                lorentzian_fwhm_deg: zeros.clone(),
493                intensity_multiplier: multiplier,
494                d_gaussian_variance_d_position: zeros.clone(),
495                d_lorentzian_fwhm_d_position: zeros.clone(),
496                d_intensity_multiplier_d_position: zeros.clone(),
497                d_gaussian_variance_d_parameters: vec![0.0; intensity_derivatives.len()],
498                d_lorentzian_fwhm_d_parameters: vec![0.0; intensity_derivatives.len()],
499                d_intensity_multiplier_d_parameters: intensity_derivatives,
500            },
501        )?,
502        parameter_names: std::iter::once("march_dollase.ratio".to_owned())
503            .chain(
504                CELL_PARAMETER_NAMES
505                    .iter()
506                    .map(|name| format!("march_dollase.cell.{name}")),
507            )
508            .collect(),
509    })
510}
511
512fn reciprocal_metric_derivatives(
513    cell: UnitCell,
514    reciprocal: Matrix3<f64>,
515) -> Result<[Matrix3<f64>; 6], SamplePhysicsError> {
516    let [a, b, c, alpha_deg, beta_deg, gamma_deg] = [
517        cell.a_angstrom,
518        cell.b_angstrom,
519        cell.c_angstrom,
520        cell.alpha_deg,
521        cell.beta_deg,
522        cell.gamma_deg,
523    ];
524    let [alpha, beta, gamma] = [alpha_deg, beta_deg, gamma_deg].map(f64::to_radians);
525    let mut direct = std::array::from_fn(|_| Matrix3::zeros());
526    direct[0] = Matrix3::new(
527        2.0 * a,
528        b * gamma.cos(),
529        c * beta.cos(),
530        b * gamma.cos(),
531        0.0,
532        0.0,
533        c * beta.cos(),
534        0.0,
535        0.0,
536    );
537    direct[1] = Matrix3::new(
538        0.0,
539        a * gamma.cos(),
540        0.0,
541        a * gamma.cos(),
542        2.0 * b,
543        c * alpha.cos(),
544        0.0,
545        c * alpha.cos(),
546        0.0,
547    );
548    direct[2] = Matrix3::new(
549        0.0,
550        0.0,
551        a * beta.cos(),
552        0.0,
553        0.0,
554        b * alpha.cos(),
555        a * beta.cos(),
556        b * alpha.cos(),
557        2.0 * c,
558    );
559    let per_degree = PI / 180.0;
560    direct[3][(1, 2)] = -b * c * alpha.sin() * per_degree;
561    direct[3][(2, 1)] = direct[3][(1, 2)];
562    direct[4][(0, 2)] = -a * c * beta.sin() * per_degree;
563    direct[4][(2, 0)] = direct[4][(0, 2)];
564    direct[5][(0, 1)] = -a * b * gamma.sin() * per_degree;
565    direct[5][(1, 0)] = direct[5][(0, 1)];
566    if direct
567        .iter()
568        .flat_map(Matrix3::iter)
569        .any(|value| !value.is_finite())
570    {
571        return Err(SamplePhysicsError::InvalidInput);
572    }
573    Ok(direct.map(|derivative| -reciprocal * derivative * reciprocal))
574}
575
576fn compose(items: &[EvaluatedSamplePhysics]) -> Result<EvaluatedSamplePhysics, SamplePhysicsError> {
577    let count = items[0].contributions.reflection_count();
578    if items
579        .iter()
580        .any(|item| item.contributions.reflection_count() != count)
581    {
582        return Err(SamplePhysicsError::InvalidInput);
583    }
584    let names = items
585        .iter()
586        .flat_map(|item| item.parameter_names.iter().cloned())
587        .collect::<Vec<_>>();
588    if names
589        .iter()
590        .collect::<std::collections::BTreeSet<_>>()
591        .len()
592        != names.len()
593    {
594        return Err(SamplePhysicsError::DuplicateParameterName);
595    }
596    let parameter_count = names.len();
597    let derivative_count =
598        parameter_count
599            .checked_mul(count)
600            .ok_or(SamplePhysicsError::Contributions(
601                CwContributionsError::AllocationOverflow,
602            ))?;
603    let mut arrays = OwnedCwContributionArrays {
604        gaussian_variance_deg2: vec![0.0; count],
605        lorentzian_fwhm_deg: vec![0.0; count],
606        intensity_multiplier: vec![1.0; count],
607        d_gaussian_variance_d_position: vec![0.0; count],
608        d_lorentzian_fwhm_d_position: vec![0.0; count],
609        d_intensity_multiplier_d_position: vec![0.0; count],
610        d_gaussian_variance_d_parameters: vec![0.0; derivative_count],
611        d_lorentzian_fwhm_d_parameters: vec![0.0; derivative_count],
612        d_intensity_multiplier_d_parameters: vec![0.0; derivative_count],
613    };
614    let mut row_offset = 0;
615    for item in items {
616        let source = item.contributions.arrays();
617        let previous_multipliers = arrays.intensity_multiplier.clone();
618        for (reflection, old_multiplier) in previous_multipliers.iter().copied().enumerate() {
619            let child_multiplier = source.intensity_multiplier[reflection];
620            arrays.gaussian_variance_deg2[reflection] += source.gaussian_variance_deg2[reflection];
621            arrays.lorentzian_fwhm_deg[reflection] += source.lorentzian_fwhm_deg[reflection];
622            arrays.d_gaussian_variance_d_position[reflection] +=
623                source.d_gaussian_variance_d_position[reflection];
624            arrays.d_lorentzian_fwhm_d_position[reflection] +=
625                source.d_lorentzian_fwhm_d_position[reflection];
626            arrays.d_intensity_multiplier_d_position[reflection] =
627                arrays.d_intensity_multiplier_d_position[reflection] * child_multiplier
628                    + old_multiplier * source.d_intensity_multiplier_d_position[reflection];
629            arrays.intensity_multiplier[reflection] *= child_multiplier;
630            for prior in 0..row_offset {
631                arrays.d_intensity_multiplier_d_parameters[prior * count + reflection] *=
632                    child_multiplier;
633            }
634        }
635        for row in 0..item.parameter_names.len() {
636            for (reflection, previous_multiplier) in
637                previous_multipliers.iter().copied().enumerate()
638            {
639                let source_index = row * count + reflection;
640                let target_index = (row_offset + row) * count + reflection;
641                arrays.d_gaussian_variance_d_parameters[target_index] =
642                    source.d_gaussian_variance_d_parameters[source_index];
643                arrays.d_lorentzian_fwhm_d_parameters[target_index] =
644                    source.d_lorentzian_fwhm_d_parameters[source_index];
645                arrays.d_intensity_multiplier_d_parameters[target_index] =
646                    source.d_intensity_multiplier_d_parameters[source_index] * previous_multiplier;
647            }
648        }
649        row_offset += item.parameter_names.len();
650    }
651    Ok(EvaluatedSamplePhysics {
652        contributions: OwnedCwContributions::new(count, parameter_count, arrays)?,
653        parameter_names: names,
654    })
655}
656
657/// Invalid built-in sample-physics state.
658#[derive(Debug)]
659pub enum SamplePhysicsError {
660    /// Model scalar or axis state is invalid.
661    InvalidModel,
662    /// Reflection/cell/wavelength state is invalid.
663    InvalidInput,
664    /// A composite model must not be empty.
665    EmptyComposite,
666    /// Composite child parameter names must be unique.
667    DuplicateParameterName,
668    /// Replacement values do not exactly match model parameters.
669    ParameterSetMismatch,
670    /// Contribution array construction failed.
671    Contributions(CwContributionsError),
672}
673
674impl Display for SamplePhysicsError {
675    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
676        match self {
677            Self::InvalidModel => formatter.write_str("native sample-physics model is invalid"),
678            Self::InvalidInput => formatter.write_str("native sample-physics input is invalid"),
679            Self::EmptyComposite => formatter.write_str("sample-physics composite is empty"),
680            Self::DuplicateParameterName => {
681                formatter.write_str("sample-physics parameter names are duplicated")
682            }
683            Self::ParameterSetMismatch => {
684                formatter.write_str("sample-physics replacement parameters do not match")
685            }
686            Self::Contributions(error) => Display::fmt(error, formatter),
687        }
688    }
689}
690
691impl Error for SamplePhysicsError {
692    fn source(&self) -> Option<&(dyn Error + 'static)> {
693        match self {
694            Self::Contributions(error) => Some(error),
695            Self::InvalidModel
696            | Self::InvalidInput
697            | Self::EmptyComposite
698            | Self::DuplicateParameterName
699            | Self::ParameterSetMismatch => None,
700        }
701    }
702}
703
704impl From<CwContributionsError> for SamplePhysicsError {
705    fn from(value: CwContributionsError) -> Self {
706        Self::Contributions(value)
707    }
708}