#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(i32)]
#[cfg_attr(feature = "python", pyo3::pyclass(eq, eq_int))]
pub enum CubicEos {
PR1976 = 0,
RK1949 = 1,
RKS1972 = 2,
VdW1870 = 3,
PRL1997 = 4,
RKSL1997 = 5,
RKSGD1978 = 6,
RP1978 = 7,
Berth1899 = 8,
VdWAda1984 = 9,
VdWVald1989 = 10,
RKSmn1980 = 11,
RKSATmn1995 = 12,
PRATmng1997 = 13,
PRMmn1989 = 14,
PRSV1986 = 15,
VdWOL1998 = 16,
RKOL1998 = 17,
PROL1998 = 18,
SchmidtWenzel = 19,
PatelTeja = 20,
PatelTejaUSB = 21,
}
impl CubicEos {
pub fn is_three_parameter(&self) -> bool {
matches!(
self,
CubicEos::SchmidtWenzel | CubicEos::PatelTeja | CubicEos::PatelTejaUSB
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum VaporModel {
IdealGas,
Virial,
Cubic(CubicEos),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LiquidModel {
IdealSolution,
Cubic(CubicEos),
Activity(super::ActivityModel),
ChaoSeader,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "python", pyo3::pyclass(eq, eq_int))]
#[repr(i32)]
pub enum PhaseId {
Vapor = 0,
Liquid = 1,
}
use crate::numerics::cubic::{solve_real, CubicError};
use crate::types::Component;
use thiserror::Error;
#[derive(Debug, Error, PartialEq)]
pub enum EosError {
#[error("cubic solver failed: {0}")]
Cubic(#[from] CubicError),
#[error("no real root above B={big_b:.6e} found for phase {phase:?}")]
NoRootForPhase { phase: PhaseId, big_b: f64 },
#[error("EOS variant {0:?} not yet ported — see M7 sub-milestones")]
NotImplemented(CubicEos),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FamilyConstants {
pub k1: f64,
pub k2: f64,
pub om_a: f64,
pub om_b: f64,
}
pub fn family_constants(eos: CubicEos) -> FamilyConstants {
use CubicEos::*;
match eos {
PR1976 | RP1978 | PRL1997 | PRATmng1997 | PRSV1986 | PROL1998 | PRMmn1989 => {
FamilyConstants {
k1: 2.0,
k2: -1.0,
om_a: 0.457235528921382,
om_b: 0.0777960739038885,
}
}
RKS1972 | RKSL1997 | RKSGD1978 | RK1949 | VdWVald1989 | RKSATmn1995 | RKOL1998
| RKSmn1980 => FamilyConstants {
k1: 1.0,
k2: 0.0,
om_a: 0.427480233540341,
om_b: 0.0866403499649577,
},
VdW1870 | Berth1899 | VdWAda1984 | VdWOL1998 => FamilyConstants {
k1: 0.0,
k2: 0.0,
om_a: 27.0 / 64.0,
om_b: 1.0 / 8.0,
},
SchmidtWenzel | PatelTeja | PatelTejaUSB => FamilyConstants {
k1: 0.0,
k2: 0.0,
om_a: 0.0,
om_b: 0.0,
},
}
}
pub fn alpha(eos: CubicEos, tr: f64, comp: &Component) -> f64 {
use CubicEos::*;
let w = comp.omega;
match eos {
VdW1870 => 1.0,
RK1949 => 1.0 / tr.sqrt(),
RKS1972 => {
let m = 0.48 + 1.574 * w - 0.176 * w * w;
let s = 1.0 - tr.sqrt();
(1.0 + m * s).powi(2)
}
PR1976 => {
let kappa = 0.37464 + 1.54226 * w - 0.26992 * w * w;
let s = 1.0 - tr.sqrt();
(1.0 + kappa * s).powi(2)
}
Berth1899 | VdWAda1984 | RKSGD1978 | RKSL1997 | RP1978 | PRL1997 | VdWVald1989
| RKSmn1980 | RKSATmn1995 | PRATmng1997 | PRMmn1989 | PRSV1986 | VdWOL1998 | RKOL1998
| PROL1998 => {
unimplemented!(
"M7.2 deferred: alpha({:?}) not yet ported — see legacy/vb6/clsQbicsPure.cls:1719",
eos
)
}
SchmidtWenzel | PatelTeja | PatelTejaUSB => {
unimplemented!(
"M7.3 deferred: 3-param EOS {:?} not yet ported — see legacy/pascal/TERMOII.PAS",
eos
)
}
}
}
pub fn d_alpha_d_tr(eos: CubicEos, tr: f64, comp: &Component) -> f64 {
use CubicEos::*;
let w = comp.omega;
match eos {
VdW1870 => 0.0,
RK1949 => -0.5 / (tr * tr.sqrt()),
RKS1972 => {
let m = 0.48 + 1.574 * w - 0.176 * w * w;
let s = 1.0 - tr.sqrt();
-m * (1.0 + m * s) / tr.sqrt()
}
PR1976 => {
let kappa = 0.37464 + 1.54226 * w - 0.26992 * w * w;
let s = 1.0 - tr.sqrt();
-kappa * (1.0 + kappa * s) / tr.sqrt()
}
Berth1899 | VdWAda1984 | RKSGD1978 | RKSL1997 | RP1978 | PRL1997 | VdWVald1989
| RKSmn1980 | RKSATmn1995 | PRATmng1997 | PRMmn1989 | PRSV1986 | VdWOL1998 | RKOL1998
| PROL1998 => {
unimplemented!(
"M7.2 deferred: d_alpha_d_tr({:?}) not yet ported",
eos
)
}
SchmidtWenzel | PatelTeja | PatelTejaUSB => {
unimplemented!(
"M7.3 deferred: 3-param EOS {:?} not yet ported",
eos
)
}
}
}
fn ab_dimensionless(eos: CubicEos, t: f64, p: f64, comp: &Component) -> (f64, f64) {
let fc = family_constants(eos);
let tr = t / comp.tc;
let pr = p / comp.pc;
let a_val = alpha(eos, tr, comp);
(fc.om_a * a_val * pr / (tr * tr), fc.om_b * pr / tr)
}
pub fn z_factor(
eos: CubicEos,
t: f64,
p: f64,
comp: &Component,
phase: PhaseId,
) -> Result<f64, EosError> {
if eos.is_three_parameter() {
return Err(EosError::NotImplemented(eos));
}
let fc = family_constants(eos);
let (big_a, big_b) = ab_dimensionless(eos, t, p, comp);
let a2 = (fc.k1 - 1.0) * big_b - 1.0;
let a1 = big_a - (fc.k1 - fc.k2) * big_b * big_b - fc.k1 * big_b;
let a0 = -(fc.k2 * big_b * big_b * big_b + fc.k2 * big_b * big_b + big_a * big_b);
let roots = solve_real(1.0, a2, a1, a0)?;
let mut physical: Vec<f64> = roots.into_iter().filter(|&z| z > big_b).collect();
if physical.is_empty() {
return Err(EosError::NoRootForPhase { phase, big_b });
}
physical.sort_by(|a, b| a.partial_cmp(b).unwrap());
Ok(match phase {
PhaseId::Liquid => *physical.first().unwrap(),
PhaseId::Vapor => *physical.last().unwrap(),
})
}
fn integral_attractive(z: f64, big_b: f64, fc: FamilyConstants) -> f64 {
let disc = fc.k1 * fc.k1 - 4.0 * fc.k2;
if disc.abs() < 1e-12 {
big_b / z
} else {
let sd = disc.sqrt();
(1.0 / sd) * ((2.0 * z + big_b * (fc.k1 + sd)) / (2.0 * z + big_b * (fc.k1 - sd))).ln()
}
}
pub fn ln_phi_pure(
eos: CubicEos,
t: f64,
p: f64,
comp: &Component,
phase: PhaseId,
) -> Result<f64, EosError> {
if eos.is_three_parameter() {
return Err(EosError::NotImplemented(eos));
}
let fc = family_constants(eos);
let (big_a, big_b) = ab_dimensionless(eos, t, p, comp);
let z = z_factor(eos, t, p, comp, phase)?;
let f = integral_attractive(z, big_b, fc);
Ok(z - 1.0 - (z - big_b).ln() - (big_a / big_b) * f)
}
pub fn h_departure_rt(
eos: CubicEos,
t: f64,
p: f64,
comp: &Component,
phase: PhaseId,
) -> Result<f64, EosError> {
if eos.is_three_parameter() {
return Err(EosError::NotImplemented(eos));
}
let fc = family_constants(eos);
let (big_a, big_b) = ab_dimensionless(eos, t, p, comp);
let z = z_factor(eos, t, p, comp, phase)?;
let tr = t / comp.tc;
let a_val = alpha(eos, tr, comp);
let da_val = d_alpha_d_tr(eos, tr, comp);
let f = integral_attractive(z, big_b, fc);
Ok((z - 1.0) + (big_a / big_b) * f * (tr * da_val / a_val - 1.0))
}
pub fn s_departure_r(
eos: CubicEos,
t: f64,
p: f64,
comp: &Component,
phase: PhaseId,
) -> Result<f64, EosError> {
if eos.is_three_parameter() {
return Err(EosError::NotImplemented(eos));
}
let g_dep = ln_phi_pure(eos, t, p, comp, phase)?;
let h_dep = h_departure_rt(eos, t, p, comp, phase)?;
Ok(h_dep - g_dep)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discriminant_values_match_legacy() {
assert_eq!(CubicEos::PR1976 as i32, 0);
assert_eq!(CubicEos::RKS1972 as i32, 2);
assert_eq!(CubicEos::PRSV1986 as i32, 15);
assert_eq!(CubicEos::PROL1998 as i32, 18);
assert_eq!(CubicEos::SchmidtWenzel as i32, 19);
assert_eq!(CubicEos::PatelTeja as i32, 20);
assert_eq!(CubicEos::PatelTejaUSB as i32, 21);
}
#[test]
fn three_parameter_detection() {
assert!(!CubicEos::PR1976.is_three_parameter());
assert!(!CubicEos::RKS1972.is_three_parameter());
assert!(CubicEos::SchmidtWenzel.is_three_parameter());
assert!(CubicEos::PatelTeja.is_three_parameter());
assert!(CubicEos::PatelTejaUSB.is_three_parameter());
}
fn methane() -> Component {
Component {
name: "methane".into(),
tc: 190.564,
pc: 4599.0, omega: 0.0115,
..Component::default()
}
}
fn n_pentane() -> Component {
Component {
name: "n-pentane".into(),
tc: 469.7,
pc: 3370.0,
omega: 0.252,
..Component::default()
}
}
#[test]
fn family_constants_match_legacy_table() {
let fc = family_constants(CubicEos::PR1976);
assert_eq!(fc.k1, 2.0);
assert_eq!(fc.k2, -1.0);
assert!((fc.om_a - 0.457235528921382).abs() < 1e-15);
assert!((fc.om_b - 0.0777960739038885).abs() < 1e-15);
let fc = family_constants(CubicEos::RKS1972);
assert_eq!(fc.k1, 1.0);
assert_eq!(fc.k2, 0.0);
assert!((fc.om_a - 0.427480233540341).abs() < 1e-15);
assert!((fc.om_b - 0.0866403499649577).abs() < 1e-15);
let fc = family_constants(CubicEos::VdW1870);
assert_eq!(fc.k1, 0.0);
assert_eq!(fc.k2, 0.0);
assert_eq!(fc.om_a, 27.0 / 64.0);
assert_eq!(fc.om_b, 1.0 / 8.0);
}
#[test]
fn alpha_at_tr_one_is_one_for_pr_rks() {
let c = n_pentane();
for eos in [
CubicEos::PR1976,
CubicEos::RKS1972,
CubicEos::RK1949,
CubicEos::VdW1870,
] {
let a = alpha(eos, 1.0, &c);
assert!(
(a - 1.0).abs() < 1e-12,
"{:?}: α(Tr=1) = {} (expected 1.0)",
eos,
a
);
}
}
fn d_alpha_numerical(eos: CubicEos, tr: f64, comp: &Component, h: f64) -> f64 {
(alpha(eos, tr + h, comp) - alpha(eos, tr - h, comp)) / (2.0 * h)
}
#[test]
fn analytical_d_alpha_matches_numerical() {
let c = n_pentane();
for eos in [
CubicEos::PR1976,
CubicEos::RKS1972,
CubicEos::RK1949,
CubicEos::VdW1870,
] {
for tr in [0.5_f64, 0.8, 1.0, 1.2, 2.0] {
let analytical = d_alpha_d_tr(eos, tr, &c);
let numerical = d_alpha_numerical(eos, tr, &c, 1e-6);
let rel = if analytical.abs() < 1e-10 {
(analytical - numerical).abs()
} else {
((analytical - numerical) / analytical).abs()
};
assert!(
rel < 1e-5,
"{:?} Tr={} analytical={} numerical={} rel={}",
eos,
tr,
analytical,
numerical,
rel
);
}
}
}
#[test]
fn z_factor_methane_supercritical() {
let c = methane();
let z_v = z_factor(CubicEos::PR1976, 300.0, 5000.0, &c, PhaseId::Vapor).unwrap();
assert!(z_v > 0.8 && z_v < 1.05, "Z(vapor) = {} not in plausible range", z_v);
}
#[test]
fn z_factor_n_pentane_two_phase() {
let c = n_pentane();
let z_l = z_factor(CubicEos::PR1976, 400.0, 1500.0, &c, PhaseId::Liquid).unwrap();
let z_v = z_factor(CubicEos::PR1976, 400.0, 1500.0, &c, PhaseId::Vapor).unwrap();
assert!(
z_l < z_v,
"expected Z_liquid={} < Z_vapor={}",
z_l,
z_v
);
assert!(z_l < 0.1, "liquid Z should be small, got {}", z_l);
assert!(z_v > 0.5, "vapor Z should be > 0.5, got {}", z_v);
}
#[test]
fn ln_phi_ideal_gas_limit() {
let c = methane();
for eos in [
CubicEos::PR1976,
CubicEos::RKS1972,
CubicEos::RK1949,
CubicEos::VdW1870,
] {
let ln_phi = ln_phi_pure(eos, 300.0, 0.1, &c, PhaseId::Vapor).unwrap();
assert!(
ln_phi.abs() < 1e-3,
"{:?}: ln(φ) at P→0 = {} (expected near 0)",
eos,
ln_phi
);
}
}
#[test]
fn three_parameter_eos_errors_cleanly() {
let c = methane();
for eos in [
CubicEos::SchmidtWenzel,
CubicEos::PatelTeja,
CubicEos::PatelTejaUSB,
] {
let err = z_factor(eos, 300.0, 1000.0, &c, PhaseId::Vapor).unwrap_err();
assert!(matches!(err, EosError::NotImplemented(_)));
}
}
#[test]
#[should_panic(expected = "M7.2 deferred")]
fn deferred_alpha_panics_with_marker() {
let c = methane();
let _ = alpha(CubicEos::RKSGD1978, 1.0, &c);
}
}