Skip to main content

phasesmith_workflows/
rietveld.rs

1//! Python-free owned structural-pattern boundary for native Rietveld workflows.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6use phasesmith_core::{
7    ConstantWavelengthInstrument, CwContributionsError, FcjGeometry, OwnedCwContributionArrays,
8    OwnedCwContributions, SupportPolicy,
9};
10use phasesmith_crystallography::IntegratedIntensityCorrectionModel;
11use phasesmith_engine::{
12    MonochromaticPositionCorrection, PreparedStructuralModel, PreparedStructuralMultiphase,
13    PreparedStructuralPhase, PreparedStructuralSpectrum, StructuralCalculationRequest,
14    StructuralModelInput, StructuralMultiphaseError, StructuralPatternError,
15    StructuralPatternResult, StructuralPhaseDefinition, StructuralSpectrumError,
16    calculate_monochromatic_reflection_geometry,
17};
18use phasesmith_execution::ExecutionPolicy;
19use phasesmith_model::{DomainError, FixedWavelengthSpectrum, PatternRecord, RecordId};
20
21use crate::{
22    BackgroundError, BackgroundModel, DifferentiableBackground, LatticeError,
23    LatticeReflectionDomain, ResidualError, ResidualEvaluation, ResidualOptions,
24    RietveldSamplePhysicsModel, SamplePhysicsError, evaluate_residuals,
25};
26
27/// One owned monochromatic structural phase and its sample-physics inputs.
28#[derive(Clone, Debug, PartialEq)]
29pub struct RietveldPhase {
30    phase_id: RecordId,
31    name: String,
32    site_ids: Vec<RecordId>,
33    reflection_ids: Vec<String>,
34    definition: StructuralPhaseDefinition,
35    contributions: OwnedCwContributions,
36    sample_physics: Option<RietveldSamplePhysicsModel>,
37    reflection_domain: Option<LatticeReflectionDomain>,
38}
39
40impl RietveldPhase {
41    /// Validate and own one built-in structural phase.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`RietveldError`] for an empty name, invalid definition, or a
46    /// contribution batch with the wrong reflection count.
47    pub fn new(
48        phase_id: RecordId,
49        name: impl Into<String>,
50        definition: StructuralPhaseDefinition,
51        contributions: OwnedCwContributions,
52    ) -> Result<Self, RietveldError> {
53        let site_ids = (0..definition.fractional_xyz.len())
54            .map(|index| RecordId::new(format!("site-{index}")))
55            .collect::<Result<Vec<_>, _>>()
56            .map_err(RietveldError::Pattern)?;
57        Self::new_with_site_ids(phase_id, name, site_ids, definition, contributions)
58    }
59
60    /// Validate and own one phase with explicit stable asymmetric-site IDs.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`RietveldError`] when site IDs are missing or duplicated, or
65    /// when another phase invariant is invalid.
66    pub fn new_with_site_ids(
67        phase_id: RecordId,
68        name: impl Into<String>,
69        site_ids: Vec<RecordId>,
70        definition: StructuralPhaseDefinition,
71        contributions: OwnedCwContributions,
72    ) -> Result<Self, RietveldError> {
73        let reflection_ids = definition
74            .hkl
75            .iter()
76            .map(|hkl| reflection_id(*hkl))
77            .collect();
78        let phase = Self {
79            phase_id,
80            name: name.into(),
81            site_ids,
82            reflection_ids,
83            definition,
84            contributions,
85            sample_physics: None,
86            reflection_domain: None,
87        };
88        phase.validate()?;
89        Ok(phase)
90    }
91
92    /// Generate a structural phase from one bounded reflection-domain contract.
93    ///
94    /// Reflection arrays are regenerated from the definition's cell and start
95    /// with neutral sample-physics contributions. Subsequent accepted cell
96    /// changes transfer contribution arrays by stable reflection ID.
97    ///
98    /// # Errors
99    ///
100    /// Returns [`RietveldError`] for an invalid domain, definition, or phase.
101    pub fn from_lattice_domain(
102        phase_id: RecordId,
103        name: impl Into<String>,
104        site_ids: Vec<RecordId>,
105        mut definition: StructuralPhaseDefinition,
106        reflection_domain: LatticeReflectionDomain,
107    ) -> Result<Self, RietveldError> {
108        let generated = reflection_domain
109            .generate(definition.cell, None)
110            .map_err(RietveldError::Lattice)?;
111        definition.hkl = generated.hkl;
112        definition.multiplicity = generated.multiplicity;
113        let phase = Self {
114            phase_id,
115            name: name.into(),
116            site_ids,
117            reflection_ids: generated.reflection_ids,
118            contributions: OwnedCwContributions::neutral(definition.hkl.len()),
119            sample_physics: None,
120            definition,
121            reflection_domain: Some(reflection_domain),
122        };
123        phase.validate()?;
124        Ok(phase)
125    }
126
127    /// Revalidate adapter-decoded phase identity, structure, and topology.
128    ///
129    /// # Errors
130    ///
131    /// Returns [`RietveldError`] for invalid phase state.
132    pub fn validate(&self) -> Result<(), RietveldError> {
133        if self.name.trim().is_empty() {
134            return Err(RietveldError::InvalidPhaseName);
135        }
136        self.definition
137            .validate()
138            .map_err(RietveldError::StructuralPattern)?;
139        if self.site_ids.len() != self.definition.fractional_xyz.len() {
140            return Err(RietveldError::SiteIdCountMismatch);
141        }
142        if self
143            .site_ids
144            .iter()
145            .collect::<std::collections::BTreeSet<_>>()
146            .len()
147            != self.site_ids.len()
148        {
149            return Err(RietveldError::DuplicateSiteId);
150        }
151        if self.contributions.reflection_count() != self.definition.hkl.len() {
152            return Err(RietveldError::ContributionCountMismatch);
153        }
154        if self.reflection_ids.len() != self.definition.hkl.len()
155            || self
156                .reflection_ids
157                .iter()
158                .collect::<std::collections::BTreeSet<_>>()
159                .len()
160                != self.reflection_ids.len()
161        {
162            return Err(RietveldError::ReflectionIdentityMismatch);
163        }
164        if self
165            .reflection_ids
166            .iter()
167            .zip(&self.definition.hkl)
168            .any(|(id, hkl)| id != &reflection_id(*hkl))
169        {
170            return Err(RietveldError::ReflectionTopologyMismatch);
171        }
172        if let Some(domain) = &self.reflection_domain {
173            domain
174                .validate_cell(self.definition.cell)
175                .map_err(RietveldError::Lattice)?;
176        }
177        Ok(())
178    }
179
180    /// Borrow the stable phase ID.
181    #[must_use]
182    pub const fn phase_id(&self) -> &RecordId {
183        &self.phase_id
184    }
185
186    /// Borrow the human-readable phase name.
187    #[must_use]
188    pub fn name(&self) -> &str {
189        &self.name
190    }
191
192    /// Borrow stable asymmetric-site IDs in structural-array order.
193    #[must_use]
194    pub fn site_ids(&self) -> &[RecordId] {
195        &self.site_ids
196    }
197
198    /// Borrow stable reflection-family IDs in calculation order.
199    #[must_use]
200    pub fn reflection_ids(&self) -> &[String] {
201        &self.reflection_ids
202    }
203
204    /// Borrow the complete structural definition.
205    #[must_use]
206    pub const fn definition(&self) -> &StructuralPhaseDefinition {
207        &self.definition
208    }
209
210    /// Borrow the owned sample-physics contribution batch.
211    #[must_use]
212    pub const fn contributions(&self) -> &OwnedCwContributions {
213        &self.contributions
214    }
215
216    /// Borrow the optional built-in sample-physics model record.
217    #[must_use]
218    pub const fn sample_physics(&self) -> Option<&RietveldSamplePhysicsModel> {
219        self.sample_physics.as_ref()
220    }
221
222    /// Attach one built-in sample-physics model to this phase.
223    ///
224    /// The static contribution batch is retained only as the fixed-provider
225    /// fallback and is not composed with the built-in model.
226    #[must_use]
227    pub fn with_sample_physics(mut self, model: RietveldSamplePhysicsModel) -> Self {
228        self.sample_physics = Some(model);
229        self
230    }
231
232    pub(crate) fn replace_sample_physics(&self, model: RietveldSamplePhysicsModel) -> Self {
233        let mut phase = self.clone();
234        phase.sample_physics = Some(model);
235        phase
236    }
237
238    pub(crate) fn resolved_sample_physics(
239        &self,
240        instrument: ConstantWavelengthInstrument,
241        position_correction: MonochromaticPositionCorrection,
242    ) -> Result<(OwnedCwContributions, Vec<String>), RietveldError> {
243        let Some(model) = &self.sample_physics else {
244            return Ok((self.contributions.clone(), Vec::new()));
245        };
246        let geometry = calculate_monochromatic_reflection_geometry(
247            self.definition.cell,
248            &self.definition.hkl,
249            instrument,
250            position_correction,
251        )
252        .map_err(RietveldError::StructuralPattern)?;
253        let evaluated = model
254            .evaluate(
255                &self.definition.hkl,
256                &geometry.two_theta_deg,
257                self.definition.cell,
258                instrument.wavelength_angstrom,
259            )
260            .map_err(RietveldError::SamplePhysics)?;
261        Ok((evaluated.contributions, evaluated.parameter_names))
262    }
263
264    /// Replace sample-physics contributions without changing phase topology.
265    ///
266    /// # Errors
267    ///
268    /// Returns [`RietveldError`] when the contribution reflection count does
269    /// not match the current stable reflection list.
270    pub fn with_contributions(
271        &self,
272        contributions: OwnedCwContributions,
273    ) -> Result<Self, RietveldError> {
274        let mut phase = self.clone();
275        phase.contributions = contributions;
276        phase.validate()?;
277        Ok(phase)
278    }
279
280    /// Borrow the guarded reflection domain for a dynamic phase.
281    #[must_use]
282    pub const fn reflection_domain(&self) -> Option<&LatticeReflectionDomain> {
283        self.reflection_domain.as_ref()
284    }
285
286    /// Regenerate a dynamic phase at another accepted bounded cell.
287    ///
288    /// Existing sample-physics values and parameter derivatives transfer by
289    /// stable reflection ID. New reflection families receive neutral values.
290    ///
291    /// # Errors
292    ///
293    /// Returns [`RietveldError`] for a fixed phase, an out-of-domain cell, or
294    /// invalid transferred contributions.
295    pub fn regenerate_lattice_at_cell(
296        &self,
297        cell: phasesmith_crystallography::UnitCell,
298    ) -> Result<(Self, RietveldTopologyChange), RietveldError> {
299        let domain = self
300            .reflection_domain
301            .as_ref()
302            .ok_or(RietveldError::FixedReflectionTopology)?;
303        let previous = self
304            .reflection_ids
305            .iter()
306            .cloned()
307            .map(|reflection_id| (reflection_id, 1.0))
308            .collect::<std::collections::BTreeMap<_, _>>();
309        let generated = domain
310            .generate(cell, Some(&previous))
311            .map_err(RietveldError::Lattice)?;
312        let contributions = transfer_contributions(
313            &self.reflection_ids,
314            &generated.reflection_ids,
315            &self.contributions,
316        )?;
317        let change = RietveldTopologyChange {
318            phase_id: self.phase_id.clone(),
319            added_reflection_ids: generated.added_reflection_ids.clone(),
320            removed_reflection_ids: generated.removed_reflection_ids.clone(),
321            preserved_reflection_count: generated.preserved_reflection_count,
322        };
323        let mut phase = self.clone();
324        phase.definition.cell = cell;
325        phase.definition.hkl = generated.hkl;
326        phase.definition.multiplicity = generated.multiplicity;
327        phase.reflection_ids = generated.reflection_ids;
328        phase.contributions = contributions;
329        phase.validate()?;
330        Ok((phase, change))
331    }
332
333    pub(crate) fn with_definition(
334        &self,
335        definition: StructuralPhaseDefinition,
336    ) -> Result<Self, RietveldError> {
337        if self.reflection_domain.is_some() && definition.cell != self.definition.cell {
338            let (mut phase, _) = self.regenerate_lattice_at_cell(definition.cell)?;
339            let hkl = std::mem::take(&mut phase.definition.hkl);
340            let multiplicity = std::mem::take(&mut phase.definition.multiplicity);
341            phase.definition = definition;
342            phase.definition.hkl = hkl;
343            phase.definition.multiplicity = multiplicity;
344            phase.validate()?;
345            return Ok(phase);
346        }
347        let mut phase = self.clone();
348        phase.definition = definition;
349        phase.validate()?;
350        Ok(phase)
351    }
352
353    pub(crate) fn restart_compatible(&self, requested: &Self) -> bool {
354        self.restart_compatible_with_wavelength(requested, false)
355    }
356
357    pub(crate) fn restart_compatible_with_wavelength(
358        &self,
359        requested: &Self,
360        allow_wavelength_change: bool,
361    ) -> bool {
362        self.phase_id == requested.phase_id
363            && self.site_ids == requested.site_ids
364            && self.definition.space_group == requested.definition.space_group
365            && self.definition.anisotropic_mask == requested.definition.anisotropic_mask
366            && self.definition.u_aniso_cif_angstrom2 == requested.definition.u_aniso_cif_angstrom2
367            && self.definition.scattering_species == requested.definition.scattering_species
368            && self.definition.scattering_real_offset == requested.definition.scattering_real_offset
369            && self.definition.scattering_imag_offset == requested.definition.scattering_imag_offset
370            && self.definition.coordinate_tolerance.to_bits()
371                == requested.definition.coordinate_tolerance.to_bits()
372            && self.definition.scattering_model == requested.definition.scattering_model
373            && (self.definition.correction_model == requested.definition.correction_model
374                || (allow_wavelength_change
375                    && correction_identity_matches(
376                        self.definition.correction_model,
377                        requested.definition.correction_model,
378                    )))
379            && sample_physics_identity_matches(
380                self.sample_physics.as_ref(),
381                requested.sample_physics.as_ref(),
382            )
383            && (self.reflection_domain == requested.reflection_domain
384                || (allow_wavelength_change
385                    && reflection_domain_identity_matches(
386                        self.reflection_domain.as_ref(),
387                        requested.reflection_domain.as_ref(),
388                    )))
389            && (self.reflection_domain.is_some()
390                || (self.reflection_ids == requested.reflection_ids
391                    && self.definition.hkl == requested.definition.hkl
392                    && self.definition.multiplicity == requested.definition.multiplicity))
393    }
394
395    pub(crate) fn with_wavelength(&self, wavelength_angstrom: f64) -> Result<Self, RietveldError> {
396        let mut phase = self.clone();
397        phase.definition.correction_model = match phase.definition.correction_model {
398            IntegratedIntensityCorrectionModel::Neutral => {
399                IntegratedIntensityCorrectionModel::Neutral
400            }
401            IntegratedIntensityCorrectionModel::BraggBrentanoUnpolarizedLp { .. } => {
402                IntegratedIntensityCorrectionModel::BraggBrentanoUnpolarizedLp {
403                    wavelength_angstrom,
404                }
405            }
406            IntegratedIntensityCorrectionModel::BraggBrentanoPolarizedLp {
407                polarization, ..
408            } => IntegratedIntensityCorrectionModel::BraggBrentanoPolarizedLp {
409                wavelength_angstrom,
410                polarization,
411            },
412            IntegratedIntensityCorrectionModel::ConstantWavelengthNeutronLorentz { .. } => {
413                IntegratedIntensityCorrectionModel::ConstantWavelengthNeutronLorentz {
414                    wavelength_angstrom,
415                }
416            }
417        };
418        if let Some(domain) = &self.reflection_domain {
419            phase.reflection_domain = Some(
420                domain
421                    .with_wavelength(wavelength_angstrom)
422                    .map_err(RietveldError::Lattice)?,
423            );
424            phase = phase.regenerate_lattice_at_cell(phase.definition.cell)?.0;
425        }
426        phase.validate()?;
427        Ok(phase)
428    }
429
430    fn correction_wavelength(&self) -> Option<f64> {
431        match self.definition.correction_model {
432            IntegratedIntensityCorrectionModel::Neutral => None,
433            IntegratedIntensityCorrectionModel::BraggBrentanoUnpolarizedLp {
434                wavelength_angstrom,
435            }
436            | IntegratedIntensityCorrectionModel::BraggBrentanoPolarizedLp {
437                wavelength_angstrom,
438                ..
439            }
440            | IntegratedIntensityCorrectionModel::ConstantWavelengthNeutronLorentz {
441                wavelength_angstrom,
442            } => Some(wavelength_angstrom),
443        }
444    }
445}
446
447fn correction_identity_matches(
448    left: IntegratedIntensityCorrectionModel,
449    right: IntegratedIntensityCorrectionModel,
450) -> bool {
451    match (left, right) {
452        (
453            IntegratedIntensityCorrectionModel::BraggBrentanoUnpolarizedLp { .. },
454            IntegratedIntensityCorrectionModel::BraggBrentanoUnpolarizedLp { .. },
455        )
456        | (
457            IntegratedIntensityCorrectionModel::ConstantWavelengthNeutronLorentz { .. },
458            IntegratedIntensityCorrectionModel::ConstantWavelengthNeutronLorentz { .. },
459        ) => true,
460        (
461            IntegratedIntensityCorrectionModel::BraggBrentanoPolarizedLp {
462                polarization: left, ..
463            },
464            IntegratedIntensityCorrectionModel::BraggBrentanoPolarizedLp {
465                polarization: right,
466                ..
467            },
468        ) => left.to_bits() == right.to_bits(),
469        _ => false,
470    }
471}
472
473fn reflection_domain_identity_matches(
474    left: Option<&LatticeReflectionDomain>,
475    right: Option<&LatticeReflectionDomain>,
476) -> bool {
477    match (left, right) {
478        (None, None) => true,
479        (Some(left), Some(right)) => left
480            .with_wavelength(right.wavelength_angstrom())
481            .is_ok_and(|updated| updated == *right),
482        _ => false,
483    }
484}
485
486fn sample_physics_identity_matches(
487    left: Option<&RietveldSamplePhysicsModel>,
488    right: Option<&RietveldSamplePhysicsModel>,
489) -> bool {
490    match (left, right) {
491        (None, None) => true,
492        (Some(left), Some(right)) => sample_physics_model_identity_matches(left, right),
493        _ => false,
494    }
495}
496
497fn sample_physics_model_identity_matches(
498    left: &RietveldSamplePhysicsModel,
499    right: &RietveldSamplePhysicsModel,
500) -> bool {
501    match (left, right) {
502        (
503            RietveldSamplePhysicsModel::IsotropicSize {
504                shape_factor: left, ..
505            },
506            RietveldSamplePhysicsModel::IsotropicSize {
507                shape_factor: right,
508                ..
509            },
510        ) => left.to_bits() == right.to_bits(),
511        (
512            RietveldSamplePhysicsModel::IsotropicMicrostrain { .. },
513            RietveldSamplePhysicsModel::IsotropicMicrostrain { .. },
514        ) => true,
515        (
516            RietveldSamplePhysicsModel::MarchDollase {
517                preferred_axis_hkl: left,
518                ..
519            },
520            RietveldSamplePhysicsModel::MarchDollase {
521                preferred_axis_hkl: right,
522                ..
523            },
524        ) => left
525            .iter()
526            .zip(right)
527            .all(|(left, right)| left.to_bits() == right.to_bits()),
528        (
529            RietveldSamplePhysicsModel::Composite(left),
530            RietveldSamplePhysicsModel::Composite(right),
531        ) => {
532            left.len() == right.len()
533                && left
534                    .iter()
535                    .zip(right)
536                    .all(|(left, right)| sample_physics_model_identity_matches(left, right))
537        }
538        _ => false,
539    }
540}
541
542/// Reflection-topology change attached to an accepted structural step.
543#[derive(Clone, Debug, PartialEq, Eq)]
544pub struct RietveldTopologyChange {
545    /// Stable phase identity.
546    pub phase_id: RecordId,
547    /// Reflection families added at the accepted cell.
548    pub added_reflection_ids: Vec<String>,
549    /// Reflection families removed at the accepted cell.
550    pub removed_reflection_ids: Vec<String>,
551    /// Families preserved by stable identity.
552    pub preserved_reflection_count: usize,
553}
554
555/// Observations, experiment state, and ordered structural phases.
556#[derive(Clone, Debug, PartialEq)]
557pub struct RietveldInput {
558    /// Observed pattern and fixed supplied background.
559    pub pattern: PatternRecord,
560    /// Monochromatic constant-wavelength profile.
561    pub instrument: ConstantWavelengthInstrument,
562    /// Optional fixed spectrum; absent means a monochromatic calculation.
563    pub fixed_spectrum: Option<FixedWavelengthSpectrum>,
564    /// Optional Finger--Cox--Jephcoat axial-divergence geometry.
565    pub axial_geometry: Option<FcjGeometry>,
566    /// Explicit instrument/sample position correction.
567    pub position_correction: MonochromaticPositionCorrection,
568    /// Optional differentiable background added to the pattern's fixed values.
569    pub background: Option<BackgroundModel>,
570    /// Ordered non-empty built-in structural phases.
571    pub phases: Vec<RietveldPhase>,
572}
573
574impl RietveldInput {
575    /// Validate one native monochromatic Rietveld calculation request.
576    ///
577    /// # Errors
578    ///
579    /// Returns [`RietveldError`] for invalid observations, experiment state,
580    /// phase state, or duplicate phase IDs.
581    pub fn new(
582        pattern: PatternRecord,
583        instrument: ConstantWavelengthInstrument,
584        axial_geometry: Option<FcjGeometry>,
585        position_correction: MonochromaticPositionCorrection,
586        phases: Vec<RietveldPhase>,
587    ) -> Result<Self, RietveldError> {
588        let input = Self {
589            pattern,
590            instrument,
591            fixed_spectrum: None,
592            axial_geometry,
593            position_correction,
594            background: None,
595            phases,
596        };
597        input.validate()?;
598        Ok(input)
599    }
600
601    /// Validate one native fixed-wavelength-spectrum Rietveld request.
602    ///
603    /// The instrument wavelength must equal the spectrum's first, reference
604    /// component. Structural lattice/topology refinement remains restricted to
605    /// monochromatic inputs.
606    ///
607    /// # Errors
608    ///
609    /// Returns [`RietveldError`] for invalid observations, spectrum,
610    /// experiment geometry, or structural phases.
611    pub fn new_fixed_spectrum(
612        pattern: PatternRecord,
613        instrument: ConstantWavelengthInstrument,
614        spectrum: FixedWavelengthSpectrum,
615        axial_geometry: Option<FcjGeometry>,
616        position_correction: MonochromaticPositionCorrection,
617        phases: Vec<RietveldPhase>,
618    ) -> Result<Self, RietveldError> {
619        let input = Self {
620            pattern,
621            instrument,
622            fixed_spectrum: Some(spectrum),
623            axial_geometry,
624            position_correction,
625            background: None,
626            phases,
627        };
628        input.validate()?;
629        Ok(input)
630    }
631
632    /// Validate a request with an additional native analytical background.
633    ///
634    /// # Errors
635    ///
636    /// Returns [`RietveldError`] for invalid observations, experiment state,
637    /// background state, or phase state.
638    pub fn new_with_background(
639        pattern: PatternRecord,
640        instrument: ConstantWavelengthInstrument,
641        axial_geometry: Option<FcjGeometry>,
642        position_correction: MonochromaticPositionCorrection,
643        background: BackgroundModel,
644        phases: Vec<RietveldPhase>,
645    ) -> Result<Self, RietveldError> {
646        let mut input = Self::new(
647            pattern,
648            instrument,
649            axial_geometry,
650            position_correction,
651            phases,
652        )?;
653        input.background = Some(background);
654        input.validate()?;
655        Ok(input)
656    }
657
658    /// Validate a fixed-spectrum request with an analytical background.
659    ///
660    /// # Errors
661    ///
662    /// Returns [`RietveldError`] for invalid observations, spectrum,
663    /// background, experiment geometry, or structural phases.
664    pub fn new_fixed_spectrum_with_background(
665        pattern: PatternRecord,
666        instrument: ConstantWavelengthInstrument,
667        spectrum: FixedWavelengthSpectrum,
668        axial_geometry: Option<FcjGeometry>,
669        position_correction: MonochromaticPositionCorrection,
670        background: BackgroundModel,
671        phases: Vec<RietveldPhase>,
672    ) -> Result<Self, RietveldError> {
673        let mut input = Self::new_fixed_spectrum(
674            pattern,
675            instrument,
676            spectrum,
677            axial_geometry,
678            position_correction,
679            phases,
680        )?;
681        input.background = Some(background);
682        input.validate()?;
683        Ok(input)
684    }
685
686    /// Revalidate an adapter-decoded complete calculation request.
687    ///
688    /// # Errors
689    ///
690    /// Returns [`RietveldError`] for invalid observations, experiment state,
691    /// background state, or phase state.
692    pub fn validate(&self) -> Result<(), RietveldError> {
693        self.pattern.validate().map_err(RietveldError::Pattern)?;
694        if self.pattern.observed_y.is_none() {
695            return Err(RietveldError::MissingObservations);
696        }
697        self.instrument
698            .validate()
699            .map_err(|_| RietveldError::InvalidInstrument)?;
700        if self.fixed_spectrum.as_ref().is_some_and(|spectrum| {
701            spectrum.wavelengths_angstrom()[0].to_bits()
702                != self.instrument.wavelength_angstrom.to_bits()
703        }) {
704            return Err(RietveldError::SpectrumReferenceWavelengthMismatch);
705        }
706        if self.axial_geometry.is_some_and(|geometry| {
707            !geometry.sample_over_radius.is_finite()
708                || !geometry.detector_over_radius.is_finite()
709                || geometry.sample_over_radius < 0.0
710                || geometry.detector_over_radius < 0.0
711        }) {
712            return Err(RietveldError::InvalidAxialGeometry);
713        }
714        let correction = self.position_correction;
715        if !correction.zero_shift_deg.is_finite()
716            || (correction.bragg_brentano_mm.is_some()
717                && correction.debye_scherrer_micrometre.is_some())
718            || correction
719                .bragg_brentano_mm
720                .is_some_and(|(displacement, radius)| {
721                    !displacement.is_finite() || !radius.is_finite() || radius <= 0.0
722                })
723            || correction
724                .debye_scherrer_micrometre
725                .is_some_and(|(x, y, radius)| {
726                    !x.is_finite() || !y.is_finite() || !radius.is_finite() || radius <= 0.0
727                })
728        {
729            return Err(RietveldError::InvalidPositionCorrection);
730        }
731        if self.phases.is_empty() {
732            return Err(RietveldError::EmptyPhases);
733        }
734        if let Some(background) = &self.background {
735            background
736                .basis(&self.pattern.x_deg)
737                .map_err(RietveldError::Background)?;
738            background
739                .calculate(&self.pattern.x_deg)
740                .map_err(RietveldError::Background)?;
741        }
742        let mut identities = std::collections::BTreeSet::new();
743        for phase in &self.phases {
744            phase.validate()?;
745            phase.resolved_sample_physics(self.instrument, self.position_correction)?;
746            if self.fixed_spectrum.is_some() && phase.reflection_domain().is_some() {
747                return Err(RietveldError::SpectrumReflectionDomain);
748            }
749            if phase.correction_wavelength().is_some_and(|wavelength| {
750                wavelength.to_bits() != self.instrument.wavelength_angstrom.to_bits()
751            }) {
752                return Err(RietveldError::CorrectionWavelengthMismatch);
753            }
754            if phase.reflection_domain().is_some_and(|domain| {
755                domain.wavelength_angstrom().to_bits()
756                    != self.instrument.wavelength_angstrom.to_bits()
757            }) {
758                return Err(RietveldError::ReflectionWavelengthMismatch);
759            }
760            if !identities.insert(phase.phase_id.clone()) {
761                return Err(RietveldError::DuplicatePhaseId);
762            }
763        }
764        Ok(())
765    }
766}
767
768pub(crate) fn prepare_phase_model(
769    phase: &RietveldPhase,
770    spectrum: Option<&FixedWavelengthSpectrum>,
771    execution: &ExecutionPolicy,
772) -> Result<PreparedStructuralModel, RietveldError> {
773    match spectrum {
774        None => PreparedStructuralPhase::new(phase.definition.clone(), execution.context().clone())
775            .map(PreparedStructuralModel::monochromatic)
776            .map_err(RietveldError::StructuralPattern),
777        Some(spectrum) => PreparedStructuralSpectrum::new(
778            &phase.definition,
779            spectrum.wavelengths_angstrom().to_vec(),
780            spectrum.relative_intensities(),
781            execution.clone(),
782        )
783        .map(PreparedStructuralModel::fixed_spectrum)
784        .map_err(RietveldError::StructuralSpectrum),
785    }
786}
787
788pub(crate) fn resolve_phase_contributions(
789    phase: &RietveldPhase,
790    input: &RietveldInput,
791) -> Result<Vec<OwnedCwContributions>, RietveldError> {
792    let wavelengths = input.fixed_spectrum.as_ref().map_or_else(
793        || vec![input.instrument.wavelength_angstrom],
794        |spectrum| spectrum.wavelengths_angstrom().to_vec(),
795    );
796    wavelengths
797        .into_iter()
798        .map(|wavelength_angstrom| {
799            let mut instrument = input.instrument;
800            instrument.wavelength_angstrom = wavelength_angstrom;
801            phase
802                .resolved_sample_physics(instrument, input.position_correction)
803                .map(|value| value.0)
804        })
805        .collect()
806}
807
808fn reflection_id(hkl: [i32; 3]) -> String {
809    format!("hkl:{},{},{}", hkl[0], hkl[1], hkl[2])
810}
811
812fn transfer_contributions(
813    previous_ids: &[String],
814    current_ids: &[String],
815    previous: &OwnedCwContributions,
816) -> Result<OwnedCwContributions, RietveldError> {
817    let old_count = previous_ids.len();
818    let new_count = current_ids.len();
819    let parameter_count = previous.parameter_count();
820    let derivative_count =
821        parameter_count
822            .checked_mul(new_count)
823            .ok_or(RietveldError::Contributions(
824                CwContributionsError::AllocationOverflow,
825            ))?;
826    let old = previous.arrays();
827    let mut arrays = OwnedCwContributionArrays {
828        gaussian_variance_deg2: vec![0.0; new_count],
829        lorentzian_fwhm_deg: vec![0.0; new_count],
830        intensity_multiplier: vec![1.0; new_count],
831        d_gaussian_variance_d_position: vec![0.0; new_count],
832        d_lorentzian_fwhm_d_position: vec![0.0; new_count],
833        d_intensity_multiplier_d_position: vec![0.0; new_count],
834        d_gaussian_variance_d_parameters: vec![0.0; derivative_count],
835        d_lorentzian_fwhm_d_parameters: vec![0.0; derivative_count],
836        d_intensity_multiplier_d_parameters: vec![0.0; derivative_count],
837    };
838    let previous_index = previous_ids
839        .iter()
840        .enumerate()
841        .map(|(index, id)| (id, index))
842        .collect::<std::collections::BTreeMap<_, _>>();
843    for (new_index, id) in current_ids.iter().enumerate() {
844        let Some(&old_index) = previous_index.get(id) else {
845            continue;
846        };
847        for (target, source) in [
848            (
849                &mut arrays.gaussian_variance_deg2,
850                &old.gaussian_variance_deg2,
851            ),
852            (&mut arrays.lorentzian_fwhm_deg, &old.lorentzian_fwhm_deg),
853            (&mut arrays.intensity_multiplier, &old.intensity_multiplier),
854            (
855                &mut arrays.d_gaussian_variance_d_position,
856                &old.d_gaussian_variance_d_position,
857            ),
858            (
859                &mut arrays.d_lorentzian_fwhm_d_position,
860                &old.d_lorentzian_fwhm_d_position,
861            ),
862            (
863                &mut arrays.d_intensity_multiplier_d_position,
864                &old.d_intensity_multiplier_d_position,
865            ),
866        ] {
867            target[new_index] = source[old_index];
868        }
869        for parameter in 0..parameter_count {
870            let old_offset = parameter * old_count + old_index;
871            let new_offset = parameter * new_count + new_index;
872            arrays.d_gaussian_variance_d_parameters[new_offset] =
873                old.d_gaussian_variance_d_parameters[old_offset];
874            arrays.d_lorentzian_fwhm_d_parameters[new_offset] =
875                old.d_lorentzian_fwhm_d_parameters[old_offset];
876            arrays.d_intensity_multiplier_d_parameters[new_offset] =
877                old.d_intensity_multiplier_d_parameters[old_offset];
878        }
879    }
880    OwnedCwContributions::new(new_count, parameter_count, arrays)
881        .map_err(RietveldError::Contributions)
882}
883
884/// Deterministic calculation controls shared by later native refinement.
885#[derive(Clone, Debug, PartialEq)]
886pub struct RietveldCalculationOptions {
887    /// Exact finite profile support in multiples of FWHM.
888    pub support_fwhm: f64,
889    /// Apply supplied one-sigma uncertainties to residual metrics.
890    pub use_uncertainty: bool,
891    /// Persistent bounded execution policy.
892    pub execution: ExecutionPolicy,
893}
894
895impl RietveldCalculationOptions {
896    /// Validate explicit support and execution controls.
897    ///
898    /// # Errors
899    ///
900    /// Returns [`RietveldError::InvalidOptions`] for invalid support.
901    pub fn new(
902        support_fwhm: f64,
903        use_uncertainty: bool,
904        execution: ExecutionPolicy,
905    ) -> Result<Self, RietveldError> {
906        let options = Self {
907            support_fwhm,
908            use_uncertainty,
909            execution,
910        };
911        options.validate()?;
912        Ok(options)
913    }
914
915    pub(crate) fn validate(&self) -> Result<(), RietveldError> {
916        if !self.support_fwhm.is_finite() || self.support_fwhm <= 0.0 {
917            return Err(RietveldError::InvalidOptions);
918        }
919        Ok(())
920    }
921}
922
923/// One labeled phase contribution and its crystallographic intermediates.
924#[derive(Clone, Debug, PartialEq)]
925pub struct RietveldPhaseCalculation {
926    /// Stable phase ID.
927    pub phase_id: RecordId,
928    /// Human-readable phase name.
929    pub name: String,
930    /// Complete structural/profile result from the native engine.
931    pub result: StructuralPatternResult,
932}
933
934/// Display-ready structural calculation and residual metrics.
935#[derive(Clone, Debug, PartialEq)]
936pub struct RietveldCalculation {
937    /// Sum of structural phase profiles before fixed background.
938    pub profile_y: Vec<f64>,
939    /// Fixed supplied background in sample order.
940    pub background_y: Vec<f64>,
941    /// Complete calculated pattern (`profile_y + background_y`).
942    pub y: Vec<f64>,
943    /// Phase calculations in input order.
944    pub phases: Vec<RietveldPhaseCalculation>,
945    /// Residual arrays and scalar fit metrics.
946    pub metrics: ResidualEvaluation,
947}
948
949/// Calculate a complete built-in monochromatic structural pattern.
950///
951/// # Errors
952///
953/// Returns [`RietveldError`] for invalid phase preparation, calculation, or
954/// residual state.
955pub fn calculate_rietveld_pattern(
956    input: &RietveldInput,
957    options: &RietveldCalculationOptions,
958) -> Result<RietveldCalculation, RietveldError> {
959    input.validate()?;
960    options.validate()?;
961    let models = input
962        .phases
963        .iter()
964        .map(|phase| prepare_phase_model(phase, input.fixed_spectrum.as_ref(), &options.execution))
965        .collect::<Result<Vec<_>, _>>()?;
966    let prepared = PreparedStructuralMultiphase::new(models, options.execution.clone())
967        .map_err(RietveldError::StructuralMultiphase)?;
968    let contributions = input
969        .phases
970        .iter()
971        .map(|phase| resolve_phase_contributions(phase, input))
972        .collect::<Result<Vec<_>, _>>()?;
973    let request = StructuralCalculationRequest {
974        x_deg: input.pattern.x_deg.clone(),
975        instrument: input.instrument,
976        axial_geometry: input.axial_geometry,
977        position_correction: input.position_correction,
978        phase_inputs: contributions
979            .into_iter()
980            .map(|contributions| StructuralModelInput { contributions })
981            .collect(),
982        support: SupportPolicy::FwhmMultiple(options.support_fwhm),
983    };
984    let calculated = prepared
985        .calculate_request(request)
986        .map_err(RietveldError::StructuralMultiphase)?;
987    assemble_rietveld_calculation(input, options, calculated.phases)
988}
989
990pub(crate) fn assemble_rietveld_calculation(
991    input: &RietveldInput,
992    options: &RietveldCalculationOptions,
993    phase_results: Vec<StructuralPatternResult>,
994) -> Result<RietveldCalculation, RietveldError> {
995    if phase_results.len() != input.phases.len() {
996        return Err(RietveldError::CalculationShapeMismatch);
997    }
998    let sample_count = input.pattern.sample_count();
999    let mut profile_y = vec![0.0; sample_count];
1000    for result in &phase_results {
1001        if result.accumulation.sample_count != sample_count
1002            || result.accumulation.y.len() != sample_count
1003        {
1004            return Err(RietveldError::CalculationShapeMismatch);
1005        }
1006        for (combined, value) in profile_y.iter_mut().zip(&result.accumulation.y) {
1007            *combined += value;
1008        }
1009    }
1010    let mut background_y = input.pattern.background_y.clone();
1011    if let Some(background) = &input.background {
1012        for (target, value) in background_y.iter_mut().zip(
1013            background
1014                .calculate(&input.pattern.x_deg)
1015                .map_err(RietveldError::Background)?,
1016        ) {
1017            *target += value;
1018        }
1019    }
1020    let y = profile_y
1021        .iter()
1022        .zip(&background_y)
1023        .map(|(profile, background)| profile + background)
1024        .collect::<Vec<_>>();
1025    if y.iter().any(|value| !value.is_finite()) {
1026        return Err(RietveldError::NonFiniteCalculation);
1027    }
1028    let metrics = evaluate_residuals(
1029        &input.pattern,
1030        &y,
1031        ResidualOptions {
1032            use_uncertainty: options.use_uncertainty,
1033            parameter_count: 0,
1034        },
1035    )
1036    .map_err(RietveldError::Residual)?;
1037    let phases = input
1038        .phases
1039        .iter()
1040        .zip(phase_results)
1041        .map(|(phase, result)| RietveldPhaseCalculation {
1042            phase_id: phase.phase_id.clone(),
1043            name: phase.name.clone(),
1044            result,
1045        })
1046        .collect();
1047    Ok(RietveldCalculation {
1048        profile_y,
1049        background_y,
1050        y,
1051        phases,
1052        metrics,
1053    })
1054}
1055
1056/// Invalid owned native Rietveld calculation state.
1057#[derive(Debug)]
1058pub enum RietveldError {
1059    /// Pattern domain state is invalid.
1060    Pattern(DomainError),
1061    /// Observations are required for residual-bearing Rietveld requests.
1062    MissingObservations,
1063    /// The constant-wavelength instrument is invalid.
1064    InvalidInstrument,
1065    /// Finger--Cox--Jephcoat geometry is invalid.
1066    InvalidAxialGeometry,
1067    /// Monochromatic position correction is invalid.
1068    InvalidPositionCorrection,
1069    /// At least one phase is required.
1070    EmptyPhases,
1071    /// Phase names must be non-empty.
1072    InvalidPhaseName,
1073    /// Phase IDs must be unique.
1074    DuplicatePhaseId,
1075    /// Sample-physics contributions must match the reflection count.
1076    ContributionCountMismatch,
1077    /// Stable reflection IDs are missing, duplicated, or mis-sized.
1078    ReflectionIdentityMismatch,
1079    /// A dynamic reflection list does not match its current cell/domain.
1080    ReflectionTopologyMismatch,
1081    /// A dynamic phase domain must use the experiment wavelength.
1082    ReflectionWavelengthMismatch,
1083    /// An integrated-intensity correction must use the experiment wavelength.
1084    CorrectionWavelengthMismatch,
1085    /// A spectrum's first component must equal the reference instrument wavelength.
1086    SpectrumReferenceWavelengthMismatch,
1087    /// Dynamic lattice/reflection domains are not supported for fixed spectra.
1088    SpectrumReflectionDomain,
1089    /// A fixed-reflection phase cannot regenerate lattice topology.
1090    FixedReflectionTopology,
1091    /// Stable site IDs must match the asymmetric-site count.
1092    SiteIdCountMismatch,
1093    /// Stable site IDs must be unique within a phase.
1094    DuplicateSiteId,
1095    /// One structural phase could not be prepared.
1096    StructuralPattern(StructuralPatternError),
1097    /// Native fixed-spectrum phase preparation failed.
1098    StructuralSpectrum(StructuralSpectrumError),
1099    /// Native multiphase structural calculation failed.
1100    StructuralMultiphase(StructuralMultiphaseError),
1101    /// Guarded reflection generation failed.
1102    Lattice(LatticeError),
1103    /// Stable-ID contribution transfer produced invalid arrays.
1104    Contributions(CwContributionsError),
1105    /// Built-in sample-physics evaluation failed.
1106    SamplePhysics(SamplePhysicsError),
1107    /// Residual evaluation failed.
1108    Residual(ResidualError),
1109    /// Analytical background evaluation failed.
1110    Background(BackgroundError),
1111    /// Calculation controls are invalid.
1112    InvalidOptions,
1113    /// Profile/background composition overflowed or became non-finite.
1114    NonFiniteCalculation,
1115    /// One internal phase result has incompatible sample dimensions.
1116    CalculationShapeMismatch,
1117}
1118
1119impl Display for RietveldError {
1120    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
1121        match self {
1122            Self::Pattern(error) => Display::fmt(error, formatter),
1123            Self::MissingObservations => formatter.write_str("observed_y is required for Rietveld"),
1124            Self::InvalidInstrument => formatter.write_str("Rietveld instrument is invalid"),
1125            Self::InvalidAxialGeometry => formatter.write_str("Rietveld axial geometry is invalid"),
1126            Self::InvalidPositionCorrection => {
1127                formatter.write_str("Rietveld position correction is invalid")
1128            }
1129            Self::EmptyPhases => formatter.write_str("at least one Rietveld phase is required"),
1130            Self::InvalidPhaseName => formatter.write_str("Rietveld phase names must be non-empty"),
1131            Self::DuplicatePhaseId => formatter.write_str("Rietveld phase IDs must be unique"),
1132            Self::ContributionCountMismatch => formatter
1133                .write_str("sample-physics contributions must match the phase reflection count"),
1134            Self::ReflectionIdentityMismatch => {
1135                formatter.write_str("Rietveld reflection identities are invalid")
1136            }
1137            Self::ReflectionTopologyMismatch => formatter
1138                .write_str("Rietveld reflection topology does not match the current cell/domain"),
1139            Self::ReflectionWavelengthMismatch => formatter
1140                .write_str("Rietveld reflection domain wavelength differs from the instrument"),
1141            Self::CorrectionWavelengthMismatch => formatter
1142                .write_str("Rietveld intensity-correction wavelength differs from the instrument"),
1143            Self::SpectrumReferenceWavelengthMismatch => formatter.write_str(
1144                "fixed spectrum reference wavelength differs from the Rietveld instrument",
1145            ),
1146            Self::SpectrumReflectionDomain => formatter
1147                .write_str("fixed-spectrum Rietveld inputs cannot use dynamic reflection domains"),
1148            Self::FixedReflectionTopology => {
1149                formatter.write_str("fixed Rietveld phases cannot regenerate topology")
1150            }
1151            Self::SiteIdCountMismatch => {
1152                formatter.write_str("Rietveld site IDs must match the asymmetric-site count")
1153            }
1154            Self::DuplicateSiteId => {
1155                formatter.write_str("Rietveld site IDs must be unique within a phase")
1156            }
1157            Self::StructuralPattern(error) => Display::fmt(error, formatter),
1158            Self::StructuralSpectrum(error) => Display::fmt(error, formatter),
1159            Self::StructuralMultiphase(error) => Display::fmt(error, formatter),
1160            Self::Lattice(error) => Display::fmt(error, formatter),
1161            Self::Contributions(error) => Display::fmt(error, formatter),
1162            Self::SamplePhysics(error) => Display::fmt(error, formatter),
1163            Self::Residual(error) => Display::fmt(error, formatter),
1164            Self::Background(error) => Display::fmt(error, formatter),
1165            Self::InvalidOptions => formatter.write_str("Rietveld calculation options are invalid"),
1166            Self::NonFiniteCalculation => {
1167                formatter.write_str("Rietveld calculated pattern is non-finite")
1168            }
1169            Self::CalculationShapeMismatch => {
1170                formatter.write_str("Rietveld phase calculation shape mismatch")
1171            }
1172        }
1173    }
1174}
1175
1176impl Error for RietveldError {
1177    fn source(&self) -> Option<&(dyn Error + 'static)> {
1178        match self {
1179            Self::Pattern(error) => Some(error),
1180            Self::StructuralPattern(error) => Some(error),
1181            Self::StructuralSpectrum(error) => Some(error),
1182            Self::StructuralMultiphase(error) => Some(error),
1183            Self::Lattice(error) => Some(error),
1184            Self::Contributions(error) => Some(error),
1185            Self::SamplePhysics(error) => Some(error),
1186            Self::Residual(error) => Some(error),
1187            Self::Background(error) => Some(error),
1188            Self::MissingObservations
1189            | Self::InvalidInstrument
1190            | Self::InvalidAxialGeometry
1191            | Self::InvalidPositionCorrection
1192            | Self::EmptyPhases
1193            | Self::InvalidPhaseName
1194            | Self::DuplicatePhaseId
1195            | Self::ContributionCountMismatch
1196            | Self::ReflectionIdentityMismatch
1197            | Self::ReflectionTopologyMismatch
1198            | Self::ReflectionWavelengthMismatch
1199            | Self::CorrectionWavelengthMismatch
1200            | Self::SpectrumReferenceWavelengthMismatch
1201            | Self::SpectrumReflectionDomain
1202            | Self::FixedReflectionTopology
1203            | Self::SiteIdCountMismatch
1204            | Self::DuplicateSiteId
1205            | Self::InvalidOptions
1206            | Self::NonFiniteCalculation
1207            | Self::CalculationShapeMismatch => None,
1208        }
1209    }
1210}