use std::time::{Duration, Instant};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_constraints::{
EnthalpyScale, TemperatureBounds,
};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_nonlinear::ReactionExtentError;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_solver_policy::{
EquilibriumSolveReport, MultiStartSolveReport,
};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_timing::EquilibriumTimingReport;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_workflows::{
MultiphaseAcceptanceReport, PhaseControlledSolveReport, PhaseStatus,
};
use crate::Thermodynamics::phase_layout::PhaseId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PhTrialPhaseState {
pub(crate) phase: PhaseId,
pub(crate) status: PhaseStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhTemperatureStepKind {
LowerBound,
UpperBound,
Seed,
Interpolation,
Bisection,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhTrialPreparation {
Unspecified,
FixedFormulationInitial,
FixedFormulationReused,
BoundedPhaseControlIsolated,
}
impl PhTrialPhaseState {
pub fn phase(&self) -> &PhaseId {
&self.phase
}
pub fn status(&self) -> PhaseStatus {
self.status
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PhTrialInnerEvidence {
pub(crate) solve_report: EquilibriumSolveReport,
pub(crate) multi_start_report: Option<MultiStartSolveReport>,
pub(crate) phase_control_report: Option<PhaseControlledSolveReport>,
pub(crate) acceptance_report: Option<MultiphaseAcceptanceReport>,
}
impl PhTrialInnerEvidence {
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 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 PhTemperatureTrial {
pub(crate) temperature: f64,
pub(crate) step_kind: PhTemperatureStepKind,
pub(crate) total_enthalpy: f64,
pub(crate) enthalpy_error_joules: f64,
pub(crate) scaled_error: f64,
pub(crate) inner_backend_attempts: usize,
pub(crate) inner_nonlinear_iterations: usize,
pub(crate) phase_control_transitions: usize,
pub(crate) preparation: PhTrialPreparation,
pub(crate) phase_states: Vec<PhTrialPhaseState>,
pub(crate) inner_timing: EquilibriumTimingReport,
pub(crate) timing: PhTrialTimingReport,
pub(crate) inner_evidence: Option<PhTrialInnerEvidence>,
}
impl PhTemperatureTrial {
pub fn temperature(&self) -> f64 {
self.temperature
}
pub fn step_kind(&self) -> PhTemperatureStepKind {
self.step_kind
}
pub fn total_enthalpy(&self) -> f64 {
self.total_enthalpy
}
pub fn enthalpy_error_joules(&self) -> f64 {
self.enthalpy_error_joules
}
pub fn scaled_error(&self) -> f64 {
self.scaled_error
}
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 preparation(&self) -> PhTrialPreparation {
self.preparation
}
pub fn phase_states(&self) -> &[PhTrialPhaseState] {
&self.phase_states
}
pub fn inner_timing(&self) -> EquilibriumTimingReport {
self.inner_timing
}
pub fn timing(&self) -> PhTrialTimingReport {
self.timing
}
pub fn inner_evidence(&self) -> Option<&PhTrialInnerEvidence> {
self.inner_evidence.as_ref()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct PhTrialTimingReport {
pub(crate) enabled: bool,
pub(crate) total: Duration,
pub(crate) inner_equilibrium: Duration,
pub(crate) enthalpy_evaluation: Duration,
}
impl PhTrialTimingReport {
pub fn enabled(&self) -> bool {
self.enabled
}
pub fn total(&self) -> Duration {
self.total
}
pub fn inner_equilibrium(&self) -> Duration {
self.inner_equilibrium
}
pub fn enthalpy_evaluation(&self) -> Duration {
self.enthalpy_evaluation
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct NestedBracketOptions {
pub(crate) scaled_enthalpy_tolerance: f64,
pub(crate) absolute_enthalpy_tolerance_joules: f64,
pub(crate) temperature_tolerance: f64,
pub(crate) max_iterations: usize,
pub(crate) max_temperature_evaluations: usize,
pub(crate) allow_interpolation: bool,
pub(crate) allow_bracketed_sign_search: bool,
pub(crate) max_wall_time: Option<Duration>,
}
impl NestedBracketOptions {
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 self.max_wall_time.is_some_and(|limit| limit.is_zero()) {
return Err(ReactionExtentError::InvalidProblem {
field: "max_wall_time",
message: "wall-time budget must be positive when provided".to_string(),
});
}
Ok(())
}
fn accepts(&self, error_joules: f64, scale: EnthalpyScale) -> bool {
error_joules.is_finite()
&& error_joules.abs()
<= self
.absolute_enthalpy_tolerance_joules
.max(self.scaled_enthalpy_tolerance * scale.joules())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NestedStepKind {
LowerBound,
UpperBound,
Seed,
Interpolation,
Bisection,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct NestedTrial {
pub(crate) temperature: f64,
pub(crate) step_kind: NestedStepKind,
pub(crate) total_enthalpy: f64,
pub(crate) enthalpy_error_joules: f64,
pub(crate) scaled_error: f64,
}
impl From<NestedTrial> for PhTemperatureTrial {
fn from(trial: NestedTrial) -> Self {
let step_kind = match trial.step_kind {
NestedStepKind::LowerBound => PhTemperatureStepKind::LowerBound,
NestedStepKind::UpperBound => PhTemperatureStepKind::UpperBound,
NestedStepKind::Seed => PhTemperatureStepKind::Seed,
NestedStepKind::Interpolation => PhTemperatureStepKind::Interpolation,
NestedStepKind::Bisection => PhTemperatureStepKind::Bisection,
};
Self {
temperature: trial.temperature,
step_kind,
total_enthalpy: trial.total_enthalpy,
enthalpy_error_joules: trial.enthalpy_error_joules,
scaled_error: trial.scaled_error,
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,
}
}
}
pub(crate) struct NestedBracketResult<T> {
pub(crate) value: T,
pub(crate) total_enthalpy: f64,
pub(crate) iterations: usize,
pub(crate) trials: Vec<NestedTrial>,
}
pub(crate) fn solve_bracketed_temperature<T, F>(
bounds: TemperatureBounds,
scale: EnthalpyScale,
target: f64,
seed_temperature: Option<f64>,
options: NestedBracketOptions,
started: Instant,
mut evaluate: F,
) -> Result<NestedBracketResult<T>, ReactionExtentError>
where
F: FnMut(f64) -> Result<(T, f64), ReactionExtentError>,
{
options.validate()?;
let mut trials = Vec::new();
let mut sampled_errors = Vec::<(f64, f64)>::new();
let ensure_temperature_budget = |evaluations: usize| {
if evaluations >= options.max_temperature_evaluations {
Err(ReactionExtentError::InvalidProblem {
field: "temperature_budget",
message: format!(
"outer P,H solve exceeded the global temperature evaluation budget of {}",
options.max_temperature_evaluations
),
})
} else {
ensure_wall_time_budget(started, options.max_wall_time)
}
};
let trial_error = |index: usize, temperature: f64, cause: ReactionExtentError| {
if matches!(&cause, ReactionExtentError::Cancelled) {
cause
} else {
ReactionExtentError::TemperatureTrialFailed {
trial_index: index,
temperature,
cause: Box::new(cause),
}
}
};
let make_trial = |temperature, step_kind, enthalpy| {
let enthalpy_error_joules = enthalpy - target;
let scaled_error = scale.scale_error(enthalpy_error_joules)?;
Ok::<NestedTrial, ReactionExtentError>(NestedTrial {
temperature,
step_kind,
total_enthalpy: enthalpy,
enthalpy_error_joules,
scaled_error,
})
};
ensure_temperature_budget(trials.len())?;
let lower_temperature = bounds.lower();
let (lower_value, lower_enthalpy) = evaluate(lower_temperature)
.map_err(|cause| trial_error(0, lower_temperature, cause))?;
ensure_wall_time_budget(started, options.max_wall_time)?;
let lower_trial = make_trial(
lower_temperature,
NestedStepKind::LowerBound,
lower_enthalpy,
)?;
let lower_error = lower_trial.scaled_error;
sampled_errors.push((lower_temperature, lower_error));
trials.push(lower_trial);
if options.accepts(lower_trial.enthalpy_error_joules, scale) {
return Ok(NestedBracketResult {
value: lower_value,
total_enthalpy: lower_enthalpy,
iterations: 0,
trials,
});
}
ensure_temperature_budget(trials.len())?;
let upper_temperature = bounds.upper();
let (upper_value, upper_enthalpy) = evaluate(upper_temperature)
.map_err(|cause| trial_error(1, upper_temperature, cause))?;
ensure_wall_time_budget(started, options.max_wall_time)?;
let upper_trial = make_trial(
upper_temperature,
NestedStepKind::UpperBound,
upper_enthalpy,
)?;
let upper_error = upper_trial.scaled_error;
sampled_errors.push((upper_temperature, upper_error));
trials.push(upper_trial);
if options.accepts(upper_trial.enthalpy_error_joules, scale) {
return Ok(NestedBracketResult {
value: upper_value,
total_enthalpy: upper_enthalpy,
iterations: 0,
trials,
});
}
let mut lower = lower_temperature;
let mut upper = upper_temperature;
let mut lower_error = lower_error;
let mut upper_error = upper_error;
if let Some(seed) = seed_temperature
.filter(|seed| *seed > bounds.lower() && *seed < bounds.upper() && seed.is_finite())
{
ensure_temperature_budget(trials.len())?;
let trial_index = trials.len();
let (seed_value, seed_enthalpy) =
evaluate(seed).map_err(|cause| trial_error(trial_index, seed, cause))?;
ensure_wall_time_budget(started, options.max_wall_time)?;
let seed_trial = make_trial(seed, NestedStepKind::Seed, seed_enthalpy)?;
let seed_error = seed_trial.scaled_error;
sampled_errors.push((seed, seed_error));
trials.push(seed_trial);
if options.accepts(seed_trial.enthalpy_error_joules, scale) {
return Ok(NestedBracketResult {
value: seed_value,
total_enthalpy: seed_enthalpy,
iterations: 0,
trials,
});
}
let endpoints_have_same_sign = lower_error.signum() == upper_error.signum();
let seed_differs_from_lower = seed_error.signum() != lower_error.signum();
let seed_differs_from_upper = seed_error.signum() != upper_error.signum();
if endpoints_have_same_sign && seed_differs_from_lower {
return Err(ReactionExtentError::InvalidProblem {
field: "enthalpy_multiple_brackets",
message: format!(
"seed {seed} K creates two sign-change brackets inside [{}, {}] K",
bounds.lower(),
bounds.upper()
),
});
}
if !endpoints_have_same_sign {
if seed_differs_from_lower {
upper = seed;
upper_error = seed_error;
} else if seed_differs_from_upper {
lower = seed;
lower_error = seed_error;
}
}
validate_sampled_monotonicity(&sampled_errors, options.allow_bracketed_sign_search)?;
}
if lower_error.signum() == upper_error.signum() {
return Err(ReactionExtentError::InvalidProblem {
field: "enthalpy_bracket",
message: format!(
"target enthalpy is not bracketed: scaled errors are {lower_error:e} and {upper_error:e}"
),
});
}
for iteration in 1..=options.max_iterations {
let midpoint = lower + (upper - lower) * 0.5;
let (trial_temperature, step_kind) = if options.allow_interpolation {
safeguarded_interpolation_step(lower, lower_error, upper, upper_error).map_or(
(midpoint, NestedStepKind::Bisection),
|temperature| (temperature, NestedStepKind::Interpolation),
)
} else {
(midpoint, NestedStepKind::Bisection)
};
ensure_temperature_budget(trials.len())?;
let trial_index = trials.len();
let (value, enthalpy) = evaluate(trial_temperature)
.map_err(|cause| trial_error(trial_index, trial_temperature, cause))?;
ensure_wall_time_budget(started, options.max_wall_time)?;
let trial = make_trial(trial_temperature, step_kind, enthalpy)?;
let error = trial.scaled_error;
sampled_errors.push((trial_temperature, error));
validate_sampled_monotonicity(&sampled_errors, options.allow_bracketed_sign_search)?;
let error_joules = trial.enthalpy_error_joules;
trials.push(trial);
if options.accepts(error_joules, scale) {
return Ok(NestedBracketResult {
value,
total_enthalpy: enthalpy,
iterations: iteration,
trials,
});
}
if (upper - lower).abs() <= options.temperature_tolerance {
return Err(ReactionExtentError::InvalidProblem {
field: "enthalpy_acceptance",
message: format!(
"temperature bracket reached tolerance but enthalpy error {error_joules:e} J exceeds the accepted limit {} J",
options
.absolute_enthalpy_tolerance_joules
.max(options.scaled_enthalpy_tolerance * scale.joules())
),
});
}
if error.signum() == lower_error.signum() {
lower = trial_temperature;
lower_error = error;
} else {
upper = trial_temperature;
upper_error = error;
}
}
Err(ReactionExtentError::InvalidProblem {
field: "enthalpy_solver",
message: format!(
"temperature bracket did not converge in {} iterations",
options.max_iterations
),
})
}
pub(crate) fn safeguarded_interpolation_step(
lower_temperature: f64,
lower_error: f64,
upper_temperature: f64,
upper_error: f64,
) -> Option<f64> {
let width = upper_temperature - lower_temperature;
let denominator = upper_error - lower_error;
if !width.is_finite() || width <= 0.0 || !denominator.is_finite() || denominator == 0.0 {
return None;
}
let candidate = upper_temperature - upper_error * width / denominator;
let guard = width * 0.1;
(candidate.is_finite()
&& candidate > lower_temperature + guard
&& candidate < upper_temperature - guard)
.then_some(candidate)
}
pub(crate) fn ensure_wall_time_budget(
started: Instant,
limit: Option<Duration>,
) -> Result<(), ReactionExtentError> {
if limit.is_some_and(|limit| started.elapsed() >= limit) {
Err(ReactionExtentError::InvalidProblem {
field: "wall_time_budget",
message: "outer P,H solve exceeded its wall-time budget".to_string(),
})
} else {
Ok(())
}
}
pub(crate) fn validate_sampled_monotonicity(
samples: &[(f64, f64)],
allow_bracketed_sign_search: bool,
) -> Result<(), ReactionExtentError> {
if allow_bracketed_sign_search || samples.len() < 3 {
return Ok(());
}
let mut ordered = samples.to_vec();
ordered.sort_by(|left, right| left.0.total_cmp(&right.0));
let mut previous_slope_sign = None;
for window in ordered.windows(2) {
let delta_temperature = window[1].0 - window[0].0;
let delta_error = window[1].1 - window[0].1;
if delta_temperature <= 0.0 || delta_error == 0.0 {
continue;
}
let slope_sign = delta_error.signum();
if previous_slope_sign.is_some_and(|previous| previous != slope_sign) {
return Err(ReactionExtentError::InvalidProblem {
field: "enthalpy_non_monotone",
message: format!(
"sampled outer enthalpy errors reverse direction near {} K",
window[0].0
),
});
}
previous_slope_sign = Some(slope_sign);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn interpolation_stays_inside_a_guarded_sign_bracket() {
let candidate = safeguarded_interpolation_step(300.0, -2.0, 700.0, 2.0);
assert_eq!(candidate, Some(500.0));
assert!(safeguarded_interpolation_step(300.0, 0.0, 700.0, 0.0).is_none());
}
#[test]
fn monotonicity_guard_rejects_a_sampled_reversal_only_when_requested() {
let samples = [(300.0, -1.0), (500.0, 1.0), (700.0, -1.0)];
assert!(validate_sampled_monotonicity(&samples, false).is_err());
assert!(validate_sampled_monotonicity(&samples, true).is_ok());
}
#[test]
fn wall_time_guard_reports_a_typed_budget_error() {
let error = ensure_wall_time_budget(Instant::now(), Some(Duration::ZERO)).unwrap_err();
assert!(matches!(
error,
ReactionExtentError::InvalidProblem {
field: "wall_time_budget",
..
}
));
}
#[test]
fn scalar_trial_conversion_keeps_step_and_defers_inner_evidence() {
let trial = PhTemperatureTrial::from(NestedTrial {
temperature: 812.5,
step_kind: NestedStepKind::Interpolation,
total_enthalpy: 42.0,
enthalpy_error_joules: -0.25,
scaled_error: -0.005,
});
assert_eq!(trial.temperature(), 812.5);
assert_eq!(trial.step_kind(), PhTemperatureStepKind::Interpolation);
assert_eq!(trial.total_enthalpy(), 42.0);
assert_eq!(trial.enthalpy_error_joules(), -0.25);
assert_eq!(trial.scaled_error(), -0.005);
assert_eq!(trial.preparation(), PhTrialPreparation::Unspecified);
assert!(trial.phase_states().is_empty());
assert!(trial.inner_evidence().is_none());
}
}