use crate::Thermodynamics::ChemEquilibrium::equilibrium_component::EquilibriumComponentDescriptor;
use crate::Thermodynamics::ChemEquilibrium::equilibrium_ids::{
ElementId, PhaseIndex, ReactionId, SpeciesId,
};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_log_moles::{
GibbsFn, Phase, compute_species_moles, equilibrium_scaling,
evaluate_equilibrium_logmole_jacobian, evaluate_equilibrium_logmole_residual,
reaction_phase_stoichiometry, scale_jacobian_rows, scale_residual_rows, species_to_phase_map,
};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_nonlinear::{
ReactionBasis, ReactionExtentError, compute_reaction_basis,
};
use crate::Thermodynamics::ChemEquilibrium::equilibrium_validation::EquilibriumCandidateReport;
use nalgebra::{DMatrix, linalg::SVD};
use std::collections::HashSet;
use std::fmt;
pub const DEFAULT_TRACE_MOLE_FLOOR: f64 = 1e-30;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TraceSpeciesSeedPolicy {
Absolute { floor: f64 },
RelativeToLargestInitialMole {
fraction: f64,
minimum_floor: f64,
},
}
impl TraceSpeciesSeedPolicy {
pub fn trace_floor_for(self, moles: &[f64]) -> Result<f64, ReactionExtentError> {
match self {
Self::Absolute { floor } => {
if !floor.is_finite() || floor <= 0.0 {
return Err(ReactionExtentError::InvalidProblem {
field: "trace_floor",
message: "trace floor must be finite and strictly positive".to_string(),
});
}
Ok(floor)
}
Self::RelativeToLargestInitialMole {
fraction,
minimum_floor,
} => {
if !fraction.is_finite() || fraction <= 0.0 || fraction > 1.0 {
return Err(ReactionExtentError::InvalidProblem {
field: "trace_floor_fraction",
message: "fraction must be finite and lie in the interval (0, 1]"
.to_string(),
});
}
if !minimum_floor.is_finite() || minimum_floor <= 0.0 {
return Err(ReactionExtentError::InvalidProblem {
field: "trace_floor_minimum",
message: "minimum floor must be finite and strictly positive".to_string(),
});
}
let mut largest_initial_mole = 0.0;
for (index, &value) in moles.iter().enumerate() {
if !value.is_finite() || value < 0.0 {
return Err(ReactionExtentError::InvalidProblem {
field: "initial_moles",
message: format!("entry {index} must be finite and non-negative"),
});
}
if value > largest_initial_mole {
largest_initial_mole = value;
}
}
if largest_initial_mole <= 0.0 {
return Ok(minimum_floor);
}
Ok((largest_initial_mole * fraction).max(minimum_floor))
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EquilibriumConditions {
temperature: f64,
pressure: f64,
reference_pressure: f64,
}
impl EquilibriumConditions {
pub fn new(
temperature: f64,
pressure: f64,
reference_pressure: f64,
) -> Result<Self, ReactionExtentError> {
for (parameter, value) in [
("temperature", temperature),
("pressure", pressure),
("reference_pressure", reference_pressure),
] {
if !value.is_finite() || value <= 0.0 {
return Err(ReactionExtentError::InvalidConditions { parameter, value });
}
}
Ok(Self {
temperature,
pressure,
reference_pressure,
})
}
pub fn temperature(self) -> f64 {
self.temperature
}
pub fn pressure(self) -> f64 {
self.pressure
}
pub fn reference_pressure(self) -> f64 {
self.reference_pressure
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LogMolesInitialGuess(Vec<f64>);
impl LogMolesInitialGuess {
pub fn from_initial_moles(moles: &[f64]) -> Result<Self, ReactionExtentError> {
Self::from_moles_with_policy(
moles,
TraceSpeciesSeedPolicy::Absolute {
floor: DEFAULT_TRACE_MOLE_FLOOR,
},
)
}
pub fn from_moles_with_policy(
moles: &[f64],
policy: TraceSpeciesSeedPolicy,
) -> Result<Self, ReactionExtentError> {
let trace_floor = policy.trace_floor_for(moles)?;
Self::from_moles(moles, trace_floor)
}
pub fn new(log_moles: Vec<f64>) -> Result<Self, ReactionExtentError> {
if log_moles.iter().any(|value| !value.is_finite()) {
return Err(ReactionExtentError::InvalidProblem {
field: "initial_log_moles",
message: "every log-mole value must be finite".to_string(),
});
}
Ok(Self(log_moles))
}
pub fn from_moles(moles: &[f64], trace_floor: f64) -> Result<Self, ReactionExtentError> {
if !trace_floor.is_finite() || trace_floor <= 0.0 {
return Err(ReactionExtentError::InvalidProblem {
field: "trace_floor",
message: "trace floor must be finite and strictly positive".to_string(),
});
}
let mut log_moles = Vec::with_capacity(moles.len());
for (index, &moles_i) in moles.iter().enumerate() {
if !moles_i.is_finite() || moles_i < 0.0 {
return Err(ReactionExtentError::InvalidProblem {
field: "initial_moles",
message: format!("entry {index} must be finite and non-negative"),
});
}
log_moles.push(moles_i.max(trace_floor).ln());
}
Self::new(log_moles)
}
pub fn as_slice(&self) -> &[f64] {
&self.0
}
pub(crate) fn into_inner(self) -> Vec<f64> {
self.0
}
}
pub struct EquilibriumProblem {
components: Vec<EquilibriumComponentDescriptor>,
labels: Vec<String>,
initial_moles: Vec<f64>,
initial_log_moles: LogMolesInitialGuess,
element_composition: DMatrix<f64>,
gibbs: Vec<GibbsFn>,
phases: Vec<Phase>,
conditions: EquilibriumConditions,
}
pub struct PreparedEquilibriumProblem {
problem: EquilibriumProblem,
reaction_basis: ReactionBasis,
element_totals: Vec<f64>,
species_phase: Vec<usize>,
phase_stoichiometry: Vec<Vec<f64>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResidualScalingContract {
scale: Vec<f64>,
}
impl ResidualScalingContract {
pub fn new(scale: Vec<f64>) -> Result<Self, ReactionExtentError> {
if scale.is_empty() {
return Err(ReactionExtentError::InvalidProblem {
field: "residual_scale",
message: "residual scale must contain at least one entry".to_string(),
});
}
for (index, value) in scale.iter().enumerate() {
if !value.is_finite() || *value <= 0.0 {
return Err(ReactionExtentError::InvalidProblem {
field: "residual_scale",
message: format!("scale[{index}] must be finite and positive"),
});
}
}
Ok(Self { scale })
}
pub fn as_slice(&self) -> &[f64] {
&self.scale
}
pub fn apply_residual(&self, residual: Vec<f64>) -> Result<Vec<f64>, ReactionExtentError> {
scale_residual_rows(residual, &self.scale)
}
pub fn apply_jacobian(
&self,
jacobian: DMatrix<f64>,
) -> Result<DMatrix<f64>, ReactionExtentError> {
scale_jacobian_rows(jacobian, &self.scale)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct VariableScalingContract {
scale: Vec<f64>,
}
impl VariableScalingContract {
pub fn new(scale: Vec<f64>) -> Result<Self, ReactionExtentError> {
if scale.is_empty() {
return Err(ReactionExtentError::InvalidProblem {
field: "variable_scale",
message: "variable scale must contain at least one entry".to_string(),
});
}
for (index, value) in scale.iter().enumerate() {
if !value.is_finite() || *value <= 0.0 {
return Err(ReactionExtentError::InvalidProblem {
field: "variable_scale",
message: format!("scale[{index}] must be finite and positive"),
});
}
}
Ok(Self { scale })
}
pub fn as_slice(&self) -> &[f64] {
&self.scale
}
pub fn apply_iterate(&self, iterate: &[f64]) -> Result<Vec<f64>, ReactionExtentError> {
if iterate.len() != self.scale.len() {
return Err(ReactionExtentError::DimensionMismatch(format!(
"iterate has {} entries but variable scale has {} entries",
iterate.len(),
self.scale.len(),
)));
}
let mut scaled = Vec::with_capacity(iterate.len());
for (index, (value, factor)) in iterate.iter().zip(self.scale.iter()).enumerate() {
if !value.is_finite() {
return Err(ReactionExtentError::InvalidProblem {
field: "variable_iterate",
message: format!("iterate[{index}] must be finite"),
});
}
scaled.push(value / factor);
}
Ok(scaled)
}
pub fn unscale_iterate(&self, scaled_iterate: &[f64]) -> Result<Vec<f64>, ReactionExtentError> {
if scaled_iterate.len() != self.scale.len() {
return Err(ReactionExtentError::DimensionMismatch(format!(
"scaled iterate has {} entries but variable scale has {} entries",
scaled_iterate.len(),
self.scale.len(),
)));
}
let mut iterate = Vec::with_capacity(scaled_iterate.len());
for (index, (value, factor)) in scaled_iterate.iter().zip(self.scale.iter()).enumerate() {
if !value.is_finite() {
return Err(ReactionExtentError::InvalidProblem {
field: "variable_iterate",
message: format!("scaled iterate[{index}] must be finite"),
});
}
iterate.push(value * factor);
}
Ok(iterate)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct EquilibriumSolution {
log_moles: Vec<f64>,
moles: Vec<f64>,
conditions: EquilibriumConditions,
validation: EquilibriumCandidateReport,
}
impl EquilibriumSolution {
pub(crate) fn new(
log_moles: Vec<f64>,
moles: Vec<f64>,
conditions: EquilibriumConditions,
validation: EquilibriumCandidateReport,
) -> Result<Self, ReactionExtentError> {
if log_moles.len() != moles.len() {
return Err(ReactionExtentError::DimensionMismatch(format!(
"solution has {} log-moles but {} physical mole values",
log_moles.len(),
moles.len()
)));
}
if log_moles.iter().any(|value| !value.is_finite())
|| moles
.iter()
.any(|value| !value.is_finite() || *value <= 0.0)
{
return Err(ReactionExtentError::InvalidCandidate {
field: "accepted_solution",
message: "accepted solution contains non-finite or non-positive values".to_string(),
});
}
Ok(Self {
log_moles,
moles,
conditions,
validation,
})
}
pub fn log_moles(&self) -> &[f64] {
&self.log_moles
}
pub fn moles(&self) -> &[f64] {
&self.moles
}
pub fn conditions(&self) -> EquilibriumConditions {
self.conditions
}
pub fn validation(&self) -> &EquilibriumCandidateReport {
&self.validation
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SpeciesCapacityLimit {
pub species_id: SpeciesId,
pub element_id: ElementId,
pub element_index: usize,
pub element_total_moles: f64,
pub stoichiometric_coefficient: f64,
pub implied_species_capacity: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SpeciesCapacityReport {
pub species_id: SpeciesId,
pub species_index: usize,
pub species_name: String,
pub limits: Vec<SpeciesCapacityLimit>,
pub limiting_limit: SpeciesCapacityLimit,
}
impl SpeciesCapacityReport {
pub fn maximum_moles(&self) -> f64 {
self.limiting_limit.implied_species_capacity
}
}
impl fmt::Display for SpeciesCapacityReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}: max {:.6} mol limited by element {}",
self.species_name,
self.maximum_moles(),
self.limiting_limit.element_index
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct NearNullDirection {
pub singular_value: f64,
pub direction: Vec<f64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FormulationDiagnostics {
pub element_rank: usize,
pub reaction_count: usize,
pub singular_values: Vec<f64>,
pub condition_estimate: Option<f64>,
pub tolerance: f64,
pub near_null_directions: Vec<NearNullDirection>,
}
impl fmt::Display for FormulationDiagnostics {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"element rank = {}, reaction count = {}",
self.element_rank, self.reaction_count
)?;
match self.condition_estimate {
Some(value) => writeln!(f, "condition estimate = {value:.6e}")?,
None => writeln!(f, "condition estimate = n/a")?,
}
for (index, singular_value) in self.singular_values.iter().enumerate() {
writeln!(f, "sv[{index}] = {singular_value:.6e}")?;
}
for (index, direction) in self.near_null_directions.iter().enumerate() {
writeln!(
f,
"null[{index}] sv={:.6e} dim={}",
direction.singular_value,
direction.direction.len()
)?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct EquilibriumProblemPreview {
pub conditions: EquilibriumConditions,
pub species: Vec<String>,
pub initial_moles: Vec<f64>,
pub element_composition: DMatrix<f64>,
pub element_totals: Vec<f64>,
pub reaction_basis_rank: usize,
pub reaction_count: usize,
pub species_capacity_reports: Vec<SpeciesCapacityReport>,
pub formulation_diagnostics: Option<FormulationDiagnostics>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EquilibriumPreviewRow {
pub section: &'static str,
pub label: String,
pub value: String,
}
impl fmt::Display for EquilibriumPreviewRow {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] {} = {}", self.section, self.label, self.value)
}
}
impl EquilibriumProblemPreview {
pub fn summary_rows(&self) -> Vec<EquilibriumPreviewRow> {
let mut rows = vec![
EquilibriumPreviewRow {
section: "problem",
label: "temperature".to_string(),
value: format!("{:.6}", self.conditions.temperature()),
},
EquilibriumPreviewRow {
section: "problem",
label: "pressure".to_string(),
value: format!("{:.6}", self.conditions.pressure()),
},
EquilibriumPreviewRow {
section: "problem",
label: "species_count".to_string(),
value: self.species.len().to_string(),
},
EquilibriumPreviewRow {
section: "problem",
label: "element_count".to_string(),
value: self.element_composition.ncols().to_string(),
},
EquilibriumPreviewRow {
section: "problem",
label: "reaction_count".to_string(),
value: self.reaction_count.to_string(),
},
];
for report in &self.species_capacity_reports {
rows.push(EquilibriumPreviewRow {
section: "species_capacity",
label: report.species_name.clone(),
value: report.to_string(),
});
}
if let Some(diagnostics) = &self.formulation_diagnostics {
rows.push(EquilibriumPreviewRow {
section: "diagnostics",
label: "element_rank".to_string(),
value: diagnostics.to_string(),
});
}
rows
}
}
impl fmt::Display for EquilibriumProblemPreview {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for row in self.summary_rows() {
writeln!(f, "{row}")?;
}
Ok(())
}
}
impl PreparedEquilibriumProblem {
pub fn new(problem: EquilibriumProblem) -> Result<Self, ReactionExtentError> {
problem.validate()?;
let reaction_basis = compute_reaction_basis(problem.element_composition(), 1e-6)?;
let species_count = problem.species().len();
let equation_count =
reaction_basis.reactions.ncols() + problem.element_composition().ncols();
if equation_count != species_count {
return Err(ReactionExtentError::DimensionMismatch(format!(
"prepared log-moles system has {equation_count} equations for {species_count} species"
)));
}
let species_phase = species_to_phase_map(problem.phases(), species_count)?;
let phase_stoichiometry =
reaction_phase_stoichiometry(&reaction_basis.reactions, problem.phases());
let element_totals = (0..problem.element_composition().ncols())
.map(|element| {
problem
.initial_moles()
.iter()
.enumerate()
.map(|(species, moles)| {
problem.element_composition()[(species, element)] * moles
})
.sum()
})
.collect();
Ok(Self {
problem,
reaction_basis,
element_totals,
species_phase,
phase_stoichiometry,
})
}
pub fn residual(&self, log_moles: &[f64]) -> Result<Vec<f64>, ReactionExtentError> {
let conditions = self.problem.conditions();
evaluate_equilibrium_logmole_residual(
log_moles,
&self.reaction_basis.reactions,
self.problem.element_composition(),
&self.element_totals,
self.problem.gibbs(),
self.problem.phases(),
conditions.temperature(),
conditions.pressure(),
conditions.reference_pressure(),
&self.species_phase,
&self.phase_stoichiometry,
)
}
pub fn jacobian(&self, log_moles: &[f64]) -> Result<DMatrix<f64>, ReactionExtentError> {
evaluate_equilibrium_logmole_jacobian(
log_moles,
&self.reaction_basis.reactions,
self.problem.element_composition(),
&self.species_phase,
&self.phase_stoichiometry,
self.problem.phases().len(),
)
}
pub fn reconstruct_moles(&self, log_moles: &[f64]) -> Result<Vec<f64>, ReactionExtentError> {
if log_moles.len() != self.problem.species().len() {
return Err(ReactionExtentError::DimensionMismatch(format!(
"log-mole vector has {} entries for {} prepared species",
log_moles.len(),
self.problem.species().len(),
)));
}
compute_species_moles(log_moles)
}
pub fn accepted_solution(
&self,
log_moles: Vec<f64>,
validation: EquilibriumCandidateReport,
) -> Result<EquilibriumSolution, ReactionExtentError> {
let moles = self.reconstruct_moles(&log_moles)?;
EquilibriumSolution::new(log_moles, moles, self.problem.conditions(), validation)
}
pub fn residual_scale(&self) -> Result<Vec<f64>, ReactionExtentError> {
self.residual_scaling_contract()
.map(|contract| contract.scale)
}
pub fn residual_scaling_contract(
&self,
) -> Result<ResidualScalingContract, ReactionExtentError> {
ResidualScalingContract::new(equilibrium_scaling(
&self.reaction_basis.reactions,
self.problem.element_composition(),
self.problem.gibbs(),
&self.element_totals,
self.problem.conditions().temperature(),
)?)
}
pub fn scaled_residual(
&self,
log_moles: &[f64],
scale: &[f64],
) -> Result<Vec<f64>, ReactionExtentError> {
ResidualScalingContract::new(scale.to_vec())?.apply_residual(self.residual(log_moles)?)
}
pub fn scaled_jacobian(
&self,
log_moles: &[f64],
scale: &[f64],
) -> Result<DMatrix<f64>, ReactionExtentError> {
ResidualScalingContract::new(scale.to_vec())?.apply_jacobian(self.jacobian(log_moles)?)
}
pub fn species_capacity_report(
&self,
species_index: usize,
) -> Result<SpeciesCapacityReport, ReactionExtentError> {
let species_id = self.species_id(species_index)?;
let mut limits = Vec::new();
let mut limiting_limit = None::<SpeciesCapacityLimit>;
for element_index in 0..self.problem.element_composition().ncols() {
let stoichiometric_coefficient =
self.problem.element_composition()[(species_index, element_index)];
if !stoichiometric_coefficient.is_finite() {
return Err(ReactionExtentError::InvalidProblem {
field: "element_composition",
message: format!(
"species row {species_index} contains a non-finite coefficient"
),
});
}
if stoichiometric_coefficient < 0.0 {
return Err(ReactionExtentError::InvalidProblem {
field: "element_composition",
message: format!(
"species row {species_index} contains a negative coefficient at element column {element_index}"
),
});
}
if stoichiometric_coefficient == 0.0 {
continue;
}
let element_total_moles = self.element_totals[element_index];
if !element_total_moles.is_finite() {
return Err(ReactionExtentError::InvalidProblem {
field: "element_totals",
message: format!("element total {element_index} is not finite"),
});
}
let implied_species_capacity = element_total_moles / stoichiometric_coefficient;
if !implied_species_capacity.is_finite() {
return Err(ReactionExtentError::InvalidProblem {
field: "species_capacity",
message: format!(
"species {species_index} capacity became non-finite for element {element_index}"
),
});
}
let limit = SpeciesCapacityLimit {
species_id,
element_id: self.element_id(element_index)?,
element_index,
element_total_moles,
stoichiometric_coefficient,
implied_species_capacity,
};
match &limiting_limit {
None => limiting_limit = Some(limit.clone()),
Some(current)
if limit.implied_species_capacity < current.implied_species_capacity =>
{
limiting_limit = Some(limit.clone());
}
_ => {}
}
limits.push(limit);
}
let limiting_limit = limiting_limit.ok_or_else(|| ReactionExtentError::InvalidProblem {
field: "element_composition",
message: format!(
"species row {species_index} contains no positive elemental coefficients"
),
})?;
Ok(SpeciesCapacityReport {
species_id,
species_index,
species_name: self.problem.species()[species_index].clone(),
limits,
limiting_limit,
})
}
pub fn species_capacity_reports(
&self,
) -> Result<Vec<SpeciesCapacityReport>, ReactionExtentError> {
(0..self.problem.species().len())
.map(|index| self.species_capacity_report(index))
.collect()
}
pub fn preview(&self) -> Result<EquilibriumProblemPreview, ReactionExtentError> {
Ok(EquilibriumProblemPreview {
conditions: self.problem.conditions(),
species: self.problem.species().to_vec(),
initial_moles: self.problem.initial_moles().to_vec(),
element_composition: self.problem.element_composition().clone(),
element_totals: self.element_totals.clone(),
reaction_basis_rank: self.reaction_basis.rank,
reaction_count: self.reaction_basis.num_reactions,
species_capacity_reports: self.species_capacity_reports()?,
formulation_diagnostics: None,
})
}
pub fn preview_with_diagnostics(
&self,
tolerance: f64,
) -> Result<EquilibriumProblemPreview, ReactionExtentError> {
let mut preview = self.preview()?;
preview.formulation_diagnostics = Some(self.formulation_diagnostics(tolerance)?);
Ok(preview)
}
pub fn formulation_diagnostics(
&self,
tolerance: f64,
) -> Result<FormulationDiagnostics, ReactionExtentError> {
if !tolerance.is_finite() || tolerance <= 0.0 {
return Err(ReactionExtentError::InvalidProblem {
field: "diagnostic_tolerance",
message: "diagnostic tolerance must be finite and strictly positive".to_string(),
});
}
let svd = SVD::new(self.problem.element_composition().transpose(), true, true);
let singular_values: Vec<f64> = svd.singular_values.iter().copied().collect();
let numerical_rank = singular_values
.iter()
.filter(|&&value| value > tolerance)
.count();
let max_sv = singular_values.iter().copied().fold(0.0_f64, f64::max);
let min_positive_sv = singular_values
.iter()
.copied()
.filter(|value| *value > tolerance)
.reduce(f64::min);
let condition_estimate = match (max_sv.is_finite(), min_positive_sv) {
(true, Some(min_sv)) if min_sv > 0.0 => Some(max_sv / min_sv),
_ => None,
};
let mut near_null_directions = Vec::new();
for reaction in 0..self.reaction_basis.reactions.ncols() {
near_null_directions.push(NearNullDirection {
singular_value: 0.0,
direction: self
.reaction_basis
.reactions
.column(reaction)
.iter()
.copied()
.collect(),
});
}
Ok(FormulationDiagnostics {
element_rank: numerical_rank,
reaction_count: self.problem.species().len().saturating_sub(numerical_rank),
singular_values,
condition_estimate,
tolerance,
near_null_directions,
})
}
pub fn problem(&self) -> &EquilibriumProblem {
&self.problem
}
pub fn reaction_basis(&self) -> &ReactionBasis {
&self.reaction_basis
}
pub fn element_totals(&self) -> &[f64] {
&self.element_totals
}
pub fn species_phase(&self) -> &[usize] {
&self.species_phase
}
pub fn phase_stoichiometry(&self) -> &[Vec<f64>] {
&self.phase_stoichiometry
}
pub fn species_id(&self, index: usize) -> Result<SpeciesId, ReactionExtentError> {
SpeciesId::new(index, self.problem.species().len())
}
pub fn element_id(&self, index: usize) -> Result<ElementId, ReactionExtentError> {
ElementId::new(index, self.element_totals.len())
}
pub fn phase_index(&self, index: usize) -> Result<PhaseIndex, ReactionExtentError> {
PhaseIndex::new(index, self.problem.phases().len())
}
pub fn reaction_id(&self, index: usize) -> Result<ReactionId, ReactionExtentError> {
ReactionId::new(index, self.reaction_basis.reactions.ncols())
}
pub(crate) fn into_legacy_parts(
self,
) -> (
Vec<String>,
Vec<f64>,
Vec<f64>,
DMatrix<f64>,
Vec<GibbsFn>,
Vec<Phase>,
EquilibriumConditions,
ReactionBasis,
Vec<f64>,
Vec<usize>,
) {
let (
species,
initial_moles,
initial_log_moles,
element_composition,
gibbs,
phases,
conditions,
) = self.problem.into_parts();
(
species,
initial_moles,
initial_log_moles,
element_composition,
gibbs,
phases,
conditions,
self.reaction_basis,
self.element_totals,
self.species_phase,
)
}
}
impl EquilibriumProblem {
#[allow(clippy::too_many_arguments)]
pub fn new<C>(
components: Vec<C>,
initial_moles: Vec<f64>,
initial_log_moles: LogMolesInitialGuess,
element_composition: DMatrix<f64>,
gibbs: Vec<GibbsFn>,
phases: Vec<Phase>,
conditions: EquilibriumConditions,
) -> Result<Self, ReactionExtentError>
where
C: Into<EquilibriumComponentDescriptor>,
{
let components = components
.into_iter()
.map(Into::into)
.collect::<Vec<EquilibriumComponentDescriptor>>();
let labels = components
.iter()
.map(EquilibriumComponentDescriptor::label)
.collect();
let problem = Self {
components,
labels,
initial_moles,
initial_log_moles,
element_composition,
gibbs,
phases,
conditions,
};
problem.validate()?;
Ok(problem)
}
pub fn validate(&self) -> Result<(), ReactionExtentError> {
let species_count = self.components.len();
if species_count == 0 {
return Err(ReactionExtentError::InvalidProblem {
field: "species",
message: "at least one species is required".to_string(),
});
}
if self
.components
.iter()
.any(|component| component.substance().trim().is_empty())
{
return Err(ReactionExtentError::InvalidProblem {
field: "components",
message: "component substance names must not be empty".to_string(),
});
}
let unique_components = self
.components
.iter()
.map(EquilibriumComponentDescriptor::id)
.collect::<HashSet<_>>();
if unique_components.len() != species_count {
return Err(ReactionExtentError::InvalidProblem {
field: "components",
message: "phase-qualified component identities must be unique".to_string(),
});
}
if self.initial_moles.len() != species_count
|| self.initial_log_moles.as_slice().len() != species_count
|| self.gibbs.len() != species_count
{
return Err(ReactionExtentError::DimensionMismatch(format!(
"species={species_count}, initial_moles={}, initial_log_moles={}, gibbs={}",
self.initial_moles.len(),
self.initial_log_moles.as_slice().len(),
self.gibbs.len(),
)));
}
if self.element_composition.nrows() != species_count
|| self.element_composition.ncols() == 0
{
return Err(ReactionExtentError::DimensionMismatch(format!(
"element composition must have {species_count} rows and at least one element column"
)));
}
if self
.initial_moles
.iter()
.any(|moles| !moles.is_finite() || *moles < 0.0)
{
return Err(ReactionExtentError::InvalidProblem {
field: "initial_moles",
message: "initial moles must be finite and non-negative".to_string(),
});
}
if self
.element_composition
.iter()
.any(|coefficient| !coefficient.is_finite() || *coefficient < 0.0)
{
return Err(ReactionExtentError::InvalidProblem {
field: "element_composition",
message: "elemental coefficients must be finite and non-negative".to_string(),
});
}
for species in 0..species_count {
if !self
.element_composition
.row(species)
.iter()
.any(|value| *value > 0.0)
{
return Err(ReactionExtentError::InvalidProblem {
field: "element_composition",
message: format!(
"species row {species} must contain at least one positive elemental coefficient"
),
});
}
}
if self.phases.is_empty() {
return Err(ReactionExtentError::InvalidProblem {
field: "phases",
message: "at least one phase is required".to_string(),
});
}
if self
.phases
.iter()
.flat_map(|phase| phase.species.iter())
.any(|&index| index >= species_count)
{
return Err(ReactionExtentError::InvalidProblem {
field: "phases",
message: "a phase references a missing species index".to_string(),
});
}
species_to_phase_map(&self.phases, species_count)?;
Ok(())
}
pub fn species(&self) -> &[String] {
&self.labels
}
pub fn components(&self) -> &[EquilibriumComponentDescriptor] {
&self.components
}
pub fn initial_moles(&self) -> &[f64] {
&self.initial_moles
}
pub fn initial_log_moles(&self) -> &LogMolesInitialGuess {
&self.initial_log_moles
}
pub fn species_id(&self, index: usize) -> Result<SpeciesId, ReactionExtentError> {
SpeciesId::new(index, self.components.len())
}
pub fn phase_index(&self, index: usize) -> Result<PhaseIndex, ReactionExtentError> {
PhaseIndex::new(index, self.phases.len())
}
pub fn element_composition(&self) -> &DMatrix<f64> {
&self.element_composition
}
pub fn gibbs(&self) -> &[GibbsFn] {
&self.gibbs
}
pub fn phases(&self) -> &[Phase] {
&self.phases
}
pub fn conditions(&self) -> EquilibriumConditions {
self.conditions
}
pub(crate) fn into_parts(
self,
) -> (
Vec<String>,
Vec<f64>,
Vec<f64>,
DMatrix<f64>,
Vec<GibbsFn>,
Vec<Phase>,
EquilibriumConditions,
) {
(
self.labels,
self.initial_moles,
self.initial_log_moles.into_inner(),
self.element_composition,
self.gibbs,
self.phases,
self.conditions,
)
}
}