use std::collections::{BTreeMap, BTreeSet};
use std::error::Error;
use std::fmt::{Display, Formatter};
use phasesmith_core::{
ConstantWavelengthInstrument, FcjGeometry, WavelengthComponentsError, WavelengthComponentsView,
};
use phasesmith_engine::{
MonochromaticPositionCorrection, StructuralPatternError, StructuralPhaseDefinition,
};
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RecordId(String);
impl RecordId {
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
let value = value.into();
if value.is_empty()
|| value.len() > 128
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
return Err(DomainError::InvalidId { value });
}
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Display for RecordId {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RadiationProbe {
Xray,
Neutron,
}
#[derive(Clone, Debug, PartialEq)]
pub struct FixedWavelengthSpectrum {
wavelengths_angstrom: Vec<f64>,
relative_intensities: Vec<f64>,
}
impl FixedWavelengthSpectrum {
pub fn new(
wavelengths_angstrom: Vec<f64>,
relative_intensities: Vec<f64>,
) -> Result<Self, DomainError> {
WavelengthComponentsView::new(&wavelengths_angstrom, &relative_intensities)
.map_err(DomainError::Radiation)?;
Ok(Self {
wavelengths_angstrom,
relative_intensities,
})
}
#[must_use]
pub fn wavelengths_angstrom(&self) -> &[f64] {
&self.wavelengths_angstrom
}
#[must_use]
pub fn relative_intensities(&self) -> &[f64] {
&self.relative_intensities
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum RadiationDefinition {
Monochromatic {
probe: RadiationProbe,
wavelength_angstrom: f64,
},
FixedSpectrum {
probe: RadiationProbe,
spectrum: FixedWavelengthSpectrum,
},
}
impl RadiationDefinition {
#[must_use]
pub const fn probe(&self) -> RadiationProbe {
match self {
Self::Monochromatic { probe, .. } | Self::FixedSpectrum { probe, .. } => *probe,
}
}
#[must_use]
pub fn reference_wavelength_angstrom(&self) -> f64 {
match self {
Self::Monochromatic {
wavelength_angstrom,
..
} => *wavelength_angstrom,
Self::FixedSpectrum { spectrum, .. } => spectrum.wavelengths_angstrom[0],
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct PatternRecord {
pub x_deg: Vec<f64>,
pub observed_y: Option<Vec<f64>>,
pub uncertainty: Option<Vec<f64>>,
pub mask: Option<Vec<bool>>,
pub background_y: Vec<f64>,
}
impl PatternRecord {
pub fn new(
x_deg: Vec<f64>,
observed_y: Option<Vec<f64>>,
uncertainty: Option<Vec<f64>>,
mask: Option<Vec<bool>>,
background_y: Option<Vec<f64>>,
) -> Result<Self, DomainError> {
let sample_count = x_deg.len();
let background_y = background_y.unwrap_or_else(|| vec![0.0; sample_count]);
let record = Self {
x_deg,
observed_y,
uncertainty,
mask,
background_y,
};
record.validate()?;
Ok(record)
}
#[must_use]
pub fn sample_count(&self) -> usize {
self.x_deg.len()
}
pub fn validate(&self) -> Result<(), DomainError> {
if self.x_deg.iter().any(|value| !value.is_finite()) {
return Err(DomainError::NonFiniteArray { name: "x_deg" });
}
if self.x_deg.windows(2).any(|pair| pair[1] <= pair[0]) {
return Err(DomainError::UnorderedGrid);
}
let sample_count = self.x_deg.len();
validate_optional_f64(
"observed_y",
self.observed_y.as_deref(),
sample_count,
false,
)?;
validate_optional_f64(
"uncertainty",
self.uncertainty.as_deref(),
sample_count,
true,
)?;
if self
.mask
.as_ref()
.is_some_and(|values| values.len() != sample_count)
{
return Err(DomainError::ArrayLengthMismatch { name: "mask" });
}
validate_f64("background_y", &self.background_y, sample_count, false)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ExperimentRecord {
pub instrument: ConstantWavelengthInstrument,
pub radiation: RadiationDefinition,
pub axial_geometry: Option<FcjGeometry>,
pub position_correction: MonochromaticPositionCorrection,
}
impl ExperimentRecord {
pub fn new(
instrument: ConstantWavelengthInstrument,
radiation: RadiationDefinition,
axial_geometry: Option<FcjGeometry>,
position_correction: MonochromaticPositionCorrection,
) -> Result<Self, DomainError> {
let record = Self {
instrument,
radiation,
axial_geometry,
position_correction,
};
record.validate()?;
Ok(record)
}
pub fn validate(&self) -> Result<(), DomainError> {
if self.instrument.wavelength_angstrom.to_bits()
!= self.radiation.reference_wavelength_angstrom().to_bits()
{
return Err(DomainError::ReferenceWavelengthMismatch);
}
match &self.radiation {
RadiationDefinition::Monochromatic {
wavelength_angstrom,
..
} if !wavelength_angstrom.is_finite() || *wavelength_angstrom <= 0.0 => {
return Err(DomainError::InvalidRadiationWavelength);
}
RadiationDefinition::FixedSpectrum { spectrum, .. } => {
WavelengthComponentsView::new(
&spectrum.wavelengths_angstrom,
&spectrum.relative_intensities,
)
.map_err(DomainError::Radiation)?;
}
RadiationDefinition::Monochromatic { .. } => {}
}
validate_instrument(self.instrument)?;
validate_axial_geometry(self.axial_geometry)?;
validate_position_correction(self.position_correction)
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct ProviderRequirement {
pub provider_id: String,
pub provider_version: String,
}
impl ProviderRequirement {
pub fn new(
provider_id: impl Into<String>,
provider_version: impl Into<String>,
) -> Result<Self, DomainError> {
let requirement = Self {
provider_id: provider_id.into(),
provider_version: provider_version.into(),
};
if requirement.provider_id.trim().is_empty()
|| requirement.provider_version.trim().is_empty()
{
return Err(DomainError::InvalidProviderRequirement);
}
Ok(requirement)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct StructuralPhaseRecord {
pub phase_id: RecordId,
pub name: String,
pub definition: StructuralPhaseDefinition,
pub required_providers: Vec<ProviderRequirement>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct HistogramRecord {
pub histogram_id: RecordId,
pub name: String,
pub pattern: PatternRecord,
pub experiment: ExperimentRecord,
pub phase_ids: Vec<RecordId>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ProjectRecord {
pub project_id: RecordId,
pub revision: u64,
pub name: String,
pub histograms: Vec<HistogramRecord>,
pub phases: Vec<StructuralPhaseRecord>,
pub metadata: BTreeMap<String, String>,
}
impl ProjectRecord {
pub fn validate(&self) -> Result<(), DomainError> {
validate_label("project", &self.name)?;
let mut phase_ids = BTreeSet::new();
for phase in &self.phases {
validate_label("phase", &phase.name)?;
phase
.definition
.validate()
.map_err(DomainError::StructuralPhase)?;
if !phase_ids.insert(phase.phase_id.clone()) {
return Err(DomainError::DuplicatePhaseId {
phase_id: phase.phase_id.clone(),
});
}
let mut requirements = BTreeSet::new();
for requirement in &phase.required_providers {
if requirement.provider_id.trim().is_empty()
|| requirement.provider_version.trim().is_empty()
{
return Err(DomainError::InvalidProviderRequirement);
}
if !requirements.insert(requirement.clone()) {
return Err(DomainError::DuplicateProviderRequirement {
phase_id: phase.phase_id.clone(),
provider_id: requirement.provider_id.clone(),
});
}
}
}
let mut histogram_ids = BTreeSet::new();
for histogram in &self.histograms {
validate_label("histogram", &histogram.name)?;
histogram.pattern.validate()?;
histogram.experiment.validate()?;
if !histogram_ids.insert(histogram.histogram_id.clone()) {
return Err(DomainError::DuplicateHistogramId {
histogram_id: histogram.histogram_id.clone(),
});
}
let mut referenced = BTreeSet::new();
for phase_id in &histogram.phase_ids {
if !phase_ids.contains(phase_id) {
return Err(DomainError::UnknownPhaseReference {
histogram_id: histogram.histogram_id.clone(),
phase_id: phase_id.clone(),
});
}
if !referenced.insert(phase_id.clone()) {
return Err(DomainError::DuplicatePhaseReference {
histogram_id: histogram.histogram_id.clone(),
phase_id: phase_id.clone(),
});
}
}
}
if self.metadata.keys().any(|key| key.trim().is_empty()) {
return Err(DomainError::InvalidMetadataKey);
}
Ok(())
}
#[must_use]
pub fn capability_diagnostics(
&self,
capabilities: &HostCapabilities,
) -> Vec<CapabilityDiagnostic> {
self.phases
.iter()
.flat_map(|phase| {
phase
.required_providers
.iter()
.filter(|requirement| !capabilities.supports(requirement))
.map(|requirement| CapabilityDiagnostic {
phase_id: phase.phase_id.clone(),
requirement: requirement.clone(),
reason: CapabilityReason::ProviderUnavailable,
})
})
.collect()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HostCapabilities {
providers: BTreeSet<ProviderRequirement>,
}
impl HostCapabilities {
#[must_use]
pub fn new(providers: impl IntoIterator<Item = ProviderRequirement>) -> Self {
Self {
providers: providers.into_iter().collect(),
}
}
#[must_use]
pub fn supports(&self, requirement: &ProviderRequirement) -> bool {
self.providers.contains(requirement)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CapabilityReason {
ProviderUnavailable,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CapabilityDiagnostic {
pub phase_id: RecordId,
pub requirement: ProviderRequirement,
pub reason: CapabilityReason,
}
#[derive(Debug)]
pub enum DomainError {
InvalidId {
value: String,
},
InvalidLabel {
record: &'static str,
},
ArrayLengthMismatch {
name: &'static str,
},
NonFiniteArray {
name: &'static str,
},
NonPositiveArray {
name: &'static str,
},
UnorderedGrid,
Radiation(WavelengthComponentsError),
InvalidRadiationWavelength,
ReferenceWavelengthMismatch,
InvalidInstrument,
InvalidAxialGeometry,
InvalidPositionCorrection,
StructuralPhase(StructuralPatternError),
InvalidProviderRequirement,
DuplicatePhaseId {
phase_id: RecordId,
},
DuplicateHistogramId {
histogram_id: RecordId,
},
UnknownPhaseReference {
histogram_id: RecordId,
phase_id: RecordId,
},
DuplicatePhaseReference {
histogram_id: RecordId,
phase_id: RecordId,
},
DuplicateProviderRequirement {
phase_id: RecordId,
provider_id: String,
},
InvalidMetadataKey,
}
impl Display for DomainError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidId { value } => write!(formatter, "invalid stable record ID {value:?}"),
Self::InvalidLabel { record } => write!(formatter, "{record} label must not be empty"),
Self::ArrayLengthMismatch { name } => {
write!(formatter, "{name} must match the pattern sample count")
}
Self::NonFiniteArray { name } => write!(formatter, "{name} must contain finite values"),
Self::NonPositiveArray { name } => {
write!(formatter, "{name} must contain positive values")
}
Self::UnorderedGrid => formatter.write_str("x_deg must be strictly increasing"),
Self::Radiation(error) => Display::fmt(error, formatter),
Self::InvalidRadiationWavelength => {
formatter.write_str("radiation wavelength must be positive and finite")
}
Self::ReferenceWavelengthMismatch => formatter
.write_str("instrument wavelength must match the radiation reference wavelength"),
Self::InvalidInstrument => {
formatter.write_str("constant-wavelength instrument parameters are invalid")
}
Self::InvalidAxialGeometry => {
formatter.write_str("axial geometry must be finite and non-negative")
}
Self::InvalidPositionCorrection => {
formatter.write_str("position-correction geometry is invalid")
}
Self::StructuralPhase(error) => Display::fmt(error, formatter),
Self::InvalidProviderRequirement => {
formatter.write_str("provider ID and version must not be empty")
}
Self::DuplicatePhaseId { phase_id } => {
write!(formatter, "duplicate phase ID {phase_id}")
}
Self::DuplicateHistogramId { histogram_id } => {
write!(formatter, "duplicate histogram ID {histogram_id}")
}
Self::UnknownPhaseReference {
histogram_id,
phase_id,
} => write!(
formatter,
"histogram {histogram_id} references unknown phase {phase_id}"
),
Self::DuplicatePhaseReference {
histogram_id,
phase_id,
} => write!(
formatter,
"histogram {histogram_id} repeats phase {phase_id}"
),
Self::DuplicateProviderRequirement {
phase_id,
provider_id,
} => write!(formatter, "phase {phase_id} repeats provider {provider_id}"),
Self::InvalidMetadataKey => formatter.write_str("metadata keys must not be empty"),
}
}
}
impl Error for DomainError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Radiation(error) => Some(error),
Self::StructuralPhase(error) => Some(error),
_ => None,
}
}
}
fn validate_f64(
name: &'static str,
values: &[f64],
expected: usize,
positive: bool,
) -> Result<(), DomainError> {
if values.len() != expected {
return Err(DomainError::ArrayLengthMismatch { name });
}
if values.iter().any(|value| !value.is_finite()) {
return Err(DomainError::NonFiniteArray { name });
}
if positive && values.iter().any(|value| *value <= 0.0) {
return Err(DomainError::NonPositiveArray { name });
}
Ok(())
}
fn validate_optional_f64(
name: &'static str,
values: Option<&[f64]>,
expected: usize,
positive: bool,
) -> Result<(), DomainError> {
values.map_or(Ok(()), |values| {
validate_f64(name, values, expected, positive)
})
}
fn validate_label(record: &'static str, value: &str) -> Result<(), DomainError> {
if value.trim().is_empty() {
return Err(DomainError::InvalidLabel { record });
}
Ok(())
}
fn validate_instrument(instrument: ConstantWavelengthInstrument) -> Result<(), DomainError> {
let values = [
instrument.wavelength_angstrom,
instrument.u_deg2,
instrument.v_deg2,
instrument.w_deg2,
instrument.x_deg,
instrument.y_deg,
];
if values.iter().any(|value| !value.is_finite()) || instrument.wavelength_angstrom <= 0.0 {
return Err(DomainError::InvalidInstrument);
}
Ok(())
}
fn validate_axial_geometry(geometry: Option<FcjGeometry>) -> Result<(), DomainError> {
if geometry.is_some_and(|value| {
!value.sample_over_radius.is_finite()
|| !value.detector_over_radius.is_finite()
|| value.sample_over_radius < 0.0
|| value.detector_over_radius < 0.0
}) {
return Err(DomainError::InvalidAxialGeometry);
}
Ok(())
}
fn validate_position_correction(
correction: MonochromaticPositionCorrection,
) -> Result<(), DomainError> {
let invalid = !correction.zero_shift_deg.is_finite()
|| correction
.bragg_brentano_mm
.is_some_and(|(displacement, radius)| {
!displacement.is_finite() || !radius.is_finite() || radius <= 0.0
})
|| correction
.debye_scherrer_micrometre
.is_some_and(|(x, y, radius)| {
!x.is_finite() || !y.is_finite() || !radius.is_finite() || radius <= 0.0
});
if invalid
|| (correction.bragg_brentano_mm.is_some()
&& correction.debye_scherrer_micrometre.is_some())
{
return Err(DomainError::InvalidPositionCorrection);
}
Ok(())
}