1use 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#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
20pub struct RecordId(String);
21
22impl RecordId {
23 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 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum RadiationProbe {
58 Xray,
60 Neutron,
62}
63
64#[derive(Clone, Debug, PartialEq)]
66pub struct FixedWavelengthSpectrum {
67 wavelengths_angstrom: Vec<f64>,
68 relative_intensities: Vec<f64>,
69}
70
71impl FixedWavelengthSpectrum {
72 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 #[must_use]
91 pub fn wavelengths_angstrom(&self) -> &[f64] {
92 &self.wavelengths_angstrom
93 }
94
95 #[must_use]
97 pub fn relative_intensities(&self) -> &[f64] {
98 &self.relative_intensities
99 }
100}
101
102#[derive(Clone, Debug, PartialEq)]
104pub enum RadiationDefinition {
105 Monochromatic {
107 probe: RadiationProbe,
109 wavelength_angstrom: f64,
111 },
112 FixedSpectrum {
114 probe: RadiationProbe,
116 spectrum: FixedWavelengthSpectrum,
118 },
119}
120
121impl RadiationDefinition {
122 #[must_use]
124 pub const fn probe(&self) -> RadiationProbe {
125 match self {
126 Self::Monochromatic { probe, .. } | Self::FixedSpectrum { probe, .. } => *probe,
127 }
128 }
129
130 #[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#[derive(Clone, Debug, PartialEq)]
145pub struct PatternRecord {
146 pub x_deg: Vec<f64>,
148 pub observed_y: Option<Vec<f64>>,
150 pub uncertainty: Option<Vec<f64>>,
152 pub mask: Option<Vec<bool>>,
154 pub background_y: Vec<f64>,
156}
157
158impl PatternRecord {
159 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 #[must_use]
186 pub fn sample_count(&self) -> usize {
187 self.x_deg.len()
188 }
189
190 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#[derive(Clone, Debug, PartialEq)]
228pub struct ExperimentRecord {
229 pub instrument: ConstantWavelengthInstrument,
231 pub radiation: RadiationDefinition,
233 pub axial_geometry: Option<FcjGeometry>,
235 pub position_correction: MonochromaticPositionCorrection,
237}
238
239impl ExperimentRecord {
240 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 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#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
298pub struct ProviderRequirement {
299 pub provider_id: String,
301 pub provider_version: String,
303}
304
305impl ProviderRequirement {
306 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#[derive(Clone, Debug, PartialEq)]
330pub struct StructuralPhaseRecord {
331 pub phase_id: RecordId,
333 pub name: String,
335 pub definition: StructuralPhaseDefinition,
337 pub required_providers: Vec<ProviderRequirement>,
339}
340
341#[derive(Clone, Debug, PartialEq)]
343pub struct HistogramRecord {
344 pub histogram_id: RecordId,
346 pub name: String,
348 pub pattern: PatternRecord,
350 pub experiment: ExperimentRecord,
352 pub phase_ids: Vec<RecordId>,
354}
355
356#[derive(Clone, Debug, PartialEq)]
358pub struct ProjectRecord {
359 pub project_id: RecordId,
361 pub revision: u64,
363 pub name: String,
365 pub histograms: Vec<HistogramRecord>,
367 pub phases: Vec<StructuralPhaseRecord>,
369 pub metadata: BTreeMap<String, String>,
371}
372
373impl ProjectRecord {
374 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 #[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#[derive(Clone, Debug, Default, PartialEq, Eq)]
469pub struct HostCapabilities {
470 providers: BTreeSet<ProviderRequirement>,
471}
472
473impl HostCapabilities {
474 #[must_use]
476 pub fn new(providers: impl IntoIterator<Item = ProviderRequirement>) -> Self {
477 Self {
478 providers: providers.into_iter().collect(),
479 }
480 }
481
482 #[must_use]
484 pub fn supports(&self, requirement: &ProviderRequirement) -> bool {
485 self.providers.contains(requirement)
486 }
487}
488
489#[derive(Clone, Copy, Debug, PartialEq, Eq)]
491pub enum CapabilityReason {
492 ProviderUnavailable,
494}
495
496#[derive(Clone, Debug, PartialEq, Eq)]
498pub struct CapabilityDiagnostic {
499 pub phase_id: RecordId,
501 pub requirement: ProviderRequirement,
503 pub reason: CapabilityReason,
505}
506
507#[derive(Debug)]
509pub enum DomainError {
510 InvalidId {
512 value: String,
514 },
515 InvalidLabel {
517 record: &'static str,
519 },
520 ArrayLengthMismatch {
522 name: &'static str,
524 },
525 NonFiniteArray {
527 name: &'static str,
529 },
530 NonPositiveArray {
532 name: &'static str,
534 },
535 UnorderedGrid,
537 Radiation(WavelengthComponentsError),
539 InvalidRadiationWavelength,
541 ReferenceWavelengthMismatch,
543 InvalidInstrument,
545 InvalidAxialGeometry,
547 InvalidPositionCorrection,
549 StructuralPhase(StructuralPatternError),
551 InvalidProviderRequirement,
553 DuplicatePhaseId {
555 phase_id: RecordId,
557 },
558 DuplicateHistogramId {
560 histogram_id: RecordId,
562 },
563 UnknownPhaseReference {
565 histogram_id: RecordId,
567 phase_id: RecordId,
569 },
570 DuplicatePhaseReference {
572 histogram_id: RecordId,
574 phase_id: RecordId,
576 },
577 DuplicateProviderRequirement {
579 phase_id: RecordId,
581 provider_id: String,
583 },
584 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}