use crate::Thermodynamics::ChemEquilibrium::equilibrium_solver_policy::SolverAttemptReport;
use crate::Thermodynamics::User_substances_error::SubsDataError;
use log::{error, info};
use nalgebra::{DMatrix, DVector};
use std::error::Error;
use std::fmt;
#[derive(Debug, Clone)]
pub enum SolveError {
MaxIterations,
SingularMatrix,
EvalError(String),
}
impl fmt::Display for SolveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MaxIterations => write!(f, "nonlinear solver reached its iteration limit"),
Self::SingularMatrix => write!(f, "nonlinear solver encountered a singular matrix"),
Self::EvalError(message) => write!(f, "nonlinear solver evaluation failed: {message}"),
}
}
}
impl Error for SolveError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackendFailureKind {
LinearSolve,
SingularJacobian,
NumericalBreakdown,
}
#[derive(Debug)]
pub enum ReactionExtentError {
InvalidProblem {
field: &'static str,
message: String,
},
InvalidConditions {
parameter: &'static str,
value: f64,
},
InvalidCandidate {
field: &'static str,
message: String,
},
AllBackendsFailed { attempts: Vec<SolverAttemptReport> },
CascadeAborted {
attempts: Vec<SolverAttemptReport>,
cause: Box<ReactionExtentError>,
},
SubsDataError(SubsDataError),
SolveError(SolveError),
BackendFailure {
backend: String,
kind: BackendFailureKind,
message: String,
},
SVDError(String),
ValidationNotApplicable {
path: &'static str,
message: String,
},
PhaseControlDidNotConverge {
iterations: usize,
},
PhaseControlCycleDetected {
iteration: usize,
active_phases: Vec<usize>,
},
DimensionMismatch(String),
DuplicateSpecies(usize),
SpeciesNotAssigned { index: usize },
InvalidInitialResiduals(Vec<f64>),
ResidualEvaluation(String),
JacobianEvaluation(String),
InvalidSpeciesAmount { index: usize, value: f64 },
InvalidNPhase { index: usize, value: f64 },
InvalidPhi { index: usize, value: f64 },
InvalidDG0 {
species_index: usize,
dg0: f64,
temperature: f64,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReactionExtentErrorKind {
InvalidInput,
ThermochemicalLookup,
Formulation,
ResidualEvaluation,
JacobianEvaluation,
BackendFailure,
NonConvergence,
InvalidCandidate,
UnsupportedModel,
ValidationMismatch,
AllBackendsFailed,
CascadeAborted,
}
impl From<SubsDataError> for ReactionExtentError {
fn from(value: SubsDataError) -> Self {
ReactionExtentError::SubsDataError(value)
}
}
impl fmt::Display for ReactionExtentError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidProblem { field, message } => {
write!(f, "invalid equilibrium problem field '{field}': {message}")
}
Self::InvalidConditions { parameter, value } => {
write!(f, "invalid equilibrium condition '{parameter}': {value}")
}
Self::InvalidCandidate { field, message } => {
write!(
f,
"equilibrium candidate field '{field}' was rejected: {message}"
)
}
Self::AllBackendsFailed { attempts } => {
write!(
f,
"all equilibrium solver backends failed after {} attempt(s)",
attempts.len()
)
}
Self::CascadeAborted { attempts, cause } => write!(
f,
"equilibrium solver cascade aborted after {} attempt(s): {cause}",
attempts.len()
),
Self::SubsDataError(error) => write!(f, "equilibrium data preparation failed: {error}"),
Self::SolveError(error) => write!(f, "equilibrium solver failed: {error}"),
Self::BackendFailure {
backend,
kind,
message,
} => write!(
f,
"equilibrium backend '{backend}' failed ({kind:?}): {message}"
),
Self::SVDError(message) => write!(f, "reaction-basis construction failed: {message}"),
Self::ValidationNotApplicable { path, message } => {
write!(f, "validation path '{path}' is not applicable: {message}")
}
Self::PhaseControlDidNotConverge { iterations } => {
write!(
f,
"phase-control outer loop did not converge after {iterations} iteration(s)"
)
}
Self::PhaseControlCycleDetected {
iteration,
active_phases,
} => write!(
f,
"phase-control cycle detected at iteration {iteration} for active phases {active_phases:?}"
),
Self::DimensionMismatch(message) => {
write!(f, "equilibrium dimension mismatch: {message}")
}
Self::DuplicateSpecies(index) => {
write!(f, "species index {index} belongs to multiple phases")
}
Self::SpeciesNotAssigned { index } => {
write!(f, "species index {index} is not assigned to any phase")
}
Self::InvalidInitialResiduals(values) => {
write!(
f,
"initial equilibrium residual contains non-finite values: {values:?}"
)
}
Self::ResidualEvaluation(message) => {
write!(f, "equilibrium residual evaluation failed: {message}")
}
Self::JacobianEvaluation(message) => {
write!(f, "equilibrium Jacobian evaluation failed: {message}")
}
Self::InvalidSpeciesAmount { index, value } => {
write!(f, "invalid mole amount at species index {index}: {value}")
}
Self::InvalidNPhase { index, value } => {
write!(f, "invalid total mole amount for phase {index}: {value}")
}
Self::InvalidPhi { index, value } => {
write!(f, "invalid phase correction at phase {index}: {value}")
}
Self::InvalidDG0 {
species_index,
dg0,
temperature,
} => {
write!(
f,
"invalid standard Gibbs energy {dg0} for species index {species_index} at temperature {temperature}"
)
}
}
}
}
impl Error for ReactionExtentError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::SubsDataError(error) => Some(error),
Self::SolveError(error) => Some(error),
Self::CascadeAborted { cause, .. } => Some(cause.as_ref()),
_ => None,
}
}
}
impl ReactionExtentError {
pub fn kind(&self) -> ReactionExtentErrorKind {
match self {
Self::InvalidProblem { .. }
| Self::InvalidConditions { .. }
| Self::DimensionMismatch(_)
| Self::DuplicateSpecies(_)
| Self::SpeciesNotAssigned { .. }
| Self::InvalidInitialResiduals(_)
| Self::InvalidSpeciesAmount { .. }
| Self::InvalidNPhase { .. }
| Self::InvalidPhi { .. } => ReactionExtentErrorKind::Formulation,
Self::SubsDataError(_) => ReactionExtentErrorKind::ThermochemicalLookup,
Self::InvalidCandidate { .. } => ReactionExtentErrorKind::InvalidCandidate,
Self::InvalidDG0 { .. } => ReactionExtentErrorKind::ThermochemicalLookup,
Self::SolveError(SolveError::MaxIterations) => ReactionExtentErrorKind::NonConvergence,
Self::SolveError(_) => ReactionExtentErrorKind::BackendFailure,
Self::BackendFailure { .. } => ReactionExtentErrorKind::BackendFailure,
Self::SVDError(_) => ReactionExtentErrorKind::UnsupportedModel,
Self::ValidationNotApplicable { .. } => ReactionExtentErrorKind::ValidationMismatch,
Self::PhaseControlDidNotConverge { .. } | Self::PhaseControlCycleDetected { .. } => {
ReactionExtentErrorKind::NonConvergence
}
Self::ResidualEvaluation(_) => ReactionExtentErrorKind::ResidualEvaluation,
Self::JacobianEvaluation(_) => ReactionExtentErrorKind::JacobianEvaluation,
Self::AllBackendsFailed { .. } => ReactionExtentErrorKind::AllBackendsFailed,
Self::CascadeAborted { .. } => ReactionExtentErrorKind::CascadeAborted,
}
}
pub fn is_retryable_backend_failure(&self) -> bool {
matches!(
self.kind(),
ReactionExtentErrorKind::NonConvergence
| ReactionExtentErrorKind::BackendFailure
| ReactionExtentErrorKind::ResidualEvaluation
| ReactionExtentErrorKind::JacobianEvaluation
)
}
pub fn is_non_retryable_input_error(&self) -> bool {
matches!(
self.kind(),
ReactionExtentErrorKind::InvalidInput
| ReactionExtentErrorKind::ThermochemicalLookup
| ReactionExtentErrorKind::Formulation
| ReactionExtentErrorKind::InvalidCandidate
| ReactionExtentErrorKind::UnsupportedModel
| ReactionExtentErrorKind::ValidationMismatch
)
}
}
#[cfg(test)]
mod error_contract_tests {
use super::{ReactionExtentError, ReactionExtentErrorKind, SolveError};
use crate::Thermodynamics::User_substances_error::SubsDataError;
use std::error::Error;
#[test]
fn equilibrium_errors_are_displayable_and_preserve_nested_sources() {
let solve_error = ReactionExtentError::SolveError(SolveError::MaxIterations);
assert_eq!(
solve_error.to_string(),
"equilibrium solver failed: nonlinear solver reached its iteration limit"
);
assert!(solve_error.source().is_some());
let data_error =
ReactionExtentError::from(SubsDataError::SubstanceNotFound("O2".to_string()));
assert!(data_error.to_string().contains("O2"));
assert!(data_error.source().is_some());
}
#[test]
fn all_backend_failure_display_reports_the_attempt_count() {
let error = ReactionExtentError::AllBackendsFailed {
attempts: Vec::new(),
};
assert_eq!(
error.to_string(),
"all equilibrium solver backends failed after 0 attempt(s)"
);
assert!(error.source().is_none());
assert_eq!(error.kind(), ReactionExtentErrorKind::AllBackendsFailed);
}
#[test]
fn aborted_cascade_preserves_the_non_retryable_cause() {
let error = ReactionExtentError::CascadeAborted {
attempts: Vec::new(),
cause: Box::new(ReactionExtentError::InvalidProblem {
field: "solver_policy",
message: "missing backend".to_string(),
}),
};
assert!(
error
.to_string()
.contains("cascade aborted after 0 attempt(s)")
);
assert!(error.source().is_some());
assert_eq!(error.kind(), ReactionExtentErrorKind::CascadeAborted);
}
#[test]
fn species_not_assigned_display_reports_the_missing_index() {
let error = ReactionExtentError::SpeciesNotAssigned { index: 3 };
assert_eq!(
error.to_string(),
"species index 3 is not assigned to any phase"
);
}
#[test]
fn error_kind_classifies_core_families() {
assert_eq!(
ReactionExtentError::InvalidProblem {
field: "solver_policy",
message: "missing backend".to_string(),
}
.kind(),
ReactionExtentErrorKind::Formulation
);
assert_eq!(
ReactionExtentError::from(SubsDataError::SubstanceNotFound("O2".to_string())).kind(),
ReactionExtentErrorKind::ThermochemicalLookup
);
assert_eq!(
ReactionExtentError::ResidualEvaluation("bad residual".to_string()).kind(),
ReactionExtentErrorKind::ResidualEvaluation
);
assert_eq!(
ReactionExtentError::SolveError(SolveError::MaxIterations).kind(),
ReactionExtentErrorKind::NonConvergence
);
assert_eq!(
ReactionExtentError::ValidationNotApplicable {
path: "keq_solver",
message: "too many reactions".to_string(),
}
.kind(),
ReactionExtentErrorKind::ValidationMismatch
);
let phase_error = ReactionExtentError::PhaseControlDidNotConverge { iterations: 3 };
assert_eq!(phase_error.kind(), ReactionExtentErrorKind::NonConvergence);
assert!(phase_error.to_string().contains("phase-control outer loop"));
}
}
pub struct LMSolver<F, J, C>
where
F: Fn(&[f64]) -> Result<Vec<f64>, ReactionExtentError>,
J: Fn(&[f64]) -> Result<DMatrix<f64>, ReactionExtentError>,
C: Fn(&[f64]) -> bool,
{
pub f: F,
pub jacobian: J,
pub feasible: C,
pub lambda: f64,
pub tol: f64,
pub max_iter: usize,
pub alpha_min: f64,
}
impl<F, J, C> LMSolver<F, J, C>
where
F: Fn(&[f64]) -> Result<Vec<f64>, ReactionExtentError>,
J: Fn(&[f64]) -> Result<DMatrix<f64>, ReactionExtentError>,
C: Fn(&[f64]) -> bool,
{
pub fn solve(&mut self, mut x: Vec<f64>) -> Result<Vec<f64>, SolveError> {
let n = x.len();
let mut lambda = self.lambda;
for _iter in 0..self.max_iter {
let f_val = (self.f)(&x).map_err(|error| {
SolveError::EvalError(format!("residual evaluation failed: {error}"))
})?;
info!("Iteration {}, x = {:?}, f = {:?}", _iter, x, f_val);
let f_norm = l2_norm(&f_val);
info!(" ||f|| = {}", f_norm);
if f_norm < self.tol {
return Ok(x);
}
if f_norm.is_nan() || f_norm.is_infinite() {
return Err(SolveError::SingularMatrix);
}
let j = (self.jacobian)(&x).map_err(|error| {
SolveError::EvalError(format!("Jacobian evaluation failed: {error}"))
})?;
info!(" Jacobian:\n{}", j);
if j.nrows() != n || j.ncols() != n {
error!(
"Jacobian nrows = {} ncols = {}, state length = {}",
j.nrows(),
j.ncols(),
n
);
return Err(SolveError::EvalError(
"Jacobian dimension mismatch".to_string(),
));
}
if j.iter().any(|value| !value.is_finite()) {
return Err(SolveError::SingularMatrix);
}
let jt = j.transpose();
let jtj = &jt * &j;
let mut lhs = jtj.clone();
info!(" Jᵀ·J:\n{}", jtj);
for i in 0..n {
lhs[(i, i)] += lambda;
}
let rhs = -(&jt * DVector::from_vec(f_val.clone()));
let delta = lhs.lu().solve(&rhs).ok_or(SolveError::SingularMatrix)?;
info!(" Step delta: {:?}", delta);
let delta = delta.data.as_vec().clone();
let mut alpha = 1.0;
let mut accepted = false;
while alpha >= self.alpha_min {
let x_trial: Vec<f64> = x
.iter()
.zip(delta.iter())
.map(|(xi, dxi)| xi + alpha * dxi)
.collect();
info!(" Trial x (alpha={}): {:?}", alpha, x_trial);
if !(self.feasible)(&x_trial) {
alpha *= 0.5;
continue;
}
let f_trial = (self.f)(&x_trial).map_err(|error| {
SolveError::EvalError(format!("residual evaluation failed: {error}"))
})?;
info!(" Trial f: {:?}", f_trial);
let f_trial_norm = l2_norm(&f_trial);
info!(" ||f_trial|| = {}", f_trial_norm);
if f_trial_norm < f_norm {
x = x_trial;
lambda *= 0.3;
accepted = true;
break;
} else {
alpha *= 0.5;
}
}
if !accepted {
lambda *= 10.0;
info!(
" No acceptable step found; increasing lambda to {}",
lambda
);
}
}
Err(SolveError::MaxIterations)
}
}
pub fn max_step_moles_nonnegative(
n: &[f64], delta_n: &[f64], safety: f64, ) -> Result<f64, SolveError> {
if n.len() != delta_n.len() {
return Err(SolveError::EvalError(format!(
"mole vector has {} entries but Newton step has {} entries",
n.len(),
delta_n.len(),
)));
}
if !safety.is_finite() || safety <= 0.0 || safety > 1.0 {
return Err(SolveError::EvalError(
"step safety factor must be finite and lie in (0, 1]".to_string(),
));
}
if n.iter().chain(delta_n).any(|value| !value.is_finite()) {
return Err(SolveError::EvalError(
"moles and Newton step must be finite".to_string(),
));
}
let mut alpha_max: f64 = 1.0;
for i in 0..n.len() {
let ni = n[i];
let dni = delta_n[i];
if dni < 0.0 {
if ni <= 0.0 {
return Ok(0.0); }
let alpha_i = safety * ni / (-dni);
alpha_max = alpha_max.min(alpha_i);
}
}
Ok(alpha_max.clamp(0.0, 1.0))
}
pub struct NRSolver<F, J, C>
where
F: Fn(&[f64]) -> Result<Vec<f64>, ReactionExtentError>,
J: Fn(&[f64]) -> Result<DMatrix<f64>, ReactionExtentError>,
C: Fn(&[f64]) -> bool,
{
pub f: F,
pub jacobian: J,
pub feasible: C,
pub n0: Vec<f64>,
pub reactions: DMatrix<f64>,
pub tol: f64,
pub max_iter: usize,
pub alpha_min: f64,
}
impl<F, J, C> NRSolver<F, J, C>
where
F: Fn(&[f64]) -> Result<Vec<f64>, ReactionExtentError>,
J: Fn(&[f64]) -> Result<DMatrix<f64>, ReactionExtentError>,
C: Fn(&[f64]) -> bool,
{
pub fn solve(&mut self, mut x: Vec<f64>) -> Result<Vec<f64>, SolveError> {
let n = x.len();
for iter in 0..self.max_iter {
let f_val = (self.f)(&x).map_err(|error| SolveError::EvalError(error.to_string()))?;
info!("NR Iteration {}, x = {:?}, f = {:?}", iter, x, f_val);
let f_norm = l2_norm(&f_val);
info!(" ||f|| = {}", f_norm);
if f_norm < self.tol {
return Ok(x);
}
if f_norm.is_nan() || f_norm.is_infinite() {
return Err(SolveError::SingularMatrix);
}
let j =
(self.jacobian)(&x).map_err(|error| SolveError::EvalError(error.to_string()))?;
info!(" Jacobian:\n{}", j);
if j.nrows() != n || j.ncols() != n {
error!(
"Jacobian nrows = {} ncols ={}, initial guess length {}",
j.nrows(),
j.ncols(),
n
);
return Err(SolveError::EvalError(
"Jacobian dimension mismatch".to_string(),
));
}
let rhs = -DVector::from_vec(f_val);
let delta_vec = j.lu().solve(&rhs).ok_or(SolveError::SingularMatrix)?;
let delta = delta_vec.data.as_vec();
info!(" Step delta: {:?}", delta_vec);
let mut alpha = 1.0;
info!("bounded step {}", alpha);
let mut accepted = false;
while alpha >= self.alpha_min {
let x_trial: Vec<f64> = x
.iter()
.zip(delta.iter())
.map(|(xi, dxi)| xi + alpha * dxi)
.collect();
info!(" Trial x (alpha={}): {:?}", alpha, x_trial);
if !(self.feasible)(&x_trial) {
alpha *= 0.5;
continue;
}
let f_trial =
(self.f)(&x_trial).map_err(|error| SolveError::EvalError(error.to_string()))?;
info!(" Trial f: {:?}", f_trial);
let f_trial_norm = l2_norm(&f_trial);
info!(" ||f_trial|| = {}", f_trial_norm);
if f_trial_norm < f_norm {
x = x_trial;
accepted = true;
break;
}
alpha *= 0.5;
if !accepted {
info!(" No acceptable step found; continuing to next iteration");
}
}
if !accepted {
error!(
"NR solver exhausted iteration {} without an acceptable step",
iter
);
return Err(SolveError::MaxIterations);
}
}
error!("NR solver reached the iteration limit without convergence");
Err(SolveError::MaxIterations)
}
}
fn l2_norm(v: &[f64]) -> f64 {
v.iter().map(|x| x * x).sum::<f64>().sqrt()
}
pub struct TrustRegionSolver<F, J, C>
where
F: Fn(&[f64]) -> Result<Vec<f64>, ReactionExtentError>,
J: Fn(&[f64]) -> Result<DMatrix<f64>, ReactionExtentError>,
C: Fn(&[f64]) -> bool,
{
pub f: F,
pub jacobian: J,
pub feasible: C,
pub tol: f64,
pub max_iter: usize,
pub delta_init: f64,
pub delta_max: f64,
pub eta: f64, }
impl<F, J, C> TrustRegionSolver<F, J, C>
where
F: Fn(&[f64]) -> Result<Vec<f64>, ReactionExtentError>,
J: Fn(&[f64]) -> Result<DMatrix<f64>, ReactionExtentError>,
C: Fn(&[f64]) -> bool,
{
pub fn solve(&self, mut x: Vec<f64>) -> Result<Vec<f64>, SolveError> {
let n = x.len();
let mut delta = self.delta_init;
for _iter in 0..self.max_iter {
let f_val = (self.f)(&x).map_err(|error| SolveError::EvalError(error.to_string()))?;
if f_val.len() != n {
return Err(SolveError::EvalError("residual dimension mismatch".into()));
}
if f_val.iter().any(|value| !value.is_finite()) {
return Err(SolveError::SingularMatrix);
}
let f_norm = l2_norm(&f_val);
if f_norm < self.tol {
return Ok(x);
}
let J =
(self.jacobian)(&x).map_err(|error| SolveError::EvalError(error.to_string()))?;
if J.nrows() != n || J.ncols() != n {
error!(
"Trust region Jacobian nrows = {} ncols = {}, state length = {}",
J.nrows(),
J.ncols(),
n
);
return Err(SolveError::EvalError("Jacobian not square".into()));
}
if J.iter().any(|value| !value.is_finite()) {
return Err(SolveError::SingularMatrix);
}
let fvec = DVector::from_vec(f_val.clone());
let newton_step = J
.clone()
.lu()
.solve(&(-&fvec))
.ok_or(SolveError::SingularMatrix)?;
let g = &J.transpose() * &fvec;
let g_norm_sq = g.dot(&g);
let jg = &J * &g;
let alpha = g_norm_sq / jg.dot(&jg).max(1e-16);
let cauchy_step = -alpha * g.clone();
let p = if newton_step.norm() <= delta {
newton_step
} else if cauchy_step.norm() >= delta {
delta / cauchy_step.norm() * cauchy_step
} else {
let p_u = cauchy_step;
let p_b = newton_step;
let d = &p_b - &p_u;
let a = d.dot(&d);
let b = 2.0 * p_u.dot(&d);
let c = p_u.dot(&p_u) - delta * delta;
let tau = (-b + (b * b - 4.0 * a * c).sqrt()) / (2.0 * a);
p_u + tau * d
};
let p_vec = p.data.as_vec().clone();
let x_trial: Vec<f64> = x.iter().zip(p_vec.iter()).map(|(xi, pi)| xi + pi).collect();
if !(self.feasible)(&x_trial) {
delta *= 0.25;
continue;
}
let f_trial =
(self.f)(&x_trial).map_err(|error| SolveError::EvalError(error.to_string()))?;
if l2_norm(&f_trial) < self.tol {
return Ok(x_trial);
}
let actual_reduction = f_norm.powi(2) - l2_norm(&f_trial).powi(2);
if !actual_reduction.is_finite() {
return Err(SolveError::SingularMatrix);
}
let model_reduction = -2.0 * g.dot(&p) - p.dot(&(J.transpose() * &J * &p));
if !model_reduction.is_finite() || model_reduction <= 0.0 {
delta *= 0.25;
continue;
}
let rho = actual_reduction / model_reduction;
if rho < 0.25 {
delta *= 0.25;
} else if rho > 0.75 && (p.norm() - delta).abs() < 1e-12 {
delta = (2.0 * delta).min(self.delta_max);
}
if rho > self.eta {
x = x_trial;
}
if delta < 1e-14 {
error!("Trust region solver exhausted the trust radius before convergence");
return Err(SolveError::SingularMatrix);
}
}
error!("Trust region solver reached the iteration limit without convergence");
Err(SolveError::MaxIterations)
}
}
#[derive(Debug, Clone)]
pub struct ReactionBasis {
pub rank: usize,
pub num_reactions: usize,
pub reactions: DMatrix<f64>,
}
pub fn compute_reaction_basis(
a: &DMatrix<f64>,
tol: f64,
) -> Result<ReactionBasis, ReactionExtentError> {
use nalgebra::linalg::SVD;
let m = a.nrows();
let at = a.transpose();
let svd = SVD::new(at, true, true);
let v_t = match svd.v_t {
Some(v) => v,
None => {
let msg = "SVD failed: Vᵀ".to_string();
error!("{}", msg);
return Err(ReactionExtentError::SVDError(msg));
}
};
let singular_values = svd.singular_values;
let rank = singular_values.iter().filter(|&&s| s > tol).count();
let num_reactions = if rank <= m { m - rank } else { 0 };
let mut reactions = DMatrix::<f64>::zeros(m, num_reactions);
let p = v_t.nrows();
let mut v_rows: Vec<DVector<f64>> = Vec::new();
for i in 0..p {
v_rows.push(v_t.row(i).transpose());
}
let mut col = 0;
for i in rank..p {
if col >= num_reactions {
break;
}
reactions.set_column(col, &v_rows[i]);
col += 1;
}
if col < num_reactions {
let orth_span: Vec<&DVector<f64>> = v_rows.iter().take(rank).collect();
let mut null_basis: Vec<DVector<f64>> = Vec::new();
for j in 0..m {
if col >= num_reactions {
break;
}
let mut cand = DVector::<f64>::from_element(m, 0.0);
cand[j] = 1.0;
for rvec in &orth_span {
let dot = rvec.dot(&cand);
cand -= *rvec * dot;
}
for u in &null_basis {
let dot = u.dot(&cand);
cand -= u * dot;
}
let norm = cand.norm();
if norm > tol {
let u = cand / norm;
reactions.set_column(col, &u);
null_basis.push(u);
col += 1;
}
}
}
let mut reactions = -1.0 * reactions;
let eps = 1e-3;
for i in 0..m {
for j in 0..num_reactions {
if reactions[(i, j)].abs() < eps {
reactions[(i, j)] = 0.0;
}
}
}
Ok(ReactionBasis {
rank,
num_reactions,
reactions,
})
}
#[cfg(test)]
mod tests {
fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() < tol
}
use super::*;
#[test]
fn lm_solves_scalar_quadratic() {
let f = |x: &[f64]| Ok(vec![x[0] * x[0] - 2.0]) as Result<Vec<f64>, ReactionExtentError>;
let j = |x: &[f64]| {
Ok(nalgebra::DMatrix::from_row_slice(1, 1, &[2.0 * x[0]]))
as Result<DMatrix<f64>, ReactionExtentError>
};
let feasible = |_x: &[f64]| true;
let mut solver = LMSolver {
f,
jacobian: j,
feasible,
lambda: 1e-3,
tol: 1e-12,
max_iter: 50,
alpha_min: 1e-6,
};
let x0 = vec![1.0];
let sol = solver.solve(x0).unwrap();
assert!(approx_eq(sol[0], 2.0_f64.sqrt(), 1e-8));
}
#[test]
fn lm_solves_2d_nonlinear_system() {
let f = |x: &[f64]| {
Ok(vec![x[0] * x[0] + x[1] * x[1] - 1.0, x[0] - x[1]])
as Result<Vec<f64>, ReactionExtentError>
};
let j = |x: &[f64]| {
Ok(nalgebra::DMatrix::from_row_slice(
2,
2,
&[2.0 * x[0], 2.0 * x[1], 1.0, -1.0],
)) as Result<DMatrix<f64>, ReactionExtentError>
};
let feasible = |_x: &[f64]| true;
let mut solver = LMSolver {
f,
jacobian: j,
feasible,
lambda: 1e-3,
tol: 1e-12,
max_iter: 50,
alpha_min: 1e-6,
};
let x0 = vec![0.8, 0.3];
let sol = solver.solve(x0).unwrap();
let expected = 1.0 / 2.0_f64.sqrt();
assert!(approx_eq(sol[0], expected, 1e-8));
assert!(approx_eq(sol[1], expected, 1e-8));
}
#[test]
fn lm_respects_feasibility_constraint() {
let f = |x: &[f64]| Ok(vec![x[0] * x[0] - 1.0]) as Result<Vec<f64>, ReactionExtentError>;
let j = |x: &[f64]| {
Ok(nalgebra::DMatrix::from_row_slice(1, 1, &[2.0 * x[0]]))
as Result<DMatrix<f64>, ReactionExtentError>
};
let feasible = |x: &[f64]| x[0] >= 0.0;
let mut solver = LMSolver {
f,
jacobian: j,
feasible,
lambda: 1e-3,
tol: 1e-12,
max_iter: 50,
alpha_min: 1e-6,
};
let x0 = vec![0.1];
let sol = solver.solve(x0).unwrap();
assert!(approx_eq(sol[0], 1.0, 1e-8));
}
#[test]
fn lm_handles_flat_jacobian() {
let f = |x: &[f64]| Ok(vec![x[0].powi(3)]) as Result<Vec<f64>, ReactionExtentError>;
let j = |x: &[f64]| {
Ok(nalgebra::DMatrix::from_row_slice(
1,
1,
&[3.0 * x[0] * x[0]],
)) as Result<DMatrix<f64>, ReactionExtentError>
};
let feasible = |_x: &[f64]| true;
let mut solver = LMSolver {
f,
jacobian: j,
feasible,
lambda: 1e-4,
tol: 1e-15,
max_iter: 1000,
alpha_min: 1e-8,
};
let x0 = vec![0.5];
let sol = solver.solve(x0).unwrap();
info!("{:?}", &sol);
assert!(sol[0].abs() < 1e-5);
}
#[test]
fn lm_reports_max_iterations() {
let f = |_x: &[f64]| Ok(vec![1.0]); let j = |_x: &[f64]| Ok(nalgebra::DMatrix::identity(1, 1));
let feasible = |_x: &[f64]| true;
let mut solver = LMSolver {
f,
jacobian: j,
feasible,
lambda: 1e-3,
tol: 1e-12,
max_iter: 5,
alpha_min: 1e-6,
};
let res = solver.solve(vec![0.0]);
assert!(matches!(res, Err(SolveError::MaxIterations)));
}
}
#[cfg(test)]
mod tests_reaction_basis {
use crate::Thermodynamics::ChemEquilibrium::equilibrium_nonlinear::compute_reaction_basis;
use log::info;
use nalgebra::DMatrix;
const TOL: f64 = 1e-10;
#[test]
fn diatomic_dissociation_o2_o() {
let a = DMatrix::from_row_slice(
2,
1,
&[
2.0, 1.0, ],
);
let basis = compute_reaction_basis(&a, TOL).unwrap();
info!("basis: {:?}", basis);
assert_eq!(basis.rank, 1);
assert_eq!(basis.num_reactions, 1);
let residual = a.transpose() * &basis.reactions;
assert!(residual.norm() < 1e-12);
}
#[test]
fn nitrogen_oxygen_system() {
let a = DMatrix::from_row_slice(
4,
2,
&[
2.0, 0.0, 0.0, 2.0, 1.0, 1.0, 1.0, 2.0, ],
);
let basis = compute_reaction_basis(&a, TOL).unwrap();
assert_eq!(basis.rank, 2);
assert_eq!(basis.num_reactions, 2);
let residual = a.transpose() * &basis.reactions;
assert!(residual.norm() < 1e-12);
}
#[test]
fn methane_combustion_species() {
let a = DMatrix::from_row_slice(
4,
3,
&[
1.0, 4.0, 0.0, 0.0, 0.0, 2.0, 1.0, 0.0, 2.0, 0.0, 2.0, 1.0, ],
);
let basis = compute_reaction_basis(&a, TOL).unwrap();
assert_eq!(basis.rank, 3);
assert_eq!(basis.num_reactions, 1);
let residual = a.transpose() * &basis.reactions;
assert!(residual.norm() < 1e-12);
}
#[test]
fn reaction_basis_o2_not_zero() {
use nalgebra::DMatrix;
let a = DMatrix::from_row_slice(2, 1, &[2.0, 1.0]);
let rb = compute_reaction_basis(&a, 1e-12).unwrap();
assert_eq!(rb.num_reactions, 1);
let nu = rb.reactions.column(0);
assert!(
nu.iter().any(|&x| x.abs() > 1e-6),
"Reaction vector is zero!"
);
assert!((2.0 * nu[0] + 1.0 * nu[1]).abs() < 1e-10);
}
}
#[cfg(test)]
mod nr_tests {
use super::*;
use nalgebra::DMatrix;
#[test]
fn max_step_limits_alpha() {
let n = vec![0.01, 0.5];
let delta = vec![-1.0, 0.1];
let alpha = max_step_moles_nonnegative(&n, &delta, 0.95).unwrap();
let expected = 0.95 * 0.01 / 1.0;
assert!((alpha - expected).abs() < 1e-12);
}
#[test]
fn max_step_rejects_malformed_public_inputs_without_panicking() {
assert!(matches!(
max_step_moles_nonnegative(&[1.0], &[1.0, 2.0], 0.95),
Err(SolveError::EvalError(_))
));
assert!(matches!(
max_step_moles_nonnegative(&[f64::NAN], &[1.0], 0.95),
Err(SolveError::EvalError(_))
));
assert!(matches!(
max_step_moles_nonnegative(&[1.0], &[-1.0], 1.5),
Err(SolveError::EvalError(_))
));
}
#[test]
fn nr_solver_respects_bounds_and_converges() {
let f = |x: &[f64]| Ok(vec![x[0]]) as Result<Vec<f64>, ReactionExtentError>;
let j = |_: &[f64]| {
Ok(DMatrix::from_row_slice(1, 1, &[1.0])) as Result<DMatrix<f64>, ReactionExtentError>
};
let feasible = |x: &[f64]| x[0] >= 0.0;
let mut solver = NRSolver {
f,
jacobian: j,
feasible,
n0: vec![0.01],
reactions: DMatrix::zeros(1, 0),
tol: 1e-12,
max_iter: 100,
alpha_min: 1e-12,
};
let sol = solver.solve(vec![0.01]).unwrap();
assert!(sol[0] >= 0.0);
assert!(sol[0].abs() < 1e-8);
}
}
#[cfg(test)]
mod trust_region_tests {
use super::*;
use approx::assert_relative_eq;
#[allow(dead_code)]
fn vec_norm(v: &[f64]) -> f64 {
v.iter().map(|x| x * x).sum::<f64>().sqrt()
}
fn approx_eq(a: &[f64], b: &[f64], tol: f64) -> bool {
a.iter().zip(b.iter()).all(|(x, y)| (*x - *y).abs() < tol)
}
#[test]
fn trust_region_linear_system() {
let f = |x: &[f64]| -> Result<Vec<f64>, ReactionExtentError> {
Ok(vec![3.0 * x[0] + 1.0 * x[1] - 1.0, 1.0 * x[0] + 2.0 * x[1]])
};
let j = |_x: &[f64]| -> Result<DMatrix<f64>, ReactionExtentError> {
Ok(DMatrix::from_row_slice(2, 2, &[3.0, 1.0, 1.0, 2.0]))
};
let feasible = |_x: &[f64]| true;
let solver = TrustRegionSolver {
f,
jacobian: j,
feasible,
tol: 1e-12,
max_iter: 10,
delta_init: 1.0,
delta_max: 10.0,
eta: 0.1,
};
let x0 = vec![0.0, 0.0];
let sol = solver.solve(x0).unwrap();
assert!(approx_eq(&sol, &[0.4, -0.2], 1e-10));
}
#[test]
fn trust_region_scalar_nonlinear() {
let f =
|x: &[f64]| -> Result<Vec<f64>, ReactionExtentError> { Ok(vec![x[0] * x[0] - 2.0]) };
let j = |x: &[f64]| -> Result<DMatrix<f64>, ReactionExtentError> {
Ok(DMatrix::from_row_slice(1, 1, &[2.0 * x[0]]))
};
let feasible = |_x: &[f64]| true;
let solver = TrustRegionSolver {
f,
jacobian: j,
feasible,
tol: 1e-12,
max_iter: 50,
delta_init: 0.1, delta_max: 10.0,
eta: 0.1,
};
let sol = solver.solve(vec![0.1]).unwrap();
assert!((sol[0] - 2.0_f64.sqrt()).abs() < 1e-10);
}
#[test]
fn trust_region_ill_conditioned() {
let f = |x: &[f64]| -> Result<Vec<f64>, ReactionExtentError> {
Ok(vec![1e6 * x[0] - 1.0, x[1] - 1.0])
};
let j = |_x: &[f64]| -> Result<DMatrix<f64>, ReactionExtentError> {
Ok(DMatrix::from_row_slice(2, 2, &[1e6, 0.0, 0.0, 1.0]))
};
let feasible = |_x: &[f64]| true;
let solver = TrustRegionSolver {
f,
jacobian: j,
feasible,
tol: 1e-12,
max_iter: 50,
delta_init: 0.01,
delta_max: 1.0,
eta: 0.1,
};
let sol = solver.solve(vec![0.0, 0.0]).unwrap();
assert!((sol[0] - 1e-6).abs() < 1e-10);
assert!((sol[1] - 1.0).abs() < 1e-10);
}
#[test]
fn trust_region_feasibility_constraint() {
let f = |x: &[f64]| -> Result<Vec<f64>, ReactionExtentError> { Ok(vec![x[0] - 1.0]) };
let j = |_x: &[f64]| -> Result<DMatrix<f64>, ReactionExtentError> {
Ok(DMatrix::from_row_slice(1, 1, &[1.0]))
};
let feasible = |x: &[f64]| x[0] >= 0.0;
let solver = TrustRegionSolver {
f,
jacobian: j,
feasible,
tol: 1e-12,
max_iter: 50,
delta_init: 10.0,
delta_max: 10.0,
eta: 0.1,
};
let sol = solver.solve(vec![-10.0]).unwrap();
assert!((sol[0] - 1.0).abs() < 1e-10);
}
#[test]
fn trust_region_singular_jacobian() {
let f = |_x: &[f64]| -> Result<Vec<f64>, ReactionExtentError> { Ok(vec![1.0]) };
let j =
|_x: &[f64]| -> Result<DMatrix<f64>, ReactionExtentError> { Ok(DMatrix::zeros(1, 1)) };
let feasible = |_x: &[f64]| true;
let solver = TrustRegionSolver {
f,
jacobian: j,
feasible,
tol: 1e-12,
max_iter: 5,
delta_init: 1.0,
delta_max: 1.0,
eta: 0.1,
};
let res = solver.solve(vec![0.0]);
assert!(matches!(res, Err(SolveError::SingularMatrix)));
}
#[test]
fn tr_solves_scalar_quadratic() {
let f = |x: &[f64]| Ok(vec![x[0] * x[0] - 2.0]) as Result<Vec<f64>, ReactionExtentError>;
let j = |x: &[f64]| {
Ok(nalgebra::DMatrix::from_row_slice(1, 1, &[2.0 * x[0]]))
as Result<DMatrix<f64>, ReactionExtentError>
};
let feasible = |_x: &[f64]| true;
let solver = TrustRegionSolver {
f,
jacobian: j,
feasible,
tol: 1e-12,
max_iter: 50,
delta_init: 0.1,
delta_max: 10.0,
eta: 0.0,
};
let x0 = vec![0.1];
let sol = solver.solve(x0).unwrap();
assert_relative_eq!(sol[0], 2.0_f64.sqrt(), epsilon = 1e-8);
}
#[test]
fn tr_solves_2d_nonlinear_system() {
let f = |x: &[f64]| {
Ok(vec![x[0] * x[0] + x[1] * x[1] - 1.0, x[0] - x[1]])
as Result<Vec<f64>, ReactionExtentError>
};
let j = |x: &[f64]| {
Ok(nalgebra::DMatrix::from_row_slice(
2,
2,
&[2.0 * x[0], 2.0 * x[1], 1.0, -1.0],
)) as Result<DMatrix<f64>, ReactionExtentError>
};
let feasible = |_x: &[f64]| true;
let solver = TrustRegionSolver {
f,
jacobian: j,
feasible,
tol: 1e-12,
max_iter: 1000,
delta_init: 0.1,
delta_max: 10.0,
eta: 0.0,
};
let x0 = vec![0.5, 0.5];
let sol = solver.solve(x0).unwrap();
let expected = 1.0 / 2.0_f64.sqrt();
assert_relative_eq!(sol[0], expected, epsilon = 1e-8);
assert_relative_eq!(sol[1], expected, epsilon = 1e-8);
}
#[test]
fn tr_respects_feasibility_constraint() {
let f = |x: &[f64]| Ok(vec![x[0] * x[0] - 1.0]) as Result<Vec<f64>, ReactionExtentError>;
let j = |x: &[f64]| {
Ok(nalgebra::DMatrix::from_row_slice(1, 1, &[2.0 * x[0]]))
as Result<DMatrix<f64>, ReactionExtentError>
};
let feasible = |_: &[f64]| true;
let solver = TrustRegionSolver {
f,
jacobian: j,
feasible,
tol: 1e-12,
max_iter: 50,
delta_init: 1.0,
delta_max: 10.0,
eta: 0.1,
};
let x0 = vec![0.1];
let sol = solver.solve(x0).unwrap();
assert_relative_eq!(sol[0].abs(), 1.0, epsilon = 1e-8);
}
#[test]
fn tr_reports_max_iterations() {
let f = |_x: &[f64]| Ok(vec![1.0]); let j = |_x: &[f64]| Ok(nalgebra::DMatrix::identity(1, 1));
let feasible = |_x: &[f64]| true;
let solver = TrustRegionSolver {
f,
jacobian: j,
feasible,
tol: 1e-12,
max_iter: 5,
delta_init: 1.0,
delta_max: 10.0,
eta: 0.1,
};
let res = solver.solve(vec![0.0]);
assert!(matches!(res, Err(SolveError::MaxIterations)));
}
#[test]
fn tr_reports_jacobian_dimension_mismatch() {
let f = |_x: &[f64]| Ok(vec![1.0]) as Result<Vec<f64>, ReactionExtentError>;
let j = |_x: &[f64]| {
Ok(nalgebra::DMatrix::from_row_slice(1, 2, &[1.0, 0.0]))
as Result<DMatrix<f64>, ReactionExtentError>
};
let feasible = |_x: &[f64]| true;
let solver = TrustRegionSolver {
f,
jacobian: j,
feasible,
tol: 1e-12,
max_iter: 5,
delta_init: 1.0,
delta_max: 10.0,
eta: 0.1,
};
let res = solver.solve(vec![0.0]);
assert!(matches!(res, Err(SolveError::EvalError(_))));
}
}