Skip to content

Flowsheeting: partitioning, tears, and exchangers

A flowsheet is a set of units connected by named streams. When a stream fed to a unit depends on that unit's own output, the loop has to be torn: one stream is guessed, the loop is evaluated, and the guess is corrected until the stream reproduces itself. fugacio.sim.Flowsheet does the bookkeeping for you: it finds the loops, chooses where to tear them, orders the calculation, converges every loop with a choice of accelerated fixed-point or quasi-Newton methods, and keeps the converged plant differentiable in its parameters.

Declare units, not an order

Register feeds and units; each unit is a plain function of its input streams and the shared parameter pytree theta, returning one stream or a tuple of streams matched to outputs. The order you register units in doesn't matter.

import jax.numpy as jnp
from fugacio.sim import Flowsheet, Stream, flash_drum, heater, mix, splitter

comps = ("methane", "ethane", "propane")
feed = Stream.from_fractions(comps, jnp.array([0.6, 0.3, 0.1]), 100.0, 300.0, 20e5)

fs = Flowsheet()
fs.feed("feed", feed)
fs.unit("mixer",  lambda f, r, th: mix([f, r], t=300.0),            inputs=("feed", "recycle"), outputs=("mixed",))
fs.unit("cooler", lambda s, th: heater(s, t_out=th["T_flash"]).outlet, inputs=("mixed",),  outputs=("cold",))
fs.unit("flash",  lambda s, th: flash_drum(s, th["T_flash"], 20e5),   inputs=("cold",),   outputs=("vapor", "liquid"))
fs.unit("split",  lambda s, th: splitter(s, jnp.array([th["purge"], 1 - th["purge"]])),
        inputs=("vapor",), outputs=("purge", "recycle"))

streams = fs.solve({"T_flash": 250.0, "purge": 0.1}, method="broyden")
streams["recycle"].total

partition() exposes the calculation order the solver derived:

for block in fs.partition():
    print(block.units, block.cyclic, block.tears)
# ('mixer', 'cooler', 'flash', 'split')  True  ('recycle',)

Units are grouped into strongly connected components (Tarjan's algorithm), the blocks are ordered so every block's inputs are known before it runs, and inside each cyclic block tear streams are chosen greedily to break every cycle with as few tears as possible, preferring streams that flow backwards in the order the units were declared (which is almost always the recycle you had in mind). The units within a block are then ordered topologically with the tears treated as known. Acyclic blocks run once; cyclic blocks are converged.

You can still tear by hand. fs.tear("recycle", guess) is honored by partition (and supplemented if it leaves a cycle unbroken), and it's also the way to give a starting guess for a tear whose component list differs from the feeds', which the automatic seed (one pass of the loop with an empty recycle) can't infer.

Tear methods

Flowsheet.solve forwards its keyword arguments to tear_solve, which accepts three methods (see fugacio.sim.TEAR_METHODS):

Method What it does Use when
"wegstein" (default) bounded secant acceleration of direct substitution, per component loops that are already contracting; cheap per iteration
"broyden" quasi-Newton with a rank-one Jacobian update strongly coupled or slowly converging loops (heat integration around a column)
"newton" full Newton with the autodiff Jacobian of the loop map small tears with a badly conditioned loop, or when quadratic convergence is worth a Jacobian per step

Whichever method converges the loop, differentiation is by the implicit function theorem at the fixed point: the adjoint solves one dense system against (I - \partial g/\partial x), where (g) is the loop map, so jax.grad(lambda th: fs.solve(th)["product"].n[0]) costs about one extra pass through the loop regardless of how many iterations the forward solve took. The three methods agree on the converged values and on the gradient to solver tolerance.

Two-sided heat exchanger

fugacio.sim.heat_exchanger couples a hot and a cold stream through a countercurrent (or parallel) exchanger with rigorous enthalpy curves on both sides. Each side may use its own property package, so steam or a refrigerant from a reference-fluid package can heat a hydrocarbon on a cubic EOS.

from fugacio.sim import heat_exchanger, package_for

steam = Stream.from_fractions(("water",), jnp.array([1.0]), 5.0, 430.0, 3e5)
res = heat_exchanger(
    steam, process,
    t_cold_out=380.0,                    # exactly one closing spec
    zones=6,                             # discretise the T-Q curves for LMTD
    model_hot=package_for(("water",), "iapws"),
)
res.duty, res.ua, res.lmtd             # W, W/K, K
res.hot_out, res.cold_out              # both outlet streams (PH-flashed)
res.approach_hot_end, res.approach_cold_end, res.min_approach
res.hot_curve, res.cold_curve          # temperature at the zone boundaries

The closing specification is any one of duty, t_hot_out, t_cold_out, min_approach (the exchanger sized to a pinch), or ua (a rating calculation: the duty that a given (UA) delivers, found as the root of the zone-integrated (UA) requirement with a bracketed solve). Both outlets are obtained by PH flashes, so a condensing or boiling side shows its saturation plateau on the T-Q curve. A temperature spec that would violate the second law (cold outlet above the hot inlet) is capped at the feasible duty rather than producing a temperature cross. Every output is differentiable in the inlet states, the spec, and the package parameters, including the duty with respect to ua.

A plant with a recycle, a column, and an economizer

The pieces compose. This is the depropanizer from the test suite: an economizer preheats the feed against the column bottoms, which closes a heat-integration loop around a rigorous column.

from fugacio.sim import ColumnFeed, purity, recovery, rigorous_column

comps = ("propane", "n-butane", "n-pentane")
feed = Stream.from_fractions(comps, jnp.array([0.40, 0.35, 0.25]), 100.0, 300.0, 16e5)
specs = [recovery(0, "distillate", 0.98), purity("distillate", 0, 0.95)]

def economiser(cold, hot, th):
    hx = heat_exchanger(hot, cold, min_approach=th["dt"])
    return hx.cold_out, hx.hot_out

def column(s, th):
    res = rigorous_column([ColumnFeed(s, 8)], 16, p=16e5, specs=specs)
    return res.distillate, res.bottoms

fs = Flowsheet()
fs.feed("feed", feed)
fs.unit("economiser", economiser, inputs=("feed", "bottoms"), outputs=("preheated", "bottoms_cooled"))
fs.unit("column", column, inputs=("preheated",), outputs=("distillate", "bottoms"))

s = fs.solve({"dt": 15.0}, method="broyden", tol=1e-8)
s["preheated"].t              # heat recovered from the bottoms
s["distillate"].z[0]          # 0.95, the purity spec

The Flowsheet finds the single cyclic block, tears bottoms, seeds it by one pass with an empty recycle (an empty hot side exchanges nothing), and converges the loop with Broyden. Because the column is itself an implicit solve, the loop map is smooth in the tear and differentiable in dt and the spec values, so jax.grad of the reboiler duty with respect to the approach temperature passes through the exchanger, the column, and the closed loop together.

The test suite carries two more plants under the plant marker: an HDA-lite toluene hydrodealkylation loop (mixer, furnace, fixed-conversion reactor, cooler, flash, purge, automatically partitioned, followed by a stabilizer and a benzene/toluene column) and an ethanol/water train on NRTL whose bottoms heat is recovered into the feed.

Compile time

Every column and exchanger is compiled once per structure (stage count, component list, spec kinds, package type) and then reused, so a unit evaluated repeatedly inside a recycle iteration or an optimization pays the compile once. The first call is where the time goes: a Peng-Robinson column or exchanger compiles in tens of seconds, while a gamma-phi package (whose every K-value and liquid enthalpy carries an EOS saturation-pressure solve per component) takes a few minutes. Run the quick suite with just test-fast to skip the plant case studies.

Equation-oriented alternative

For flowsheets where every unit can be written as residual equations, the equation-oriented engine solves the plant as one Newton system with no tears at all. It now includes HeatExchanger, StoichiometricReactor, and Column blocks and takes the same property packages through EOFlowsheet(model=...). The two engines share the same physics and agree on any flowsheet both can express; the sequential-modular Flowsheet remains the more forgiving of the two for large recycles with poor initial guesses.