use crate::binary::equilibrium::EquilibriumCurve;
use crate::column::CondenserKind;
use crate::types::{Result, StagesError};
const MAX_STAGES: usize = 500;
const PINCH_PROGRESS: f64 = 1e-10;
#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "python", pyo3::pyclass(get_all))]
pub struct McCabeThieleSpec {
pub x_distillate: f64,
pub x_bottoms: f64,
pub z_feed: f64,
pub q: f64,
pub reflux: f64,
pub murphree: f64,
pub condenser: CondenserKind,
}
impl McCabeThieleSpec {
fn validate(&self) -> Result<()> {
for (name, v) in [
("x_D", self.x_distillate),
("x_B", self.x_bottoms),
("z_F", self.z_feed),
] {
if !(0.0 < v && v < 1.0) {
return Err(StagesError::Dimension(format!(
"{name} must be strictly inside (0, 1), got {v}"
)));
}
}
if !(self.x_bottoms < self.z_feed && self.z_feed < self.x_distillate) {
return Err(StagesError::Dimension(format!(
"specs must satisfy x_B < z_F < x_D, got x_B = {}, z_F = {}, x_D = {}",
self.x_bottoms, self.z_feed, self.x_distillate
)));
}
if self.reflux.is_nan() || self.reflux <= 0.0 {
return Err(StagesError::Dimension(format!(
"reflux ratio must be positive, got {}",
self.reflux
)));
}
if !(0.0 < self.murphree && self.murphree <= 1.0) {
return Err(StagesError::Dimension(format!(
"Murphree efficiency must be in (0, 1], got {}",
self.murphree
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "python", pyo3::pyclass(get_all))]
pub struct Line {
pub slope: f64,
pub intercept: f64,
}
impl Line {
fn y_at(&self, x: f64) -> f64 {
self.slope * x + self.intercept
}
}
#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "python", pyo3::pyclass(get_all))]
pub struct StagePoint {
pub index: usize,
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "python", pyo3::pyclass(get_all))]
pub struct RminResult {
pub r_min: f64,
pub pinch: (f64, f64),
pub tangent: bool,
pub feed_point: (f64, f64),
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "python", pyo3::pyclass(get_all))]
pub struct McCabeThieleResult {
pub spec: McCabeThieleSpec,
pub n_stages: f64,
pub stages: Vec<StagePoint>,
pub feed_stage: usize,
pub rmin: RminResult,
pub rectifying: Line,
pub stripping: Line,
pub intersection: (f64, f64),
pub staircase: Vec<(f64, f64)>,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "python", pyo3::pyclass(get_all))]
pub struct TotalRefluxResult {
pub n_min: f64,
pub stages: Vec<StagePoint>,
pub staircase: Vec<(f64, f64)>,
}
fn operating_intersection(rect: Line, z_feed: f64, q: f64) -> Result<(f64, f64)> {
if (q - 1.0).abs() < 1e-12 {
return Ok((z_feed, rect.y_at(z_feed)));
}
let mq = q / (q - 1.0);
let denom = rect.slope - mq;
if denom.abs() < 1e-12 {
return Err(StagesError::Infeasible(
"q-line is parallel to the rectifying line (degenerate q/R combination)".into(),
));
}
let x = (z_feed - mq * z_feed - rect.intercept) / denom;
Ok((x, rect.y_at(x)))
}
fn q_line_curve_intersection(curve: &EquilibriumCurve, z_feed: f64, q: f64) -> Result<(f64, f64)> {
if (q - 1.0).abs() < 1e-12 {
return Ok((z_feed, curve.y_of_x(z_feed)?));
}
if q.abs() < 1e-12 {
return Ok((curve.x_of_y(z_feed)?, z_feed));
}
let mq = q / (q - 1.0);
let line = |x: f64| mq * (x - z_feed) + z_feed;
let xs = curve.x_samples();
let f = |x: f64| -> Result<f64> { Ok(curve.y_of_x(x)? - line(x)) };
let mut prev_x = z_feed;
let mut prev_f = f(prev_x)?; let indices: Vec<usize> = if mq > 1.0 {
(0..xs.len()).filter(|&i| xs[i] > z_feed).collect()
} else {
(0..xs.len()).rev().filter(|&i| xs[i] < z_feed).collect()
};
for i in indices {
let xi = xs[i];
let fi = f(xi)?;
if prev_f >= 0.0 && fi < 0.0 {
let t = prev_f / (prev_f - fi);
let xr = prev_x + t * (xi - prev_x);
return Ok((xr, line(xr)));
}
prev_x = xi;
prev_f = fi;
}
Err(StagesError::Infeasible(format!(
"the q-line (q = {q}) never meets the equilibrium curve — check z_F = {z_feed} and q"
)))
}
pub fn rmin(
curve: &EquilibriumCurve,
x_distillate: f64,
x_bottoms: f64,
z_feed: f64,
q: f64,
) -> Result<RminResult> {
let (xd, xb) = (x_distillate, x_bottoms);
if !(xb < z_feed && z_feed < xd) {
return Err(StagesError::Dimension(format!(
"specs must satisfy x_B < z_F < x_D, got x_B = {xb}, z_F = {z_feed}, x_D = {xd}"
)));
}
let feed_point = q_line_curve_intersection(curve, z_feed, q)?;
let (xq, yq) = feed_point;
let mut m_rect = f64::NEG_INFINITY;
let mut pinch_rect = feed_point;
let candidates = curve
.x_samples()
.iter()
.zip(curve.y_samples())
.map(|(&x, &y)| (x, y))
.filter(|&(x, _)| x >= xq && x < xd)
.chain(std::iter::once(feed_point));
for (xe, ye) in candidates {
if xd - xe < 1e-12 {
continue;
}
let m = (xd - ye) / (xd - xe);
if m > m_rect {
m_rect = m;
pinch_rect = (xe, ye);
}
}
if m_rect >= 1.0 {
return Err(StagesError::Infeasible(format!(
"x_D = {xd} is unreachable at any reflux (equilibrium curve at or below the diagonal — azeotrope?)"
)));
}
let m_rect = m_rect.max(0.0); let r_rect = m_rect / (1.0 - m_rect);
let mut s_strip = f64::INFINITY;
let mut pinch_strip = feed_point;
let candidates = curve
.x_samples()
.iter()
.zip(curve.y_samples())
.map(|(&x, &y)| (x, y))
.filter(|&(x, _)| x > xb && x <= xq)
.chain(std::iter::once(feed_point));
for (xe, ye) in candidates {
if xe - xb < 1e-12 {
continue;
}
let s = (ye - xb) / (xe - xb);
if s < s_strip {
s_strip = s;
pinch_strip = (xe, ye);
}
}
let d = (z_feed - xb) / (xd - xb);
if s_strip <= 1.0 + 1e-12 {
return Err(StagesError::Infeasible(format!(
"x_B = {xb} is unreachable at any boilup (stripping line pinned to the diagonal)"
)));
}
let r_strip = (q + s_strip * (1.0 - q) - s_strip * d) / (d * (s_strip - 1.0));
let (r_min, pinch) = if r_rect >= r_strip {
(r_rect, pinch_rect)
} else {
(r_strip, pinch_strip)
};
let tangent = (pinch.0 - xq).abs() > 1e-9 || (pinch.1 - yq).abs() > 1e-9;
Ok(RminResult {
r_min,
pinch,
tangent,
feed_point,
})
}
fn step_to_curve(
curve: &EquilibriumCurve,
op_line: Line,
murphree: f64,
y_target: f64,
x_hi: f64,
) -> Result<f64> {
if (murphree - 1.0).abs() < 1e-12 {
return curve.x_of_y(y_target);
}
let g = |x: f64| -> Result<f64> {
let y_op = op_line.y_at(x);
Ok(y_op + murphree * (curve.y_of_x(x)? - y_op) - y_target)
};
let (mut lo, mut hi) = (0.0_f64, x_hi);
let g_lo = g(lo)?;
let g_hi = g(hi)?;
if g_lo > 0.0 || g_hi < 0.0 {
return Err(StagesError::Convergence(format!(
"pseudo-equilibrium step cannot reach y = {y_target} (E_MV = {murphree}) — \
the staircase has pinched; raise R or E_MV"
)));
}
for _ in 0..60 {
let mid = 0.5 * (lo + hi);
if g(mid)? < 0.0 {
lo = mid;
} else {
hi = mid;
}
}
Ok(0.5 * (lo + hi))
}
pub fn mccabe_thiele(
curve: &EquilibriumCurve,
spec: McCabeThieleSpec,
) -> Result<McCabeThieleResult> {
spec.validate()?;
let (xd, xb, r) = (spec.x_distillate, spec.x_bottoms, spec.reflux);
let rectifying = Line {
slope: r / (r + 1.0),
intercept: xd / (r + 1.0),
};
let intersection = operating_intersection(rectifying, spec.z_feed, spec.q)?;
let (xi, yi) = intersection;
if yi < xi {
return Err(StagesError::Convergence(format!(
"operating-line intersection ({xi:.4}, {yi:.4}) fell below the diagonal — R = {r} \
is below R_min for this q"
)));
}
let stripping = Line {
slope: (yi - xb) / (xi - xb),
intercept: xb - (yi - xb) / (xi - xb) * xb,
};
let rmin_result = rmin(curve, xd, xb, spec.z_feed, spec.q)?;
if r <= rmin_result.r_min {
return Err(StagesError::Convergence(format!(
"R = {r} ≤ R_min = {:.6}: the staircase pinches before reaching x_B",
rmin_result.r_min
)));
}
let mut stages = Vec::new();
let mut staircase = vec![(xd, xd)];
let mut x_prev = xd; let mut y_n = xd; let mut in_stripping = false;
let mut feed_stage = 0usize;
let mut n_stages = 0.0_f64;
for n in 1..=MAX_STAGES {
let op_line = if in_stripping { stripping } else { rectifying };
let x_n = step_to_curve(curve, op_line, spec.murphree, y_n, x_prev)?;
staircase.push((x_n, y_n));
stages.push(StagePoint {
index: n,
x: x_n,
y: y_n,
});
if x_n <= xb {
let frac = if x_prev - x_n > 0.0 {
((x_prev - xb) / (x_prev - x_n)).clamp(0.0, 1.0)
} else {
1.0
};
n_stages = (n - 1) as f64 + frac;
break;
}
if x_prev - x_n < PINCH_PROGRESS {
return Err(StagesError::Convergence(format!(
"staircase pinched at x = {x_n:.6} after {n} stages (R too close to R_min = {:.6})",
rmin_result.r_min
)));
}
if !in_stripping && x_n <= xi {
in_stripping = true;
feed_stage = n;
}
let y_next = if in_stripping {
stripping.y_at(x_n)
} else {
rectifying.y_at(x_n)
};
staircase.push((x_n, y_next));
x_prev = x_n;
y_n = y_next;
if n == MAX_STAGES {
return Err(StagesError::Convergence(format!(
"exceeded {MAX_STAGES} stages without reaching x_B = {xb} — spec pinched"
)));
}
}
if feed_stage == 0 {
feed_stage = stages.len();
}
Ok(McCabeThieleResult {
spec,
n_stages,
stages,
feed_stage,
rmin: rmin_result,
rectifying,
stripping,
intersection,
staircase,
})
}
pub fn total_reflux(
curve: &EquilibriumCurve,
x_distillate: f64,
x_bottoms: f64,
murphree: f64,
) -> Result<TotalRefluxResult> {
let (xd, xb) = (x_distillate, x_bottoms);
if !(0.0 < xb && xb < xd && xd < 1.0) {
return Err(StagesError::Dimension(format!(
"need 0 < x_B < x_D < 1, got x_B = {xb}, x_D = {xd}"
)));
}
if !(0.0 < murphree && murphree <= 1.0) {
return Err(StagesError::Dimension(format!(
"Murphree efficiency must be in (0, 1], got {murphree}"
)));
}
let diagonal = Line {
slope: 1.0,
intercept: 0.0,
};
let mut stages = Vec::new();
let mut staircase = vec![(xd, xd)];
let mut x_prev = xd;
let mut y_n = xd;
let mut n_min = 0.0_f64;
for n in 1..=MAX_STAGES {
let x_n = step_to_curve(curve, diagonal, murphree, y_n, x_prev)?;
staircase.push((x_n, y_n));
stages.push(StagePoint {
index: n,
x: x_n,
y: y_n,
});
if x_n <= xb {
let frac = if x_prev - x_n > 0.0 {
((x_prev - xb) / (x_prev - x_n)).clamp(0.0, 1.0)
} else {
1.0
};
n_min = (n - 1) as f64 + frac;
break;
}
if x_prev - x_n < PINCH_PROGRESS {
return Err(StagesError::Convergence(format!(
"total-reflux staircase pinched at x = {x_n:.6} (azeotrope between x_B and x_D?)"
)));
}
staircase.push((x_n, x_n)); x_prev = x_n;
y_n = x_n;
if n == MAX_STAGES {
return Err(StagesError::Convergence(format!(
"exceeded {MAX_STAGES} stages at total reflux — x_B = {xb} unreachable"
)));
}
}
Ok(TotalRefluxResult {
n_min,
stages,
staircase,
})
}
pub fn n_vs_r(
curve: &EquilibriumCurve,
spec: McCabeThieleSpec,
r_values: &[f64],
) -> Vec<(f64, f64)> {
r_values
.iter()
.map(|&r| {
let s = McCabeThieleSpec { reflux: r, ..spec };
match mccabe_thiele(curve, s) {
Ok(res) => (r, res.n_stages),
Err(_) => (r, f64::NAN),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::column::CondenserKind;
fn spec(reflux: f64) -> McCabeThieleSpec {
McCabeThieleSpec {
x_distillate: 0.95,
x_bottoms: 0.05,
z_feed: 0.50,
q: 1.0,
reflux,
murphree: 1.0,
condenser: CondenserKind::Total,
}
}
#[test]
fn rmin_matches_underwood_closed_form() {
let alpha = 2.5;
let curve = EquilibriumCurve::constant_alpha(alpha, 2001).unwrap();
let r = rmin(&curve, 0.95, 0.05, 0.50, 1.0).unwrap();
let exact = 1.0 / (alpha - 1.0) * (0.95 / 0.50 - alpha * 0.05 / 0.50);
assert!(
(r.r_min - exact).abs() < 2e-3,
"R_min = {}, Underwood exact = {exact}",
r.r_min
);
assert!(!r.tangent);
assert!((r.pinch.0 - 0.50).abs() < 1e-6);
}
#[test]
fn rmin_saturated_vapor_feed_pinch() {
let curve = EquilibriumCurve::constant_alpha(2.5, 2001).unwrap();
let r = rmin(&curve, 0.95, 0.05, 0.50, 0.0).unwrap();
assert!(
(r.feed_point.1 - 0.50).abs() < 1e-9,
"y_q* should equal z_F"
);
let r_liq = rmin(&curve, 0.95, 0.05, 0.50, 1.0).unwrap();
assert!(r.r_min > r_liq.r_min);
}
#[test]
fn total_reflux_matches_fenske() {
let alpha = 2.5;
let curve = EquilibriumCurve::constant_alpha(alpha, 2001).unwrap();
let res = total_reflux(&curve, 0.95, 0.05, 1.0).unwrap();
let fenske = ((0.95_f64 / 0.05) * (0.95 / 0.05)).ln() / alpha.ln();
assert_eq!(res.n_min.ceil(), fenske.ceil(), "whole-stage counts differ");
assert!(
(res.n_min - fenske).abs() < 0.15,
"N_min = {}, Fenske = {fenske}",
res.n_min
);
}
#[test]
fn construction_at_operating_reflux() {
let curve = EquilibriumCurve::constant_alpha(2.5, 2001).unwrap();
let r_min = rmin(&curve, 0.95, 0.05, 0.50, 1.0).unwrap().r_min;
let res = mccabe_thiele(&curve, spec(1.5 * r_min)).unwrap();
let nmin = total_reflux(&curve, 0.95, 0.05, 1.0).unwrap().n_min;
assert!(res.n_stages > nmin, "N = {} ≤ N_min = {nmin}", res.n_stages);
assert!(
res.n_stages < 30.0,
"N = {} implausibly large",
res.n_stages
);
assert!(res.feed_stage > 1 && res.feed_stage < res.stages.len());
assert_eq!(res.staircase[0], (0.95, 0.95));
assert!(res.stages.last().unwrap().x <= 0.05 + 1e-12);
let (xi, yi) = res.intersection;
assert!((res.rectifying.y_at(xi) - yi).abs() < 1e-12);
assert!((res.stripping.y_at(xi) - yi).abs() < 1e-9);
}
#[test]
fn n_decreases_with_reflux() {
let curve = EquilibriumCurve::constant_alpha(2.5, 2001).unwrap();
let pts = n_vs_r(&curve, spec(0.0), &[1.3, 1.8, 2.5, 4.0, 8.0]);
let ns: Vec<f64> = pts.iter().map(|&(_, n)| n).collect();
assert!(ns.iter().all(|n| n.is_finite()));
assert!(
ns.windows(2).all(|w| w[1] < w[0]),
"N(R) not decreasing: {ns:?}"
);
let nmin = total_reflux(&curve, 0.95, 0.05, 1.0).unwrap().n_min;
assert!(*ns.last().unwrap() > nmin);
}
#[test]
fn below_rmin_is_caught() {
let curve = EquilibriumCurve::constant_alpha(2.5, 2001).unwrap();
let r_min = rmin(&curve, 0.95, 0.05, 0.50, 1.0).unwrap().r_min;
assert!(matches!(
mccabe_thiele(&curve, spec(0.9 * r_min)),
Err(StagesError::Convergence(_))
));
let pts = n_vs_r(&curve, spec(0.0), &[0.9 * r_min]);
assert!(pts[0].1.is_nan());
}
#[test]
fn murphree_increases_stage_count() {
let curve = EquilibriumCurve::constant_alpha(2.5, 2001).unwrap();
let ideal = mccabe_thiele(&curve, spec(2.0)).unwrap();
let real = mccabe_thiele(
&curve,
McCabeThieleSpec {
murphree: 0.7,
..spec(2.0)
},
)
.unwrap();
assert!(
real.n_stages > ideal.n_stages,
"E_MV = 0.7 gave {} stages vs {} ideal",
real.n_stages,
ideal.n_stages
);
}
#[test]
fn tangent_pinch_detected() {
let n = 2001;
let mut x = Vec::with_capacity(n);
let mut y = Vec::with_capacity(n);
for i in 0..n {
let xi = i as f64 / (n - 1) as f64;
let alpha = 1.05 + 4.95 * (1.0 - xi).powi(2);
y.push(alpha * xi / (1.0 + (alpha - 1.0) * xi));
x.push(xi);
}
let curve = EquilibriumCurve::from_points(x, y, Vec::new(), None).unwrap();
let r = rmin(&curve, 0.90, 0.05, 0.30, 1.0).unwrap();
assert!(
r.tangent,
"expected a tangent pinch, got feed pinch at {:?}",
r.pinch
);
assert!(
r.pinch.0 > 0.30,
"tangent pinch should sit above the feed composition"
);
let res = mccabe_thiele(
&curve,
McCabeThieleSpec {
x_distillate: 0.90,
x_bottoms: 0.05,
z_feed: 0.30,
q: 1.0,
reflux: 1.3 * r.r_min,
murphree: 1.0,
condenser: CondenserKind::Total,
},
)
.unwrap();
assert!(res.n_stages.is_finite());
}
}