use serde::{Deserialize, Serialize};
use crate::Thermodynamics::thermo_lib_api::ThermoCatalogConsistencyReport;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_candidate_selection::EquilibriumCandidateSelectionReport;
use crate::Thermodynamics::ChemEquilibrium::phase_equilibrium_workflow::{
EquilibriumSolveOptions, EquilibriumSolveOptionsSnapshot, ResolvedPhaseEquilibriumOutcome,
};
pub const EQUILIBRIUM_REPRODUCIBILITY_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EquilibriumRecordIdentity {
pub component: String,
pub phase: String,
pub substance: String,
pub library: String,
pub record_key: String,
pub lookup_priority: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EquilibriumPhaseSpecSnapshot {
pub phase: String,
pub physical_state: String,
pub model: String,
pub components: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThermoCatalogSnapshot {
pub structure_fingerprint: u64,
pub indexed_pair_count: usize,
pub unique_indexed_pair_count: usize,
pub payload_pair_count: usize,
pub duplicate_index_pair_count: usize,
pub indexed_without_payload_count: usize,
pub payload_without_index_count: usize,
pub consistent: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EquilibriumCandidateSelectionSnapshot {
pub requested_elements: Vec<String>,
pub element_mode: String,
pub library_preference: Vec<String>,
pub physical_states: Option<Vec<String>>,
pub temperature_range_kelvin: Option<(f64, f64)>,
pub max_candidates: Option<usize>,
pub selected_records: Vec<EquilibriumCandidateRecordSnapshot>,
pub rejected_record_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EquilibriumCandidateRecordSnapshot {
pub substance: String,
pub library: String,
pub record_key: String,
pub physical_state: Option<String>,
pub elements: Vec<String>,
pub temperature_support: String,
pub library_rank: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EquilibriumReproducibilityCapsule {
pub schema_version: u32,
pub temperature_kelvin: f64,
pub pressure_pa: f64,
pub reference_pressure_pa: f64,
pub layout_fingerprint: u64,
pub selected_record_identity_fingerprint: u64,
pub nist_fallback_policy: String,
pub phases: Vec<EquilibriumPhaseSpecSnapshot>,
pub selected_records: Vec<EquilibriumRecordIdentity>,
pub solve_options: EquilibriumSolveOptionsSnapshot,
pub accepted_backend: String,
pub residual_l2_norm: f64,
pub max_abs_element_balance_error: f64,
pub data_release_label: Option<String>,
pub catalog: Option<ThermoCatalogSnapshot>,
pub candidate_selection: Option<EquilibriumCandidateSelectionSnapshot>,
}
impl EquilibriumReproducibilityCapsule {
pub fn from_outcome(
outcome: &ResolvedPhaseEquilibriumOutcome,
solve_options: &EquilibriumSolveOptions,
) -> Self {
let solution = outcome.solution();
let presentation = crate::Thermodynamics::ChemEquilibrium::equilibrium_presentation::
EquilibriumPresentationReport::from_solution(solution);
let selected_records = presentation
.components
.into_iter()
.map(|component| EquilibriumRecordIdentity {
component: component.component,
phase: component.phase,
substance: component.substance,
library: component.library,
record_key: component.record_key,
lookup_priority: component.lookup_priority,
})
.collect::<Vec<_>>();
let phases = outcome
.resolved()
.phase_specs()
.iter()
.map(|phase| EquilibriumPhaseSpecSnapshot {
phase: phase
.id()
.as_option()
.clone()
.unwrap_or_else(|| "single".to_string()),
physical_state: format!("{:?}", phase.physical_state()),
model: format!("{:?}", phase.model()),
components: phase.components().to_vec(),
})
.collect();
let validation = solution.accepted_solution().validation();
let conditions = solution.conditions();
Self {
schema_version: EQUILIBRIUM_REPRODUCIBILITY_SCHEMA_VERSION,
temperature_kelvin: conditions.temperature(),
pressure_pa: conditions.pressure(),
reference_pressure_pa: conditions.reference_pressure(),
layout_fingerprint: solution.metadata().layout_fingerprint(),
selected_record_identity_fingerprint: selected_record_fingerprint(&selected_records),
nist_fallback_policy: format!("{:?}", outcome.lookup_report().nist_fallback_policy()),
phases,
selected_records,
solve_options: solve_options.reproducibility_snapshot(),
accepted_backend: format!("{:?}", solution.solve_report().accepted_backend),
residual_l2_norm: validation.residual_l2_norm,
max_abs_element_balance_error: validation.max_abs_element_balance_error,
data_release_label: None,
catalog: None,
candidate_selection: None,
}
}
pub fn with_data_release_label(mut self, label: impl Into<String>) -> Self {
let label = label.into();
self.data_release_label = (!label.trim().is_empty()).then_some(label);
self
}
pub fn with_catalog_consistency(mut self, report: &ThermoCatalogConsistencyReport) -> Self {
self.catalog = Some(ThermoCatalogSnapshot::from_report(report));
self
}
pub fn with_candidate_selection(
mut self,
selection: &EquilibriumCandidateSelectionReport,
) -> Self {
self.candidate_selection = Some(EquilibriumCandidateSelectionSnapshot::from_report(
selection,
));
self
}
pub fn to_pretty_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
impl ThermoCatalogSnapshot {
fn from_report(report: &ThermoCatalogConsistencyReport) -> Self {
let mut identities = Vec::new();
identities.push(format!("indexed={}", report.indexed_pair_count()));
identities.push(format!("unique={}", report.unique_indexed_pair_count()));
identities.push(format!("payload={}", report.payload_pair_count()));
identities.extend(
report
.duplicate_index_pairs()
.iter()
.map(|(library, substance)| format!("duplicate:{library}:{substance}")),
);
identities.extend(
report
.indexed_without_payload()
.iter()
.map(|(library, substance)| format!("missing:{library}:{substance}")),
);
identities.extend(
report
.payload_without_index()
.iter()
.map(|(library, substance)| format!("orphan:{library}:{substance}")),
);
Self {
structure_fingerprint: stable_fingerprint(identities.iter().map(String::as_str)),
indexed_pair_count: report.indexed_pair_count(),
unique_indexed_pair_count: report.unique_indexed_pair_count(),
payload_pair_count: report.payload_pair_count(),
duplicate_index_pair_count: report.duplicate_index_pairs().len(),
indexed_without_payload_count: report.indexed_without_payload().len(),
payload_without_index_count: report.payload_without_index().len(),
consistent: report.is_consistent(),
}
}
}
impl EquilibriumCandidateSelectionSnapshot {
fn from_report(report: &EquilibriumCandidateSelectionReport) -> Self {
let policy = report.policy();
Self {
requested_elements: report.requested_elements().to_vec(),
element_mode: format!("{:?}", policy.element_mode()),
library_preference: policy.library_preference().to_vec(),
physical_states: policy
.physical_states()
.map(|states| states.iter().map(|state| format!("{state:?}")).collect()),
temperature_range_kelvin: policy
.temperature_range()
.map(|range| (range.lower(), range.upper())),
max_candidates: policy.max_candidates(),
selected_records: report
.selected()
.iter()
.map(|candidate| EquilibriumCandidateRecordSnapshot {
substance: candidate.substance().to_string(),
library: candidate.library().to_string(),
record_key: candidate.record_key().to_string(),
physical_state: candidate.physical_state().map(|state| format!("{state:?}")),
elements: candidate.elements().to_vec(),
temperature_support: format!("{:?}", candidate.temperature_support()),
library_rank: candidate.library_rank(),
})
.collect(),
rejected_record_count: report.rejected().len(),
}
}
}
fn selected_record_fingerprint(records: &[EquilibriumRecordIdentity]) -> u64 {
stable_fingerprint(records.iter().flat_map(|record| {
[
record.component.as_str(),
record.phase.as_str(),
record.substance.as_str(),
record.library.as_str(),
record.record_key.as_str(),
record.lookup_priority.as_str(),
]
}))
}
fn stable_fingerprint<'a>(parts: impl IntoIterator<Item = &'a str>) -> u64 {
parts
.into_iter()
.flat_map(|part| part.bytes().chain(std::iter::once(0xff)))
.fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
(hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Thermodynamics::thermo_lib_api::ThermoData;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_log_moles::Solvers;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_problem::EquilibriumConditions;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_solver_policy::{
SolverBackend, SolverPolicy,
};
use crate::Thermodynamics::ChemEquilibrium::phase_equilibrium_workflow::PhaseEquilibriumPipelineRequest;
use crate::Thermodynamics::User_PhaseOrSolution::{
SubstanceSystemSpecBuilder, SubstancesContainer,
};
#[test]
fn local_outcome_exports_stable_policy_provenance_and_catalog_evidence() {
let repository = ThermoData::try_default_repository().unwrap();
let spec = SubstanceSystemSpecBuilder::new(SubstancesContainer::SinglePhase(vec![
"N2".to_string(),
"O2".to_string(),
]))
.with_library_priorities(vec!["NASA_gas".to_string()])
.with_search_in_nist(false)
.build()
.unwrap();
let options = EquilibriumSolveOptions::new()
.with_solver_policy(SolverPolicy::Single(SolverBackend::Legacy(Solvers::NR)))
.unwrap();
let outcome = PhaseEquilibriumPipelineRequest::new(
spec,
vec![0.79, 0.21],
EquilibriumConditions::new(500.0, 101_325.0, 101_325.0).unwrap(),
)
.with_repository(repository.clone())
.with_solve_options(options.clone())
.solve()
.unwrap();
let capsule = EquilibriumReproducibilityCapsule::from_outcome(&outcome, &options)
.with_data_release_label("bundled-local-test-data")
.with_catalog_consistency(&repository.consistency_report());
let repeated = EquilibriumReproducibilityCapsule::from_outcome(&outcome, &options)
.with_data_release_label("bundled-local-test-data")
.with_catalog_consistency(&repository.consistency_report());
assert_eq!(capsule, repeated);
assert_eq!(
capsule.schema_version,
EQUILIBRIUM_REPRODUCIBILITY_SCHEMA_VERSION
);
assert_eq!(capsule.selected_records.len(), 2);
assert_eq!(
capsule.solve_options.effective_backend_order,
vec!["Legacy(NR)"]
);
assert!(capsule.selected_record_identity_fingerprint != 0);
assert!(capsule.catalog.is_some());
let json = capsule.to_pretty_json().unwrap();
assert!(json.contains("NASA_gas"));
assert!(json.contains("bundled-local-test-data"));
}
#[test]
fn options_snapshot_expands_the_implicit_production_cascade() {
let snapshot = EquilibriumSolveOptions::new().reproducibility_snapshot();
assert!(snapshot
.effective_backend_order
.iter()
.any(|backend| backend.contains("RustedSciThe")));
assert!(snapshot
.effective_backend_order
.iter()
.any(|backend| backend.contains("Legacy")));
assert_eq!(snapshot.timing_mode, "Disabled");
assert!(!snapshot.execution_control_attached);
}
}