#![allow(clippy::needless_range_loop)]
use num_dual::DualNum;
use smallvec::SmallVec;
use thiserror::Error;
use crate::activity::{ActivityModel, ln_gamma_all_generic};
use crate::eos::{ChaoSeaderSpecies, CubicEos, PhaseId, chao_seader_ln_phi};
use crate::mixing::MixingRule;
use crate::numerics::cubic::solve_real;
use crate::types::Component;
type Buf<D> = SmallVec<[D; 8]>;
#[derive(Debug, Error, PartialEq)]
pub enum MixError {
#[error("dimension mismatch: {0}")]
Dimension(String),
#[error("unsupported combination: {0}")]
Unsupported(String),
#[error("cubic solver failed: {0}")]
Cubic(#[from] crate::numerics::cubic::CubicError),
#[error("no real root above B={big_b:.6e} for phase {phase:?}")]
NoRootForPhase { phase: PhaseId, big_b: f64 },
}
#[derive(Debug, Clone, Copy)]
pub struct GeSpec<'a> {
pub model: ActivityModel,
pub aij: &'a [Vec<f64>],
pub alpha: &'a [Vec<f64>],
pub vl: &'a [f64],
pub delta: &'a [f64],
}
#[derive(Debug, Clone, Copy)]
pub struct MixtureSpec<'a> {
pub eos: CubicEos,
pub rule: MixingRule,
pub components: &'a [Component],
pub kij: &'a [Vec<f64>],
pub ge: Option<GeSpec<'a>>,
}
const MHV1_Q1: f64 = -0.593;
const MHV2_Q1: f64 = -0.478;
const MHV2_Q2: f64 = -0.0047;
pub fn hv_c_constant(eos: CubicEos) -> f64 {
let fc = crate::eos::family_constants(eos);
-i_tilde(1.0, fc.k1, fc.k2)
}
#[inline]
fn kij_at(kij: &[Vec<f64>], i: usize, j: usize) -> f64 {
if kij.is_empty() { 0.0 } else { kij[i][j] }
}
fn i_tilde_generic<D: DualNum<f64> + Copy>(z: D, u: D, w: D) -> D {
let disc = u * u - w * 4.0;
let scale = (u.re() * u.re()).max(4.0 * w.re().abs()).max(1e-300);
if disc.re().abs() <= 1e-12 * scale {
(z + u * 0.5).recip()
} else if disc.re() > 0.0 {
let delta = disc.sqrt();
((z * 2.0 + u + delta) / (z * 2.0 + u - delta)).ln() / delta
} else {
let delta = (-disc).sqrt();
(-((z * 2.0 + u) / delta).atan() + std::f64::consts::FRAC_PI_2) * (delta.recip() * 2.0)
}
}
fn i_tilde(z: f64, u: f64, w: f64) -> f64 {
i_tilde_generic(z, u, w)
}
#[derive(Debug, Clone)]
struct PureParams<D> {
big_a: Buf<D>,
big_b: Buf<D>,
big_c: Buf<D>,
sqrt_a: Buf<D>,
sqrt_b: Buf<D>,
}
fn pure_params<D: DualNum<f64> + Copy>(
eos: CubicEos,
rule: MixingRule,
t: D,
p: D,
comps: &[Component],
) -> PureParams<D> {
let n = comps.len();
let mut big_a = Buf::with_capacity(n);
let mut big_b = Buf::with_capacity(n);
let mut big_c = Buf::new();
let mut sqrt_a = Buf::new();
let mut sqrt_b = Buf::new();
let pt_family = matches!(eos, CubicEos::PatelTeja | CubicEos::PatelTejaUSB);
let needs_sqrt_a = matches!(
rule,
MixingRule::Classical | MixingRule::IVDW | MixingRule::IIVDW
) || eos == CubicEos::SchmidtWenzel;
let needs_sqrt_b = eos == CubicEos::PatelTejaUSB;
for comp in comps {
let (a, b, u, _w) = crate::eos::eos_dimensionless_generic(eos, t, p, comp);
big_a.push(a);
big_b.push(b);
if needs_sqrt_a {
sqrt_a.push(a.sqrt());
}
if needs_sqrt_b {
sqrt_b.push(b.sqrt());
}
if pt_family {
big_c.push(u - b);
}
}
PureParams {
big_a,
big_b,
big_c,
sqrt_a,
sqrt_b,
}
}
#[derive(Debug, Clone)]
pub struct MixtureParams<D> {
pub big_a: D,
pub big_b: D,
pub u: D,
pub w: D,
pub a_bar: Buf<D>,
pub b_bar: Buf<D>,
pub u_bar: Buf<D>,
pub w_bar: Buf<D>,
}
impl<D: DualNum<f64> + Copy> MixtureParams<D> {
pub fn new() -> Self {
Self {
big_a: D::from(0.0),
big_b: D::from(0.0),
u: D::from(0.0),
w: D::from(0.0),
a_bar: Buf::new(),
b_bar: Buf::new(),
u_bar: Buf::new(),
w_bar: Buf::new(),
}
}
fn resize(&mut self, n: usize) {
for buf in [
&mut self.a_bar,
&mut self.b_bar,
&mut self.u_bar,
&mut self.w_bar,
] {
if buf.len() < n {
buf.resize(n, D::from(0.0));
}
}
}
}
impl<D: DualNum<f64> + Copy> Default for MixtureParams<D> {
fn default() -> Self {
Self::new()
}
}
fn validate(spec: &MixtureSpec, x_len: usize) -> Result<(), MixError> {
let n = spec.components.len();
if n != x_len {
return Err(MixError::Dimension(format!(
"components.len()={n} but composition.len()={x_len}"
)));
}
if !spec.kij.is_empty() && (spec.kij.len() != n || spec.kij.iter().any(|r| r.len() != n)) {
return Err(MixError::Dimension(format!("kij must be empty or {n}×{n}")));
}
let ge_based = matches!(
spec.rule,
MixingRule::WongSandler
| MixingRule::HuronVidalOriginal
| MixingRule::HuronVidalSimplified
| MixingRule::MHV1
| MixingRule::MHV2
);
if ge_based && spec.ge.is_none() {
return Err(MixError::Unsupported(format!(
"mixing rule {:?} requires an activity model (GeSpec)",
spec.rule
)));
}
if ge_based && spec.eos.is_three_parameter() {
return Err(MixError::Unsupported(format!(
"GE-based rule {:?} with 3-parameter EOS {:?} is not supported \
(the legacy programs pair 3-parameter EOS with classical a/b \
mixing only)",
spec.rule, spec.eos
)));
}
if matches!(
spec.rule,
MixingRule::PatelTejaC | MixingRule::PatelTejaUSBC | MixingRule::SchmidtWenzelC
) {
return Err(MixError::Unsupported(format!(
"{:?} is a C-parameter rule; pass Classical/IVDW as the a/b rule \
(the C rule is implied by the 3-parameter EOS variant)",
spec.rule
)));
}
Ok(())
}
pub fn mixture_params<D: DualNum<f64> + Copy>(
spec: &MixtureSpec,
t: D,
p: D,
x: &[D],
) -> Result<MixtureParams<D>, MixError> {
validate(spec, x.len())?;
let pure = pure_params(spec.eos, spec.rule, t, p, spec.components);
let mut out = MixtureParams::new();
let mut scratch = Buf::new();
mixture_params_with(spec, t, x, &pure, None, &mut out, &mut scratch)?;
Ok(out)
}
#[inline]
fn quad_a<D: DualNum<f64> + Copy, F: Fn(usize, usize) -> D>(
n: usize,
x: &[D],
a_ij: F,
a_bar: &mut [D],
) -> D {
let mut a = D::from(0.0);
for i in 0..n {
let mut row = D::from(0.0);
for j in 0..n {
row += x[j] * a_ij(i, j);
}
a += x[i] * row;
a_bar[i] = row * 2.0;
}
a
}
#[inline]
fn quad_a_factorized<D: DualNum<f64> + Copy>(
n: usize,
x: &[D],
sqrt_a: &[D],
a_bar: &mut [D],
) -> D {
let mut s = D::from(0.0);
for j in 0..n {
s += x[j] * sqrt_a[j];
}
for i in 0..n {
a_bar[i] = sqrt_a[i] * s * 2.0;
}
s * s
}
#[inline]
fn quad_a_sparse<D: DualNum<f64> + Copy>(
n: usize,
x: &[D],
sqrt_a: &[D],
entries: &[KijEntry],
a_bar: &mut [D],
r: &mut [D],
) -> D {
let mut s = D::from(0.0);
for j in 0..n {
s += x[j] * sqrt_a[j];
}
for slot in r.iter_mut().take(n) {
*slot = D::from(0.0);
}
for e in entries {
let (i, j) = (e.i as usize, e.j as usize);
r[i] += x[j] * sqrt_a[j] * e.kij;
}
let mut a = s * s;
for i in 0..n {
a -= x[i] * sqrt_a[i] * r[i];
a_bar[i] = sqrt_a[i] * (s - r[i]) * 2.0;
}
a
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KijEntry {
pub i: u32,
pub j: u32,
pub kij: f64,
}
#[derive(Debug, Clone, Default)]
pub struct KijIndex {
entries: Vec<KijEntry>,
n: usize,
}
impl KijIndex {
pub fn build(kij: &[Vec<f64>]) -> Self {
let n = kij.len();
let mut entries = Vec::new();
for (i, row) in kij.iter().enumerate() {
for (j, &k) in row.iter().enumerate() {
if k != 0.0 {
entries.push(KijEntry {
i: i as u32,
j: j as u32,
kij: k,
});
}
}
}
Self { entries, n }
}
pub fn nnz(&self) -> usize {
self.entries.len()
}
pub fn density(&self) -> f64 {
if self.n == 0 {
0.0
} else {
self.entries.len() as f64 / (self.n * self.n) as f64
}
}
pub fn is_zero(&self) -> bool {
self.entries.is_empty()
}
#[inline]
fn entries(&self) -> &[KijEntry] {
&self.entries
}
}
enum KijForm<'a> {
Zero,
Sparse(&'a [KijEntry]),
Dense,
}
const SPARSE_KIJ_MAX_DENSITY: f64 = 0.15;
#[inline]
fn kij_form<'a>(kij: &[Vec<f64>], index: Option<&'a KijIndex>, n: usize) -> KijForm<'a> {
if kij.is_empty() {
return KijForm::Zero;
}
match index {
Some(idx) if idx.is_zero() => KijForm::Zero,
Some(idx) if (idx.nnz() as f64) < SPARSE_KIJ_MAX_DENSITY * (n * n) as f64 => {
KijForm::Sparse(idx.entries())
}
_ => KijForm::Dense,
}
}
fn mixture_params_with<D: DualNum<f64> + Copy>(
spec: &MixtureSpec,
t: D,
x: &[D],
pure: &PureParams<D>,
kij_index: Option<&KijIndex>,
out: &mut MixtureParams<D>,
scratch: &mut Buf<D>,
) -> Result<(), MixError> {
let n = x.len();
out.resize(n);
if scratch.len() < n {
scratch.resize(n, D::from(0.0));
}
let (ai, bi) = (&pure.big_a, &pure.big_b);
let lin_b = |x: &[D]| -> D {
let mut b = D::from(0.0);
for j in 0..n {
b += x[j] * bi[j];
}
b
};
let ge_terms = |x: &[D]| -> (Buf<D>, D) {
let ge = spec.ge.expect("validated: GE rule has GeSpec");
let mut lng: Buf<D> = smallvec::smallvec![D::from(0.0); n];
ln_gamma_all_generic(ge.model, x, ge.aij, ge.alpha, ge.vl, ge.delta, t, &mut lng);
let mut g_rt = D::from(0.0);
for i in 0..n {
g_rt += x[i] * lng[i];
}
(lng, g_rt)
};
let fc = crate::eos::family_constants(spec.eos);
let (big_a, big_b): (D, D) = match spec.rule {
MixingRule::Classical | MixingRule::IVDW => {
let sq = &pure.sqrt_a;
let a = match kij_form(spec.kij, kij_index, n) {
KijForm::Zero => quad_a_factorized(n, x, sq, &mut out.a_bar),
KijForm::Sparse(entries) => {
quad_a_sparse(n, x, sq, entries, &mut out.a_bar, scratch)
}
KijForm::Dense => {
let a_ij = |i: usize, j: usize| sq[i] * sq[j] * (1.0 - kij_at(spec.kij, i, j));
quad_a(n, x, a_ij, &mut out.a_bar)
}
};
let b = lin_b(x);
for i in 0..n {
out.b_bar[i] = bi[i];
}
(a, b)
}
MixingRule::IIVDW => {
let sq = &pure.sqrt_a;
let sqrt_aa = |i: usize, j: usize| -> D { sq[i] * sq[j] };
let mut a = D::from(0.0);
let a_bar = &mut out.a_bar;
let mut sum3 = D::from(0.0);
for k in 0..n {
let mut inner = D::from(0.0);
for j in 0..n {
inner +=
x[j] * (sqrt_aa(k, j) * (kij_at(spec.kij, k, j) + kij_at(spec.kij, j, k)));
}
sum3 += x[k] * x[k] * inner;
}
for i in 0..n {
let mut row = D::from(0.0); let mut row_k = D::from(0.0); for j in 0..n {
let km = x[i] * kij_at(spec.kij, i, j) + x[j] * kij_at(spec.kij, j, i);
row += x[j] * sqrt_aa(i, j) * (-km + 1.0);
row_k +=
x[j] * (sqrt_aa(i, j) * (kij_at(spec.kij, i, j) + kij_at(spec.kij, j, i)));
}
a += x[i] * row;
a_bar[i] = row * 2.0 - x[i] * row_k + sum3;
}
let b = lin_b(x);
for i in 0..n {
out.b_bar[i] = bi[i];
}
(a, b)
}
MixingRule::WongSandler => {
let (lng, g_rt) = ge_terms(x);
let c_star = hv_c_constant(spec.eos);
let mut q_ws = D::from(0.0);
let mut row_ws: Buf<D> = smallvec::smallvec![D::from(0.0); n];
if spec.kij.is_empty() {
let (mut c_sum, mut x_sum) = (D::from(0.0), D::from(0.0));
for j in 0..n {
c_sum += x[j] * (bi[j] - ai[j]);
x_sum += x[j];
}
q_ws = c_sum * x_sum;
for i in 0..n {
row_ws[i] = ((bi[i] - ai[i]) * x_sum + c_sum) * 0.5;
}
} else {
let bij_ws = |i: usize, j: usize| -> D {
((bi[i] - ai[i]) + (bi[j] - ai[j])) * (0.5 * (1.0 - kij_at(spec.kij, i, j)))
};
for i in 0..n {
let mut row = D::from(0.0);
for j in 0..n {
row += x[j] * bij_ws(i, j);
}
q_ws += x[i] * row;
row_ws[i] = row;
}
}
let mut d_ws = g_rt / c_star;
for i in 0..n {
d_ws += x[i] * (ai[i] / bi[i]);
}
let one_minus_d = -d_ws + 1.0;
let b = q_ws / one_minus_d;
let a = b * d_ws;
for i in 0..n {
let d_bar_i = lng[i] / c_star + ai[i] / bi[i];
let b_bar_i = row_ws[i] * 2.0 / one_minus_d
- q_ws * (-d_bar_i + 1.0) / (one_minus_d * one_minus_d);
out.b_bar[i] = b_bar_i;
out.a_bar[i] = d_ws * b_bar_i + b * d_bar_i;
}
(a, b)
}
MixingRule::HuronVidalOriginal | MixingRule::HuronVidalSimplified | MixingRule::MHV1 => {
let (lng, g_rt) = ge_terms(x);
let c = match spec.rule {
MixingRule::MHV1 => MHV1_Q1,
_ => hv_c_constant(spec.eos),
};
let b = lin_b(x);
let mut alpha_sum = D::from(0.0);
for i in 0..n {
alpha_sum += x[i] * (ai[i] / bi[i]);
}
let with_b_log = spec.rule != MixingRule::HuronVidalOriginal;
let alpha_mix = if with_b_log {
let mut blog = D::from(0.0);
for i in 0..n {
blog += x[i] * (b / bi[i]).ln();
}
alpha_sum + (g_rt + blog) / c
} else {
alpha_sum + g_rt / c
};
let a = b * alpha_mix;
for i in 0..n {
out.b_bar[i] = bi[i];
}
for i in 0..n {
let alpha_bar_i = if with_b_log {
(lng[i] + (b / bi[i]).ln() + b.recip() * bi[i] - 1.0) / c + ai[i] / bi[i]
} else {
lng[i] / c + ai[i] / bi[i]
};
out.a_bar[i] = alpha_bar_i * b + alpha_mix * bi[i];
}
(a, b)
}
MixingRule::MHV2 => {
let (lng, g_rt) = ge_terms(x);
let (q1, q2) = (MHV2_Q1, MHV2_Q2);
let b = lin_b(x);
let mut rhs = g_rt;
for i in 0..n {
let alpha_i = ai[i] / bi[i];
rhs += x[i] * (alpha_i * q1 + alpha_i * alpha_i * q2);
rhs += x[i] * (b / bi[i]).ln();
}
let disc = (rhs * (4.0 * q2) + q1 * q1).sqrt();
let r1 = (disc - q1) / (2.0 * q2);
let r2 = (-disc - q1) / (2.0 * q2);
let alpha_mix = if r1.re() >= r2.re() { r1 } else { r2 };
let a = b * alpha_mix;
let denom = alpha_mix * (2.0 * q2) + q1;
for i in 0..n {
out.b_bar[i] = bi[i];
}
for i in 0..n {
let alpha_i = ai[i] / bi[i];
let alpha_bar_i = (lng[i]
+ (b / bi[i]).ln()
+ b.recip() * bi[i]
+ (alpha_mix * alpha_mix + alpha_i * alpha_i) * q2
+ alpha_i * q1
- 1.0)
/ denom;
out.a_bar[i] = alpha_bar_i * b + alpha_mix * bi[i];
}
(a, b)
}
MixingRule::PatelTejaC | MixingRule::PatelTejaUSBC | MixingRule::SchmidtWenzelC => {
unreachable!("validated")
}
};
let (u, w) = if !spec.eos.is_three_parameter() {
for i in 0..n {
let bb = out.b_bar[i];
out.u_bar[i] = bb * fc.k1;
out.w_bar[i] = big_b * bb * (2.0 * fc.k2);
}
(big_b * fc.k1, big_b * big_b * fc.k2)
} else {
three_param_uw(spec, x, pure, big_b, out)?
};
out.big_a = big_a;
out.big_b = big_b;
out.u = u;
out.w = w;
Ok(())
}
fn three_param_uw<D: DualNum<f64> + Copy>(
spec: &MixtureSpec,
x: &[D],
pure: &PureParams<D>,
big_b: D,
out: &mut MixtureParams<D>,
) -> Result<(D, D), MixError> {
let n = x.len();
match spec.eos {
CubicEos::SchmidtWenzel => {
let mut f_num = D::from(0.0);
let mut e_den = D::from(0.0);
for j in 0..n {
let sa = pure.sqrt_a[j];
f_num += x[j] * (sa * spec.components[j].omega);
e_den += x[j] * sa;
}
let c = f_num / e_den;
let u = (c * 3.0 + 1.0) * big_b;
let w = c * big_b * big_b * (-3.0);
for i in 0..n {
let dc = (-c + spec.components[i].omega) * pure.sqrt_a[i] / e_den;
let bb = out.b_bar[i];
out.u_bar[i] = (c * 3.0 + 1.0) * bb + big_b * dc * 3.0;
out.w_bar[i] = (dc * big_b * big_b + c * big_b * bb * 2.0) * (-3.0);
}
Ok((u, w))
}
CubicEos::PatelTeja | CubicEos::PatelTejaUSB => {
let ci = &pure.big_c;
let (c, dc_dx): (D, Buf<D>) = if spec.eos == CubicEos::PatelTeja {
let mut c = D::from(0.0);
for j in 0..n {
c += x[j] * ci[j];
}
(c, (0..n).map(|i| ci[i]).collect())
} else {
let mut num = D::from(0.0);
let mut e_den = D::from(0.0);
for j in 0..n {
let sb = pure.sqrt_b[j];
num += x[j] * (sb * ci[j]);
e_den += x[j] * sb;
}
let c = num / e_den;
let bars: Buf<D> = (0..n)
.map(|i| c + (-c + ci[i]) * pure.sqrt_b[i] / e_den)
.collect();
(c, bars)
};
let u = big_b + c;
let w = -(big_b * c);
for i in 0..n {
let c_bar_i = dc_dx[i]; let bb = out.b_bar[i];
out.u_bar[i] = bb + c_bar_i;
out.w_bar[i] = -(bb * c + big_b * c_bar_i);
}
Ok((u, w))
}
_ => unreachable!("three_param_uw called for 2-parameter EOS"),
}
}
fn z_mix_generic<D: DualNum<f64> + Copy>(
pars: &MixtureParams<D>,
phase: PhaseId,
) -> Result<D, MixError> {
let (a, b, u, w) = (pars.big_a, pars.big_b, pars.u, pars.w);
let c2 = u - b - 1.0;
let c1 = a + w - u - b * u;
let c0 = -(a * b + w + b * w);
let (roots, count) = solve_real(1.0, c2.re(), c1.re(), c0.re())?;
let mut z0: Option<f64> = None;
for &r in &roots[..count] {
if r <= b.re() {
continue;
}
z0 = Some(match (z0, phase) {
(None, _) => r,
(Some(cur), PhaseId::Liquid) => cur.min(r),
(Some(cur), PhaseId::Vapor) => cur.max(r),
});
}
let z0 = z0.ok_or(MixError::NoRootForPhase {
phase,
big_b: b.re(),
})?;
let mut z = D::from(z0);
for _ in 0..2 {
let f = ((z + c2) * z + c1) * z + c0;
let fp = (z * 3.0 + c2 * 2.0) * z + c1;
if fp.re().abs() < 1e-10 {
break;
}
z -= f / fp;
}
Ok(z)
}
fn ln_phi_from_params_generic<D: DualNum<f64> + Copy>(
pars: &MixtureParams<D>,
z: D,
out: &mut [D],
) {
let (a, b, u, w) = (pars.big_a, pars.big_b, pars.u, pars.w);
let q = (z + u) * z + w;
let itilde = i_tilde_generic(z, u, w);
let disc = u * u - w * 4.0;
let scale = (u.re() * u.re()).max(4.0 * w.re().abs()).max(1e-300);
let j0 = if disc.re().abs() <= 1e-12 * scale {
((z + u * 0.5).powi(3) * 3.0).recip()
} else {
((z * 2.0 + u) / q - itilde * 2.0) / disc
};
let j1 = (q * 2.0).recip() - u * 0.5 * j0;
let ln_zb = (z - b).ln();
let az_q = a * z / q;
for i in 0..out.len() {
out[i] = -ln_zb + pars.b_bar[i] / (z - b) - (pars.a_bar[i] - a) * itilde - az_q
+ a * (j1 * (pars.u_bar[i] - u) + j0 * (pars.w_bar[i] - w * 2.0));
}
}
fn ln_phi_all_generic<D: DualNum<f64> + Copy>(
spec: &MixtureSpec,
t: D,
p: D,
x: &[D],
phase: PhaseId,
) -> Result<Buf<D>, MixError> {
let pars = mixture_params(spec, t, p, x)?;
let z = z_mix_generic(&pars, phase)?;
let mut out: Buf<D> = smallvec::smallvec![D::from(0.0); x.len()];
ln_phi_from_params_generic(&pars, z, &mut out);
Ok(out)
}
pub fn z_mix(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
phase: PhaseId,
) -> Result<f64, MixError> {
let pars = mixture_params::<f64>(spec, t, p, x)?;
z_mix_generic::<f64>(&pars, phase)
}
pub fn ln_phi_mix(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
phase: PhaseId,
) -> Result<Vec<f64>, MixError> {
let mut out = vec![0.0; x.len()];
ln_phi_mix_into(spec, t, p, x, phase, &mut out)?;
Ok(out)
}
#[derive(Debug, Clone, Default)]
pub struct MixtureWorkspace {
params: MixtureParams<f64>,
scratch: Buf<f64>,
}
impl MixtureWorkspace {
pub fn new() -> Self {
Self {
params: MixtureParams::new(),
scratch: Buf::new(),
}
}
pub fn params(&self) -> &MixtureParams<f64> {
&self.params
}
}
#[derive(Debug, Clone)]
pub struct TpCache {
eos: CubicEos,
t: f64,
p: f64,
pure: PureParams<f64>,
kij: KijIndex,
}
impl TpCache {
pub fn new(spec: &MixtureSpec, t: f64, p: f64) -> Result<Self, MixError> {
validate(spec, spec.components.len())?;
Ok(Self {
eos: spec.eos,
t,
p,
pure: pure_params(spec.eos, spec.rule, t, p, spec.components),
kij: KijIndex::build(spec.kij),
})
}
#[inline]
pub fn temperature(&self) -> f64 {
self.t
}
#[inline]
pub fn pressure(&self) -> f64 {
self.p
}
#[inline]
pub fn len(&self) -> usize {
self.pure.big_a.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
#[inline]
pub fn matches(&self, spec: &MixtureSpec, t: f64, p: f64) -> bool {
self.eos == spec.eos && self.t == t && self.p == p && self.len() == spec.components.len()
}
fn mismatch(&self, spec: &MixtureSpec, t: f64, p: f64) -> MixError {
MixError::Dimension(format!(
"TpCache built for {:?} at T={} P={} with {} components, \
but used for {:?} at T={t} P={p} with {} components",
self.eos,
self.t,
self.p,
self.len(),
spec.eos,
spec.components.len()
))
}
}
pub fn ln_phi_mix_cached_into(
spec: &MixtureSpec,
cache: &TpCache,
x: &[f64],
phase: PhaseId,
out: &mut [f64],
) -> Result<(), MixError> {
if out.len() != x.len() {
return Err(MixError::Dimension(format!(
"out.len()={} but composition.len()={}",
out.len(),
x.len()
)));
}
if !cache.matches(spec, cache.t, cache.p) || x.len() != cache.len() {
return Err(cache.mismatch(spec, cache.t, cache.p));
}
let mut ws = MixtureWorkspace::new();
ln_phi_mix_cached_ws_into(spec, cache, &mut ws, x, phase, out)
}
pub fn ln_phi_mix_cached_ws_into(
spec: &MixtureSpec,
cache: &TpCache,
ws: &mut MixtureWorkspace,
x: &[f64],
phase: PhaseId,
out: &mut [f64],
) -> Result<(), MixError> {
if out.len() != x.len() {
return Err(MixError::Dimension(format!(
"out.len()={} but composition.len()={}",
out.len(),
x.len()
)));
}
if !cache.matches(spec, cache.t, cache.p) || x.len() != cache.len() {
return Err(cache.mismatch(spec, cache.t, cache.p));
}
mixture_params_with(
spec,
cache.t,
x,
&cache.pure,
Some(&cache.kij),
&mut ws.params,
&mut ws.scratch,
)?;
let z = z_mix_generic::<f64>(&ws.params, phase)?;
ln_phi_from_params_generic(&ws.params, z, out);
Ok(())
}
pub fn ln_phi_mix_min_gibbs_cached_into(
spec: &MixtureSpec,
cache: &TpCache,
x: &[f64],
out: &mut [f64],
) -> Result<(), MixError> {
let n = x.len();
if out.len() != n {
return Err(MixError::Dimension(format!(
"out.len()={} but composition.len()={n}",
out.len()
)));
}
if !cache.matches(spec, cache.t, cache.p) || n != cache.len() {
return Err(cache.mismatch(spec, cache.t, cache.p));
}
let mut ws = MixtureWorkspace::new();
mixture_params_with(
spec,
cache.t,
x,
&cache.pure,
Some(&cache.kij),
&mut ws.params,
&mut ws.scratch,
)?;
min_gibbs_from_params(&ws.params, x, out)
}
fn min_gibbs_from_params(
pars: &MixtureParams<f64>,
x: &[f64],
out: &mut [f64],
) -> Result<(), MixError> {
let n = x.len();
let mut trial: Buf<f64> = smallvec::smallvec![0.0; n];
let mut best_g = f64::INFINITY;
let mut found = false;
let mut last_err: Option<MixError> = None;
for phase in [PhaseId::Liquid, PhaseId::Vapor] {
match z_mix_generic::<f64>(pars, phase) {
Ok(z) => {
ln_phi_from_params_generic(pars, z, &mut trial);
let g: f64 = (0..n)
.filter(|&i| x[i] > 0.0)
.map(|i| x[i] * (x[i].ln() + trial[i]))
.sum();
if !found || g < best_g {
best_g = g;
out.copy_from_slice(&trial);
found = true;
}
}
Err(e) => last_err = Some(e),
}
}
if found {
Ok(())
} else {
Err(last_err.unwrap_or(MixError::NoRootForPhase {
phase: PhaseId::Liquid,
big_b: pars.big_b,
}))
}
}
pub fn ln_phi_mix_into(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
phase: PhaseId,
out: &mut [f64],
) -> Result<(), MixError> {
if out.len() != x.len() {
return Err(MixError::Dimension(format!(
"out.len()={} but composition.len()={}",
out.len(),
x.len()
)));
}
let pars = mixture_params::<f64>(spec, t, p, x)?;
let z = z_mix_generic::<f64>(&pars, phase)?;
ln_phi_from_params_generic(&pars, z, out);
Ok(())
}
pub fn ln_phi_mix_min_gibbs_into(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
out: &mut [f64],
) -> Result<(), MixError> {
let n = x.len();
if out.len() != n {
return Err(MixError::Dimension(format!(
"out.len()={} but composition.len()={n}",
out.len()
)));
}
let pars = mixture_params::<f64>(spec, t, p, x)?;
min_gibbs_from_params(&pars, x, out)
}
pub fn d_ln_phi_d_n(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
phase: PhaseId,
) -> Result<Vec<Vec<f64>>, MixError> {
let analytic = matches!(spec.rule, MixingRule::Classical | MixingRule::IVDW)
&& !spec.eos.is_three_parameter();
if analytic {
return d_ln_phi_d_n_classical(spec, t, p, x, phase);
}
let n = x.len();
let mut jac = vec![vec![0.0; n]; n];
for j in 0..n {
let moles: Buf<num_dual::Dual64> = (0..n)
.map(|k| {
let mut d = num_dual::Dual64::from(x[k]);
if k == j {
d.eps = 1.0;
}
d
})
.collect();
let mut total = num_dual::Dual64::from(0.0);
for m in &moles {
total += *m;
}
let xd: Buf<num_dual::Dual64> = moles.iter().map(|&m| m / total).collect();
let (td, pd) = (num_dual::Dual64::from(t), num_dual::Dual64::from(p));
let lnphi = ln_phi_all_generic(spec, td, pd, &xd, phase)?;
for i in 0..n {
jac[i][j] = lnphi[i].eps;
}
}
Ok(jac)
}
pub fn d_ln_phi_d_n_apply(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
phase: PhaseId,
v: &[f64],
out: &mut [f64],
) -> Result<(), MixError> {
let n = x.len();
if v.len() != n || out.len() != n {
return Err(MixError::Dimension(format!(
"d_ln_phi_d_n_apply: x={n}, v={}, out={}",
v.len(),
out.len()
)));
}
let factorized = spec.kij.is_empty()
&& matches!(spec.rule, MixingRule::Classical | MixingRule::IVDW)
&& !spec.eos.is_three_parameter();
if !factorized {
let jac = d_ln_phi_d_n(spec, t, p, x, phase)?;
for i in 0..n {
out[i] = (0..n).map(|j| jac[i][j] * v[j]).sum();
}
return Ok(());
}
let pars = mixture_params::<f64>(spec, t, p, x)?;
let z = z_mix_generic(&pars, phase)?;
let fc = crate::eos::family_constants(spec.eos);
let (k1, k2) = (fc.k1, fc.k2);
let (a, b) = (pars.big_a, pars.big_b);
let (u, w) = (pars.u, pars.w);
let pure = pure_params(spec.eos, spec.rule, t, p, spec.components);
let q = z * z + u * z + w;
let itilde = i_tilde(z, u, w);
let disc = u * u - 4.0 * w;
let scale = (u * u).max(4.0 * w.abs()).max(1e-300);
let degenerate = disc.abs() <= 1e-12 * scale;
let j0 = if degenerate {
1.0 / (3.0 * (z + 0.5 * u).powi(3))
} else {
((2.0 * z + u) / q - 2.0 * itilde) / disc
};
let j1 = 1.0 / (2.0 * q) - 0.5 * u * j0;
let f_z = 3.0 * z * z + 2.0 * (u - b - 1.0) * z + (a + w - u - b * u);
let f_a = z - b;
let f_b = (k1 - 1.0) * z * z + (2.0 * k2 * b - k1 - 2.0 * k1 * b) * z
- (a + 2.0 * k2 * b + k2 * b * b + 2.0 * k2 * b * b);
let (mut s_v, mut s_qv) = (0.0, 0.0); let (mut s_da, mut s_db, mut s_du, mut s_dw) = (0.0, 0.0, 0.0, 0.0);
let (mut s_dz, mut s_dit) = (0.0, 0.0);
let (mut s_dj0, mut s_dj1) = (0.0, 0.0);
let mut s_scalar = 0.0; for j in 0..n {
let vj = v[j];
let da = pars.a_bar[j] - 2.0 * a;
let db = pure.big_b[j] - b;
let du = k1 * db;
let dw = 2.0 * k2 * b * db;
let dz = -(f_a * da + f_b * db) / f_z;
let dq = (2.0 * z + u) * dz + z * du + dw;
let ditilde = -dz / q - j1 * du - j0 * dw;
let dj0 = if degenerate {
-(z + 0.5 * u).powi(-4) * (dz + 0.5 * du)
} else {
let dd2 = 2.0 * u * du - 4.0 * dw;
let num = (2.0 * z + u) / q - 2.0 * itilde;
((2.0 * dz + du) / q - (2.0 * z + u) * dq / (q * q) - 2.0 * ditilde) / disc
- num * dd2 / (disc * disc)
};
let dj1 = -dq / (2.0 * q * q) - 0.5 * (du * j0 + u * dj0);
s_v += vj;
s_qv += pure.sqrt_a[j] * vj;
s_da += da * vj;
s_db += db * vj;
s_du += du * vj;
s_dw += dw * vj;
s_dz += dz * vj;
s_dit += ditilde * vj;
s_dj0 += dj0 * vj;
s_dj1 += dj1 * vj;
s_scalar += vj * (-(dz - db) / (z - b) - (da * z / q + a * dz / q - a * z * dq / (q * q)));
}
let zb2 = (z - b) * (z - b);
for i in 0..n {
let a_bar_i = pars.a_bar[i];
let b_bar_i = pars.b_bar[i];
let q_i = pure.sqrt_a[i];
let bracket_i = j1 * (k1 * b_bar_i - u) + j0 * (2.0 * k2 * b * b_bar_i - 2.0 * w);
let mut acc = -b_bar_i * (s_dz - s_db) / zb2;
acc += -2.0 * itilde * q_i * s_qv + itilde * a_bar_i * s_v + itilde * s_da
- (a_bar_i - a) * s_dit;
acc += bracket_i * s_da
+ a * ((k1 * b_bar_i - u) * s_dj1 - j1 * s_du
+ (2.0 * k2 * b * b_bar_i - 2.0 * w) * s_dj0
+ j0 * (2.0 * k2 * b_bar_i * s_db - 2.0 * s_dw));
out[i] = acc + s_scalar;
}
Ok(())
}
fn d_ln_phi_d_n_classical(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
phase: PhaseId,
) -> Result<Vec<Vec<f64>>, MixError> {
let n = x.len();
let pars = mixture_params::<f64>(spec, t, p, x)?;
let z = z_mix_generic(&pars, phase)?;
let fc = crate::eos::family_constants(spec.eos);
let (k1, k2) = (fc.k1, fc.k2);
let (a, b) = (pars.big_a, pars.big_b);
let (u, w) = (pars.u, pars.w);
let pure = pure_params(spec.eos, spec.rule, t, p, spec.components);
let a_ij =
|i: usize, j: usize| (1.0 - kij_at(spec.kij, i, j)) * pure.sqrt_a[i] * pure.sqrt_a[j];
let q = z * z + u * z + w;
let itilde = i_tilde(z, u, w);
let disc = u * u - 4.0 * w;
let scale = (u * u).max(4.0 * w.abs()).max(1e-300);
let degenerate = disc.abs() <= 1e-12 * scale;
let j0 = if degenerate {
1.0 / (3.0 * (z + 0.5 * u).powi(3))
} else {
((2.0 * z + u) / q - 2.0 * itilde) / disc
};
let j1 = 1.0 / (2.0 * q) - 0.5 * u * j0;
let f_z = 3.0 * z * z + 2.0 * (u - b - 1.0) * z + (a + w - u - b * u);
let f_a = z - b;
let f_b = (k1 - 1.0) * z * z + (2.0 * k2 * b - k1 - 2.0 * k1 * b) * z
- (a + 2.0 * k2 * b + k2 * b * b + 2.0 * k2 * b * b);
let n_out = n;
let mut jac = vec![vec![0.0; n_out]; n_out];
for j in 0..n {
let da = pars.a_bar[j] - 2.0 * a; let db = pure.big_b[j] - b; let du = k1 * db;
let dw = 2.0 * k2 * b * db;
let dz = -(f_a * da + f_b * db) / f_z;
let dq = (2.0 * z + u) * dz + z * du + dw;
let ditilde = -dz / q - j1 * du - j0 * dw;
let dj0 = if degenerate {
-(z + 0.5 * u).powi(-4) * (dz + 0.5 * du)
} else {
let dd2 = 2.0 * u * du - 4.0 * dw;
let num = (2.0 * z + u) / q - 2.0 * itilde;
((2.0 * dz + du) / q - (2.0 * z + u) * dq / (q * q) - 2.0 * ditilde) / disc
- num * dd2 / (disc * disc)
};
let dj1 = -dq / (2.0 * q * q) - 0.5 * (du * j0 + u * dj0);
for i in 0..n {
let a_bar_i = pars.a_bar[i];
let b_bar_i = pars.b_bar[i]; let da_bar_i = 2.0 * a_ij(i, j) - a_bar_i; let du_bar_i = 0.0; let dw_bar_i = 2.0 * k2 * db * b_bar_i; let d1 = -(dz - db) / (z - b);
let d2 = -b_bar_i * (dz - db) / ((z - b) * (z - b));
let d3 = -((da_bar_i - da) * itilde + (a_bar_i - a) * ditilde);
let d4 = -(da * z / q + a * dz / q - a * z * dq / (q * q));
let bracket = j1 * (k1 * b_bar_i - u) + j0 * (2.0 * k2 * b * b_bar_i - 2.0 * w);
let dbracket = dj1 * (k1 * b_bar_i - u)
+ j1 * (du_bar_i - du)
+ dj0 * (2.0 * k2 * b * b_bar_i - 2.0 * w)
+ j0 * (dw_bar_i - 2.0 * dw);
let d5 = da * bracket + a * dbracket;
jac[i][j] = d1 + d2 + d3 + d4 + d5;
}
}
Ok(jac)
}
pub fn d_ln_phi_d_t(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
phase: PhaseId,
) -> Result<Vec<f64>, MixError> {
use num_dual::Dual64;
let td = Dual64::new(t, 1.0);
let pd = Dual64::from(p);
let xd: Buf<Dual64> = x.iter().map(|&xi| Dual64::from(xi)).collect();
let lnphi = ln_phi_all_generic(spec, td, pd, &xd, phase)?;
Ok(lnphi.iter().map(|v| v.eps).collect())
}
pub fn d_ln_phi_d_p(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
phase: PhaseId,
) -> Result<Vec<f64>, MixError> {
use num_dual::Dual64;
let td = Dual64::from(t);
let pd = Dual64::new(p, 1.0);
let xd: Buf<Dual64> = x.iter().map(|&xi| Dual64::from(xi)).collect();
let lnphi = ln_phi_all_generic(spec, td, pd, &xd, phase)?;
Ok(lnphi.iter().map(|v| v.eps).collect())
}
pub fn residual_cp(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
phase: PhaseId,
) -> Result<f64, MixError> {
use num_dual::Dual2_64;
const R: f64 = 8.31451; let n = x.len();
let (_, g1, g2) = num_dual::try_second_derivative(
|td: Dual2_64| -> Result<Dual2_64, MixError> {
let pd = Dual2_64::from(p);
let xd: Buf<Dual2_64> = x.iter().map(|&xi| Dual2_64::from(xi)).collect();
let lnphi = ln_phi_all_generic(spec, td, pd, &xd, phase)?;
let mut g = Dual2_64::from(0.0);
for i in 0..n {
g += xd[i] * lnphi[i];
}
Ok(g)
},
t,
)?;
Ok(-R * (2.0 * t * g1 + t * t * g2))
}
pub fn chao_seader_ln_phi_mix(
components: &[Component],
species: &[ChaoSeaderSpecies],
t: f64,
p: f64,
) -> Result<Vec<f64>, MixError> {
if components.len() != species.len() {
return Err(MixError::Dimension(format!(
"components.len()={} but species.len()={}",
components.len(),
species.len()
)));
}
Ok(components
.iter()
.zip(species)
.map(|(c, &s)| chao_seader_ln_phi(t, p, c, s))
.collect())
}
#[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_pentane() -> Component {
Component {
name: "n-pentane".into(),
tc: 469.7,
pc: 3370.0,
omega: 0.252,
..Component::default()
}
}
fn methanol() -> Component {
Component {
name: "methanol".into(),
tc: 512.6,
pc: 8097.0,
omega: 0.564,
liquid_volume: 40.7,
..Component::default()
}
}
fn water() -> Component {
Component {
name: "water".into(),
tc: 647.1,
pc: 22064.0,
omega: 0.344,
liquid_volume: 18.07,
..Component::default()
}
}
fn kij2(k: f64) -> Vec<Vec<f64>> {
vec![vec![0.0, k], vec![k, 0.0]]
}
struct Fixture {
comps: Vec<Component>,
kij: Vec<Vec<f64>>,
}
impl Fixture {
fn pr_classical() -> Self {
Self {
comps: vec![methane(), n_pentane()],
kij: kij2(0.023),
}
}
fn spec(&self, rule: MixingRule) -> MixtureSpec<'_> {
MixtureSpec {
eos: CubicEos::PR1976,
rule,
components: &self.comps,
kij: &self.kij,
ge: None,
}
}
}
fn van_laar_aij() -> Vec<Vec<f64>> {
vec![vec![0.0, 0.847], vec![0.522, 0.0]]
}
const GE_RULES: [MixingRule; 5] = [
MixingRule::WongSandler,
MixingRule::HuronVidalOriginal,
MixingRule::HuronVidalSimplified,
MixingRule::MHV1,
MixingRule::MHV2,
];
#[test]
fn tp_cache_matches_uncached_path() {
let comps = [
Component {
name: "n-butane".into(),
tc: 425.12,
pc: 3796.0,
omega: 0.200,
..Component::default()
},
Component {
name: "n-heptane".into(),
tc: 540.2,
pc: 2740.0,
omega: 0.350,
..Component::default()
},
];
let kij = vec![vec![0.0, 0.02], vec![0.02, 0.0]];
let spec = MixtureSpec {
eos: CubicEos::RKS1972,
rule: MixingRule::Classical,
components: &comps,
kij: &kij,
ge: None,
};
let (t, p) = (400.0, 1500.0);
let cache = TpCache::new(&spec, t, p).unwrap();
assert!(cache.matches(&spec, t, p));
assert_eq!(cache.len(), 2);
assert_eq!(cache.temperature(), t);
assert_eq!(cache.pressure(), p);
for x in [[0.5, 0.5], [0.85, 0.15], [0.2, 0.8]] {
for phase in [PhaseId::Liquid, PhaseId::Vapor] {
let want = ln_phi_mix(&spec, t, p, &x, phase).unwrap();
let mut got = vec![0.0; 2];
ln_phi_mix_cached_into(&spec, &cache, &x, phase, &mut got).unwrap();
for i in 0..2 {
assert_eq!(got[i], want[i], "x={x:?} {phase:?} comp {i}");
}
}
let want = {
let mut v = vec![0.0; 2];
ln_phi_mix_min_gibbs_into(&spec, t, p, &x, &mut v).unwrap();
v
};
let mut got = vec![0.0; 2];
ln_phi_mix_min_gibbs_cached_into(&spec, &cache, &x, &mut got).unwrap();
assert_eq!(got, want, "min-Gibbs at x={x:?}");
}
}
#[test]
fn tp_cache_rejects_mismatched_spec() {
let comps = [Component {
name: "n-butane".into(),
tc: 425.12,
pc: 3796.0,
omega: 0.200,
..Component::default()
}];
let rks = MixtureSpec {
eos: CubicEos::RKS1972,
rule: MixingRule::Classical,
components: &comps,
kij: &[],
ge: None,
};
let pr = MixtureSpec {
eos: CubicEos::PR1976,
..rks
};
let cache = TpCache::new(&rks, 400.0, 1500.0).unwrap();
assert!(!cache.matches(&pr, 400.0, 1500.0));
let mut out = vec![0.0; 1];
assert!(matches!(
ln_phi_mix_cached_into(&pr, &cache, &[1.0], PhaseId::Liquid, &mut out),
Err(MixError::Dimension(_))
));
}
#[test]
fn hv_c_star_matches_exact_and_vb6_table() {
let s2 = std::f64::consts::SQRT_2;
let pr_exact = (s2 - 1.0).ln() / s2;
assert!((hv_c_constant(CubicEos::PR1976) - pr_exact).abs() < 1e-13);
assert!((hv_c_constant(CubicEos::RKS1972) - (-std::f64::consts::LN_2)).abs() < 1e-12);
assert!((hv_c_constant(CubicEos::VdW1870) - (-1.0)).abs() < 1e-12);
assert!((hv_c_constant(CubicEos::PR1976) - (-0.62322540138)).abs() < 1e-6);
}
#[test]
fn pure_limit_matches_ln_phi_pure_classical_and_3param() {
let comps = [n_pentane()];
for eos in [
CubicEos::PR1976,
CubicEos::RKS1972,
CubicEos::VdW1870,
CubicEos::SchmidtWenzel,
CubicEos::PatelTeja,
CubicEos::PatelTejaUSB,
] {
let spec = MixtureSpec {
eos,
rule: MixingRule::Classical,
components: &comps,
kij: &[],
ge: None,
};
let got = ln_phi_mix(&spec, 400.0, 1500.0, &[1.0], PhaseId::Vapor).unwrap()[0];
let want =
crate::eos::ln_phi_pure(eos, 400.0, 1500.0, &comps[0], PhaseId::Vapor).unwrap();
assert!(
(got - want).abs() < 1e-10,
"{eos:?}: mixture pure-limit {got} vs pure {want}"
);
}
}
#[test]
fn pure_limit_matches_ln_phi_pure_ge_rules() {
let comps = [methanol()];
let aij = vec![vec![0.0]];
let ge = GeSpec {
model: ActivityModel::VanLaar,
aij: &aij,
alpha: &[],
vl: &[40.7],
delta: &[],
};
for rule in GE_RULES {
let spec = MixtureSpec {
eos: CubicEos::PR1976,
rule,
components: &comps,
kij: &[],
ge: Some(ge),
};
let got = ln_phi_mix(&spec, 450.0, 500.0, &[1.0], PhaseId::Vapor).unwrap()[0];
let want =
crate::eos::ln_phi_pure(CubicEos::PR1976, 450.0, 500.0, &comps[0], PhaseId::Vapor)
.unwrap();
assert!(
(got - want).abs() < 1e-9,
"{rule:?}: pure-limit {got} vs pure {want}"
);
}
}
#[test]
fn classical_pr_matches_textbook_form() {
let fx = Fixture::pr_classical();
let spec = fx.spec(MixingRule::IVDW);
let x = [0.35, 0.65];
let (t, p) = (350.0, 2000.0);
let got = ln_phi_mix(&spec, t, p, &x, PhaseId::Vapor).unwrap();
let pure = pure_params(CubicEos::PR1976, MixingRule::Classical, t, p, &fx.comps);
let a_ij =
|i: usize, j: usize| (1.0 - fx.kij[i][j]) * (pure.big_a[i] * pure.big_a[j]).sqrt();
let mut a = 0.0;
let mut b = 0.0;
for i in 0..2 {
b += x[i] * pure.big_b[i];
for j in 0..2 {
a += x[i] * x[j] * a_ij(i, j);
}
}
let z = z_mix(&spec, t, p, &x, PhaseId::Vapor).unwrap();
let s2 = std::f64::consts::SQRT_2;
for i in 0..2 {
let a_bar = 2.0 * (x[0] * a_ij(i, 0) + x[1] * a_ij(i, 1));
let want = (pure.big_b[i] / b) * (z - 1.0)
- (z - b).ln()
- a / (2.0 * s2 * b)
* (a_bar / a - pure.big_b[i] / b)
* ((z + (1.0 + s2) * b) / (z + (1.0 - s2) * b)).ln();
assert!(
(got[i] - want).abs() < 1e-10,
"component {i}: general {} vs textbook {}",
got[i],
want
);
}
}
#[test]
fn ideal_gas_limit_all_rules() {
let fx = Fixture::pr_classical();
let x = [0.4, 0.6];
for rule in [MixingRule::Classical, MixingRule::IVDW, MixingRule::IIVDW] {
let spec = fx.spec(rule);
let lnphi = ln_phi_mix(&spec, 400.0, 1e-4, &x, PhaseId::Vapor).unwrap();
for (i, v) in lnphi.iter().enumerate() {
assert!(v.abs() < 1e-6, "{rule:?} comp {i}: lnphi={v} at P→0");
}
}
let comps = vec![methanol(), water()];
let aij = van_laar_aij();
let vl = [40.7, 18.07];
let ge = GeSpec {
model: ActivityModel::VanLaar,
aij: &aij,
alpha: &[],
vl: &vl,
delta: &[],
};
for rule in GE_RULES {
let spec = MixtureSpec {
eos: CubicEos::PR1976,
rule,
components: &comps,
kij: &[],
ge: Some(ge),
};
let lnphi = ln_phi_mix(&spec, 450.0, 1e-4, &x, PhaseId::Vapor).unwrap();
for (i, v) in lnphi.iter().enumerate() {
assert!(v.abs() < 1e-4, "{rule:?} comp {i}: lnphi={v} at P→0");
}
}
}
fn jac_fd(spec: &MixtureSpec, t: f64, p: f64, x: &[f64], phase: PhaseId) -> Vec<Vec<f64>> {
let n = x.len();
let h = 1e-6;
let eval = |moles: &[f64]| -> Vec<f64> {
let tot: f64 = moles.iter().sum();
let xn: Vec<f64> = moles.iter().map(|m| m / tot).collect();
ln_phi_mix(spec, t, p, &xn, phase).unwrap()
};
let mut jac = vec![vec![0.0; n]; n];
for j in 0..n {
let mut plus = x.to_vec();
plus[j] += h;
let mut minus = x.to_vec();
minus[j] -= h;
let fp = eval(&plus);
let fm = eval(&minus);
for i in 0..n {
jac[i][j] = (fp[i] - fm[i]) / (2.0 * h);
}
}
jac
}
fn assert_jac_close(got: &[Vec<f64>], want: &[Vec<f64>], tol: f64, label: &str) {
for i in 0..got.len() {
for j in 0..got.len() {
let (g, w) = (got[i][j], want[i][j]);
let denom = w.abs().max(1.0);
assert!(
((g - w) / denom).abs() < tol,
"{label} [{i}][{j}]: got {g}, want {w}"
);
}
}
}
#[test]
fn classical_analytic_jacobian_matches_fd_and_is_symmetric() {
let fx = Fixture::pr_classical();
let spec = fx.spec(MixingRule::IVDW);
let x = [0.35, 0.65];
let (t, p) = (350.0, 2000.0);
for phase in [PhaseId::Vapor, PhaseId::Liquid] {
let analytic = d_ln_phi_d_n(&spec, t, p, &x, phase).unwrap();
let fd = jac_fd(&spec, t, p, &x, phase);
assert_jac_close(&analytic, &fd, 1e-5, &format!("analytic-vs-fd {phase:?}"));
assert!(
(analytic[0][1] - analytic[1][0]).abs() < 1e-9 * analytic[0][1].abs().max(1.0),
"symmetry {phase:?}: {} vs {}",
analytic[0][1],
analytic[1][0]
);
}
}
#[test]
fn dual_jacobian_matches_fd_for_exotic_rules() {
let fx = Fixture::pr_classical();
let x = [0.35, 0.65];
let (t, p) = (350.0, 2000.0);
{
let spec = fx.spec(MixingRule::IIVDW);
let dual = d_ln_phi_d_n(&spec, t, p, &x, PhaseId::Vapor).unwrap();
let fd = jac_fd(&spec, t, p, &x, PhaseId::Vapor);
assert_jac_close(&dual, &fd, 1e-5, "IIVDW dual-vs-fd");
}
let comps = vec![methanol(), water()];
let aij = van_laar_aij();
let vl = [40.7, 18.07];
let ge = GeSpec {
model: ActivityModel::VanLaar,
aij: &aij,
alpha: &[],
vl: &vl,
delta: &[],
};
for rule in GE_RULES {
let spec = MixtureSpec {
eos: CubicEos::PR1976,
rule,
components: &comps,
kij: &kij2(0.05),
ge: Some(ge),
};
let dual = d_ln_phi_d_n(&spec, 400.0, 300.0, &x, PhaseId::Vapor).unwrap();
let fd = jac_fd(&spec, 400.0, 300.0, &x, PhaseId::Vapor);
assert_jac_close(&dual, &fd, 1e-4, &format!("{rule:?} dual-vs-fd"));
}
}
#[test]
fn dual_jacobian_matches_fd_for_3param_eos() {
let comps = vec![methane(), n_pentane()];
let kij = kij2(0.023);
let x = [0.35, 0.65];
for eos in [
CubicEos::SchmidtWenzel,
CubicEos::PatelTeja,
CubicEos::PatelTejaUSB,
] {
let spec = MixtureSpec {
eos,
rule: MixingRule::Classical,
components: &comps,
kij: &kij,
ge: None,
};
let dual = d_ln_phi_d_n(&spec, 350.0, 2000.0, &x, PhaseId::Vapor).unwrap();
let fd = jac_fd(&spec, 350.0, 2000.0, &x, PhaseId::Vapor);
assert_jac_close(&dual, &fd, 1e-4, &format!("{eos:?} dual-vs-fd"));
}
}
fn pseudo_components(n: usize) -> Vec<Component> {
(0..n)
.map(|i| {
let f = 1.0 + 0.021 * i as f64;
Component {
name: format!("pseudo{i}"),
tc: 190.0 * f,
pc: 4600.0 / f,
omega: 0.011 + 0.006 * i as f64,
psat_coeffs: vec![4.2, 900.0 * f, -8.0],
liquid_volume: 37.9 * f,
..Component::default()
}
})
.collect()
}
#[test]
fn factorized_matches_general_path() {
for n in [2usize, 5, 17, 60] {
let comps = pseudo_components(n);
let zeros: Vec<Vec<f64>> = vec![vec![0.0; n]; n];
let (t, p) = (350.0, 2000.0);
for weights in [vec![1.0; n], (1..=n).map(|k| k as f64).collect()] {
let total: f64 = weights.iter().sum();
let x: Vec<f64> = weights.iter().map(|w| w / total).collect();
let dense = MixtureSpec {
eos: CubicEos::PR1976,
rule: MixingRule::Classical,
components: &comps,
kij: &zeros,
ge: None,
};
let fast = MixtureSpec { kij: &[], ..dense };
let pd = mixture_params::<f64>(&dense, t, p, &x).unwrap();
let pf = mixture_params::<f64>(&fast, t, p, &x).unwrap();
let tol = 1e-12 * pd.big_a.abs().max(1.0);
assert!(
(pd.big_a - pf.big_a).abs() < tol,
"n={n}: A {} vs {}",
pd.big_a,
pf.big_a
);
for i in 0..n {
assert!(
(pd.a_bar[i] - pf.a_bar[i]).abs() < 1e-12 * pd.a_bar[i].abs().max(1.0),
"n={n} i={i}: Ābar {} vs {}",
pd.a_bar[i],
pf.a_bar[i]
);
}
}
}
}
#[test]
fn sparse_matches_general_path() {
let n = 40;
let comps = pseudo_components(n);
let mut kij = vec![vec![0.0; n]; n];
for g in 0..3 {
for j in 0..n {
if g != j {
kij[g][j] = 0.08 + 0.001 * j as f64;
kij[j][g] = kij[g][j];
}
}
}
kij[7][9] = 0.05;
kij[9][7] = -0.02; let index = KijIndex::build(&kij);
assert!(index.nnz() > 0 && index.density() < SPARSE_KIJ_MAX_DENSITY);
let (t, p) = (350.0, 2000.0);
let x: Vec<f64> = (0..n).map(|i| (i + 1) as f64).collect();
let total: f64 = x.iter().sum();
let x: Vec<f64> = x.iter().map(|v| v / total).collect();
let spec = MixtureSpec {
eos: CubicEos::PR1976,
rule: MixingRule::Classical,
components: &comps,
kij: &kij,
ge: None,
};
let pure = pure_params(spec.eos, spec.rule, t, p, spec.components);
let mut dense = MixtureParams::new();
let mut sparse = MixtureParams::new();
let mut scratch = Buf::new();
mixture_params_with(&spec, t, &x, &pure, None, &mut dense, &mut scratch).unwrap();
mixture_params_with(&spec, t, &x, &pure, Some(&index), &mut sparse, &mut scratch).unwrap();
assert!(
(dense.big_a - sparse.big_a).abs() < 1e-12 * dense.big_a.abs(),
"A {} vs {}",
dense.big_a,
sparse.big_a
);
for i in 0..n {
assert!(
(dense.a_bar[i] - sparse.a_bar[i]).abs() < 1e-12 * dense.a_bar[i].abs().max(1.0),
"i={i}: {} vs {}",
dense.a_bar[i],
sparse.a_bar[i]
);
}
let _ = p;
}
#[test]
fn cached_and_uncached_agree_at_scale() {
let n = 80;
let comps = pseudo_components(n);
let x: Vec<f64> = vec![1.0 / n as f64; n];
let (t, p) = (350.0, 2000.0);
let spec = MixtureSpec {
eos: CubicEos::PR1976,
rule: MixingRule::Classical,
components: &comps,
kij: &[],
ge: None,
};
let cache = TpCache::new(&spec, t, p).unwrap();
assert!(cache.kij.is_zero());
let mut cached = vec![0.0; n];
ln_phi_mix_cached_into(&spec, &cache, &x, PhaseId::Liquid, &mut cached).unwrap();
let direct = ln_phi_mix(&spec, t, p, &x, PhaseId::Liquid).unwrap();
for i in 0..n {
assert!(
(cached[i] - direct[i]).abs() < 1e-12 * direct[i].abs().max(1.0),
"i={i}: {} vs {}",
cached[i],
direct[i]
);
}
}
#[test]
fn rank1_apply_matches_formed_jacobian() {
for n in [2usize, 4, 25, 70] {
let comps = pseudo_components(n);
let x: Vec<f64> = {
let w: Vec<f64> = (1..=n).map(|k| k as f64).collect();
let s: f64 = w.iter().sum();
w.iter().map(|v| v / s).collect()
};
let spec = MixtureSpec {
eos: CubicEos::PR1976,
rule: MixingRule::Classical,
components: &comps,
kij: &[],
ge: None,
};
for phase in [PhaseId::Vapor, PhaseId::Liquid] {
let jac = d_ln_phi_d_n(&spec, 350.0, 2000.0, &x, phase).unwrap();
let probes: Vec<Vec<f64>> = vec![
vec![1.0; n],
(0..n)
.map(|k| if k % 2 == 0 { 1.0 } else { -1.0 })
.collect(),
(0..n).map(|k| (k as f64 + 1.0).sin()).collect(),
];
for v in probes {
let want: Vec<f64> = (0..n)
.map(|i| (0..n).map(|j| jac[i][j] * v[j]).sum())
.collect();
let mut got = vec![0.0; n];
d_ln_phi_d_n_apply(&spec, 350.0, 2000.0, &x, phase, &v, &mut got).unwrap();
for i in 0..n {
let denom = want[i].abs().max(1.0);
assert!(
(got[i] - want[i]).abs() / denom < 1e-9,
"n={n} {phase:?} i={i}: got {} want {}",
got[i],
want[i]
);
}
}
}
}
}
#[test]
fn rank1_apply_falls_back_correctly() {
let n = 6;
let comps = pseudo_components(n);
let x: Vec<f64> = vec![1.0 / n as f64; n];
let kij: Vec<Vec<f64>> = (0..n)
.map(|i| (0..n).map(|j| if i == j { 0.0 } else { 0.02 }).collect())
.collect();
for eos in [CubicEos::PR1976, CubicEos::PatelTeja] {
let spec = MixtureSpec {
eos,
rule: MixingRule::Classical,
components: &comps,
kij: &kij,
ge: None,
};
let jac = d_ln_phi_d_n(&spec, 350.0, 2000.0, &x, PhaseId::Vapor).unwrap();
let v: Vec<f64> = (0..n).map(|k| (k as f64 + 1.0).cos()).collect();
let want: Vec<f64> = (0..n)
.map(|i| (0..n).map(|j| jac[i][j] * v[j]).sum())
.collect();
let mut got = vec![0.0; n];
d_ln_phi_d_n_apply(&spec, 350.0, 2000.0, &x, PhaseId::Vapor, &v, &mut got).unwrap();
for i in 0..n {
assert!(
(got[i] - want[i]).abs() < 1e-12 * want[i].abs().max(1.0),
"{eos:?} i={i}: {} vs {}",
got[i],
want[i]
);
}
}
let spec = MixtureSpec {
eos: CubicEos::PR1976,
rule: MixingRule::Classical,
components: &comps,
kij: &[],
ge: None,
};
let mut out = vec![0.0; n];
assert!(matches!(
d_ln_phi_d_n_apply(&spec, 350.0, 2000.0, &x, PhaseId::Vapor, &[1.0], &mut out),
Err(MixError::Dimension(_))
));
}
#[test]
fn wong_sandler_collapse_matches_general_path() {
for n in [2usize, 3, 12, 45] {
let comps = pseudo_components(n);
let aij: Vec<Vec<f64>> = (0..n)
.map(|i| {
(0..n)
.map(|j| {
if i == j {
0.0
} else {
120.0 * (j as f64 - i as f64)
}
})
.collect()
})
.collect();
let vl: Vec<f64> = comps.iter().map(|c| c.liquid_volume).collect();
let ge = GeSpec {
model: ActivityModel::Wilson,
aij: &aij,
alpha: &[],
vl: &vl,
delta: &[],
};
let zeros: Vec<Vec<f64>> = vec![vec![0.0; n]; n];
let (t, p) = (350.0, 2000.0);
let w: Vec<f64> = (1..=n).map(|k| k as f64).collect();
let s: f64 = w.iter().sum();
let x: Vec<f64> = w.iter().map(|v| v / s).collect();
let dense = MixtureSpec {
eos: CubicEos::RKS1972,
rule: MixingRule::WongSandler,
components: &comps,
kij: &zeros,
ge: Some(ge),
};
let fast = MixtureSpec { kij: &[], ..dense };
let pd = mixture_params::<f64>(&dense, t, p, &x).unwrap();
let pf = mixture_params::<f64>(&fast, t, p, &x).unwrap();
for (label, d, f) in [
("A", pd.big_a, pf.big_a),
("B", pd.big_b, pf.big_b),
("U", pd.u, pf.u),
("W", pd.w, pf.w),
] {
assert!(
(d - f).abs() < 1e-11 * d.abs().max(1.0),
"n={n} {label}: {d} vs {f}"
);
}
for i in 0..n {
assert!(
(pd.a_bar[i] - pf.a_bar[i]).abs() < 1e-11 * pd.a_bar[i].abs().max(1.0),
"n={n} i={i} Ā: {} vs {}",
pd.a_bar[i],
pf.a_bar[i]
);
assert!(
(pd.b_bar[i] - pf.b_bar[i]).abs() < 1e-11 * pd.b_bar[i].abs().max(1.0),
"n={n} i={i} B̄: {} vs {}",
pd.b_bar[i],
pf.b_bar[i]
);
}
}
}
#[test]
fn workspace_matches_allocating_path_and_survives_reuse() {
let mut ws = MixtureWorkspace::new();
for n in [40usize, 9, 25, 3] {
let comps = pseudo_components(n);
let x: Vec<f64> = {
let w: Vec<f64> = (1..=n).map(|k| k as f64).collect();
let s: f64 = w.iter().sum();
w.iter().map(|v| v / s).collect()
};
for rule in [MixingRule::Classical, MixingRule::IVDW] {
let spec = MixtureSpec {
eos: CubicEos::PR1976,
rule,
components: &comps,
kij: &[],
ge: None,
};
let cache = TpCache::new(&spec, 350.0, 2000.0).unwrap();
for phase in [PhaseId::Vapor, PhaseId::Liquid] {
let want = ln_phi_mix(&spec, 350.0, 2000.0, &x, phase).unwrap();
let mut got = vec![0.0; n];
ln_phi_mix_cached_ws_into(&spec, &cache, &mut ws, &x, phase, &mut got).unwrap();
for i in 0..n {
assert!(
(got[i] - want[i]).abs() < 1e-12 * want[i].abs().max(1.0),
"n={n} {rule:?} {phase:?} i={i}: {} vs {}",
got[i],
want[i]
);
}
}
}
}
let comps = pseudo_components(12);
let x = vec![1.0 / 12.0; 12];
for eos in [CubicEos::SchmidtWenzel, CubicEos::PatelTeja] {
let spec = MixtureSpec {
eos,
rule: MixingRule::Classical,
components: &comps,
kij: &[],
ge: None,
};
let cache = TpCache::new(&spec, 350.0, 2000.0).unwrap();
let want = ln_phi_mix(&spec, 350.0, 2000.0, &x, PhaseId::Vapor).unwrap();
let mut got = vec![0.0; 12];
ln_phi_mix_cached_ws_into(&spec, &cache, &mut ws, &x, PhaseId::Vapor, &mut got)
.unwrap();
for i in 0..12 {
assert!(
(got[i] - want[i]).abs() < 1e-12 * want[i].abs().max(1.0),
"{eos:?} i={i}: {} vs {}",
got[i],
want[i]
);
}
}
}
#[test]
fn kij_index_counts_every_nonzero() {
assert!(KijIndex::build(&[]).is_zero());
assert_eq!(KijIndex::build(&[]).density(), 0.0);
let m = vec![
vec![0.0, 0.1, 0.0],
vec![0.1, 0.0, 0.0],
vec![0.0, 0.0, 0.0],
];
let idx = KijIndex::build(&m);
assert_eq!(idx.nnz(), 2);
assert!(!idx.is_zero());
assert!((idx.density() - 2.0 / 9.0).abs() < 1e-15);
}
#[test]
fn a_bar_euler_identity_every_rule() {
let x = [0.35, 0.65];
let fx = Fixture::pr_classical();
let mut specs: Vec<MixtureSpec> = vec![
fx.spec(MixingRule::Classical),
fx.spec(MixingRule::IVDW),
fx.spec(MixingRule::IIVDW),
];
let comps = vec![methanol(), water()];
let aij = van_laar_aij();
let vl = [40.7, 18.07];
let ge = GeSpec {
model: ActivityModel::VanLaar,
aij: &aij,
alpha: &[],
vl: &vl,
delta: &[],
};
let kij_ge = kij2(0.05);
for rule in GE_RULES {
specs.push(MixtureSpec {
eos: CubicEos::PR1976,
rule,
components: &comps,
kij: &kij_ge,
ge: Some(ge),
});
}
for spec in &specs {
let pars = mixture_params::<f64>(spec, 400.0, 800.0, &x).unwrap();
let sum_a: f64 = (0..2).map(|i| x[i] * pars.a_bar[i]).sum();
let sum_b: f64 = (0..2).map(|i| x[i] * pars.b_bar[i]).sum();
let sum_u: f64 = (0..2).map(|i| x[i] * pars.u_bar[i]).sum();
let sum_w: f64 = (0..2).map(|i| x[i] * pars.w_bar[i]).sum();
assert!(
(sum_a - 2.0 * pars.big_a).abs() < 1e-10 * pars.big_a.abs().max(1e-10),
"{:?}: Σx·Ā = {sum_a} vs 2A = {}",
spec.rule,
2.0 * pars.big_a
);
assert!(
(sum_b - pars.big_b).abs() < 1e-10 * pars.big_b.abs().max(1e-10),
"{:?}: Σx·B̄ = {sum_b} vs B = {}",
spec.rule,
pars.big_b
);
assert!(
(sum_u - pars.u).abs() < 1e-10 * pars.u.abs().max(1e-10),
"{:?}: Σx·Ū = {sum_u} vs U = {}",
spec.rule,
pars.u
);
assert!(
(sum_w - 2.0 * pars.w).abs() < 1e-10 * pars.w.abs().max(1e-10),
"{:?}: Σx·W̄ = {sum_w} vs 2W = {}",
spec.rule,
2.0 * pars.w
);
}
}
#[test]
fn three_param_euler_identity() {
let comps = vec![methane(), n_pentane()];
let kij = kij2(0.023);
let x = [0.35, 0.65];
for eos in [
CubicEos::SchmidtWenzel,
CubicEos::PatelTeja,
CubicEos::PatelTejaUSB,
] {
let spec = MixtureSpec {
eos,
rule: MixingRule::Classical,
components: &comps,
kij: &kij,
ge: None,
};
let pars = mixture_params::<f64>(&spec, 350.0, 2000.0, &x).unwrap();
let sum_u: f64 = (0..2).map(|i| x[i] * pars.u_bar[i]).sum();
let sum_w: f64 = (0..2).map(|i| x[i] * pars.w_bar[i]).sum();
assert!(
(sum_u - pars.u).abs() < 1e-12 * pars.u.abs().max(1e-12),
"{eos:?}: Σx·Ū = {sum_u} vs U = {}",
pars.u
);
assert!(
(sum_w - 2.0 * pars.w).abs() < 1e-12 * pars.w.abs().max(1e-12),
"{eos:?}: Σx·W̄ = {sum_w} vs 2W = {}",
2.0 * pars.w
);
}
}
#[test]
fn ws_b_mix_satisfies_wong_sandler_construction() {
let comps = vec![methanol(), water()];
let aij = van_laar_aij();
let vl = [40.7, 18.07];
let ge = GeSpec {
model: ActivityModel::VanLaar,
aij: &aij,
alpha: &[],
vl: &vl,
delta: &[],
};
let kij = kij2(0.1);
let spec = MixtureSpec {
eos: CubicEos::PR1976,
rule: MixingRule::WongSandler,
components: &comps,
kij: &kij,
ge: Some(ge),
};
let x = [0.4, 0.6];
let (t, p) = (350.0, 200.0);
let pars = mixture_params::<f64>(&spec, t, p, &x).unwrap();
let pure = pure_params(CubicEos::PR1976, MixingRule::Classical, t, p, &comps);
let mut lng = [0.0; 2];
crate::activity::ln_gamma_all(ActivityModel::VanLaar, &x, &aij, &[], &vl, &[], t, &mut lng);
let g_rt: f64 = (0..2).map(|i| x[i] * lng[i]).sum();
let c_star = hv_c_constant(CubicEos::PR1976);
let d: f64 = (0..2)
.map(|i| x[i] * pure.big_a[i] / pure.big_b[i])
.sum::<f64>()
+ g_rt / c_star;
assert!(
(pars.big_a - pars.big_b * d).abs() < 1e-12,
"A = {}, B·D = {}",
pars.big_a,
pars.big_b * d
);
}
#[test]
fn invalid_combinations_are_rejected() {
let fx = Fixture::pr_classical();
let spec = fx.spec(MixingRule::WongSandler);
assert!(matches!(
ln_phi_mix(&spec, 350.0, 100.0, &[0.5, 0.5], PhaseId::Vapor),
Err(MixError::Unsupported(_))
));
let spec = fx.spec(MixingRule::PatelTejaC);
assert!(matches!(
ln_phi_mix(&spec, 350.0, 100.0, &[0.5, 0.5], PhaseId::Vapor),
Err(MixError::Unsupported(_))
));
let spec = fx.spec(MixingRule::IVDW);
assert!(matches!(
ln_phi_mix(&spec, 350.0, 100.0, &[1.0], PhaseId::Vapor),
Err(MixError::Dimension(_))
));
}
#[test]
fn chao_seader_mix_matches_pure_calls() {
let comps = vec![methane(), n_pentane()];
let species = vec![ChaoSeaderSpecies::Methane, ChaoSeaderSpecies::Normal];
let got = chao_seader_ln_phi_mix(&comps, &species, 300.0, 500.0).unwrap();
for (i, (c, &s)) in comps.iter().zip(&species).enumerate() {
let want = chao_seader_ln_phi(300.0, 500.0, c, s);
assert!((got[i] - want).abs() < 1e-15, "component {i}");
}
}
fn dlnphi_dt_fd(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
phase: PhaseId,
h: f64,
) -> Vec<f64> {
let hi = ln_phi_mix(spec, t + h, p, x, phase).unwrap();
let lo = ln_phi_mix(spec, t - h, p, x, phase).unwrap();
hi.iter()
.zip(&lo)
.map(|(a, b)| (a - b) / (2.0 * h))
.collect()
}
fn dlnphi_dp_fd(
spec: &MixtureSpec,
t: f64,
p: f64,
x: &[f64],
phase: PhaseId,
h: f64,
) -> Vec<f64> {
let hi = ln_phi_mix(spec, t, p + h, x, phase).unwrap();
let lo = ln_phi_mix(spec, t, p - h, x, phase).unwrap();
hi.iter()
.zip(&lo)
.map(|(a, b)| (a - b) / (2.0 * h))
.collect()
}
fn derivative_spec_matrix<'a>(
fx: &'a Fixture,
pt: &'a Fixture,
comps: &'a [Component],
aij: &'a [Vec<f64>],
vl: &'a [f64],
kij_ge: &'a [Vec<f64>],
ge: &'a GeSpec<'a>,
) -> Vec<MixtureSpec<'a>> {
let mut specs = vec![fx.spec(MixingRule::Classical), fx.spec(MixingRule::IVDW)];
specs.push(MixtureSpec {
eos: CubicEos::PatelTeja,
rule: MixingRule::Classical,
components: &pt.comps,
kij: &pt.kij,
ge: None,
});
for rule in GE_RULES {
specs.push(MixtureSpec {
eos: CubicEos::PR1976,
rule,
components: comps,
kij: kij_ge,
ge: Some(*ge),
});
}
let _ = (aij, vl); specs
}
#[test]
fn dlnphi_dt_dp_match_fd_across_matrix() {
let fx = Fixture::pr_classical();
let pt = Fixture {
comps: vec![methane(), n_pentane()],
kij: kij2(0.0),
};
let comps = vec![methanol(), water()];
let aij = van_laar_aij();
let vl = [40.7, 18.07];
let ge = GeSpec {
model: ActivityModel::VanLaar,
aij: &aij,
alpha: &[],
vl: &vl,
delta: &[],
};
let kij_ge = kij2(0.05);
let specs = derivative_spec_matrix(&fx, &pt, &comps, &aij, &vl, &kij_ge, &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(dual_t) = d_ln_phi_d_t(spec, t, p, &x, phase) else {
continue;
};
let fd_t = dlnphi_dt_fd(spec, t, p, &x, phase, 1e-3);
for i in 0..2 {
let tol = 1e-6 * dual_t[i].abs().max(1e-6) + 1e-9;
assert!(
(dual_t[i] - fd_t[i]).abs() <= tol,
"{:?} {phase:?} ∂lnφ{i}/∂T: dual={} fd={}",
spec.rule,
dual_t[i],
fd_t[i]
);
}
let dual_p = d_ln_phi_d_p(spec, t, p, &x, phase).unwrap();
let fd_p = dlnphi_dp_fd(spec, t, p, &x, phase, 1e-1);
for i in 0..2 {
let tol = 1e-6 * dual_p[i].abs().max(1e-6) + 1e-12;
assert!(
(dual_p[i] - fd_p[i]).abs() <= tol,
"{:?} {phase:?} ∂lnφ{i}/∂P: dual={} fd={}",
spec.rule,
dual_p[i],
fd_p[i]
);
}
}
}
}
#[test]
fn gibbs_helmholtz_identity_vs_departure_enthalpy() {
let fx = Fixture::pr_classical();
let comps = vec![methanol(), water()];
let aij = van_laar_aij();
let vl = [40.7, 18.07];
let ge = GeSpec {
model: ActivityModel::VanLaar,
aij: &aij,
alpha: &[],
vl: &vl,
delta: &[],
};
let kij_ge = kij2(0.05);
let mut specs = vec![fx.spec(MixingRule::Classical)];
for rule in GE_RULES {
specs.push(MixtureSpec {
eos: CubicEos::PR1976,
rule,
components: &comps,
kij: &kij_ge,
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(dt) = d_ln_phi_d_t(spec, t, p, &x, phase) else {
continue;
};
let sum_dt: f64 = (0..2).map(|i| x[i] * dt[i]).sum();
let h_rt = crate::energy::h_departure_rt_mix(spec, t, p, &x, phase).unwrap();
let gh = -h_rt / t;
assert!(
(sum_dt - gh).abs() <= 1e-9 * gh.abs().max(1e-6) + 1e-12,
"{:?} {phase:?} Gibbs–Helmholtz: Σx·∂lnφ/∂T={sum_dt} vs −H^R/RT²={gh}",
spec.rule
);
}
}
}
#[test]
fn volumetric_identity_all_rules() {
let fx = Fixture::pr_classical();
let comps = vec![methanol(), water()];
let aij = van_laar_aij();
let vl = [40.7, 18.07];
let ge = GeSpec {
model: ActivityModel::VanLaar,
aij: &aij,
alpha: &[],
vl: &vl,
delta: &[],
};
let kij_ge = kij2(0.05);
let mut specs = vec![fx.spec(MixingRule::Classical)];
for rule in GE_RULES {
specs.push(MixtureSpec {
eos: CubicEos::PR1976,
rule,
components: &comps,
kij: &kij_ge,
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(dp) = d_ln_phi_d_p(spec, t, p, &x, phase) else {
continue;
};
let sum_dp: f64 = (0..2).map(|i| x[i] * dp[i]).sum();
let z = z_mix(spec, t, p, &x, phase).unwrap();
let vol = (z - 1.0) / p;
assert!(
(sum_dp - vol).abs() <= 1e-9 * vol.abs().max(1e-9) + 1e-14,
"{:?} {phase:?} volumetric: Σx·∂lnφ/∂P={sum_dp} vs (Z−1)/P={vol}",
spec.rule
);
}
}
}
#[test]
fn wong_sandler_departure_enthalpy_matches_gibbs_helmholtz() {
let comps = vec![methanol(), water()];
let aij = van_laar_aij();
let vl = [40.7, 18.07];
let ge = GeSpec {
model: ActivityModel::VanLaar,
aij: &aij,
alpha: &[],
vl: &vl,
delta: &[],
};
let kij_ge = kij2(0.05);
let spec = MixtureSpec {
eos: CubicEos::PR1976,
rule: MixingRule::WongSandler,
components: &comps,
kij: &kij_ge,
ge: Some(ge),
};
let (t, p, x) = (360.0, 1500.0, [0.4, 0.6]);
for phase in [PhaseId::Vapor, PhaseId::Liquid] {
let dt = d_ln_phi_d_t(&spec, t, p, &x, phase).unwrap();
let sum_dt: f64 = (0..2).map(|i| x[i] * dt[i]).sum();
let gh = -crate::energy::h_departure_rt_mix(&spec, t, p, &x, phase).unwrap() / t;
let reldiff = ((sum_dt - gh) / gh.abs()).abs();
assert!(
reldiff < 1e-12,
"WS {phase:?} Gibbs–Helmholtz inconsistency returned: \
reldiff={reldiff:.2e} (dual={sum_dt}, gh={gh})"
);
}
}
}