#![allow(clippy::needless_range_loop)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(i32)]
#[cfg_attr(feature = "python", pyo3::pyclass(eq, eq_int))]
pub enum ActivityModel {
IdealSolution = 25,
VanLaar = 21,
Wilson = 22,
ScatchardHildebrand = 23,
Margules = 24,
Nrtl = 37,
}
use crate::types::R_GAS;
const R_CAL: f64 = R_GAS * 0.23898;
const CAL_TO_KJ_PER_KMOL: f64 = 4.18445;
#[allow(clippy::too_many_arguments)]
pub fn ln_gamma(
model: ActivityModel,
i: usize,
x: &[f64],
aij: &[Vec<f64>],
alpha: &[Vec<f64>],
vl: &[f64],
delta: &[f64],
temperature: f64,
) -> f64 {
let n = x.len();
match model {
ActivityModel::IdealSolution => 0.0,
ActivityModel::ScatchardHildebrand => {
let v_tot: f64 = (0..n).map(|k| x[k] * vl[k]).sum();
let delta_mix: f64 = (0..n).map(|k| x[k] * vl[k] * delta[k] / v_tot).sum();
let d = delta[i] - delta_mix;
vl[i] * d * d / (R_CAL * temperature)
}
ActivityModel::Margules => {
let (x1, x2) = (x[0], x[1]);
let (a12, a21) = (aij[0][1], aij[1][0]);
if i == 0 {
x2 * x2 * (a12 + 2.0 * (a21 - a12) * x1)
} else {
x1 * x1 * (a21 + 2.0 * (a12 - a21) * x2)
}
}
ActivityModel::VanLaar => {
let mut sum_ij = 0.0; let mut sum_ji = 0.0; for j in 0..n {
sum_ij += x[j] * aij[i][j];
sum_ji += x[j] * aij[j][i];
}
let denom = x[i] * sum_ij + (1.0 - x[i]) * sum_ji;
if denom.abs() < f64::EPSILON {
0.0
} else {
let ratio = sum_ji / denom;
sum_ij * (1.0 - x[i]) * ratio * ratio
}
}
ActivityModel::Wilson => {
let denom_i: f64 = (0..n)
.map(|j| x[j] * wilson_lambda(i, j, aij, vl, temperature))
.sum();
let nested: f64 = (0..n)
.map(|k| {
let dk: f64 = (0..n)
.map(|j| x[j] * wilson_lambda(k, j, aij, vl, temperature))
.sum();
x[k] * wilson_lambda(k, i, aij, vl, temperature) / dk
})
.sum();
1.0 - denom_i.ln() - nested
}
ActivityModel::Nrtl => {
let tau = |k: usize, j: usize| aij[k][j] / (R_GAS * temperature);
let g = |k: usize, j: usize| (-alpha[k][j] * tau(k, j)).exp();
let (s, c) = nrtl_column_sums(x, &tau, &g);
nrtl_ln_gamma_i(i, x, &tau, &g, &s, &c)
}
}
}
fn nrtl_column_sums(
x: &[f64],
tau: &dyn Fn(usize, usize) -> f64,
g: &dyn Fn(usize, usize) -> f64,
) -> (Vec<f64>, Vec<f64>) {
let n = x.len();
let mut s = vec![0.0; n];
let mut c = vec![0.0; n];
for j in 0..n {
for k in 0..n {
let gkj = g(k, j);
s[j] += x[k] * gkj;
c[j] += x[k] * tau(k, j) * gkj;
}
}
(s, c)
}
fn nrtl_ln_gamma_i(
i: usize,
x: &[f64],
tau: &dyn Fn(usize, usize) -> f64,
g: &dyn Fn(usize, usize) -> f64,
s: &[f64],
c: &[f64],
) -> f64 {
let n = x.len();
let mut acc = c[i] / s[i];
for j in 0..n {
acc += x[j] * g(i, j) / s[j] * (tau(i, j) - c[j] / s[j]);
}
acc
}
fn wilson_lambda(i: usize, j: usize, aij: &[Vec<f64>], vl: &[f64], t: f64) -> f64 {
if i == j {
1.0
} else {
(vl[j] / vl[i]) * (-aij[i][j] / (R_GAS * t)).exp()
}
}
#[derive(Debug, Clone)]
pub struct WilsonCache {
n: usize,
lambda: Vec<f64>,
}
impl WilsonCache {
pub fn new(aij: &[Vec<f64>], vl: &[f64], t: f64) -> Self {
let n = vl.len();
let mut lambda = vec![0.0; n * n];
for i in 0..n {
for j in 0..n {
lambda[i * n + j] = wilson_lambda(i, j, aij, vl, t);
}
}
Self { n, lambda }
}
#[inline]
pub fn lambda(&self, i: usize, j: usize) -> f64 {
self.lambda[i * self.n + j]
}
#[inline]
pub fn len(&self) -> usize {
self.n
}
#[inline]
pub fn is_empty(&self) -> bool {
self.n == 0
}
pub fn ln_gamma_all(&self, x: &[f64], out: &mut [f64]) {
let n = self.n;
let mut s: smallvec::SmallVec<[f64; 8]> = smallvec::smallvec![0.0; n];
for (k, sk) in s.iter_mut().enumerate() {
let mut acc = 0.0;
for j in 0..n {
acc += x[j] * self.lambda(k, j);
}
*sk = acc;
}
for i in 0..n {
let mut nested = 0.0;
for k in 0..n {
nested += x[k] * self.lambda(k, i) / s[k];
}
out[i] = 1.0 - s[i].ln() - nested;
}
}
}
#[derive(Debug, Clone)]
pub enum ActivityTpCache {
Wilson {
n: usize,
lambda: Vec<f64>,
},
Nrtl {
n: usize,
tau: Vec<f64>,
g: Vec<f64>,
tau_g: Vec<f64>,
},
None,
}
impl ActivityTpCache {
pub fn new(
model: ActivityModel,
aij: &[Vec<f64>],
alpha: &[Vec<f64>],
vl: &[f64],
t: f64,
) -> Self {
match model {
ActivityModel::Wilson => {
let n = vl.len();
let mut lambda = vec![0.0; n * n];
for i in 0..n {
for j in 0..n {
lambda[i * n + j] = wilson_lambda(i, j, aij, vl, t);
}
}
Self::Wilson { n, lambda }
}
ActivityModel::Nrtl => {
let n = aij.len();
let mut tau = vec![0.0; n * n];
let mut g = vec![0.0; n * n];
let mut tau_g = vec![0.0; n * n];
for i in 0..n {
for j in 0..n {
let idx = i * n + j;
let t_ij = aij[i][j] / (R_GAS * t);
let g_ij = (-alpha[i][j] * t_ij).exp();
tau[idx] = t_ij;
g[idx] = g_ij;
tau_g[idx] = t_ij * g_ij;
}
}
Self::Nrtl { n, tau, g, tau_g }
}
_ => Self::None,
}
}
#[allow(clippy::too_many_arguments)]
pub fn ln_gamma_all(
&self,
model: ActivityModel,
x: &[f64],
aij: &[Vec<f64>],
alpha: &[Vec<f64>],
vl: &[f64],
delta: &[f64],
temperature: f64,
out: &mut [f64],
) {
match self {
Self::Wilson { n, lambda } => {
let n = *n;
let mut s: smallvec::SmallVec<[f64; 8]> = smallvec::smallvec![0.0; n];
for (k, sk) in s.iter_mut().enumerate() {
let row = &lambda[k * n..(k + 1) * n];
*sk = row.iter().zip(x).map(|(&l, &xj)| xj * l).sum();
}
for (i, o) in out.iter_mut().enumerate() {
let mut nested = 0.0;
for k in 0..n {
nested += x[k] * lambda[k * n + i] / s[k];
}
*o = 1.0 - s[i].ln() - nested;
}
}
Self::Nrtl { n, tau, g, tau_g } => {
let n = *n;
let mut s: smallvec::SmallVec<[f64; 8]> = smallvec::smallvec![0.0; n];
let mut c: smallvec::SmallVec<[f64; 8]> = smallvec::smallvec![0.0; n];
for k in 0..n {
let xk = x[k];
let (grow, tgrow) = (&g[k * n..(k + 1) * n], &tau_g[k * n..(k + 1) * n]);
for j in 0..n {
s[j] += xk * grow[j];
c[j] += xk * tgrow[j];
}
}
for (i, o) in out.iter_mut().enumerate() {
let mut acc = c[i] / s[i];
let (grow, trow) = (&g[i * n..(i + 1) * n], &tau[i * n..(i + 1) * n]);
for j in 0..n {
acc += x[j] * grow[j] / s[j] * (trow[j] - c[j] / s[j]);
}
*o = acc;
}
}
Self::None => {
for (i, o) in out.iter_mut().enumerate() {
*o = ln_gamma(model, i, x, aij, alpha, vl, delta, temperature);
}
}
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn ln_gamma_all(
model: ActivityModel,
x: &[f64],
aij: &[Vec<f64>],
alpha: &[Vec<f64>],
vl: &[f64],
delta: &[f64],
temperature: f64,
out: &mut [f64],
) {
ActivityTpCache::new(model, aij, alpha, vl, temperature).ln_gamma_all(
model,
x,
aij,
alpha,
vl,
delta,
temperature,
out,
);
}
use num_dual::DualNum;
fn wilson_lambda_generic<D: DualNum<f64> + Copy>(
i: usize,
j: usize,
aij: &[Vec<f64>],
vl: &[f64],
t: D,
) -> D {
if i == j {
D::from(1.0)
} else {
((t * R_GAS).recip() * (-aij[i][j])).exp() * (vl[j] / vl[i])
}
}
#[allow(clippy::too_many_arguments)]
pub fn ln_gamma_all_generic<D: DualNum<f64> + Copy>(
model: ActivityModel,
x: &[D],
aij: &[Vec<f64>],
alpha: &[Vec<f64>],
vl: &[f64],
delta: &[f64],
temperature: D,
out: &mut [D],
) {
let n = x.len();
match model {
ActivityModel::IdealSolution => {
for o in out.iter_mut() {
*o = D::from(0.0);
}
}
ActivityModel::ScatchardHildebrand => {
let mut v_tot = D::from(0.0);
let mut num = D::from(0.0);
for k in 0..n {
v_tot += x[k] * vl[k];
num += x[k] * (vl[k] * delta[k]);
}
let delta_mix = num / v_tot;
for i in 0..n {
let d = -delta_mix + delta[i];
out[i] = d * d * ((temperature * R_CAL).recip() * vl[i]);
}
}
ActivityModel::Margules => {
let (x1, x2) = (x[0], x[1]);
let (a12, a21) = (aij[0][1], aij[1][0]);
out[0] = x2 * x2 * (x1 * (2.0 * (a21 - a12)) + a12);
out[1] = x1 * x1 * (x2 * (2.0 * (a12 - a21)) + a21);
}
ActivityModel::VanLaar => {
for i in 0..n {
let mut sum_ij = D::from(0.0); let mut sum_ji = D::from(0.0); for j in 0..n {
sum_ij += x[j] * aij[i][j];
sum_ji += x[j] * aij[j][i];
}
let one_minus_xi = -x[i] + 1.0;
let denom = x[i] * sum_ij + one_minus_xi * sum_ji;
if denom.re().abs() < f64::EPSILON {
out[i] = D::from(0.0);
} else {
let ratio = sum_ji / denom;
out[i] = sum_ij * one_minus_xi * ratio * ratio;
}
}
}
ActivityModel::Wilson => {
let mut s: smallvec::SmallVec<[D; 8]> = smallvec::smallvec![D::from(0.0); n];
for k in 0..n {
let mut acc = D::from(0.0);
for j in 0..n {
acc += x[j] * wilson_lambda_generic(k, j, aij, vl, temperature);
}
s[k] = acc;
}
for i in 0..n {
let mut nested = D::from(0.0);
for k in 0..n {
nested += x[k] * wilson_lambda_generic(k, i, aij, vl, temperature) / s[k];
}
out[i] = -s[i].ln() - nested + 1.0;
}
}
ActivityModel::Nrtl => {
let rt = temperature * R_GAS;
let tau = |k: usize, j: usize| -> D { rt.recip() * aij[k][j] };
let g = |k: usize, j: usize| -> D { (tau(k, j) * (-alpha[k][j])).exp() };
let mut s: smallvec::SmallVec<[D; 8]> = smallvec::smallvec![D::from(0.0); n];
let mut c: smallvec::SmallVec<[D; 8]> = smallvec::smallvec![D::from(0.0); n];
for j in 0..n {
for k in 0..n {
let gkj = g(k, j);
s[j] += x[k] * gkj;
c[j] += x[k] * tau(k, j) * gkj;
}
}
for i in 0..n {
let mut acc = c[i] / s[i];
for j in 0..n {
acc += x[j] * g(i, j) / s[j] * (tau(i, j) - c[j] / s[j]);
}
out[i] = acc;
}
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn excess_gibbs_rt_generic<D: DualNum<f64> + Copy>(
model: ActivityModel,
x: &[D],
aij: &[Vec<f64>],
alpha: &[Vec<f64>],
vl: &[f64],
delta: &[f64],
temperature: D,
) -> D {
let n = x.len();
let mut lng: smallvec::SmallVec<[D; 8]> = smallvec::smallvec![D::from(0.0); n];
ln_gamma_all_generic(model, x, aij, alpha, vl, delta, temperature, &mut lng);
let mut acc = D::from(0.0);
for i in 0..n {
acc += x[i] * lng[i];
}
acc
}
pub fn excess_gibbs(
model: ActivityModel,
x: &[f64],
aij: &[Vec<f64>],
alpha: &[Vec<f64>],
vl: &[f64],
delta: &[f64],
temperature: f64,
) -> f64 {
let n = x.len();
let sum: f64 = (0..n)
.map(|i| x[i] * ln_gamma(model, i, x, aij, alpha, vl, delta, temperature))
.sum();
R_GAS * temperature * sum
}
pub fn excess_enthalpy(
model: ActivityModel,
x: &[f64],
aij: &[Vec<f64>],
alpha: &[Vec<f64>],
vl: &[f64],
delta: &[f64],
temperature: f64,
) -> f64 {
let n = x.len();
match model {
ActivityModel::IdealSolution => 0.0,
ActivityModel::ScatchardHildebrand => {
let v_tot: f64 = (0..n).map(|k| x[k] * vl[k]).sum();
let delta_mix: f64 = (0..n).map(|k| x[k] * vl[k] * delta[k] / v_tot).sum();
let sum: f64 = (0..n)
.map(|j| {
let d = delta[j] - delta_mix;
x[j] * vl[j] * d * d
})
.sum();
sum * CAL_TO_KJ_PER_KMOL
}
ActivityModel::Margules | ActivityModel::VanLaar => {
excess_gibbs(model, x, aij, alpha, vl, delta, temperature)
}
ActivityModel::Wilson => (0..n)
.map(|j| {
let mut up = 0.0;
let mut down = 0.0;
for k in 0..n {
let lam = wilson_lambda(j, k, aij, vl, temperature);
down += x[k] * lam;
if k != j {
up += x[k] * aij[j][k] * lam;
}
}
x[j] * up / down
})
.sum(),
ActivityModel::Nrtl => {
use num_dual::Dual64;
let xd: smallvec::SmallVec<[Dual64; 8]> =
x.iter().map(|&xi| Dual64::from(xi)).collect();
let td = Dual64::new(temperature, 1.0);
let g_rt = excess_gibbs_rt_generic(model, &xd, aij, alpha, vl, delta, td);
-temperature * temperature * R_GAS * g_rt.eps
}
}
}
pub fn excess_cp(
model: ActivityModel,
x: &[f64],
aij: &[Vec<f64>],
alpha: &[Vec<f64>],
vl: &[f64],
delta: &[f64],
temperature: f64,
) -> f64 {
use num_dual::{Dual2_64, Dual64};
let n = x.len();
match model {
ActivityModel::IdealSolution | ActivityModel::ScatchardHildebrand => 0.0,
ActivityModel::Margules | ActivityModel::VanLaar => {
let xd: smallvec::SmallVec<[Dual64; 8]> =
x.iter().map(|&xi| Dual64::from(xi)).collect();
let td = Dual64::new(temperature, 1.0);
let g = excess_gibbs_rt_generic(model, &xd, aij, alpha, vl, delta, td);
R_GAS * (g.re + temperature * g.eps)
}
ActivityModel::Wilson => {
let td = Dual64::new(temperature, 1.0);
let mut h = Dual64::from(0.0);
for j in 0..n {
let mut up = Dual64::from(0.0);
let mut down = Dual64::from(0.0);
for k in 0..n {
let lam = wilson_lambda_generic(j, k, aij, vl, td);
down += lam * x[k];
if k != j {
up += lam * (x[k] * aij[j][k]);
}
}
h += up / down * x[j];
}
h.eps
}
ActivityModel::Nrtl => {
let xd: smallvec::SmallVec<[Dual2_64; 8]> =
x.iter().map(|&xi| Dual2_64::from(xi)).collect();
let td = Dual2_64::new(temperature, 1.0, 0.0);
let g = excess_gibbs_rt_generic(model, &xd, aij, alpha, vl, delta, td);
-R_GAS * (2.0 * temperature * g.v1 + temperature * temperature * g.v2)
}
}
}
pub fn excess_entropy(
model: ActivityModel,
x: &[f64],
aij: &[Vec<f64>],
alpha: &[Vec<f64>],
vl: &[f64],
delta: &[f64],
temperature: f64,
) -> f64 {
let he = excess_enthalpy(model, x, aij, alpha, vl, delta, temperature);
let ge = excess_gibbs(model, x, aij, alpha, vl, delta, temperature);
(he - ge) / temperature
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn activity_tp_cache_matches_direct_evaluation() {
let n = 4;
let x = [0.4, 0.25, 0.2, 0.15];
let aij: Vec<Vec<f64>> = (0..n)
.map(|i| {
(0..n)
.map(|j| {
if i == j {
0.0
} else {
250.0 * (j as f64 - i as f64)
}
})
.collect()
})
.collect();
let alpha: Vec<Vec<f64>> = (0..n)
.map(|i| (0..n).map(|j| if i == j { 0.0 } else { 0.3 }).collect())
.collect();
let vl = [37.9, 74.9, 116.1, 147.5];
let t = 350.0;
for model in [
ActivityModel::Wilson,
ActivityModel::Nrtl,
ActivityModel::VanLaar,
ActivityModel::IdealSolution,
] {
let cache = ActivityTpCache::new(model, &aij, &alpha, &vl, t);
let mut got = vec![0.0; n];
cache.ln_gamma_all(model, &x, &aij, &alpha, &vl, &[], t, &mut got);
for i in 0..n {
let want = ln_gamma(model, i, &x, &aij, &alpha, &vl, &[], t);
assert!(
(got[i] - want).abs() <= 1e-12 * want.abs().max(1.0),
"{model:?} comp {i}: cached={} direct={want}",
got[i]
);
}
}
}
#[test]
fn discriminant_values_match_legacy() {
assert_eq!(ActivityModel::VanLaar as i32, 21);
assert_eq!(ActivityModel::Wilson as i32, 22);
assert_eq!(ActivityModel::ScatchardHildebrand as i32, 23);
assert_eq!(ActivityModel::Margules as i32, 24);
assert_eq!(ActivityModel::IdealSolution as i32, 25);
assert_eq!(ActivityModel::Nrtl as i32, 37);
}
fn zeros(n: usize) -> Vec<Vec<f64>> {
vec![vec![0.0; n]; n]
}
#[test]
fn ideal_solution_is_unity_and_zero_excess() {
let x = [0.4, 0.6];
let aij = zeros(2);
for i in 0..2 {
assert_eq!(
ln_gamma(
ActivityModel::IdealSolution,
i,
&x,
&aij,
&[],
&[],
&[],
300.0
),
0.0
);
}
assert_eq!(
excess_gibbs(ActivityModel::IdealSolution, &x, &aij, &[], &[], &[], 300.0),
0.0
);
assert_eq!(
excess_enthalpy(ActivityModel::IdealSolution, &x, &aij, &[], &[], &[], 300.0),
0.0
);
}
#[test]
fn margules_matches_table_2_3_closed_form() {
let x = [0.3, 0.7];
let mut aij = zeros(2);
aij[0][1] = 0.5; aij[1][0] = 0.8; let g1 = ln_gamma(ActivityModel::Margules, 0, &x, &aij, &[], &[], &[], 300.0);
let g2 = ln_gamma(ActivityModel::Margules, 1, &x, &aij, &[], &[], &[], 300.0);
let e1 = x[1] * x[1] * (0.5 + 2.0 * (0.8 - 0.5) * x[0]);
let e2 = x[0] * x[0] * (0.8 + 2.0 * (0.5 - 0.8) * x[1]);
assert!((g1 - e1).abs() < 1e-12);
assert!((g2 - e2).abs() < 1e-12);
}
#[test]
fn van_laar_reduces_to_binary_closed_form() {
let x = [0.35, 0.65];
let (a12, a21) = (1.2, 0.9);
let mut aij = zeros(2);
aij[0][1] = a12;
aij[1][0] = a21;
let g1 = ln_gamma(ActivityModel::VanLaar, 0, &x, &aij, &[], &[], &[], 300.0);
let g2 = ln_gamma(ActivityModel::VanLaar, 1, &x, &aij, &[], &[], &[], 300.0);
let r1 = 1.0 + (a12 * x[0]) / (a21 * x[1]);
let r2 = 1.0 + (a21 * x[1]) / (a12 * x[0]);
assert!((g1 - a12 / (r1 * r1)).abs() < 1e-12);
assert!((g2 - a21 / (r2 * r2)).abs() < 1e-12);
}
#[test]
fn wilson_gamma_goes_to_one_for_zero_interaction() {
let x = [0.5, 0.5];
let aij = zeros(2);
let vl = [40.0, 40.0];
for i in 0..2 {
let g = ln_gamma(ActivityModel::Wilson, i, &x, &aij, &[], &vl, &[], 320.0);
assert!(g.abs() < 1e-12, "ln γ should vanish, got {g}");
}
}
#[test]
fn scatchard_gamma_unity_when_solubility_params_equal() {
let x = [0.3, 0.7];
let vl = [75.0, 110.0];
let delta = [9.0, 9.0];
for i in 0..2 {
let g = ln_gamma(
ActivityModel::ScatchardHildebrand,
i,
&x,
&zeros(2),
&[],
&vl,
&delta,
300.0,
);
assert!(g.abs() < 1e-12, "got {g}");
}
}
#[test]
fn gibbs_duhem_excess_gibbs_is_symmetric_endpoints() {
let aij = {
let mut a = zeros(2);
a[0][1] = 800.0;
a[1][0] = 1200.0;
a
};
let alpha = {
let mut a = zeros(2);
a[0][1] = 0.3;
a[1][0] = 0.3;
a
};
let vl = [58.0, 92.0];
let delta = [7.4, 9.2];
for model in [
ActivityModel::Margules,
ActivityModel::VanLaar,
ActivityModel::Wilson,
ActivityModel::ScatchardHildebrand,
ActivityModel::Nrtl,
] {
for x in [[1.0 - 1e-9, 1e-9], [1e-9, 1.0 - 1e-9]] {
let ge = excess_gibbs(model, &x, &aij, &alpha, &vl, &delta, 313.15);
assert!(ge.abs() < 1e-1, "{model:?}: Gᴱ at pure limit = {ge}");
}
}
}
#[test]
fn wilson_excess_enthalpy_matches_numerical_oracle() {
let x = [0.4, 0.6];
let mut aij = zeros(2);
aij[0][1] = 1500.0; aij[1][0] = 2600.0; let vl = [74.0, 18.0]; let t = 333.15;
let h = 1e-2;
let g_over_t =
|tt: f64| excess_gibbs(ActivityModel::Wilson, &x, &aij, &[], &vl, &[], tt) / tt;
let d = (g_over_t(t + h) - g_over_t(t - h)) / (2.0 * h);
let he_num = -t * t * d;
let he_ana = excess_enthalpy(ActivityModel::Wilson, &x, &aij, &[], &vl, &[], t);
assert!(
(he_ana - he_num).abs() < 1e-2 * he_num.abs().max(1.0),
"analytical {he_ana} vs numerical {he_num}"
);
}
#[test]
fn nrtl_excess_enthalpy_matches_numerical_oracle() {
let x = [0.35, 0.65];
let mut aij = zeros(2);
aij[0][1] = 2400.0; aij[1][0] = -1100.0; let mut alpha = zeros(2);
alpha[0][1] = 0.3;
alpha[1][0] = 0.3;
let t = 320.0;
let h = 1e-2;
let g_over_t =
|tt: f64| excess_gibbs(ActivityModel::Nrtl, &x, &aij, &alpha, &[], &[], tt) / tt;
let d = (g_over_t(t + h) - g_over_t(t - h)) / (2.0 * h);
let he_num = -t * t * d;
let he_ana = excess_enthalpy(ActivityModel::Nrtl, &x, &aij, &alpha, &[], &[], t);
assert!(
(he_ana - he_num).abs() < 1e-4 * he_num.abs().max(1.0),
"analytical {he_ana} vs numerical {he_num}"
);
assert!(he_ana.abs() > 1.0, "expected nonzero Hᴱ, got {he_ana}");
}
#[test]
fn nrtl_matches_binary_closed_form() {
let x = [0.4, 0.6];
let t = 330.0;
let mut aij = zeros(2);
aij[0][1] = 1800.0;
aij[1][0] = 900.0;
let mut alpha = zeros(2);
alpha[0][1] = 0.25;
alpha[1][0] = 0.25;
let tau12 = aij[0][1] / (R_GAS * t);
let tau21 = aij[1][0] / (R_GAS * t);
let g12 = (-alpha[0][1] * tau12).exp();
let g21 = (-alpha[1][0] * tau21).exp();
let (x1, x2) = (x[0], x[1]);
let d21 = x1 + x2 * g21;
let d12 = x2 + x1 * g12;
let e1 = x2 * x2 * (tau21 * (g21 / d21).powi(2) + tau12 * g12 / (d12 * d12));
let e2 = x1 * x1 * (tau12 * (g12 / d12).powi(2) + tau21 * g21 / (d21 * d21));
let g1 = ln_gamma(ActivityModel::Nrtl, 0, &x, &aij, &alpha, &[], &[], t);
let g2 = ln_gamma(ActivityModel::Nrtl, 1, &x, &aij, &alpha, &[], &[], t);
assert!((g1 - e1).abs() < 1e-12, "ln γ₁ {g1} vs {e1}");
assert!((g2 - e2).abs() < 1e-12, "ln γ₂ {g2} vs {e2}");
}
#[test]
fn margules_van_laar_excess_enthalpy_equals_gibbs() {
let x = [0.45, 0.55];
let mut aij = zeros(2);
aij[0][1] = 0.7;
aij[1][0] = 1.1;
for model in [ActivityModel::Margules, ActivityModel::VanLaar] {
let ge = excess_gibbs(model, &x, &aij, &[], &[], &[], 298.15);
let he = excess_enthalpy(model, &x, &aij, &[], &[], &[], 298.15);
let se = excess_entropy(model, &x, &aij, &[], &[], &[], 298.15);
assert!((he - ge).abs() < 1e-9);
assert!(se.abs() < 1e-9);
}
}
#[test]
fn wilson_cache_matches_per_component_ln_gamma() {
let x = [0.3, 0.45, 0.25];
let aij = vec![
vec![0.0, 1200.0, 800.0],
vec![-300.0, 0.0, 650.0],
vec![450.0, -150.0, 0.0],
];
let vl = [90.0, 116.0, 130.0];
let t = 340.0;
let cache = WilsonCache::new(&aij, &vl, t);
let mut got = [0.0; 3];
cache.ln_gamma_all(&x, &mut got);
for i in 0..3 {
let want = ln_gamma(ActivityModel::Wilson, i, &x, &aij, &[], &vl, &[], t);
assert!(
(got[i] - want).abs() < 1e-14,
"component {i}: cached={} reference={}",
got[i],
want
);
}
}
#[test]
fn ln_gamma_all_matches_per_component_for_every_model() {
let x = [0.4, 0.6];
let aij = vec![vec![0.0, 950.0], vec![620.0, 0.0]];
let alpha = vec![vec![0.0, 0.3], vec![0.3, 0.0]];
let vl = [90.0, 116.0];
let delta = [7.5, 8.2];
let t = 330.0;
for model in [
ActivityModel::IdealSolution,
ActivityModel::Margules,
ActivityModel::VanLaar,
ActivityModel::Wilson,
ActivityModel::ScatchardHildebrand,
ActivityModel::Nrtl,
] {
let mut got = [0.0; 2];
ln_gamma_all(model, &x, &aij, &alpha, &vl, &delta, t, &mut got);
for i in 0..2 {
let want = ln_gamma(model, i, &x, &aij, &alpha, &vl, &delta, t);
assert!(
(got[i] - want).abs() < 1e-14,
"{model:?} component {i}: {} vs {}",
got[i],
want
);
}
}
}
#[test]
fn nrtl_ternary_generic_matches_f64_per_component() {
let x = [0.25, 0.35, 0.40];
let aij = vec![
vec![0.0, 1800.0, -600.0],
vec![900.0, 0.0, 1500.0],
vec![400.0, -250.0, 0.0],
];
let alpha = vec![
vec![0.0, 0.30, 0.20],
vec![0.30, 0.0, 0.47],
vec![0.20, 0.47, 0.0],
];
let t = 345.0;
let mut got = [0.0f64; 3];
ln_gamma_all_generic(ActivityModel::Nrtl, &x, &aij, &alpha, &[], &[], t, &mut got);
for i in 0..3 {
let want = ln_gamma(ActivityModel::Nrtl, i, &x, &aij, &alpha, &[], &[], t);
assert!(
(got[i] - want).abs() < 1e-12,
"component {i}: generic={} f64={}",
got[i],
want
);
}
}
#[test]
fn excess_cp_matches_fd_of_shipped_excess_enthalpy_per_model() {
let x = [0.4, 0.6];
let vl = [74.0, 18.0];
let delta = [11.5, 23.4];
let mut a_energy = zeros(2); a_energy[0][1] = 1500.0;
a_energy[1][0] = 2600.0;
let mut a_dimless = zeros(2); a_dimless[0][1] = 0.7;
a_dimless[1][0] = 1.1;
let mut alpha = zeros(2);
alpha[0][1] = 0.3;
alpha[1][0] = 0.3;
let t = 333.15;
let h = 1e-2;
type Case<'a> = (
ActivityModel,
&'a [Vec<f64>],
&'a [Vec<f64>],
&'a [f64],
&'a [f64],
);
let cases: [Case<'_>; 6] = [
(ActivityModel::IdealSolution, &a_dimless, &[], &[], &[]),
(ActivityModel::ScatchardHildebrand, &[], &[], &vl, &delta),
(ActivityModel::Margules, &a_dimless, &[], &[], &[]),
(ActivityModel::VanLaar, &a_dimless, &[], &[], &[]),
(ActivityModel::Wilson, &a_energy, &[], &vl, &[]),
(ActivityModel::Nrtl, &a_energy, &alpha, &[], &[]),
];
for (model, aij, al, v, d) in cases {
let he = |tt: f64| excess_enthalpy(model, &x, aij, al, v, d, tt);
let fd = (he(t + h) - he(t - h)) / (2.0 * h);
let cp = excess_cp(model, &x, aij, al, v, d, t);
assert!(
(cp - fd).abs() < 1e-6 * fd.abs().max(1e-3),
"{model:?}: Cpᴱ {cp} vs FD {fd}"
);
}
assert_eq!(
excess_cp(
ActivityModel::IdealSolution,
&x,
&a_dimless,
&[],
&[],
&[],
t
),
0.0
);
assert_eq!(
excess_cp(
ActivityModel::ScatchardHildebrand,
&x,
&[],
&[],
&vl,
&delta,
t
),
0.0
);
assert!(excess_cp(ActivityModel::Nrtl, &x, &a_energy, &alpha, &[], &[], t).abs() > 1e-3);
assert!(excess_cp(ActivityModel::Wilson, &x, &a_energy, &[], &vl, &[], t).abs() > 1e-3);
let ge = excess_gibbs(ActivityModel::VanLaar, &x, &a_dimless, &[], &[], &[], t);
let cp = excess_cp(ActivityModel::VanLaar, &x, &a_dimless, &[], &[], &[], t);
assert!((cp - ge / t).abs() < 1e-9, "{cp} vs {}", ge / t);
}
}