#![allow(clippy::needless_range_loop)]
use crate::activity::{ActivityModel, excess_enthalpy, excess_entropy};
use crate::eos::{EosState, PhaseId};
use crate::mixing::MixingRule;
use crate::mixture::{MixError, MixtureSpec, ln_phi_mix, z_mix};
use crate::types::{Component, R_GAS};
pub fn ideal_cp(comp: &Component, t: f64) -> f64 {
let c = &comp.cp_coeffs;
R_GAS * (c[0] + t * (c[1] + t * (c[2] + t * (c[3] + t * c[4]))))
}
pub fn ideal_enthalpy_integral(comp: &Component, t: f64, t_ref: f64) -> f64 {
let c = &comp.cp_coeffs;
let mut acc = 0.0;
for (k, &ak) in c.iter().enumerate() {
let p = (k + 1) as i32;
acc += ak * (t.powi(p) - t_ref.powi(p)) / p as f64;
}
R_GAS * acc
}
pub fn ideal_entropy_integral(comp: &Component, t: f64, t_ref: f64) -> f64 {
let c = &comp.cp_coeffs;
let mut acc = c[0] * (t / t_ref).ln();
for (k, &ak) in c.iter().enumerate().skip(1) {
let p = k as i32;
acc += ak * (t.powi(p) - t_ref.powi(p)) / p as f64;
}
R_GAS * acc
}
pub fn ideal_mixing_entropy(x: &[f64]) -> f64 {
let s: f64 = x
.iter()
.filter(|&&xi| xi > 1e-300)
.map(|&xi| xi * xi.ln())
.sum();
-R_GAS * s
}
pub fn ideal_enthalpy_mix(
comps: &[Component],
x: &[f64],
t: f64,
t_ref: f64,
h_ref: &[f64],
) -> f64 {
(0..comps.len())
.map(|i| {
let h0 = h_ref.get(i).copied().unwrap_or(0.0);
x[i] * (h0 + ideal_enthalpy_integral(&comps[i], t, t_ref))
})
.sum()
}
pub fn ideal_entropy_mix(
comps: &[Component],
x: &[f64],
t: f64,
p: f64,
t_ref: f64,
p_ref: f64,
s_ref: &[f64],
) -> f64 {
let pressure_term = R_GAS * (p / p_ref).ln();
let sum: f64 = (0..comps.len())
.map(|i| {
let s0 = s_ref.get(i).copied().unwrap_or(0.0);
x[i] * (s0 + ideal_entropy_integral(&comps[i], t, t_ref) - pressure_term)
})
.sum();
sum + ideal_mixing_entropy(x)
}
pub fn h_departure_rt_mix(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
phase: PhaseId,
) -> Result<f64, MixError> {
let (a_mix, itilde, z) = mix_attractive_pieces(spec, t, p, x, phase)?;
let (t_dln_a, t_dln_b) = t_dln_ab_dt_mix(spec, t, p, x)?;
let attractive = a_mix * itilde;
let delta = t_dln_b + 1.0;
Ok((z - 1.0) + attractive * (t_dln_a + 1.0) - delta * ((z - 1.0) + attractive))
}
pub fn s_departure_r_mix(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
phase: PhaseId,
) -> Result<f64, MixError> {
let h_rt = h_departure_rt_mix(spec, t, p, x, phase)?;
let g_rt: f64 = ln_phi_mix(spec, t, p, x, phase)?
.iter()
.zip(x)
.map(|(lnphi, xi)| xi * lnphi)
.sum();
Ok(h_rt - g_rt)
}
fn mix_attractive_pieces(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
phase: PhaseId,
) -> Result<(f64, f64, f64), MixError> {
let pars = crate::mixture::mixture_params::<f64>(spec, t, p, x)?;
let z = z_mix(spec, t, p, x, phase)?;
let (a, u, w) = (pars.big_a, pars.u, pars.w);
let g = crate::eos::attractive_term_uw(z, a, u, w);
let itilde = if a.abs() < 1e-300 { 0.0 } else { g / a };
Ok((a, itilde, z))
}
pub fn t_dln_a_dt_mix(spec: &MixtureSpec, t: f64, p: f64, x: &[f64]) -> Result<f64, MixError> {
Ok(t_dln_ab_dt_mix(spec, t, p, x)?.0)
}
fn t_dln_ab_dt_mix(spec: &MixtureSpec, t: f64, p: f64, x: &[f64]) -> Result<(f64, f64), MixError> {
let n = x.len();
let mut ai = vec![0.0; n];
let mut bi = vec![0.0; n];
let mut t_dln_ai = vec![0.0; n]; for i in 0..n {
let comp = &spec.components[i];
let st = EosState::new(spec.eos, t, p, comp);
ai[i] = st.big_a;
bi[i] = st.big_b;
let alpha_prime_over_alpha = st.tr * st.d_alpha_d_tr / st.alpha;
t_dln_ai[i] = alpha_prime_over_alpha - 2.0;
}
let kij_at = |i: usize, j: usize| {
if spec.kij.is_empty() {
0.0
} else {
spec.kij[i][j]
}
};
let (a_mix, t_da_mix, t_dln_b): (f64, f64, f64) = match spec.rule {
MixingRule::Classical | MixingRule::IVDW | MixingRule::IIVDW => {
let iivdw = spec.rule == MixingRule::IIVDW;
let mut a_mix = 0.0;
let mut t_da = 0.0;
for i in 0..n {
for j in 0..n {
let km = if iivdw {
x[i] * kij_at(i, j) + x[j] * kij_at(j, i)
} else {
kij_at(i, j)
};
let aij = (1.0 - km) * (ai[i] * ai[j]).sqrt();
let xx = x[i] * x[j];
a_mix += xx * aij;
t_da += xx * aij * 0.5 * (t_dln_ai[i] + t_dln_ai[j]);
}
}
(a_mix, t_da, -1.0)
}
MixingRule::HuronVidalOriginal
| MixingRule::HuronVidalSimplified
| MixingRule::MHV1
| MixingRule::MHV2 => {
let ge = spec
.ge
.ok_or_else(|| MixError::Unsupported("GE rule requires GeSpec".into()))?;
let b: f64 = (0..n).map(|i| x[i] * bi[i]).sum();
let t_db = -b; let mut alpha_sum = 0.0;
let mut t_dalpha_sum = 0.0;
for i in 0..n {
let alpha_i = ai[i] / bi[i];
alpha_sum += x[i] * alpha_i;
t_dalpha_sum += x[i] * alpha_i * (t_dln_ai[i] + 1.0);
}
let g_rt = excess_gibbs_rt(ge.model, x, ge.aij, ge.alpha, ge.vl, ge.delta, t);
let he = consistent_excess_enthalpy(ge.model, x, ge.aij, ge.alpha, ge.vl, ge.delta, t);
let t_dg_rt = -he / (R_GAS * t);
match spec.rule {
MixingRule::HuronVidalOriginal => {
let c = crate::mixture::hv_c_constant(spec.eos);
let alpha_mix = alpha_sum + g_rt / c;
let t_dalpha_mix = t_dalpha_sum + t_dg_rt / c;
let a_mix = b * alpha_mix;
(a_mix, t_db * alpha_mix + b * t_dalpha_mix, -1.0)
}
MixingRule::HuronVidalSimplified | MixingRule::MHV1 => {
let c = if spec.rule == MixingRule::MHV1 {
-0.593
} else {
crate::mixture::hv_c_constant(spec.eos)
};
let blog: f64 = (0..n).map(|i| x[i] * (b / bi[i]).ln()).sum();
let alpha_mix = alpha_sum + (g_rt + blog) / c;
let t_dalpha_mix = t_dalpha_sum + t_dg_rt / c;
let a_mix = b * alpha_mix;
(a_mix, t_db * alpha_mix + b * t_dalpha_mix, -1.0)
}
MixingRule::MHV2 => {
let (q1, q2) = (-0.478, -0.0047);
let blog: f64 = (0..n).map(|i| x[i] * (b / bi[i]).ln()).sum();
let mut rhs = g_rt + blog;
for i in 0..n {
let alpha_i = ai[i] / bi[i];
rhs += x[i] * (q1 * alpha_i + q2 * alpha_i * alpha_i);
}
let disc = (q1 * q1 + 4.0 * q2 * rhs).sqrt();
let r1 = (-q1 + disc) / (2.0 * q2);
let r2 = (-q1 - disc) / (2.0 * q2);
let alpha_mix = if r1 >= r2 { r1 } else { r2 };
let mut t_drhs = t_dg_rt;
for i in 0..n {
let alpha_i = ai[i] / bi[i];
let t_dalpha_i = alpha_i * (t_dln_ai[i] + 1.0);
t_drhs += x[i] * (q1 + 2.0 * q2 * alpha_i) * t_dalpha_i;
}
let t_dalpha_mix = t_drhs / (q1 + 2.0 * q2 * alpha_mix);
let a_mix = b * alpha_mix;
(a_mix, t_db * alpha_mix + b * t_dalpha_mix, -1.0)
}
_ => unreachable!(),
}
}
MixingRule::WongSandler => {
let ge = spec
.ge
.ok_or_else(|| MixError::Unsupported("WS requires GeSpec".into()))?;
let c_star = crate::mixture::hv_c_constant(spec.eos);
let mut q = 0.0;
let mut t_dq = 0.0;
for i in 0..n {
for j in 0..n {
let bij = 0.5 * ((bi[i] - ai[i]) + (bi[j] - ai[j])) * (1.0 - kij_at(i, j));
let t_dbij = 0.5
* ((-bi[i] - ai[i] * t_dln_ai[i]) + (-bi[j] - ai[j] * t_dln_ai[j]))
* (1.0 - kij_at(i, j));
let xx = x[i] * x[j];
q += xx * bij;
t_dq += xx * t_dbij;
}
}
let mut d = 0.0;
let mut t_dd = 0.0;
for i in 0..n {
let alpha_i = ai[i] / bi[i];
d += x[i] * alpha_i;
t_dd += x[i] * alpha_i * (t_dln_ai[i] + 1.0);
}
let g_rt = excess_gibbs_rt(ge.model, x, ge.aij, ge.alpha, ge.vl, ge.delta, t);
let he = consistent_excess_enthalpy(ge.model, x, ge.aij, ge.alpha, ge.vl, ge.delta, t);
d += g_rt / c_star;
t_dd += (-he / (R_GAS * t)) / c_star;
let one_minus_d = 1.0 - d;
let b = q / one_minus_d;
let t_db = (t_dq * one_minus_d + q * t_dd) / (one_minus_d * one_minus_d);
let a_mix = b * d;
let t_dln_b = if b.abs() < 1e-300 { -1.0 } else { t_db / b };
(a_mix, t_db * d + b * t_dd, t_dln_b)
}
MixingRule::PatelTejaC | MixingRule::PatelTejaUSBC | MixingRule::SchmidtWenzelC => {
return Err(MixError::Unsupported(
"C-parameter rule is implied by the 3-parameter EOS; pass Classical".into(),
));
}
};
if a_mix.abs() < 1e-300 {
return Ok((0.0, t_dln_b));
}
Ok((t_da_mix / a_mix, t_dln_b))
}
fn excess_gibbs_rt(
model: ActivityModel,
x: &[f64],
aij: &[Vec<f64>],
alpha: &[Vec<f64>],
vl: &[f64],
delta: &[f64],
t: f64,
) -> f64 {
crate::activity::excess_gibbs(model, x, aij, alpha, vl, delta, t) / (R_GAS * t)
}
fn consistent_excess_enthalpy(
model: ActivityModel,
x: &[f64],
aij: &[Vec<f64>],
alpha: &[Vec<f64>],
vl: &[f64],
delta: &[f64],
t: f64,
) -> f64 {
match model {
ActivityModel::Margules | ActivityModel::VanLaar => 0.0,
_ => excess_enthalpy(model, x, aij, alpha, vl, delta, t),
}
}
#[allow(clippy::too_many_arguments)]
pub fn phase_enthalpy_entropy(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
phase: PhaseId,
t_ref: f64,
p_ref: f64,
h_ref: &[f64],
s_ref: &[f64],
) -> Result<(f64, f64), MixError> {
let h_ideal = ideal_enthalpy_mix(spec.components, x, t, t_ref, h_ref);
let s_ideal = ideal_entropy_mix(spec.components, x, t, p, t_ref, p_ref, s_ref);
let h_res = h_departure_rt_mix(spec, t, p, x, phase)? * R_GAS * t;
let s_res = s_departure_r_mix(spec, t, p, x, phase)? * R_GAS;
Ok((h_ideal + h_res, s_ideal + s_res))
}
#[allow(clippy::too_many_arguments)]
pub fn partial_molar_enthalpy(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
phase: PhaseId,
t_ref: f64,
h_ref: &[f64],
) -> Result<Vec<f64>, MixError> {
let d_ln_phi_dt = crate::mixture::d_ln_phi_d_t(spec, t, p, x, phase)?;
let rt2 = R_GAS * t * t;
Ok((0..x.len())
.map(|i| {
let h0 = h_ref.get(i).copied().unwrap_or(0.0);
h0 + ideal_enthalpy_integral(&spec.components[i], t, t_ref) - rt2 * d_ln_phi_dt[i]
})
.collect())
}
pub fn phase_cp(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
phase: PhaseId,
) -> Result<f64, MixError> {
let cp_ideal: f64 = (0..x.len())
.map(|i| x[i] * ideal_cp(&spec.components[i], t))
.sum();
let cp_res = crate::mixture::residual_cp(spec, t, p, x, phase)?;
Ok(cp_ideal + cp_res)
}
pub fn excess_h_s(
model: ActivityModel,
x: &[f64],
aij: &[Vec<f64>],
alpha: &[Vec<f64>],
vl: &[f64],
delta: &[f64],
t: f64,
) -> (f64, f64) {
(
excess_enthalpy(model, x, aij, alpha, vl, delta, t),
excess_entropy(model, x, aij, alpha, vl, delta, t),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::eos::CubicEos;
use crate::mixture::GeSpec;
fn methane() -> Component {
Component {
name: "methane".into(),
tc: 190.564,
pc: 4599.0,
omega: 0.0115,
cp_coeffs: [4.5, 1.5e-3, 0.0, 0.0, 0.0],
..Component::default()
}
}
fn n_pentane() -> Component {
Component {
name: "n-pentane".into(),
tc: 469.7,
pc: 3370.0,
omega: 0.252,
cp_coeffs: [7.0, 5.0e-3, -1.0e-6, 0.0, 0.0],
..Component::default()
}
}
fn methanol() -> Component {
Component {
name: "methanol".into(),
tc: 512.6,
pc: 8097.0,
omega: 0.564,
liquid_volume: 40.7,
cp_coeffs: [4.9, 1.2e-2, -3.0e-6, 0.0, 0.0],
..Component::default()
}
}
fn water() -> Component {
Component {
name: "water".into(),
tc: 647.1,
pc: 22064.0,
omega: 0.344,
liquid_volume: 18.07,
cp_coeffs: [4.0, 1.0e-3, 0.0, 0.0, 0.0],
..Component::default()
}
}
fn kij2(k: f64) -> Vec<Vec<f64>> {
vec![vec![0.0, k], vec![k, 0.0]]
}
#[test]
fn ideal_cp_and_integral_consistent() {
let c = n_pentane();
let t = 400.0;
let h = 1e-3;
let d_int = (ideal_enthalpy_integral(&c, t + h, 298.15)
- ideal_enthalpy_integral(&c, t - h, 298.15))
/ (2.0 * h);
assert!(
(d_int - ideal_cp(&c, t)).abs() < 1e-4,
"{d_int} vs {}",
ideal_cp(&c, t)
);
}
#[test]
fn ideal_entropy_integral_is_cp_over_t() {
let c = methanol();
let t = 350.0;
let h = 1e-3;
let d_int = (ideal_entropy_integral(&c, t + h, 298.15)
- ideal_entropy_integral(&c, t - h, 298.15))
/ (2.0 * h);
assert!((d_int - ideal_cp(&c, t) / t).abs() < 1e-6);
}
#[test]
fn ideal_mixing_entropy_binary() {
let s = ideal_mixing_entropy(&[0.5, 0.5]);
assert!((s - R_GAS * std::f64::consts::LN_2).abs() < 1e-10);
assert!(ideal_mixing_entropy(&[1.0, 0.0]).abs() < 1e-12);
}
fn t_dln_a_fd(spec: &MixtureSpec, t: f64, p: f64, x: &[f64]) -> f64 {
let h = t * 1e-6;
let a = |tt: f64| {
crate::mixture::mixture_params::<f64>(spec, tt, p, x)
.unwrap()
.big_a
};
let (ap, am) = (a(t + h), a(t - h));
t * (ap.ln() - am.ln()) / (2.0 * h)
}
fn t_dln_b_fd(spec: &MixtureSpec, t: f64, p: f64, x: &[f64]) -> f64 {
let h = t * 1e-6;
let b = |tt: f64| {
crate::mixture::mixture_params::<f64>(spec, tt, p, x)
.unwrap()
.big_b
};
let (bp, bm) = (b(t + h), b(t - h));
t * (bp.ln() - bm.ln()) / (2.0 * h)
}
#[test]
fn analytic_t_derivative_matches_oracle_classical_and_3param() {
let comps = vec![methane(), n_pentane()];
let kij = kij2(0.023);
let x = [0.4, 0.6];
for eos in [
CubicEos::PR1976,
CubicEos::RKS1972,
CubicEos::SchmidtWenzel,
CubicEos::PatelTeja,
] {
for rule in [MixingRule::Classical, MixingRule::IVDW, MixingRule::IIVDW] {
if eos.is_three_parameter() && rule == MixingRule::IIVDW {
continue; }
let spec = MixtureSpec {
eos,
rule,
components: &comps,
kij: &kij,
ge: None,
};
let analytic = t_dln_a_dt_mix(&spec, 350.0, 2000.0, &x).unwrap();
let oracle = t_dln_a_fd(&spec, 350.0, 2000.0, &x);
assert!(
(analytic - oracle).abs() < 1e-6 * oracle.abs().max(1.0),
"{eos:?}/{rule:?}: analytic {analytic} vs oracle {oracle}"
);
}
}
}
#[test]
fn analytic_t_derivative_matches_oracle_ge_rules() {
let comps = vec![methanol(), water()];
let aij = vec![vec![0.0, 0.847], vec![0.522, 0.0]];
let vl = [40.7, 18.07];
let ge = GeSpec {
model: ActivityModel::VanLaar,
aij: &aij,
alpha: &[],
vl: &vl,
delta: &[],
};
let x = [0.4, 0.6];
for rule in [
MixingRule::HuronVidalOriginal,
MixingRule::HuronVidalSimplified,
MixingRule::MHV1,
MixingRule::MHV2,
MixingRule::WongSandler,
] {
let spec = MixtureSpec {
eos: CubicEos::PR1976,
rule,
components: &comps,
kij: &kij2(0.05),
ge: Some(ge),
};
let (analytic, analytic_b) = t_dln_ab_dt_mix(&spec, 400.0, 800.0, &x).unwrap();
let oracle = t_dln_a_fd(&spec, 400.0, 800.0, &x);
assert!(
(analytic - oracle).abs() < 1e-5 * oracle.abs().max(1.0),
"{rule:?}: analytic {analytic} vs oracle {oracle}"
);
let oracle_b = t_dln_b_fd(&spec, 400.0, 800.0, &x);
assert!(
(analytic_b - oracle_b).abs() < 1e-5,
"{rule:?}: analytic T dlnB {analytic_b} vs oracle {oracle_b}"
);
if rule == MixingRule::WongSandler {
assert!(
(analytic_b + 1.0).abs() > 1e-4,
"WS T dlnB should differ from −1 (b_mix depends on T), got {analytic_b}"
);
} else {
assert_eq!(analytic_b, -1.0, "{rule:?} has T-independent b_mix");
}
}
}
#[test]
fn departure_reduces_to_pure() {
let comps = [n_pentane()];
for eos in [CubicEos::PR1976, CubicEos::RKS1972, CubicEos::PatelTeja] {
let spec = MixtureSpec {
eos,
rule: MixingRule::Classical,
components: &comps,
kij: &[],
ge: None,
};
let h = h_departure_rt_mix(&spec, 400.0, 2000.0, &[1.0], PhaseId::Vapor).unwrap();
let s = s_departure_r_mix(&spec, 400.0, 2000.0, &[1.0], PhaseId::Vapor).unwrap();
let hp =
crate::eos::h_departure_rt(eos, 400.0, 2000.0, &comps[0], PhaseId::Vapor).unwrap();
let sp =
crate::eos::s_departure_r(eos, 400.0, 2000.0, &comps[0], PhaseId::Vapor).unwrap();
assert!((h - hp).abs() < 1e-9, "{eos:?} H^R: {h} vs {hp}");
assert!((s - sp).abs() < 1e-9, "{eos:?} S^R: {s} vs {sp}");
}
}
#[test]
fn departure_lewis_randall_consistency() {
let comps = vec![methane(), n_pentane()];
let spec = MixtureSpec {
eos: CubicEos::PR1976,
rule: MixingRule::IVDW,
components: &comps,
kij: &kij2(0.023),
ge: None,
};
let x = [0.4, 0.6];
let h = h_departure_rt_mix(&spec, 350.0, 2000.0, &x, PhaseId::Vapor).unwrap();
let s = s_departure_r_mix(&spec, 350.0, 2000.0, &x, PhaseId::Vapor).unwrap();
let g: f64 = ln_phi_mix(&spec, 350.0, 2000.0, &x, PhaseId::Vapor)
.unwrap()
.iter()
.zip(&x)
.map(|(l, xi)| xi * l)
.sum();
assert!((s - (h - g)).abs() < 1e-12);
}
#[test]
fn departure_h_via_direct_energy_oracle() {
let comps = vec![methane(), n_pentane()];
let spec = MixtureSpec {
eos: CubicEos::PR1976,
rule: MixingRule::IVDW,
components: &comps,
kij: &kij2(0.023),
ge: None,
};
let x = [0.35, 0.65];
let (p, phase) = (2000.0, PhaseId::Vapor);
let g_rt = |t: f64| -> f64 {
ln_phi_mix(&spec, t, p, &x, phase)
.unwrap()
.iter()
.zip(&x)
.map(|(l, xi)| xi * l)
.sum()
};
let t = 350.0;
let h = 1e-2;
let dgrt_dt = (g_rt(t + h) - g_rt(t - h)) / (2.0 * h);
let h_rt_oracle = -t * dgrt_dt;
let h_rt = h_departure_rt_mix(&spec, t, p, &x, phase).unwrap();
assert!(
(h_rt - h_rt_oracle).abs() < 1e-5 * h_rt_oracle.abs().max(1.0),
"analytic {h_rt} vs G-derivative oracle {h_rt_oracle}"
);
}
#[test]
fn full_phase_enthalpy_entropy_runs() {
let comps = vec![methane(), n_pentane()];
let spec = MixtureSpec {
eos: CubicEos::PR1976,
rule: MixingRule::IVDW,
components: &comps,
kij: &kij2(0.023),
ge: None,
};
let x = [0.4, 0.6];
let (h, s) = phase_enthalpy_entropy(
&spec,
350.0,
2000.0,
&x,
PhaseId::Vapor,
298.15,
101.325,
&[],
&[],
)
.unwrap();
assert!(h.is_finite() && s.is_finite(), "H={h} S={s}");
}
#[test]
fn excess_h_s_matches_activity_layer() {
let comps = [methanol(), water()];
let _ = &comps;
let aij = vec![vec![0.0, 0.847], vec![0.522, 0.0]];
let vl = [40.7, 18.07];
let x = [0.4, 0.6];
let (he, se) = excess_h_s(ActivityModel::Wilson, &x, &aij, &[], &vl, &[], 340.0);
assert!(
(he - excess_enthalpy(ActivityModel::Wilson, &x, &aij, &[], &vl, &[], 340.0)).abs()
< 1e-12
);
assert!(
(se - excess_entropy(ActivityModel::Wilson, &x, &aij, &[], &vl, &[], 340.0)).abs()
< 1e-12
);
}
#[test]
fn partial_molar_enthalpy_euler_sum_equals_total() {
let comps = vec![methane(), n_pentane()];
let aij = vec![vec![0.0, 0.4], vec![0.4, 0.0]];
let vl = [37.0, 115.0];
let ge = GeSpec {
model: ActivityModel::VanLaar,
aij: &aij,
alpha: &[],
vl: &vl,
delta: &[],
};
let specs = [
MixtureSpec {
eos: CubicEos::PR1976,
rule: MixingRule::Classical,
components: &comps,
kij: &kij2(0.02),
ge: None,
},
MixtureSpec {
eos: CubicEos::PR1976,
rule: MixingRule::MHV1,
components: &comps,
kij: &kij2(0.02),
ge: Some(ge),
},
];
let (t, p, x) = (360.0, 1500.0, [0.4, 0.6]);
for spec in &specs {
for phase in [PhaseId::Vapor, PhaseId::Liquid] {
let Ok(hbar) = partial_molar_enthalpy(spec, t, p, &x, phase, 298.15, &[]) else {
continue;
};
let sum: f64 = (0..2).map(|i| x[i] * hbar[i]).sum();
let (h, _) =
phase_enthalpy_entropy(spec, t, p, &x, phase, 298.15, 101.325, &[], &[])
.unwrap();
assert!(
(sum - h).abs() <= 1e-6 * h.abs().max(1.0),
"{:?} {phase:?}: Σx·H̄={sum} vs H={h}",
spec.rule
);
}
}
}
#[test]
fn phase_cp_matches_fd_of_enthalpy_and_ideal_limit() {
let comps = vec![methane(), n_pentane()];
let spec = MixtureSpec {
eos: CubicEos::PR1976,
rule: MixingRule::Classical,
components: &comps,
kij: &kij2(0.02),
ge: None,
};
let (p, x) = (2000.0, [0.4, 0.6]);
let t = 360.0;
let cp = phase_cp(&spec, t, p, &x, PhaseId::Vapor).unwrap();
let h = 1e-2;
let (h_hi, _) = phase_enthalpy_entropy(
&spec,
t + h,
p,
&x,
PhaseId::Vapor,
298.15,
101.325,
&[],
&[],
)
.unwrap();
let (h_lo, _) = phase_enthalpy_entropy(
&spec,
t - h,
p,
&x,
PhaseId::Vapor,
298.15,
101.325,
&[],
&[],
)
.unwrap();
let cp_fd = (h_hi - h_lo) / (2.0 * h);
assert!(
(cp - cp_fd).abs() <= 1e-4 * cp.abs().max(1.0),
"Cp={cp} vs FD={cp_fd}"
);
let cp_lowp = phase_cp(&spec, t, 1e-3, &x, PhaseId::Vapor).unwrap();
let cp_ideal: f64 = (0..2).map(|i| x[i] * ideal_cp(&comps[i], t)).sum();
assert!(
(cp_lowp - cp_ideal).abs() <= 1e-3 * cp_ideal.abs().max(1.0),
"low-P Cp={cp_lowp} vs ideal={cp_ideal}"
);
}
#[test]
fn gamma_phi_liquid_enthalpy_ideal_minus_condensation() {
use crate::eos::{LiquidModel, VaporModel};
use crate::flash::{SystemSpec, phase_enthalpy_entropy as sys_hs};
let aij = vec![vec![0.0, 0.847], vec![0.522, 0.0]];
let vl = [40.7, 18.07];
let mut a = methanol();
a.psat_coeffs = vec![5.20, 3200.0, -35.0];
let mut b = water();
b.psat_coeffs = vec![5.11, 3800.0, -46.0];
let comps = [a, b];
let spec = SystemSpec {
components: &comps,
vapor: VaporModel::IdealGas,
liquid: LiquidModel::Activity(ActivityModel::VanLaar),
mixing_rule: MixingRule::Classical,
kij: &[],
aij: &aij,
alpha: &[],
vl: &vl,
delta: &[],
sat_models: &[],
ge_model: None,
};
let (t, p, x) = (340.0, 100.0, [0.4, 0.6]);
let (h, _s) = sys_hs(&spec, t, p, &x, PhaseId::Liquid, 298.15, 101.325, &[], &[]).unwrap();
const R: f64 = 8.31451;
let h_ideal = ideal_enthalpy_mix(&comps, &x, t, 298.15, &[]);
let mut h_cond = 0.0;
for i in 0..2 {
let psat_i = crate::saturation::psat(comps[i].sat_model, &comps[i], t).unwrap();
let dpsat = crate::saturation::d_psat_dt(comps[i].sat_model, &comps[i], t).unwrap();
h_cond += x[i] * R * t * t * dpsat / psat_i;
}
let (he, _) = excess_h_s(ActivityModel::VanLaar, &x, &aij, &[], &vl, &[], t);
let expect = h_ideal - h_cond + he;
assert!(
(h - expect).abs() <= 1e-9 * expect.abs().max(1.0),
"γ-φ liquid H={h} vs hand={expect}"
);
assert!(
h < h_ideal,
"liquid H={h} should be below ideal gas {h_ideal}"
);
}
}