use std::cell::RefCell;
use std::rc::Rc;
use std::time::{Duration, Instant};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_constraints::{
additive_total_enthalpy, EnthalpyScale, EquilibriumConstraint, TemperatureBounds,
};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_execution::{
EquilibriumExecutionControl, EquilibriumProgressEvent, EquilibriumProgressStage,
};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_log_moles::GibbsFn;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_multiphase_domain::{
MultiphaseEquilibriumLayout, MultiphaseInitialComposition,
};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_nonlinear::{
ReactionExtentError, ReactionExtentErrorKind,
};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_ph_formulation::PreparedPhFormulation;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_ph_monolithic::{
solve_monolithic_active_set_candidate, PreparedMonolithicPhRunner,
};
#[cfg(test)]
use crate::Thermodynamics::ChemEquilibrium::equilibrium_ph_nested::safeguarded_interpolation_step;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_ph_nested::{
solve_bracketed_temperature as solve_nested_bracketed_temperature, NestedBracketOptions,
};
pub use crate::Thermodynamics::ChemEquilibrium::equilibrium_ph_nested::{
PhTemperatureStepKind, PhTemperatureTrial, PhTrialInnerEvidence, PhTrialPhaseState,
PhTrialPreparation, PhTrialTimingReport,
};
pub use crate::Thermodynamics::ChemEquilibrium::equilibrium_ph_options::{
PhAcceptanceOptions, PhMonolithicOptions, PhMonotonicityPolicy, PhNestedOptions,
};
pub use crate::Thermodynamics::ChemEquilibrium::equilibrium_ph_thermochemistry::{
MolarEnthalpyFunction, MolarThermoFunction, ResolvedThermochemistry, ThermochemistryProvenance,
};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_problem::LogMolesInitialGuess;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_rst_backend::{
prepare_rst_symbolic_ph_problem, RstPreparedProblem,
};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_solver_policy::{
EquilibriumSolveReport, MultiStartSolveReport,
};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_timing::{
EquilibriumTimingMode, EquilibriumTimingReport,
};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_workflows::{
MultiphaseAcceptanceReport, PhaseControlledSolveReport,
};
use crate::Thermodynamics::ChemEquilibrium::phase_equilibrium_problem::{
build_phase_equilibrium_problem_with_timing, PhaseEquilibriumBuildRequest,
PreparedPhaseEquilibriumTemplate, SupportedPhaseModelPolicy,
};
use crate::Thermodynamics::ChemEquilibrium::phase_equilibrium_solution::MultiphaseEquilibriumSolution;
use crate::Thermodynamics::ChemEquilibrium::phase_equilibrium_workflow::{
solve_resolved_pt, EquilibriumSolveOptions, PhaseControlPolicy, PhaseEquilibriumSolveMode,
ResolvedPhaseEquilibriumRequest,
};
use crate::Thermodynamics::User_PhaseOrSolution::ResolvedPhaseSystem;
impl ResolvedThermochemistry {
pub fn enthalpy_model(&self) -> EnthalpyModel<'static> {
EnthalpyModel {
functions: self.enthalpy_functions(),
heat_capacity: self.heat_capacity_functions(),
}
}
}
#[derive(Clone)]
pub struct EnthalpyModel<'a> {
functions: Vec<MolarEnthalpyFunction<'a>>,
heat_capacity: Vec<Option<MolarEnthalpyFunction<'a>>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct EnthalpyEvaluation {
molar_enthalpies: Vec<f64>,
heat_capacities: Vec<f64>,
total_enthalpy: f64,
partial_temperature_derivative: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhSolveMode {
Monolithic,
NestedTemperature,
Auto,
}
impl Default for PhSolveMode {
fn default() -> Self {
Self::Monolithic
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhSolvePath {
MonolithicFixedActiveSet,
MonolithicPhaseControl,
NestedTemperature,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PhFallbackReason {
error_kind: ReactionExtentErrorKind,
message: String,
}
impl PhFallbackReason {
fn from_error(error: &ReactionExtentError) -> Self {
Self {
error_kind: error.kind(),
message: error.to_string(),
}
}
pub fn error_kind(&self) -> ReactionExtentErrorKind {
self.error_kind
}
pub fn message(&self) -> &str {
&self.message
}
}
impl EnthalpyEvaluation {
pub fn molar_enthalpies(&self) -> &[f64] {
&self.molar_enthalpies
}
pub fn heat_capacities(&self) -> &[f64] {
&self.heat_capacities
}
pub fn total_enthalpy(&self) -> f64 {
self.total_enthalpy
}
pub fn partial_temperature_derivative(&self) -> f64 {
self.partial_temperature_derivative
}
}
impl<'a> EnthalpyModel<'a> {
pub fn from_functions(
functions: Vec<MolarEnthalpyFunction<'a>>,
) -> Result<Self, ReactionExtentError> {
if functions.is_empty() {
return Err(ReactionExtentError::InvalidProblem {
field: "enthalpy_functions",
message: "at least one molar enthalpy function is required".to_string(),
});
}
Ok(Self {
heat_capacity: vec![None; functions.len()],
functions,
})
}
pub fn from_functions_with_heat_capacity(
functions: Vec<MolarEnthalpyFunction<'a>>,
heat_capacity: Vec<Option<MolarEnthalpyFunction<'a>>>,
) -> Result<Self, ReactionExtentError> {
let model = Self::from_functions(functions)?;
if model.functions.len() != heat_capacity.len() {
return Err(ReactionExtentError::DimensionMismatch(format!(
"enthalpy model has {} functions but {} heat-capacity functions",
model.functions.len(),
heat_capacity.len()
)));
}
Ok(Self {
heat_capacity,
..model
})
}
pub fn from_resolved_system(
resolved: &'a ResolvedPhaseSystem,
) -> Result<Self, ReactionExtentError> {
Ok(ResolvedThermochemistry::from_resolved_system(resolved)?.enthalpy_model())
}
pub fn len(&self) -> usize {
self.functions.len()
}
pub fn is_empty(&self) -> bool {
self.functions.is_empty()
}
pub fn evaluate_molar(&self, temperature: f64) -> Result<Vec<f64>, ReactionExtentError> {
if !temperature.is_finite() || temperature <= 0.0 {
return Err(ReactionExtentError::InvalidProblem {
field: "temperature",
message: "enthalpy temperature must be finite and positive".to_string(),
});
}
self.functions
.iter()
.enumerate()
.map(|(index, function)| {
let value = function(temperature)?;
if !value.is_finite() {
return Err(ReactionExtentError::InvalidProblem {
field: "molar_enthalpy",
message: format!("enthalpy function {index} returned a non-finite value"),
});
}
Ok(value)
})
.collect()
}
pub fn evaluate_total(
&self,
moles: &[f64],
temperature: f64,
) -> Result<f64, ReactionExtentError> {
let molar = self.evaluate_molar(temperature)?;
additive_total_enthalpy(moles, &molar)
}
pub fn evaluate_total_with_partial_temperature_derivative(
&self,
moles: &[f64],
temperature: f64,
) -> Result<EnthalpyEvaluation, ReactionExtentError> {
let molar_enthalpies = self.evaluate_molar(temperature)?;
let total_enthalpy = additive_total_enthalpy(moles, &molar_enthalpies)?;
let mut heat_capacities = Vec::with_capacity(self.heat_capacity.len());
for (index, function) in self.heat_capacity.iter().enumerate() {
let Some(function) = function else {
return Err(ReactionExtentError::InvalidProblem {
field: "heat_capacity",
message: format!("heat capacity capability {index} is unavailable"),
});
};
let value = function(temperature)?;
if !value.is_finite() {
return Err(ReactionExtentError::InvalidProblem {
field: "heat_capacity",
message: format!(
"heat capacity capability {index} returned a non-finite value"
),
});
}
heat_capacities.push(value);
}
let partial_temperature_derivative = moles
.iter()
.zip(&heat_capacities)
.map(|(&moles_i, &cp_i)| moles_i * cp_i)
.sum::<f64>();
if !partial_temperature_derivative.is_finite() {
return Err(ReactionExtentError::InvalidProblem {
field: "enthalpy_temperature_derivative",
message: "partial enthalpy temperature derivative is not finite".to_string(),
});
}
Ok(EnthalpyEvaluation {
molar_enthalpies,
heat_capacities,
total_enthalpy,
partial_temperature_derivative,
})
}
}
#[derive(Debug, Clone)]
pub struct PhTemperatureSolveOptions {
pub scaled_enthalpy_tolerance: f64,
pub absolute_enthalpy_tolerance_joules: f64,
pub temperature_tolerance: f64,
pub max_iterations: usize,
pub max_temperature_evaluations: usize,
pub monotonicity_policy: PhMonotonicityPolicy,
pub max_inner_backend_attempts: Option<usize>,
pub max_inner_nonlinear_iterations: Option<usize>,
pub max_phase_control_transitions: Option<usize>,
pub max_wall_time: Option<Duration>,
execution_control: Option<EquilibriumExecutionControl>,
}
impl Default for PhTemperatureSolveOptions {
fn default() -> Self {
Self {
scaled_enthalpy_tolerance: 1.0e-8,
absolute_enthalpy_tolerance_joules: 1.0e-6,
temperature_tolerance: 1.0e-8,
max_iterations: 80,
max_temperature_evaluations: 82,
monotonicity_policy: PhMonotonicityPolicy::default(),
max_inner_backend_attempts: None,
max_inner_nonlinear_iterations: None,
max_phase_control_transitions: None,
max_wall_time: None,
execution_control: None,
}
}
}
impl PhTemperatureSolveOptions {
pub fn with_max_inner_backend_attempts(
mut self,
limit: usize,
) -> Result<Self, ReactionExtentError> {
if limit == 0 {
return Err(ReactionExtentError::InvalidProblem {
field: "max_inner_backend_attempts",
message: "inner backend-attempt budget must be positive".to_string(),
});
}
self.max_inner_backend_attempts = Some(limit);
Ok(self)
}
pub fn without_inner_backend_attempt_limit(mut self) -> Self {
self.max_inner_backend_attempts = None;
self
}
pub fn with_max_inner_nonlinear_iterations(
mut self,
limit: usize,
) -> Result<Self, ReactionExtentError> {
if limit == 0 {
return Err(ReactionExtentError::InvalidProblem {
field: "max_inner_nonlinear_iterations",
message: "inner nonlinear-iteration budget must be positive".to_string(),
});
}
self.max_inner_nonlinear_iterations = Some(limit);
Ok(self)
}
pub fn without_inner_nonlinear_iteration_limit(mut self) -> Self {
self.max_inner_nonlinear_iterations = None;
self
}
pub fn with_max_phase_control_transitions(
mut self,
limit: usize,
) -> Result<Self, ReactionExtentError> {
if limit == 0 {
return Err(ReactionExtentError::InvalidProblem {
field: "max_phase_control_transitions",
message: "phase-transition budget must be positive".to_string(),
});
}
self.max_phase_control_transitions = Some(limit);
Ok(self)
}
pub fn without_phase_control_transition_limit(mut self) -> Self {
self.max_phase_control_transitions = None;
self
}
pub fn with_monotonicity_policy(mut self, policy: PhMonotonicityPolicy) -> Self {
self.monotonicity_policy = policy;
self
}
pub fn validate(&self) -> Result<(), ReactionExtentError> {
if !self.scaled_enthalpy_tolerance.is_finite() || self.scaled_enthalpy_tolerance <= 0.0 {
return Err(ReactionExtentError::InvalidProblem {
field: "scaled_enthalpy_tolerance",
message: "tolerance must be finite and positive".to_string(),
});
}
if !self.absolute_enthalpy_tolerance_joules.is_finite()
|| self.absolute_enthalpy_tolerance_joules <= 0.0
{
return Err(ReactionExtentError::InvalidProblem {
field: "absolute_enthalpy_tolerance_joules",
message: "tolerance must be finite and positive".to_string(),
});
}
if !self.temperature_tolerance.is_finite() || self.temperature_tolerance <= 0.0 {
return Err(ReactionExtentError::InvalidProblem {
field: "temperature_tolerance",
message: "tolerance must be finite and positive".to_string(),
});
}
if self.max_iterations == 0 {
return Err(ReactionExtentError::InvalidProblem {
field: "max_iterations",
message: "maximum iterations must be greater than zero".to_string(),
});
}
if self.max_temperature_evaluations < 2 {
return Err(ReactionExtentError::InvalidProblem {
field: "max_temperature_evaluations",
message: "at least two evaluations are required to test a bracket".to_string(),
});
}
if matches!(self.max_inner_backend_attempts, Some(limit) if limit == 0) {
return Err(ReactionExtentError::InvalidProblem {
field: "max_inner_backend_attempts",
message: "inner backend-attempt budget must be positive when provided".to_string(),
});
}
if matches!(self.max_inner_nonlinear_iterations, Some(limit) if limit == 0) {
return Err(ReactionExtentError::InvalidProblem {
field: "max_inner_nonlinear_iterations",
message: "inner nonlinear-iteration budget must be positive when provided"
.to_string(),
});
}
if matches!(self.max_phase_control_transitions, Some(limit) if limit == 0) {
return Err(ReactionExtentError::InvalidProblem {
field: "max_phase_control_transitions",
message: "phase-transition budget must be positive when provided".to_string(),
});
}
if matches!(self.max_wall_time, Some(limit) if limit.is_zero()) {
return Err(ReactionExtentError::InvalidProblem {
field: "max_wall_time",
message: "wall-time budget must be positive when provided".to_string(),
});
}
Ok(())
}
pub fn accepted_enthalpy_error_limit_joules(&self, scale: EnthalpyScale) -> f64 {
self.absolute_enthalpy_tolerance_joules
.max(self.scaled_enthalpy_tolerance * scale.joules())
}
pub fn accepts_enthalpy_error(&self, error_joules: f64, scale: EnthalpyScale) -> bool {
error_joules.is_finite()
&& error_joules.abs() <= self.accepted_enthalpy_error_limit_joules(scale)
}
pub fn with_execution_control(mut self, control: EquilibriumExecutionControl) -> Self {
self.execution_control = Some(control);
self
}
pub fn execution_control(&self) -> Option<&EquilibriumExecutionControl> {
self.execution_control.as_ref()
}
pub fn acceptance_options(&self) -> Result<PhAcceptanceOptions, ReactionExtentError> {
PhAcceptanceOptions::new(
self.scaled_enthalpy_tolerance,
self.absolute_enthalpy_tolerance_joules,
)
}
pub fn monolithic_options(&self) -> Result<PhMonolithicOptions, ReactionExtentError> {
Ok(PhMonolithicOptions::new(self.acceptance_options()?))
}
pub fn nested_options(&self) -> Result<PhNestedOptions, ReactionExtentError> {
PhNestedOptions::new(
self.acceptance_options()?,
self.temperature_tolerance,
self.max_iterations,
self.max_temperature_evaluations,
self.monotonicity_policy,
self.max_wall_time,
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct PhTemperatureTimingReport {
enabled: bool,
total: Duration,
scalar_orchestration: Duration,
enthalpy_evaluation: Duration,
}
impl PhTemperatureTimingReport {
pub fn enabled(&self) -> bool {
self.enabled
}
pub fn total(&self) -> Duration {
self.total
}
pub fn scalar_orchestration(&self) -> Duration {
self.scalar_orchestration
}
pub fn enthalpy_evaluation(&self) -> Duration {
self.enthalpy_evaluation
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PhMonolithicEvidence {
solve_report: EquilibriumSolveReport,
multi_start_report: Option<MultiStartSolveReport>,
phase_control_report: Option<PhaseControlledSolveReport>,
acceptance_report: Option<MultiphaseAcceptanceReport>,
inner_timing: EquilibriumTimingReport,
}
impl PhMonolithicEvidence {
pub fn solve_report(&self) -> &EquilibriumSolveReport {
&self.solve_report
}
pub fn multi_start_report(&self) -> Option<&MultiStartSolveReport> {
self.multi_start_report.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 inner_timing(&self) -> EquilibriumTimingReport {
self.inner_timing
}
pub fn residual_evaluations(&self) -> usize {
self.solve_report
.attempts
.iter()
.filter_map(|attempt| attempt.metrics.as_ref())
.map(|metrics| metrics.residual_evaluations)
.sum()
}
pub fn jacobian_evaluations(&self) -> usize {
self.solve_report
.attempts
.iter()
.filter_map(|attempt| attempt.metrics.as_ref())
.map(|metrics| metrics.jacobian_evaluations)
.sum()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PhTemperatureSolveReport {
solve_path: PhSolvePath,
fallback_reason: Option<PhFallbackReason>,
target_enthalpy: f64,
enthalpy_scale_joules: f64,
absolute_enthalpy_tolerance_joules: f64,
scaled_enthalpy_tolerance: f64,
max_temperature_evaluations: usize,
initial_temperature_seed: f64,
monotonicity_policy: PhMonotonicityPolicy,
max_inner_backend_attempts: Option<usize>,
max_inner_nonlinear_iterations: Option<usize>,
max_phase_control_transitions: Option<usize>,
max_wall_time: Option<Duration>,
inner_backend_attempts: usize,
inner_nonlinear_iterations: usize,
phase_control_transitions: usize,
fixed_formulation_builds: usize,
fixed_formulation_reuses: usize,
inner_timing: EquilibriumTimingReport,
timing: PhTemperatureTimingReport,
iterations: usize,
trials: Vec<PhTemperatureTrial>,
monolithic_evidence: Option<PhMonolithicEvidence>,
}
impl PhTemperatureSolveReport {
pub fn solve_path(&self) -> PhSolvePath {
self.solve_path
}
pub fn fallback_reason(&self) -> Option<&PhFallbackReason> {
self.fallback_reason.as_ref()
}
pub fn target_enthalpy(&self) -> f64 {
self.target_enthalpy
}
pub fn enthalpy_scale_joules(&self) -> f64 {
self.enthalpy_scale_joules
}
pub fn absolute_enthalpy_tolerance_joules(&self) -> f64 {
self.absolute_enthalpy_tolerance_joules
}
pub fn scaled_enthalpy_tolerance(&self) -> f64 {
self.scaled_enthalpy_tolerance
}
pub fn max_temperature_evaluations(&self) -> usize {
self.max_temperature_evaluations
}
pub fn initial_temperature_seed(&self) -> f64 {
self.initial_temperature_seed
}
pub fn monotonicity_policy(&self) -> PhMonotonicityPolicy {
self.monotonicity_policy
}
pub fn max_inner_backend_attempts(&self) -> Option<usize> {
self.max_inner_backend_attempts
}
pub fn max_inner_nonlinear_iterations(&self) -> Option<usize> {
self.max_inner_nonlinear_iterations
}
pub fn max_phase_control_transitions(&self) -> Option<usize> {
self.max_phase_control_transitions
}
pub fn max_wall_time(&self) -> Option<Duration> {
self.max_wall_time
}
pub fn inner_backend_attempts(&self) -> usize {
self.inner_backend_attempts
}
pub fn inner_nonlinear_iterations(&self) -> usize {
self.inner_nonlinear_iterations
}
pub fn phase_control_transitions(&self) -> usize {
self.phase_control_transitions
}
pub fn fixed_formulation_builds(&self) -> usize {
self.fixed_formulation_builds
}
pub fn fixed_formulation_reuses(&self) -> usize {
self.fixed_formulation_reuses
}
pub fn inner_timing(&self) -> EquilibriumTimingReport {
self.inner_timing
}
pub fn timing(&self) -> PhTemperatureTimingReport {
self.timing
}
pub fn accepted_enthalpy_error_limit_joules(&self) -> f64 {
self.absolute_enthalpy_tolerance_joules
.max(self.scaled_enthalpy_tolerance * self.enthalpy_scale_joules)
}
pub fn iterations(&self) -> usize {
self.iterations
}
pub fn trials(&self) -> &[PhTemperatureTrial] {
&self.trials
}
pub fn monolithic_evidence(&self) -> Option<&PhMonolithicEvidence> {
self.monolithic_evidence.as_ref()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FixedPressureEnthalpySolution {
solution: MultiphaseEquilibriumSolution,
calculated_enthalpy: f64,
target_enthalpy: f64,
report: PhTemperatureSolveReport,
thermochemistry: Option<ResolvedThermochemistry>,
}
impl FixedPressureEnthalpySolution {
pub fn equilibrium(&self) -> &MultiphaseEquilibriumSolution {
&self.solution
}
pub fn temperature(&self) -> f64 {
self.solution.conditions().temperature()
}
pub fn pressure(&self) -> f64 {
self.solution.conditions().pressure()
}
pub fn calculated_enthalpy(&self) -> f64 {
self.calculated_enthalpy
}
pub fn target_enthalpy(&self) -> f64 {
self.target_enthalpy
}
pub fn enthalpy_error(&self) -> f64 {
self.calculated_enthalpy - self.target_enthalpy
}
pub fn scaled_enthalpy_error(&self) -> f64 {
self.enthalpy_error() / self.report.enthalpy_scale_joules()
}
pub fn enthalpy_error_limit_joules(&self) -> f64 {
self.report.accepted_enthalpy_error_limit_joules()
}
pub fn report(&self) -> &PhTemperatureSolveReport {
&self.report
}
pub fn thermochemistry(&self) -> Option<&ResolvedThermochemistry> {
self.thermochemistry.as_ref()
}
}
#[derive(Clone)]
pub struct ResolvedPhaseEnthalpyRequest<'a> {
resolved: ResolvedPhaseSystem,
initial_composition: MultiphaseInitialComposition,
constraint: EquilibriumConstraint,
temperature_bounds: TemperatureBounds,
enthalpy: EnthalpyModel<'a>,
solve_options: EquilibriumSolveOptions,
solve_mode: PhaseEquilibriumSolveMode,
ph_solve_mode: PhSolveMode,
temperature_options: PhTemperatureSolveOptions,
thermochemistry: Option<ResolvedThermochemistry>,
}
impl<'a> ResolvedPhaseEnthalpyRequest<'a> {
#[deprecated(
note = "use ResolvedPhaseEnthalpyRequest::from_resolved_thermochemistry for the canonical production API"
)]
pub fn new<'resolved>(
resolved: &'resolved ResolvedPhaseSystem,
initial_composition: MultiphaseInitialComposition,
constraint: EquilibriumConstraint,
temperature_bounds: TemperatureBounds,
enthalpy: EnthalpyModel<'a>,
) -> Result<Self, ReactionExtentError> {
Self::from_legacy_parts(
resolved,
initial_composition,
constraint,
temperature_bounds,
enthalpy,
)
}
fn from_legacy_parts<'resolved>(
resolved: &'resolved ResolvedPhaseSystem,
initial_composition: MultiphaseInitialComposition,
constraint: EquilibriumConstraint,
temperature_bounds: TemperatureBounds,
enthalpy: EnthalpyModel<'a>,
) -> Result<Self, ReactionExtentError> {
let request = Self {
resolved: resolved.clone(),
initial_composition,
constraint,
temperature_bounds,
enthalpy,
solve_options: EquilibriumSolveOptions::default(),
solve_mode: PhaseEquilibriumSolveMode::fixed_declared_phases(),
ph_solve_mode: PhSolveMode::NestedTemperature,
temperature_options: PhTemperatureSolveOptions::default(),
thermochemistry: None,
};
request.validate()?;
Ok(request)
}
pub fn from_resolved_thermochemistry<'resolved>(
resolved: &'resolved ResolvedPhaseSystem,
initial_composition: MultiphaseInitialComposition,
constraint: EquilibriumConstraint,
temperature_bounds: TemperatureBounds,
thermochemistry: ResolvedThermochemistry,
) -> Result<Self, ReactionExtentError> {
let bundle_bounds = thermochemistry.temperature_bounds();
if temperature_bounds.lower() < bundle_bounds.lower()
|| temperature_bounds.upper() > bundle_bounds.upper()
{
return Err(ReactionExtentError::InvalidProblem {
field: "temperature_bounds",
message: "P,H bounds extend beyond the selected thermochemistry domain".to_string(),
});
}
let enthalpy = thermochemistry.enthalpy_model();
let mut request = Self::from_legacy_parts(
resolved,
initial_composition,
constraint,
temperature_bounds,
enthalpy,
)?;
request.thermochemistry = Some(thermochemistry);
request.ph_solve_mode = PhSolveMode::default();
Ok(request)
}
fn validate(&self) -> Result<(), ReactionExtentError> {
let (target_enthalpy, initial_temperature) = validated_ph_parameters(self.constraint)?;
self.constraint
.validate_temperature(initial_temperature, self.temperature_bounds)?;
if self.initial_composition.moles().len() != self.resolved.layout().component_count() {
return Err(ReactionExtentError::DimensionMismatch(format!(
"initial composition has {} entries but resolved layout has {} components",
self.initial_composition.moles().len(),
self.resolved.layout().component_count()
)));
}
if self.enthalpy.len() != self.initial_composition.moles().len() {
return Err(ReactionExtentError::DimensionMismatch(format!(
"enthalpy model has {} functions but resolved layout has {} components",
self.enthalpy.len(),
self.initial_composition.moles().len()
)));
}
self.temperature_options.validate()?;
let initial_molar = self.enthalpy.evaluate_molar(initial_temperature)?;
EnthalpyScale::from_magnitudes(
target_enthalpy,
self.initial_composition.moles(),
&initial_molar,
)?;
Ok(())
}
pub fn with_solve_options(mut self, options: EquilibriumSolveOptions) -> Self {
self.solve_options = options;
self
}
pub fn with_phase_control_policy(mut self, policy: PhaseControlPolicy) -> Self {
self.solve_mode = PhaseEquilibriumSolveMode::bounded_phase_control(policy);
self
}
pub fn with_ph_solve_mode(mut self, mode: PhSolveMode) -> Self {
self.ph_solve_mode = mode;
self
}
pub fn with_temperature_options(
mut self,
options: PhTemperatureSolveOptions,
) -> Result<Self, ReactionExtentError> {
options.validate()?;
self.temperature_options = options;
Ok(self)
}
pub fn with_initial_composition(
mut self,
composition: MultiphaseInitialComposition,
) -> Result<Self, ReactionExtentError> {
let layout = MultiphaseEquilibriumLayout::new(self.resolved.phase_specs().to_vec())?;
composition.validate_for(&layout)?;
self.initial_composition = composition;
self.validate()?;
Ok(self)
}
pub fn with_target_enthalpy_and_seed(
mut self,
target_enthalpy: crate::Thermodynamics::ChemEquilibrium::equilibrium_constraints::
TotalEnthalpyJoules,
initial_temperature: f64,
) -> Result<Self, ReactionExtentError> {
self.constraint = EquilibriumConstraint::ph_joules(
self.constraint.pressure(),
self.constraint.reference_pressure(),
target_enthalpy,
initial_temperature,
)?;
self.validate()?;
Ok(self)
}
pub fn resolved(&self) -> &ResolvedPhaseSystem {
&self.resolved
}
pub fn initial_composition(&self) -> &MultiphaseInitialComposition {
&self.initial_composition
}
pub fn constraint(&self) -> EquilibriumConstraint {
self.constraint
}
pub fn ph_solve_mode(&self) -> PhSolveMode {
self.ph_solve_mode
}
fn cloned_with_ph_solve_mode(&self, mode: PhSolveMode) -> Self {
Self {
resolved: self.resolved.clone(),
initial_composition: self.initial_composition.clone(),
constraint: self.constraint,
temperature_bounds: self.temperature_bounds,
enthalpy: self.enthalpy.clone(),
solve_options: self.solve_options.clone(),
solve_mode: self.solve_mode.clone(),
ph_solve_mode: mode,
temperature_options: self.temperature_options.clone(),
thermochemistry: self.thermochemistry.clone(),
}
}
pub fn solve_options(&self) -> &EquilibriumSolveOptions {
&self.solve_options
}
pub(crate) fn uses_fixed_declared_phases(&self) -> bool {
matches!(
self.solve_mode,
PhaseEquilibriumSolveMode::FixedDeclaredPhases
)
}
}
pub(crate) struct PreparedPhContinuationState {
prepared: crate::Thermodynamics::ChemEquilibrium::phase_equilibrium_problem::
PreparedFixedActiveProblem,
formulation: PreparedPhFormulation,
thermochemistry: ResolvedThermochemistry,
rst_problem: Option<RstPreparedProblem>,
}
pub(crate) struct PreparedNestedPhContinuationState {
fixed_template: Rc<RefCell<Option<PreparedPhaseEquilibriumTemplate>>>,
}
impl PreparedNestedPhContinuationState {
pub(crate) fn new(
request: &ResolvedPhaseEnthalpyRequest<'_>,
) -> Result<Self, ReactionExtentError> {
request.validate()?;
if !matches!(request.ph_solve_mode, PhSolveMode::NestedTemperature)
|| !request.uses_fixed_declared_phases()
{
return Err(ReactionExtentError::InvalidProblem {
field: "ph_range_nested_template",
message: "nested P,H template requires fixed declared phases".into(),
});
}
Ok(Self {
fixed_template: Rc::new(RefCell::new(None)),
})
}
pub(crate) fn solve(
&self,
request: ResolvedPhaseEnthalpyRequest<'_>,
) -> Result<FixedPressureEnthalpySolution, ReactionExtentError> {
solve_resolved_ph_nested_with_template(request, Some(Rc::clone(&self.fixed_template)))
}
}
impl PreparedPhContinuationState {
pub(crate) fn new(
request: &ResolvedPhaseEnthalpyRequest<'_>,
) -> Result<Self, ReactionExtentError> {
request.validate()?;
if !matches!(request.ph_solve_mode, PhSolveMode::Monolithic)
|| !matches!(
request.solve_mode,
PhaseEquilibriumSolveMode::FixedDeclaredPhases
)
{
return Err(ReactionExtentError::InvalidProblem {
field: "ph_range_prepared_state",
message: "prepared monolithic P,H state requires fixed declared phases".into(),
});
}
let thermochemistry =
request
.thermochemistry
.clone()
.ok_or_else(|| ReactionExtentError::InvalidProblem {
field: "ph_monolithic_thermochemistry",
message: "prepared monolithic P,H state requires resolved thermochemistry"
.into(),
})?;
let (target, initial_temperature) = validated_ph_parameters(request.constraint)?;
let initial_molar_enthalpies = thermochemistry.evaluate_enthalpy(initial_temperature)?;
let scale = EnthalpyScale::from_magnitudes(
target,
request.initial_composition.moles(),
&initial_molar_enthalpies,
)?;
let conditions = request.constraint.conditions_at(initial_temperature)?;
let bundle = build_phase_equilibrium_problem_with_timing(
PhaseEquilibriumBuildRequest::new(
&request.resolved,
conditions,
request.initial_composition.clone(),
request.solve_options.trace_seed_policy(),
SupportedPhaseModelPolicy::default(),
)?,
request.solve_options.timing_mode(),
)?;
let prepared = bundle.into_prepared_fixed_active_problem()?;
let formulation = PreparedPhFormulation::new(
prepared.prepared.clone(),
thermochemistry.clone(),
request.temperature_bounds,
target,
scale,
)?;
let rst_problem = request
.solve_options
.prepares_rst_backend()
.then(|| prepare_rst_symbolic_ph_problem(&formulation))
.transpose()?;
Ok(Self {
prepared,
formulation,
thermochemistry,
rst_problem,
})
}
pub(crate) fn solve(
&mut self,
request: &ResolvedPhaseEnthalpyRequest<'_>,
formulation_reused: bool,
) -> Result<FixedPressureEnthalpySolution, ReactionExtentError> {
let started = Instant::now();
request.validate()?;
let (target, initial_temperature) = validated_ph_parameters(request.constraint)?;
let initial_molar_enthalpies = self
.thermochemistry
.evaluate_enthalpy(initial_temperature)?;
let scale = EnthalpyScale::from_magnitudes(
target,
request.initial_composition.moles(),
&initial_molar_enthalpies,
)?;
let conditions = request.constraint.conditions_at(initial_temperature)?;
let log_seed =
LogMolesInitialGuess::from_initial_moles(request.initial_composition.moles())?;
let gibbs = self
.thermochemistry
.gibbs_snapshot_for_legacy_boundary(initial_temperature)?;
let prepared =
self.prepared
.prepared
.retarget_with_gibbs(conditions, log_seed.clone(), gibbs)?;
self.prepared.prepared = prepared.clone();
self.formulation = self.formulation.retarget(prepared, target, scale)?;
if let Some(rst_problem) = self.rst_problem.as_mut() {
rst_problem.set_ph_parameters(target, scale.joules())?;
}
let options = request.temperature_options.clone();
let acceptance_options = options.monolithic_options()?.acceptance();
let runner = PreparedMonolithicPhRunner::new(
self.formulation.clone(),
request.solve_options.clone().into_settings(),
acceptance_options,
)?;
if let Some(control) = options.execution_control() {
control.check_cancelled()?;
control.report(EquilibriumProgressEvent::new(
EquilibriumProgressStage::FormulationPreparation,
Some(0),
Some(1),
Some(initial_temperature),
));
}
let outcome = runner.solve_from_log_moles_and_temperature_seed(
&log_seed,
initial_temperature,
self.rst_problem.as_ref(),
)?;
let accepted_conditions = request
.constraint
.conditions_at(outcome.snapshot.temperature)?;
let accepted_solution = self.prepared.prepared.accepted_solution_at_conditions(
outcome.snapshot.log_moles.clone(),
outcome.pt_validation.clone(),
accepted_conditions,
)?;
let accepted_gibbs = self
.thermochemistry
.evaluate_gibbs(outcome.snapshot.temperature)?
.into_iter()
.map(|value| Rc::new(move |_| value) as GibbsFn)
.collect::<Vec<_>>();
let accepted_report = self
.prepared
.report
.at_conditions(accepted_conditions, &accepted_gibbs)?;
let solution = MultiphaseEquilibriumSolution::from_fixed_active_parts(
self.prepared.metadata.clone(),
accepted_report,
accepted_solution,
outcome.solve_report.clone(),
self.prepared.timing,
)?;
let timing_enabled = request.solve_options.timing_mode() == EquilibriumTimingMode::Enabled;
let report = PhTemperatureSolveReport {
solve_path: PhSolvePath::MonolithicFixedActiveSet,
fallback_reason: None,
target_enthalpy: target,
enthalpy_scale_joules: scale.joules(),
absolute_enthalpy_tolerance_joules: options.absolute_enthalpy_tolerance_joules,
scaled_enthalpy_tolerance: options.scaled_enthalpy_tolerance,
max_temperature_evaluations: options.max_temperature_evaluations,
initial_temperature_seed: initial_temperature,
monotonicity_policy: options.monotonicity_policy,
max_inner_backend_attempts: options.max_inner_backend_attempts,
max_inner_nonlinear_iterations: options.max_inner_nonlinear_iterations,
max_phase_control_transitions: options.max_phase_control_transitions,
max_wall_time: options.max_wall_time,
inner_backend_attempts: outcome.solve_report.started_attempt_count(),
inner_nonlinear_iterations: outcome.solve_report.nonlinear_iterations(),
phase_control_transitions: 0,
fixed_formulation_builds: usize::from(!formulation_reused),
fixed_formulation_reuses: usize::from(formulation_reused),
inner_timing: *solution.timing_report(),
timing: PhTemperatureTimingReport {
enabled: timing_enabled,
total: if timing_enabled {
started.elapsed()
} else {
Duration::ZERO
},
scalar_orchestration: Duration::ZERO,
enthalpy_evaluation: Duration::ZERO,
},
iterations: 0,
trials: Vec::new(),
monolithic_evidence: Some(PhMonolithicEvidence {
solve_report: solution.solve_report().clone(),
multi_start_report: solution.multi_start_report().cloned(),
phase_control_report: None,
acceptance_report: solution.acceptance_report().cloned(),
inner_timing: *solution.timing_report(),
}),
};
Ok(FixedPressureEnthalpySolution {
solution,
calculated_enthalpy: outcome.snapshot.total_enthalpy,
target_enthalpy: target,
report,
thermochemistry: Some(self.thermochemistry.clone()),
})
}
}
pub fn solve_resolved_ph(
request: ResolvedPhaseEnthalpyRequest<'_>,
) -> Result<FixedPressureEnthalpySolution, ReactionExtentError> {
match request.ph_solve_mode {
PhSolveMode::NestedTemperature => solve_resolved_ph_nested(request),
PhSolveMode::Monolithic => match request.solve_mode {
PhaseEquilibriumSolveMode::FixedDeclaredPhases => solve_resolved_ph_monolithic(request),
PhaseEquilibriumSolveMode::BoundedPhaseControl(_) => {
solve_resolved_ph_monolithic_phase_control(request)
}
},
PhSolveMode::Auto => {
let monolithic_request = request.cloned_with_ph_solve_mode(PhSolveMode::Monolithic);
match solve_resolved_ph(monolithic_request) {
Ok(solution) => Ok(solution),
Err(error) if error.is_retryable_formulation_failure() => {
let fallback_reason = PhFallbackReason::from_error(&error);
match solve_resolved_ph(
request.cloned_with_ph_solve_mode(PhSolveMode::NestedTemperature),
) {
Ok(mut nested) => {
nested.report.fallback_reason = Some(fallback_reason);
Ok(nested)
}
Err(nested) => Err(ReactionExtentError::PhAutoFallbackFailed {
monolithic: Box::new(error),
nested: Box::new(nested),
}),
}
}
Err(error) => Err(error),
}
}
}
}
fn solve_resolved_ph_monolithic_phase_control(
request: ResolvedPhaseEnthalpyRequest<'_>,
) -> Result<FixedPressureEnthalpySolution, ReactionExtentError> {
let started = Instant::now();
request.validate()?;
let thermochemistry = request.thermochemistry.clone().ok_or_else(|| {
ReactionExtentError::InvalidProblem {
field: "ph_monolithic_thermochemistry",
message: "monolithic P,H requires a ResolvedThermochemistry bundle with Gibbs, enthalpy, and Cp capabilities".to_string(),
}
})?;
let (target, initial_temperature) = validated_ph_parameters(request.constraint)?;
let initial_molar_enthalpies = thermochemistry.evaluate_enthalpy(initial_temperature)?;
let scale = EnthalpyScale::from_magnitudes(
target,
request.initial_composition.moles(),
&initial_molar_enthalpies,
)?;
let initial_conditions = request.constraint.conditions_at(initial_temperature)?;
let timing_mode = request.solve_options.timing_mode();
let bundle = build_phase_equilibrium_problem_with_timing(
PhaseEquilibriumBuildRequest::new(
&request.resolved,
initial_conditions,
request.initial_composition.clone(),
request.solve_options.trace_seed_policy(),
SupportedPhaseModelPolicy::default(),
)?,
timing_mode,
)?;
let policy = match request.solve_mode {
PhaseEquilibriumSolveMode::BoundedPhaseControl(policy) => policy,
PhaseEquilibriumSolveMode::FixedDeclaredPhases => {
return Err(ReactionExtentError::InvalidProblem {
field: "ph_monolithic_phase_control",
message: "the bounded monolithic adapter requires a phase-control policy"
.to_string(),
});
}
};
let template = bundle.into_phase_control_template(|manager| {
*manager = policy.into_phase_manager();
})?;
let settings = request.solve_options.clone().into_settings();
let temperature_options = request.temperature_options.clone();
let acceptance_options = temperature_options.monolithic_options()?.acceptance();
let temperature_bounds = request.temperature_bounds;
let mut temperature_seed = initial_temperature;
let solution = template.solve_with_fixed_active_solver(
settings,
|runner, active, seed, species_phase, full_element_totals| {
solve_monolithic_active_set_candidate(
runner,
active,
seed,
species_phase,
full_element_totals,
&thermochemistry,
temperature_bounds,
target,
scale,
&mut temperature_seed,
&acceptance_options,
)
},
)?;
let phase_control_report = solution.phase_control_report().cloned().ok_or_else(|| {
ReactionExtentError::InvalidCandidate {
field: "ph_monolithic_phase_control_report",
message: "bounded monolithic solve published no phase-control report".to_string(),
}
})?;
let calculated_enthalpy = thermochemistry.enthalpy_model().evaluate_total(
solution.component_moles(),
solution.conditions().temperature(),
)?;
let inner_backend_attempts = phase_control_report
.nonlinear_reports
.iter()
.map(|report| report.started_attempt_count())
.sum();
let inner_nonlinear_iterations = phase_control_report
.nonlinear_reports
.iter()
.map(|report| report.nonlinear_iterations())
.sum();
let timing_enabled = timing_mode == EquilibriumTimingMode::Enabled;
let total = started.elapsed();
let report = PhTemperatureSolveReport {
solve_path: PhSolvePath::MonolithicPhaseControl,
fallback_reason: None,
target_enthalpy: target,
enthalpy_scale_joules: scale.joules(),
absolute_enthalpy_tolerance_joules: temperature_options.absolute_enthalpy_tolerance_joules,
scaled_enthalpy_tolerance: temperature_options.scaled_enthalpy_tolerance,
max_temperature_evaluations: temperature_options.max_temperature_evaluations,
initial_temperature_seed: initial_temperature,
monotonicity_policy: temperature_options.monotonicity_policy,
max_inner_backend_attempts: temperature_options.max_inner_backend_attempts,
max_inner_nonlinear_iterations: temperature_options.max_inner_nonlinear_iterations,
max_phase_control_transitions: temperature_options.max_phase_control_transitions,
max_wall_time: temperature_options.max_wall_time,
inner_backend_attempts,
inner_nonlinear_iterations,
phase_control_transitions: phase_control_report.transitions.len(),
fixed_formulation_builds: 0,
fixed_formulation_reuses: 0,
inner_timing: *solution.timing_report(),
timing: PhTemperatureTimingReport {
enabled: timing_enabled,
total: timing_enabled.then_some(total).unwrap_or(Duration::ZERO),
scalar_orchestration: Duration::ZERO,
enthalpy_evaluation: Duration::ZERO,
},
iterations: phase_control_report.iterations,
trials: Vec::new(),
monolithic_evidence: Some(PhMonolithicEvidence {
solve_report: solution.solve_report().clone(),
multi_start_report: solution.multi_start_report().cloned(),
phase_control_report: Some(phase_control_report),
acceptance_report: solution.acceptance_report().cloned(),
inner_timing: *solution.timing_report(),
}),
};
let result = FixedPressureEnthalpySolution {
solution,
calculated_enthalpy,
target_enthalpy: target,
report,
thermochemistry: Some(thermochemistry),
};
if let Some(control) = temperature_options.execution_control() {
control.report(EquilibriumProgressEvent::new(
EquilibriumProgressStage::PublicationCompleted,
Some(1),
Some(1),
Some(result.temperature()),
));
control.check_cancelled()?;
}
Ok(result)
}
fn solve_resolved_ph_monolithic(
request: ResolvedPhaseEnthalpyRequest<'_>,
) -> Result<FixedPressureEnthalpySolution, ReactionExtentError> {
let started = Instant::now();
request.validate()?;
let thermochemistry = request.thermochemistry.clone().ok_or_else(|| {
ReactionExtentError::InvalidProblem {
field: "ph_monolithic_thermochemistry",
message: "monolithic P,H requires a ResolvedThermochemistry bundle with Gibbs, enthalpy, and Cp capabilities".to_string(),
}
})?;
let (target, initial_temperature) = validated_ph_parameters(request.constraint)?;
let initial_molar_enthalpies = thermochemistry.evaluate_enthalpy(initial_temperature)?;
let scale = EnthalpyScale::from_magnitudes(
target,
request.initial_composition.moles(),
&initial_molar_enthalpies,
)?;
let initial_conditions = request.constraint.conditions_at(initial_temperature)?;
let timing_mode = request.solve_options.timing_mode();
let bundle = build_phase_equilibrium_problem_with_timing(
PhaseEquilibriumBuildRequest::new(
&request.resolved,
initial_conditions,
request.initial_composition.clone(),
request.solve_options.trace_seed_policy(),
SupportedPhaseModelPolicy::default(),
)?,
timing_mode,
)?;
let prepared = bundle.into_prepared_fixed_active_problem()?;
let formulation = PreparedPhFormulation::new(
prepared.prepared.clone(),
thermochemistry.clone(),
request.temperature_bounds,
target,
scale,
)?;
let options = request.temperature_options.clone();
let acceptance_options = options.monolithic_options()?.acceptance();
let runner = PreparedMonolithicPhRunner::new(
formulation,
request.solve_options.clone().into_settings(),
acceptance_options,
)?;
if let Some(control) = options.execution_control() {
control.check_cancelled()?;
control.report(EquilibriumProgressEvent::new(
EquilibriumProgressStage::FormulationPreparation,
Some(0),
Some(1),
Some(initial_temperature),
));
}
let outcome = runner.solve_from_temperature_seed(initial_temperature)?;
let conditions = request
.constraint
.conditions_at(outcome.snapshot.temperature)?;
let accepted_solution = prepared.prepared.accepted_solution_at_conditions(
outcome.snapshot.log_moles.clone(),
outcome.pt_validation.clone(),
conditions,
)?;
let accepted_gibbs = thermochemistry
.evaluate_gibbs(outcome.snapshot.temperature)?
.into_iter()
.map(|value| Rc::new(move |_| value) as GibbsFn)
.collect::<Vec<_>>();
let accepted_report = prepared.report.at_conditions(conditions, &accepted_gibbs)?;
let solution = MultiphaseEquilibriumSolution::from_fixed_active_parts(
prepared.metadata,
accepted_report,
accepted_solution,
outcome.solve_report.clone(),
prepared.timing,
)?;
let timing_enabled = timing_mode == EquilibriumTimingMode::Enabled;
let total = started.elapsed();
let report = PhTemperatureSolveReport {
solve_path: PhSolvePath::MonolithicFixedActiveSet,
fallback_reason: None,
target_enthalpy: target,
enthalpy_scale_joules: scale.joules(),
absolute_enthalpy_tolerance_joules: options.absolute_enthalpy_tolerance_joules,
scaled_enthalpy_tolerance: options.scaled_enthalpy_tolerance,
max_temperature_evaluations: options.max_temperature_evaluations,
initial_temperature_seed: initial_temperature,
monotonicity_policy: options.monotonicity_policy,
max_inner_backend_attempts: options.max_inner_backend_attempts,
max_inner_nonlinear_iterations: options.max_inner_nonlinear_iterations,
max_phase_control_transitions: options.max_phase_control_transitions,
max_wall_time: options.max_wall_time,
inner_backend_attempts: outcome.solve_report.started_attempt_count(),
inner_nonlinear_iterations: outcome.solve_report.nonlinear_iterations(),
phase_control_transitions: 0,
fixed_formulation_builds: 1,
fixed_formulation_reuses: 0,
inner_timing: *solution.timing_report(),
timing: PhTemperatureTimingReport {
enabled: timing_enabled,
total: timing_enabled.then_some(total).unwrap_or(Duration::ZERO),
scalar_orchestration: Duration::ZERO,
enthalpy_evaluation: Duration::ZERO,
},
iterations: 0,
trials: Vec::new(),
monolithic_evidence: Some(PhMonolithicEvidence {
solve_report: solution.solve_report().clone(),
multi_start_report: solution.multi_start_report().cloned(),
phase_control_report: None,
acceptance_report: solution.acceptance_report().cloned(),
inner_timing: *solution.timing_report(),
}),
};
let result = FixedPressureEnthalpySolution {
solution,
calculated_enthalpy: outcome.snapshot.total_enthalpy,
target_enthalpy: target,
report,
thermochemistry: Some(thermochemistry),
};
if let Some(control) = options.execution_control() {
control.report(EquilibriumProgressEvent::new(
EquilibriumProgressStage::PublicationCompleted,
Some(1),
Some(1),
Some(result.temperature()),
));
control.check_cancelled()?;
}
Ok(result)
}
fn solve_resolved_ph_nested(
request: ResolvedPhaseEnthalpyRequest<'_>,
) -> Result<FixedPressureEnthalpySolution, ReactionExtentError> {
solve_resolved_ph_nested_with_template(request, None)
}
fn solve_resolved_ph_nested_with_template(
request: ResolvedPhaseEnthalpyRequest<'_>,
shared_template: Option<Rc<RefCell<Option<PreparedPhaseEquilibriumTemplate>>>>,
) -> Result<FixedPressureEnthalpySolution, ReactionExtentError> {
let outer_started = Instant::now();
request.validate()?;
let constraint = request.constraint;
let (target, initial_temperature) = validated_ph_parameters(constraint)?;
let initial_molar = request.enthalpy.evaluate_molar(initial_temperature)?;
let scale = EnthalpyScale::from_magnitudes(
target,
request.initial_composition.moles(),
&initial_molar,
)?;
let bounds = request.temperature_bounds;
let options = request.temperature_options;
let resolved = &request.resolved;
let initial_composition = request.initial_composition.clone();
let enthalpy = request.enthalpy.clone();
let solve_options = request.solve_options.clone();
let solve_mode = request.solve_mode.clone();
let thermochemistry = request.thermochemistry.clone();
let timing_enabled = solve_options.timing_mode() == EquilibriumTimingMode::Enabled;
let execution_control = options.execution_control().cloned();
let publication_execution_control = execution_control.clone();
let evaluator_options = options.clone();
if let Some(control) = &execution_control {
control.check_cancelled()?;
control.report(EquilibriumProgressEvent::new(
EquilibriumProgressStage::FormulationPreparation,
None,
None,
None,
));
}
let fixed_template = shared_template.or_else(|| {
matches!(&solve_mode, PhaseEquilibriumSolveMode::FixedDeclaredPhases)
.then(|| Rc::new(RefCell::new(None)))
});
let fixed_template_for_report = fixed_template.clone();
let template_was_initialized = fixed_template_for_report
.as_ref()
.is_some_and(|template| template.borrow().is_some());
let use_continuation = matches!(&solve_mode, PhaseEquilibriumSolveMode::FixedDeclaredPhases);
let allow_interpolation = use_continuation;
let mut continuation_seed: Option<LogMolesInitialGuess> = None;
let mut temperature_evaluations = 0usize;
let mut inner_backend_attempts_total = 0usize;
let mut inner_nonlinear_iterations_total = 0usize;
let mut phase_control_transitions_total = 0usize;
let nested_evidence = Rc::new(RefCell::new(Vec::<PhNestedTrialEvidence>::new()));
let nested_evidence_for_trial = Rc::clone(&nested_evidence);
let enthalpy_timing = Rc::new(RefCell::new(Duration::ZERO));
let enthalpy_timing_for_trial = Rc::clone(&enthalpy_timing);
let evaluator = move |temperature: f64| {
if temperature_evaluations >= evaluator_options.max_temperature_evaluations {
return Err(ReactionExtentError::InvalidProblem {
field: "temperature_budget",
message: format!(
"outer P,H solve exceeded the global temperature evaluation budget of {}",
evaluator_options.max_temperature_evaluations
),
});
}
if let Some(control) = &execution_control {
control.check_cancelled()?;
control.report(EquilibriumProgressEvent::new(
EquilibriumProgressStage::TemperatureTrialStarted,
Some(temperature_evaluations),
Some(evaluator_options.max_temperature_evaluations),
Some(temperature),
));
}
temperature_evaluations += 1;
if let Some(control) = &execution_control {
control.report(EquilibriumProgressEvent::new(
EquilibriumProgressStage::InnerSolveStarted,
Some(temperature_evaluations - 1),
Some(evaluator_options.max_temperature_evaluations),
Some(temperature),
));
control.check_cancelled()?;
}
let trial_evaluator = PhTrialEvaluator {
resolved,
initial_composition: &initial_composition,
constraint,
enthalpy: &enthalpy,
solve_options: &solve_options,
solve_mode: &solve_mode,
fixed_template: fixed_template.as_deref(),
execution_control: execution_control.as_ref(),
timing_enabled,
};
let trial_continuation_seed = use_continuation.then(|| continuation_seed.take()).flatten();
let outcome = match trial_evaluator.evaluate(temperature, trial_continuation_seed) {
Ok(outcome) => outcome,
Err(error) => {
if !matches!(&error, ReactionExtentError::Cancelled) {
if let Some(control) = &execution_control {
control.report(EquilibriumProgressEvent::new(
EquilibriumProgressStage::TemperatureTrialRejected,
Some(temperature_evaluations - 1),
Some(evaluator_options.max_temperature_evaluations),
Some(temperature),
));
}
}
return Err(error);
}
};
if let Some(control) = &execution_control {
control.report(EquilibriumProgressEvent::new(
EquilibriumProgressStage::InnerSolveCompleted,
Some(temperature_evaluations - 1),
Some(evaluator_options.max_temperature_evaluations),
Some(temperature),
));
for _ in 0..outcome.evidence.phase_control_transitions {
control.report(EquilibriumProgressEvent::new(
EquilibriumProgressStage::PhaseTransitionAccepted,
Some(temperature_evaluations - 1),
Some(evaluator_options.max_temperature_evaluations),
Some(temperature),
));
}
control.check_cancelled()?;
}
let attempts = outcome.evidence.backend_attempts;
inner_backend_attempts_total = inner_backend_attempts_total.saturating_add(attempts);
if let Some(limit) = evaluator_options.max_inner_backend_attempts {
if inner_backend_attempts_total > limit {
return Err(ReactionExtentError::InvalidProblem {
field: "inner_backend_budget",
message: format!(
"outer P,H solve exceeded the global inner backend-attempt budget of {limit}"
),
});
}
}
let nonlinear_iterations = outcome.evidence.nonlinear_iterations;
inner_nonlinear_iterations_total =
inner_nonlinear_iterations_total.saturating_add(nonlinear_iterations);
if let Some(limit) = evaluator_options.max_inner_nonlinear_iterations {
if inner_nonlinear_iterations_total > limit {
return Err(ReactionExtentError::InvalidProblem {
field: "inner_nonlinear_iteration_budget",
message: format!(
"outer P,H solve exceeded the global inner nonlinear-iteration budget of {limit}"
),
});
}
}
phase_control_transitions_total = phase_control_transitions_total
.saturating_add(outcome.evidence.phase_control_transitions);
if let Some(limit) = evaluator_options.max_phase_control_transitions {
if phase_control_transitions_total > limit {
return Err(ReactionExtentError::InvalidProblem {
field: "phase_control_transition_budget",
message: format!(
"outer P,H solve exceeded the global phase-transition budget of {limit}"
),
});
}
}
if use_continuation {
continuation_seed = Some(LogMolesInitialGuess::from_initial_moles(
outcome.solution.component_moles(),
)?);
}
*enthalpy_timing_for_trial.borrow_mut() += outcome.enthalpy_evaluation;
nested_evidence_for_trial
.borrow_mut()
.push(outcome.evidence.clone());
if let Some(control) = &execution_control {
control.check_cancelled()?;
control.report(EquilibriumProgressEvent::new(
EquilibriumProgressStage::TemperatureTrialAccepted,
Some(temperature_evaluations - 1),
Some(evaluator_options.max_temperature_evaluations),
Some(temperature),
));
}
Ok((outcome.solution, outcome.total_enthalpy))
};
let report_options = options.clone();
let scalar_started = Instant::now();
let (solution, total_enthalpy, iterations, mut trials) = solve_bracketed_temperature_from(
bounds,
scale,
target,
Some(initial_temperature),
allow_interpolation,
options,
outer_started,
evaluator,
)?;
if let Some(control) = &publication_execution_control {
control.report(EquilibriumProgressEvent::new(
EquilibriumProgressStage::PublicationStarted,
None,
None,
Some(solution.conditions().temperature()),
));
control.check_cancelled()?;
}
let timing = PhTemperatureTimingReport {
enabled: timing_enabled,
total: if timing_enabled {
outer_started.elapsed()
} else {
Duration::ZERO
},
scalar_orchestration: if timing_enabled {
scalar_started.elapsed()
} else {
Duration::ZERO
},
enthalpy_evaluation: if timing_enabled {
*enthalpy_timing.borrow()
} else {
Duration::ZERO
},
};
let (
inner_backend_attempts,
inner_nonlinear_iterations,
phase_control_transitions,
inner_timing,
) = attach_nested_evidence(&mut trials, &nested_evidence.borrow())?;
let template_is_initialized = fixed_template_for_report
.as_ref()
.is_some_and(|template| template.borrow().is_some());
let fixed_formulation_builds =
usize::from(!template_was_initialized && template_is_initialized);
let fixed_formulation_reuses = if !template_is_initialized {
0
} else if template_was_initialized {
trials.len()
} else {
trials.len().saturating_sub(1)
};
let report = PhTemperatureSolveReport {
solve_path: PhSolvePath::NestedTemperature,
fallback_reason: None,
target_enthalpy: target,
enthalpy_scale_joules: scale.joules(),
absolute_enthalpy_tolerance_joules: report_options.absolute_enthalpy_tolerance_joules,
scaled_enthalpy_tolerance: report_options.scaled_enthalpy_tolerance,
max_temperature_evaluations: report_options.max_temperature_evaluations,
initial_temperature_seed: initial_temperature,
monotonicity_policy: report_options.monotonicity_policy,
max_inner_backend_attempts: report_options.max_inner_backend_attempts,
max_inner_nonlinear_iterations: report_options.max_inner_nonlinear_iterations,
max_phase_control_transitions: report_options.max_phase_control_transitions,
max_wall_time: report_options.max_wall_time,
inner_backend_attempts,
inner_nonlinear_iterations,
phase_control_transitions,
fixed_formulation_builds,
fixed_formulation_reuses,
inner_timing,
timing,
iterations,
trials,
monolithic_evidence: None,
};
let result = FixedPressureEnthalpySolution {
solution,
calculated_enthalpy: total_enthalpy,
target_enthalpy: target,
report,
thermochemistry,
};
if let Some(control) = &publication_execution_control {
control.report(EquilibriumProgressEvent::new(
EquilibriumProgressStage::PublicationCompleted,
None,
None,
Some(result.temperature()),
));
control.check_cancelled()?;
}
Ok(result)
}
#[cfg(test)]
fn solve_bracketed_temperature<T, F>(
bounds: TemperatureBounds,
scale: EnthalpyScale,
target: f64,
options: PhTemperatureSolveOptions,
evaluate: F,
) -> Result<(T, f64, usize, Vec<PhTemperatureTrial>), ReactionExtentError>
where
F: FnMut(f64) -> Result<(T, f64), ReactionExtentError>,
{
solve_bracketed_temperature_from(
bounds,
scale,
target,
None,
true,
options,
Instant::now(),
evaluate,
)
}
fn solve_bracketed_temperature_from<T, F>(
bounds: TemperatureBounds,
scale: EnthalpyScale,
target: f64,
seed_temperature: Option<f64>,
allow_interpolation: bool,
options: PhTemperatureSolveOptions,
started: Instant,
evaluate: F,
) -> Result<(T, f64, usize, Vec<PhTemperatureTrial>), ReactionExtentError>
where
F: FnMut(f64) -> Result<(T, f64), ReactionExtentError>,
{
options.validate()?;
let route_options = options.nested_options()?;
let acceptance = route_options.acceptance();
let nested_options = NestedBracketOptions {
scaled_enthalpy_tolerance: acceptance.scaled_enthalpy_tolerance(),
absolute_enthalpy_tolerance_joules: acceptance.absolute_enthalpy_tolerance_joules(),
temperature_tolerance: route_options.temperature_tolerance(),
max_iterations: route_options.max_iterations(),
max_temperature_evaluations: route_options.max_temperature_evaluations(),
allow_interpolation,
allow_bracketed_sign_search: matches!(
route_options.monotonicity_policy(),
PhMonotonicityPolicy::AllowBracketedSignSearch
),
max_wall_time: route_options.max_wall_time(),
};
let result = solve_nested_bracketed_temperature(
bounds,
scale,
target,
seed_temperature,
nested_options,
started,
evaluate,
)?;
let trials = result
.trials
.into_iter()
.map(PhTemperatureTrial::from)
.collect();
Ok((
result.value,
result.total_enthalpy,
result.iterations,
trials,
))
}
#[derive(Debug, Clone)]
struct PhNestedTrialEvidence {
backend_attempts: usize,
nonlinear_iterations: usize,
phase_control_transitions: usize,
preparation: PhTrialPreparation,
phase_states: Vec<PhTrialPhaseState>,
timing: EquilibriumTimingReport,
trial_timing: PhTrialTimingReport,
inner_evidence: Option<PhTrialInnerEvidence>,
}
struct PhTrialOutcome {
solution: MultiphaseEquilibriumSolution,
total_enthalpy: f64,
enthalpy_evaluation: Duration,
evidence: PhNestedTrialEvidence,
}
struct PhTrialEvaluator<'request, 'enthalpy> {
resolved: &'request ResolvedPhaseSystem,
initial_composition: &'request MultiphaseInitialComposition,
constraint: EquilibriumConstraint,
enthalpy: &'request EnthalpyModel<'enthalpy>,
solve_options: &'request EquilibriumSolveOptions,
solve_mode: &'request PhaseEquilibriumSolveMode,
fixed_template: Option<&'request RefCell<Option<PreparedPhaseEquilibriumTemplate>>>,
execution_control: Option<&'request EquilibriumExecutionControl>,
timing_enabled: bool,
}
impl<'request, 'enthalpy> PhTrialEvaluator<'request, 'enthalpy> {
fn evaluate(
&self,
temperature: f64,
continuation_seed: Option<LogMolesInitialGuess>,
) -> Result<PhTrialOutcome, ReactionExtentError> {
let trial_started = self.timing_enabled.then(Instant::now);
let conditions = self.constraint.conditions_at(temperature)?;
let trial_solve_options = if let Some(control) = self.execution_control {
self.solve_options
.clone()
.with_execution_control(control.clone())
} else {
self.solve_options.clone()
};
let (solution, preparation) = match self.solve_mode {
PhaseEquilibriumSolveMode::FixedDeclaredPhases => {
let mut seeds = vec![LogMolesInitialGuess::from_initial_moles(
self.initial_composition.moles(),
)?];
if let Some(seed) = continuation_seed {
seeds.push(seed);
}
let template =
self.fixed_template
.ok_or_else(|| ReactionExtentError::InvalidProblem {
field: "ph_fixed_template",
message: "fixed P,H trial is missing its prepared formulation"
.to_string(),
})?;
let mut template = template.borrow_mut();
let reused = template.is_some();
if template.is_none() {
let build_request = PhaseEquilibriumBuildRequest::new(
self.resolved,
conditions,
self.initial_composition.clone(),
self.solve_options.trace_seed_policy(),
SupportedPhaseModelPolicy::default(),
)?;
let bundle = build_phase_equilibrium_problem_with_timing(
build_request,
self.solve_options.timing_mode(),
)?;
*template = Some(
bundle.into_temperature_template(
self.solve_options.prepares_rst_backend(),
self.solve_options.timing_mode(),
)?,
);
}
let solution = template
.as_mut()
.ok_or_else(|| ReactionExtentError::InvalidProblem {
field: "ph_fixed_template",
message: "fixed P,H formulation was not initialized".to_string(),
})?
.solve_at_with_initial_guesses(
conditions,
seeds,
trial_solve_options.into_settings(),
self.solve_options.timing_mode(),
)?;
(
solution,
if reused {
PhTrialPreparation::FixedFormulationReused
} else {
PhTrialPreparation::FixedFormulationInitial
},
)
}
PhaseEquilibriumSolveMode::BoundedPhaseControl(policy) => {
let inner = ResolvedPhaseEquilibriumRequest::new(
self.resolved,
conditions,
self.initial_composition.clone(),
)
.with_solve_options(trial_solve_options)
.with_phase_control_policy(policy.clone());
(
solve_resolved_pt(inner)?,
PhTrialPreparation::BoundedPhaseControlIsolated,
)
}
};
let enthalpy_started = self.timing_enabled.then(Instant::now);
let total_enthalpy = self
.enthalpy
.evaluate_total(solution.component_moles(), temperature)?;
let enthalpy_evaluation =
enthalpy_started.map_or(Duration::ZERO, |started| started.elapsed());
let trial_timing = PhTrialTimingReport {
enabled: self.timing_enabled,
total: trial_started.map_or(Duration::ZERO, |started| started.elapsed()),
inner_equilibrium: if self.timing_enabled {
solution.timing_report().total()
} else {
Duration::ZERO
},
enthalpy_evaluation,
};
let evidence = PhNestedTrialEvidence {
backend_attempts: solution.started_backend_attempts(),
nonlinear_iterations: solution.nonlinear_iterations(),
phase_control_transitions: solution.phase_control_transitions(),
preparation,
phase_states: phase_states_from_solution(&solution)?,
timing: *solution.timing_report(),
trial_timing,
inner_evidence: Some(PhTrialInnerEvidence {
solve_report: solution.solve_report().clone(),
multi_start_report: solution.multi_start_report().cloned(),
phase_control_report: solution.phase_control_report().cloned(),
acceptance_report: solution.acceptance_report().cloned(),
}),
};
Ok(PhTrialOutcome {
solution,
total_enthalpy,
enthalpy_evaluation,
evidence,
})
}
}
fn attach_nested_evidence(
trials: &mut [PhTemperatureTrial],
evidence: &[PhNestedTrialEvidence],
) -> Result<(usize, usize, usize, EquilibriumTimingReport), ReactionExtentError> {
if trials.len() != evidence.len() {
return Err(ReactionExtentError::InvalidProblem {
field: "ph_nested_evidence",
message: format!(
"outer trial count {} does not match nested evidence count {}",
trials.len(),
evidence.len()
),
});
}
let mut inner_backend_attempts = 0usize;
let mut inner_nonlinear_iterations = 0usize;
let mut phase_control_transitions = 0usize;
let mut inner_timing = EquilibriumTimingReport::default();
for (trial, evidence) in trials.iter_mut().zip(evidence) {
trial.inner_backend_attempts = evidence.backend_attempts;
trial.inner_nonlinear_iterations = evidence.nonlinear_iterations;
trial.phase_control_transitions = evidence.phase_control_transitions;
trial.preparation = evidence.preparation;
trial.phase_states = evidence.phase_states.clone();
trial.inner_timing = evidence.timing;
trial.timing = evidence.trial_timing;
trial.inner_evidence = evidence.inner_evidence.clone();
inner_backend_attempts = inner_backend_attempts.saturating_add(evidence.backend_attempts);
inner_nonlinear_iterations =
inner_nonlinear_iterations.saturating_add(evidence.nonlinear_iterations);
phase_control_transitions =
phase_control_transitions.saturating_add(evidence.phase_control_transitions);
inner_timing.accumulate(evidence.timing);
}
Ok((
inner_backend_attempts,
inner_nonlinear_iterations,
phase_control_transitions,
inner_timing,
))
}
fn phase_states_from_solution(
solution: &MultiphaseEquilibriumSolution,
) -> Result<Vec<PhTrialPhaseState>, ReactionExtentError> {
solution
.phases()
.iter()
.map(|descriptor| {
let phase = descriptor.id().clone();
let status = solution.phase_status(&phase).ok_or_else(|| {
ReactionExtentError::InvalidProblem {
field: "phase_status",
message: format!(
"accepted equilibrium solution omitted the status for phase '{phase:?}'"
),
}
})?;
Ok(PhTrialPhaseState { phase, status })
})
.collect()
}
fn validated_ph_parameters(
constraint: EquilibriumConstraint,
) -> Result<(f64, f64), ReactionExtentError> {
match constraint {
EquilibriumConstraint::PH {
target_enthalpy,
initial_temperature,
..
} => Ok((target_enthalpy.joules(), initial_temperature)),
EquilibriumConstraint::PT { .. } => Err(ReactionExtentError::InvalidProblem {
field: "constraint",
message: "resolved phase enthalpy workflow requires a PH constraint".to_string(),
}),
}
}
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use crate::Thermodynamics::phase_layout::{PhaseComponentId, PhaseId};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_multiphase_domain::MultiphaseEquilibriumLayout;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_problem::EquilibriumConditions;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_solver_policy::EquilibriumSolveReport;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_workflows::PhaseStatus;
use crate::Thermodynamics::User_PhaseOrSolution::PhaseSpec;
use crate::Thermodynamics::User_substances::{DataType, SubsData};
fn linear_enthalpy_model() -> EnthalpyModel<'static> {
EnthalpyModel::from_functions(vec![Arc::new(|temperature| Ok(temperature * 10.0))]).unwrap()
}
fn synthetic_inner_evidence() -> PhTrialInnerEvidence {
use crate::Thermodynamics::ChemEquilibrium::equilibrium_log_moles::Solvers;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_solver_policy::{
SolverAttemptOutcome, SolverAttemptReport, SolverBackend, SolverPolicy,
};
let backend = SolverBackend::Legacy(Solvers::NR);
PhTrialInnerEvidence {
solve_report: EquilibriumSolveReport {
policy: SolverPolicy::Single(backend),
attempts: vec![SolverAttemptReport {
backend,
outcome: SolverAttemptOutcome::Accepted,
metrics: None,
}],
accepted_backend: backend,
},
multi_start_report: None,
phase_control_report: None,
acceptance_report: None,
}
}
#[test]
fn monolithic_mode_requires_resolved_thermochemistry_capabilities() {
assert_eq!(PhSolveMode::default(), PhSolveMode::Monolithic);
let mut data = SubsData::new();
data.set_substances(vec!["A".to_string()]);
let phase = PhaseSpec::ideal_gas(PhaseId::new(None), vec!["A".to_string()]).unwrap();
let resolved =
ResolvedPhaseSystem::new(vec![phase.clone()], HashMap::from([(None, data)])).unwrap();
let layout = MultiphaseEquilibriumLayout::new(vec![phase]).unwrap();
let composition = MultiphaseInitialComposition::from_dense(&layout, vec![1.0]).unwrap();
let request = ResolvedPhaseEnthalpyRequest::new(
&resolved,
composition,
EquilibriumConstraint::ph(101_325.0, 101_325.0, 0.0, 700.0).unwrap(),
TemperatureBounds::new(300.0, 1_000.0).unwrap(),
EnthalpyModel::from_functions(vec![Arc::new(|_| Ok(0.0))]).unwrap(),
)
.unwrap()
.with_ph_solve_mode(PhSolveMode::Monolithic);
assert_eq!(request.ph_solve_mode(), PhSolveMode::Monolithic);
let stages = Arc::new(Mutex::new(Vec::new()));
let stages_for_sink = Arc::clone(&stages);
let control = EquilibriumExecutionControl::new().with_progress_sink(move |event| {
stages_for_sink.lock().unwrap().push(event.stage());
});
let auto_request = request
.cloned_with_ph_solve_mode(PhSolveMode::Auto)
.with_temperature_options(
PhTemperatureSolveOptions::default().with_execution_control(control),
)
.unwrap();
assert!(matches!(
solve_resolved_ph(request),
Err(ReactionExtentError::InvalidProblem {
field: "ph_monolithic_thermochemistry",
..
})
));
assert!(matches!(
solve_resolved_ph(auto_request),
Err(ReactionExtentError::InvalidProblem {
field: "ph_monolithic_thermochemistry",
..
})
));
assert!(!stages
.lock()
.unwrap()
.contains(&EquilibriumProgressStage::TemperatureTrialStarted));
}
#[test]
fn auto_fallback_reason_preserves_retryable_backend_classification() {
let error = ReactionExtentError::BackendFailure {
backend: "monolithic".to_string(),
kind: crate::Thermodynamics::ChemEquilibrium::equilibrium_nonlinear::
BackendFailureKind::NumericalBreakdown,
message: "synthetic breakdown".to_string(),
};
let reason = PhFallbackReason::from_error(&error);
assert_eq!(reason.error_kind(), ReactionExtentErrorKind::BackendFailure);
assert!(reason.message().contains("monolithic"));
assert!(error.is_retryable_backend_failure());
}
#[test]
fn validated_ph_parameter_extraction_rejects_pt_without_panicking() {
let constraint = EquilibriumConstraint::pt(
EquilibriumConditions::new(700.0, 101_325.0, 101_325.0).unwrap(),
);
let error = validated_ph_parameters(constraint).unwrap_err();
assert!(matches!(
error,
ReactionExtentError::InvalidProblem {
field: "constraint",
..
}
));
}
#[test]
fn additive_enthalpy_derivative_reports_only_the_explicit_temperature_term() {
let model = EnthalpyModel::from_functions_with_heat_capacity(
vec![Arc::new(|temperature| Ok(100.0 + 2.0 * temperature))],
vec![Some(Arc::new(|_| Ok(2.0)))],
)
.unwrap();
let evaluation = model
.evaluate_total_with_partial_temperature_derivative(&[3.0], 400.0)
.unwrap();
assert_eq!(evaluation.molar_enthalpies(), &[900.0]);
assert_eq!(evaluation.heat_capacities(), &[2.0]);
assert_eq!(evaluation.total_enthalpy(), 2_700.0);
assert_eq!(evaluation.partial_temperature_derivative(), 6.0);
}
#[test]
fn additive_enthalpy_derivative_rejects_missing_heat_capacity() {
let model = linear_enthalpy_model();
let error = model
.evaluate_total_with_partial_temperature_derivative(&[1.0], 400.0)
.unwrap_err();
assert!(matches!(
error,
ReactionExtentError::InvalidProblem {
field: "heat_capacity",
..
}
));
}
#[test]
fn sampled_non_monotone_branch_is_rejected_by_default_but_explicit_compatibility_allows_it() {
let branch = |temperature: f64| {
if temperature <= 850.0 {
-1.0 - 99.0 * (temperature - 500.0) / 350.0
} else {
-100.0 + 101.0 * (temperature - 850.0) / 350.0
}
};
let bounds = TemperatureBounds::new(500.0, 1_200.0).unwrap();
let scale = EnthalpyScale::new(1.0).unwrap();
let error = solve_bracketed_temperature(
bounds,
scale,
0.0,
PhTemperatureSolveOptions::default(),
|temperature| Ok((temperature, branch(temperature))),
)
.unwrap_err();
assert!(matches!(
error,
ReactionExtentError::InvalidProblem {
field: "enthalpy_non_monotone",
..
}
));
let compatibility = PhTemperatureSolveOptions::default()
.with_monotonicity_policy(PhMonotonicityPolicy::AllowBracketedSignSearch);
let (temperature, enthalpy, _, _) =
solve_bracketed_temperature(bounds, scale, 0.0, compatibility, |temperature| {
Ok((temperature, branch(temperature)))
})
.unwrap();
assert!((temperature - (850.0 + 100.0 * 350.0 / 101.0)).abs() < 1.0e-5);
assert!(enthalpy.abs() < 1.0e-6);
}
#[test]
fn ph_temperature_seed_is_a_real_trial_and_can_accept_the_root() {
let (temperature, enthalpy, iterations, trials) = solve_bracketed_temperature_from(
TemperatureBounds::new(500.0, 1_000.0).unwrap(),
EnthalpyScale::new(1.0).unwrap(),
0.0,
Some(750.0),
true,
PhTemperatureSolveOptions::default(),
Instant::now(),
|temperature| Ok((temperature, temperature - 750.0)),
)
.unwrap();
assert_eq!(iterations, 0);
assert_eq!(temperature, 750.0);
assert_eq!(enthalpy, 0.0);
assert_eq!(trials.len(), 3);
assert_eq!(trials[2].temperature(), 750.0);
}
#[test]
fn ph_seed_reports_observed_multiple_brackets_instead_of_selecting_one() {
let error = solve_bracketed_temperature_from(
TemperatureBounds::new(500.0, 1_000.0).unwrap(),
EnthalpyScale::new(1.0).unwrap(),
0.0,
Some(750.0),
true,
PhTemperatureSolveOptions::default(),
Instant::now(),
|temperature| Ok((temperature, (temperature - 650.0) * (temperature - 850.0))),
)
.unwrap_err();
assert!(matches!(
error,
ReactionExtentError::InvalidProblem {
field: "enthalpy_multiple_brackets",
..
}
));
}
#[test]
fn safeguarded_scalar_solver_uses_an_interior_secant_step_for_a_linear_branch() {
let (temperature, enthalpy, iterations, trials) = solve_bracketed_temperature(
TemperatureBounds::new(500.0, 1_000.0).unwrap(),
EnthalpyScale::new(1.0).unwrap(),
0.0,
PhTemperatureSolveOptions::default(),
|temperature| Ok((temperature, temperature - 775.0)),
)
.unwrap();
assert_eq!(iterations, 1);
assert_eq!(temperature, 775.0);
assert_eq!(enthalpy, 0.0);
assert_eq!(trials[2].step_kind(), PhTemperatureStepKind::Interpolation);
}
#[test]
fn phase_control_scalar_path_uses_bisection_without_branch_interpolation() {
let (temperature, enthalpy, iterations, trials) = solve_bracketed_temperature_from(
TemperatureBounds::new(500.0, 1_000.0).unwrap(),
EnthalpyScale::new(1.0).unwrap(),
0.0,
None,
false,
PhTemperatureSolveOptions::default(),
Instant::now(),
|temperature| Ok((temperature, temperature - 750.0)),
)
.unwrap();
assert_eq!(iterations, 1);
assert_eq!(temperature, 750.0);
assert_eq!(enthalpy, 0.0);
assert_eq!(trials[2].step_kind(), PhTemperatureStepKind::Bisection);
}
#[test]
fn interpolation_step_falls_back_when_secant_hugs_a_bracket_endpoint() {
assert_eq!(
safeguarded_interpolation_step(0.0, -1.0, 100.0, 100.0),
None
);
assert_eq!(
safeguarded_interpolation_step(0.0, -1.0, 100.0, 1.0),
Some(50.0)
);
}
#[test]
fn derivative_ready_enthalpy_model_rejects_misaligned_capabilities() {
let error = match EnthalpyModel::from_functions_with_heat_capacity(
vec![Arc::new(|temperature| Ok(temperature))],
Vec::new(),
) {
Ok(_) => panic!("misaligned heat-capacity capabilities must be rejected"),
Err(error) => error,
};
assert!(matches!(
error,
ReactionExtentError::DimensionMismatch(message)
if message.contains("heat-capacity functions")
));
}
#[test]
fn bracketed_solver_recovers_known_temperature_without_a_chemical_solver() {
let model = linear_enthalpy_model();
let target = 7_500.0;
let scale = EnthalpyScale::from_magnitudes(target, &[1.0], &[7_000.0]).unwrap();
let options = PhTemperatureSolveOptions {
scaled_enthalpy_tolerance: 1.0e-10,
absolute_enthalpy_tolerance_joules: 1.0e-6,
temperature_tolerance: 1.0e-10,
max_iterations: 80,
max_temperature_evaluations: 82,
monotonicity_policy: PhMonotonicityPolicy::default(),
max_inner_backend_attempts: None,
max_inner_nonlinear_iterations: None,
max_phase_control_transitions: None,
max_wall_time: None,
execution_control: None,
};
let (value, enthalpy, _, _) = solve_bracketed_temperature(
TemperatureBounds::new(500.0, 1_000.0).unwrap(),
scale,
target,
options,
|temperature| Ok((temperature, model.evaluate_total(&[1.0], temperature)?)),
)
.unwrap();
assert!((value - 750.0).abs() < 1.0e-8);
assert!((enthalpy - target).abs() < 1.0e-7);
}
#[test]
fn nonreacting_sensible_heat_fixture_recovers_the_analytic_root() {
let model = EnthalpyModel::from_functions(vec![
Arc::new(|temperature| Ok(100.0 + 4.0 * temperature)),
Arc::new(|temperature| Ok(-50.0 + 2.0 * temperature)),
])
.unwrap();
let moles = [2.0, 3.0];
let target_temperature = 725.0;
let target = model.evaluate_total(&moles, target_temperature).unwrap();
let initial_molar = model.evaluate_molar(500.0).unwrap();
let scale = EnthalpyScale::from_magnitudes(target, &moles, &initial_molar).unwrap();
let (temperature, enthalpy, _, _) = solve_bracketed_temperature(
TemperatureBounds::new(300.0, 1_000.0).unwrap(),
scale,
target,
PhTemperatureSolveOptions::default(),
|temperature| Ok((temperature, model.evaluate_total(&moles, temperature)?)),
)
.unwrap();
assert!((temperature - target_temperature).abs() < 1.0e-5);
assert!((enthalpy - target).abs() < 1.0e-4);
}
#[test]
fn inventory_scaling_preserves_temperature_and_mole_fractions() {
let model = EnthalpyModel::from_functions(vec![
Arc::new(|temperature| Ok(300.0 + 3.0 * temperature)),
Arc::new(|temperature| Ok(-100.0 + 5.0 * temperature)),
])
.unwrap();
let base_moles = [1.5, 2.5];
let scaled_moles = [1_500.0, 2_500.0];
let target_temperature = 810.0;
let base_target = model
.evaluate_total(&base_moles, target_temperature)
.unwrap();
let scaled_target = model
.evaluate_total(&scaled_moles, target_temperature)
.unwrap();
let base_scale = EnthalpyScale::from_magnitudes(
base_target,
&base_moles,
&model.evaluate_molar(400.0).unwrap(),
)
.unwrap();
let scaled_scale = EnthalpyScale::from_magnitudes(
scaled_target,
&scaled_moles,
&model.evaluate_molar(400.0).unwrap(),
)
.unwrap();
let options = PhTemperatureSolveOptions::default();
let (base_temperature, _, _, _) = solve_bracketed_temperature(
TemperatureBounds::new(300.0, 1_000.0).unwrap(),
base_scale,
base_target,
options.clone(),
|temperature| Ok((temperature, model.evaluate_total(&base_moles, temperature)?)),
)
.unwrap();
let (scaled_temperature, _, _, _) = solve_bracketed_temperature(
TemperatureBounds::new(300.0, 1_000.0).unwrap(),
scaled_scale,
scaled_target,
options,
|temperature| {
Ok((
temperature,
model.evaluate_total(&scaled_moles, temperature)?,
))
},
)
.unwrap();
assert!((base_temperature - target_temperature).abs() < 1.0e-5);
assert!((scaled_temperature - base_temperature).abs() < 1.0e-5);
let base_fraction = base_moles[0] / base_moles.iter().sum::<f64>();
let scaled_fraction = scaled_moles[0] / scaled_moles.iter().sum::<f64>();
assert!((base_fraction - scaled_fraction).abs() < 1.0e-15);
}
#[test]
fn ph_constraint_rejects_nonfinite_target_before_solving() {
let error = EquilibriumConstraint::ph(101_325.0, 101_325.0, f64::NAN, 700.0).unwrap_err();
assert!(matches!(
error,
ReactionExtentError::InvalidProblem {
field: "target_enthalpy",
..
}
));
}
#[test]
fn acceptance_contract_keeps_an_absolute_floor_for_zero_target() {
let scale = EnthalpyScale::new(1.0e-12).unwrap();
let options = PhTemperatureSolveOptions {
scaled_enthalpy_tolerance: 1.0e-12,
absolute_enthalpy_tolerance_joules: 1.0e-4,
temperature_tolerance: 1.0e-8,
max_iterations: 4,
max_temperature_evaluations: 6,
monotonicity_policy: PhMonotonicityPolicy::default(),
max_inner_backend_attempts: None,
max_inner_nonlinear_iterations: None,
max_phase_control_transitions: None,
max_wall_time: None,
execution_control: None,
};
assert_eq!(options.accepted_enthalpy_error_limit_joules(scale), 1.0e-4);
assert!(options.accepts_enthalpy_error(5.0e-5, scale));
assert!(!options.accepts_enthalpy_error(2.0e-4, scale));
}
#[test]
fn inner_backend_budget_is_typed_and_rejects_zero() {
let invalid = PhTemperatureSolveOptions {
max_inner_backend_attempts: Some(0),
..PhTemperatureSolveOptions::default()
};
assert!(matches!(
invalid.validate(),
Err(ReactionExtentError::InvalidProblem {
field: "max_inner_backend_attempts",
..
})
));
let limited = PhTemperatureSolveOptions::default()
.with_max_inner_backend_attempts(3)
.unwrap();
assert_eq!(limited.max_inner_backend_attempts, Some(3));
assert_eq!(
limited
.clone()
.without_inner_backend_attempt_limit()
.max_inner_backend_attempts,
None
);
assert!(limited.with_max_inner_backend_attempts(0).is_err());
let iteration_limited = PhTemperatureSolveOptions::default()
.with_max_inner_nonlinear_iterations(7)
.unwrap();
assert_eq!(iteration_limited.max_inner_nonlinear_iterations, Some(7));
assert_eq!(
iteration_limited
.clone()
.without_inner_nonlinear_iteration_limit()
.max_inner_nonlinear_iterations,
None
);
assert!(iteration_limited
.with_max_inner_nonlinear_iterations(0)
.is_err());
let transition_limited = PhTemperatureSolveOptions::default()
.with_max_phase_control_transitions(4)
.unwrap();
assert_eq!(transition_limited.max_phase_control_transitions, Some(4));
assert_eq!(
transition_limited
.clone()
.without_phase_control_transition_limit()
.max_phase_control_transitions,
None
);
assert!(transition_limited
.with_max_phase_control_transitions(0)
.is_err());
}
#[test]
fn bracket_solver_does_not_accept_temperature_width_without_energy_accuracy() {
let scale = EnthalpyScale::new(1.0).unwrap();
let options = PhTemperatureSolveOptions {
scaled_enthalpy_tolerance: 1.0e-12,
absolute_enthalpy_tolerance_joules: 1.0e-12,
temperature_tolerance: 10.0,
max_iterations: 16,
max_temperature_evaluations: 18,
monotonicity_policy: PhMonotonicityPolicy::default(),
max_inner_backend_attempts: None,
max_inner_nonlinear_iterations: None,
max_phase_control_transitions: None,
max_wall_time: None,
execution_control: None,
};
let error = solve_bracketed_temperature(
TemperatureBounds::new(500.0, 1_000.0).unwrap(),
scale,
0.0,
options,
|temperature| Ok(((), if temperature < 750.0 { -1.0 } else { 1.0 })),
)
.unwrap_err();
assert!(matches!(
error,
ReactionExtentError::InvalidProblem {
field: "enthalpy_acceptance",
..
}
));
}
#[test]
fn outer_temperature_budget_is_global_across_bracket_and_midpoints() {
let scale = EnthalpyScale::new(1.0).unwrap();
let options = PhTemperatureSolveOptions {
scaled_enthalpy_tolerance: 1.0e-12,
absolute_enthalpy_tolerance_joules: 1.0e-12,
temperature_tolerance: 1.0e-12,
max_iterations: 80,
max_temperature_evaluations: 2,
monotonicity_policy: PhMonotonicityPolicy::default(),
max_inner_backend_attempts: None,
max_inner_nonlinear_iterations: None,
max_phase_control_transitions: None,
max_wall_time: None,
execution_control: None,
};
let evaluations = std::cell::Cell::new(0usize);
let error = solve_bracketed_temperature(
TemperatureBounds::new(500.0, 1_000.0).unwrap(),
scale,
0.0,
options,
|temperature| {
evaluations.set(evaluations.get() + 1);
Ok((temperature, temperature - 800.0))
},
)
.unwrap_err();
assert_eq!(evaluations.get(), 2);
assert!(matches!(
error,
ReactionExtentError::InvalidProblem {
field: "temperature_budget",
..
}
));
}
#[test]
fn nested_evidence_is_attached_to_trials_without_silent_truncation() {
let mut trials = vec![PhTemperatureTrial {
temperature: 700.0,
step_kind: PhTemperatureStepKind::Bisection,
total_enthalpy: 10.0,
enthalpy_error_joules: 0.0,
scaled_error: 0.0,
inner_backend_attempts: 0,
inner_nonlinear_iterations: 0,
phase_control_transitions: 0,
preparation: PhTrialPreparation::Unspecified,
phase_states: Vec::new(),
inner_timing: EquilibriumTimingReport::default(),
timing: PhTrialTimingReport::default(),
inner_evidence: None,
}];
let evidence = vec![PhNestedTrialEvidence {
backend_attempts: 3,
nonlinear_iterations: 11,
phase_control_transitions: 2,
preparation: PhTrialPreparation::BoundedPhaseControlIsolated,
phase_states: vec![PhTrialPhaseState {
phase: PhaseId::new(Some("condensed".to_string())),
status: PhaseStatus::Appeared,
}],
timing: EquilibriumTimingReport::default(),
trial_timing: PhTrialTimingReport {
enabled: true,
total: Duration::from_millis(3),
inner_equilibrium: Duration::from_millis(2),
enthalpy_evaluation: Duration::from_micros(10),
},
inner_evidence: Some(synthetic_inner_evidence()),
}];
let totals = attach_nested_evidence(&mut trials, &evidence).unwrap();
assert_eq!(totals.0, 3);
assert_eq!(totals.1, 11);
assert_eq!(totals.2, 2);
assert_eq!(trials[0].inner_backend_attempts(), 3);
assert_eq!(trials[0].inner_nonlinear_iterations(), 11);
assert_eq!(trials[0].phase_control_transitions(), 2);
assert_eq!(
trials[0].preparation(),
PhTrialPreparation::BoundedPhaseControlIsolated
);
assert_eq!(trials[0].phase_states().len(), 1);
assert_eq!(trials[0].phase_states()[0].status(), PhaseStatus::Appeared);
assert!(trials[0].timing().enabled());
assert_eq!(trials[0].timing().total(), Duration::from_millis(3));
assert_eq!(
trials[0].timing().inner_equilibrium(),
Duration::from_millis(2)
);
let inner = trials[0]
.inner_evidence()
.expect("accepted nested trial must retain its backend trace");
assert_eq!(inner.solve_report().started_attempt_count(), 1);
assert!(inner.multi_start_report().is_none());
assert!(inner.phase_control_report().is_none());
let mismatch = attach_nested_evidence(&mut trials, &[]).unwrap_err();
assert!(matches!(
mismatch,
ReactionExtentError::InvalidProblem {
field: "ph_nested_evidence",
..
}
));
}
#[test]
fn wall_time_budget_is_validated_and_enforced_by_the_bracket_helper() {
let invalid = PhTemperatureSolveOptions {
max_wall_time: Some(Duration::ZERO),
..PhTemperatureSolveOptions::default()
};
assert!(matches!(
invalid.validate(),
Err(ReactionExtentError::InvalidProblem {
field: "max_wall_time",
..
})
));
let options = PhTemperatureSolveOptions {
max_wall_time: Some(Duration::from_nanos(1)),
..PhTemperatureSolveOptions::default()
};
let error = solve_bracketed_temperature(
TemperatureBounds::new(500.0, 1_000.0).unwrap(),
EnthalpyScale::new(1.0).unwrap(),
0.0,
options,
|temperature| Ok((temperature, temperature - 800.0)),
)
.unwrap_err();
assert!(matches!(
error,
ReactionExtentError::InvalidProblem {
field: "wall_time_budget",
..
}
));
}
#[test]
fn wall_time_budget_rejects_a_trial_that_finishes_after_the_deadline() {
let options = PhTemperatureSolveOptions {
max_wall_time: Some(Duration::from_millis(1)),
..PhTemperatureSolveOptions::default()
};
let error = solve_bracketed_temperature(
TemperatureBounds::new(500.0, 1_000.0).unwrap(),
EnthalpyScale::new(1.0).unwrap(),
0.0,
options,
|temperature| {
std::thread::sleep(Duration::from_millis(5));
Ok((temperature, temperature - 800.0))
},
)
.unwrap_err();
assert!(matches!(
error,
ReactionExtentError::InvalidProblem {
field: "wall_time_budget",
..
}
));
}
#[test]
fn bracket_solver_reports_endpoint_temperature_on_inner_failure() {
let error = solve_bracketed_temperature(
TemperatureBounds::new(500.0, 1_000.0).unwrap(),
EnthalpyScale::new(1.0).unwrap(),
0.0,
PhTemperatureSolveOptions::default(),
|_| {
Err::<((), f64), _>(ReactionExtentError::InvalidProblem {
field: "inner",
message: "synthetic endpoint failure".to_string(),
})
},
)
.unwrap_err();
match error {
ReactionExtentError::TemperatureTrialFailed {
trial_index,
temperature,
cause,
} => {
assert_eq!(trial_index, 0);
assert_eq!(temperature, 500.0);
assert!(matches!(
*cause,
ReactionExtentError::InvalidProblem { field: "inner", .. }
));
}
other => panic!("unexpected endpoint error: {other}"),
}
}
#[test]
fn bracket_solver_reports_midpoint_temperature_on_inner_failure() {
let error = solve_bracketed_temperature(
TemperatureBounds::new(500.0, 1_000.0).unwrap(),
EnthalpyScale::new(1.0).unwrap(),
0.0,
PhTemperatureSolveOptions::default(),
|temperature| {
if temperature == 750.0 {
Err(ReactionExtentError::InvalidProblem {
field: "inner",
message: "synthetic midpoint failure".to_string(),
})
} else if temperature < 750.0 {
Ok((temperature, -1.0))
} else {
Ok((temperature, 100.0))
}
},
)
.unwrap_err();
match error {
ReactionExtentError::TemperatureTrialFailed {
trial_index,
temperature,
cause,
} => {
assert_eq!(trial_index, 2);
assert_eq!(temperature, 750.0);
assert!(matches!(
*cause,
ReactionExtentError::InvalidProblem { field: "inner", .. }
));
}
other => panic!("unexpected midpoint error: {other}"),
}
}
#[test]
fn bracket_solver_keeps_inner_cancellation_as_a_top_level_cancellation() {
let error = solve_bracketed_temperature(
TemperatureBounds::new(500.0, 1_000.0).unwrap(),
EnthalpyScale::new(1.0).unwrap(),
0.0,
PhTemperatureSolveOptions::default(),
|_| Err::<((), f64), _>(ReactionExtentError::Cancelled),
)
.unwrap_err();
assert!(matches!(error, ReactionExtentError::Cancelled));
}
#[test]
fn cancelled_ph_request_stops_before_an_inner_equilibrium_attempt() {
let mut data = SubsData::new();
data.set_substances(vec!["A".to_string()]);
let phase = PhaseSpec::ideal_gas(PhaseId::new(None), vec!["A".to_string()]).unwrap();
let resolved =
ResolvedPhaseSystem::new(vec![phase], HashMap::from([(None, data)])).unwrap();
let layout = MultiphaseEquilibriumLayout::new(vec![PhaseSpec::ideal_gas(
PhaseId::new(None),
vec!["A".to_string()],
)
.unwrap()])
.unwrap();
let composition = MultiphaseInitialComposition::from_dense(&layout, vec![1.0]).unwrap();
let constraint = EquilibriumConstraint::ph(101_325.0, 101_325.0, 0.0, 700.0).unwrap();
let control = EquilibriumExecutionControl::new();
control.request_cancel();
let options = PhTemperatureSolveOptions::default().with_execution_control(control);
let request = ResolvedPhaseEnthalpyRequest::new(
&resolved,
composition,
constraint,
TemperatureBounds::new(300.0, 1_000.0).unwrap(),
EnthalpyModel::from_functions(vec![Arc::new(|_| Ok(0.0))]).unwrap(),
)
.unwrap()
.with_temperature_options(options)
.unwrap();
drop(resolved);
assert!(matches!(
solve_resolved_ph(request),
Err(ReactionExtentError::Cancelled)
));
}
#[test]
fn ph_cancellation_after_inner_start_never_publishes_a_result() {
let mut data = SubsData::new();
data.set_substances(vec!["A".to_string()]);
let phase = PhaseSpec::ideal_gas(PhaseId::new(None), vec!["A".to_string()]).unwrap();
let resolved =
ResolvedPhaseSystem::new(vec![phase], HashMap::from([(None, data)])).unwrap();
let layout = MultiphaseEquilibriumLayout::new(vec![PhaseSpec::ideal_gas(
PhaseId::new(None),
vec!["A".to_string()],
)
.unwrap()])
.unwrap();
let composition = MultiphaseInitialComposition::from_dense(&layout, vec![1.0]).unwrap();
let constraint = EquilibriumConstraint::ph(101_325.0, 101_325.0, 0.0, 700.0).unwrap();
let stages = Arc::new(Mutex::new(Vec::new()));
let stages_for_sink = Arc::clone(&stages);
let control = EquilibriumExecutionControl::new();
let cancellation = control.clone();
let control = control.with_progress_sink(move |event| {
stages_for_sink
.lock()
.expect("progress stage lock is not poisoned")
.push(event.stage());
if event.stage() == EquilibriumProgressStage::InnerSolveStarted {
cancellation.request_cancel();
}
});
let options = PhTemperatureSolveOptions::default().with_execution_control(control);
let request = ResolvedPhaseEnthalpyRequest::new(
&resolved,
composition,
constraint,
TemperatureBounds::new(300.0, 1_000.0).unwrap(),
EnthalpyModel::from_functions(vec![Arc::new(|_| Ok(0.0))]).unwrap(),
)
.unwrap()
.with_temperature_options(options)
.unwrap();
assert!(matches!(
solve_resolved_ph(request),
Err(ReactionExtentError::Cancelled)
));
let stages = stages.lock().unwrap().clone();
assert!(stages.contains(&EquilibriumProgressStage::InnerSolveStarted));
assert!(!stages.contains(&EquilibriumProgressStage::PublicationCompleted));
}
#[test]
fn bracketed_solver_rejects_unreachable_target() {
let model = linear_enthalpy_model();
let scale = EnthalpyScale::new(10_000.0).unwrap();
let error = solve_bracketed_temperature(
TemperatureBounds::new(500.0, 1_000.0).unwrap(),
scale,
20_000.0,
PhTemperatureSolveOptions::default(),
|temperature| Ok((temperature, model.evaluate_total(&[1.0], temperature)?)),
)
.unwrap_err();
assert!(matches!(
error,
ReactionExtentError::InvalidProblem {
field: "enthalpy_bracket",
..
}
));
}
#[test]
fn enthalpy_model_rejects_wrong_component_count() {
let error = match EnthalpyModel::from_functions(Vec::new()) {
Ok(_) => panic!("an empty enthalpy model must be rejected"),
Err(error) => error,
};
assert!(matches!(
error,
ReactionExtentError::InvalidProblem {
field: "enthalpy_functions",
..
}
));
}
#[test]
fn resolved_bridge_does_not_trust_unprovenanced_dh_cache() {
let mut data = SubsData::new();
data.set_substances(vec!["A".to_string()]);
data.therm_map_of_fun.insert(
"A".to_string(),
HashMap::from([(
DataType::dH_fun,
Some(Box::new(|temperature: f64| temperature * 4.0)
as Box<dyn Fn(f64) -> f64 + Send + Sync>),
)]),
);
let phase = PhaseSpec::ideal_gas(PhaseId::new(None), vec!["A".to_string()]).unwrap();
let resolved =
ResolvedPhaseSystem::new(vec![phase], HashMap::from([(None, data)])).unwrap();
let error = match EnthalpyModel::from_resolved_system(&resolved) {
Ok(_) => panic!("an unresolved cache must not bypass thermochemistry provenance"),
Err(error) => error,
};
assert!(matches!(
error,
ReactionExtentError::InvalidProblem {
field: "thermochemistry_provenance",
..
}
));
assert!(resolved
.phase_data()
.get(&None)
.unwrap()
.get_thermo_function("A", DataType::dH_fun)
.is_some());
}
fn bundle_provenance(label: &str) -> ThermochemistryProvenance {
ThermochemistryProvenance::new(
PhaseComponentId::new(PhaseId::new(None), label),
"synthetic",
label,
"gas",
)
}
#[test]
fn thermochemistry_bundle_keeps_order_and_optional_cp_explicit() {
let bounds = TemperatureBounds::new(300.0, 1_000.0).unwrap();
let bundle = ResolvedThermochemistry::from_functions(
vec![bundle_provenance("A"), bundle_provenance("B")],
bounds,
vec![
Arc::new(|temperature| Ok(temperature + 1.0)),
Arc::new(|temperature| Ok(temperature + 2.0)),
],
vec![
Arc::new(|temperature| Ok(2.0 * temperature)),
Arc::new(|temperature| Ok(3.0 * temperature)),
],
vec![Some(Arc::new(|_| Ok(10.0))), None],
)
.unwrap();
assert_eq!(
bundle
.provenance()
.iter()
.map(|row| row.component().label())
.collect::<Vec<_>>(),
vec!["A", "B"]
);
assert_eq!(bundle.evaluate_gibbs(500.0).unwrap(), vec![501.0, 502.0]);
assert_eq!(
bundle.evaluate_enthalpy(500.0).unwrap(),
vec![1_000.0, 1_500.0]
);
assert_eq!(
bundle.evaluate_heat_capacity(500.0).unwrap(),
vec![Some(10.0), None]
);
}
#[test]
fn thermochemistry_bundle_rejects_dimension_mismatch_and_domain_escape() {
let error = match ResolvedThermochemistry::from_functions(
vec![bundle_provenance("A")],
TemperatureBounds::new(300.0, 1_000.0).unwrap(),
vec![Arc::new(|_| Ok(0.0)), Arc::new(|_| Ok(0.0))],
vec![Arc::new(|_| Ok(0.0))],
vec![None],
) {
Ok(_) => panic!("a dimension-mismatched bundle must be rejected"),
Err(error) => error,
};
assert!(matches!(error, ReactionExtentError::DimensionMismatch(_)));
let bundle = ResolvedThermochemistry::from_functions(
vec![bundle_provenance("A")],
TemperatureBounds::new(300.0, 1_000.0).unwrap(),
vec![Arc::new(|_| Ok(0.0))],
vec![Arc::new(|_| Ok(0.0))],
vec![None],
)
.unwrap();
let error = bundle.evaluate_enthalpy(1_001.0).unwrap_err();
assert!(matches!(
error,
ReactionExtentError::InvalidProblem {
field: "temperature",
..
}
));
}
}