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