use super::FlashError;
use super::bubble::bubble_pressure;
use super::system::SystemSpec;
use crate::eos::{CubicEos, LiquidModel, VaporModel};
use crate::mixing::MixingRule;
use crate::numerics::root_finding::brent_minimize;
use crate::types::Component;
#[derive(Debug, Clone)]
pub struct BubblePoint {
pub t: f64,
pub x1: f64,
pub p_exp: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct KijFit {
pub kij: f64,
pub sse: f64,
pub rmse: f64,
}
pub fn fit_kij(
eos: CubicEos,
components: &[Component],
data: &[BubblePoint],
k_lo: f64,
k_hi: f64,
tol: f64,
max_iter: usize,
) -> Result<KijFit, FlashError> {
if components.len() != 2 {
return Err(FlashError::Dimension(format!(
"kij regression is binary: components={}",
components.len()
)));
}
if data.is_empty() {
return Err(FlashError::Dimension("no data points".into()));
}
let sse = |k: f64| -> f64 {
let kij = vec![vec![0.0, k], vec![k, 0.0]];
let spec = SystemSpec {
components,
vapor: VaporModel::Cubic(eos),
liquid: LiquidModel::Cubic(eos),
mixing_rule: MixingRule::Classical,
kij: &kij,
aij: &[],
alpha: &[],
vl: &[],
delta: &[],
sat_models: &[],
ge_model: None,
};
let mut acc = 0.0;
for d in data {
let x = [d.x1, 1.0 - d.x1];
match bubble_pressure(&spec, d.t, &x, 1e-8, 200) {
Ok(r) => {
let e = r.value - d.p_exp;
acc += e * e;
}
Err(_) => acc += 1e12,
}
}
acc
};
let (kij, sse_min) = brent_minimize(sse, k_lo, k_hi, tol, max_iter)
.map_err(|e| FlashError::Thermo(format!("kij optimization failed: {e}")))?;
let rmse = (sse_min / data.len() as f64).sqrt();
Ok(KijFit {
kij,
sse: sse_min,
rmse,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn co2() -> Component {
Component {
name: "CO2".into(),
tc: 304.13,
pc: 7377.0,
omega: 0.2239,
psat_coeffs: vec![4.86, 1147.0, -8.0],
..Component::default()
}
}
fn n_butane() -> Component {
Component {
name: "n-butane".into(),
tc: 425.12,
pc: 3796.0,
omega: 0.200,
psat_coeffs: vec![4.35, 2277.0, -30.0],
..Component::default()
}
}
#[test]
fn recovers_a_known_kij() {
let comps = [co2(), n_butane()];
let eos = CubicEos::PR1976;
let k_true = 0.13;
let kij = vec![vec![0.0, k_true], vec![k_true, 0.0]];
let spec = SystemSpec {
components: &comps,
vapor: VaporModel::Cubic(eos),
liquid: LiquidModel::Cubic(eos),
mixing_rule: MixingRule::Classical,
kij: &kij,
aij: &[],
alpha: &[],
vl: &[],
delta: &[],
sat_models: &[],
ge_model: None,
};
let mut data = Vec::new();
for &x1 in &[0.2, 0.4, 0.5, 0.6, 0.8] {
let p = bubble_pressure(&spec, 310.0, &[x1, 1.0 - x1], 1e-9, 200)
.unwrap()
.value;
data.push(BubblePoint {
t: 310.0,
x1,
p_exp: p,
});
}
let fit = fit_kij(eos, &comps, &data, -0.05, 0.30, 1e-6, 100).unwrap();
assert!(
(fit.kij - k_true).abs() < 1e-3,
"recovered kij={} vs true {k_true}",
fit.kij
);
assert!(
fit.rmse < 1e-2,
"rmse={} should be ~0 for exact data",
fit.rmse
);
}
#[test]
fn nonzero_kij_beats_zero_for_nonideal_data() {
let comps = [co2(), n_butane()];
let eos = CubicEos::PR1976;
let k_true = 0.15;
let kij = vec![vec![0.0, k_true], vec![k_true, 0.0]];
let spec = SystemSpec {
components: &comps,
vapor: VaporModel::Cubic(eos),
liquid: LiquidModel::Cubic(eos),
mixing_rule: MixingRule::Classical,
kij: &kij,
aij: &[],
alpha: &[],
vl: &[],
delta: &[],
sat_models: &[],
ge_model: None,
};
let data: Vec<_> = [0.3, 0.5, 0.7]
.iter()
.map(|&x1| BubblePoint {
t: 310.0,
x1,
p_exp: bubble_pressure(&spec, 310.0, &[x1, 1.0 - x1], 1e-9, 200)
.unwrap()
.value,
})
.collect();
let fit = fit_kij(eos, &comps, &data, -0.05, 0.30, 1e-6, 100).unwrap();
assert!((fit.kij - k_true).abs() < 2e-3, "kij={}", fit.kij);
}
#[test]
fn rejects_non_binary() {
let comps = [co2()];
assert!(matches!(
fit_kij(
CubicEos::PR1976,
&comps,
&[BubblePoint {
t: 310.0,
x1: 0.5,
p_exp: 3000.0
}],
-0.1,
0.3,
1e-6,
100
),
Err(FlashError::Dimension(_))
));
}
}