Skip to main content

phasesmith_model/
lib.rs

1//! Application-neutral owned records shared by scripting and native hosts.
2//!
3//! These types describe validated live domain state. Persistence wire records,
4//! migrations, `PyO3` objects, and Tauri command payloads deliberately live in
5//! adapter crates instead of being derived directly from this model.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::error::Error;
9use std::fmt::{Display, Formatter};
10
11use phasesmith_core::{
12    ConstantWavelengthInstrument, FcjGeometry, WavelengthComponentsError, WavelengthComponentsView,
13};
14use phasesmith_engine::{
15    MonochromaticPositionCorrection, StructuralPatternError, StructuralPhaseDefinition,
16};
17
18/// Stable project-owned identifier used for projects, histograms, and phases.
19#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
20pub struct RecordId(String);
21
22impl RecordId {
23    /// Validate and own an adapter-supplied stable identifier.
24    ///
25    /// # Errors
26    ///
27    /// Returns [`DomainError::InvalidId`] when the value is empty, too long, or
28    /// contains characters outside ASCII letters, digits, `.`, `_`, and `-`.
29    pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
30        let value = value.into();
31        if value.is_empty()
32            || value.len() > 128
33            || !value
34                .bytes()
35                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
36        {
37            return Err(DomainError::InvalidId { value });
38        }
39        Ok(Self(value))
40    }
41
42    /// Borrow the stable textual representation.
43    #[must_use]
44    pub fn as_str(&self) -> &str {
45        &self.0
46    }
47}
48
49impl Display for RecordId {
50    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
51        formatter.write_str(&self.0)
52    }
53}
54
55/// X-ray or neutron radiation selection.
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum RadiationProbe {
58    /// Electromagnetic X-ray radiation.
59    Xray,
60    /// Constant-wavelength nuclear-neutron radiation.
61    Neutron,
62}
63
64/// Validated fixed radiation components in input order.
65#[derive(Clone, Debug, PartialEq)]
66pub struct FixedWavelengthSpectrum {
67    wavelengths_angstrom: Vec<f64>,
68    relative_intensities: Vec<f64>,
69}
70
71impl FixedWavelengthSpectrum {
72    /// Validate and own fixed wavelength components.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`DomainError::Radiation`] for invalid wavelengths or weights.
77    pub fn new(
78        wavelengths_angstrom: Vec<f64>,
79        relative_intensities: Vec<f64>,
80    ) -> Result<Self, DomainError> {
81        WavelengthComponentsView::new(&wavelengths_angstrom, &relative_intensities)
82            .map_err(DomainError::Radiation)?;
83        Ok(Self {
84            wavelengths_angstrom,
85            relative_intensities,
86        })
87    }
88
89    /// Borrow component wavelengths in ångströms.
90    #[must_use]
91    pub fn wavelengths_angstrom(&self) -> &[f64] {
92        &self.wavelengths_angstrom
93    }
94
95    /// Borrow component intensities relative to the first component.
96    #[must_use]
97    pub fn relative_intensities(&self) -> &[f64] {
98        &self.relative_intensities
99    }
100}
101
102/// Radiation attached to one constant-wavelength histogram.
103#[derive(Clone, Debug, PartialEq)]
104pub enum RadiationDefinition {
105    /// One monochromatic wavelength.
106    Monochromatic {
107        /// Probe family.
108        probe: RadiationProbe,
109        /// Wavelength in ångströms.
110        wavelength_angstrom: f64,
111    },
112    /// Fixed discrete wavelength spectrum.
113    FixedSpectrum {
114        /// Probe family.
115        probe: RadiationProbe,
116        /// Validated spectrum components.
117        spectrum: FixedWavelengthSpectrum,
118    },
119}
120
121impl RadiationDefinition {
122    /// Return the probe family.
123    #[must_use]
124    pub const fn probe(&self) -> RadiationProbe {
125        match self {
126            Self::Monochromatic { probe, .. } | Self::FixedSpectrum { probe, .. } => *probe,
127        }
128    }
129
130    /// Return the reference wavelength used by the instrument record.
131    #[must_use]
132    pub fn reference_wavelength_angstrom(&self) -> f64 {
133        match self {
134            Self::Monochromatic {
135                wavelength_angstrom,
136                ..
137            } => *wavelength_angstrom,
138            Self::FixedSpectrum { spectrum, .. } => spectrum.wavelengths_angstrom[0],
139        }
140    }
141}
142
143/// Owned observed powder pattern for one histogram.
144#[derive(Clone, Debug, PartialEq)]
145pub struct PatternRecord {
146    /// Strictly increasing coordinate grid in degrees `2theta`.
147    pub x_deg: Vec<f64>,
148    /// Optional observed intensity values.
149    pub observed_y: Option<Vec<f64>>,
150    /// Optional positive one-sigma uncertainties.
151    pub uncertainty: Option<Vec<f64>>,
152    /// Optional inclusion mask.
153    pub mask: Option<Vec<bool>>,
154    /// Supplied fixed background, always sample-aligned.
155    pub background_y: Vec<f64>,
156}
157
158impl PatternRecord {
159    /// Validate and own one pattern grid and its optional observations.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`DomainError`] for non-finite, unordered, or mismatched arrays.
164    pub fn new(
165        x_deg: Vec<f64>,
166        observed_y: Option<Vec<f64>>,
167        uncertainty: Option<Vec<f64>>,
168        mask: Option<Vec<bool>>,
169        background_y: Option<Vec<f64>>,
170    ) -> Result<Self, DomainError> {
171        let sample_count = x_deg.len();
172        let background_y = background_y.unwrap_or_else(|| vec![0.0; sample_count]);
173        let record = Self {
174            x_deg,
175            observed_y,
176            uncertainty,
177            mask,
178            background_y,
179        };
180        record.validate()?;
181        Ok(record)
182    }
183
184    /// Return the number of samples.
185    #[must_use]
186    pub fn sample_count(&self) -> usize {
187        self.x_deg.len()
188    }
189
190    /// Revalidate all arrays after direct adapter-side record construction.
191    ///
192    /// # Errors
193    ///
194    /// Returns [`DomainError`] for non-finite, unordered, or mismatched arrays.
195    pub fn validate(&self) -> Result<(), DomainError> {
196        if self.x_deg.iter().any(|value| !value.is_finite()) {
197            return Err(DomainError::NonFiniteArray { name: "x_deg" });
198        }
199        if self.x_deg.windows(2).any(|pair| pair[1] <= pair[0]) {
200            return Err(DomainError::UnorderedGrid);
201        }
202        let sample_count = self.x_deg.len();
203        validate_optional_f64(
204            "observed_y",
205            self.observed_y.as_deref(),
206            sample_count,
207            false,
208        )?;
209        validate_optional_f64(
210            "uncertainty",
211            self.uncertainty.as_deref(),
212            sample_count,
213            true,
214        )?;
215        if self
216            .mask
217            .as_ref()
218            .is_some_and(|values| values.len() != sample_count)
219        {
220            return Err(DomainError::ArrayLengthMismatch { name: "mask" });
221        }
222        validate_f64("background_y", &self.background_y, sample_count, false)
223    }
224}
225
226/// Constant-wavelength experiment shared by native workflows and adapters.
227#[derive(Clone, Debug, PartialEq)]
228pub struct ExperimentRecord {
229    /// Reference U/V/W/X/Y instrument.
230    pub instrument: ConstantWavelengthInstrument,
231    /// Monochromatic or fixed-spectrum radiation.
232    pub radiation: RadiationDefinition,
233    /// Optional axial-divergence geometry.
234    pub axial_geometry: Option<FcjGeometry>,
235    /// Explicit zero/specimen-displacement correction.
236    pub position_correction: MonochromaticPositionCorrection,
237}
238
239impl ExperimentRecord {
240    /// Validate agreement between radiation, instrument, and geometry.
241    ///
242    /// # Errors
243    ///
244    /// Returns [`DomainError`] when reference wavelengths disagree or geometry
245    /// contains a non-finite/nonphysical value.
246    pub fn new(
247        instrument: ConstantWavelengthInstrument,
248        radiation: RadiationDefinition,
249        axial_geometry: Option<FcjGeometry>,
250        position_correction: MonochromaticPositionCorrection,
251    ) -> Result<Self, DomainError> {
252        let record = Self {
253            instrument,
254            radiation,
255            axial_geometry,
256            position_correction,
257        };
258        record.validate()?;
259        Ok(record)
260    }
261
262    /// Revalidate the experiment after direct adapter-side construction.
263    ///
264    /// # Errors
265    ///
266    /// Returns [`DomainError`] when reference wavelengths disagree or geometry
267    /// contains a non-finite/nonphysical value.
268    pub fn validate(&self) -> Result<(), DomainError> {
269        if self.instrument.wavelength_angstrom.to_bits()
270            != self.radiation.reference_wavelength_angstrom().to_bits()
271        {
272            return Err(DomainError::ReferenceWavelengthMismatch);
273        }
274        match &self.radiation {
275            RadiationDefinition::Monochromatic {
276                wavelength_angstrom,
277                ..
278            } if !wavelength_angstrom.is_finite() || *wavelength_angstrom <= 0.0 => {
279                return Err(DomainError::InvalidRadiationWavelength);
280            }
281            RadiationDefinition::FixedSpectrum { spectrum, .. } => {
282                WavelengthComponentsView::new(
283                    &spectrum.wavelengths_angstrom,
284                    &spectrum.relative_intensities,
285                )
286                .map_err(DomainError::Radiation)?;
287            }
288            RadiationDefinition::Monochromatic { .. } => {}
289        }
290        validate_instrument(self.instrument)?;
291        validate_axial_geometry(self.axial_geometry)?;
292        validate_position_correction(self.position_correction)
293    }
294}
295
296/// Exact external provider capability required by a phase.
297#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
298pub struct ProviderRequirement {
299    /// Stable provider family identifier.
300    pub provider_id: String,
301    /// Exact provider API/data version.
302    pub provider_version: String,
303}
304
305impl ProviderRequirement {
306    /// Validate a provider requirement.
307    ///
308    /// # Errors
309    ///
310    /// Returns [`DomainError::InvalidProviderRequirement`] for empty fields.
311    pub fn new(
312        provider_id: impl Into<String>,
313        provider_version: impl Into<String>,
314    ) -> Result<Self, DomainError> {
315        let requirement = Self {
316            provider_id: provider_id.into(),
317            provider_version: provider_version.into(),
318        };
319        if requirement.provider_id.trim().is_empty()
320            || requirement.provider_version.trim().is_empty()
321        {
322            return Err(DomainError::InvalidProviderRequirement);
323        }
324        Ok(requirement)
325    }
326}
327
328/// One validated structural phase and its optional extension requirements.
329#[derive(Clone, Debug, PartialEq)]
330pub struct StructuralPhaseRecord {
331    /// Stable phase identifier.
332    pub phase_id: RecordId,
333    /// Human-readable phase label.
334    pub name: String,
335    /// Native crystallographic/scattering definition.
336    pub definition: StructuralPhaseDefinition,
337    /// External providers required in addition to built-in native models.
338    pub required_providers: Vec<ProviderRequirement>,
339}
340
341/// One independently observed dataset in a multi-histogram project.
342#[derive(Clone, Debug, PartialEq)]
343pub struct HistogramRecord {
344    /// Stable histogram identifier.
345    pub histogram_id: RecordId,
346    /// Human-readable dataset label.
347    pub name: String,
348    /// Observed grid and arrays.
349    pub pattern: PatternRecord,
350    /// Experiment attached to this dataset.
351    pub experiment: ExperimentRecord,
352    /// Ordered phase references active for this histogram.
353    pub phase_ids: Vec<RecordId>,
354}
355
356/// Revisioned application-neutral project snapshot.
357#[derive(Clone, Debug, PartialEq)]
358pub struct ProjectRecord {
359    /// Stable project identifier.
360    pub project_id: RecordId,
361    /// Monotonically increasing adapter-owned revision.
362    pub revision: u64,
363    /// Human-readable project label.
364    pub name: String,
365    /// Independently observed datasets.
366    pub histograms: Vec<HistogramRecord>,
367    /// Project-owned phase definitions.
368    pub phases: Vec<StructuralPhaseRecord>,
369    /// Small textual metadata; bulk arrays remain typed fields.
370    pub metadata: BTreeMap<String, String>,
371}
372
373impl ProjectRecord {
374    /// Validate cross-record identities and references.
375    ///
376    /// Empty projects are allowed so an application can create a project before
377    /// importing data. Once histograms exist, every phase reference must resolve.
378    ///
379    /// # Errors
380    ///
381    /// Returns [`DomainError`] for invalid labels, duplicate IDs/providers, or
382    /// dangling/duplicate phase references.
383    pub fn validate(&self) -> Result<(), DomainError> {
384        validate_label("project", &self.name)?;
385        let mut phase_ids = BTreeSet::new();
386        for phase in &self.phases {
387            validate_label("phase", &phase.name)?;
388            phase
389                .definition
390                .validate()
391                .map_err(DomainError::StructuralPhase)?;
392            if !phase_ids.insert(phase.phase_id.clone()) {
393                return Err(DomainError::DuplicatePhaseId {
394                    phase_id: phase.phase_id.clone(),
395                });
396            }
397            let mut requirements = BTreeSet::new();
398            for requirement in &phase.required_providers {
399                if requirement.provider_id.trim().is_empty()
400                    || requirement.provider_version.trim().is_empty()
401                {
402                    return Err(DomainError::InvalidProviderRequirement);
403                }
404                if !requirements.insert(requirement.clone()) {
405                    return Err(DomainError::DuplicateProviderRequirement {
406                        phase_id: phase.phase_id.clone(),
407                        provider_id: requirement.provider_id.clone(),
408                    });
409                }
410            }
411        }
412        let mut histogram_ids = BTreeSet::new();
413        for histogram in &self.histograms {
414            validate_label("histogram", &histogram.name)?;
415            histogram.pattern.validate()?;
416            histogram.experiment.validate()?;
417            if !histogram_ids.insert(histogram.histogram_id.clone()) {
418                return Err(DomainError::DuplicateHistogramId {
419                    histogram_id: histogram.histogram_id.clone(),
420                });
421            }
422            let mut referenced = BTreeSet::new();
423            for phase_id in &histogram.phase_ids {
424                if !phase_ids.contains(phase_id) {
425                    return Err(DomainError::UnknownPhaseReference {
426                        histogram_id: histogram.histogram_id.clone(),
427                        phase_id: phase_id.clone(),
428                    });
429                }
430                if !referenced.insert(phase_id.clone()) {
431                    return Err(DomainError::DuplicatePhaseReference {
432                        histogram_id: histogram.histogram_id.clone(),
433                        phase_id: phase_id.clone(),
434                    });
435                }
436            }
437        }
438        if self.metadata.keys().any(|key| key.trim().is_empty()) {
439            return Err(DomainError::InvalidMetadataKey);
440        }
441        Ok(())
442    }
443
444    /// Return all missing extension-provider capabilities in stable order.
445    #[must_use]
446    pub fn capability_diagnostics(
447        &self,
448        capabilities: &HostCapabilities,
449    ) -> Vec<CapabilityDiagnostic> {
450        self.phases
451            .iter()
452            .flat_map(|phase| {
453                phase
454                    .required_providers
455                    .iter()
456                    .filter(|requirement| !capabilities.supports(requirement))
457                    .map(|requirement| CapabilityDiagnostic {
458                        phase_id: phase.phase_id.clone(),
459                        requirement: requirement.clone(),
460                        reason: CapabilityReason::ProviderUnavailable,
461                    })
462            })
463            .collect()
464    }
465}
466
467/// Provider versions available to one application host.
468#[derive(Clone, Debug, Default, PartialEq, Eq)]
469pub struct HostCapabilities {
470    providers: BTreeSet<ProviderRequirement>,
471}
472
473impl HostCapabilities {
474    /// Construct a host capability set from exact provider requirements.
475    #[must_use]
476    pub fn new(providers: impl IntoIterator<Item = ProviderRequirement>) -> Self {
477        Self {
478            providers: providers.into_iter().collect(),
479        }
480    }
481
482    /// Return whether the host has this exact provider/version pair.
483    #[must_use]
484    pub fn supports(&self, requirement: &ProviderRequirement) -> bool {
485        self.providers.contains(requirement)
486    }
487}
488
489/// Stable reason a project capability is unavailable to a host.
490#[derive(Clone, Copy, Debug, PartialEq, Eq)]
491pub enum CapabilityReason {
492    /// The required provider/version pair was not registered by the host.
493    ProviderUnavailable,
494}
495
496/// One host-capability diagnostic tied to a stable phase.
497#[derive(Clone, Debug, PartialEq, Eq)]
498pub struct CapabilityDiagnostic {
499    /// Phase requiring the missing capability.
500    pub phase_id: RecordId,
501    /// Exact missing provider/version pair.
502    pub requirement: ProviderRequirement,
503    /// Stable diagnostic category.
504    pub reason: CapabilityReason,
505}
506
507/// Invalid native domain or project data.
508#[derive(Debug)]
509pub enum DomainError {
510    /// A stable identifier is invalid.
511    InvalidId {
512        /// Rejected value.
513        value: String,
514    },
515    /// A human-readable project record label is empty.
516    InvalidLabel {
517        /// Record family with the invalid label.
518        record: &'static str,
519    },
520    /// A numeric array has an unexpected length.
521    ArrayLengthMismatch {
522        /// Stable field name.
523        name: &'static str,
524    },
525    /// A numeric array contains a non-finite value.
526    NonFiniteArray {
527        /// Stable field name.
528        name: &'static str,
529    },
530    /// A required-positive array contains zero or a negative value.
531    NonPositiveArray {
532        /// Stable field name.
533        name: &'static str,
534    },
535    /// Pattern coordinates are not strictly increasing.
536    UnorderedGrid,
537    /// Radiation components are invalid.
538    Radiation(WavelengthComponentsError),
539    /// A monochromatic radiation wavelength is non-finite or non-positive.
540    InvalidRadiationWavelength,
541    /// Instrument and radiation reference wavelengths differ.
542    ReferenceWavelengthMismatch,
543    /// Instrument parameters are non-finite or nonphysical.
544    InvalidInstrument,
545    /// Axial geometry is non-finite or negative.
546    InvalidAxialGeometry,
547    /// Position-correction geometry is non-finite or nonphysical.
548    InvalidPositionCorrection,
549    /// A structural phase definition is invalid.
550    StructuralPhase(StructuralPatternError),
551    /// Provider ID or version is empty.
552    InvalidProviderRequirement,
553    /// A phase ID is repeated.
554    DuplicatePhaseId {
555        /// Repeated phase ID.
556        phase_id: RecordId,
557    },
558    /// A histogram ID is repeated.
559    DuplicateHistogramId {
560        /// Repeated histogram ID.
561        histogram_id: RecordId,
562    },
563    /// One histogram references a missing phase.
564    UnknownPhaseReference {
565        /// Histogram containing the reference.
566        histogram_id: RecordId,
567        /// Missing phase ID.
568        phase_id: RecordId,
569    },
570    /// One histogram references the same phase twice.
571    DuplicatePhaseReference {
572        /// Histogram containing the duplicate.
573        histogram_id: RecordId,
574        /// Duplicated phase ID.
575        phase_id: RecordId,
576    },
577    /// One phase repeats the same exact provider requirement.
578    DuplicateProviderRequirement {
579        /// Phase containing the duplicate.
580        phase_id: RecordId,
581        /// Repeated provider ID.
582        provider_id: String,
583    },
584    /// A metadata key is empty.
585    InvalidMetadataKey,
586}
587
588impl Display for DomainError {
589    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
590        match self {
591            Self::InvalidId { value } => write!(formatter, "invalid stable record ID {value:?}"),
592            Self::InvalidLabel { record } => write!(formatter, "{record} label must not be empty"),
593            Self::ArrayLengthMismatch { name } => {
594                write!(formatter, "{name} must match the pattern sample count")
595            }
596            Self::NonFiniteArray { name } => write!(formatter, "{name} must contain finite values"),
597            Self::NonPositiveArray { name } => {
598                write!(formatter, "{name} must contain positive values")
599            }
600            Self::UnorderedGrid => formatter.write_str("x_deg must be strictly increasing"),
601            Self::Radiation(error) => Display::fmt(error, formatter),
602            Self::InvalidRadiationWavelength => {
603                formatter.write_str("radiation wavelength must be positive and finite")
604            }
605            Self::ReferenceWavelengthMismatch => formatter
606                .write_str("instrument wavelength must match the radiation reference wavelength"),
607            Self::InvalidInstrument => {
608                formatter.write_str("constant-wavelength instrument parameters are invalid")
609            }
610            Self::InvalidAxialGeometry => {
611                formatter.write_str("axial geometry must be finite and non-negative")
612            }
613            Self::InvalidPositionCorrection => {
614                formatter.write_str("position-correction geometry is invalid")
615            }
616            Self::StructuralPhase(error) => Display::fmt(error, formatter),
617            Self::InvalidProviderRequirement => {
618                formatter.write_str("provider ID and version must not be empty")
619            }
620            Self::DuplicatePhaseId { phase_id } => {
621                write!(formatter, "duplicate phase ID {phase_id}")
622            }
623            Self::DuplicateHistogramId { histogram_id } => {
624                write!(formatter, "duplicate histogram ID {histogram_id}")
625            }
626            Self::UnknownPhaseReference {
627                histogram_id,
628                phase_id,
629            } => write!(
630                formatter,
631                "histogram {histogram_id} references unknown phase {phase_id}"
632            ),
633            Self::DuplicatePhaseReference {
634                histogram_id,
635                phase_id,
636            } => write!(
637                formatter,
638                "histogram {histogram_id} repeats phase {phase_id}"
639            ),
640            Self::DuplicateProviderRequirement {
641                phase_id,
642                provider_id,
643            } => write!(formatter, "phase {phase_id} repeats provider {provider_id}"),
644            Self::InvalidMetadataKey => formatter.write_str("metadata keys must not be empty"),
645        }
646    }
647}
648
649impl Error for DomainError {
650    fn source(&self) -> Option<&(dyn Error + 'static)> {
651        match self {
652            Self::Radiation(error) => Some(error),
653            Self::StructuralPhase(error) => Some(error),
654            _ => None,
655        }
656    }
657}
658
659fn validate_f64(
660    name: &'static str,
661    values: &[f64],
662    expected: usize,
663    positive: bool,
664) -> Result<(), DomainError> {
665    if values.len() != expected {
666        return Err(DomainError::ArrayLengthMismatch { name });
667    }
668    if values.iter().any(|value| !value.is_finite()) {
669        return Err(DomainError::NonFiniteArray { name });
670    }
671    if positive && values.iter().any(|value| *value <= 0.0) {
672        return Err(DomainError::NonPositiveArray { name });
673    }
674    Ok(())
675}
676
677fn validate_optional_f64(
678    name: &'static str,
679    values: Option<&[f64]>,
680    expected: usize,
681    positive: bool,
682) -> Result<(), DomainError> {
683    values.map_or(Ok(()), |values| {
684        validate_f64(name, values, expected, positive)
685    })
686}
687
688fn validate_label(record: &'static str, value: &str) -> Result<(), DomainError> {
689    if value.trim().is_empty() {
690        return Err(DomainError::InvalidLabel { record });
691    }
692    Ok(())
693}
694
695fn validate_instrument(instrument: ConstantWavelengthInstrument) -> Result<(), DomainError> {
696    let values = [
697        instrument.wavelength_angstrom,
698        instrument.u_deg2,
699        instrument.v_deg2,
700        instrument.w_deg2,
701        instrument.x_deg,
702        instrument.y_deg,
703    ];
704    if values.iter().any(|value| !value.is_finite()) || instrument.wavelength_angstrom <= 0.0 {
705        return Err(DomainError::InvalidInstrument);
706    }
707    Ok(())
708}
709
710fn validate_axial_geometry(geometry: Option<FcjGeometry>) -> Result<(), DomainError> {
711    if geometry.is_some_and(|value| {
712        !value.sample_over_radius.is_finite()
713            || !value.detector_over_radius.is_finite()
714            || value.sample_over_radius < 0.0
715            || value.detector_over_radius < 0.0
716    }) {
717        return Err(DomainError::InvalidAxialGeometry);
718    }
719    Ok(())
720}
721
722fn validate_position_correction(
723    correction: MonochromaticPositionCorrection,
724) -> Result<(), DomainError> {
725    let invalid = !correction.zero_shift_deg.is_finite()
726        || correction
727            .bragg_brentano_mm
728            .is_some_and(|(displacement, radius)| {
729                !displacement.is_finite() || !radius.is_finite() || radius <= 0.0
730            })
731        || correction
732            .debye_scherrer_micrometre
733            .is_some_and(|(x, y, radius)| {
734                !x.is_finite() || !y.is_finite() || !radius.is_finite() || radius <= 0.0
735            });
736    if invalid
737        || (correction.bragg_brentano_mm.is_some()
738            && correction.debye_scherrer_micrometre.is_some())
739    {
740        return Err(DomainError::InvalidPositionCorrection);
741    }
742    Ok(())
743}