use crate::binary::equilibrium::EnthalpyCurve;
use crate::binary::mccabe_thiele::StagePoint;
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 PonchonSavaritSpec {
pub x_distillate: f64,
pub x_bottoms: f64,
pub z_feed: f64,
pub q: f64,
pub reflux: f64,
pub condenser: CondenserKind,
}
impl PonchonSavaritSpec {
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 matches!(self.condenser, CondenserKind::Partial) {
return Err(StagesError::Infeasible(
"Ponchon–Savarit v1 supports a total condenser only; a partial condenser adds an \
equilibrium stage above the top tray that is not yet modelled"
.into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "python", pyo3::pyclass(get_all))]
pub struct PonchonSavaritResult {
pub spec: PonchonSavaritSpec,
pub n_stages: f64,
pub stages: Vec<StagePoint>,
pub feed_stage: usize,
pub delta_d: (f64, f64),
pub delta_b: (f64, f64),
pub feed_point: (f64, f64),
pub q_condenser: f64,
pub q_reboiler: f64,
pub tie_lines: Vec<((f64, f64), (f64, f64))>,
}
fn pole_line_vapor_intersection(
curve: &EnthalpyCurve,
pole: (f64, f64),
liq: (f64, f64),
y_upper: f64,
) -> Result<f64> {
let (xp, hp) = pole;
let (xl, hl) = liq;
let g = |y: f64, hv: f64| (xl - xp) * (hv - hp) - (hl - hp) * (y - xp);
let ys = curve.equilibrium().y_samples();
let hvs = curve.h_vap_samples();
let mut prev_y = xl;
let mut prev_g = g(xl, curve.h_vapor_of_y(xl)?);
for j in 0..ys.len() {
let yj = ys[j];
if yj <= xl {
continue;
}
if yj > y_upper + 1e-9 {
break;
}
let gj = g(yj, hvs[j]);
if (prev_g < 0.0 && gj >= 0.0) || (prev_g > 0.0 && gj <= 0.0) {
let denom = prev_g - gj;
let t = if denom.abs() > 0.0 {
prev_g / denom
} else {
0.0
};
return Ok(prev_y + t * (yj - prev_y));
}
prev_y = yj;
prev_g = gj;
}
Err(StagesError::Convergence(format!(
"pole line from Δ = ({xp:.4}, {hp:.1}) through liquid x = {xl:.4} does not cut the \
saturated-vapor curve below y = {y_upper:.4} — R too close to the minimum (pinched)"
)))
}
pub fn ponchon_savarit(
curve: &EnthalpyCurve,
spec: PonchonSavaritSpec,
) -> Result<PonchonSavaritResult> {
spec.validate()?;
let (xd, xb, zf, r) = (spec.x_distillate, spec.x_bottoms, spec.z_feed, spec.reflux);
let h_l0 = curve.h_liquid_of_x(xd)?; let h_v1 = curve.h_vapor_of_y(xd)?; let q_prime_d = h_v1 + r * (h_v1 - h_l0);
let delta_d = (xd, q_prime_d);
let h_lf = curve.h_liquid_of_x(zf)?;
let h_vf = curve.h_vapor_of_y(zf)?;
let h_f = spec.q * h_lf + (1.0 - spec.q) * h_vf;
let feed_point = (zf, h_f);
if (zf - xd).abs() < 1e-12 {
return Err(StagesError::Infeasible(
"z_F equals x_D — degenerate feed/product specification".into(),
));
}
let slope_df = (h_f - q_prime_d) / (zf - xd);
let q_prime_b = q_prime_d + slope_df * (xb - xd);
let delta_b = (xb, q_prime_b);
let d_over_f = (zf - xb) / (xd - xb);
let b_over_f = (xd - zf) / (xd - xb);
let h_d = h_l0; let h_b = curve.h_liquid_of_x(xb)?; let q_condenser = d_over_f * (q_prime_d - h_d);
let q_reboiler = b_over_f * (h_b - q_prime_b);
let mut stages: Vec<StagePoint> = Vec::new();
let mut tie_lines: Vec<((f64, f64), (f64, f64))> = Vec::new();
let mut y_n = xd; let mut x_prev = 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 x_n = curve.equilibrium().x_of_y(y_n)?;
let h_ln = curve.h_liquid_of_x(x_n)?;
let h_vn = curve.h_vapor_of_y(y_n)?;
tie_lines.push(((x_n, h_ln), (y_n, h_vn)));
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 the \
Ponchon–Savarit minimum reflux)"
)));
}
if !in_stripping && x_n <= zf {
in_stripping = true;
feed_stage = n;
}
let pole = if in_stripping { delta_b } else { delta_d };
let y_next = pole_line_vapor_intersection(curve, pole, (x_n, h_ln), y_n)?;
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(PonchonSavaritResult {
spec,
n_stages,
stages,
feed_stage,
delta_d,
delta_b,
feed_point,
q_condenser,
q_reboiler,
tie_lines,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::binary::mccabe_thiele::{McCabeThieleSpec, mccabe_thiele, rmin};
use crate::thermo::ThermoSystem;
fn benzene_toluene_curve() -> EnthalpyCurve {
let sys = ThermoSystem::peng_robinson(&["benzene", "toluene"]).unwrap();
EnthalpyCurve::from_thermo(&sys, 101.325, 201).unwrap()
}
fn spec(reflux: f64) -> PonchonSavaritSpec {
PonchonSavaritSpec {
x_distillate: 0.95,
x_bottoms: 0.05,
z_feed: 0.50,
q: 1.0,
reflux,
condenser: CondenserKind::Total,
}
}
#[test]
fn construction_is_well_formed() {
let curve = benzene_toluene_curve();
let res = ponchon_savarit(&curve, spec(2.0)).unwrap();
assert!(res.n_stages > 1.0 && res.n_stages < 40.0);
assert!(res.feed_stage >= 1 && res.feed_stage <= res.stages.len());
assert!(res.delta_d.1 > curve.h_vapor_of_y(0.95).unwrap());
assert!(res.delta_b.1 < curve.h_liquid_of_x(0.05).unwrap());
}
#[test]
fn energy_balance_closes() {
let curve = benzene_toluene_curve();
let res = ponchon_savarit(&curve, spec(2.0)).unwrap();
let d = (0.50 - 0.05) / (0.95 - 0.05);
let b = (0.95 - 0.50) / (0.95 - 0.05);
let h_f = res.feed_point.1;
let h_d = curve.h_liquid_of_x(0.95).unwrap();
let h_b = curve.h_liquid_of_x(0.05).unwrap();
let lhs = h_f + res.q_reboiler;
let rhs = d * h_d + b * h_b + res.q_condenser;
assert!(
(lhs - rhs).abs() < 1e-6 * (lhs.abs() + rhs.abs() + 1.0),
"energy balance not closed: LHS {lhs} vs RHS {rhs} kJ/kmol"
);
assert!(res.q_condenser > 0.0, "Q_C = {}", res.q_condenser);
assert!(res.q_reboiler > 0.0, "Q_R = {}", res.q_reboiler);
}
#[test]
fn agrees_with_mccabe_thiele_on_benzene_toluene() {
let sys = ThermoSystem::peng_robinson(&["benzene", "toluene"]).unwrap();
let ec = EnthalpyCurve::from_thermo(&sys, 101.325, 401).unwrap();
let r = 1.5 * rmin(ec.equilibrium(), 0.95, 0.05, 0.50, 1.0).unwrap().r_min;
let ps = ponchon_savarit(&ec, spec(r)).unwrap();
let mt = mccabe_thiele(
ec.equilibrium(),
McCabeThieleSpec {
x_distillate: 0.95,
x_bottoms: 0.05,
z_feed: 0.50,
q: 1.0,
reflux: r,
murphree: 1.0,
condenser: CondenserKind::Total,
},
)
.unwrap();
assert!(
(ps.n_stages - mt.n_stages).abs() < 1.2,
"P–S N = {} vs M–T N = {} should agree within ~1 stage for near-ideal \
benzene–toluene",
ps.n_stages,
mt.n_stages
);
}
#[test]
fn stages_decrease_with_reflux() {
let curve = benzene_toluene_curve();
let n_low = ponchon_savarit(&curve, spec(1.5)).unwrap().n_stages;
let n_high = ponchon_savarit(&curve, spec(4.0)).unwrap().n_stages;
assert!(
n_high < n_low,
"N should fall with reflux: N(1.5) = {n_low}, N(4.0) = {n_high}"
);
}
#[test]
fn partial_condenser_rejected() {
let curve = benzene_toluene_curve();
let s = PonchonSavaritSpec {
condenser: CondenserKind::Partial,
..spec(2.0)
};
assert!(matches!(
ponchon_savarit(&curve, s),
Err(StagesError::Infeasible(_))
));
}
}