use super::FlashError;
use super::isothermal::{FlashResult, flash_isothermal_warm};
use super::system::SystemSpec;
use crate::saturation::psat;
#[derive(Debug, Clone, PartialEq)]
pub struct FreeWaterFlashResult {
pub vapor_fraction: f64,
pub hc_liquid_fraction: f64,
pub free_water_fraction: f64,
pub y: Vec<f64>,
pub x: Vec<f64>,
pub k: Vec<f64>,
pub free_water: bool,
pub y_water: f64,
pub psat_water: f64,
pub iterations: usize,
}
#[allow(clippy::too_many_arguments)]
pub fn flash_free_water(
spec: &SystemSpec,
t: f64,
p: f64,
z: &[f64],
water_index: usize,
psat_water: Option<f64>,
tol: f64,
max_iter: usize,
) -> Result<FreeWaterFlashResult, FlashError> {
let n = spec.n();
if z.len() != n {
return Err(FlashError::Dimension(format!(
"components={n}, z={}",
z.len()
)));
}
if water_index >= n {
return Err(FlashError::InvalidInput(format!(
"water_index {water_index} out of range for {n} components"
)));
}
if !(t > 0.0 && p > 0.0 && t.is_finite() && p.is_finite()) {
return Err(FlashError::InvalidInput(format!("T = {t} K, P = {p} kPa")));
}
let zsum: f64 = z.iter().sum();
if zsum <= 0.0 || !zsum.is_finite() || z.iter().any(|&zi| zi < 0.0 || !zi.is_finite()) {
return Err(FlashError::InvalidInput(
"feed must be non-negative and non-empty".into(),
));
}
let z_w = z[water_index] / zsum;
let z_hc_total = 1.0 - z_w;
let pw = match psat_water {
Some(v) if v > 0.0 && v.is_finite() => v,
Some(v) => {
return Err(FlashError::InvalidInput(format!("psat_water = {v} kPa")));
}
None => psat(
spec.sat_models
.get(water_index)
.copied()
.unwrap_or(spec.components[water_index].sat_model),
&spec.components[water_index],
t,
)
.map_err(|e| FlashError::Thermo(format!("water saturation pressure: {e}")))?,
};
let mut z_dry = vec![0.0; n];
if z_hc_total > 0.0 {
for (i, zi) in z.iter().enumerate() {
z_dry[i] = if i == water_index {
0.0
} else {
zi / zsum / z_hc_total
};
}
}
let mut iterations = 0usize;
let mut k_warm: Option<Vec<f64>> = None;
let dry_flash = |p_hc: f64,
k_warm: &mut Option<Vec<f64>>,
iterations: &mut usize|
-> Result<FlashResult, FlashError> {
if z_hc_total <= 0.0 {
return Ok(FlashResult {
beta: 1.0,
x: z_dry.clone(),
y: z_dry.clone(),
k: vec![1.0; n],
iterations: 0,
two_phase: false,
});
}
let r = flash_isothermal_warm(spec, t, p_hc, &z_dry, k_warm.as_deref(), tol, max_iter)?;
*iterations += r.iterations;
*k_warm = Some(r.k.clone());
Ok(r)
};
if pw < p {
let y_w = pw / p;
let r = dry_flash(p - pw, &mut k_warm, &mut iterations)?;
let v_hc = r.beta * z_hc_total;
let n_wv = v_hc * y_w / (1.0 - y_w);
let free_w = z_w - n_wv;
if free_w >= 0.0 {
let v_total = v_hc + n_wv;
let mut y = vec![0.0; n];
if v_total > 0.0 {
for (i, (yi, ry)) in y.iter_mut().zip(&r.y).enumerate() {
*yi = if i == water_index {
n_wv / v_total
} else {
ry * v_hc / v_total
};
}
}
let mut k = r.k.clone();
k[water_index] = if v_total > 0.0 {
y[water_index]
} else {
f64::NAN
};
let mut x = r.x.clone();
x[water_index] = 0.0;
return Ok(FreeWaterFlashResult {
vapor_fraction: v_total,
hc_liquid_fraction: (1.0 - r.beta) * z_hc_total,
free_water_fraction: free_w,
y,
x,
k,
free_water: true,
y_water: if v_total > 0.0 { n_wv / v_total } else { y_w },
psat_water: pw,
iterations,
});
}
}
let mut y_w = if pw < p { pw / p } else { z_w.max(1e-12) };
let mut r = dry_flash(p * (1.0 - y_w), &mut k_warm, &mut iterations)?;
for _ in 0..50 {
let v_hc = r.beta * z_hc_total;
let next = if v_hc + z_w > 0.0 {
z_w / (v_hc + z_w)
} else {
0.0
};
let done = (next - y_w).abs() < 1e-12;
y_w = next;
if done {
break;
}
r = dry_flash(p * (1.0 - y_w), &mut k_warm, &mut iterations)?;
}
let v_hc = r.beta * z_hc_total;
let v_total = v_hc + z_w;
let mut y = vec![0.0; n];
if v_total > 0.0 {
for (i, (yi, ry)) in y.iter_mut().zip(&r.y).enumerate() {
*yi = if i == water_index {
z_w / v_total
} else {
ry * v_hc / v_total
};
}
}
let mut k = r.k.clone();
k[water_index] = f64::NAN;
let mut x = r.x.clone();
x[water_index] = 0.0;
Ok(FreeWaterFlashResult {
vapor_fraction: v_total,
hc_liquid_fraction: (1.0 - r.beta) * z_hc_total,
free_water_fraction: 0.0,
y,
x,
k,
free_water: false,
y_water: if v_total > 0.0 { z_w / v_total } else { 0.0 },
psat_water: pw,
iterations,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::eos::{CubicEos, LiquidModel, VaporModel};
use crate::mixing::MixingRule;
use crate::types::Component;
fn water() -> Component {
Component {
name: "water".into(),
tc: 647.1,
pc: 22064.0,
omega: 0.344,
psat_coeffs: vec![6.288, 3816.44, -46.13],
..Component::default()
}
}
fn n_pentane() -> Component {
Component {
name: "n-pentane".into(),
tc: 469.7,
pc: 3370.0,
omega: 0.252,
psat_coeffs: vec![4.55, 2477.07, -39.94],
..Component::default()
}
}
fn n_decane() -> Component {
Component {
name: "n-decane".into(),
tc: 617.7,
pc: 2110.0,
omega: 0.492,
psat_coeffs: vec![4.34, 3456.8, -78.67],
..Component::default()
}
}
fn spec<'a>(comps: &'a [Component], kij: &'a [Vec<f64>]) -> SystemSpec<'a> {
SystemSpec {
components: comps,
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 water_antoine_fixture_is_sane() {
let w = water();
let p100 = crate::saturation::psat(w.sat_model, &w, 373.15).unwrap();
assert!(
(p100 - 101.325).abs() / 101.325 < 0.02,
"Psat(100 °C) = {p100} kPa"
);
}
#[test]
fn cold_overhead_drum_decants_free_water() {
let comps = [n_pentane(), n_decane(), water()];
let s = spec(&comps, &[]);
let z = [0.25, 0.65, 0.10];
let r = flash_free_water(&s, 325.0, 40.0, &z, 2, None, 1e-10, 200).unwrap();
assert!(r.free_water, "{r:?}");
assert!(
r.vapor_fraction > 0.02,
"vapor {} — {r:?}",
r.vapor_fraction
);
assert!(
r.free_water_fraction > 0.02,
"free water {} — {r:?}",
r.free_water_fraction
);
let total = r.vapor_fraction + r.hc_liquid_fraction + r.free_water_fraction;
assert!((total - 1.0).abs() < 1e-12, "phases sum to {total}");
let water_in_vapor = r.vapor_fraction * r.y[2];
assert!((water_in_vapor + r.free_water_fraction - 0.10).abs() < 1e-12);
for (i, &zi) in z.iter().enumerate().take(2) {
let got = r.vapor_fraction * r.y[i] + r.hc_liquid_fraction * r.x[i];
assert!((got - zi).abs() < 1e-10, "component {i}: {got} vs {zi}");
}
assert!(
(r.y[2] - r.psat_water / 40.0).abs() < 1e-12,
"y_w = {}, Psat/P = {} — {r:?}",
r.y[2],
r.psat_water / 40.0
);
assert_eq!(r.x[2], 0.0);
}
#[test]
fn a_subcooled_drum_is_two_liquids_and_no_vapor() {
let comps = [n_pentane(), n_decane(), water()];
let s = spec(&comps, &[]);
let z = [0.45, 0.45, 0.10];
let r = flash_free_water(&s, 320.0, 200.0, &z, 2, None, 1e-10, 200).unwrap();
assert!(r.free_water);
assert!(r.vapor_fraction.abs() < 1e-12, "{}", r.vapor_fraction);
assert!((r.free_water_fraction - 0.10).abs() < 1e-12);
assert!((r.hc_liquid_fraction - 0.90).abs() < 1e-12);
}
#[test]
fn hot_stripper_keeps_all_water_in_the_vapor() {
let comps = [n_pentane(), n_decane(), water()];
let s = spec(&comps, &[]);
let z = [0.2, 0.7, 0.10];
let r = flash_free_water(&s, 450.0, 150.0, &z, 2, None, 1e-10, 200).unwrap();
assert!(!r.free_water);
assert_eq!(r.free_water_fraction, 0.0);
let water_in_vapor = r.vapor_fraction * r.y[2];
assert!((water_in_vapor - 0.10).abs() < 1e-10, "{water_in_vapor}");
let total = r.vapor_fraction + r.hc_liquid_fraction;
assert!((total - 1.0).abs() < 1e-12);
for (i, &zi) in z.iter().enumerate().take(2) {
let got = r.vapor_fraction * r.y[i] + r.hc_liquid_fraction * r.x[i];
assert!((got - zi).abs() < 1e-10);
}
assert!(r.y.iter().sum::<f64>() - 1.0 < 1e-12);
}
#[test]
fn a_little_water_in_a_hot_vapor_needs_no_free_phase_even_below_psat() {
let comps = [n_pentane(), n_decane(), water()];
let s = spec(&comps, &[]);
let z = [0.79, 0.20, 0.01];
let r = flash_free_water(&s, 380.0, 200.0, &z, 2, None, 1e-10, 200).unwrap();
assert!(!r.free_water, "{r:?}");
assert!(
r.y[2] < r.psat_water / 200.0,
"vapor is undersaturated in water"
);
assert!((r.vapor_fraction * r.y[2] - 0.01).abs() < 1e-10);
}
#[test]
fn an_explicit_water_saturation_pressure_is_honoured() {
let comps = [n_pentane(), n_decane(), water()];
let s = spec(&comps, &[]);
let z = [0.25, 0.65, 0.10];
let r = flash_free_water(&s, 325.0, 40.0, &z, 2, Some(15.0), 1e-10, 200).unwrap();
assert_eq!(r.psat_water, 15.0);
assert!(r.free_water, "{r:?}");
assert!(r.vapor_fraction > 0.0);
assert!((r.y[2] - 15.0 / 40.0).abs() < 1e-12);
}
#[test]
fn a_dry_feed_reduces_to_the_ordinary_flash() {
let comps = [n_pentane(), n_decane(), water()];
let s = spec(&comps, &[]);
let z = [0.5, 0.5, 0.0];
let r = flash_free_water(&s, 350.0, 150.0, &z, 2, None, 1e-10, 200).unwrap();
let plain =
crate::flash::isothermal::flash_isothermal(&s, 350.0, 150.0, &z, 1e-10, 200).unwrap();
assert!(!r.free_water);
assert!(
(r.vapor_fraction - plain.beta).abs() < 1e-8,
"{} vs {}",
r.vapor_fraction,
plain.beta
);
for i in 0..2 {
assert!((r.x[i] - plain.x[i]).abs() < 1e-8);
}
}
#[test]
fn rejects_bad_inputs() {
let comps = [n_pentane(), water()];
let s = spec(&comps, &[]);
assert!(flash_free_water(&s, 320.0, 200.0, &[0.5], 1, None, 1e-10, 100).is_err());
assert!(flash_free_water(&s, 320.0, 200.0, &[0.5, 0.5], 5, None, 1e-10, 100).is_err());
assert!(
flash_free_water(&s, 320.0, 200.0, &[0.5, 0.5], 1, Some(-1.0), 1e-10, 100).is_err()
);
assert!(flash_free_water(&s, -1.0, 200.0, &[0.5, 0.5], 1, None, 1e-10, 100).is_err());
}
}