use crate::thermo::{Phase, ThermoSystem};
use crate::types::{Result, StagesError};
#[derive(Debug, Clone)]
#[cfg_attr(feature = "python", pyo3::pyclass)]
pub struct EquilibriumCurve {
pressure: Option<f64>,
x: Vec<f64>,
y: Vec<f64>,
t: Vec<f64>,
}
impl EquilibriumCurve {
pub fn from_thermo(system: &ThermoSystem, pressure: f64, n_points: usize) -> Result<Self> {
if system.n_components() != 2 {
return Err(StagesError::Dimension(format!(
"equilibrium curve needs a binary system, got {} components",
system.n_components()
)));
}
if n_points < 5 {
return Err(StagesError::Dimension(format!(
"n_points must be at least 5, got {n_points}"
)));
}
let mut x = Vec::with_capacity(n_points);
let mut y = Vec::with_capacity(n_points);
let mut t = Vec::with_capacity(n_points);
for i in 0..n_points {
let xi = i as f64 / (n_points - 1) as f64;
let bp = system.bubble_temperature(pressure, &[xi, 1.0 - xi])?;
x.push(xi);
let yi = if i == 0 {
0.0
} else if i == n_points - 1 {
1.0
} else {
bp.y[0]
};
y.push(yi);
t.push(bp.value);
}
if y[1] <= x[1] {
return Err(StagesError::Thermo(format!(
"component order looks inverted: y*({:.3}) = {:.4} ≤ x — list the more volatile component first",
x[1], y[1]
)));
}
Ok(Self {
pressure: Some(pressure),
x,
y,
t,
})
}
pub fn constant_alpha(alpha: f64, n_points: usize) -> Result<Self> {
if alpha.is_nan() || alpha <= 0.0 {
return Err(StagesError::Dimension(format!(
"alpha must be positive, got {alpha}"
)));
}
if n_points < 5 {
return Err(StagesError::Dimension(format!(
"n_points must be at least 5, got {n_points}"
)));
}
let mut x = Vec::with_capacity(n_points);
let mut y = Vec::with_capacity(n_points);
for i in 0..n_points {
let xi = i as f64 / (n_points - 1) as f64;
x.push(xi);
y.push(alpha * xi / (1.0 + (alpha - 1.0) * xi));
}
Ok(Self {
pressure: None,
x,
y,
t: Vec::new(),
})
}
pub fn from_points(
x: Vec<f64>,
y: Vec<f64>,
t: Vec<f64>,
pressure: Option<f64>,
) -> Result<Self> {
if x.len() != y.len() || x.len() < 5 {
return Err(StagesError::Dimension(format!(
"x and y must have equal length ≥ 5, got {} and {}",
x.len(),
y.len()
)));
}
if !t.is_empty() && t.len() != x.len() {
return Err(StagesError::Dimension(format!(
"t must be empty or match x's length {}, got {}",
x.len(),
t.len()
)));
}
if x[0] != 0.0 || *x.last().unwrap() != 1.0 {
return Err(StagesError::Dimension(
"x must span [0, 1] with x[0] = 0 and x[last] = 1".into(),
));
}
if !x.windows(2).all(|w| w[1] > w[0]) {
return Err(StagesError::Dimension(
"x must be strictly increasing".into(),
));
}
if !y.windows(2).all(|w| w[1] >= w[0]) {
return Err(StagesError::Dimension(
"y must be nondecreasing (binary VLE curves are monotone in x)".into(),
));
}
if y.iter().any(|&v| !(0.0..=1.0).contains(&v)) {
return Err(StagesError::Dimension("y values must lie in [0, 1]".into()));
}
Ok(Self { pressure, x, y, t })
}
pub fn pressure(&self) -> Option<f64> {
self.pressure
}
pub fn x_samples(&self) -> &[f64] {
&self.x
}
pub fn y_samples(&self) -> &[f64] {
&self.y
}
pub fn t_samples(&self) -> &[f64] {
&self.t
}
pub fn y_of_x(&self, x: f64) -> Result<f64> {
interp(&self.x, &self.y, x)
}
pub fn x_of_y(&self, y: f64) -> Result<f64> {
interp(&self.y, &self.x, y)
}
pub fn temperature_of_x(&self, x: f64) -> Result<f64> {
if self.t.is_empty() {
return Err(StagesError::Dimension(
"this curve has no temperature data (synthetic curve)".into(),
));
}
interp(&self.x, &self.t, x)
}
pub fn relative_volatility(&self, x: f64) -> Result<f64> {
if !(0.0 < x && x < 1.0) {
return Err(StagesError::Dimension(format!(
"relative volatility needs x strictly inside (0, 1), got {x}"
)));
}
let y = self.y_of_x(x)?;
Ok((y / (1.0 - y)) / (x / (1.0 - x)))
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "python", pyo3::pyclass)]
pub struct EnthalpyCurve {
curve: EquilibriumCurve,
h_liq: Vec<f64>,
h_vap: Vec<f64>,
}
impl EnthalpyCurve {
pub fn from_thermo(system: &ThermoSystem, pressure: f64, n_points: usize) -> Result<Self> {
let curve = EquilibriumCurve::from_thermo(system, pressure, n_points)?;
let n = curve.x_samples().len();
let mut h_liq = Vec::with_capacity(n);
let mut h_vap = Vec::with_capacity(n);
for i in 0..n {
let (xi, yi, ti) = (
curve.x_samples()[i],
curve.y_samples()[i],
curve.t_samples()[i],
);
h_liq.push(system.phase_enthalpy(ti, pressure, &[xi, 1.0 - xi], Phase::Liquid)?);
h_vap.push(system.phase_enthalpy(ti, pressure, &[yi, 1.0 - yi], Phase::Vapor)?);
}
Ok(Self {
curve,
h_liq,
h_vap,
})
}
pub fn from_points(
x: Vec<f64>,
y: Vec<f64>,
t: Vec<f64>,
h_liq: Vec<f64>,
h_vap: Vec<f64>,
pressure: Option<f64>,
) -> Result<Self> {
let n = x.len();
if h_liq.len() != n || h_vap.len() != n {
return Err(StagesError::Dimension(format!(
"h_liq and h_vap must match x's length {n}, got {} and {}",
h_liq.len(),
h_vap.len()
)));
}
let curve = EquilibriumCurve::from_points(x, y, t, pressure)?;
Ok(Self {
curve,
h_liq,
h_vap,
})
}
pub fn equilibrium(&self) -> &EquilibriumCurve {
&self.curve
}
pub fn h_liq_samples(&self) -> &[f64] {
&self.h_liq
}
pub fn h_vap_samples(&self) -> &[f64] {
&self.h_vap
}
pub fn h_liquid_of_x(&self, x: f64) -> Result<f64> {
interp(self.curve.x_samples(), &self.h_liq, x)
}
pub fn h_vapor_of_y(&self, y: f64) -> Result<f64> {
interp(self.curve.y_samples(), &self.h_vap, y)
}
}
fn interp(xs: &[f64], ys: &[f64], x: f64) -> Result<f64> {
let (lo, hi) = (xs[0], *xs.last().unwrap());
if x < lo - 1e-12 || x > hi + 1e-12 {
return Err(StagesError::Dimension(format!(
"query {x} outside the sampled range [{lo}, {hi}]"
)));
}
let x = x.clamp(lo, hi);
let idx = xs.partition_point(|&v| v < x);
if idx == 0 {
return Ok(ys[0]);
}
let (x0, x1) = (xs[idx - 1], xs[idx.min(xs.len() - 1)]);
let (y0, y1) = (ys[idx - 1], ys[idx.min(ys.len() - 1)]);
if x1 == x0 {
return Ok(y0);
}
Ok(y0 + (y1 - y0) * (x - x0) / (x1 - x0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constant_alpha_matches_closed_form() {
let curve = EquilibriumCurve::constant_alpha(2.5, 201).unwrap();
for &x in &[0.1, 0.25, 0.5, 0.75, 0.9] {
let exact = 2.5 * x / (1.0 + 1.5 * x);
let y = curve.y_of_x(x).unwrap();
assert!((y - exact).abs() < 5e-5, "y({x}) = {y}, exact {exact}");
}
}
#[test]
fn inverse_roundtrip() {
let curve = EquilibriumCurve::constant_alpha(2.5, 201).unwrap();
for &x in &[0.05, 0.3, 0.6, 0.95] {
let y = curve.y_of_x(x).unwrap();
let x_back = curve.x_of_y(y).unwrap();
assert!((x_back - x).abs() < 1e-9, "roundtrip {x} → {y} → {x_back}");
}
}
#[test]
fn relative_volatility_recovers_alpha() {
let curve = EquilibriumCurve::constant_alpha(2.5, 401).unwrap();
for &x in &[0.2, 0.5, 0.8] {
let a = curve.relative_volatility(x).unwrap();
assert!((a - 2.5).abs() < 5e-3, "α({x}) = {a}");
}
}
#[test]
fn from_points_validation() {
assert!(
EquilibriumCurve::from_points(vec![0.0, 1.0], vec![0.0, 1.0], vec![], None).is_err()
);
assert!(
EquilibriumCurve::from_points(
vec![0.1, 0.3, 0.5, 0.7, 1.0],
vec![0.2, 0.5, 0.7, 0.85, 1.0],
vec![],
None
)
.is_err()
);
assert!(
EquilibriumCurve::from_points(
vec![0.0, 0.25, 0.5, 0.75, 1.0],
vec![0.0, 0.6, 0.5, 0.9, 1.0],
vec![],
None
)
.is_err()
);
}
#[test]
fn enthalpy_curve_vapor_above_liquid() {
use crate::thermo::ThermoSystem;
let sys = ThermoSystem::peng_robinson(&["benzene", "toluene"]).unwrap();
let ec = EnthalpyCurve::from_thermo(&sys, 101.325, 51).unwrap();
let (hl, hv) = (ec.h_liq_samples(), ec.h_vap_samples());
for i in 1..hl.len() - 1 {
assert!(
hv[i] > hl[i],
"H_vap[{i}] = {} ≤ H_liq[{i}] = {} kJ/kmol",
hv[i],
hl[i]
);
}
let x = ec.equilibrium().x_samples()[10];
assert!((ec.h_liquid_of_x(x).unwrap() - hl[10]).abs() < 1e-6);
}
#[test]
fn enthalpy_from_points_validation() {
let x = vec![0.0, 0.25, 0.5, 0.75, 1.0];
let y = vec![0.0, 0.4, 0.6, 0.8, 1.0];
assert!(
EnthalpyCurve::from_points(
x.clone(),
y.clone(),
vec![],
vec![0.0, 1.0, 2.0],
vec![10.0, 11.0, 12.0, 13.0, 14.0],
None
)
.is_err()
);
assert!(
EnthalpyCurve::from_points(
x,
y,
vec![],
vec![0.0, 1.0, 2.0, 3.0, 4.0],
vec![10.0, 11.0, 12.0, 13.0, 14.0],
None
)
.is_ok()
);
}
}