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