Skip to content
This repository was archived by the owner on Dec 12, 2025. It is now read-only.

Commit d57eecc

Browse files
authored
Implement PC-SAFT for a binary mixture (#1)
1 parent 7bb7db9 commit d57eecc

File tree

4 files changed

+407
-79
lines changed

4 files changed

+407
-79
lines changed

src/eos/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ pub(crate) mod ideal_gas;
66
pub(crate) mod pcsaft;
77
pub use gc_pcsaft::{GcPcSaft, GcPcSaftParameters};
88
pub use ideal_gas::Joback;
9-
pub use pcsaft::PcSaftPure;
9+
pub use pcsaft::{PcSaftBinary, PcSaftPure};
1010

1111
/// Input for group-contribution models that allows for derivatives.
1212
pub struct ChemicalRecord<D> {

src/eos/pcsaft/mod.rs

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
pub(crate) mod pcsaft_pure;
1+
mod pcsaft_binary;
2+
mod pcsaft_pure;
3+
pub use pcsaft_binary::PcSaftBinary;
24
pub use pcsaft_pure::PcSaftPure;
35

46
const MAX_ETA: f64 = 0.5;
@@ -84,9 +86,9 @@ pub const CD: [[f64; 3]; 4] = [
8486

8587
#[cfg(test)]
8688
pub mod test {
87-
use super::PcSaftPure;
88-
use feos::pcsaft::{PcSaft, PcSaftParameters, PcSaftRecord};
89-
use feos_core::parameter::Parameter;
89+
use super::{PcSaftBinary, PcSaftPure};
90+
use feos::pcsaft::{PcSaft, PcSaftBinaryRecord, PcSaftParameters, PcSaftRecord};
91+
use feos_core::parameter::{Parameter, PureRecord};
9092
use feos_core::EosResult;
9193
use std::sync::Arc;
9294

@@ -119,6 +121,43 @@ pub mod test {
119121
Ok((PcSaftPure(params), eos))
120122
}
121123

124+
pub fn pcsaft_binary() -> EosResult<(PcSaftBinary, Arc<PcSaft>)> {
125+
let params = [
126+
[1.5, 3.4, 180.0, 2.2, 0.03, 2500., 2.0, 1.0],
127+
[2.5, 3.6, 250.0, 1.2, 0.015, 1500., 1.0, 2.0],
128+
];
129+
let kij = 0.15;
130+
let records = params
131+
.map(|p| {
132+
PureRecord::new(
133+
Default::default(),
134+
0.0,
135+
PcSaftRecord::new(
136+
p[0],
137+
p[1],
138+
p[2],
139+
Some(p[3]),
140+
None,
141+
Some(p[4]),
142+
Some(p[5]),
143+
Some(p[6]),
144+
Some(p[7]),
145+
None,
146+
None,
147+
None,
148+
None,
149+
),
150+
)
151+
})
152+
.to_vec();
153+
let params_feos = PcSaftParameters::new_binary(
154+
records,
155+
Some(PcSaftBinaryRecord::new(Some(kij), None, None)),
156+
)?;
157+
let eos = Arc::new(PcSaft::new(Arc::new(params_feos)));
158+
Ok((PcSaftBinary::new(params, kij), eos))
159+
}
160+
122161
#[cfg(feature = "parameter_fit")]
123162
pub fn pcsaft_non_assoc() -> EosResult<(PcSaftPure<4>, Arc<PcSaft>)> {
124163
let m = 1.5;

src/eos/pcsaft/pcsaft_binary.rs

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
use super::{A0, A1, A2, AD, B0, B1, B2, BD, CD, MAX_ETA};
2+
use crate::{ParametersAD, ResidualHelmholtzEnergy};
3+
use nalgebra::SVector;
4+
use num_dual::{jacobian, DualNum, DualVec};
5+
use std::f64::consts::{FRAC_PI_6, PI};
6+
7+
const PI_SQ_43: f64 = 4.0 / 3.0 * PI * PI;
8+
9+
pub struct PcSaftBinary {
10+
parameters: [[f64; 8]; 2],
11+
kij: f64,
12+
}
13+
14+
impl PcSaftBinary {
15+
pub fn new(parameters: [[f64; 8]; 2], kij: f64) -> Self {
16+
Self { parameters, kij }
17+
}
18+
}
19+
20+
impl ParametersAD for PcSaftBinary {
21+
type Parameters<D: DualNum<f64> + Copy> = ([[D; 8]; 2], D);
22+
23+
fn params<D: DualNum<f64> + Copy>(&self) -> Self::Parameters<D> {
24+
(self.parameters.map(|p| p.map(D::from)), D::from(self.kij))
25+
}
26+
27+
fn params_from_inner<D: DualNum<f64> + Copy, D2: DualNum<f64, Inner = D> + Copy>(
28+
&(parameters, kij): &Self::Parameters<D>,
29+
) -> Self::Parameters<D2> {
30+
(
31+
parameters.map(|p| p.map(D2::from_inner)),
32+
D2::from_inner(kij),
33+
)
34+
}
35+
}
36+
37+
impl ResidualHelmholtzEnergy<2> for PcSaftBinary {
38+
const RESIDUAL: &str = "PC-SAFT (binary)";
39+
40+
fn compute_max_density(&self, molefracs: &SVector<f64, 2>) -> f64 {
41+
let [p1, p2] = self.parameters;
42+
let [x1, x2] = molefracs.data.0[0];
43+
let [m1, sigma1, ..] = p1;
44+
let [m2, sigma2, ..] = p2;
45+
MAX_ETA / (FRAC_PI_6 * (m1 * sigma1.powi(3) * x1 + m2 * sigma2.powi(3) * x2))
46+
}
47+
48+
fn residual_helmholtz_energy_density<D: DualNum<f64> + Copy>(
49+
&([p1, p2], kij): &Self::Parameters<D>,
50+
temperature: D,
51+
partial_density: &SVector<D, 2>,
52+
) -> D {
53+
let [m1, sigma1, epsilon_k1, mu1, kappa_ab1, epsilon_k_ab1, na1, nb1] = p1;
54+
let [m2, sigma2, epsilon_k2, mu2, kappa_ab2, epsilon_k_ab2, na2, nb2] = p2;
55+
56+
// temperature dependent segment diameter
57+
let t_inv = temperature.recip();
58+
let diameter1 = sigma1 * (-(epsilon_k1 * (-3.) * t_inv).exp() * 0.12 + 1.0);
59+
let diameter2 = sigma2 * (-(epsilon_k2 * (-3.) * t_inv).exp() * 0.12 + 1.0);
60+
61+
// Packing fractions
62+
let [density1, density2] = partial_density.data.0[0];
63+
let density = density1 + density2;
64+
let [x1, x2] = [density1 / density, density2 / density];
65+
let zeta = [0, 1, 2, 3]
66+
.map(|k| (m1 * x1 * diameter1.powi(k) + m2 * x2 * diameter2.powi(k)) * FRAC_PI_6);
67+
let zeta_23 = zeta[2] / zeta[3];
68+
let [zeta0, zeta1, zeta2, zeta3] = zeta.map(|z| z * density);
69+
let frac_1mz3 = (-zeta3 + 1.0).recip();
70+
71+
let eta = zeta3;
72+
let eta2 = eta * eta;
73+
let eta3 = eta2 * eta;
74+
let eta_m1 = (-eta + 1.0).recip();
75+
let eta_m2 = eta_m1 * eta_m1;
76+
let etas = [
77+
D::one(),
78+
eta,
79+
eta2,
80+
eta3,
81+
eta2 * eta2,
82+
eta2 * eta3,
83+
eta3 * eta3,
84+
];
85+
86+
// hard sphere
87+
let hs = (zeta1 * zeta2 * frac_1mz3 * 3.0
88+
+ zeta2.powi(2) * frac_1mz3.powi(2) * zeta_23
89+
+ (zeta2 * zeta_23.powi(2) - zeta0) * (-zeta3).ln_1p())
90+
/ std::f64::consts::FRAC_PI_6;
91+
92+
// hard chain
93+
let c = zeta2 * frac_1mz3 * frac_1mz3;
94+
let g_hs = [diameter1, diameter2]
95+
.map(|d| frac_1mz3 + d * c * 1.5 - (d * c).powi(2) * (zeta3 - 1.0) * 0.5);
96+
let hc = -(density1 * (m1 - 1.0) * g_hs[0].ln() + density2 * (m2 - 1.0) * g_hs[1].ln());
97+
98+
// binary interactions
99+
let m = x1 * m1 + x2 * m2;
100+
let epsilon_k11 = epsilon_k1 * t_inv;
101+
let epsilon_k12 = (epsilon_k1 * epsilon_k2).sqrt() * t_inv;
102+
let epsilon_k22 = epsilon_k2 * t_inv;
103+
let sigma11_3 = sigma1.powi(3);
104+
let sigma12_3 = ((sigma1 + sigma2) * 0.5).powi(3);
105+
let sigma22_3 = sigma2.powi(3);
106+
let d11 = density1 * density1 * m1 * m1 * epsilon_k11 * sigma11_3;
107+
let d12 = density1 * density2 * m1 * m2 * epsilon_k12 * sigma12_3 * (-kij + 1.0);
108+
let d22 = density2 * density2 * m2 * m2 * epsilon_k22 * sigma22_3;
109+
let rho1mix = d11 + d12 * 2.0 + d22;
110+
let rho2mix =
111+
d11 * epsilon_k11 + d12 * epsilon_k12 * (-kij + 1.0) * 2.0 + d22 * epsilon_k22;
112+
113+
// I1, I2 and C1
114+
let mm1 = (m - 1.0) / m;
115+
let mm2 = (m - 2.0) / m;
116+
let mut i1 = D::zero();
117+
let mut i2 = D::zero();
118+
for i in 0..7 {
119+
i1 += (mm1 * (mm2 * A2[i] + A1[i]) + A0[i]) * etas[i];
120+
i2 += (mm1 * (mm2 * B2[i] + B1[i]) + B0[i]) * etas[i];
121+
}
122+
let c1 = (m * (eta * 8.0 - eta2 * 2.0) * eta_m2 * eta_m2 + 1.0
123+
- (m - 1.0) * (eta * 20.0 - eta2 * 27.0 + eta2 * eta * 12.0 - eta2 * eta2 * 2.0)
124+
/ ((eta - 1.0) * (eta - 2.0)).powi(2))
125+
.recip();
126+
127+
// dispersion
128+
let disp = (-rho1mix * i1 * 2.0 - rho2mix * m * c1 * i2) * PI;
129+
130+
// dipoles
131+
let mu_term1 = mu1 * mu1 / (m1 * temperature * 1.380649e-4) * density1;
132+
let mu_term2 = mu2 * mu2 / (m2 * temperature * 1.380649e-4) * density2;
133+
let sigma111 = sigma11_3;
134+
let sigma112 = sigma1 * ((sigma1 + sigma2) * 0.5).powi(2);
135+
let sigma122 = sigma2 * ((sigma1 + sigma2) * 0.5).powi(2);
136+
let sigma222 = sigma22_3;
137+
138+
let m11_dipole = if m1.re() > 2.0 { D::from(2.0) } else { m1 };
139+
let m22_dipole = if m2.re() > 2.0 { D::from(2.0) } else { m2 };
140+
let m12_dipole = (m11_dipole * m22_dipole).sqrt();
141+
let [j2_11, j2_12, j2_22] = [
142+
(m11_dipole, epsilon_k11),
143+
(m12_dipole, epsilon_k12),
144+
(m22_dipole, epsilon_k22),
145+
]
146+
.map(|(m, e)| {
147+
let m1 = (m - 1.0) / m;
148+
let m2 = m1 * (m - 2.0) / m;
149+
let mut j2 = D::zero();
150+
for i in 0..5 {
151+
let a = m2 * AD[i][2] + m1 * AD[i][1] + AD[i][0];
152+
let b = m2 * BD[i][2] + m1 * BD[i][1] + BD[i][0];
153+
j2 += (a + b * e) * etas[i];
154+
}
155+
j2
156+
});
157+
let m112_dipole = (m11_dipole * m11_dipole * m22_dipole).cbrt();
158+
let m122_dipole = (m11_dipole * m22_dipole * m22_dipole).cbrt();
159+
let [j3_111, j3_112, j3_122, j3_222] = [m11_dipole, m112_dipole, m122_dipole, m22_dipole]
160+
.map(|m| {
161+
let m1 = (m - 1.0) / m;
162+
let m2 = m1 * (m - 2.0) / m;
163+
let mut j3 = D::zero();
164+
for i in 0..4 {
165+
j3 += (m2 * CD[i][2] + m1 * CD[i][1] + CD[i][0]) * etas[i];
166+
}
167+
j3
168+
});
169+
170+
let phi2 = (mu_term1 * mu_term1 / sigma11_3 * j2_11
171+
+ mu_term1 * mu_term2 / sigma12_3 * j2_12 * 2.0
172+
+ mu_term2 * mu_term2 / sigma22_3 * j2_22)
173+
* (-PI);
174+
let phi3 = (mu_term1.powi(3) / sigma111 * j3_111
175+
+ mu_term1.powi(2) * mu_term2 / sigma112 * j3_112 * 3.0
176+
+ mu_term1 * mu_term2.powi(2) / sigma122 * j3_122 * 3.0
177+
+ mu_term2.powi(3) / sigma222 * j3_222)
178+
* (-PI_SQ_43);
179+
180+
let mut polar = phi2 * phi2 / (phi2 - phi3);
181+
if polar.re().is_nan() {
182+
polar = phi2
183+
}
184+
185+
// association
186+
let d11 = diameter1 * 0.5;
187+
let d12 = diameter1 * diameter2 / (diameter1 + diameter2);
188+
let d22 = diameter2 * 0.5;
189+
let [k11, k12, k22] = [d11, d12, d22].map(|d| d * zeta2 * frac_1mz3);
190+
let s11 = sigma11_3 * kappa_ab1;
191+
let mut s12 = (sigma11_3 * sigma22_3 * kappa_ab1 * kappa_ab2).sqrt();
192+
if s12.re() == 0.0 {
193+
s12 = D::zero();
194+
}
195+
let s22 = sigma22_3 * kappa_ab2;
196+
let e11 = (epsilon_k_ab1 * t_inv).exp() - 1.0;
197+
let e12 = ((epsilon_k_ab1 + epsilon_k_ab2) * 0.5 * t_inv).exp() - 1.0;
198+
let e22 = (epsilon_k_ab2 * t_inv).exp() - 1.0;
199+
let d11 = frac_1mz3 * (k11 * (k11 * 2.0 + 3.0) + 1.0) * s11 * e11;
200+
let d12 = frac_1mz3 * (k12 * (k12 * 2.0 + 3.0) + 1.0) * s12 * e12;
201+
let d22 = frac_1mz3 * (k22 * (k22 * 2.0 + 3.0) + 1.0) * s22 * e22;
202+
let rhoa1 = density1 * na1;
203+
let rhob1 = density1 * nb1;
204+
let rhoa2 = density2 * na2;
205+
let rhob2 = density2 * nb2;
206+
207+
let [mut xa1, mut xa2] = [D::from(0.2); 2];
208+
for _ in 0..50 {
209+
let (g, j) = jacobian(
210+
|x| {
211+
let [xa1, xa2] = x.data.0[0];
212+
let xb1_i = xa1 * DualVec::from_re(rhoa1 * d11)
213+
+ xa2 * DualVec::from_re(rhoa2 * d12)
214+
+ 1.0;
215+
let xb2_i = xa1 * DualVec::from_re(rhoa1 * d12)
216+
+ xa2 * DualVec::from_re(rhoa2 * d22)
217+
+ 1.0;
218+
219+
let f1 = xa1 - 1.0
220+
+ xa1 / xb1_i * DualVec::from_re(rhob1 * d11)
221+
+ xa1 / xb2_i * DualVec::from_re(rhob2 * d12);
222+
let f2 = xa2 - 1.0
223+
+ xa2 / xb1_i * DualVec::from_re(rhob1 * d12)
224+
+ xa2 / xb2_i * DualVec::from_re(rhob2 * d22);
225+
226+
SVector::from([f1, f2])
227+
},
228+
SVector::from([xa1, xa2]),
229+
);
230+
231+
let [g1, g2] = g.data.0[0];
232+
let [[j11, j12], [j21, j22]] = j.data.0;
233+
let det = j11 * j22 - j12 * j21;
234+
235+
let delta_xa1 = (j22 * g1 - j12 * g2) / det;
236+
let delta_xa2 = (-j21 * g1 + j11 * g2) / det;
237+
if delta_xa1.re() < xa1.re() * 0.8 {
238+
xa1 -= delta_xa1;
239+
} else {
240+
xa1 *= 0.2;
241+
}
242+
if delta_xa2.re() < xa2.re() * 0.8 {
243+
xa2 -= delta_xa2;
244+
} else {
245+
xa2 *= 0.2;
246+
}
247+
248+
if g1.re().abs() < 1e-15 && g2.re().abs() < 1e-15 {
249+
break;
250+
}
251+
}
252+
253+
let xb1 = (xa1 * rhoa1 * d11 + xa2 * rhoa2 * d12 + 1.0).recip();
254+
let xb2 = (xa1 * rhoa1 * d12 + xa2 * rhoa2 * d22 + 1.0).recip();
255+
let f = |x: D| x.ln() - x * 0.5 + 0.5;
256+
let assoc = rhoa1 * f(xa1) + rhoa2 * f(xa2) + rhob1 * f(xb1) + rhob2 * f(xb2);
257+
258+
(hs + hc + disp + polar + assoc) * temperature
259+
}
260+
}
261+
262+
#[cfg(test)]
263+
pub mod test {
264+
use super::{PcSaftBinary, ResidualHelmholtzEnergy};
265+
use crate::eos::pcsaft::test::pcsaft_binary;
266+
use approx::assert_relative_eq;
267+
use feos_core::{Contributions::Total, EosResult, ReferenceSystem, State};
268+
use nalgebra::SVector;
269+
use ndarray::arr1;
270+
use quantity::{KELVIN, KILO, METER, MOL};
271+
272+
#[test]
273+
fn test_pcsaft_binary() -> EosResult<()> {
274+
let (pcsaft, eos) = pcsaft_binary()?;
275+
let pcsaft = (pcsaft.parameters, pcsaft.kij);
276+
277+
let temperature = 300.0 * KELVIN;
278+
let volume = 2.3 * METER * METER * METER;
279+
let moles = arr1(&[1.3, 2.5]) * KILO * MOL;
280+
281+
let state = State::new_nvt(&eos, temperature, volume, &moles)?;
282+
let a_feos = state.residual_molar_helmholtz_energy();
283+
let mu_feos = state.residual_chemical_potential();
284+
let p_feos = state.pressure(Total);
285+
let s_feos = state.residual_molar_entropy();
286+
let h_feos = state.residual_molar_enthalpy();
287+
288+
let total_moles = moles.sum();
289+
let t = temperature.to_reduced();
290+
let v = (volume / total_moles).to_reduced();
291+
let x = SVector::from_fn(|i, _| moles.get(i).convert_into(total_moles));
292+
let a_ad = PcSaftBinary::residual_molar_helmholtz_energy(&pcsaft, t, v, &x);
293+
let mu_ad = PcSaftBinary::residual_chemical_potential(&pcsaft, t, v, &x);
294+
let p_ad = PcSaftBinary::pressure(&pcsaft, t, v, &x);
295+
let s_ad = PcSaftBinary::residual_molar_entropy(&pcsaft, t, v, &x);
296+
let h_ad = PcSaftBinary::residual_molar_enthalpy(&pcsaft, t, v, &x);
297+
298+
for (s, c) in state.residual_helmholtz_energy_contributions() {
299+
println!("{s:20} {}", (c / state.volume).to_reduced());
300+
}
301+
302+
println!("\nMolar Helmholtz energy:\n{}", a_feos.to_reduced(),);
303+
println!("{a_ad}");
304+
assert_relative_eq!(a_feos.to_reduced(), a_ad, max_relative = 1e-14);
305+
306+
println!("\nChemical potential:\n{}", mu_feos.get(0).to_reduced());
307+
println!("{}", mu_ad[0]);
308+
assert_relative_eq!(mu_feos.get(0).to_reduced(), mu_ad[0], max_relative = 1e-14);
309+
310+
println!("\nPressure:\n{}", p_feos.to_reduced());
311+
println!("{p_ad}");
312+
assert_relative_eq!(p_feos.to_reduced(), p_ad, max_relative = 1e-14);
313+
314+
println!("\nMolar entropy:\n{}", s_feos.to_reduced());
315+
println!("{s_ad}");
316+
assert_relative_eq!(s_feos.to_reduced(), s_ad, max_relative = 1e-14);
317+
318+
println!("\nMolar enthalpy:\n{}", h_feos.to_reduced());
319+
println!("{h_ad}");
320+
assert_relative_eq!(h_feos.to_reduced(), h_ad, max_relative = 1e-14);
321+
322+
Ok(())
323+
}
324+
}

0 commit comments

Comments
 (0)