use super::RefineryError;
use crate::eos::PhaseId;
use crate::numerics::root_finding::brent;
use crate::types::{Component, R_GAS};
pub const OMEGA_REFERENCE: f64 = 0.3978;
#[derive(Debug, Clone, Copy)]
struct Bwr {
b1: f64,
b2: f64,
b3: f64,
b4: f64,
c1: f64,
c2: f64,
c3: f64,
c4: f64,
d1: f64,
d2: f64,
beta: f64,
gamma: f64,
}
const SIMPLE: Bwr = Bwr {
b1: 0.118_119_3,
b2: 0.265_728,
b3: 0.154_790,
b4: 0.030_323,
c1: 0.023_674_4,
c2: 0.018_698_4,
c3: 0.0,
c4: 0.042_724,
d1: 0.155_488e-4,
d2: 0.623_689e-4,
beta: 0.653_92,
gamma: 0.060_167,
};
const REFERENCE: Bwr = Bwr {
b1: 0.202_657_9,
b2: 0.331_511,
b3: 0.027_655,
b4: 0.203_488,
c1: 0.031_338_5,
c2: 0.050_361_8,
c3: 0.016_901,
c4: 0.041_577,
d1: 0.487_36e-4,
d2: 0.074_033_6e-4,
beta: 1.226,
gamma: 0.037_54,
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LkDeparture {
pub z: f64,
pub h_dep_rt: f64,
pub s_dep_r: f64,
pub ln_phi: f64,
}
#[inline]
fn z_and_dz(k: &Bwr, tr: f64, vr: f64) -> (f64, f64) {
let tr2 = tr * tr;
let tr3 = tr2 * tr;
let b = k.b1 - k.b2 / tr - k.b3 / tr2 - k.b4 / tr3;
let c = k.c1 - k.c2 / tr + k.c3 / tr3;
let d = k.d1 + k.d2 / tr;
let iv = 1.0 / vr;
let iv2 = iv * iv;
let iv5 = iv2 * iv2 * iv;
let u = iv2; let e = (-k.gamma * u).exp();
let z = 1.0 + b * iv + c * iv2 + d * iv5 + k.c4 / tr3 * (k.beta * u + k.gamma * u * u) * e;
let dexp_du =
e * (k.beta + 2.0 * k.gamma * u - k.gamma * k.beta * u - k.gamma * k.gamma * u * u);
let dz = -b * iv2 - 2.0 * c * iv2 * iv - 5.0 * d * iv5 * iv
+ k.c4 / tr3 * dexp_du * (-2.0 * iv2 * iv);
(z, dz)
}
fn departure(k: &Bwr, tr: f64, vr: f64) -> LkDeparture {
let tr2 = tr * tr;
let tr3 = tr2 * tr;
let b = k.b1 - k.b2 / tr - k.b3 / tr2 - k.b4 / tr3;
let c = k.c1 - k.c2 / tr + k.c3 / tr3;
let d = k.d1 + k.d2 / tr;
let iv = 1.0 / vr;
let iv2 = iv * iv;
let iv5 = iv2 * iv2 * iv;
let g = k.gamma * iv2;
let ex = (-g).exp();
let z = 1.0 + b * iv + c * iv2 + d * iv5 + k.c4 / tr3 * iv2 * (k.beta + g) * ex;
let e = k.c4 / (2.0 * tr3 * k.gamma) * (k.beta + 1.0 - (k.beta + 1.0 + g) * ex);
let h_rtc = tr
* (z - 1.0
- (k.b2 + 2.0 * k.b3 / tr + 3.0 * k.b4 / tr2) / (tr * vr)
- (k.c2 - 3.0 * k.c3 / tr2) / (2.0 * tr * vr * vr)
+ k.d2 / (5.0 * tr * vr * vr * vr * vr * vr)
+ 3.0 * e);
let s_r = z.ln()
- (k.b1 + k.b3 / tr2 + 2.0 * k.b4 / tr3) * iv
- (k.c1 - 2.0 * k.c3 / tr3) / (2.0 * vr * vr)
- k.d1 / (5.0 * vr * vr * vr * vr * vr)
+ 2.0 * e;
let ln_phi = z - 1.0 - z.ln() + b * iv + c * iv2 / 2.0 + d * iv5 / 5.0 + e;
LkDeparture {
z,
h_dep_rt: h_rtc / tr, s_dep_r: s_r,
ln_phi,
}
}
fn solve_vr(k: &Bwr, tr: f64, pr: f64, phase: PhaseId) -> Result<f64, RefineryError> {
let f = |vr: f64| pr * vr / tr - z_and_dz(k, tr, vr).0;
let lo = 0.02_f64;
let hi = (4.0 * tr / pr).max(4.0);
const N: usize = 60;
let step = (hi / lo).ln() / N as f64;
let mut brackets: [(f64, f64); 8] = [(0.0, 0.0); 8];
let mut nb = 0;
let mut prev_v = lo;
let mut prev_f = f(lo);
for i in 1..=N {
let v = lo * (step * i as f64).exp();
let fv = f(v);
if prev_f <= 0.0 && fv > 0.0 && nb < brackets.len() {
brackets[nb] = (prev_v, v);
nb += 1;
}
prev_v = v;
prev_f = fv;
}
if nb == 0 {
return Err(RefineryError::NoConvergence(format!(
"Lee-Kesler found no reduced-volume root at Tr = {tr:.4}, Pr = {pr:.4}"
)));
}
let (a, b) = match phase {
PhaseId::Liquid => brackets[0],
PhaseId::Vapor => brackets[nb - 1],
};
let vr = brent(f, a, b, 1e-13, 200).map_err(|e| {
RefineryError::NoConvergence(format!(
"Lee-Kesler Vr solve failed at Tr = {tr:.4}, Pr = {pr:.4}: {e}"
))
})?;
let mut v = vr;
for _ in 0..2 {
let (z, dz) = z_and_dz(k, tr, v);
let fv = pr * v / tr - z;
let dfv = pr / tr - dz;
if dfv.abs() > 0.0 {
let nv = v - fv / dfv;
if nv > 0.0 && nv.is_finite() {
v = nv;
}
}
}
Ok(v)
}
fn check_reduced(tr: f64, pr: f64, omega: f64) -> Result<(), RefineryError> {
if !(tr > 0.0 && tr.is_finite() && pr > 0.0 && pr.is_finite() && omega.is_finite()) {
return Err(RefineryError::InvalidInput(format!(
"Lee-Kesler needs Tr > 0, Pr > 0 and a finite ω, got Tr = {tr}, Pr = {pr}, ω = {omega}"
)));
}
Ok(())
}
pub fn lee_kesler_reduced(
tr: f64,
pr: f64,
omega: f64,
phase: PhaseId,
) -> Result<LkDeparture, RefineryError> {
check_reduced(tr, pr, omega)?;
let v0 = solve_vr(&SIMPLE, tr, pr, phase)?;
let d0 = departure(&SIMPLE, tr, v0);
if omega == 0.0 {
return Ok(d0);
}
let vr = solve_vr(&REFERENCE, tr, pr, phase)?;
let dr = departure(&REFERENCE, tr, vr);
let w = omega / OMEGA_REFERENCE;
Ok(LkDeparture {
z: d0.z + w * (dr.z - d0.z),
h_dep_rt: d0.h_dep_rt + w * (dr.h_dep_rt - d0.h_dep_rt),
s_dep_r: d0.s_dep_r + w * (dr.s_dep_r - d0.s_dep_r),
ln_phi: d0.ln_phi + w * (dr.ln_phi - d0.ln_phi),
})
}
pub fn lee_kesler_departure(
comp: &Component,
t: f64,
p: f64,
phase: PhaseId,
) -> Result<LkDeparture, RefineryError> {
if !(comp.tc > 0.0 && comp.pc > 0.0) {
return Err(RefineryError::InvalidInput(format!(
"component '{}' needs Tc > 0 and Pc > 0 (got {}, {})",
comp.name, comp.tc, comp.pc
)));
}
lee_kesler_reduced(t / comp.tc, p / comp.pc, comp.omega, phase)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LkPseudoCritical {
pub tc: f64,
pub pc: f64,
pub omega: f64,
}
pub fn lee_kesler_pseudocritical(
components: &[Component],
x: &[f64],
eta: f64,
) -> Result<LkPseudoCritical, RefineryError> {
let n = components.len();
if n == 0 || x.len() != n {
return Err(RefineryError::InvalidInput(format!(
"components={n}, x={}",
x.len()
)));
}
let sum: f64 = x.iter().sum();
if !(sum > 0.0 && sum.is_finite()) || x.iter().any(|&xi| xi < 0.0 || !xi.is_finite()) {
return Err(RefineryError::InvalidInput(format!(
"mole fractions must be non-negative and sum to a positive number (sum = {sum})"
)));
}
let mut cbrt_vc: smallvec::SmallVec<[f64; 16]> = smallvec::SmallVec::with_capacity(n);
let mut sqrt_tc: smallvec::SmallVec<[f64; 16]> = smallvec::SmallVec::with_capacity(n);
let mut omega_m = 0.0;
for (c, &xi) in components.iter().zip(x) {
if !(c.tc > 0.0 && c.pc > 0.0) {
return Err(RefineryError::InvalidInput(format!(
"component '{}' needs Tc > 0 and Pc > 0 (got {}, {})",
c.name, c.tc, c.pc
)));
}
let zc = 0.2905 - 0.085 * c.omega;
let vc = zc * R_GAS * c.tc / c.pc; cbrt_vc.push(vc.cbrt());
sqrt_tc.push(c.tc.sqrt());
omega_m += xi * c.omega;
}
omega_m /= sum;
let mut vcm = 0.0;
let mut tnum = 0.0;
let one = eta == 1.0;
let quarter = eta == 0.25;
for i in 0..n {
let xi = x[i] / sum;
if xi == 0.0 {
continue;
}
for j in i..n {
let xj = x[j] / sum;
if xj == 0.0 {
continue;
}
let s = cbrt_vc[i] + cbrt_vc[j];
let vcij = s * s * s * 0.125;
let tcij = sqrt_tc[i] * sqrt_tc[j];
let w = if i == j { xi * xj } else { 2.0 * xi * xj };
vcm += w * vcij;
let vc_eta = if one {
vcij
} else if quarter {
vcij.sqrt().sqrt()
} else {
vcij.powf(eta)
};
tnum += w * vc_eta * tcij;
}
}
let tcm = tnum / (if one { vcm } else { vcm.powf(eta) });
let zcm = 0.2905 - 0.085 * omega_m;
let pcm = zcm * R_GAS * tcm / vcm;
Ok(LkPseudoCritical {
tc: tcm,
pc: pcm,
omega: omega_m,
})
}
pub fn lee_kesler_departure_mix(
components: &[Component],
x: &[f64],
t: f64,
p: f64,
phase: PhaseId,
eta: f64,
) -> Result<LkDeparture, RefineryError> {
let pc = lee_kesler_pseudocritical(components, x, eta)?;
lee_kesler_reduced(t / pc.tc, p / pc.pc, pc.omega, phase)
}
#[cfg(test)]
mod tests {
use super::*;
fn methane() -> Component {
Component {
name: "methane".into(),
tc: 190.564,
pc: 4599.0,
omega: 0.0115,
..Component::default()
}
}
fn n_octane() -> Component {
Component {
name: "n-octane".into(),
tc: 568.7,
pc: 2490.0,
omega: 0.3978,
..Component::default()
}
}
fn n_decane() -> Component {
Component {
name: "n-decane".into(),
tc: 617.7,
pc: 2110.0,
omega: 0.492,
..Component::default()
}
}
#[test]
fn low_pressure_limit_is_the_ideal_gas() {
for k in [&SIMPLE, &REFERENCE] {
for tr in [0.7, 1.0, 1.5, 3.0] {
let vr = solve_vr(k, tr, 1e-6, PhaseId::Vapor).unwrap();
let d = departure(k, tr, vr);
assert!((d.z - 1.0).abs() < 1e-5, "Tr={tr}: Z={}", d.z);
assert!(d.h_dep_rt.abs() < 1e-4, "Tr={tr}: H dep {}", d.h_dep_rt);
assert!(d.s_dep_r.abs() < 1e-4, "Tr={tr}: S dep {}", d.s_dep_r);
assert!(d.ln_phi.abs() < 1e-4, "Tr={tr}: ln φ {}", d.ln_phi);
}
}
}
#[test]
fn second_virial_coefficient_matches_pitzer_within_a_few_percent() {
let b = |k: &Bwr, tr: f64| k.b1 - k.b2 / tr - k.b3 / tr.powi(2) - k.b4 / tr.powi(3);
let b0 = |tr: f64| 0.083 - 0.422 / tr.powf(1.6);
let b1 = |tr: f64| 0.139 - 0.172 / tr.powf(4.2);
for tr in [0.8, 1.0, 1.5, 2.0] {
let simple = b(&SIMPLE, tr);
let reference = b(&REFERENCE, tr);
let want0 = b0(tr);
let want_r = b0(tr) + OMEGA_REFERENCE * b1(tr);
assert!(
(simple - want0).abs() < 0.03,
"Tr={tr}: B⁰ {simple} vs {want0}"
);
assert!(
(reference - want_r).abs() < 0.04,
"Tr={tr}: Bʳ {reference} vs {want_r}"
);
}
}
#[test]
fn enthalpy_departure_is_the_temperature_derivative_of_the_fugacity() {
for k in [&SIMPLE, &REFERENCE] {
for (tr, pr, phase) in [
(0.7, 0.05, PhaseId::Vapor),
(0.7, 0.05, PhaseId::Liquid),
(0.9, 0.5, PhaseId::Vapor),
(0.9, 0.5, PhaseId::Liquid),
(1.2, 1.5, PhaseId::Vapor),
(2.0, 5.0, PhaseId::Vapor),
] {
let at = |t: f64| departure(k, t, solve_vr(k, t, pr, phase).unwrap());
let d = at(tr);
let h = 1e-5;
let dlnphi = (at(tr + h).ln_phi - at(tr - h).ln_phi) / (2.0 * h);
let want_h_rtc = -tr * tr * dlnphi;
let got_h_rtc = d.h_dep_rt * tr;
assert!(
(got_h_rtc - want_h_rtc).abs() < 2e-5,
"Tr={tr} Pr={pr} {phase:?}: H/RTc {got_h_rtc} vs −Tr²∂lnφ/∂Tr {want_h_rtc}"
);
let want_s = d.h_dep_rt - d.ln_phi;
assert!(
(d.s_dep_r - want_s).abs() < 1e-10,
"Tr={tr} Pr={pr} {phase:?}: S/R {} vs H/RT − lnφ {want_s}",
d.s_dep_r
);
}
}
}
#[test]
fn root_selection_gives_a_dense_liquid_and_a_light_vapor_below_tc() {
for k in [&SIMPLE, &REFERENCE] {
let (tr, pr) = (0.8, 0.2);
let vl = solve_vr(k, tr, pr, PhaseId::Liquid).unwrap();
let vv = solve_vr(k, tr, pr, PhaseId::Vapor).unwrap();
assert!(vl < 0.5, "liquid Vr {vl}");
assert!(vv > 2.0, "vapor Vr {vv}");
let zl = departure(k, tr, vl).z;
let zv = departure(k, tr, vv).z;
assert!(zl < 0.15 && zv > 0.8, "Zl={zl} Zv={zv}");
for v in [vl, vv] {
assert!((pr * v / tr - z_and_dz(k, tr, v).0).abs() < 1e-10);
}
}
}
#[test]
fn above_the_critical_point_both_phases_return_the_same_root() {
let a = lee_kesler_reduced(1.5, 2.0, 0.3, PhaseId::Liquid).unwrap();
let b = lee_kesler_reduced(1.5, 2.0, 0.3, PhaseId::Vapor).unwrap();
assert!((a.z - b.z).abs() < 1e-10);
assert!((a.h_dep_rt - b.h_dep_rt).abs() < 1e-10);
}
#[test]
fn analytic_dz_dvr_matches_finite_differences() {
for k in [&SIMPLE, &REFERENCE] {
for tr in [0.6, 1.0, 2.5] {
for vr in [0.05, 0.3, 1.0, 10.0] {
let (_, dz) = z_and_dz(k, tr, vr);
let h = 1e-6 * vr;
let fd = (z_and_dz(k, tr, vr + h).0 - z_and_dz(k, tr, vr - h).0) / (2.0 * h);
assert!(
(dz - fd).abs() < 1e-6 * (1.0 + fd.abs()),
"Tr={tr} Vr={vr}: {dz} vs {fd}"
);
}
}
}
}
#[test]
fn agrees_with_peng_robinson_for_a_near_simple_fluid_vapor() {
use crate::eos::{CubicEos, PhaseId};
use crate::mixing::MixingRule;
use crate::mixture::MixtureSpec;
let comps = [methane()];
let spec = MixtureSpec {
eos: CubicEos::PR1976,
rule: MixingRule::Classical,
components: &comps,
kij: &[],
ge: None,
};
for (t, p, tol) in [
(300.0, 300.0, 0.20),
(250.0, 2000.0, 0.20),
(400.0, 10_000.0, 0.20),
] {
let lk = lee_kesler_departure(&comps[0], t, p, PhaseId::Vapor).unwrap();
let pr_h =
crate::energy::h_departure_rt_mix(&spec, t, p, &[1.0], PhaseId::Vapor).unwrap();
let pr_z = crate::mixture::z_mix(&spec, t, p, &[1.0], PhaseId::Vapor).unwrap();
assert!(
(lk.h_dep_rt - pr_h).abs() < tol * pr_h.abs().max(0.02),
"T={t} P={p}: LK H/RT {} vs PR {pr_h}",
lk.h_dep_rt
);
assert!(
(lk.z - pr_z).abs() < 0.02,
"T={t} P={p}: LK Z {} vs PR {pr_z}",
lk.z
);
}
}
#[test]
fn heavy_liquid_enthalpy_departure_is_large_and_negative() {
let d = lee_kesler_departure(&n_decane(), 400.0, 500.0, PhaseId::Liquid).unwrap();
assert!(
d.h_dep_rt < -10.0 && d.h_dep_rt > -16.0,
"H/RT = {}",
d.h_dep_rt
);
assert!(d.z < 0.1, "Z = {}", d.z);
}
#[test]
fn reference_fluid_reproduces_itself() {
let d = lee_kesler_reduced(0.8, 0.3, OMEGA_REFERENCE, PhaseId::Liquid).unwrap();
let vr = solve_vr(&REFERENCE, 0.8, 0.3, PhaseId::Liquid).unwrap();
let r = departure(&REFERENCE, 0.8, vr);
assert!((d.h_dep_rt - r.h_dep_rt).abs() < 1e-12);
}
#[test]
fn pseudocritical_of_a_pure_component_is_the_component() {
let c = n_octane();
let pc = lee_kesler_pseudocritical(std::slice::from_ref(&c), &[1.0], 1.0).unwrap();
assert!((pc.tc - c.tc).abs() < 1e-9);
assert!((pc.pc - c.pc).abs() < 1e-6, "{} vs {}", pc.pc, c.pc);
assert!((pc.omega - c.omega).abs() < 1e-12);
}
#[test]
fn pseudocritical_lies_between_the_pure_components_and_is_symmetric() {
let comps = [methane(), n_decane()];
for eta in [1.0, 0.25] {
let a = lee_kesler_pseudocritical(&comps, &[0.3, 0.7], eta).unwrap();
let b = lee_kesler_pseudocritical(&[n_decane(), methane()], &[0.7, 0.3], eta).unwrap();
assert!((a.tc - b.tc).abs() < 1e-9 && (a.pc - b.pc).abs() < 1e-9);
assert!(a.tc > comps[0].tc && a.tc < comps[1].tc, "Tc,m = {}", a.tc);
assert!(a.pc > comps[1].pc && a.pc < comps[0].pc, "Pc,m = {}", a.pc);
}
let c = lee_kesler_pseudocritical(&comps, &[3.0, 7.0], 1.0).unwrap();
let d = lee_kesler_pseudocritical(&comps, &[0.3, 0.7], 1.0).unwrap();
assert!((c.tc - d.tc).abs() < 1e-9);
}
#[test]
fn mixture_departure_runs_and_is_bounded_by_the_pure_ends() {
let comps = [n_octane(), n_decane()];
let m = lee_kesler_departure_mix(&comps, &[0.5, 0.5], 450.0, 300.0, PhaseId::Liquid, 0.25)
.unwrap();
let a = lee_kesler_departure(&comps[0], 450.0, 300.0, PhaseId::Liquid).unwrap();
let b = lee_kesler_departure(&comps[1], 450.0, 300.0, PhaseId::Liquid).unwrap();
let (lo, hi) = (a.h_dep_rt.min(b.h_dep_rt), a.h_dep_rt.max(b.h_dep_rt));
assert!(
m.h_dep_rt > lo - 0.5 && m.h_dep_rt < hi + 0.5,
"{} not in [{lo}, {hi}]",
m.h_dep_rt
);
}
#[test]
fn scales_to_hundreds_of_pseudocomponents() {
let comps: Vec<Component> = (0..300)
.map(|i| Component {
name: format!("PC-{i}"),
tc: 400.0 + i as f64,
pc: 3000.0 - 5.0 * i as f64,
omega: 0.2 + 0.002 * i as f64,
..Component::default()
})
.collect();
let x = vec![1.0 / 300.0; 300];
let d = lee_kesler_departure_mix(&comps, &x, 600.0, 200.0, PhaseId::Vapor, 0.25).unwrap();
assert!(d.h_dep_rt.is_finite() && d.z > 0.0);
}
#[test]
fn rejects_bad_inputs() {
assert!(lee_kesler_reduced(0.0, 1.0, 0.2, PhaseId::Vapor).is_err());
assert!(lee_kesler_reduced(1.0, -1.0, 0.2, PhaseId::Vapor).is_err());
assert!(lee_kesler_reduced(1.0, 1.0, f64::NAN, PhaseId::Vapor).is_err());
assert!(lee_kesler_pseudocritical(&[], &[], 1.0).is_err());
assert!(lee_kesler_pseudocritical(&[methane()], &[1.0, 0.0], 1.0).is_err());
assert!(lee_kesler_pseudocritical(&[methane()], &[-1.0], 1.0).is_err());
assert!(lee_kesler_departure(&Component::default(), 300.0, 100.0, PhaseId::Vapor).is_err());
}
}