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        ) => true,
539        (
540            RietveldSamplePhysicsModel::MarchDollase {
541                preferred_axis_hkl: left,
542                ..
543            },
544            RietveldSamplePhysicsModel::MarchDollase {
545                preferred_axis_hkl: right,
546                ..
547            },
548        ) => left
549            .iter()
550            .zip(right)
551            .all(|(left, right)| left.to_bits() == right.to_bits()),
552        (
553            RietveldSamplePhysicsModel::Composite(left),
554            RietveldSamplePhysicsModel::Composite(right),
555        ) => {
556            left.len() == right.len()
557                && left
558                    .iter()
559                    .zip(right)
560                    .all(|(left, right)| sample_physics_model_identity_matches(left, right))
561        }
562        _ => false,
563    }
564}
565
566/// Reflection-topology change attached to an accepted structural step.
567#[derive(Clone, Debug, PartialEq, Eq)]
568pub struct RietveldTopologyChange {
569    /// Stable phase identity.
570    pub phase_id: RecordId,
571    /// Reflection families added at the accepted cell.
572    pub added_reflection_ids: Vec<String>,
573    /// Reflection families removed at the accepted cell.
574    pub removed_reflection_ids: Vec<String>,
575    /// Families preserved by stable identity.
576    pub preserved_reflection_count: usize,
577}
578
579/// Observations, experiment state, and ordered structural phases.
580#[derive(Clone, Debug, PartialEq)]
581pub struct RietveldInput {
582    /// Observed pattern and fixed supplied background.
583    pub pattern: PatternRecord,
584    /// Monochromatic constant-wavelength profile.
585    pub instrument: ConstantWavelengthInstrument,
586    /// Optional fixed spectrum; absent means a monochromatic calculation.
587    pub fixed_spectrum: Option<FixedWavelengthSpectrum>,
588    /// Optional Finger--Cox--Jephcoat axial-divergence geometry.
589    pub axial_geometry: Option<FcjGeometry>,
590    /// Explicit instrument/sample position correction.
591    pub position_correction: MonochromaticPositionCorrection,
592    /// Optional differentiable background added to the pattern's fixed values.
593    pub background: Option<BackgroundModel>,
594    /// Ordered non-empty built-in structural phases.
595    pub phases: Vec<RietveldPhase>,
596}
597
598impl RietveldInput {
599    /// Validate one native monochromatic Rietveld calculation request.
600    ///
601    /// # Errors
602    ///
603    /// Returns [`RietveldError`] for invalid observations, experiment state,
604    /// phase state, or duplicate phase IDs.
605    pub fn new(
606        pattern: PatternRecord,
607        instrument: ConstantWavelengthInstrument,
608        axial_geometry: Option<FcjGeometry>,
609        position_correction: MonochromaticPositionCorrection,
610        phases: Vec<RietveldPhase>,
611    ) -> Result<Self, RietveldError> {
612        let input = Self {
613            pattern,
614            instrument,
615            fixed_spectrum: None,
616            axial_geometry,
617            position_correction,
618            background: None,
619            phases,
620        };
621        input.validate()?;
622        Ok(input)
623    }
624
625    /// Validate one native fixed-wavelength-spectrum Rietveld request.
626    ///
627    /// The instrument wavelength must equal the spectrum's first, reference
628    /// component. Structural lattice/topology refinement remains restricted to
629    /// monochromatic inputs.
630    ///
631    /// # Errors
632    ///
633    /// Returns [`RietveldError`] for invalid observations, spectrum,
634    /// experiment geometry, or structural phases.
635    pub fn new_fixed_spectrum(
636        pattern: PatternRecord,
637        instrument: ConstantWavelengthInstrument,
638        spectrum: FixedWavelengthSpectrum,
639        axial_geometry: Option<FcjGeometry>,
640        position_correction: MonochromaticPositionCorrection,
641        phases: Vec<RietveldPhase>,
642    ) -> Result<Self, RietveldError> {
643        let input = Self {
644            pattern,
645            instrument,
646            fixed_spectrum: Some(spectrum),
647            axial_geometry,
648            position_correction,
649            background: None,
650            phases,
651        };
652        input.validate()?;
653        Ok(input)
654    }
655
656    /// Validate a request with an additional native analytical background.
657    ///
658    /// # Errors
659    ///
660    /// Returns [`RietveldError`] for invalid observations, experiment state,
661    /// background state, or phase state.
662    pub fn new_with_background(
663        pattern: PatternRecord,
664        instrument: ConstantWavelengthInstrument,
665        axial_geometry: Option<FcjGeometry>,
666        position_correction: MonochromaticPositionCorrection,
667        background: BackgroundModel,
668        phases: Vec<RietveldPhase>,
669    ) -> Result<Self, RietveldError> {
670        let mut input = Self::new(
671            pattern,
672            instrument,
673            axial_geometry,
674            position_correction,
675            phases,
676        )?;
677        input.background = Some(background);
678        input.validate()?;
679        Ok(input)
680    }
681
682    /// Validate a fixed-spectrum request with an analytical background.
683    ///
684    /// # Errors
685    ///
686    /// Returns [`RietveldError`] for invalid observations, spectrum,
687    /// background, experiment geometry, or structural phases.
688    pub fn new_fixed_spectrum_with_background(
689        pattern: PatternRecord,
690        instrument: ConstantWavelengthInstrument,
691        spectrum: FixedWavelengthSpectrum,
692        axial_geometry: Option<FcjGeometry>,
693        position_correction: MonochromaticPositionCorrection,
694        background: BackgroundModel,
695        phases: Vec<RietveldPhase>,
696    ) -> Result<Self, RietveldError> {
697        let mut input = Self::new_fixed_spectrum(
698            pattern,
699            instrument,
700            spectrum,
701            axial_geometry,
702            position_correction,
703            phases,
704        )?;
705        input.background = Some(background);
706        input.validate()?;
707        Ok(input)
708    }
709
710    /// Revalidate an adapter-decoded complete calculation request.
711    ///
712    /// # Errors
713    ///
714    /// Returns [`RietveldError`] for invalid observations, experiment state,
715    /// background state, or phase state.
716    pub fn validate(&self) -> Result<(), RietveldError> {
717        self.pattern.validate().map_err(RietveldError::Pattern)?;
718        if self.pattern.observed_y.is_none() {
719            return Err(RietveldError::MissingObservations);
720        }
721        self.instrument
722            .validate()
723            .map_err(|_| RietveldError::InvalidInstrument)?;
724        if self.fixed_spectrum.as_ref().is_some_and(|spectrum| {
725            spectrum.wavelengths_angstrom()[0].to_bits()
726                != self.instrument.wavelength_angstrom.to_bits()
727        }) {
728            return Err(RietveldError::SpectrumReferenceWavelengthMismatch);
729        }
730        if self.axial_geometry.is_some_and(|geometry| {
731            !geometry.sample_over_radius.is_finite()
732                || !geometry.detector_over_radius.is_finite()
733                || geometry.sample_over_radius < 0.0
734                || geometry.detector_over_radius < 0.0
735        }) {
736            return Err(RietveldError::InvalidAxialGeometry);
737        }
738        let correction = self.position_correction;
739        if !correction.zero_shift_deg.is_finite()
740            || (correction.bragg_brentano_mm.is_some()
741                && correction.debye_scherrer_micrometre.is_some())
742            || correction
743                .bragg_brentano_mm
744                .is_some_and(|(displacement, radius)| {
745                    !displacement.is_finite() || !radius.is_finite() || radius <= 0.0
746                })
747            || correction
748                .debye_scherrer_micrometre
749                .is_some_and(|(x, y, radius)| {
750                    !x.is_finite() || !y.is_finite() || !radius.is_finite() || radius <= 0.0
751                })
752        {
753            return Err(RietveldError::InvalidPositionCorrection);
754        }
755        if self.phases.is_empty() {
756            return Err(RietveldError::EmptyPhases);
757        }
758        if let Some(background) = &self.background {
759            background
760                .basis(&self.pattern.x_deg)
761                .map_err(RietveldError::Background)?;
762            background
763                .calculate(&self.pattern.x_deg)
764                .map_err(RietveldError::Background)?;
765        }
766        let mut identities = std::collections::BTreeSet::new();
767        for phase in &self.phases {
768            phase.validate()?;
769            phase.resolved_sample_physics(self.instrument, self.position_correction)?;
770            if self.fixed_spectrum.is_some() && phase.reflection_domain().is_some() {
771                return Err(RietveldError::SpectrumReflectionDomain);
772            }
773            if phase.correction_wavelength().is_some_and(|wavelength| {
774                wavelength.to_bits() != self.instrument.wavelength_angstrom.to_bits()
775            }) {
776                return Err(RietveldError::CorrectionWavelengthMismatch);
777            }
778            if phase.reflection_domain().is_some_and(|domain| {
779                domain.wavelength_angstrom().to_bits()
780                    != self.instrument.wavelength_angstrom.to_bits()
781            }) {
782                return Err(RietveldError::ReflectionWavelengthMismatch);
783            }
784            if !identities.insert(phase.phase_id.clone()) {
785                return Err(RietveldError::DuplicatePhaseId);
786            }
787        }
788        Ok(())
789    }
790}
791
792pub(crate) fn prepare_phase_model(
793    phase: &RietveldPhase,
794    spectrum: Option<&FixedWavelengthSpectrum>,
795    execution: &ExecutionPolicy,
796) -> Result<PreparedStructuralModel, RietveldError> {
797    match spectrum {
798        None => PreparedStructuralPhase::new(phase.definition.clone(), execution.context().clone())
799            .map(PreparedStructuralModel::monochromatic)
800            .map_err(RietveldError::StructuralPattern),
801        Some(spectrum) => PreparedStructuralSpectrum::new(
802            &phase.definition,
803            spectrum.wavelengths_angstrom().to_vec(),
804            spectrum.relative_intensities(),
805            execution.clone(),
806        )
807        .map(PreparedStructuralModel::fixed_spectrum)
808        .map_err(RietveldError::StructuralSpectrum),
809    }
810}
811
812pub(crate) fn resolve_phase_contributions(
813    phase: &RietveldPhase,
814    input: &RietveldInput,
815) -> Result<Vec<OwnedCwContributions>, RietveldError> {
816    let wavelengths = input.fixed_spectrum.as_ref().map_or_else(
817        || vec![input.instrument.wavelength_angstrom],
818        |spectrum| spectrum.wavelengths_angstrom().to_vec(),
819    );
820    wavelengths
821        .into_iter()
822        .map(|wavelength_angstrom| {
823            let mut instrument = input.instrument;
824            instrument.wavelength_angstrom = wavelength_angstrom;
825            phase
826                .resolved_sample_physics(instrument, input.position_correction)
827                .map(|value| value.0)
828        })
829        .collect()
830}
831
832fn reflection_id(hkl: [i32; 3]) -> String {
833    format!("hkl:{},{},{}", hkl[0], hkl[1], hkl[2])
834}
835
836fn transfer_contributions(
837    previous_ids: &[String],
838    current_ids: &[String],
839    previous: &OwnedCwContributions,
840) -> Result<OwnedCwContributions, RietveldError> {
841    let old_count = previous_ids.len();
842    let new_count = current_ids.len();
843    let parameter_count = previous.parameter_count();
844    let derivative_count =
845        parameter_count
846            .checked_mul(new_count)
847            .ok_or(RietveldError::Contributions(
848                CwContributionsError::AllocationOverflow,
849            ))?;
850    let old = previous.arrays();
851    let mut arrays = OwnedCwContributionArrays {
852        gaussian_variance_deg2: vec![0.0; new_count],
853        lorentzian_fwhm_deg: vec![0.0; new_count],
854        intensity_multiplier: vec![1.0; new_count],
855        d_gaussian_variance_d_position: vec![0.0; new_count],
856        d_lorentzian_fwhm_d_position: vec![0.0; new_count],
857        d_intensity_multiplier_d_position: vec![0.0; new_count],
858        d_gaussian_variance_d_parameters: vec![0.0; derivative_count],
859        d_lorentzian_fwhm_d_parameters: vec![0.0; derivative_count],
860        d_intensity_multiplier_d_parameters: vec![0.0; derivative_count],
861    };
862    let previous_index = previous_ids
863        .iter()
864        .enumerate()
865        .map(|(index, id)| (id, index))
866        .collect::<std::collections::BTreeMap<_, _>>();
867    for (new_index, id) in current_ids.iter().enumerate() {
868        let Some(&old_index) = previous_index.get(id) else {
869            continue;
870        };
871        for (target, source) in [
872            (
873                &mut arrays.gaussian_variance_deg2,
874                &old.gaussian_variance_deg2,
875            ),
876            (&mut arrays.lorentzian_fwhm_deg, &old.lorentzian_fwhm_deg),
877            (&mut arrays.intensity_multiplier, &old.intensity_multiplier),
878            (
879                &mut arrays.d_gaussian_variance_d_position,
880                &old.d_gaussian_variance_d_position,
881            ),
882            (
883                &mut arrays.d_lorentzian_fwhm_d_position,
884                &old.d_lorentzian_fwhm_d_position,
885            ),
886            (
887                &mut arrays.d_intensity_multiplier_d_position,
888                &old.d_intensity_multiplier_d_position,
889            ),
890        ] {
891            target[new_index] = source[old_index];
892        }
893        for parameter in 0..parameter_count {
894            let old_offset = parameter * old_count + old_index;
895            let new_offset = parameter * new_count + new_index;
896            arrays.d_gaussian_variance_d_parameters[new_offset] =
897                old.d_gaussian_variance_d_parameters[old_offset];
898            arrays.d_lorentzian_fwhm_d_parameters[new_offset] =
899                old.d_lorentzian_fwhm_d_parameters[old_offset];
900            arrays.d_intensity_multiplier_d_parameters[new_offset] =
901                old.d_intensity_multiplier_d_parameters[old_offset];
902        }
903    }
904    OwnedCwContributions::new(new_count, parameter_count, arrays)
905        .map_err(RietveldError::Contributions)
906}
907
908/// Deterministic calculation controls shared by later native refinement.
909#[derive(Clone, Debug, PartialEq)]
910pub struct RietveldCalculationOptions {
911    /// Exact finite profile support in multiples of FWHM.
912    pub support_fwhm: f64,
913    /// Apply supplied one-sigma uncertainties to residual metrics.
914    pub use_uncertainty: bool,
915    /// Persistent bounded execution policy.
916    pub execution: ExecutionPolicy,
917}
918
919impl RietveldCalculationOptions {
920    /// Validate explicit support and execution controls.
921    ///
922    /// # Errors
923    ///
924    /// Returns [`RietveldError::InvalidOptions`] for invalid support.
925    pub fn new(
926        support_fwhm: f64,
927        use_uncertainty: bool,
928        execution: ExecutionPolicy,
929    ) -> Result<Self, RietveldError> {
930        let options = Self {
931            support_fwhm,
932            use_uncertainty,
933            execution,
934        };
935        options.validate()?;
936        Ok(options)
937    }
938
939    pub(crate) fn validate(&self) -> Result<(), RietveldError> {
940        if !self.support_fwhm.is_finite() || self.support_fwhm <= 0.0 {
941            return Err(RietveldError::InvalidOptions);
942        }
943        Ok(())
944    }
945}
946
947/// One labeled phase contribution and its crystallographic intermediates.
948#[derive(Clone, Debug, PartialEq)]
949pub struct RietveldPhaseCalculation {
950    /// Stable phase ID.
951    pub phase_id: RecordId,
952    /// Human-readable phase name.
953    pub name: String,
954    /// Complete structural/profile result from the native engine.
955    pub result: StructuralPatternResult,
956}
957
958/// Display-ready structural calculation and residual metrics.
959#[derive(Clone, Debug, PartialEq)]
960pub struct RietveldCalculation {
961    /// Sum of structural phase profiles before fixed background.
962    pub profile_y: Vec<f64>,
963    /// Fixed supplied background in sample order.
964    pub background_y: Vec<f64>,
965    /// Complete calculated pattern (`profile_y + background_y`).
966    pub y: Vec<f64>,
967    /// Phase calculations in input order.
968    pub phases: Vec<RietveldPhaseCalculation>,
969    /// Residual arrays and scalar fit metrics.
970    pub metrics: ResidualEvaluation,
971}
972
973/// Calculate a complete built-in monochromatic structural pattern.
974///
975/// # Errors
976///
977/// Returns [`RietveldError`] for invalid phase preparation, calculation, or
978/// residual state.
979pub fn calculate_rietveld_pattern(
980    input: &RietveldInput,
981    options: &RietveldCalculationOptions,
982) -> Result<RietveldCalculation, RietveldError> {
983    input.validate()?;
984    options.validate()?;
985    let models = input
986        .phases
987        .iter()
988        .map(|phase| prepare_phase_model(phase, input.fixed_spectrum.as_ref(), &options.execution))
989        .collect::<Result<Vec<_>, _>>()?;
990    let prepared = PreparedStructuralMultiphase::new(models, options.execution.clone())
991        .map_err(RietveldError::StructuralMultiphase)?;
992    let contributions = input
993        .phases
994        .iter()
995        .map(|phase| resolve_phase_contributions(phase, input))
996        .collect::<Result<Vec<_>, _>>()?;
997    let request = StructuralCalculationRequest {
998        x_deg: input.pattern.x_deg.clone(),
999        instrument: input.instrument,
1000        axial_geometry: input.axial_geometry,
1001        position_correction: input.position_correction,
1002        phase_inputs: contributions
1003            .into_iter()
1004            .map(|contributions| StructuralModelInput { contributions })
1005            .collect(),
1006        support: SupportPolicy::FwhmMultiple(options.support_fwhm),
1007    };
1008    let calculated = prepared
1009        .calculate_request(request)
1010        .map_err(RietveldError::StructuralMultiphase)?;
1011    assemble_rietveld_calculation(input, options, calculated.phases)
1012}
1013
1014pub(crate) fn assemble_rietveld_calculation(
1015    input: &RietveldInput,
1016    options: &RietveldCalculationOptions,
1017    phase_results: Vec<StructuralPatternResult>,
1018) -> Result<RietveldCalculation, RietveldError> {
1019    if phase_results.len() != input.phases.len() {
1020        return Err(RietveldError::CalculationShapeMismatch);
1021    }
1022    let sample_count = input.pattern.sample_count();
1023    let mut profile_y = vec![0.0; sample_count];
1024    for result in &phase_results {
1025        if result.accumulation.sample_count != sample_count
1026            || result.accumulation.y.len() != sample_count
1027        {
1028            return Err(RietveldError::CalculationShapeMismatch);
1029        }
1030        for (combined, value) in profile_y.iter_mut().zip(&result.accumulation.y) {
1031            *combined += value;
1032        }
1033    }
1034    let mut background_y = input.pattern.background_y.clone();
1035    if let Some(background) = &input.background {
1036        for (target, value) in background_y.iter_mut().zip(
1037            background
1038                .calculate(&input.pattern.x_deg)
1039                .map_err(RietveldError::Background)?,
1040        ) {
1041            *target += value;
1042        }
1043    }
1044    let y = profile_y
1045        .iter()
1046        .zip(&background_y)
1047        .map(|(profile, background)| profile + background)
1048        .collect::<Vec<_>>();
1049    if y.iter().any(|value| !value.is_finite()) {
1050        return Err(RietveldError::NonFiniteCalculation);
1051    }
1052    let metrics = evaluate_residuals(
1053        &input.pattern,
1054        &y,
1055        ResidualOptions {
1056            use_uncertainty: options.use_uncertainty,
1057            parameter_count: 0,
1058        },
1059    )
1060    .map_err(RietveldError::Residual)?;
1061    let phases = input
1062        .phases
1063        .iter()
1064        .zip(phase_results)
1065        .map(|(phase, result)| RietveldPhaseCalculation {
1066            phase_id: phase.phase_id.clone(),
1067            name: phase.name.clone(),
1068            result,
1069        })
1070        .collect();
1071    Ok(RietveldCalculation {
1072        profile_y,
1073        background_y,
1074        y,
1075        phases,
1076        metrics,
1077    })
1078}
1079
1080/// Invalid owned native Rietveld calculation state.
1081#[derive(Debug)]
1082pub enum RietveldError {
1083    /// Pattern domain state is invalid.
1084    Pattern(DomainError),
1085    /// Observations are required for residual-bearing Rietveld requests.
1086    MissingObservations,
1087    /// The constant-wavelength instrument is invalid.
1088    InvalidInstrument,
1089    /// Finger--Cox--Jephcoat geometry is invalid.
1090    InvalidAxialGeometry,
1091    /// Monochromatic position correction is invalid.
1092    InvalidPositionCorrection,
1093    /// At least one phase is required.
1094    EmptyPhases,
1095    /// Phase names must be non-empty.
1096    InvalidPhaseName,
1097    /// Phase IDs must be unique.
1098    DuplicatePhaseId,
1099    /// Sample-physics contributions must match the reflection count.
1100    ContributionCountMismatch,
1101    /// Stable reflection IDs are missing, duplicated, or mis-sized.
1102    ReflectionIdentityMismatch,
1103    /// A dynamic reflection list does not match its current cell/domain.
1104    ReflectionTopologyMismatch,
1105    /// A dynamic phase domain must use the experiment wavelength.
1106    ReflectionWavelengthMismatch,
1107    /// An integrated-intensity correction must use the experiment wavelength.
1108    CorrectionWavelengthMismatch,
1109    /// A spectrum's first component must equal the reference instrument wavelength.
1110    SpectrumReferenceWavelengthMismatch,
1111    /// Dynamic lattice/reflection domains are not supported for fixed spectra.
1112    SpectrumReflectionDomain,
1113    /// A fixed-reflection phase cannot regenerate lattice topology.
1114    FixedReflectionTopology,
1115    /// Stable site IDs must match the asymmetric-site count.
1116    SiteIdCountMismatch,
1117    /// Stable site IDs must be unique within a phase.
1118    DuplicateSiteId,
1119    /// One structural phase could not be prepared.
1120    StructuralPattern(StructuralPatternError),
1121    /// Native fixed-spectrum phase preparation failed.
1122    StructuralSpectrum(StructuralSpectrumError),
1123    /// Native multiphase structural calculation failed.
1124    StructuralMultiphase(StructuralMultiphaseError),
1125    /// Guarded reflection generation failed.
1126    Lattice(LatticeError),
1127    /// Stable-ID contribution transfer produced invalid arrays.
1128    Contributions(CwContributionsError),
1129    /// Built-in sample-physics evaluation failed.
1130    SamplePhysics(SamplePhysicsError),
1131    /// Residual evaluation failed.
1132    Residual(ResidualError),
1133    /// Analytical background evaluation failed.
1134    Background(BackgroundError),
1135    /// Calculation controls are invalid.
1136    InvalidOptions,
1137    /// Profile/background composition overflowed or became non-finite.
1138    NonFiniteCalculation,
1139    /// One internal phase result has incompatible sample dimensions.
1140    CalculationShapeMismatch,
1141}
1142
1143impl Display for RietveldError {
1144    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
1145        match self {
1146            Self::Pattern(error) => Display::fmt(error, formatter),
1147            Self::MissingObservations => formatter.write_str("observed_y is required for Rietveld"),
1148            Self::InvalidInstrument => formatter.write_str("Rietveld instrument is invalid"),
1149            Self::InvalidAxialGeometry => formatter.write_str("Rietveld axial geometry is invalid"),
1150            Self::InvalidPositionCorrection => {
1151                formatter.write_str("Rietveld position correction is invalid")
1152            }
1153            Self::EmptyPhases => formatter.write_str("at least one Rietveld phase is required"),
1154            Self::InvalidPhaseName => formatter.write_str("Rietveld phase names must be non-empty"),
1155            Self::DuplicatePhaseId => formatter.write_str("Rietveld phase IDs must be unique"),
1156            Self::ContributionCountMismatch => formatter
1157                .write_str("sample-physics contributions must match the phase reflection count"),
1158            Self::ReflectionIdentityMismatch => {
1159                formatter.write_str("Rietveld reflection identities are invalid")
1160            }
1161            Self::ReflectionTopologyMismatch => formatter
1162                .write_str("Rietveld reflection topology does not match the current cell/domain"),
1163            Self::ReflectionWavelengthMismatch => formatter
1164                .write_str("Rietveld reflection domain wavelength differs from the instrument"),
1165            Self::CorrectionWavelengthMismatch => formatter
1166                .write_str("Rietveld intensity-correction wavelength differs from the instrument"),
1167            Self::SpectrumReferenceWavelengthMismatch => formatter.write_str(
1168                "fixed spectrum reference wavelength differs from the Rietveld instrument",
1169            ),
1170            Self::SpectrumReflectionDomain => formatter
1171                .write_str("fixed-spectrum Rietveld inputs cannot use dynamic reflection domains"),
1172            Self::FixedReflectionTopology => {
1173                formatter.write_str("fixed Rietveld phases cannot regenerate topology")
1174            }
1175            Self::SiteIdCountMismatch => {
1176                formatter.write_str("Rietveld site IDs must match the asymmetric-site count")
1177            }
1178            Self::DuplicateSiteId => {
1179                formatter.write_str("Rietveld site IDs must be unique within a phase")
1180            }
1181            Self::StructuralPattern(error) => Display::fmt(error, formatter),
1182            Self::StructuralSpectrum(error) => Display::fmt(error, formatter),
1183            Self::StructuralMultiphase(error) => Display::fmt(error, formatter),
1184            Self::Lattice(error) => Display::fmt(error, formatter),
1185            Self::Contributions(error) => Display::fmt(error, formatter),
1186            Self::SamplePhysics(error) => Display::fmt(error, formatter),
1187            Self::Residual(error) => Display::fmt(error, formatter),
1188            Self::Background(error) => Display::fmt(error, formatter),
1189            Self::InvalidOptions => formatter.write_str("Rietveld calculation options are invalid"),
1190            Self::NonFiniteCalculation => {
1191                formatter.write_str("Rietveld calculated pattern is non-finite")
1192            }
1193            Self::CalculationShapeMismatch => {
1194                formatter.write_str("Rietveld phase calculation shape mismatch")
1195            }
1196        }
1197    }
1198}
1199
1200impl Error for RietveldError {
1201    fn source(&self) -> Option<&(dyn Error + 'static)> {
1202        match self {
1203            Self::Pattern(error) => Some(error),
1204            Self::StructuralPattern(error) => Some(error),
1205            Self::StructuralSpectrum(error) => Some(error),
1206            Self::StructuralMultiphase(error) => Some(error),
1207            Self::Lattice(error) => Some(error),
1208            Self::Contributions(error) => Some(error),
1209            Self::SamplePhysics(error) => Some(error),
1210            Self::Residual(error) => Some(error),
1211            Self::Background(error) => Some(error),
1212            Self::MissingObservations
1213            | Self::InvalidInstrument
1214            | Self::InvalidAxialGeometry
1215            | Self::InvalidPositionCorrection
1216            | Self::EmptyPhases
1217            | Self::InvalidPhaseName
1218            | Self::DuplicatePhaseId
1219            | Self::ContributionCountMismatch
1220            | Self::ReflectionIdentityMismatch
1221            | Self::ReflectionTopologyMismatch
1222            | Self::ReflectionWavelengthMismatch
1223            | Self::CorrectionWavelengthMismatch
1224            | Self::SpectrumReferenceWavelengthMismatch
1225            | Self::SpectrumReflectionDomain
1226            | Self::FixedReflectionTopology
1227            | Self::SiteIdCountMismatch
1228            | Self::DuplicateSiteId
1229            | Self::InvalidOptions
1230            | Self::NonFiniteCalculation
1231            | Self::CalculationShapeMismatch => None,
1232        }
1233    }
1234}