use std::collections::BTreeMap;
use std::fmt;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_constant_cross_validation::EquilibriumConstantCrossValidationStatus;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_nonlinear::ReactionExtentError;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_problem::{
EquilibriumConditions, EquilibriumSolution,
};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_solver_policy::EquilibriumSolveReport;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_timing::{
EquilibriumTimingCollector, EquilibriumTimingReport, EquilibriumTimingStage,
};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_workflows::{
MultiphaseAcceptanceReport, PhaseControlledSolveReport, PhaseStatus,
};
use crate::Thermodynamics::ChemEquilibrium::phase_equilibrium_problem::{
EquilibriumPhaseDescriptor, PhaseEquilibriumBuildReport, PhaseEquilibriumMetadata,
PhaseEquilibriumSolutionBundle,
};
use crate::Thermodynamics::phase_layout::{PhaseComponentId, PhaseId};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MultiphaseEquilibriumSummaryRow {
pub section: &'static str,
pub label: String,
pub value: String,
}
impl fmt::Display for MultiphaseEquilibriumSummaryRow {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] {} = {}", self.section, self.label, self.value)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct MultiphaseEquilibriumSolution {
metadata: PhaseEquilibriumMetadata,
build_report: PhaseEquilibriumBuildReport,
accepted_solution: EquilibriumSolution,
physical_component_moles: Vec<f64>,
phase_totals: Vec<f64>,
mole_fractions: Vec<f64>,
numerical_phase_totals: Vec<f64>,
phase_statuses: Vec<PhaseStatus>,
solve_report: EquilibriumSolveReport,
keq_validation_status: Option<EquilibriumConstantCrossValidationStatus>,
phase_control_report: Option<PhaseControlledSolveReport>,
acceptance_report: Option<MultiphaseAcceptanceReport>,
timing: EquilibriumTimingReport,
}
impl MultiphaseEquilibriumSolution {
pub fn from_fixed_active_bundle(
bundle: PhaseEquilibriumSolutionBundle,
) -> Result<Self, ReactionExtentError> {
let metadata = bundle.metadata().clone();
let build_report = bundle.build_report().clone();
let accepted_solution = bundle.solution().clone();
let solve_report = bundle.solve_report().clone();
let keq_validation_status = bundle.keq_validation_status().cloned();
let timing = *bundle.timing_report();
Self::from_parts(
metadata,
build_report,
accepted_solution,
solve_report,
keq_validation_status,
None,
None,
None,
timing,
)
}
pub(crate) fn from_phase_control_parts(
metadata: PhaseEquilibriumMetadata,
build_report: PhaseEquilibriumBuildReport,
accepted_solution: EquilibriumSolution,
solve_report: EquilibriumSolveReport,
keq_validation_status: Option<EquilibriumConstantCrossValidationStatus>,
phase_control_report: PhaseControlledSolveReport,
acceptance_report: MultiphaseAcceptanceReport,
phase_statuses: Vec<PhaseStatus>,
timing: EquilibriumTimingReport,
) -> Result<Self, ReactionExtentError> {
if acceptance_report.phase_control != phase_control_report {
return Err(ReactionExtentError::InvalidCandidate {
field: "multiphase_acceptance",
message: "acceptance report does not retain the published phase-control report"
.to_string(),
});
}
Self::from_parts(
metadata,
build_report,
accepted_solution,
solve_report,
keq_validation_status,
Some(phase_control_report),
Some(acceptance_report),
Some(phase_statuses),
timing,
)
}
fn from_parts(
metadata: PhaseEquilibriumMetadata,
build_report: PhaseEquilibriumBuildReport,
accepted_solution: EquilibriumSolution,
solve_report: EquilibriumSolveReport,
keq_validation_status: Option<EquilibriumConstantCrossValidationStatus>,
phase_control_report: Option<PhaseControlledSolveReport>,
acceptance_report: Option<MultiphaseAcceptanceReport>,
phase_statuses: Option<Vec<PhaseStatus>>,
timing: EquilibriumTimingReport,
) -> Result<Self, ReactionExtentError> {
if metadata.layout_fingerprint() != build_report.layout_fingerprint() {
return Err(ReactionExtentError::InvalidCandidate {
field: "multiphase_solution_layout",
message: "accepted result and build report have different layout fingerprints"
.to_string(),
});
}
if accepted_solution.conditions() != build_report.conditions() {
return Err(ReactionExtentError::InvalidCandidate {
field: "multiphase_solution_conditions",
message: "accepted result and build report have different thermodynamic conditions"
.to_string(),
});
}
if metadata.components().len() != accepted_solution.moles().len()
|| metadata.components().len() != build_report.components().len()
{
return Err(ReactionExtentError::DimensionMismatch(format!(
"multiphase result has {} components, solution has {} moles, and build report has {} rows",
metadata.components().len(),
accepted_solution.moles().len(),
build_report.components().len(),
)));
}
let phase_statuses =
phase_statuses.unwrap_or_else(|| vec![PhaseStatus::Active; metadata.phases().len()]);
if phase_statuses.len() != metadata.phases().len() {
return Err(ReactionExtentError::DimensionMismatch(format!(
"multiphase result has {} phase descriptors but {} statuses",
metadata.phases().len(),
phase_statuses.len(),
)));
}
let mut phase_totals = Vec::with_capacity(metadata.phases().len());
let mut numerical_phase_totals = Vec::with_capacity(metadata.phases().len());
let mut physical_component_moles = vec![0.0; metadata.components().len()];
let mut mole_fractions = vec![0.0; metadata.components().len()];
for phase in metadata.phases() {
let range = phase.component_range();
let numerical_total = accepted_solution.moles()[range.clone()].iter().sum::<f64>();
if !numerical_total.is_finite() || numerical_total <= 0.0 {
return Err(ReactionExtentError::InvalidCandidate {
field: "multiphase_phase_total",
message: format!(
"phase {:?} has invalid accepted numerical total {numerical_total:e}",
phase.id().as_option()
),
});
}
numerical_phase_totals.push(numerical_total);
let phase_index = phase.index().index();
if phase_statuses[phase_index].is_active() {
for component_index in range.clone() {
physical_component_moles[component_index] =
accepted_solution.moles()[component_index];
mole_fractions[component_index] =
accepted_solution.moles()[component_index] / numerical_total;
}
phase_totals.push(numerical_total);
} else {
phase_totals.push(0.0);
}
}
Ok(Self {
metadata,
build_report,
accepted_solution,
physical_component_moles,
phase_totals,
mole_fractions,
numerical_phase_totals,
phase_statuses,
solve_report,
keq_validation_status,
phase_control_report,
acceptance_report,
timing,
})
}
pub fn conditions(&self) -> EquilibriumConditions {
self.accepted_solution.conditions()
}
pub fn metadata(&self) -> &PhaseEquilibriumMetadata {
&self.metadata
}
pub fn layout_fingerprint(&self) -> u64 {
self.metadata.layout_fingerprint()
}
pub fn build_report(&self) -> &PhaseEquilibriumBuildReport {
&self.build_report
}
pub fn timing_report(&self) -> &EquilibriumTimingReport {
&self.timing
}
pub(crate) fn with_timing_total(mut self, total: std::time::Duration) -> Self {
if self.timing.enabled() {
let mut collector = EquilibriumTimingCollector::from_report(self.timing);
collector.set_total(total);
self.timing = collector.finish();
}
self
}
pub(crate) fn with_timing_stage(
mut self,
stage: EquilibriumTimingStage,
duration: std::time::Duration,
) -> Self {
if self.timing.enabled() {
let mut collector = EquilibriumTimingCollector::from_report(self.timing);
collector.record(stage, duration);
self.timing = collector.finish();
}
self
}
pub fn accepted_solution(&self) -> &EquilibriumSolution {
&self.accepted_solution
}
pub fn component_moles(&self) -> &[f64] {
&self.physical_component_moles
}
pub fn numerical_component_moles(&self) -> &[f64] {
self.accepted_solution.moles()
}
pub fn moles_for(&self, component: &PhaseComponentId) -> Option<f64> {
self.metadata
.component_index(component)
.map(|index| self.physical_component_moles[index])
}
pub fn numerical_moles_for(&self, component: &PhaseComponentId) -> Option<f64> {
self.metadata
.component_index(component)
.map(|index| self.accepted_solution.moles()[index])
}
pub fn mole_fraction_for(&self, component: &PhaseComponentId) -> Option<f64> {
self.metadata
.component_index(component)
.map(|index| self.mole_fractions[index])
}
pub fn phases(&self) -> &[EquilibriumPhaseDescriptor] {
self.metadata.phases()
}
pub fn phase_total(&self, phase: &PhaseId) -> Option<f64> {
self.metadata
.phase_index(phase)
.map(|index| self.phase_totals[index.index()])
}
pub fn numerical_phase_total(&self, phase: &PhaseId) -> Option<f64> {
self.metadata
.phase_index(phase)
.map(|index| self.numerical_phase_totals[index.index()])
}
pub fn phase_status(&self, phase: &PhaseId) -> Option<PhaseStatus> {
self.metadata
.phase_index(phase)
.map(|index| self.phase_statuses[index.index()])
}
pub fn aggregate_moles_by_substance(&self) -> BTreeMap<String, f64> {
let mut totals = BTreeMap::new();
for (descriptor, moles) in self
.metadata
.components()
.iter()
.zip(self.physical_component_moles.iter().copied())
{
*totals
.entry(descriptor.substance().to_string())
.or_insert(0.0) += moles;
}
totals
}
pub fn aggregate_numerical_moles_by_substance(&self) -> BTreeMap<String, f64> {
let mut totals = BTreeMap::new();
for (descriptor, &moles) in self
.metadata
.components()
.iter()
.zip(self.accepted_solution.moles())
{
*totals
.entry(descriptor.substance().to_string())
.or_insert(0.0) += moles;
}
totals
}
pub fn solve_report(&self) -> &EquilibriumSolveReport {
&self.solve_report
}
pub fn keq_validation_status(&self) -> Option<&EquilibriumConstantCrossValidationStatus> {
self.keq_validation_status.as_ref()
}
pub fn phase_control_report(&self) -> Option<&PhaseControlledSolveReport> {
self.phase_control_report.as_ref()
}
pub fn acceptance_report(&self) -> Option<&MultiphaseAcceptanceReport> {
self.acceptance_report.as_ref()
}
pub fn summary_rows(&self) -> Vec<MultiphaseEquilibriumSummaryRow> {
let mut rows = vec![
MultiphaseEquilibriumSummaryRow {
section: "conditions",
label: "temperature_k".to_string(),
value: format!("{:.6}", self.conditions().temperature()),
},
MultiphaseEquilibriumSummaryRow {
section: "conditions",
label: "pressure_pa".to_string(),
value: format!("{:.6}", self.conditions().pressure()),
},
MultiphaseEquilibriumSummaryRow {
section: "layout",
label: "fingerprint".to_string(),
value: self.layout_fingerprint().to_string(),
},
MultiphaseEquilibriumSummaryRow {
section: "backend",
label: "accepted".to_string(),
value: format!("{:?}", self.solve_report.accepted_backend),
},
MultiphaseEquilibriumSummaryRow {
section: "validation",
label: "residual_l2_norm".to_string(),
value: format!(
"{:.6e}",
self.accepted_solution.validation().residual_l2_norm
),
},
];
for (index, phase) in self.metadata.phases().iter().enumerate() {
rows.push(MultiphaseEquilibriumSummaryRow {
section: "phase",
label: phase
.id()
.as_option()
.clone()
.unwrap_or_else(|| "single".to_string()),
value: format!(
"total={:.6e}, status={:?}",
self.phase_totals[index], self.phase_statuses[index]
),
});
}
if let Some(report) = &self.phase_control_report {
rows.push(MultiphaseEquilibriumSummaryRow {
section: "phase_control",
label: "iterations".to_string(),
value: report.iterations.to_string(),
});
rows.push(MultiphaseEquilibriumSummaryRow {
section: "phase_control",
label: "transitions".to_string(),
value: report.transitions.len().to_string(),
});
}
if let Some(report) = &self.acceptance_report {
rows.push(MultiphaseEquilibriumSummaryRow {
section: "acceptance",
label: "complementarity_satisfied".to_string(),
value: report.complementarity.satisfied.to_string(),
});
}
if let Some(status) = &self.keq_validation_status {
rows.push(MultiphaseEquilibriumSummaryRow {
section: "keq_validation",
label: "status".to_string(),
value: match status {
EquilibriumConstantCrossValidationStatus::Compared(report) => {
format!("compared accepted={}", report.accepted)
}
EquilibriumConstantCrossValidationStatus::CanonicalFailed { .. } => {
"canonical_failed".to_string()
}
EquilibriumConstantCrossValidationStatus::ValidatorFailed { .. } => {
"validator_failed".to_string()
}
EquilibriumConstantCrossValidationStatus::ValidatorNotApplicable { .. } => {
"not_applicable".to_string()
}
},
});
}
for (index, component) in self.metadata.components().iter().enumerate() {
rows.push(MultiphaseEquilibriumSummaryRow {
section: "component",
label: component.label(),
value: format!(
"moles={:.6e}, x={:.6e}",
self.physical_component_moles[index], self.mole_fractions[index]
),
});
}
rows
}
}
impl fmt::Display for MultiphaseEquilibriumSolution {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for row in self.summary_rows() {
writeln!(f, "{row}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_multiphase_domain::{
MultiphaseEquilibriumLayout, MultiphaseInitialComposition,
};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_problem::{
EquilibriumConditions, TraceSpeciesSeedPolicy,
};
use crate::Thermodynamics::ChemEquilibrium::phase_equilibrium_problem::{
PhaseEquilibriumBuildRequest, build_phase_equilibrium_problem,
};
use crate::Thermodynamics::User_PhaseOrSolution::{PhaseSpec, ResolvedPhaseSystem};
use crate::Thermodynamics::User_substances::{LibraryPriority, SubsData};
use crate::Thermodynamics::phase_layout::PhaseId;
use super::MultiphaseEquilibriumSolution;
fn accepted_local_nasa_gas(phase_name: &str) -> crate::Thermodynamics::ChemEquilibrium::phase_equilibrium_problem::PhaseEquilibriumSolutionBundle{
let phase_id = PhaseId::new(Some(phase_name.to_string()));
let phase = PhaseSpec::ideal_gas(
phase_id.clone(),
vec!["H2".to_string(), "O2".to_string(), "H2O".to_string()],
)
.unwrap();
let mut data = SubsData::new();
data.substances = vec!["H2".to_string(), "O2".to_string(), "H2O".to_string()];
data.set_multiple_library_priorities(
vec!["NASA_gas".to_string()],
LibraryPriority::Priority,
);
data.search_substances().unwrap();
data.parse_all_thermal_coeffs().unwrap();
let resolved = ResolvedPhaseSystem::new(
vec![phase],
HashMap::from([(Some(phase_name.to_string()), data)]),
)
.unwrap();
let layout = MultiphaseEquilibriumLayout::new(resolved.phase_specs().to_vec()).unwrap();
let composition =
MultiphaseInitialComposition::from_dense(&layout, vec![2.0, 1.0, 0.0]).unwrap();
build_phase_equilibrium_problem(
PhaseEquilibriumBuildRequest::new(
&resolved,
EquilibriumConditions::new(1200.0, 101_325.0, 101_325.0).unwrap(),
composition,
TraceSpeciesSeedPolicy::Absolute { floor: 1e-30 },
Default::default(),
)
.unwrap(),
)
.unwrap()
.solve()
.unwrap()
}
#[test]
fn reconstruction_rejects_metadata_and_build_report_from_different_layouts() {
let gas = accepted_local_nasa_gas("gas");
let other = accepted_local_nasa_gas("other_gas");
assert_ne!(
gas.metadata().layout_fingerprint(),
other.build_report().layout_fingerprint()
);
let error = MultiphaseEquilibriumSolution::from_parts(
gas.metadata().clone(),
other.build_report().clone(),
gas.solution().clone(),
gas.solve_report().clone(),
gas.keq_validation_status().cloned(),
None,
None,
None,
crate::Thermodynamics::ChemEquilibrium::equilibrium_timing::
EquilibriumTimingReport::default(),
)
.unwrap_err();
assert!(matches!(
error,
crate::Thermodynamics::ChemEquilibrium::equilibrium_nonlinear::ReactionExtentError::InvalidCandidate {
field: "multiphase_solution_layout",
..
}
));
}
}