use nalgebra::{DMatrix, DVector};
use super::FlashError;
use super::init::wilson_k_values;
use super::system::{SystemSpec, min_gibbs_ln_phi};
#[derive(Debug, Clone, PartialEq)]
pub struct EnvelopePoint {
pub t: f64,
pub p: f64,
pub k: Vec<f64>,
pub incipient: Vec<f64>,
}
fn residual(
spec: &SystemSpec,
z: &[f64],
x: &DVector<f64>,
s_idx: usize,
s_val: f64,
) -> Result<DVector<f64>, FlashError> {
let n = z.len();
let k: Vec<f64> = (0..n).map(|i| x[i].exp()).collect();
let t = x[n].exp();
let p = x[n + 1].exp();
let w_un: Vec<f64> = (0..n).map(|i| k[i] * z[i]).collect();
let sw: f64 = w_un.iter().sum();
let w: Vec<f64> = w_un.iter().map(|wi| wi / sw).collect();
let ln_phi_w = min_gibbs_ln_phi(spec, t, p, &w)?;
let ln_phi_z = min_gibbs_ln_phi(spec, t, p, z)?;
let mut g = DVector::zeros(n + 2);
for i in 0..n {
g[i] = x[i] + ln_phi_w[i] - ln_phi_z[i];
}
g[n] = (0..n).map(|i| z[i] * (k[i] - 1.0)).sum();
g[n + 1] = x[s_idx] - s_val;
Ok(g)
}
fn jacobian(
spec: &SystemSpec,
z: &[f64],
x: &DVector<f64>,
s_idx: usize,
s_val: f64,
) -> Result<DMatrix<f64>, FlashError> {
let m = x.len();
let g0 = residual(spec, z, x, s_idx, s_val)?;
let mut j = DMatrix::zeros(m, m);
for col in 0..m {
let h = 1e-6 * x[col].abs().max(1.0);
let mut xp = x.clone();
xp[col] += h;
let gp = residual(spec, z, &xp, s_idx, s_val)?;
for row in 0..m {
j[(row, col)] = (gp[row] - g0[row]) / h;
}
}
Ok(j)
}
fn correct(
spec: &SystemSpec,
z: &[f64],
mut x: DVector<f64>,
s_idx: usize,
s_val: f64,
tol: f64,
max_iter: usize,
) -> Result<DVector<f64>, FlashError> {
for iter in 0..max_iter {
let g = residual(spec, z, &x, s_idx, s_val)?;
if g.amax() < tol {
return Ok(x);
}
let j = jacobian(spec, z, &x, s_idx, s_val)?;
let dx = j.lu().solve(&(-&g)).ok_or(FlashError::NoConvergence {
what: "envelope corrector (singular Jacobian)",
iters: iter,
residual: g.amax(),
})?;
let scale = {
let max_step = dx.amax();
if max_step > 0.3 { 0.3 / max_step } else { 1.0 }
};
x += scale * dx;
if iter + 1 == max_iter {
let g = residual(spec, z, &x, s_idx, s_val)?;
return Err(FlashError::NoConvergence {
what: "envelope corrector",
iters: max_iter,
residual: g.amax(),
});
}
}
unreachable!("loop returns via convergence or NoConvergence")
}
pub fn trace_envelope(
spec: &SystemSpec,
z: &[f64],
p_start: f64,
max_points: usize,
) -> Result<Vec<EnvelopePoint>, FlashError> {
let n = z.len();
if z.len() != spec.n() {
return Err(FlashError::Dimension(format!(
"components={}, z={}",
spec.n(),
n
)));
}
if !matches!(spec.liquid, crate::eos::LiquidModel::Cubic(_)) {
return Err(FlashError::Unsupported(
"phase envelope requires a cubic (φ-φ) system".into(),
));
}
let t0 = {
let tavg: f64 = z.iter().zip(spec.components).map(|(zi, c)| zi * c.tc).sum();
0.7 * tavg
};
let mut x = DVector::zeros(n + 2);
let kw = wilson_k_values(spec.components, t0, p_start);
for i in 0..n {
x[i] = kw[i].ln();
}
x[n] = t0.ln();
x[n + 1] = p_start.ln();
let s_idx0 = n + 1;
let mut x = correct(spec, z, x, s_idx0, p_start.ln(), 1e-9, 100)?;
let mut points = Vec::with_capacity(max_points);
let record = |x: &DVector<f64>| -> EnvelopePoint {
let k: Vec<f64> = (0..n).map(|i| x[i].exp()).collect();
let t = x[n].exp();
let p = x[n + 1].exp();
let w_un: Vec<f64> = (0..n).map(|i| k[i] * z[i]).collect();
let sw: f64 = w_un.iter().sum();
EnvelopePoint {
t,
p,
k: k.clone(),
incipient: w_un.iter().map(|wi| wi / sw).collect(),
}
};
points.push(record(&x));
let mut s_idx = n; let mut ds = 0.05; let mut x_prev = x.clone();
for _ in 1..max_points {
let j = jacobian(spec, z, &x, s_idx, x[s_idx])?;
let mut rhs = DVector::zeros(n + 2);
rhs[n + 1] = 1.0; let tangent = match j.lu().solve(&rhs) {
Some(t) => t,
None => break,
};
let mut best = n; let mut best_mag = 0.0;
for (idx, item) in tangent.iter().enumerate().take(n + 2) {
if item.abs() > best_mag {
best_mag = item.abs();
best = idx;
}
}
s_idx = best;
let step_dir = tangent[s_idx].signum();
let s_val = x[s_idx] + ds * step_dir;
let scale = ds * step_dir / tangent[s_idx];
let x_pred = &x + scale * &tangent;
match correct(spec, z, x_pred, s_idx, s_val, 1e-9, 60) {
Ok(x_new) => {
let moved = (&x_new - &x).amax();
if !x_new.iter().all(|v| v.is_finite()) || moved < 1e-9 {
break;
}
x_prev = x.clone();
x = x_new;
points.push(record(&x));
ds = (ds * 1.1).min(0.15);
}
Err(_) => {
ds *= 0.5;
x = x_prev.clone();
if ds < 1e-3 {
break;
}
}
}
}
Ok(points)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::eos::{CubicEos, LiquidModel, VaporModel};
use crate::mixing::MixingRule;
use crate::types::Component;
fn methane() -> Component {
Component {
name: "methane".into(),
tc: 190.564,
pc: 4599.0,
omega: 0.0115,
..Component::default()
}
}
fn ethane() -> Component {
Component {
name: "ethane".into(),
tc: 305.32,
pc: 4872.0,
omega: 0.0995,
..Component::default()
}
}
fn pr(components: &[Component]) -> SystemSpec<'_> {
SystemSpec {
components,
vapor: VaporModel::Cubic(CubicEos::PR1976),
liquid: LiquidModel::Cubic(CubicEos::PR1976),
mixing_rule: MixingRule::Classical,
kij: &[],
aij: &[],
alpha: &[],
vl: &[],
delta: &[],
sat_models: &[],
ge_model: None,
}
}
#[test]
fn traces_a_multi_point_envelope() {
let comps = [methane(), ethane()];
let spec = pr(&comps);
let env = trace_envelope(&spec, &[0.5, 0.5], 300.0, 40).unwrap();
assert!(env.len() >= 5, "only traced {} points", env.len());
for pt in &env {
assert!(pt.t > 0.0 && pt.p > 0.0, "non-physical point {pt:?}");
assert!(pt.k.iter().all(|k| k.is_finite() && *k > 0.0));
assert!((pt.incipient.iter().sum::<f64>() - 1.0).abs() < 1e-8);
}
}
#[test]
fn envelope_points_satisfy_equal_fugacity() {
let comps = [methane(), ethane()];
let spec = pr(&comps);
let z = [0.4, 0.6];
let env = trace_envelope(&spec, &z, 300.0, 20).unwrap();
for pt in &env {
let ln_phi_z = min_gibbs_ln_phi(&spec, pt.t, pt.p, &z).unwrap();
let ln_phi_w = min_gibbs_ln_phi(&spec, pt.t, pt.p, &pt.incipient).unwrap();
for i in 0..2 {
let g = pt.k[i].ln() + ln_phi_w[i] - ln_phi_z[i];
assert!(g.abs() < 1e-6, "equal-fugacity residual {g} at {pt:?}");
}
}
}
#[test]
fn envelope_pressure_climbs_from_the_seed() {
let comps = [methane(), ethane()];
let spec = pr(&comps);
let env = trace_envelope(&spec, &[0.5, 0.5], 300.0, 40).unwrap();
let p_max = env.iter().map(|p| p.p).fold(0.0_f64, f64::max);
assert!(
p_max > 2.0 * 300.0,
"envelope P_max {p_max} did not climb above the seed"
);
}
#[test]
fn rejects_non_cubic_system() {
let comps = [methane(), ethane()];
let mut spec = pr(&comps);
spec.liquid = LiquidModel::IdealSolution;
assert!(matches!(
trace_envelope(&spec, &[0.5, 0.5], 300.0, 10),
Err(FlashError::Unsupported(_))
));
}
}