#![allow(clippy::needless_range_loop)]
use smallvec::SmallVec;
use super::FlashError;
use super::init::wilson_ln_k;
use super::system::{SystemSpec, SystemTpCache, ln_k_values_cached_into};
const INLINE_COMPONENTS: usize = 8;
type WorkVec = SmallVec<[f64; INLINE_COMPONENTS]>;
struct FlashWorkspace {
ln_k: WorkVec,
ln_k_new: WorkVec,
ln_k_ss: WorkVec,
trial: WorkVec,
k: WorkVec,
x: WorkVec,
y: WorkVec,
r: WorkVec,
r_prev: WorkVec,
}
impl FlashWorkspace {
fn new(n: usize) -> Self {
let zeros = || -> WorkVec { smallvec::smallvec![0.0; n] };
Self {
ln_k: zeros(),
ln_k_new: zeros(),
ln_k_ss: zeros(),
trial: zeros(),
k: zeros(),
x: zeros(),
y: zeros(),
r: zeros(),
r_prev: zeros(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FlashResult {
pub beta: f64,
pub x: Vec<f64>,
pub y: Vec<f64>,
pub k: Vec<f64>,
pub iterations: usize,
pub two_phase: bool,
}
const RR_DEGENERATE_C: f64 = 1e-14;
#[inline]
fn rr_fdd(z: &[f64], k: &[f64], beta: f64) -> (f64, f64, f64) {
let mut f = 0.0;
let mut df = 0.0;
let mut ddf = 0.0;
for (&zi, &ki) in z.iter().zip(k) {
let c = ki - 1.0;
let inv = (1.0 + beta * c).recip();
let base = zi * c * inv;
let q = c * inv;
f += base;
df -= base * q;
ddf += 2.0 * base * q * q;
}
(f, df, ddf)
}
fn rr_validate(z: &[f64], k: &[f64]) -> Result<(), FlashError> {
if z.is_empty() {
return Err(FlashError::InvalidInput("empty mixture".into()));
}
for (i, (&zi, &ki)) in z.iter().zip(k).enumerate() {
if !zi.is_finite() || zi < 0.0 {
return Err(FlashError::InvalidInput(format!("z[{i}]={zi}")));
}
if !ki.is_finite() || ki <= 0.0 {
return Err(FlashError::InvalidInput(format!("K[{i}]={ki}")));
}
}
Ok(())
}
#[inline]
fn rr_bracket(z: &[f64], k: &[f64]) -> (f64, f64) {
let mut kmax = f64::NEG_INFINITY;
let mut kmin = f64::INFINITY;
for (&zi, &ki) in z.iter().zip(k) {
let usable = zi != 0.0 && (ki - 1.0).abs() > RR_DEGENERATE_C;
kmax = kmax.max(if usable { ki } else { f64::NEG_INFINITY });
kmin = kmin.min(if usable { ki } else { f64::INFINITY });
}
if kmax == f64::NEG_INFINITY {
return (1.0, 1.0);
}
(kmax, kmin)
}
#[inline]
fn nudge_off_pole(v: f64, span: f64, upward: bool) -> f64 {
let delta = (1e-10 * span)
.max(v.abs() * 16.0 * f64::EPSILON)
.max(f64::MIN_POSITIVE);
if upward { v + delta } else { v - delta }
}
fn rr_solve(
z: &[f64],
k: &[f64],
kmax: f64,
kmin: f64,
tol: f64,
max_iter: usize,
) -> Result<f64, FlashError> {
if !tol.is_finite() || tol <= 0.0 {
return Err(FlashError::InvalidInput(format!("tolerance={tol}")));
}
if kmax <= 1.0 || kmin >= 1.0 {
return Err(FlashError::NoRachfordRiceRoot { kmax, kmin });
}
let beta_lo = 1.0 / (1.0 - kmax); let beta_hi = 1.0 / (1.0 - kmin); let span = beta_hi - beta_lo;
let mut lo = nudge_off_pole(beta_lo, span, true);
let mut hi = nudge_off_pole(beta_hi, span, false);
let mut beta = 0.5 * (lo + hi);
let mut width_prev = hi - lo;
let mut width_prev2 = hi - lo;
let mut last_f = f64::NAN;
for iter in 0..max_iter {
let (f, df, ddf) = rr_fdd(z, k, beta);
last_f = f;
if f.abs() <= tol {
return Ok(beta);
}
if f > 0.0 {
lo = beta;
} else {
hi = beta;
}
let width = hi - lo;
if width <= 4.0 * f64::EPSILON * beta.abs().max(1.0) {
return Ok(0.5 * (lo + hi));
}
let denom = 2.0 * df * df - f * ddf;
let scale = (2.0 * df * df).abs() + (f * ddf).abs();
let halley_ok = denom.is_finite() && denom.abs() > 32.0 * f64::EPSILON * scale;
let must_bisect = width > 0.5 * width_prev2;
width_prev2 = width_prev;
width_prev = width;
let next = if halley_ok && !must_bisect {
beta - 2.0 * f * df / denom
} else {
f64::NAN
};
beta = if next.is_finite() && next > lo && next < hi {
next
} else {
0.5 * (lo + hi)
};
if iter + 1 == max_iter {
let (f, _, _) = rr_fdd(z, k, beta);
return Err(FlashError::NoConvergence {
what: "Rachford-Rice",
iters: max_iter,
residual: f.abs(),
});
}
}
Err(FlashError::NoConvergence {
what: "Rachford-Rice",
iters: max_iter,
residual: last_f.abs(),
})
}
pub fn rachford_rice(z: &[f64], k: &[f64], tol: f64, max_iter: usize) -> Result<f64, FlashError> {
let n = z.len();
if k.len() != n {
return Err(FlashError::Dimension(format!("z={n}, k={}", k.len())));
}
rr_validate(z, k)?;
let (kmax, kmin) = rr_bracket(z, k);
rr_solve(z, k, kmax, kmin, tol, max_iter)
}
#[inline]
fn split_into(z: &[f64], k: &[f64], beta: f64, x: &mut [f64], y: &mut [f64]) {
for i in 0..z.len() {
x[i] = z[i] / (1.0 + beta * (k[i] - 1.0));
y[i] = k[i] * x[i];
}
}
const GDEM_MU_MAX: f64 = 0.95;
const GDEM_GAIN_MAX: f64 = 4.0;
const LN_K_BOUND: f64 = 80.0;
fn gdem_gain(r: &[f64], r_prev: &[f64]) -> Option<f64> {
let mut num = 0.0;
let mut den = 0.0;
for (&ri, &rp) in r.iter().zip(r_prev) {
num += ri * rp;
den += rp * rp;
}
if !den.is_finite() || den <= f64::MIN_POSITIVE {
return None;
}
let mu = num / den;
if !mu.is_finite() || mu <= 0.0 || mu >= GDEM_MU_MAX {
return None;
}
Some((1.0 / (1.0 - mu)).min(GDEM_GAIN_MAX))
}
fn gdem_trial(ln_k: &[f64], r: &[f64], gain: f64, trial: &mut [f64]) -> bool {
for i in 0..ln_k.len() {
let candidate = ln_k[i] + gain * r[i];
if !candidate.is_finite() || !(-LN_K_BOUND..=LN_K_BOUND).contains(&candidate) {
return false;
}
trial[i] = candidate;
}
true
}
pub fn flash_isothermal(
spec: &SystemSpec,
t: f64,
p: f64,
z: &[f64],
tol: f64,
max_iter: usize,
) -> Result<FlashResult, FlashError> {
flash_isothermal_warm(spec, t, p, z, None, tol, max_iter)
}
pub fn flash_isothermal_warm(
spec: &SystemSpec,
t: f64,
p: f64,
z: &[f64],
k_init: Option<&[f64]>,
tol: f64,
max_iter: usize,
) -> Result<FlashResult, 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.is_finite() || zi < 0.0) {
return Err(FlashError::InvalidInput(format!("z[{i}]={}", z[i])));
}
let mut ws = FlashWorkspace::new(n);
let cache = SystemTpCache::new(spec, t, p)?;
let warm_ok =
matches!(k_init, Some(k0) if k0.len() == n && k0.iter().all(|&v| v.is_finite() && v > 0.0));
if warm_ok {
let k0 = k_init.expect("checked above");
for i in 0..n {
ws.ln_k[i] = k0[i].ln();
}
} else {
for (i, comp) in spec.components.iter().enumerate() {
ws.ln_k[i] = wilson_ln_k(comp, t, p);
}
}
let mut have_r_prev = false;
let mut gdem_pending: Option<f64> = None;
for iter in 0..max_iter {
for i in 0..n {
ws.k[i] = ws.ln_k[i].exp();
}
let (kmax, kmin) = rr_bracket(z, &ws.k);
let beta = match rr_solve(z, &ws.k, kmax, kmin, 1e-12, 200) {
Ok(b) => b,
Err(FlashError::NoRachfordRiceRoot { .. }) => {
return Ok(single_phase(z, &ws.k));
}
Err(e) => return Err(e),
};
split_into(z, &ws.k, beta, &mut ws.x, &mut ws.y);
ln_k_values_cached_into(spec, &cache, &ws.x, &ws.y, &mut ws.ln_k_new)?;
let mut resid = 0.0_f64;
for i in 0..n {
ws.r[i] = ws.ln_k_new[i] - ws.ln_k[i];
resid = resid.max(ws.r[i].abs());
}
if resid <= tol {
for i in 0..n {
ws.k[i] = ws.ln_k_new[i].exp();
}
if !(0.0..=1.0).contains(&beta) {
return Ok(single_phase(z, &ws.k));
}
return Ok(FlashResult {
beta,
x: ws.x.to_vec(),
y: ws.y.to_vec(),
k: ws.k.to_vec(),
iterations: iter + 1,
two_phase: true,
});
}
if let Some(resid_before) = gdem_pending.take() {
if resid > resid_before {
ws.ln_k.copy_from_slice(&ws.ln_k_ss);
have_r_prev = false;
continue;
}
}
let mut accelerated = false;
if have_r_prev && iter % 5 == 4 {
if let Some(gain) = gdem_gain(&ws.r, &ws.r_prev) {
if gdem_trial(&ws.ln_k, &ws.r, gain, &mut ws.trial) {
ws.ln_k_ss.copy_from_slice(&ws.ln_k_new);
std::mem::swap(&mut ws.ln_k, &mut ws.trial);
gdem_pending = Some(resid);
accelerated = true;
}
}
}
if !accelerated {
ws.ln_k.copy_from_slice(&ws.ln_k_new);
}
ws.r_prev.copy_from_slice(&ws.r);
have_r_prev = true;
if iter + 1 == max_iter {
return Err(FlashError::NoConvergence {
what: "isothermal flash",
iters: max_iter,
residual: resid,
});
}
}
Err(FlashError::NoConvergence {
what: "isothermal flash",
iters: max_iter,
residual: f64::INFINITY,
})
}
fn single_phase(z: &[f64], k: &[f64]) -> FlashResult {
let (f0, _, _) = rr_fdd(z, k, 0.0);
let beta = if f0 > 0.0 { 1.0 } else { 0.0 };
FlashResult {
beta,
x: z.to_vec(),
y: z.to_vec(),
k: k.to_vec(),
iterations: 0,
two_phase: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::activity::ActivityModel;
use crate::eos::{CubicEos, LiquidModel, VaporModel};
use crate::flash::k_values;
use crate::mixing::MixingRule;
use crate::types::Component;
fn rr_residual(z: &[f64], k: &[f64], beta: f64) -> f64 {
rr_fdd(z, k, beta).0
}
#[test]
fn rr_matches_hand_solution_binary() {
let beta = rachford_rice(&[0.5, 0.5], &[2.0, 0.5], 1e-12, 100).unwrap();
assert!((beta - 0.5).abs() < 1e-10, "β={beta}");
}
#[test]
fn rr_residual_is_zero_at_root() {
let z = [0.3, 0.4, 0.3];
let k = [3.0, 1.2, 0.4];
let beta = rachford_rice(&z, &k, 1e-13, 100).unwrap();
let f = rr_residual(&z, &k, beta);
assert!(f.abs() < 1e-10, "f(β*)={f}");
assert!((0.0..=1.0).contains(&beta), "β={beta} should be two-phase");
}
#[test]
fn rr_negative_flash_root_outside_unit_interval() {
let z = [0.98, 0.02];
let k = [1.05, 0.2];
let beta = rachford_rice(&z, &k, 1e-12, 100).unwrap();
let f = rr_residual(&z, &k, beta);
assert!(f.abs() < 1e-9);
}
#[test]
fn rr_rejects_numerically_invalid_inputs() {
let bad: [(&[f64], &[f64]); 4] = [
(&[f64::NAN, 0.5], &[2.0, 0.5]), (&[-0.1, 1.1], &[2.0, 0.5]), (&[0.5, 0.5], &[2.0, 0.0]), (&[0.5, 0.5], &[2.0, f64::NAN]), ];
for (z, k) in bad {
assert!(
matches!(
rachford_rice(z, k, 1e-12, 100),
Err(FlashError::InvalidInput(_))
),
"expected InvalidInput for z={z:?}, k={k:?}"
);
}
assert!(matches!(
rachford_rice(&[], &[], 1e-12, 100),
Err(FlashError::InvalidInput(_))
));
assert!(matches!(
rachford_rice(&[0.5, 0.5], &[2.0, 0.5], 0.0, 100),
Err(FlashError::InvalidInput(_))
));
}
#[test]
fn rr_ignores_absent_components() {
let base = rachford_rice(&[0.5, 0.5], &[2.0, 0.5], 1e-13, 100).unwrap();
let padded = rachford_rice(&[0.5, 0.5, 0.0], &[2.0, 0.5, 1.0e9], 1e-13, 100).unwrap();
assert!(
(base - padded).abs() < 1e-12,
"absent component moved β: {base} vs {padded}"
);
}
#[test]
fn rr_degenerate_unity_k_reports_no_root() {
assert!(matches!(
rachford_rice(&[0.5, 0.5], &[1.0, 1.0 + 1e-16], 1e-12, 100),
Err(FlashError::NoRachfordRiceRoot { .. })
));
}
#[test]
fn gdem_gain_is_bounded() {
let r = [1.0, 1.0];
let r_prev = [1.0 + 1e-15, 1.0];
match gdem_gain(&r, &r_prev) {
None => {}
Some(g) => assert!(g <= GDEM_GAIN_MAX, "gain {g} exceeded the cap"),
}
let g = gdem_gain(&[0.5, 0.5], &[1.0, 1.0]).unwrap();
assert!((g - 2.0).abs() < 1e-12, "gain={g}");
assert!(gdem_gain(&[-1.0, -1.0], &[1.0, 1.0]).is_none());
}
#[test]
fn gdem_trial_rejects_runaway_candidates() {
let ln_k = [0.0, 0.0];
let mut trial = [0.0; 2];
assert!(gdem_trial(&ln_k, &[1.0, -1.0], 2.0, &mut trial));
assert_eq!(trial, [2.0, -2.0]);
assert!(!gdem_trial(&ln_k, &[1000.0, 0.0], 4.0, &mut trial));
}
#[test]
fn rr_rejects_single_phase_k() {
assert!(matches!(
rachford_rice(&[0.5, 0.5], &[2.0, 1.5], 1e-12, 100),
Err(FlashError::NoRachfordRiceRoot { .. })
));
assert!(matches!(
rachford_rice(&[0.5, 0.5], &[0.9, 0.3], 1e-12, 100),
Err(FlashError::NoRachfordRiceRoot { .. })
));
}
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()
}
}
fn n_heptane() -> Component {
Component {
name: "n-heptane".into(),
tc: 540.2,
pc: 2740.0,
omega: 0.350,
psat_coeffs: vec![4.02, 2911.0, -56.0],
..Component::default()
}
}
fn rks_system(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 flash_two_phase_mass_balance_and_equilibrium() {
let comps = [n_butane(), n_heptane()];
let spec = rks_system(&comps);
let z = [0.5, 0.5];
let res = flash_isothermal(&spec, 420.0, 1000.0, &z, 1e-10, 200).unwrap();
assert!(res.two_phase, "expected a two-phase split");
assert!((0.0..=1.0).contains(&res.beta));
for i in 0..2 {
let recombined = res.beta * res.y[i] + (1.0 - res.beta) * res.x[i];
assert!((recombined - z[i]).abs() < 1e-8, "mass balance comp {i}");
}
assert!((res.x.iter().sum::<f64>() - 1.0).abs() < 1e-8);
assert!((res.y.iter().sum::<f64>() - 1.0).abs() < 1e-8);
for i in 0..2 {
assert!((res.k[i] - res.y[i] / res.x[i]).abs() < 1e-6);
}
}
#[test]
fn flash_isofugacity_at_convergence() {
let comps = [n_butane(), n_heptane()];
let spec = rks_system(&comps);
let res = flash_isothermal(&spec, 420.0, 1000.0, &[0.5, 0.5], 1e-11, 200).unwrap();
let k_check = k_values(&spec, 420.0, 1000.0, &res.x, &res.y).unwrap();
for i in 0..2 {
assert!(
(k_check[i] / res.k[i] - 1.0).abs() < 1e-6,
"K comp {i} drifted"
);
}
}
#[test]
fn flash_single_phase_high_pressure_liquid() {
let comps = [n_butane(), n_heptane()];
let spec = rks_system(&comps);
let res = flash_isothermal(&spec, 350.0, 20000.0, &[0.5, 0.5], 1e-10, 200).unwrap();
assert!(!res.two_phase);
assert_eq!(res.beta, 0.0);
}
#[test]
fn flash_gamma_phi_activity_liquid() {
let a = Component {
name: "a".into(),
tc: 508.3,
pc: 4762.0,
omega: 0.665,
liquid_volume: 76.8,
psat_coeffs: vec![5.31, 3100.0, -60.0],
..Component::default()
};
let b = Component {
name: "water".into(),
tc: 647.1,
pc: 22064.0,
omega: 0.344,
liquid_volume: 18.07,
psat_coeffs: vec![5.11, 3800.0, -46.0],
..Component::default()
};
let comps = [a, b];
let aij = vec![vec![0.0, 1100.0], vec![-250.0, 0.0]];
let vl = [76.8, 18.07];
let spec = SystemSpec {
components: &comps,
vapor: VaporModel::IdealGas,
liquid: LiquidModel::Activity(ActivityModel::Wilson),
mixing_rule: MixingRule::Classical,
kij: &[],
aij: &aij,
alpha: &[],
vl: &vl,
delta: &[],
sat_models: &[],
ge_model: None,
};
let z = [0.5, 0.5];
let res = flash_isothermal(&spec, 350.0, 80.0, &z, 1e-10, 300).unwrap();
if res.two_phase {
for i in 0..2 {
let recombined = res.beta * res.y[i] + (1.0 - res.beta) * res.x[i];
assert!((recombined - z[i]).abs() < 1e-8);
}
}
}
}