use smallvec::SmallVec;
use super::FlashError;
use super::init::wilson_k_values;
use super::system::{SystemSpec, SystemTpCache, min_gibbs_ln_phi_cached_into};
type WorkVec = SmallVec<[f64; 8]>;
struct TrialWorkspace {
w: WorkVec,
wn: WorkVec,
ln_phi: WorkVec,
}
impl TrialWorkspace {
fn new(n: usize) -> Self {
Self {
w: smallvec::smallvec![0.0; n],
wn: smallvec::smallvec![0.0; n],
ln_phi: smallvec::smallvec![0.0; n],
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Stability {
Stable,
Unstable { trial_k: Vec<f64>, tpd: f64 },
}
const TPD_TOL: f64 = 1e-9;
const TRIVIAL_TOL: f64 = 1e-4;
const NEG_TPD_TOL: f64 = 1e-8;
fn run_trial(
spec: &SystemSpec,
cache: &SystemTpCache,
d: &[f64],
w0: &[f64],
max_iter: usize,
ws: &mut TrialWorkspace,
) -> Result<(f64, bool), FlashError> {
let n = spec.n();
ws.w.copy_from_slice(w0);
let mut converged = false;
for _ in 0..max_iter {
normalize_into(&ws.w, &mut ws.wn);
min_gibbs_ln_phi_cached_into(spec, cache, &ws.wn, &mut ws.ln_phi)?;
let mut max_step = 0.0_f64;
for ((wi, &di), &lnphi) in ws.w.iter_mut().zip(d.iter()).zip(ws.ln_phi.iter()) {
let w_new = (di - lnphi).exp();
max_step = max_step.max((w_new - *wi).abs());
*wi = w_new;
}
if max_step < TPD_TOL {
converged = true;
break;
}
}
normalize_into(&ws.w, &mut ws.wn);
min_gibbs_ln_phi_cached_into(spec, cache, &ws.wn, &mut ws.ln_phi)?;
let tm: f64 = 1.0
+ (0..n)
.filter(|&i| ws.w[i] > 0.0)
.map(|i| ws.w[i] * (ws.w[i].ln() + ws.ln_phi[i] - d[i] - 1.0))
.sum::<f64>();
Ok((tm, converged))
}
#[inline]
fn normalize_into(w: &[f64], out: &mut [f64]) {
let inv = w.iter().sum::<f64>().recip();
for (dst, &wi) in out.iter_mut().zip(w) {
*dst = wi * inv;
}
}
pub fn stability_analysis(
spec: &SystemSpec,
t: f64,
p: f64,
z: &[f64],
max_iter: usize,
) -> Result<Stability, FlashError> {
let n = spec.n();
if z.len() != n {
return Err(FlashError::Dimension(format!(
"components={n}, z={}",
z.len()
)));
}
if let Some(i) = z.iter().position(|&zi| zi <= 0.0 || !zi.is_finite()) {
return Err(FlashError::InvalidInput(format!(
"stability analysis needs every zᵢ > 0; z[{i}]={}",
z[i]
)));
}
let cache = SystemTpCache::new(spec, t, p)?;
let mut ws = TrialWorkspace::new(n);
let mut d: WorkVec = smallvec::smallvec![0.0; n];
min_gibbs_ln_phi_cached_into(spec, &cache, z, &mut d)?;
for i in 0..n {
d[i] += z[i].ln();
}
let kw = wilson_k_values(spec.components, t, p);
let mut seed: WorkVec = smallvec::smallvec![0.0; n];
let mut best: Option<(f64, Vec<f64>)> = None;
for vapor_like in [true, false] {
for i in 0..n {
seed[i] = if vapor_like {
z[i] * kw[i]
} else {
z[i] / kw[i]
};
}
let (tm, converged) = run_trial(spec, &cache, &d, &seed, max_iter, &mut ws)?;
let wn = &ws.wn;
let trivial = (0..n).all(|i| (wn[i] - z[i]).abs() < TRIVIAL_TOL);
if converged && !trivial && tm < -NEG_TPD_TOL {
let trial_k: Vec<f64> = (0..n).map(|i| wn[i] / z[i]).collect();
if best.as_ref().is_none_or(|(btm, _)| tm < *btm) {
best = Some((tm, trial_k));
}
}
}
Ok(match best {
Some((tpd, trial_k)) => Stability::Unstable { trial_k, tpd },
None => Stability::Stable,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::eos::{CubicEos, LiquidModel, VaporModel};
use crate::mixing::MixingRule;
use crate::types::Component;
fn n_butane() -> Component {
Component {
name: "n-butane".into(),
tc: 425.12,
pc: 3796.0,
omega: 0.200,
..Component::default()
}
}
fn n_decane() -> Component {
Component {
name: "n-decane".into(),
tc: 617.7,
pc: 2110.0,
omega: 0.4884,
..Component::default()
}
}
fn rks(components: &[Component]) -> SystemSpec<'_> {
SystemSpec {
components,
vapor: VaporModel::Cubic(CubicEos::RKS1972),
liquid: LiquidModel::Cubic(CubicEos::RKS1972),
mixing_rule: MixingRule::Classical,
kij: &[],
aij: &[],
alpha: &[],
vl: &[],
delta: &[],
sat_models: &[],
ge_model: None,
}
}
#[test]
fn unstable_feed_detected_and_gives_warm_start() {
let comps = [n_butane(), n_decane()];
let spec = rks(&comps);
let res = stability_analysis(&spec, 450.0, 800.0, &[0.5, 0.5], 100).unwrap();
match res {
Stability::Unstable { trial_k, tpd } => {
assert!(tpd < 0.0, "TPD should be negative, got {tpd}");
assert!(trial_k[0] != trial_k[1]);
assert!(trial_k.iter().all(|k| k.is_finite() && *k > 0.0));
}
Stability::Stable => panic!("expected unstable two-phase feed"),
}
}
#[test]
fn stable_single_phase_high_pressure() {
let comps = [n_butane(), n_decane()];
let spec = rks(&comps);
let res = stability_analysis(&spec, 350.0, 30000.0, &[0.5, 0.5], 100).unwrap();
assert_eq!(res, Stability::Stable);
}
#[test]
fn stability_agrees_with_flash_phase_count() {
use crate::flash::isothermal::flash_isothermal;
let comps = [n_butane(), n_decane()];
let spec = rks(&comps);
for (t, p) in [(450.0, 800.0), (350.0, 30000.0)] {
let stab = stability_analysis(&spec, t, p, &[0.5, 0.5], 100).unwrap();
let flash = flash_isothermal(&spec, t, p, &[0.5, 0.5], 1e-9, 200).unwrap();
match stab {
Stability::Unstable { .. } => assert!(
flash.two_phase,
"stability=unstable but flash single-phase at ({t},{p})"
),
Stability::Stable => assert!(
!flash.two_phase,
"stability=stable but flash two-phase at ({t},{p})"
),
}
}
}
}