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