McCabe-Thiele stepper: fractional stages, pinches, optimum reflux

McCabe-Thiele stepper: fractional stages, pinches, optimum reflux#

difflow.staged steps a McCabe-Thiele diagram with a fixed-length lax.scan and returns a fractional, differentiable stage count. This notebook (1) reproduces a hand calculation at constant alpha, (2) steps the real benzene-toluene curve and compares with the rigorous DistillationColumn, and (3) finds the optimum R/R_min by gradient.

The constant-alpha numbers are an illustration computed for the issue, not a published example. No textbook example is reproduced here.

import jax, jax.numpy as jnp, numpy as np
jax.config.update("jax_enable_x64", True)
from difflow import staged as S

alpha, z, xD, xB = 2.5, 0.4, 0.95, 0.05
curve = S.constant_alpha(alpha)
R_min = S.minimum_reflux(curve, z, 1.0, xD)
print("R_min =", round(float(R_min), 4), "  N_min (Fenske) =", round(float(S.total_reflux_stages(curve, xD, xB)), 4))

def column(curve, R, q=1.0, **kw):
    return S.mccabe_thiele(curve, S.rectifying_line(R, xD), S.stripping_line(R, xD, xB, z, q),
                           xD, xB, q_line=S.q_line(q, z), **kw)

res = column(curve, 1.5 * R_min)
print("N =", round(float(res["N"]), 3), " -> integer stages", int(np.ceil(float(res["N"]))),
      " feed stage", round(float(res["feed_stage"]), 2))
R_min = 1.4444   N_min (Fenske) = 6.4269
N = 11.722  -> integer stages 12  feed stage 6.0
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from difflow.visualization import plot_mccabe_thiele
R = 1.5 * R_min
ax = plot_mccabe_thiele(res, curve, top_line=S.rectifying_line(R, xD),
                        bottom_line=S.stripping_line(R, xD, xB, z, 1.0), q_line=S.q_line(1.0, z))
ax.figure.set_size_inches(5, 5)
plt.show()

The real benzene-toluene curve vs the rigorous column#

xy_curve gives the Raoult’s-law curve (alpha varies 2.4-2.6 with x); tabulated builds a monotone C1 interpolant and its inverse.

from difflow.database import get_species_data
from difflow.thermo import IdealThermo
from difflow.phase_diagrams import xy_curve, bubble_T
from difflow import DistillationColumn, make_stream
from difflow.units.distillation import DistillationColumnParams
from difflow.streams import get_flows

sp = ["benzene", "toluene"]
th = IdealThermo({s: get_species_data(s) for s in sp})
d = xy_curve(th, sp, 101325.0, n=81)
bt = S.tabulated(d["x"], d["y"])
Rmin_bt = S.minimum_reflux(bt, z, 1.0, xD)
R = 1.5 * Rmin_bt
res_bt = column(bt, R)
print("R_min", round(float(Rmin_bt), 3), " N_min", round(float(S.total_reflux_stages(bt, xD, xB)), 2),
      " N", round(float(res_bt["N"]), 2), " feed stage", round(float(res_bt["feed_stage"]), 1))

Tb = bubble_T(th, sp, jnp.array([z, 1 - z]), 101325.0)
D = 100.0 * (z - xB) / (xD - xB)
for N in (11, 12, 13):
    params = DistillationColumnParams(species_order=sp, n_stages=N, feed_stage=6,
                                      condenser_type="total", P=101325.0)
    dist, bot, info = DistillationColumn(params, th)(
        make_stream({"benzene": 40.0, "toluene": 60.0}, T=float(Tb), P=101325.0), R=float(R), D_spec=D)
    fd, fb = get_flows(dist), get_flows(bot)
    print(N, "stages: x_D =", round(float(fd["benzene"] / (fd["benzene"] + fd["toluene"])), 4),
          " x_B =", round(float(fb["benzene"] / (fb["benzene"] + fb["toluene"])), 4))
R_min 1.48  N_min 6.63  N 11.89  feed stage 6.0
11 stages: x_D = 0.9311  x_B = 0.0621
12 stages: x_D = 0.9451  x_B = 0.0532
13 stages: x_D = 0.955  x_B = 0.0469

The stepper asks for about 12 stages with the feed on stage 6; the rigorous column (constant molar overflow, ideal K-values) reaches about 0.945 / 0.053 with 12 and meets the 0.95 / 0.05 specification with 13. They agree to about one stage; the stepper’s integer count is the usual graphical estimate (the specification is met exactly at the fractional N).

Optimum R/R_min by gradient#

An integer stage count has no gradient; the fractional one does. A crude cost: stages x diameter (vapor load grows as R + 1) plus reboiler steam (also growing as R + 1), in arbitrary units. The optimum sits where the two trade off; compare the 1.2-1.5 rule. The weights are arbitrary, so the number is only a demonstration of the gradient, not a design result.

def cost(ratio, w_steam=1.0):
    r = ratio * Rmin_bt
    N = column(bt, r)["N"]
    vapor = (r + 1.0) * D
    return N * (vapor ** 0.5) + w_steam * vapor     # capital ~ stages x diameter, + steam

grad = jax.jit(jax.grad(cost))
lo, hi = 1.05, 3.0                       # bisection on the sign of the exact gradient
for _ in range(40):
    mid = 0.5 * (lo + hi)
    lo, hi = (mid, hi) if grad(mid) < 0 else (lo, mid)
ratio = 0.5 * (lo + hi)
print("optimum R/R_min ~", round(ratio, 3), " cost", round(float(cost(ratio)), 2))
print("grad there", float(grad(ratio)), "(a kink of the piecewise-smooth N: the gradient changes sign across it)")
for r_ in (1.05, 1.1, 1.2, 1.3, 1.5, 2.0):
    print(r_, round(float(cost(r_)), 2))
optimum R/R_min ~ 1.35  cost 256.94
grad there -19.82776432913315 (a kink of the piecewise-smooth N: the gradient changes sign across it)
1.05 301.28
1.1 279.81
1.2 263.57
1.3 258.02
1.5 258.22
2.0 278.7