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