#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(i32)]
#[cfg_attr(feature = "python", pyo3::pyclass(eq, eq_int))]
pub enum SatPressureModel {
Antoine = 0,
Riedel = 1,
Muller = 2,
RPM = 3,
Polynomial = 4,
Maxwell = 5,
}
use crate::eos::{CubicEos, PhaseId, ln_phi_pure};
use crate::types::Component;
use num_dual::{Dual2_64, Dual64, DualNum};
use thiserror::Error;
#[derive(Debug, Error, PartialEq)]
pub enum SatError {
#[error("component {name:?}: expected {expected} Antoine coefficients, got {got}")]
BadCoefficients {
name: String,
expected: usize,
got: usize,
},
#[error("saturation model {0:?} not available through this entry point")]
NotImplemented(SatPressureModel),
#[error("Maxwell construction failed: {0}")]
Maxwell(String),
#[error("temperature {0} K out of range for saturation correlation")]
OutOfRange(f64),
}
pub fn psat_antoine(comp: &Component, t: f64) -> Result<f64, SatError> {
psat_antoine_generic(comp, t)
}
pub fn psat_antoine_generic<D: DualNum<f64> + Copy>(comp: &Component, t: D) -> Result<D, SatError> {
if comp.psat_coeffs.len() != 3 {
return Err(SatError::BadCoefficients {
name: comp.name.clone(),
expected: 3,
got: comp.psat_coeffs.len(),
});
}
let a1 = comp.psat_coeffs[0];
let a2 = comp.psat_coeffs[1];
let a3 = comp.psat_coeffs[2];
let denom = t + a3;
if denom.re() <= 0.0 {
return Err(SatError::OutOfRange(t.re()));
}
Ok((denom.recip() * (-a2) + a1).exp() * comp.pc)
}
pub fn d_psat_dt_antoine(comp: &Component, t: f64) -> Result<f64, SatError> {
let psat = psat_antoine(comp, t)?;
let a2 = comp.psat_coeffs[1];
let a3 = comp.psat_coeffs[2];
let denom = a3 + t;
Ok(psat * a2 / (denom * denom))
}
const ATM_KPA: f64 = 101.325;
pub fn psat_riedel(comp: &Component, t: f64) -> Result<f64, SatError> {
psat_riedel_generic(comp, t)
}
pub fn psat_riedel_generic<D: DualNum<f64> + Copy>(comp: &Component, t: D) -> Result<D, SatError> {
if comp.tb <= 0.0 || comp.tc <= 0.0 || comp.pc <= 0.0 || t.re() <= 0.0 {
return Err(SatError::OutOfRange(t.re()));
}
let trb = comp.tb / comp.tc;
let aux = -35.0 + 36.0 / trb + 42.0 * trb.ln() - trb.powi(6);
let q = (0.315 * aux + (comp.pc / ATM_KPA).ln()) / (0.0838 * aux - trb.ln());
let c1 = 0.0838 * (3.758 - q);
let tr = t / comp.tc;
let ln_pr = (tr.recip() * 36.0 - 35.0) * c1 + tr.ln() * (42.0 * c1 + q) - tr.powi(6) * c1;
Ok(ln_pr.exp() * comp.pc)
}
pub fn psat_muller(comp: &Component, t: f64) -> Result<f64, SatError> {
psat_muller_generic(comp, t)
}
pub fn psat_muller_generic<D: DualNum<f64> + Copy>(comp: &Component, t: D) -> Result<D, SatError> {
if comp.tb <= 0.0 || comp.tc <= 0.0 || comp.pc <= 0.0 || t.re() <= 0.0 {
return Err(SatError::OutOfRange(t.re()));
}
let trb = comp.tb / comp.tc;
let mut a = 5.37273 * (1.0 + comp.omega);
let b = ((ATM_KPA / comp.pc).ln() - a * (1.0 - 1.0 / trb))
/ (trb.ln() - 0.832223 * (1.0 - 1.0 / trb));
a -= 0.832223 * b;
let tr = t / comp.tc;
let ln_pr = (-tr.recip() + 1.0) * a + tr.ln() * b;
Ok(ln_pr.exp() * comp.pc)
}
pub fn psat_rpm(comp: &Component, t: f64) -> Result<f64, SatError> {
psat_rpm_generic(comp, t)
}
pub fn psat_rpm_generic<D: DualNum<f64> + Copy>(comp: &Component, t: D) -> Result<D, SatError> {
if comp.tb <= 0.0 || comp.tc <= 0.0 || comp.pc <= 0.0 || t.re() <= 0.0 {
return Err(SatError::OutOfRange(t.re()));
}
let trb = comp.tb / comp.tc;
let x = (comp.pc / ATM_KPA).ln() * trb / (1.0 - trb);
let c1 = 0.4835 + 0.4605 * x;
let g = (x / c1 - (1.0 + trb)) / ((3.0 + trb) * (1.0 - trb).powi(2));
let tr = t / comp.tc;
let one_minus = -tr + 1.0;
let poly = (tr + 3.0) * one_minus.powi(3) * g - tr * tr + 1.0;
let ln_pr = -(poly / tr) * c1;
Ok(ln_pr.exp() * comp.pc)
}
pub fn psat_polynomial(comp: &Component, t: f64) -> Result<f64, SatError> {
psat_polynomial_generic(comp, t)
}
pub fn psat_polynomial_generic<D: DualNum<f64> + Copy>(
comp: &Component,
t: D,
) -> Result<D, SatError> {
if comp.psat_coeffs.len() != 5 {
return Err(SatError::BadCoefficients {
name: comp.name.clone(),
expected: 5,
got: comp.psat_coeffs.len(),
});
}
if t.re() <= 0.0 {
return Err(SatError::OutOfRange(t.re()));
}
let c = &comp.psat_coeffs;
let ln_p = t.recip() * c[1] + t.ln() * c[2] + t.powf(c[4]) * c[3] + c[0];
Ok(ln_p.exp())
}
pub fn psat_maxwell(eos: CubicEos, comp: &Component, t: f64) -> Result<f64, SatError> {
if comp.tc <= 0.0 || comp.pc <= 0.0 || t <= 0.0 {
return Err(SatError::OutOfRange(t));
}
let mut p = if comp.psat_coeffs.len() == 3 {
psat_antoine(comp, t).unwrap_or(comp.pc)
} else {
comp.pc * (5.37 * (1.0 + comp.omega) * (1.0 - comp.tc / t)).exp()
};
for _ in 0..100 {
let lnphi_l = ln_phi_pure(eos, t, p, comp, PhaseId::Liquid)
.map_err(|e| SatError::Maxwell(e.to_string()))?;
let lnphi_v = ln_phi_pure(eos, t, p, comp, PhaseId::Vapor)
.map_err(|e| SatError::Maxwell(e.to_string()))?;
let step = lnphi_l - lnphi_v;
let p_new = p * step.exp();
if !(p_new.is_finite() && p_new > 0.0) {
return Err(SatError::Maxwell(format!("non-finite P at T={t}")));
}
if ((p_new - p) / p_new).abs() < 1e-9 {
return Ok(p_new);
}
p = p_new;
}
Err(SatError::Maxwell(format!("no convergence at T={t}")))
}
pub fn reduced_psat(model: SatPressureModel, comp: &Component, t: f64) -> Result<f64, SatError> {
Ok(psat(model, comp, t)? / comp.pc)
}
pub fn d_psat_dt(model: SatPressureModel, comp: &Component, t: f64) -> Result<f64, SatError> {
let d = psat_generic(model, comp, Dual64::new(t, 1.0))?;
Ok(d.eps)
}
pub fn d2_psat_dt2(
model: SatPressureModel,
comp: &Component,
t: f64,
) -> Result<(f64, f64, f64), SatError> {
let d = psat_generic(model, comp, Dual2_64::new(t, 1.0, 0.0))?;
Ok((d.re, d.v1, d.v2))
}
pub fn condensation_cp(model: SatPressureModel, comp: &Component, t: f64) -> Result<f64, SatError> {
const R: f64 = 8.31451; let (p, p1, p2) = d2_psat_dt2(model, comp, t)?;
Ok(R * (2.0 * t * p1 / p + t * t * (p2 * p - p1 * p1) / (p * p)))
}
pub fn boiling_temperature(
model: SatPressureModel,
comp: &Component,
p: f64,
) -> Result<f64, SatError> {
if comp.tc <= 0.0 || comp.pc <= 0.0 || p <= 0.0 {
return Err(SatError::OutOfRange(p));
}
if model == SatPressureModel::Antoine {
if comp.psat_coeffs.len() != 3 {
return Err(SatError::BadCoefficients {
name: comp.name.clone(),
expected: 3,
got: comp.psat_coeffs.len(),
});
}
let (a1, a2, a3) = (
comp.psat_coeffs[0],
comp.psat_coeffs[1],
comp.psat_coeffs[2],
);
let denom = a1 - (p / comp.pc).ln();
if denom.abs() < 1e-300 {
return Err(SatError::OutOfRange(p));
}
return Ok(a2 / denom - a3);
}
let f = |tt: f64| psat(model, comp, tt).map(|ps| ps - p).unwrap_or(f64::NAN);
crate::numerics::root_finding::brent(f, 0.3 * comp.tc, comp.tc, 1e-6, 200)
.map_err(|e| SatError::Maxwell(format!("boiling-point solve: {e}")))
}
pub fn poynting_factor(comp: &Component, p: f64, psat: f64, t: f64) -> f64 {
ln_poynting_factor(comp, p, psat, t).exp()
}
pub fn ln_poynting_factor(comp: &Component, p: f64, psat: f64, t: f64) -> f64 {
const R: f64 = 8.31451; comp.liquid_volume * (p - psat) * 1e-3 / (R * t)
}
pub fn pseudo_antoine(
model: SatPressureModel,
comp: &Component,
t_ref: f64,
range: f64,
) -> Result<[f64; 3], SatError> {
let t1 = t_ref - range;
let t3 = t_ref + range;
let x1 = (psat(model, comp, t1)? / comp.pc).ln();
let x2 = (psat(model, comp, t_ref)? / comp.pc).ln();
let x3 = (psat(model, comp, t3)? / comp.pc).ln();
let teta = (t1 - t_ref) / (t_ref - t3);
let gama = (x1 - x2) / (x2 - x3);
let a3 = -(teta * t3 - gama * t1) / (teta - gama);
let a2 = (x2 - x3) / (1.0 / (t3 + a3) - 1.0 / (t_ref + a3));
let a1 = x2 + a2 / (t_ref + a3);
Ok([a1, a2, a3])
}
pub fn psat(model: SatPressureModel, comp: &Component, t: f64) -> Result<f64, SatError> {
psat_generic(model, comp, t)
}
pub fn psat_generic<D: DualNum<f64> + Copy>(
model: SatPressureModel,
comp: &Component,
t: D,
) -> Result<D, SatError> {
match model {
SatPressureModel::Antoine => psat_antoine_generic(comp, t),
SatPressureModel::Riedel => psat_riedel_generic(comp, t),
SatPressureModel::Muller => psat_muller_generic(comp, t),
SatPressureModel::RPM => psat_rpm_generic(comp, t),
SatPressureModel::Polynomial => psat_polynomial_generic(comp, t),
SatPressureModel::Maxwell => Err(SatError::NotImplemented(SatPressureModel::Maxwell)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::eos::CubicEos;
fn pentane() -> Component {
Component {
name: "n-pentane".into(),
tc: 469.7,
pc: 3370.0,
omega: 0.252,
tb: 309.2,
psat_coeffs: vec![6.738, 3165.0, 0.0],
liquid_volume: 116.0,
..Component::default()
}
}
const CORRELATIONS: [SatPressureModel; 3] = [
SatPressureModel::Riedel,
SatPressureModel::Muller,
SatPressureModel::RPM,
];
#[test]
fn correlations_finite_and_subcritical() {
let c = pentane();
for model in CORRELATIONS {
let ps = psat(model, &c, 350.0).unwrap();
assert!(ps.is_finite() && ps > 0.0 && ps < c.pc, "{model:?} ps={ps}");
assert!(reduced_psat(model, &c, 350.0).unwrap() < 1.0, "{model:?}");
}
}
#[test]
fn correlations_hit_one_atm_at_boiling_point() {
let c = pentane();
for model in CORRELATIONS {
let ps = psat(model, &c, c.tb).unwrap();
assert!(
(ps - ATM_KPA).abs() / ATM_KPA < 0.05,
"{model:?} ps@Tb={ps}"
);
}
}
#[test]
fn d_psat_dt_matches_numerical() {
let c = pentane();
let t = 350.0;
for model in [
SatPressureModel::Antoine,
SatPressureModel::Riedel,
SatPressureModel::Muller,
SatPressureModel::RPM,
] {
let analytical = d_psat_dt(model, &c, t).unwrap();
let h = 1e-2;
let num =
(psat(model, &c, t + h).unwrap() - psat(model, &c, t - h).unwrap()) / (2.0 * h);
assert!(
((analytical - num) / analytical).abs() < 1e-3,
"{model:?} a={analytical} n={num}"
);
}
}
#[test]
fn polynomial_dippr_form() {
let mut c = pentane();
c.psat_coeffs = vec![10.0, -3000.0, 0.0, 0.0, 0.0]; let ps = psat_polynomial(&c, 350.0).unwrap();
assert!((ps - (10.0 - 3000.0 / 350.0_f64).exp()).abs() < 1e-6);
}
#[test]
fn maxwell_in_antoine_ballpark() {
let c = pentane();
let pm = psat_maxwell(CubicEos::PR1976, &c, 350.0).unwrap();
let pa = psat_antoine(&c, 350.0).unwrap();
assert!(pm.is_finite() && pm > 0.0, "maxwell={pm}");
assert!((pm / pa).ln().abs() < 1.0, "maxwell={pm} antoine={pa}");
}
#[test]
fn boiling_point_round_trips() {
let c = pentane();
let p = 200.0;
let tb_a = boiling_temperature(SatPressureModel::Antoine, &c, p).unwrap();
assert!((psat_antoine(&c, tb_a).unwrap() - p).abs() / p < 1e-6);
let tb_r = boiling_temperature(SatPressureModel::Riedel, &c, p).unwrap();
assert!((psat(SatPressureModel::Riedel, &c, tb_r).unwrap() - p).abs() / p < 1e-4);
}
#[test]
fn poynting_unity_at_saturation_and_grows() {
let c = pentane();
assert!((poynting_factor(&c, 500.0, 500.0, 350.0) - 1.0).abs() < 1e-12);
assert!(poynting_factor(&c, 2000.0, 500.0, 350.0) > 1.0);
}
#[test]
fn pseudo_antoine_reproduces_model_at_ref() {
let c = pentane();
let [a1, a2, a3] = pseudo_antoine(SatPressureModel::Riedel, &c, 350.0, 5.0).unwrap();
let lp_fit = a1 - a2 / (a3 + 350.0);
let lp_true = (psat(SatPressureModel::Riedel, &c, 350.0).unwrap() / c.pc).ln();
assert!((lp_fit - lp_true).abs() < 1e-6);
}
fn polynomial_pentane() -> Component {
Component {
psat_coeffs: vec![78.741 - 6.9078, -5420.3, -8.8253, 9.6171e-6, 2.0],
sat_model: SatPressureModel::Polynomial,
..pentane()
}
}
const ALL_FORMULA_MODELS: [SatPressureModel; 5] = [
SatPressureModel::Antoine,
SatPressureModel::Riedel,
SatPressureModel::Muller,
SatPressureModel::RPM,
SatPressureModel::Polynomial,
];
fn comp_for(model: SatPressureModel) -> Component {
if model == SatPressureModel::Polynomial {
polynomial_pentane()
} else {
pentane()
}
}
#[test]
fn dual_real_part_equals_psat() {
for model in ALL_FORMULA_MODELS {
let c = comp_for(model);
for &t in &[300.0, 350.0, 420.0] {
let v = psat(model, &c, t).unwrap();
let d = psat_generic(model, &c, Dual64::new(t, 1.0)).unwrap();
assert!(
(v - d.re).abs() <= 4.0 * f64::EPSILON * v,
"{model:?} at {t} K"
);
let (p, _, _) = d2_psat_dt2(model, &c, t).unwrap();
assert!(
(v - p).abs() <= 4.0 * f64::EPSILON * v,
"{model:?} at {t} K (Dual2)"
);
}
}
}
#[test]
fn dual_first_derivative_matches_closed_form_and_fd() {
let c = pentane();
for &t in &[300.0, 350.0, 420.0] {
let a = d_psat_dt_antoine(&c, t).unwrap();
let d = d_psat_dt(SatPressureModel::Antoine, &c, t).unwrap();
assert!((a - d).abs() < 1e-12 * a.abs(), "Antoine {t}: {a} vs {d}");
}
for model in ALL_FORMULA_MODELS {
let c = comp_for(model);
for &t in &[300.0, 350.0, 420.0] {
let h = 1e-4 * t;
let fd =
(psat(model, &c, t + h).unwrap() - psat(model, &c, t - h).unwrap()) / (2.0 * h);
let d = d_psat_dt(model, &c, t).unwrap();
assert!(
(fd - d).abs() < 1e-6 * d.abs(),
"{model:?} at {t} K: FD {fd} vs dual {d}"
);
assert!(d > 0.0, "{model:?}: Psat must rise with T");
}
}
}
#[test]
fn dual_second_derivative_and_condensation_cp_match_fd() {
for model in ALL_FORMULA_MODELS {
let c = comp_for(model);
for &t in &[300.0, 350.0, 420.0] {
let h = 1e-4 * t;
let fd2 = (d_psat_dt(model, &c, t + h).unwrap()
- d_psat_dt(model, &c, t - h).unwrap())
/ (2.0 * h);
let (_, _, p2) = d2_psat_dt2(model, &c, t).unwrap();
assert!(
(fd2 - p2).abs() < 1e-6 * p2.abs().max(1e-6),
"{model:?} at {t} K: FD {fd2} vs dual {p2}"
);
let dh = |tt: f64| {
8.31451 * tt * tt * d_psat_dt(model, &c, tt).unwrap()
/ psat(model, &c, tt).unwrap()
};
let fd_cp = (dh(t + h) - dh(t - h)) / (2.0 * h);
let cp = condensation_cp(model, &c, t).unwrap();
assert!(
(fd_cp - cp).abs() < 1e-5 * cp.abs().max(1.0),
"{model:?} at {t} K: FD {fd_cp} vs analytic {cp}"
);
if t <= 350.0 {
assert!(cp < 1e-9, "{model:?} at {t} K: d(ΔH_vap)/dT = {cp} > 0");
}
}
}
assert!(matches!(
d2_psat_dt2(SatPressureModel::Maxwell, &pentane(), 300.0),
Err(SatError::NotImplemented(_))
));
}
}