use vle_thermo::activity::ActivityModel;
use vle_thermo::eos::{CubicEos, LiquidModel, PhaseId, VaporModel};
use vle_thermo::flash::bubble::{bubble_pressure, bubble_temperature};
use vle_thermo::flash::{SystemSpec, phase_enthalpy_entropy};
use vle_thermo::mixing::MixingRule;
use vle_thermo::types::Component;
use crate::types::{Result, StagesError};
const BUBBLE_TOL: f64 = 1e-9;
const BUBBLE_MAX_ITER: usize = 200;
const T_REF: f64 = 298.15;
const P_REF: f64 = 101.325;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
Liquid,
Vapor,
}
impl Phase {
fn to_phase_id(self) -> PhaseId {
match self {
Phase::Liquid => PhaseId::Liquid,
Phase::Vapor => PhaseId::Vapor,
}
}
}
#[derive(Debug, Clone)]
pub struct BubblePoint {
pub value: f64,
pub y: Vec<f64>,
pub k: Vec<f64>,
}
#[derive(Debug, Clone)]
enum ModelKind {
PhiPhi { eos: CubicEos, kij: Vec<Vec<f64>> },
GammaPhi {
activity: ActivityModel,
aij: Vec<Vec<f64>>,
alpha: Vec<Vec<f64>>,
},
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "python", pyo3::pyclass)]
pub struct ThermoSystem {
components: Vec<Component>,
model: ModelKind,
t_ref: f64,
p_ref: f64,
}
impl ThermoSystem {
pub fn peng_robinson(names: &[&str]) -> Result<Self> {
Ok(Self {
components: load_components(names)?,
model: ModelKind::PhiPhi {
eos: CubicEos::PR1976,
kij: Vec::new(),
},
t_ref: T_REF,
p_ref: P_REF,
})
}
pub fn van_laar(names: &[&str; 2], a12: f64, a21: f64) -> Result<Self> {
Ok(Self {
components: load_components(names)?,
model: ModelKind::GammaPhi {
activity: ActivityModel::VanLaar,
aij: vec![vec![0.0, a12], vec![a21, 0.0]],
alpha: Vec::new(),
},
t_ref: T_REF,
p_ref: P_REF,
})
}
pub fn nrtl(names: &[&str; 2], a12: f64, a21: f64, alpha12: f64) -> Result<Self> {
Ok(Self {
components: load_components(names)?,
model: ModelKind::GammaPhi {
activity: ActivityModel::Nrtl,
aij: vec![vec![0.0, a12], vec![a21, 0.0]],
alpha: vec![vec![0.0, alpha12], vec![alpha12, 0.0]],
},
t_ref: T_REF,
p_ref: P_REF,
})
}
pub fn t_ref(&self) -> f64 {
self.t_ref
}
pub fn p_ref(&self) -> f64 {
self.p_ref
}
pub fn n_components(&self) -> usize {
self.components.len()
}
pub fn component_names(&self) -> Vec<String> {
self.components.iter().map(|c| c.name.clone()).collect()
}
pub fn bubble_temperature(&self, p: f64, x: &[f64]) -> Result<BubblePoint> {
self.check_composition(x)?;
self.with_spec(|spec| {
let r = bubble_temperature(spec, p, x, BUBBLE_TOL, BUBBLE_MAX_ITER)
.map_err(|e| StagesError::Thermo(e.to_string()))?;
Ok(BubblePoint {
value: r.value,
y: r.incipient,
k: r.k,
})
})
}
pub fn bubble_pressure(&self, t: f64, x: &[f64]) -> Result<BubblePoint> {
self.check_composition(x)?;
self.with_spec(|spec| {
let r = bubble_pressure(spec, t, x, BUBBLE_TOL, BUBBLE_MAX_ITER)
.map_err(|e| StagesError::Thermo(e.to_string()))?;
Ok(BubblePoint {
value: r.value,
y: r.incipient,
k: r.k,
})
})
}
pub fn phase_enthalpy(&self, t: f64, p: f64, comp: &[f64], phase: Phase) -> Result<f64> {
self.check_composition(comp)?;
let phase_id = phase.to_phase_id();
self.with_spec(|spec| {
let (h, _s) = phase_enthalpy_entropy(
spec,
t,
p,
comp,
phase_id,
self.t_ref,
self.p_ref,
&[],
&[],
)
.map_err(|e| StagesError::Thermo(e.to_string()))?;
Ok(h)
})
}
fn with_spec<T>(&self, f: impl FnOnce(&SystemSpec<'_>) -> Result<T>) -> Result<T> {
let spec = match &self.model {
ModelKind::PhiPhi { eos, kij } => SystemSpec {
components: &self.components,
vapor: VaporModel::Cubic(*eos),
liquid: LiquidModel::Cubic(*eos),
mixing_rule: MixingRule::Classical,
kij,
aij: &[],
alpha: &[],
vl: &[],
delta: &[],
sat_models: &[],
ge_model: None,
},
ModelKind::GammaPhi {
activity,
aij,
alpha,
} => SystemSpec {
components: &self.components,
vapor: VaporModel::IdealGas,
liquid: LiquidModel::Activity(*activity),
mixing_rule: MixingRule::Classical,
kij: &[],
aij,
alpha,
vl: &[],
delta: &[],
sat_models: &[],
ge_model: None,
},
};
f(&spec)
}
fn check_composition(&self, x: &[f64]) -> Result<()> {
if x.len() != self.components.len() {
return Err(StagesError::Dimension(format!(
"composition has {} entries but the system has {} components",
x.len(),
self.components.len()
)));
}
Ok(())
}
}
fn load_components(names: &[&str]) -> Result<Vec<Component>> {
names
.iter()
.map(|name| {
vle_thermo::db::component(name).ok_or_else(|| {
StagesError::Thermo(format!(
"component {name:?} not in the vle-thermo database (available: {})",
vle_thermo::db::available().join(", ")
))
})
})
.collect()
}
pub fn smoke_bubble_temperature() -> Result<f64> {
let system = ThermoSystem::peng_robinson(&["methanol", "water"])?;
Ok(system.bubble_temperature(101.325, &[0.5, 0.5])?.value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn smoke_bubble_temperature_is_physical() {
let t = smoke_bubble_temperature().expect("bubble_temperature should converge");
assert!(
(280.0..400.0).contains(&t),
"methanol/water bubble T = {t} K is outside the plausible window [280, 400)"
);
}
#[test]
fn benzene_toluene_pure_endpoints_near_boiling_points() {
let sys = ThermoSystem::peng_robinson(&["benzene", "toluene"]).unwrap();
let t_b = sys.bubble_temperature(101.325, &[1.0, 0.0]).unwrap().value;
let t_t = sys.bubble_temperature(101.325, &[0.0, 1.0]).unwrap().value;
assert!((t_b - 353.24).abs() < 3.0, "benzene Tb = {t_b} K");
assert!((t_t - 383.78).abs() < 3.0, "toluene Tb = {t_t} K");
}
#[test]
fn methanol_water_van_laar_positive_deviation() {
let sys = ThermoSystem::van_laar(&["methanol", "water"], 0.5853, 0.3458).unwrap();
let p_pure_m = sys.bubble_pressure(298.15, &[1.0, 0.0]).unwrap().value;
let p_pure_w = sys.bubble_pressure(298.15, &[0.0, 1.0]).unwrap().value;
let p_mid = sys.bubble_pressure(298.15, &[0.5, 0.5]).unwrap().value;
let raoult = 0.5 * p_pure_m + 0.5 * p_pure_w;
assert!(
p_mid > raoult,
"van Laar should give positive deviation: P(0.5) = {p_mid} kPa ≤ Raoult {raoult} kPa"
);
}
#[test]
fn ethanol_water_nrtl_positive_deviation() {
let sys = ThermoSystem::nrtl(&["ethanol", "water"], -458.7, 5574.0, 0.303).unwrap();
let p_pure_e = sys.bubble_pressure(298.15, &[1.0, 0.0]).unwrap().value;
let p_pure_w = sys.bubble_pressure(298.15, &[0.0, 1.0]).unwrap().value;
let p_mid = sys.bubble_pressure(298.15, &[0.5, 0.5]).unwrap().value;
let raoult = 0.5 * p_pure_e + 0.5 * p_pure_w;
assert!(
p_mid > raoult,
"NRTL should give positive deviation: P(0.5) = {p_mid} kPa ≤ Raoult {raoult} kPa"
);
}
#[test]
fn methanol_water_vapor_enthalpy_exceeds_liquid() {
let sys = ThermoSystem::van_laar(&["methanol", "water"], 0.5853, 0.3458).unwrap();
let p = 101.325;
let x = [0.4, 0.6];
let bp = sys.bubble_temperature(p, &x).unwrap();
let t = bp.value;
let h_liq = sys.phase_enthalpy(t, p, &x, Phase::Liquid).unwrap();
let h_vap = sys.phase_enthalpy(t, p, &bp.y, Phase::Vapor).unwrap();
assert!(
h_vap > h_liq,
"latent heat must be positive: H_vap = {h_vap} ≤ H_liq = {h_liq} kJ/kmol"
);
let dh = h_vap - h_liq;
assert!(
(10_000.0..80_000.0).contains(&dh),
"ΔH_vap = {dh} kJ/kmol is outside the plausible latent-heat window"
);
}
#[test]
fn reference_state_is_standard() {
let sys = ThermoSystem::peng_robinson(&["benzene", "toluene"]).unwrap();
assert_eq!(sys.t_ref(), 298.15);
assert_eq!(sys.p_ref(), 101.325);
}
#[test]
fn unknown_component_is_a_thermo_error() {
let err = ThermoSystem::peng_robinson(&["benzene", "unobtainium"]).unwrap_err();
assert!(matches!(err, StagesError::Thermo(_)));
assert!(err.to_string().contains("unobtainium"));
}
#[test]
fn dimension_mismatch_is_caught() {
let sys = ThermoSystem::peng_robinson(&["benzene", "toluene"]).unwrap();
let err = sys.bubble_temperature(101.325, &[1.0]).unwrap_err();
assert!(matches!(err, StagesError::Dimension(_)));
}
}