1use 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#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
57pub struct RecordId(String);
58
59impl RecordId {
60 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 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum RadiationProbe {
95 Xray,
97 Neutron,
99}
100
101#[derive(Clone, Debug, PartialEq)]
103pub struct FixedWavelengthSpectrum {
104 wavelengths_angstrom: Vec<f64>,
105 relative_intensities: Vec<f64>,
106}
107
108impl FixedWavelengthSpectrum {
109 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 #[must_use]
128 pub fn wavelengths_angstrom(&self) -> &[f64] {
129 &self.wavelengths_angstrom
130 }
131
132 #[must_use]
134 pub fn relative_intensities(&self) -> &[f64] {
135 &self.relative_intensities
136 }
137}
138
139#[derive(Clone, Debug, PartialEq)]
141pub enum RadiationDefinition {
142 Monochromatic {
144 probe: RadiationProbe,
146 wavelength_angstrom: f64,
148 },
149 FixedSpectrum {
151 probe: RadiationProbe,
153 spectrum: FixedWavelengthSpectrum,
155 },
156}
157
158impl RadiationDefinition {
159 #[must_use]
161 pub const fn probe(&self) -> RadiationProbe {
162 match self {
163 Self::Monochromatic { probe, .. } | Self::FixedSpectrum { probe, .. } => *probe,
164 }
165 }
166
167 #[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#[derive(Clone, Debug, PartialEq)]
182pub struct PatternRecord {
183 pub x_deg: Vec<f64>,
185 pub observed_y: Option<Vec<f64>>,
187 pub uncertainty: Option<Vec<f64>>,
189 pub mask: Option<Vec<bool>>,
191 pub background_y: Vec<f64>,
193}
194
195impl PatternRecord {
196 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 #[must_use]
223 pub fn sample_count(&self) -> usize {
224 self.x_deg.len()
225 }
226
227 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#[derive(Clone, Debug, PartialEq)]
265pub struct TofPatternRecord {
266 pub tof_us: Vec<f64>,
268 pub observed_y: Option<Vec<f64>>,
270 pub uncertainty: Option<Vec<f64>>,
272 pub mask: Option<Vec<bool>>,
274 pub background_y: Vec<f64>,
276}
277
278impl TofPatternRecord {
279 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 #[must_use]
305 pub fn sample_count(&self) -> usize {
306 self.tof_us.len()
307 }
308
309 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#[derive(Clone, Debug, PartialEq)]
347pub struct ExperimentRecord {
348 pub instrument: ConstantWavelengthInstrument,
350 pub radiation: RadiationDefinition,
352 pub axial_geometry: Option<FcjGeometry>,
354 pub position_correction: MonochromaticPositionCorrection,
356}
357
358impl ExperimentRecord {
359 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 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#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
417pub struct ProviderRequirement {
418 pub provider_id: String,
420 pub provider_version: String,
422}
423
424impl ProviderRequirement {
425 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#[derive(Clone, Debug, PartialEq)]
449pub struct StructuralPhaseRecord {
450 pub phase_id: RecordId,
452 pub name: String,
454 pub definition: StructuralPhaseDefinition,
456 pub required_providers: Vec<ProviderRequirement>,
458}
459
460#[derive(Clone, Debug, PartialEq)]
462pub struct HistogramRecord {
463 pub histogram_id: RecordId,
465 pub name: String,
467 pub pattern: PatternRecord,
469 pub experiment: ExperimentRecord,
471 pub phase_ids: Vec<RecordId>,
473}
474
475#[derive(Clone, Copy, Debug, PartialEq)]
477pub struct TofExperimentRecord {
478 pub instrument: TofInstrument,
480}
481
482impl TofExperimentRecord {
483 pub fn new(instrument: TofInstrument) -> Result<Self, DomainError> {
489 let result = Self { instrument };
490 result.validate()?;
491 Ok(result)
492 }
493
494 pub fn validate(&self) -> Result<(), DomainError> {
500 self.instrument
501 .validate()
502 .map_err(DomainError::TofInstrument)
503 }
504}
505
506#[derive(Clone, Debug, PartialEq)]
508pub struct TofHistogramRecord {
509 pub histogram_id: RecordId,
511 pub name: String,
513 pub pattern: TofPatternRecord,
515 pub experiment: TofExperimentRecord,
517 pub phase_ids: Vec<RecordId>,
519}
520
521#[derive(Clone, Debug, PartialEq)]
523pub struct ProjectRecord {
524 pub project_id: RecordId,
526 pub revision: u64,
528 pub name: String,
530 pub histograms: Vec<HistogramRecord>,
532 pub tof_histograms: Vec<TofHistogramRecord>,
534 pub phases: Vec<StructuralPhaseRecord>,
536 pub metadata: BTreeMap<String, String>,
538}
539
540impl ProjectRecord {
541 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 #[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#[derive(Clone, Debug, Default, PartialEq, Eq)]
661pub struct HostCapabilities {
662 providers: BTreeSet<ProviderRequirement>,
663}
664
665impl HostCapabilities {
666 #[must_use]
668 pub fn new(providers: impl IntoIterator<Item = ProviderRequirement>) -> Self {
669 Self {
670 providers: providers.into_iter().collect(),
671 }
672 }
673
674 #[must_use]
676 pub fn supports(&self, requirement: &ProviderRequirement) -> bool {
677 self.providers.contains(requirement)
678 }
679}
680
681#[derive(Clone, Copy, Debug, PartialEq, Eq)]
683pub enum CapabilityReason {
684 ProviderUnavailable,
686}
687
688#[derive(Clone, Debug, PartialEq, Eq)]
690pub struct CapabilityDiagnostic {
691 pub phase_id: RecordId,
693 pub requirement: ProviderRequirement,
695 pub reason: CapabilityReason,
697}
698
699#[derive(Debug)]
701pub enum DomainError {
702 InvalidId {
704 value: String,
706 },
707 InvalidLabel {
709 record: &'static str,
711 },
712 ArrayLengthMismatch {
714 name: &'static str,
716 },
717 NonFiniteArray {
719 name: &'static str,
721 },
722 NonPositiveArray {
724 name: &'static str,
726 },
727 UnorderedGrid,
729 Radiation(WavelengthComponentsError),
731 InvalidRadiationWavelength,
733 ReferenceWavelengthMismatch,
735 InvalidInstrument,
737 TofInstrument(TofError),
739 InvalidAxialGeometry,
741 InvalidPositionCorrection,
743 StructuralPhase(StructuralPatternError),
745 InvalidProviderRequirement,
747 DuplicatePhaseId {
749 phase_id: RecordId,
751 },
752 DuplicateHistogramId {
754 histogram_id: RecordId,
756 },
757 UnknownPhaseReference {
759 histogram_id: RecordId,
761 phase_id: RecordId,
763 },
764 DuplicatePhaseReference {
766 histogram_id: RecordId,
768 phase_id: RecordId,
770 },
771 DuplicateProviderRequirement {
773 phase_id: RecordId,
775 provider_id: String,
777 },
778 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}