use crate::Thermodynamics::ChemEquilibrium::equilibrium_nonlinear::ReactionExtentError;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_solver_policy::{
EquilibriumSolveReport, SolverAttemptOutcome, SolverAttemptReport,
};
use crate::Thermodynamics::ChemEquilibrium::phase_equilibrium_solution::MultiphaseEquilibriumSolution;
use std::fmt::Write;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq)]
pub struct EquilibriumPresentationSummary {
pub temperature_kelvin: f64,
pub pressure_pa: f64,
pub layout_fingerprint: u64,
pub accepted_backend: String,
pub started_backend_attempts: usize,
pub nonlinear_iterations: usize,
pub phase_transitions: usize,
pub residual_l2_norm: f64,
pub max_abs_element_balance_error: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct EquilibriumPhasePresentationRow {
pub phase: String,
pub status: String,
pub physical_total_moles: f64,
pub numerical_total_moles: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct EquilibriumComponentPresentationRow {
pub component: String,
pub phase: String,
pub substance: String,
pub physical_moles: f64,
pub numerical_moles: f64,
pub mole_fraction: f64,
pub initial_moles: f64,
pub standard_gibbs_j_per_mol: f64,
pub library: String,
pub record_key: String,
pub lookup_priority: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EquilibriumBackendAttemptPresentationRow {
pub attempt_index: usize,
pub backend: String,
pub outcome: String,
pub failure_kind: Option<String>,
pub termination: Option<String>,
pub backend_converged: Option<bool>,
pub iterations: Option<usize>,
pub residual_evaluations: Option<usize>,
pub jacobian_evaluations: Option<usize>,
pub linear_solves: Option<usize>,
pub elapsed_millis: Option<u128>,
pub residual_evaluation_micros: Option<u128>,
pub jacobian_evaluation_micros: Option<u128>,
pub solver_overhead_micros: Option<u128>,
pub detail: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EquilibriumTimingPresentationRow {
pub stage: &'static str,
pub duration: Duration,
}
#[derive(Debug, Clone, PartialEq)]
pub struct EquilibriumPresentationReport {
pub summary: EquilibriumPresentationSummary,
pub phases: Vec<EquilibriumPhasePresentationRow>,
pub components: Vec<EquilibriumComponentPresentationRow>,
pub backend_attempts: Vec<EquilibriumBackendAttemptPresentationRow>,
pub timing: Vec<EquilibriumTimingPresentationRow>,
}
impl EquilibriumPresentationReport {
pub fn from_solution(solution: &MultiphaseEquilibriumSolution) -> Self {
let conditions = solution.conditions();
let validation = solution.accepted_solution().validation();
let summary = EquilibriumPresentationSummary {
temperature_kelvin: conditions.temperature(),
pressure_pa: conditions.pressure(),
layout_fingerprint: solution.metadata().layout_fingerprint(),
accepted_backend: format!("{:?}", solution.solve_report().accepted_backend),
started_backend_attempts: solution.started_backend_attempts(),
nonlinear_iterations: solution.nonlinear_iterations(),
phase_transitions: solution.phase_control_transitions(),
residual_l2_norm: validation.residual_l2_norm,
max_abs_element_balance_error: validation.max_abs_element_balance_error,
};
let phases = solution
.phases()
.iter()
.map(|phase| EquilibriumPhasePresentationRow {
phase: phase_label(phase.id().as_option()),
status: solution
.phase_status(phase.id())
.map(|status| format!("{status:?}"))
.unwrap_or_else(|| "Unknown".to_string()),
physical_total_moles: solution.phase_total(phase.id()).unwrap_or(0.0),
numerical_total_moles: solution.numerical_phase_total(phase.id()).unwrap_or(0.0),
})
.collect();
let components = solution
.metadata()
.components()
.iter()
.zip(solution.build_report().components())
.enumerate()
.map(|(index, (component, preparation))| {
let source = preparation.thermo_source();
EquilibriumComponentPresentationRow {
component: component.label(),
phase: phase_label(component.id().phase.as_option()),
substance: component.substance().to_string(),
physical_moles: solution.component_moles()[index],
numerical_moles: solution.numerical_component_moles()[index],
mole_fraction: solution.mole_fraction_for(component.id()).unwrap_or(0.0),
initial_moles: preparation.initial_moles(),
standard_gibbs_j_per_mol: preparation.standard_gibbs_at_conditions(),
library: source.library().to_string(),
record_key: source.record_key().to_string(),
lookup_priority: source.priority().to_string(),
}
})
.collect();
Self {
summary,
phases,
components,
backend_attempts: backend_attempt_rows(solution.solve_report()),
timing: timing_rows(solution.timing_report().enabled(), solution),
}
}
pub fn render_compact(&self) -> String {
let mut output = String::new();
let _ = writeln!(
output,
"equilibrium: T={:.6} K, P={:.6e} Pa, backend={}, residual={:.3e}, balance={:.3e}",
self.summary.temperature_kelvin,
self.summary.pressure_pa,
self.summary.accepted_backend,
self.summary.residual_l2_norm,
self.summary.max_abs_element_balance_error,
);
let _ = writeln!(
output,
"phases: phase | status | physical mol | numerical mol"
);
for phase in &self.phases {
let _ = writeln!(
output,
" {} | {} | {:.6e} | {:.6e}",
phase.phase, phase.status, phase.physical_total_moles, phase.numerical_total_moles,
);
}
let _ = writeln!(output, "components: component | mol | x | library | record");
for component in &self.components {
let _ = writeln!(
output,
" {} | {:.6e} | {:.6e} | {} | {}",
component.component,
component.physical_moles,
component.mole_fraction,
component.library,
component.record_key,
);
}
let _ = writeln!(
output,
"backends: index | backend | outcome | iterations | elapsed ms"
);
for attempt in &self.backend_attempts {
let _ = writeln!(
output,
" {} | {} | {} | {} | {}",
attempt.attempt_index,
attempt.backend,
attempt.outcome,
optional_display(attempt.iterations),
optional_display(attempt.elapsed_millis),
);
}
if !self.timing.is_empty() {
let _ = writeln!(output, "timing: stage | ms");
for timing in &self.timing {
let _ = writeln!(
output,
" {} | {:.3}",
timing.stage,
timing.duration.as_secs_f64() * 1_000.0,
);
}
}
output
}
}
pub fn backend_attempt_rows(
report: &EquilibriumSolveReport,
) -> Vec<EquilibriumBackendAttemptPresentationRow> {
report
.attempts
.iter()
.enumerate()
.map(|(attempt_index, attempt)| backend_attempt_row(attempt_index, attempt))
.collect()
}
pub fn backend_attempt_rows_from_error(
error: &ReactionExtentError,
) -> Option<Vec<EquilibriumBackendAttemptPresentationRow>> {
let attempts = match error {
ReactionExtentError::AllBackendsFailed { attempts }
| ReactionExtentError::CascadeAborted { attempts, .. } => attempts,
_ => return None,
};
Some(
attempts
.iter()
.enumerate()
.map(|(attempt_index, attempt)| backend_attempt_row(attempt_index, attempt))
.collect(),
)
}
fn backend_attempt_row(
attempt_index: usize,
attempt: &SolverAttemptReport,
) -> EquilibriumBackendAttemptPresentationRow {
let (outcome, detail) = match &attempt.outcome {
SolverAttemptOutcome::Accepted => ("accepted".to_string(), None),
SolverAttemptOutcome::Failed { reason, .. } => ("failed".to_string(), Some(reason.clone())),
SolverAttemptOutcome::RejectedCandidate { reason } => {
("rejected_candidate".to_string(), Some(reason.clone()))
}
SolverAttemptOutcome::Skipped { reason } => ("skipped".to_string(), Some(reason.clone())),
};
let metrics = attempt.metrics.as_ref();
let timing = metrics.and_then(|metrics| metrics.evaluation_timing.as_ref());
EquilibriumBackendAttemptPresentationRow {
attempt_index,
backend: format!("{:?}", attempt.backend),
outcome,
failure_kind: attempt.failure_kind().map(|kind| format!("{kind:?}")),
termination: metrics.map(|metrics| format!("{:?}", metrics.termination)),
backend_converged: metrics.map(|metrics| metrics.backend_converged),
iterations: metrics.map(|metrics| metrics.iterations),
residual_evaluations: metrics.map(|metrics| metrics.residual_evaluations),
jacobian_evaluations: metrics.map(|metrics| metrics.jacobian_evaluations),
linear_solves: metrics.map(|metrics| metrics.linear_solves),
elapsed_millis: metrics.map(|metrics| metrics.elapsed_millis),
residual_evaluation_micros: timing.map(|timing| timing.residual_evaluation_micros),
jacobian_evaluation_micros: timing.map(|timing| timing.jacobian_evaluation_micros),
solver_overhead_micros: timing.map(|timing| timing.solver_overhead_micros),
detail,
}
}
fn timing_rows(
enabled: bool,
solution: &MultiphaseEquilibriumSolution,
) -> Vec<EquilibriumTimingPresentationRow> {
if !enabled {
return Vec::new();
}
let timing = solution.timing_report();
[
("total", timing.total()),
("repository_lookup", timing.repository_lookup()),
(
"thermochemistry_preparation",
timing.thermochemistry_preparation(),
),
(
"numeric_closure_construction",
timing.numeric_closure_construction(),
),
("symbolic_construction", timing.symbolic_construction()),
("equation_construction", timing.equation_construction()),
(
"numerical_problem_preparation",
timing.numerical_problem_preparation(),
),
("projection_build", timing.projection_build()),
("nonlinear_solve", timing.nonlinear_solve()),
("phase_control", timing.phase_control()),
("validation", timing.validation()),
("postprocessing", timing.postprocessing()),
]
.into_iter()
.map(|(stage, duration)| EquilibriumTimingPresentationRow { stage, duration })
.collect()
}
fn phase_label(phase: &Option<String>) -> String {
phase.clone().unwrap_or_else(|| "single".to_string())
}
fn optional_display<T: std::fmt::Display>(value: Option<T>) -> String {
value.map_or_else(|| "-".to_string(), |value| value.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_log_moles::Solvers;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_solver_policy::{
SolverAttemptFailureKind, SolverAttemptMetrics, SolverBackend, SolverEvaluationTiming,
SolverPolicy, SolverTermination,
};
use crate::Thermodynamics::ChemEquilibrium::prelude::{
EquilibriumConditions, EquilibriumSolveOptions, LegacyEquilibriumSolver,
PhaseEquilibriumPipelineRequest, SubstanceSystemSpecBuilder, SubstancesContainer,
};
#[test]
fn backend_rows_preserve_outcomes_metrics_and_diagnostics() {
let legacy_lm = SolverBackend::Legacy(Solvers::LM);
let legacy_nr = SolverBackend::Legacy(Solvers::NR);
let legacy_tr = SolverBackend::Legacy(Solvers::TR);
let report = EquilibriumSolveReport {
policy: SolverPolicy::Cascade(vec![legacy_lm, legacy_nr, legacy_tr]),
attempts: vec![
SolverAttemptReport {
backend: legacy_lm,
outcome: SolverAttemptOutcome::Failed {
kind: SolverAttemptFailureKind::Backend,
reason: "step rejected".to_string(),
},
metrics: Some(SolverAttemptMetrics {
termination: SolverTermination::Stagnation,
backend_converged: false,
iterations: 7,
residual_evaluations: 8,
jacobian_evaluations: 7,
linear_solves: 7,
elapsed_millis: 12,
evaluation_timing: Some(SolverEvaluationTiming {
residual_evaluation_micros: 20,
jacobian_evaluation_micros: 30,
solver_overhead_micros: 40,
}),
}),
},
SolverAttemptReport {
backend: legacy_nr,
outcome: SolverAttemptOutcome::Accepted,
metrics: None,
},
SolverAttemptReport {
backend: legacy_tr,
outcome: SolverAttemptOutcome::Skipped {
reason: "cascade budget exhausted".to_string(),
},
metrics: None,
},
],
accepted_backend: legacy_nr,
};
let rows = backend_attempt_rows(&report);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].outcome, "failed");
assert_eq!(rows[0].failure_kind.as_deref(), Some("Backend"));
assert_eq!(rows[0].iterations, Some(7));
assert_eq!(rows[0].jacobian_evaluation_micros, Some(30));
assert_eq!(rows[1].outcome, "accepted");
assert_eq!(rows[1].detail, None);
assert_eq!(rows[2].outcome, "skipped");
assert_eq!(rows[2].detail.as_deref(), Some("cascade budget exhausted"));
}
#[test]
fn failed_cascade_projects_the_same_attempt_trace_without_a_solution() {
let error = ReactionExtentError::AllBackendsFailed {
attempts: vec![SolverAttemptReport {
backend: SolverBackend::Legacy(Solvers::TR),
outcome: SolverAttemptOutcome::RejectedCandidate {
reason: "physical balances did not pass acceptance".to_string(),
},
metrics: None,
}],
};
let rows = backend_attempt_rows_from_error(&error)
.expect("all-backends failure must retain attempt rows");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].outcome, "rejected_candidate");
assert_eq!(
rows[0].detail.as_deref(),
Some("physical balances did not pass acceptance")
);
assert!(backend_attempt_rows_from_error(&ReactionExtentError::Cancelled).is_none());
}
#[test]
fn compact_renderer_keeps_missing_backend_metrics_explicit() {
let report = EquilibriumPresentationReport {
summary: EquilibriumPresentationSummary {
temperature_kelvin: 900.0,
pressure_pa: 101_325.0,
layout_fingerprint: 7,
accepted_backend: "Legacy(NR)".to_string(),
started_backend_attempts: 1,
nonlinear_iterations: 4,
phase_transitions: 0,
residual_l2_norm: 1.0e-9,
max_abs_element_balance_error: 2.0e-12,
},
phases: Vec::new(),
components: Vec::new(),
backend_attempts: vec![EquilibriumBackendAttemptPresentationRow {
attempt_index: 0,
backend: "Legacy(NR)".to_string(),
outcome: "accepted".to_string(),
failure_kind: None,
termination: None,
backend_converged: None,
iterations: None,
residual_evaluations: None,
jacobian_evaluations: None,
linear_solves: None,
elapsed_millis: None,
residual_evaluation_micros: None,
jacobian_evaluation_micros: None,
solver_overhead_micros: None,
detail: None,
}],
timing: Vec::new(),
};
let rendered = report.render_compact();
assert!(rendered.contains("equilibrium: T=900.000000 K"));
assert!(rendered.contains("0 | Legacy(NR) | accepted | - | -"));
}
#[test]
fn accepted_local_solution_projects_physical_rows_and_lookup_provenance() {
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()
.expect("offline local-NASA specification must validate");
let options = EquilibriumSolveOptions::new()
.with_solver_policy(SolverPolicy::Single(SolverBackend::Legacy(
LegacyEquilibriumSolver::NR,
)))
.expect("single legacy-NR policy must validate");
let outcome = PhaseEquilibriumPipelineRequest::new(
spec,
vec![0.79, 0.21],
EquilibriumConditions::new(500.0, 101_325.0, 101_325.0).unwrap(),
)
.with_solve_options(options)
.solve()
.expect("offline local-NASA point must solve");
let presentation = EquilibriumPresentationReport::from_solution(outcome.solution());
assert_eq!(presentation.phases.len(), 1);
assert_eq!(presentation.phases[0].phase, "single");
assert_eq!(presentation.components.len(), 2);
assert_eq!(presentation.components[0].component, "N2");
assert_eq!(presentation.components[1].component, "O2");
assert!(presentation
.components
.iter()
.all(|component| component.library == "NASA_gas"));
assert!(presentation
.components
.iter()
.all(|component| component.physical_moles.is_finite()));
assert!(presentation
.render_compact()
.contains("components: component | mol | x | library | record"));
}
}