Chemical Unit Operations

Contents

Chemical Unit Operations#

This document provides comprehensive documentation for all chemical unit operations available in Difflow.


Reactors#

CSTR (Continuous Stirred-Tank Reactor)#

Location: difflow/units/cstr.py

Class: CSTR

Description: Models an ideal continuous stirred-tank reactor with perfect mixing. The reactor contents are assumed to be at uniform temperature and composition, equal to the outlet conditions.

Process Role#

CSTRs are widely used in chemical processes for:

  • Liquid-phase reactions

  • Polymerization reactions

  • Fermentation (as idealized model)

  • Processes requiring uniform conditions

Parameters#

@dataclass
class CSTRParams:
    V: float               # Reactor volume (m³)
    rate_fn: Callable      # Rate function: rate_fn(C, T, rate_params) -> r  [n_reactions]
    stoich: Array          # Stoichiometric matrix [n_species × n_reactions]
    rate_params: dict      # Parameters passed to rate_fn (k_ref, E_a, ...)
    species_order: list    # Species names, in the row order of `stoich`
    dH_rxn: Array = None   # Heat of reaction for each reaction (J/mol)
    molar_density: float = None  # Constant molar density (mol/m³), see below
    eos: Any = None        # Cubic EOS for the reaction-phase density
    reaction_phase: str = None   # 'liquid' or 'vapor' (required with eos)
    T_damping: float = ... # Damping on the adiabatic/duty temperature solve

Inputs#

Parameter

Type

Units

Description

inlet

Stream

-

Inlet stream with species flows, T, P

T_spec

float

K

Target outlet temperature (isothermal mode)

Q_spec

float

W

Specified heat duty (specified_duty mode)

volumetric_flow

float

m³/s

Volumetric flow rate (optional)

Outputs#

Parameter

Type

Units

Description

outlet

Stream

-

Outlet stream

info['Q']

float

W

Heat duty (positive = heating)

info['rates']

Array

mol/m³/s

Reaction rates

info['conversion']

dict

-

Conversion of each species

Operating Modes#

The mode is chosen when the unit is built (CSTR(params, thermo, mode=...)):

  1. Isothermal (mode='isothermal', T_spec given at call time): Outlet temperature is fixed, heat duty calculated

  2. Adiabatic (mode='adiabatic'): Q = 0, outlet temperature calculated

  3. Specified Duty (mode='specified_duty', Q_spec given at call time): Heat duty fixed, outlet temperature calculated

The dynamic difflow.dynamic.DynamicCSTR is different: its duty is set when it is built (mode='specified_duty', duty=...), and it also has a cooling jacket (mode='jacket', UA=..., Tc=..., Q = UA (Tc − T)); see Dynamic modeling.

Governing Equations#

Material Balance (steady-state):

\[F_{i,out} = F_{i,in} + V \sum_j \nu_{ij} r_j\]

Where:

  • \(F_{i,out}\): Outlet molar flow of species i (mol/s)

  • \(F_{i,in}\): Inlet molar flow of species i (mol/s)

  • \(V\): Reactor volume (m³)

  • \(\nu_{ij}\): Stoichiometric coefficient of species i in reaction j

  • \(r_j\): Rate of reaction j (mol/m³/s)

Reaction Rate (Arrhenius kinetics):

\[r_j = k_{ref} \exp\left[\frac{E_a}{R}\left(\frac{1}{T_{ref}} - \frac{1}{T}\right)\right] \prod_i C_i^{n_i}\]

Where:

  • \(k_{ref}\): Rate constant at reference temperature

  • \(E_a\): Activation energy (J/mol)

  • \(R\): Gas constant (8.314 J/mol/K)

  • \(C_i\): Concentration of species i (mol/m³)

  • \(n_i\): Reaction order with respect to species i

Energy Balance:

\[Q = \dot{H}_{out} - \dot{H}_{in} + V \sum_j r_j \Delta H_{rxn,j}\]

Where:

  • \(Q\): Heat duty (W)

  • \(\dot{H}\): Enthalpy flow rate (W)

  • \(\Delta H_{rxn,j}\): Heat of reaction j (J/mol)

Conversion:

\[X = \frac{F_{A,in} - F_{A,out}}{F_{A,in}}\]

Example Usage#

from difflow import CSTR, CSTRParams, IdealThermo, SpeciesData, make_stream
import jax.numpy as jnp

# Two-species thermodynamics (liquid Cp, Antoine and Hvap constants)
species_data = {
    n: SpeciesData(n, MW=100.0, Cp_coeffs=(150.0, 0.0, 0.0, 0.0),
                   Hvap_coeffs=(35000.0, 0.38, 600.0),
                   antoine_coeffs=(10.0, 3000.0, -50.0))
    for n in ('A', 'B')
}
thermo = IdealThermo(species_data)

# A -> B (first-order, exothermic)
def rate_fn(C, T, p):
    k = p['k_ref'] * jnp.exp(p['E_a'] / 8.314 * (1 / p['T_ref'] - 1 / T))
    return jnp.array([k * C['A']])

params = CSTRParams(
    V=2.0,  # m³
    rate_fn=rate_fn,
    stoich=jnp.array([[-1.0], [1.0]]),  # A -> B
    rate_params={'k_ref': 1e-3, 'E_a': 50000.0, 'T_ref': 350.0},  # 1/s at 350 K
    species_order=['A', 'B'],
    dH_rxn=jnp.array([-80000.0]),  # J/mol (exothermic)
    molar_density=1000.0,  # mol/m³
)

inlet = make_stream({'A': 1.0, 'B': 0.0}, T=350.0, P=101325.0)

# Isothermal operation: the mode is set on the unit
cstr = CSTR(params, thermo, mode='isothermal')
outlet, info = cstr(inlet, T_spec=350.0)
print(f"Conversion of A: {info['conversion']['A']:.2%}")
print(f"Heat duty: {info['Q']:.2f} W")

# Adiabatic operation
cstr_adiab = CSTR(params, thermo, mode='adiabatic')
outlet_adiab, info_adiab = cstr_adiab(inlet)
print(f"Outlet temperature: {outlet_adiab['T']:.1f} K")

Concentration Basis (Molar Density)#

The rate law is evaluated at concentrations, so the reactor needs a molar density: \(C_i = F_i / \dot{V}\) with \(\dot{V} = F_{total}/\rho\), which makes the residence time \(\tau = V\rho/F_{total}\). An error in \(\rho\) is a proportional error in \(\tau\), and so in the conversion. Three ways to set it, in the order the CSTR resolves them:

  1. An equation of state – eos=<cubic EOS> with reaction_phase='liquid' or 'vapor'. Concentration is then the real molarity at reactor \((T, P, y)\), \(C_i = y_i\,\rho_{EOS}(T, P, y)\), recomputed inside the solve, so the reactor shares the flash’s thermodynamics. reaction_phase is required with eos: the liquid and vapor molar densities differ by two orders of magnitude, so there is no defensible default. Pair it with a CubicThermo to make the enthalpy real-gas too; a CubicThermo passed as thermo also supplies the EOS itself when reaction_phase is set and no eos= is given.

  2. A constant – molar_density=<mol/m^3>.

  3. Neither, in which case the reactor falls back to 55500 mol/m^3 (liquid water) and raises a CSTRDensityWarning. That fallback is right only for aqueous systems: it is ~8x high for a C3-C8 hydrocarbon liquid and ~200x high for a gas, and it silently inflates residence time. Treat the warning as a request to say which basis you meant.

Passing volumetric_flow= to the call sets \(\dot{V}\) outright and bypasses all three; info['molar_density'] then reports the density that flow implies.

from difflow.eos import PengRobinson
from difflow.database import get_critical_props

species = ['propane', 'n-butane']
eos = PengRobinson({c: get_critical_props(c) for c in species})
params_eos = CSTRParams(V=2.0, rate_fn=lambda C, T, p: jnp.array([p['k'] * C['propane']]),
                        stoich=jnp.array([[-1.0], [1.0]]), rate_params={'k': 1e-3},
                        species_order=species, eos=eos, reaction_phase='liquid')

Design Considerations#

  • Residence Time: \(\tau = V/Q_{vol}\) determines conversion

  • Heat Transfer: Large exothermic reactions may require cooling coils or jackets

  • Mixing: Perfect mixing assumption requires adequate agitation

  • Multiple CSTRs: Series arrangement approaches PFR behavior


PFR (Plug Flow Reactor)#

Location: difflow/units/pfr.py

Class: PFR

Description: Models an ideal plug flow reactor where all fluid elements have the same residence time. No axial mixing, but perfect radial mixing is assumed.

Process Role#

PFRs are preferred for:

  • Gas-phase reactions

  • High conversion requirements

  • Reactions where selectivity depends on conversion

  • Fast reactions

Parameters#

@dataclass
class PFRParams:
    V: float               # Total reactor volume (m³)
    rate_fn: Callable      # Rate function: rate_fn(C, T, rate_params) -> r
    stoich: Array          # Stoichiometry matrix [n_species × n_reactions]
    rate_params: dict      # Parameters passed to rate_fn
    species_order: list    # List of species names
    dH_rxn: Array = None   # Heat of reaction (J/mol), required for non-isothermal
    dP_dV: float = None    # Pressure gradient along the reactor (Pa/m³), optional
    rtol: float = 1e-6     # Relative tolerance for ODE solver
    atol: float = 1e-8     # Absolute tolerance for ODE solver
    n_save_points: int = 101  # Points to save in profile output

Inputs#

Parameter

Type

Units

Description

inlet

Stream

-

Inlet stream

T_spec

float

K

Outlet temperature (isothermal mode)

volumetric_flow

float

m³/s

Volumetric flow rate

Outputs#

Parameter

Type

Units

Description

outlet

Stream

-

Outlet stream

info['conversion']

dict

-

Conversion of each species

info['profiles']['V']

Array

m³

Volume along reactor

info['profiles']['F']

Array

mol/s

Molar flows along reactor, [n_save_points, n_species]

info['profiles']['T']

Array

K

Temperature along reactor

Governing Equations#

Material Balance (differential):

\[\frac{dF_i}{dV} = \sum_j \nu_{ij} r_j\]

Energy Balance (adiabatic):

\[\frac{dT}{dV} = \frac{-\sum_j r_j \Delta H_{rxn,j}}{F_{total} C_{p,mix}}\]

Integration Method: Adaptive ODE integration using diffrax (Tsit5 or Dopri5 solvers)

Example Usage#

from difflow.units.pfr import PFR, PFRParams
from difflow import make_stream
import jax.numpy as jnp

# Define rate function: A -> B (first-order)
def pfr_rate_fn(C, T, params):
    """Rate function: r = k * C_A with Arrhenius temperature dependence."""
    k_ref, E_a, T_ref = params['k_ref'], params['E_a'], params['T_ref']
    R = 8.314
    k = k_ref * jnp.exp(E_a / R * (1/T_ref - 1/T))
    return jnp.array([k * C['A']])

pfr_params = PFRParams(
    V=0.005,
    rate_fn=pfr_rate_fn,
    stoich=jnp.array([[-1.0], [1.0]]),  # A -> B
    rate_params={'k_ref': 0.5, 'E_a': 60000.0, 'T_ref': 400.0},
    species_order=['A', 'B'],
    dH_rxn=jnp.array([-50000.0]),
    n_save_points=201
)

pfr = PFR(pfr_params, thermo)
pfr_inlet = make_stream({'A': 2.0, 'B': 0.0}, T=400.0, P=200000.0)
outlet, info = pfr(pfr_inlet, volumetric_flow=0.001)  # m³/s

# Plot conversion profile
import matplotlib.pyplot as plt
prof = info['profiles']
plt.plot(prof['V'], 1 - prof['F'][:, 0] / pfr_inlet['F_A'])
plt.xlabel('Volume (m³)')
plt.ylabel('Conversion')

GasPFR (Gas-Phase PFR with Pressure Drop)#

Location: difflow/units/pfr.py

Class: GasPFR

Description: Extended PFR model for gas-phase reactions accounting for:

  • Pressure drop (Ergun equation)

  • Variable volumetric flow due to mole change and pressure/temperature effects

Additional Parameters#

@dataclass
class GasPFRParams(PFRParams):
    alpha: float           # Pressure drop parameter (Pa/m³); or give the physical
                           # bed instead: d_p, voidage, mu, rho_gas, u_s0, bed_area

Instead of a lumped alpha, pass d_p, voidage, mu, rho_gas, bed_area (and optionally u_s0 and sphericity) and \(\alpha\) is computed from the full Ergun equation; see Particles and Beds. Leave u_s0=None to take the superficial velocity from the inlet stream (\(Q_0/A_c\)); a given u_s0 that differs from the stream’s by more than 1 % raises GasPFRBedWarning. rho_gas works the same way: leave it None and it is the inlet stream’s density, mass flow\(/Q_0\) (with the default ideal-gas \(Q_0\), \(P_0\bar M/(RT_0)\)), which needs the molar masses, from MW= (g/mol, a number or {species: MW}) or from the unit’s thermo. A given rho_gas is checked against the stream when the molar masses are known and raises GasPFRDensityWarning (a subclass of GasPFRBedWarning) above 2 % (RHO_GAS_RTOL). With neither rho_gas nor a molar mass the call raises ValueError.

Additional Equations#

Pressure Drop (Ergun equation):

\[\frac{dP}{dV} = -\alpha \frac{P_0}{P} \frac{T}{T_0} \frac{F_{total}}{F_{total,0}}\]

Where \(\alpha\) combines Ergun parameters:

\[\alpha = \frac{G}{\rho_0 g_c D_p A_c} \left[\frac{150(1-\phi)\mu}{D_p} + 1.75 G\right] \frac{(1-\phi)}{\phi^3}\]

Variable Volumetric Flow:

\[Q = Q_0 \frac{F_{total}}{F_{total,0}} \frac{P_0}{P} \frac{T}{T_0}\]

Outputs#

Additional outputs compared to PFR:

Parameter

Type

Units

Description

info['profiles']['P']

Array

Pa

Pressure along reactor

info['pressure_drop']

float

Pa

Total pressure drop


FedBatchReactor#

Location: difflow/units/fed_batch.py

Class: FedBatchReactor, SemiBatchReactor

Description: Models fed-batch (semi-batch) reactors with time-varying feed profiles. Commonly used when reactant addition rate affects selectivity or safety.

Process Role#

Fed-batch reactors are used for:

  • Controlling exothermic reactions

  • Improving selectivity by maintaining low reactant concentration

  • Fermentation with substrate feeding

  • Polymerization with monomer addition

Parameters#

@dataclass
class FedBatchParams:
    V0: float              # Initial reactor volume (m³)
    rate_fn: Callable      # Rate function: rate_fn(C, T, rate_params) -> r
    stoich: Array          # Stoichiometry matrix [n_species × n_reactions]
    rate_params: dict      # Parameters passed to rate_fn
    species_order: list    # List of species names
    dH_rxn: Array = None   # Heat of reaction (J/mol), None for isothermal

Inputs#

Parameter

Type

Units

Description

inlet

Stream

-

Feed stream composition

feed_flow

Callable

mol/s

Feed rate as function of time: F(t)

T_profile

Callable

K

Temperature as function of time: T(t)

Governing Equations#

Volume Change:

\[\frac{dV}{dt} = Q_{feed}\]

Material Balance:

\[\frac{d(V C_i)}{dt} = F_{in} C_{in,i} + V \sum_j \nu_{ij} r_j\]

Or equivalently:

\[\frac{dN_i}{dt} = F_{in,i} + V \sum_j \nu_{ij} r_j\]

Energy Balance:

\[\frac{d(V \rho C_p T)}{dt} = F_{in} \rho_{in} C_{p,in} T_{in} + V \sum_j r_j (-\Delta H_{rxn,j}) + Q\]

Utility Functions#

from difflow.units.fed_batch import (
    FedBatchParams, batch_time_for_conversion, optimal_feed_profile,
)

fb_params = FedBatchParams(
    V0=1.0,
    rate_fn=lambda C, T, p: jnp.array([p['k'] * C['A']]),
    stoich=jnp.array([[-1.0], [1.0]]),     # A -> B
    rate_params={'k': 1e-3},
    species_order=['A', 'B'],
)
C0 = {'A': 1000.0, 'B': 0.0}               # mol/m³

# Calculate batch time for target conversion (here 95 % of A)
t_batch = batch_time_for_conversion(fb_params, C0, 350.0, 'A', 0.95)

# Generate an optimal piecewise-constant feed profile (maximize product B)
feed_fn, t_opt = optimal_feed_profile(
    'max_yield', fb_params, C0, 350.0, 'B',
    feed_composition={'A': 2000.0, 'B': 0.0}, V_max=2.0, t_max=3600.0,
    n_intervals=4, n_sim_steps=50,
)

SemiBatchReactor#

Location: difflow/units/fed_batch.py

Class: SemiBatchReactor

Description: FedBatchReactor under the name the process usually goes by. Same model, same parameters, same equations.

A semi-batch reactor is a batch vessel into which one reactant is fed gradually — to cap the heat release, to hold a reactant concentration low for selectivity, or to keep a hazardous intermediate from accumulating. That is exactly the fed-batch model above, so SemiBatchReactor is a subclass of FedBatchReactor that changes nothing but its display symbol:

from difflow.units.fed_batch import SemiBatchReactor, FedBatchParams

reactor = SemiBatchReactor(fb_params, thermo, mode="isothermal")   # fb_params: FedBatchParams

Everything in FedBatchReactor — parameters, feed and temperature profiles, the material and energy balances, the utility functions — applies unchanged. Pick the name that makes the flowsheet read correctly; there is no modelling difference to weigh.


StoichiometricReactor#

Location: difflow/units/stoichiometric.py

Class: StoichiometricReactor (parameters: StoichiometricReactorParams)

Description: Fixed-conversion reactor (a simulator’s RStoic block): the conversion \(X\) of a key reactant \(k\) is specified and the extent follows. No kinetics, equilibrium or thermodynamic model; differentiable in the conversion and the feed. Several reactions (lists of stoich, key, conversion) run in parallel, each conversion based on the reactor inlet.

Governing Equations#

\[\xi_r = \frac{X_r F^{in}_{k_r}}{-\nu_{k_r,r}}, \qquad F^{out}_i = F^{in}_i + \sum_r \nu_{i,r}\, \xi_r\]

The outlet is at the inlet temperature (or T_out) and the inlet pressure less pressure_drop; no energy balance is solved. Products absent from the inlet are added to the outlet. info reports extent, conversion, key_consumed and min_outlet_flow (negative when the specified conversions consume more of a species than is fed).

Example Usage#

import jax
from difflow import StoichiometricReactor, StoichiometricReactorParams, make_stream

feed = make_stream({"CO": 100.0, "H2": 200.0, "MeOH": 0.0}, T=500.0, P=5e6)

def methanol_out(X):
    rx = StoichiometricReactor(StoichiometricReactorParams(
        stoich={"CO": -1.0, "H2": -2.0, "MeOH": 1.0}, key="CO", conversion=X))
    out, info = rx(feed)
    return out["F_MeOH"]

print(float(methanol_out(0.25)), float(jax.grad(methanol_out)(0.25)))   # 25.0, 100.0

GibbsReactor#

Location: difflow/units/gibbs.py

Class: GibbsReactor (parameters: GibbsReactorParams; thermodynamics: GibbsThermo)

Description: Equilibrium reactor, like a simulator’s RGibbs block. The outlet minimises the total Gibbs energy of an ideal-gas mixture subject to the element balances. No reactions need to be listed. The modes are:

  • Isothermal: at T_out, or the inlet T.

  • Adiabatic: mode="adiabatic", with an optional heat_duty.

It can also run short of equilibrium:

  • Overall: approach_dT.

  • Per reaction: reactions plus reaction_approach_dT. The outlet is then restricted to those reactions, and species in none of them pass through as inerts.

The thermodynamics are pluggable. GibbsThermo takes Hf, S° and a Cp cubic. The refinery’s IdealGasSet also works, as does any object with names, gibbs(T), enthalpy(T) and ELEMENTS. A species that contains an element the feed lacks comes out at exactly zero. So does one whose element is fed only as a trace, below TRACE_ELEMENT (1e-10) of the fed atoms: that element’s balance row, scaled by its own abundance, otherwise threw Newton off (a Claus furnace fed 1e-9 mol/s of CO2 diverged). The trace’s atoms are dropped, an element-balance error of at most 1e-10 of the feed.

Governing Equations#

\[\min_n \sum_i n_i\left[\frac{g_i^\circ(T)}{RT} + \ln\frac{n_i}{N} + \ln\frac{P}{P^\circ}\right] \quad \text{s.t.}\quad A n = A n^{in},\qquad P^\circ = 1\ \text{bar}\]

The solver is the element-potential method (Smith & Missen 1982; Reynolds’s STANJAN). At the minimum, \(n_i = N\exp\left(\sum_k A_{ki}\lambda_k - g_i^\circ/RT - \ln P/P^\circ\right)\). Newton’s method, with a backtracking line search, then solves for one potential \(\lambda_k\) per independent element plus \(\ln N\). Adiabatic mode adds \(T\) and \(\sum_i n_i h_i(T) = H_{in} + Q\).

Every \(n_i\) is an exponential, so trace species stay positive. Gradients come from the implicit-function theorem at the converged point, not from unrolling Newton, so jax.grad, jax.jacfwd and jit all work.

info reports:

  • converged, residual and iterations;

  • element_potentials;

  • T;

  • heat_duty;

  • element_balance.

Validation#

Checked when the reactor was written (#468); the IDAES comparison is regenerated by tests/reference/gibbs_generate.py. The per-commit tests (tests/test_gibbs_reactor.py) keep the balances, convergence and finite gradients:

  • Closed forms: WGS and SMR equilibrium quotients equal \(K(T)\) to 1e-9 in \(\ln K\).

  • JANAF: a GibbsThermo built from the NIST-JANAF tables (CO C-093, CO2 C-095, H2O H-064, H2 H-050, CH4 C-067) reproduces JANAF’s \(\log K_f\) at 800–1200 K. The worst miss is 0.019 in \(\ln K\), which is the Cp-cubic fit; the JANAF columns themselves agree to 0.001.

  • The refinery thermo table: it agrees with JANAF to within 0.03.

  • IDAES: the GibbsReactor of IDAES 2.10 + IPOPT, on the same constants, agrees in flow to 1e-6 (relative) and in adiabatic T to 1e-9 K.

  • Balances, energy closure and gradients: element balances close to round-off, the energy balance closes, and the derivatives in T, P, the feed and Q agree with finite differences. \(dx_B/dT\) for A ⇌ B equals the van ‘t Hoff closed form.

Example Usage#

import jax
from difflow import GibbsReactor, GibbsReactorParams, make_stream
from difflow_refinery.thermochemistry import IdealGasSet

th = IdealGasSet(["methane", "water", "carbon_monoxide", "carbon_dioxide", "hydrogen"])
feed = make_stream({"methane": 1.0, "water": 3.0}, T=1100.0, P=20e5)

def h2(T_out):
    out, info = GibbsReactor(GibbsReactorParams(thermo=th, T_out=T_out))(feed)
    return out["F_hydrogen"]

print(float(h2(1100.0)), float(jax.grad(h2)(1100.0)))   # mol/s, mol/s/K

out, info = GibbsReactor(GibbsReactorParams(thermo=th, mode="adiabatic"))(feed)
print(float(info["T"]))                                  # ~855 K: SMR is endothermic

Declarative Kinetics#

The reactors above take rate_fn as a Python callable. That is expressive, but it is code, not data — it cannot be written to a file, built from a form, or round-tripped through a GUI. mass_action_kinetics builds the callable from plain dictionaries instead, so a reaction network can be stored, edited and shared as data.

from difflow import CSTR, CSTRParams, mass_action_kinetics

reactions = [{
    "equation":  "A -> B",
    "reactants": {"A": 1.0},
    "products":  {"B": 1.0},
    "rate_params": {"A": 1.0e6, "Ea": 50_000.0, "n": 0.0},
}]

kin = mass_action_kinetics(reactions, species_order=["A", "B"])
cstr = CSTR(CSTRParams(V=1.5, **kin.params_kwargs()))

params_kwargs() supplies rate_fn, stoich, rate_params and species_order — every rate-law field the reactors need. The result is numerically identical to the equivalent hand-written callable.

The dictionary format is exactly what import_reactions returns from a Cantera YAML file, so a published mechanism goes straight into a reactor:

from difflow import import_reactions

reactions = import_reactions("mech.yaml")
kin = mass_action_kinetics(reactions, reverse="forward_only")

The rate law#

\[k_j(T) = A_j \, T^{n_j} \exp\!\left(\frac{-E_{a,j}}{R T}\right), \qquad r_j = k_j \prod_i C_i^{\alpha_{ji}}\]

Orders \(\alpha\) come from the reactant stoichiometry unless given explicitly via orders=, which covers empirical rate laws where the order is not the coefficient. A reversible reaction subtracts the reverse term scaled by its equilibrium constant:

\[r_j = k_j \left( \prod_i C_i^{\alpha_{ji}} - \frac{1}{K_{eq,j}} \prod_i C_i^{\beta_{ji}} \right)\]

Parameters:

  • reactions — one dict per reaction with reactants, products and rate_params (A, Ea, n); optionally equation, reversible, type and K_eq.

  • species_order — fixes the rows of stoich; defaults to the sorted union of every species mentioned.

  • reverse — "error" (default), "forward_only", or "equilibrium".

  • orders — per-reaction {species: order} overrides. None keeps stoichiometric orders; {} means zeroth order in everything.

What it refuses, and why#

Two specifications raise KineticsSpecError rather than being approximated, because in both cases a guess produces a plausible number that is wrong:

  • Reversible reactions, by default. Forward Arrhenius parameters alone do not determine the reverse rate. Pass reverse="equilibrium" with a K_eq on each reaction, or reverse="forward_only" to drop the reverse term as an explicit, recorded approximation.

  • Three-body, falloff and other pressure-dependent types. These need their own rate law; mass action covers elementary reactions only.

Units#

Concentrations mol/m³, temperature K, activation energy J/mol, rates mol/m³/s. Cantera files declare their own units in a units: block — a mechanism written in cm³ or kcal/mol imports numerically unchanged and will be silently wrong, so check that block before trusting a rate constant.


Separators#

Flash Drum#

Location: difflow/units/flash.py

Classes: Flash, EOSFlash, PHFlash

Description: Performs vapor-liquid equilibrium (VLE) separation. The feed is separated into vapor and liquid phases at equilibrium conditions.

Process Role#

Flash drums are used for:

  • Separating light and heavy components

  • Pressure reduction with phase separation

  • Overhead condensers

  • Feed preparation for distillation

Parameters#

@dataclass
class FlashParams:
    species_order: list[str]  # List of species names for array ordering

@dataclass
class EOSFlashParams:
    species_order: list[str]  # List of species names for array ordering
    eos_type: str = "PR"      # "PR" (Peng-Robinson) or "SRK"

Inputs#

Parameter

Type

Units

Description

inlet

Stream

-

Feed stream

T

float

K

Flash temperature (optional override)

P

float

Pa

Flash pressure (optional override)

Outputs#

Parameter

Type

Units

Description

liquid

Stream

-

Liquid product

vapor

Stream

-

Vapor product

info['V_frac']

float

-

Vapor fraction

info['K']

dict

-

K-values for each species

info['x']

dict

-

Liquid mole fractions

info['y']

dict

-

Vapor mole fractions

Governing Equations#

Rachford-Rice Equation:

\[f(V) = \sum_i \frac{z_i (K_i - 1)}{1 + V(K_i - 1)} = 0\]

Where:

  • \(z_i\): Feed mole fraction of species i

  • \(K_i\): Equilibrium ratio (K-value) = \(y_i / x_i\)

  • \(V\): Vapor fraction (moles vapor / moles feed)

Phase Compositions:

\[x_i = \frac{z_i}{1 + V(K_i - 1)}\]
\[y_i = \frac{K_i z_i}{1 + V(K_i - 1)}\]

K-Value Calculation (Raoult’s Law for Flash):

\[K_i = \frac{P_i^{sat}(T)}{P}\]

K-Value Calculation (Fugacity-based for EOSFlash):

\[K_i = \frac{\phi_i^L}{\phi_i^V}\]

Where \(\phi_i\) are fugacity coefficients from Peng-Robinson or SRK equation of state.

Material Balance:

\[F = L + V\]
\[F z_i = L x_i + V y_i\]

Flash Classes#

Flash (Ideal)#

Uses Raoult’s law K-values from IdealThermo. Suitable for ideal or near-ideal mixtures.

from difflow import IdealThermo, make_stream
from difflow.database import get_species_data
from difflow.units.flash import Flash, FlashParams

thermo = IdealThermo({n: get_species_data(n) for n in ['methane', 'ethane', 'propane']})
flash = Flash(FlashParams(species_order=['methane', 'ethane', 'propane']), thermo)
feed = make_stream({'methane': 0.5, 'ethane': 0.3, 'propane': 0.2}, T=200.0, P=500000.0)

liquid, vapor, info = flash(feed)
print(f"Vapor fraction: {info['V_frac']:.3f}")
Flash with an activity model (non-ideal liquid)#

With activity_model= the K-values follow the modified Raoult’s law \(K_i = \gamma_i(x,T)\,P_i^{sat}(T)/P\), which captures azeotropes. The argument is any activity model: an object with a gamma(x, T) method (the difflow.activity.ActivityModel protocol). NRTLParams, UNIQUACParams, WilsonParams, MargulesParams and VanLaarParams all qualify (Margules and van Laar are binary; Wilson cannot predict liquid-liquid splitting). species of the model must match species_order. The NRTL path is unchanged. See Activity-Coefficient Models and, for Txy/Pxy/xy diagrams, Phase Diagrams.

from difflow import WilsonParams

wilson = WilsonParams.binary(("methanol", "water"), [40.73e-6, 18.07e-6], 667.0, 1981.0)
th = IdealThermo({n: get_species_data(n) for n in ("methanol", "water")})
f = Flash(FlashParams(species_order=["methanol", "water"]), th, activity_model=wilson)
feed = make_stream({"methanol": 0.5, "water": 0.5}, T=345.0, P=101325.0)
liquid, vapor, info = f(feed)
print(f"Vapor fraction: {float(info['V_frac']):.3f}")
EOSFlash (Non-Ideal)#

Uses fugacity coefficients from cubic equations of state (Peng-Robinson or SRK) for non-ideal VLE.

from difflow.units.flash import EOSFlash, EOSFlashParams
from difflow.eos import PengRobinson, CriticalProperties

# Define species with critical properties
species_data = {
    "methane": CriticalProperties("methane", 190.6, 4.6e6, 0.011),
    "ethane": CriticalProperties("ethane", 305.4, 4.9e6, 0.099),
    "propane": CriticalProperties("propane", 369.8, 4.2e6, 0.152),
}
eos = PengRobinson(species_data)

params = EOSFlashParams(species_order=["methane", "ethane", "propane"], eos_type="PR")
flash = EOSFlash(params, eos)

feed = make_stream({'methane': 40.0, 'ethane': 30.0, 'propane': 30.0}, T=250.0, P=2e6)
liquid, vapor, info = flash(feed)
PHFlash (Isenthalpic)#

Performs adiabatic flash at constant pressure and enthalpy. Solves for flash temperature.

With an IdealThermo (ideal K-values) the inlet is taken as liquid at its own T, and the outlet T comes from a bracketed bisection on T_bounds over the exact (unsmoothed) Rachford-Rice split, so there is no guess and no wrong root (T_guess is ignored). A pure component let down into its two-phase region lands on its boiling point with the lever-rule vapour fraction: saturated water at 180 °C let down to 1.5 bar gives 111.2 °C and 12.6 % vapour (steam tables: 111.4 °C, 13.3 %). The old Newton on T returned the 150 K clip with no vapour. info carries residual and converged, and an unbracketed solve warns EOSUnitConvergenceWarning. With a CubicThermo it calls flash_PH_eos (see EOS units): the inlet enthalpy is the EOS enthalpy at the inlet T and P, and the split uses the EOS liquid and vapour compositions. info then also carries x, y, residual and converged, and T_bounds=(100, 1000) can be passed to the call. This path handles a pure component, where the flash lands on \(T_\mathrm{sat}\) with a lever-rule vapour fraction.

from difflow.units.flash import PHFlash, FlashParams

thermo_lh = IdealThermo({n: get_species_data(n) for n in ['n_pentane', 'n_heptane']})
ph_flash = PHFlash(FlashParams(species_order=['n_pentane', 'n_heptane']), thermo_lh)

# Hot liquid feed, flash to lower pressure
feed = make_stream({'n_pentane': 50.0, 'n_heptane': 50.0}, T=380.0, P=101325.0)
liquid, vapor, info = ph_flash(feed, P=30000.0)

print(f"Flash temperature: {info['T_flash']:.1f} K")
print(f"Vapor fraction: {info['V_frac']:.3f}")

Bubble and Dew Point Methods#

The Flash class provides methods for calculating phase boundaries. They use the flash’s own thermodynamics:

The temperature solves are Newton with implicit differentiation, started from the composition-weighted Antoine (or Wilson) estimate. T_guess is optional. The results are not clipped. A concrete solve that fails (or an EOS solve that lands on the trivial \(K=1\) root near the critical point) raises a BubbleDewConvergenceWarning. A temperature outside a species’ fitted Antoine range raises an AntoineRangeWarning.

flash = Flash(FlashParams(species_order=['n_pentane', 'n_heptane']), thermo_lh)
feed = make_stream({'n_pentane': 50.0, 'n_heptane': 50.0}, T=350.0, P=50000.0)

# Pressure calculations (at specified T)
P_bubble = flash.bubble_point_pressure(feed, T=350.0)  # First bubble forms
P_dew = flash.dew_point_pressure(feed, T=350.0)        # Last drop condenses

# Temperature calculations (at specified P)
T_bubble = flash.bubble_point_temperature(feed, P=50000.0)
T_dew = flash.dew_point_temperature(feed, P=50000.0)

print(f"Bubble point: T={T_bubble:.1f} K, P={P_bubble:.0f} Pa")
print(f"Dew point: T={T_dew:.1f} K, P={P_dew:.0f} Pa")

Mixer#

Location: difflow/units/flash.py

Class: Mixer

Description: Combines multiple inlet streams into a single outlet stream by summing molar flows.

Governing Equations#

Mass Balance:

\[F_{out,i} = \sum_k F_{k,i}\]

Energy Balance (adiabatic mixing). With a thermo model the outlet temperature solves the enthalpy balance exactly,

\[\sum_i F_{out,i}\, h_i(T_{out}) = \sum_k \sum_i F_{k,i}\, h_i(T_k),\]

by Newton’s method on \(T_{out}\) started at the flow-weighted mean temperature (the phase is fixed, so \(H\) is smooth), with a bracketed bisection as the fallback; jax.grad differentiates the converged root by the implicit function theorem. Starting at the mean picks the physical root when the enthalpy is not monotone: IdealThermo’s vapor branch adds a Watson heat of vaporization that vanishes at \(T_c\), so near a species’ critical temperature \(H_{vap}(T)\) can fall with \(T\) and the balance has a second, spurious root. Without thermo the outlet is at the flow-weighted mean temperature, which is the same balance for equal, constant heat capacities:

\[T_{out} = \frac{\sum_k F_k T_k}{\sum_k F_k}\]

(Before this was solved exactly, the thermo path evaluated one \(c_p\) at the mean inlet temperature: 1 mol/s of methane at 300 K mixed with 10 mol/s at 800 K came out at 752.7 K instead of 763.6 K.)

Pressure: the lowest inlet pressure.

Missing species: an inlet that does not carry a species in the mixer’s list contributes zero flow of it, as in combine_streams.

Example Usage#

from difflow.units.flash import Mixer

mixer = Mixer(['A', 'B', 'C'])   # optional: thermo=..., phase='liquid'
stream1 = make_stream({'A': 1.0, 'B': 0.5, 'C': 0.0}, T=350.0, P=101325.0)
stream2 = make_stream({'A': 0.0, 'B': 0.3, 'C': 0.2}, T=360.0, P=101325.0)

# Streams are passed as separate arguments; a species an inlet omits is zero
outlet, info = mixer(stream1, stream2)

Splitter#

Location: difflow/units/flash.py

Class: Splitter

Description: Divides a single inlet stream into multiple outlet streams based on split fractions.

Governing Equations#

\[F_{out,k} = \alpha_k F_{in}\]

Where \(\sum_k \alpha_k = 1\)

All outlet streams have the same composition and temperature as the inlet.

Example Usage#

from difflow.units.flash import Splitter

splitter = Splitter(species_order=['A', 'B'])
inlet = make_stream({'A': 1.0, 'B': 0.5}, T=350.0, P=101325.0)

# Split into 3 streams: 50%, 30%, 20%
out1, out2, out3, info = splitter(inlet, split_frac=[0.5, 0.3, 0.2])

ComponentSplitter#

Location: difflow/units/flash.py

Class: ComponentSplitter (parameters: ComponentSplitterParams)

Description: Ideal component splitter: a specified fraction \(\phi_i\) of each species goes to the first outlet and the rest to the second. The block-flow separator of a material-balance calculation (“all the methanol to the liquid, none of the gas”); no equilibrium and no thermodynamic model, so it is differentiable in the fractions and the feed. Unlike Splitter, each species has its own split. (ComponentSeparator is the same balance with an enthalpy duty and needs a CubicThermo.)

Governing Equations#

\[F^{(1)}_i = \phi_i F^{in}_i, \qquad F^{(2)}_i = (1 - \phi_i) F^{in}_i\]

Both outlets leave at the inlet temperature and pressure. Species missing from split_fractions use default_fraction (default 0: all to the second outlet).

Example Usage#

from difflow import ComponentSplitter, ComponentSplitterParams, make_stream

sep = ComponentSplitter(ComponentSplitterParams(
    split_fractions={"MeOH": 0.0, "H2O": 0.05}, default_fraction=1.0))
feed = make_stream({"CO": 10.0, "H2": 20.0, "MeOH": 5.0, "H2O": 1.0}, T=320.0, P=5e6)
gas, liquid, info = sep(feed)      # outlets in order: first, second
print(float(gas["F_CO"]), float(liquid["F_MeOH"]), float(gas["F_H2O"]))

Distillation#

ShortcutColumn#

Location: difflow/units/distillation.py

Class: ShortcutColumn

Description: Uses shortcut methods (Fenske-Underwood-Gilliland) for rapid distillation column design and rating calculations.

Process Role#

Shortcut methods are used for:

  • Initial column design estimates

  • Optimization studies

  • Screening alternatives

  • Quick sensitivity analysis

Parameters#

@dataclass
class ShortcutColumnParams:
    species_order: list[str]   # Species names; sets array ordering
    light_key: str             # Name of the light key component
    heavy_key: str             # Name of the heavy key component
    x_D_LK: float = 0.99       # Fractional recovery of LK in the distillate
    x_B_HK: float = 0.99       # Fractional recovery of HK in the bottoms

x_D_LK and x_B_HK are recoveries, not mole fractions, despite the names: x_D_LK=0.99 sends 99 % of the feed’s light key overhead. The distillate’s actual light-key mole fraction comes back in info["x_D"].

The reflux ratio is not a parameter — it is an argument to the call, along with the column pressure and the feed quality:

distillate, bottoms, info = column(feed, R=3.0, P=101325.0, q=1.0)

Governing Equations#

Relative Volatility:

\[\alpha_{ij} = \frac{K_i}{K_j}\]

The second equality usually written here, \(\alpha_{ij} = P_i^{sat}/P_j^{sat}\), holds only under Raoult’s law. On a CubicThermo the K-values are fugacity coefficient ratios and the vapor pressures cancel out of nothing — which is the whole reason the volatilities differ between the two packages.

Average Relative Volatility (geometric mean):

\[\bar{\alpha} = (\alpha_{top} \cdot \alpha_{bottom})^{0.5}\]

The two ends are the column’s actual ends, not estimates around the feed: the top is a total condenser, so \(T_{top}\) is the bubble point of the distillate, and \(T_{bot}\) is the bubble point of the bottoms in the reboiler. Those are the temperatures the product streams come out at, reported as info["T_condenser"] and info["T_reboiler"].

They also make the design a small fixed point, since \(\bar\alpha\) sets the product split through Hengstebeck-Geddes and the split sets the bubble points in turn. The column sweeps it a few times from the feed’s own bubble point; it settles to under a hundredth of a degree by the third sweep, and the whole loop is unrolled, so jax.grad runs through it.

Non-key distribution (Hengstebeck-Geddes). The keys go where their recoveries put them; every other species follows the straight line in \((\log\alpha, \log d/b)\) through both keys:

\[\log\frac{d_i}{b_i} = A + C\log\alpha_i,\qquad A = \log\left(\frac{d}{b}\right)_{HK},\qquad C = \frac{\log(d/b)_{LK} - \log(d/b)_{HK}}{\log\alpha_{LK}}\]

with \(\alpha\) relative to the heavy key, so \(\alpha_{HK} = 1\) (Geddes, AIChE J. 4, 389 (1958); Hengstebeck, Distillation, Reinhold (1961); page and equation numbers unverified). The distillate share \(d_i/(d_i + b_i)\) is the logistic function of that line, so each species’ balance closes exactly. Before this was corrected the constants were \(A = \log(d/b)_{LK} - \log(d/b)_{HK}\) and \(C = \log(d/b)_{LK}/\log\alpha_{LK}\), a line that misses the heavy key: on a propane/isobutane depropanizer it sent 99.9 % of the n-butane overhead.

Two consequences worth knowing, because the old estimate had neither: the end temperatures no longer move when you feed the same mixture in hotter, and they do move with column pressure.

Fenske Equation (minimum stages):

\[N_{min} = \frac{\ln\left[\frac{x_{D,LK}}{x_{B,LK}} \cdot \frac{x_{B,HK}}{x_{D,HK}}\right]}{\ln \bar{\alpha}_{LK/HK}}\]

Underwood Equations (minimum reflux):

For each component i: $\(\sum_i \frac{\alpha_i x_{F,i}}{\alpha_i - \theta} = 1 - q\)$

\[R_{min} + 1 = \sum_i \frac{\alpha_i x_{D,i}}{\alpha_i - \theta}\]

Where:

  • \(\theta\): Root between \(\alpha_{HK}\) and \(\alpha_{LK}\)

  • \(q\): Feed quality (1 for saturated liquid, 0 for saturated vapor)

The Fenske equation is the key-ratio (multicomponent) form above. Before the unit-ops book’s Chapter 13 the column used the pseudo-binary form, with \(1 - x_{LK}\) in place of \(x_{HK}\); a light non-key in the distillate (ethane over a propane/isobutane split) then made \(N_{min}\) 16 % too small. The Underwood root is found by bisection on the open interval between the heavy key and the next volatility above it, where the function is monotone; a Newton iteration used to leave that interval and be clipped to 1.001, which happened in about a quarter of random feeds. \(R_{min}\) is floored at 0 (it used to be floored at 0.1, which overstated an easy split).

Gilliland Correlation (actual stages):

\[\frac{N - N_{min}}{N + 1} = 1 - \exp\left[\frac{(1 + 54.4X)(X - 1)}{(11 + 117.2X)(X^{0.5})}\right]\]

Where: $\(X = \frac{R - R_{min}}{R + 1}\)$

Feed Stage Location (Kirkbride correlation):

\[\frac{N_R}{N_S} = \left[\frac{B}{D} \cdot \frac{x_{F,HK}}{x_{F,LK}} \cdot \left(\frac{x_{B,LK}}{x_{D,HK}}\right)^2\right]^{0.206}\]

Outputs#

Key

Type

Units

Description

distillate

Stream

-

Overhead product, at the condenser temperature

bottoms

Stream

-

Bottom product, at the reboiler temperature

info['N_min']

float

-

Minimum stages (Fenske)

info['R_min']

float

-

Minimum reflux ratio (Underwood)

info['N']

float

-

Actual stages (Gilliland)

info['N_feed']

float

-

Feed stage (Kirkbride), counted from the bottom and rounded

info['N_rect'], info['N_strip']

float

-

Continuous Kirkbride split of N above / below the feed (differentiable; the rounded N_feed has a zero derivative)

info['D'], info['B']

float

mol/s

Distillate and bottoms flow

info['x_D'], info['x_B']

dict

-

Product compositions by species

info['T_condenser'] / info['T_top']

float

K

Condenser temperature = bubble point of \(x_D\)

info['T_reboiler'] / info['T_bot']

float

K

Reboiler temperature = bubble point of \(x_B\)

info['Q_condenser']

float

W

Condenser duty (negative: heat removed)

info['Q_reboiler']

float

W

Reboiler duty (positive: heat added)

info['alpha']

dict

-

Relative volatilities vs the heavy key

info['alpha_LK']

float

-

Light key’s relative volatility

info['alpha_top'], info['alpha_bot']

dict

-

Volatilities at each column end

info['alpha_variation']

dict

-

Relative spread between the two ends

info['alpha_varies_significantly']

bool

-

True if that spread exceeds 0.3

info['theta']

float

-

Underwood root

info['close_boiling']

bool

-

True if \(\bar\alpha \approx 1\) capped \(N_{min}\)

info['near_min_reflux']

bool

-

True if \(R \approx R_{min}\)

info['negative_flows_detected']

bool

-

True if the split produced a negative flow

info['feasible']

bool

-

All three checks above passed

Example Usage#

from difflow import IdealThermo, make_stream
from difflow.database import get_species_data
from difflow.units.distillation import ShortcutColumn, ShortcutColumnParams

names = ['benzene', 'toluene', 'ethylbenzene']
thermo = IdealThermo({s: get_species_data(s) for s in names})

params = ShortcutColumnParams(
    species_order=names,
    light_key='benzene',
    heavy_key='toluene',
    x_D_LK=0.99,    # 99 % of the benzene recovered overhead
    x_B_HK=0.99,    # 99 % of the toluene recovered in the bottoms
)

column = ShortcutColumn(params, thermo)
feed = make_stream({'benzene': 40.0, 'toluene': 35.0, 'ethylbenzene': 25.0},
                   T=370.0, P=101325.0)

distillate, bottoms, info = column(feed, R=3.0, P=101325.0, q=1.0)
print(f"Minimum stages:  {float(info['N_min']):.1f}")
print(f"Minimum reflux:  {float(info['R_min']):.2f}")
print(f"Actual stages:   {float(info['N']):.1f}")
print(f"Feed stage:      {float(info['N_feed']):.1f}")
print(f"Condenser:       {float(info['T_condenser']):.1f} K, "
      f"{float(info['Q_condenser'])/1e6:.2f} MW")
print(f"Reboiler:        {float(info['T_reboiler']):.1f} K, "
      f"{float(info['Q_reboiler'])/1e6:.2f} MW")

Utility Functions#

The design correlations are also available on their own, taking plain numbers rather than a column object. Note the argument order — each takes the quantities in the order the correlation is written, not the order the column computes them:

from difflow.units.distillation import (
    fenske_stages,        # (x_D_LK, x_B_LK, alpha)
    minimum_reflux_ratio, # (z_LK, z_HK, x_D_LK, alpha, q=1.0)
    gilliland_stages,     # (R, R_min, N_min)
    column_diameter,      # DEPRECATED, see Column Hardware below
)

N_min = fenske_stages(x_D_LK=0.98, x_B_LK=0.02, alpha=2.4)
R_min = minimum_reflux_ratio(z_LK=0.45, z_HK=0.55, x_D_LK=0.98, alpha=2.4, q=1.0)
N = gilliland_stages(R=1.3 * R_min, R_min=R_min, N_min=N_min)

from difflow.units.column_design import tray_column_diameter
# vapor/liquid rates (mol/s), MW (g/mol), densities (kg/m^3), sigma (N/m)
diameter, _ = tray_column_diameter(V_molar=50.0, MW_V=80.0, rho_V=3.0,
                                   L_molar=50.0, MW_L=85.0, rho_L=800.0, sigma=0.02)

The multicomponent versions take arrays over every species (alpha on any basis; give alpha_HK on the same basis) and are differentiable, the Underwood root by its implicit derivative:

import jax.numpy as jnp
from difflow import (fenske_stages_multicomponent, fenske_distribution,
                     underwood_theta, underwood_minimum_reflux,
                     kirkbride_feed_stage, gilliland_stages)

# Geankoplis 5e, Examples 11.7-2 and 11.7-3 (keys B and C, 90 % recoveries)
K = jnp.array([3.12, 1.38, 0.60, 0.28]); z = jnp.array([0.40, 0.25, 0.20, 0.15])
x_D = jnp.array([0.6197, 0.3489, 0.0310, 0.0004])
x_B = jnp.array([0.0011, 0.0704, 0.5068, 0.4217])
N_min = fenske_stages_multicomponent(x_D[1], x_D[2], x_B[1], x_B[2], 2.258)  # 5.40
d, b, N_min_d = fenske_distribution(100 * z, K, 1, 2, 0.9, 0.9)  # non-keys by Fenske
R_min, theta = underwood_minimum_reflux(K / K[2], z, x_D, q=1.0)          # 0.395
N = gilliland_stages(1.5 * R_min, R_min, N_min, method="molokanov")
N_R, N_S = kirkbride_feed_stage(11.0, z[1], z[2], x_B[1], x_D[2], 35.52, 64.48)  # 5.96, 5.04

underwood_minimum_reflux is the one-root (class 1) form. When non-keys distribute at minimum reflux, as a species boiling between the keys always does, use underwood_class2. It finds one root per volatility gap spanned by the keys and the distributing species, and solves Underwood’s second equation for V_min and their distillate flows:

import jax.numpy as jnp
from difflow import underwood_class2
R_min, d, thetas = underwood_class2(jnp.array([4.0, 2.0, 1.5, 1.0]),   # alpha
                                    jnp.array([30.0, 30.0, 10.0, 30.0]),  # feed flows
                                    d_LK=29.0, d_HK=1.0, light_key=1, heavy_key=3)
# d[2] = 4.31 of 10: the intermediate splits; R_min = 1.00

gilliland_stages defaults to Eduljee’s fit; method="molokanov" is the fit ShortcutColumn uses. fenske_stages (above) is the pseudo-binary form, right only for a binary.

Relative volatility is a method on the column, not a free function (column.relative_volatility(T, P, x=None)), because it needs the key components from the column’s parameters — and, on a CubicThermo, the phase composition.

minimum_reflux_ratio honours the feed quality q (1 saturated liquid, 0 saturated vapor, above 1 subcooled): since #424 it is the McCabe-Thiele pinch of a constant-alpha curve, computed by difflow.staged.minimum_reflux. It used to accept q and ignore it, and to floor the answer at 0.1; neither is true now (a result below 0.1, or below 0 for an x_D the curve cannot reach, is returned as is). z_HK is unused: the feed is 1 - z_LK. The column itself (ShortcutColumn) uses the Underwood root directly and was not affected. For a curved equilibrium line (tangent pinch) use difflow.staged.minimum_reflux with a tabulated curve, see Graphical Staged Design.


DistillationColumn (Rigorous)#

Location: difflow/units/distillation.py

Class: DistillationColumn

Description: Stage-by-stage calculation using MESH equations (Material balance, Equilibrium, Summation, enthalpy balance).

Parameters#

@dataclass
class DistillationColumnParams:
    species_order: list[str]     # Species names; sets array ordering
    n_stages: int                # Equilibrium stages: the trays plus the reboiler
    feed_stage: int              # Zero-based stage index from the bottom
    condenser_type: str = 'total'  # 'total' or 'partial' (an extra equilibrium stage)
    P: float = 101325.0          # One pressure for the whole column
    q: float = 1.0               # Feed quality (1 = saturated liquid)
    stage_efficiency: float | Array = 1.0  # Murphree vapour efficiency, scalar or (n_stages,)

Stage numbering is zero-based from the bottom: stage 0 is the reboiler, stage n_stages - 1 is the top tray, and every profile in info is in that order. The condenser is not a stage — it sits outside the cascade — so n_stages counts the trays plus the reboiler, and feed_stage=10 in a 20-stage column puts the feed halfway up. Stages below the feed stage are the stripping section and stages above it the rectifying section. There is one column pressure: no tray pressure drop.

The feed stage itself is split by the feed, which contributes q F to the liquid running down and (1 - q) F to the vapour running up. Its liquid is therefore a stripping-section flow and its vapour a rectifying-section one:

\[\begin{split}L_j = \begin{cases} L' = L + qF & j \le n_F \\ L = R D & j > n_F \end{cases} \qquad V_j = \begin{cases} V' = V - (1-q)F & j < n_F \\ V = (R+1) D & j \ge n_F \end{cases}\end{split}\]

That is one convention, not two: both flows follow from asking whether the horizontal cut a stream crosses has the feed above it. _is_rectifying_cut and _cmo_section_flows in difflow/units/distillation.py are the single definition, and _cmo_flows — the only thing that builds an L/V profile — is a thin wrapper over them, so both solver paths read the same boundary.

q sets those rates on both paths, and it is also the feed’s thermal condition in the energy balance:

\[h_F = q\, h^L(z, T_F) + (1 - q)\, H^V(z, T_F)\]

both phase enthalpies at the feed stream’s own temperature. So a saturated-vapour feed arrives with its latent heat already in it and the reboiler is not charged for it: going from q = 1 to q = 0 drops Q_reboiler by F (H^V - h^L) at the feed temperature (2.4 MW on a 100 mol/s equimolar benzene/toluene feed at 380 K), and moves the ~F step in the converged profile from L to V across the feed stage. The shortcut column forms its feed enthalpy the same way, from the q passed to the call.

q outside [0, 1] is allowed, and means what a textbook means by it: q > 1 is a subcooled feed, q < 0 a superheated one. It has to be allowed, because on the CMO path (use_mesh=False) q is the only place either can be said — _cmo_section_rates is the whole model there, and T_feed never reaches it. L_strip = L_rect + q F with q > 1 is exactly how the extra internal reflux of a subcooled feed is written. Underwood’s equation takes it as written too.

The one place it is clamped is the feed enthalpy, and that clamp is the physics rather than a guard. Both phase enthalpies are evaluated at the feed’s own temperature, so at q = 1.3 the honest answer is h_liquid(z, T_feed): an all-liquid feed below its bubble point, with the subcooling carried by T_feed. Forming 1.3 h^L - 0.3 H^V would subtract three tenths of a latent heat that is not there, and count the departure from saturation twice.

Partial condenser and Murphree efficiency#

condenser_type='partial' makes the condenser an equilibrium stage at the column pressure, outside n_stages. Its vapour is the distillate and its liquid is the reflux:

\[(R+1)\,D\,y_{i,top} = D\,y_{D,i} + R\,D\,x_{R,i},\qquad y_{D,i} = K_i(T_c)\,x_{R,i},\qquad \sum_i x_{R,i} = 1\]

so \(T_c\) is the dew point of \(y_D\) (and the bubble point of \(x_R\)). The condenser is one more row of the same tridiagonal system, so the summed balances still telescope to \(D y_D + B x_B = F z\). Stage counting: the profiles in info keep their n_stages rows (nothing shifts when you switch the condenser type), the condenser is reported separately as info['y_D'], info['x_R'] and info['T_condenser'], and info['n_equilibrium_stages'] is n_stages + 1. The distillate stream is the vapour at T_condenser. Q_condenser is the duty on the partial basis: it removes the latent heat of the reflux only, not of the vapour distillate. Under CMO a partial condenser on N stages is exactly a total condenser on N + 1 stages. A mixed (vapour + liquid distillate) condenser, condenser_type='mixed', is not implemented and raises NotImplementedError; there is no separate condenser pressure (P_condenser) either, the column has one P.

stage_efficiency is a Murphree vapour efficiency, a scalar or an (n_stages,) array (the name and the 0-1 convention of CascadeParams.stage_efficiency in the LLE unit):

\[y_{i,j} = y_{i,j-1} + E_j\,\bigl(K_{i,j}\,x_{i,j} - y_{i,j-1}\bigr)\]

with \(y_{j-1}\) the actual vapour leaving the stage below. The reboiler (stage 0) and a partial condenser are equilibrium stages, so their \(E\) is 1 whatever is given for stage 0. The default \(E = 1\) reproduces the previous results bit for bit (it takes the original code path). The MESH energy balance uses the actual vapour compositions. Each iteration solves the balance and the Murphree relation together as a block-tridiagonal system (so it is well posed at \(E = 0\), where the trays do nothing and the column is a flash at the reboiler), and the iteration is a fixed-count homotopy: converge at \(E = 1\), step \(E\) to its target in four steps, iterate at the target. It is differentiable in \(E\) (scalar or one element of the array), in \(R\), and on both solver paths. info['overall_efficiency_equiv'] is a Fenske-style proxy for equivalent theoretical stages over actual stages: the ratio of \(\ln\) of the light/heavy key separation factor (keys = most and least volatile species at the feed bubble point) to the same column’s at \(E = 1\). It is not the theoretical-stage ratio itself and is meant as a trend to compare with an O’Connell estimate.

What was checked, and against what:

  • Constant-\(\alpha\) binary under CMO (use_mesh=False) with \(E = 0.7\), total and partial condenser: the tray liquids equal those of difflow.staged.mccabe_thiele on the pseudo-equilibrium curve to 1e-10 (observed ~1e-15), and the component balance closes to 1e-12 at cmo_iter=400.

  • \(E \to 0\) against an independent flash with vapour fraction \(D/F\).

  • Partial vs total on \(N + 1\) stages (CMO), distillate dew point against an independent root find, check_grads on the CMO and MESH paths.

  • Not checked against a published worked example (Wankat, Seader): their inputs and printed answers were not available, so no agreement with a textbook number is claimed. The depropanizer test (ethane to n-pentane on Peng-Robinson at 17 bar, 20 stages, partial condenser) uses an illustrative feed, not the book’s wex13-1 data: it checks convergence and closure only.

from difflow import IdealThermo, make_stream
from difflow.database import get_species_data
from difflow.units.distillation import DistillationColumn, DistillationColumnParams

names = ['benzene', 'toluene']
thermo = IdealThermo({s: get_species_data(s) for s in names})
feed = make_stream({'benzene': 50.0, 'toluene': 50.0}, T=365.0, P=101325.0)

for cond, eff in [('total', 1.0), ('partial', 1.0), ('total', 0.7)]:
    column = DistillationColumn(
        DistillationColumnParams(species_order=names, n_stages=10, feed_stage=5,
                                 condenser_type=cond, stage_efficiency=eff),
        thermo=thermo,
    )
    distillate, bottoms, info = column(feed, R=2.5, D_spec=50.0, use_mesh=False,
                                       cmo_iter=100)
    print(f"{cond:7s} E={eff}: x_D(benzene) = "
          f"{float(distillate['F_benzene'] / 50.0):.4f}, "
          f"T_cond = {float(info['T_condenser']):.1f} K, "
          f"equilibrium stages = {int(info['n_equilibrium_stages'])}")

The reflux ratio and the product split are arguments to the call, not parameters — give exactly one of D_spec or B_spec:

distillate, bottoms, info = column(feed, R=2.0, B_spec=40.0)

To design to product specifications instead, column.solve_specs(feed, specs) solves for R and D from any two of ("x_D", species, value), ("x_B", species, value), ("recovery_D", species, value), ("recovery_B", species, value), ("R", value), ("D", value) and ("B", value). It runs a damped Newton iteration and attaches the implicit-function derivative, so jax.grad of, say, info["R"] with respect to a purity specification is the design sensitivity. info["spec_converged"] is False for an infeasible pair:

distillate, bottoms, info = column.solve_specs(
    feed, [("x_D", "benzene", 0.95), ("x_B", "toluene", 0.95)])
R, D = info["R"], info["D"]

Governing Equations (MESH)#

For each stage j:

Material Balance: $\(L_{j-1} x_{i,j-1} + V_{j+1} y_{i,j+1} + F_j z_{i,j} = L_j x_{i,j} + V_j y_{i,j}\)$

Equilibrium: $\(y_{i,j} = K_{i,j} x_{i,j}\)$

Summation: $\(\sum_i x_{i,j} = 1\)\( \)\(\sum_i y_{i,j} = 1\)$

Enthalpy Balance: $\(L_{j-1} H^L_{j-1} + V_{j+1} H^V_{j+1} + F_j H^F_j = L_j H^L_j + V_j H^V_j + Q_j\)$

Product temperatures#

The bottoms leaves the reboiler, which is a stage, so it is at info["T_profile"][0]. The distillate does not leave a stage — it leaves the condenser. A total condenser condenses the whole of the top stage’s vapor, so the distillate (and the reflux returned with it) is a saturated liquid of composition \(x_D\) at its own bubble point, reported as info["T_condenser"].

That is not the top stage temperature. The top stage sits at the bubble point of its liquid \(x_{top}\), equivalently the dew point of the vapor \(y_{top} = x_D\) it sends up, and a mixture’s dew point is above its bubble point. The gap is the boiling range of the distillate itself:

distillate

top stage \(T\)

condenser \(T\)

gap

99.9999 % benzene / toluene, 1 atm

368.665 K

368.665 K

0.0001 K

C3-C8 cut, 10 bar (Peng-Robinson)

400.6 K

362.9 K

38 K

A one-component distillate has no boiling range and so no gap, which is why the binary row reads as zero: at \(\alpha \approx 8\) over 15 stages that column takes 27 µmol/s of toluene overhead and nothing more. The gap is a property of the cut, not of the column.

The same distinction runs through the energy balance: \(Q_{cond}\) takes the top stage vapor down to that condensed state, and the reflux re-enters the top stage subcooled, at the condenser temperature rather than the tray’s.

Thermodynamics: ideal K-values or a cubic EOS#

The column takes either an IdealThermo or a CubicThermo, and the choice is the whole of the difference between a near-ideal separation and a hydrocarbon one:

IdealThermo

CubicThermo

\(K_i\)

\(P^{sat}_i(T)/P\) (Raoult)

\(\hat\phi^L_i(T,P,x)\,/\,\hat\phi^V_i(T,P,y)\) (PR or SRK)

stage enthalpy

ideal-gas \(C_p\) + Watson \(H_{vap}\)

ideal-gas \(C_p\) + EOS departure

\(K_i\) depends on composition

no

yes

from difflow import IdealThermo, CubicThermo, PengRobinson, make_stream
from difflow.database import get_critical_props, get_species_data
from difflow.units.distillation import DistillationColumn, DistillationColumnParams

names = ["propane", "isobutane", "n_butane", "isopentane",
         "n_pentane", "n_hexane", "n_heptane", "n_octane"]
ideal = IdealThermo({s: get_species_data(s) for s in names})
eos = PengRobinson({s: get_critical_props(s) for s in names})

column = DistillationColumn(
    DistillationColumnParams(species_order=names, n_stages=20, feed_stage=10,
                             condenser_type="total", P=10e5),
    thermo=CubicThermo(ideal, eos),      # Peng-Robinson K-values and enthalpies
)
feed = make_stream(
    dict(zip(names, [10.0, 7.0, 7.0, 8.0, 8.0, 20.0, 10.0, 30.0])),
    T=380.0, P=10e5,
)
distillate, bottoms, info = column(feed, R=2.0, B_spec=40.0)

Use the EOS for light hydrocarbons at pressure. At moderate pressure Raoult’s law with accurate vapor pressures is close: for this C3-C8 cut at 10 bar the Raoult and Peng-Robinson bubble points agree to about 1.4 K at the light end and 0.7 K at the heavy end. The gap grows as the stages approach the critical region, where the vapor is far from ideal and the Antoine fits are extrapolated past their ranges (which raises AntoineRangeWarning); there only the EOS K-values are meaningful.

Two things about the EOS path are worth knowing:

  • The K-values depend on composition, so they are a fixed point rather than a formula. CubicThermo.K_values_array(T, P, x) runs a short successive substitution on \(y\) internally to close it at the stage’s own composition; pass both x and y if you already have a consistent pair.

  • They only exist where the cubic has two roots. Away from the bubble point – a subcooled liquid, a superheated vapor – there is one root, both phases take it, and \(K_i\) comes back identically 1. That is the EOS reporting a single phase, but it makes \(\sum_i K_i x_i - 1\) flat, so the column solves each stage’s bubble point in two passes: a Newton solve on ideal K-values to land inside the two-root window, then a step-capped Newton on the EOS K-values that bisects back if a step leaves it. Nothing about this is visible in the API, but it is why the bubble point is not one optimistix call.

The ideal pass is itself in two parts, for a reason that has nothing to do with the EOS. Vapor pressure is exponential in \(-1/T\), so a couple of hundred degrees below the bubble point both \(\sum_i K_i x_i\) and its slope are round-off away from zero, and a Newton step there divides one tiny number by another and lands tens of thousands of degrees away. So the solve first takes a few damped steps on \(\log \sum_i K_i x_i\) – nearly linear in \(1/T\), and well scaled over the whole range – and only then runs Newton on the residual itself. This is why a column can be handed a feed far below its own boiling point and still find its ends.

Solver Paths#

Both paths run the Wang-Henke bubble-point iteration, which solves the component material balances for the whole column as a tridiagonal system. They differ only in where the L/V profiles come from:

use_mesh

L/V profiles

Cost

True (default)

corrected each iteration by the stage enthalpy balances

~30 % more

False

frozen at their constant-molar-overflow values, \(L' = L + qF\) and \(V' = V - (1-q)F\)

cheaper

The CMO path is a shortcut in the energy balance, not in the material balance: because the tridiagonal solve is the component balance, summing it over the stages telescopes to \(D x_{D,i} + B x_{B,i} = F z_i\). Both paths satisfy that only to within their iteration count, so both report the residual:

from difflow import IdealThermo, make_stream
from difflow.database import get_species_data
from difflow.units.distillation import DistillationColumn, DistillationColumnParams

names = ['n_pentane', 'n_hexane', 'n_heptane']
column = DistillationColumn(
    DistillationColumnParams(species_order=names, n_stages=20, feed_stage=10,
                             P=101325.0),
    thermo=IdealThermo({s: get_species_data(s) for s in names}),
)
feed = make_stream({'n_pentane': 30.0, 'n_hexane': 40.0, 'n_heptane': 30.0},
                   T=360.0, P=101325.0)

distillate, bottoms, info = column(feed, R=2.0, B_spec=40.0, use_mesh=False)
print(info['balance_error'])      # (n_species,) D_i + B_i - F_i, mol/s
print(info['balance_error_rel'])  # max |error| / F_total

If balance_error_rel is larger than your problem tolerates, raise cmo_iter (default 30). The residual falls geometrically with it, but at a rate the column sets, so treat the reported number as the answer rather than assuming a count is enough:

case

cmo_iter 30

60

100

20-stage ternary, \(R = 2\)

1.6e-4

5.0e-10

—

12-stage binary, \(R = 1.2\) (near \(R_{min}\))

1.9e-4

1.8e-4

1.7e-4

Near minimum reflux the fixed-point iteration converges with a rate close to one, and more sweeps buy almost nothing. That is a property of the column, not a defect in the solver — but it is exactly why the residual is reported instead of asserted.

Outputs#

Key

Type

Units

Description

distillate

Stream

-

Overhead product, at the condenser temperature

bottoms

Stream

-

Bottom product, at the reboiler temperature

info['T_profile']

(n,) array

K

Stage temperatures, reboiler first

info['x_profile']

(n, nc) array

-

Liquid compositions per stage

info['y_profile']

(n, nc) array

-

Vapor compositions per stage

info['T_condenser']

float

K

Condenser temperature = distillate T

info['T_reboiler']

float

K

Reboiler temperature = bottoms T = T_profile[0]

info['D'], info['B']

float

mol/s

Distillate and bottoms flow

info['Q_condenser']

float

W

Condenser duty (negative: heat removed)

info['Q_reboiler']

float

W

Reboiler duty (positive: heat added)

info['L_profile'], info['V_profile']

(n,) array

mol/s

Internal flows — use_mesh=True only

info['L_rect'], info['V_rect']

float

mol/s

Rectifying flows — use_mesh=False only

info['balance_error']

(nc,) array

mol/s

Component balance residual \(D_i + B_i - F_i\)

info['balance_error_rel']

float

-

max abs(balance_error) / F_total

info['n_equilibrium_stages']

int

-

n_stages, plus one for a partial condenser

info['overall_efficiency_equiv']

float

-

Fenske-style efficiency proxy (1 at \(E = 1\)), see above

info['y_D'], info['x_R']

(nc,) array

-

Partial condenser only: vapour distillate and reflux compositions

Example Usage#

from difflow import IdealThermo, make_stream
from difflow.database import get_species_data
from difflow.units.distillation import DistillationColumn, DistillationColumnParams

names = ['n_pentane', 'n_hexane', 'n_heptane']
thermo = IdealThermo({s: get_species_data(s) for s in names})

column = DistillationColumn(
    DistillationColumnParams(
        species_order=names,
        n_stages=20,      # 19 trays plus the reboiler
        feed_stage=10,    # halfway up, counting from the reboiler at 0
        P=101325.0,
    ),
    thermo=thermo,
)

feed = make_stream({'n_pentane': 30.0, 'n_hexane': 40.0, 'n_heptane': 30.0},
                   T=360.0, P=101325.0)

distillate, bottoms, info = column(feed, R=2.0, B_spec=40.0)
print(f"Distillate: {float(distillate['T']):.1f} K, "
      f"{float(distillate['F_n_pentane']):.1f} mol/s n-pentane")
print(f"Bottoms:    {float(bottoms['T']):.1f} K, "
      f"{float(bottoms['F_n_heptane']):.1f} mol/s n-heptane")
print(f"Duties:     {float(info['Q_condenser'])/1e6:.2f} / "
      f"{float(info['Q_reboiler'])/1e6:.2f} MW")

Pass use_mesh=False for the faster constant-molar-overflow solution, which skips the energy-balance correction to the internal flows but solves the same component material balances — see Solver Paths above.

BatchDistillation#

A batch of F mol is charged to a still, boiled at a constant rate V, and the vapor passes through N equilibrium stages to a total condenser that returns reflux and draws distillate D = V/(R+1). The model is quasi-steady (no stage or condenser holdup), constant molar overflow, binary. With the still holdup W and its key fraction x_W:

\[ \frac{dW}{dt} = -D, \qquad \frac{d(W x_W)}{dt} = -D\,x_D \quad\Longrightarrow\quad \ln\frac{F}{W} = \int_{x_W}^{x_F} \frac{dx}{x_D(x) - x}. \]

For N = 0 the still is in equilibrium with its distillate, x_D = y*(x_W), and this is the Rayleigh equation ln(F/W) = int dx/(y* - x); for constant relative volatility it has the closed form ln(F/W) = [ln(x_F/x_W) + alpha ln((1-x_W)/(1-x_F))]/(alpha - 1). difflow.units.batch_distillation.rayleigh uses that form for constant_alpha and a 64-point Gauss-Legendre quadrature (in ln x, where the integrand stays smooth as x_W -> 0) for any other difflow.staged.EquilibriumCurve; give W_over_F or x_W and it solves for the other. Past an azeotrope the integral diverges: it returns ln_F_over_W = inf and ok = False, never NaN.

With trays, x_D(x_W) is the McCabe-Thiele stage relation inverted: from y_1 = x_D step x_n = x*(y_n), y_{n+1} = [R x_n + x_D]/(R+1) through the N trays, then the still, x_W = x*(y_{N+1}). A Murphree vapor efficiency (stage_efficiency) applies to the trays, not the still.

  • mode="constant_reflux" (R): D is constant, so t = (F - W)(R+1)/V, and the distillate composition falls with time.

  • mode="constant_composition" (x_D_spec, light_key): x_D is fixed, so W/F = (x_D - x_F)/(x_D - x_W) exactly; the reflux R(x_W) is found at each still composition (a scalar root find with implicit gradients) and rises with time; t = (1/V) int (R+1) dW, and info["boilup_total"] = int V dt is the vapor generated.

Charge convention. charge is a stream read as moles (F_i in mol, not mol/s), the initial contents of the still, as FedBatchReactor reads its initial state. The still and distillate outlets are final holdup and accumulated product in moles.

Stopping is data, not control flow: stop is a dict with any of "x_W" (final still composition), "x_D_avg" (accumulated distillate purity), "distillate" (mol) and "time" (s) (t_final= is shorthand); the first one reached ends the batch. The stopping composition is a bisection followed by an implicit-function Newton step, so jax.grad of any result with respect to the spec, R, boilup, the charge or the efficiency follows dt*/d theta = -(dg/d theta)/(dg/dt).

import jax
import jax.numpy as jnp
from difflow.staged import constant_alpha
from difflow.units.batch_distillation import (
    BatchDistillation, BatchDistillationParams, rayleigh)

curve = constant_alpha(2.5)                    # an illustration, not a published example
r = rayleigh(curve, 0.5, W_over_F=0.4)         # simple batch distillation
print(float(r["x_W"]), float(r["x_D_avg"]))

def run(R, mode="constant_reflux"):
    p = BatchDistillationParams(species_order=["a", "b"], n_stages=3, R=R,
                                boilup=2.0, mode=mode, x_D_spec=0.9)
    unit = BatchDistillation(p, curve=curve)
    charge = {"F_a": 50.0, "F_b": 50.0, "T": 350.0, "P": 1e5}      # mol
    return unit(charge, stop={"x_W": 0.2}, n_traj=11)

still, distillate, info = run(3.0)
print(float(info["t_batch"]), float(info["x_D_avg"]), float(info["recovery"]))
# balances close: charge = still + distillate
print(float(still["F_a"] + distillate["F_a"]))                      # 50.0

# constant composition: x_D held at 0.9, reflux rises
_, _, cc = run(1.0, "constant_composition")
print(float(cc["x_D"][0]), float(cc["R"][0]), float(cc["R"][-1]))

# design with gradients: d(recovery)/dR at fixed final still composition
print(float(jax.grad(lambda R: run(R)[2]["recovery"])(3.0)))

For ethanol-water give BatchDistillation(params, thermo, activity_model) (NRTL). The distillate cannot pass the azeotrope: info["x_azeotrope"] reports it, info["azeotrope_limited"] is True when the distillate comes within 0.01 of it (or a constant-composition spec lies beyond it) and info["feasible"] is False when the operation cannot be carried out (a spec beyond the azeotrope or beyond what the stages reach, a charge on the far side of the azeotrope, the wrong key). The numbers stay finite; read the flags.

batch_cuts(unit, charge, cut_specs) runs successive cuts, each charged with the previous still (heads, hearts, tails). Each cut is a stop dict, or {"stop": {...}, "R": 6.0} with per-cut parameter overrides (the reflux policy); it returns each cut’s moles and key composition, the residue and a balance that is zero to round-off. rayleigh_multicomponent(thermo, species, x_F, W_over_F, P) integrates dx_i/d ln W = y_i - x_i with y_i = K_i(T_b) x_i by fixed RK4 steps.

Assumptions: binary, total condenser, constant molar overflow, no stage or condenser holdup (holdup must be 0), the key must be the more volatile component. Not provided: multicomponent tray-to-tray columns, holdup dynamics, optimal reflux policies other than the two classic modes, inverted or middle-vessel columns.

What was and was not verified (when the model was written): the constant-alpha closed form and scipy.integrate.quad; the quadrature path against the closed form to 1e-5; a separate NumPy/SciPy time integration of the batch balances (solve_ivp, tray march by brentq) for constant reflux with and without Murphree efficiency; the exact W/F and a NumPy quadrature of the time for constant composition; mass balances; check_grads. No published textbook worked example (Geankoplis Sec. 11.3, McCabe-Smith-Harriott Ch. 21, Wankat Ch. 9) is reproduced: the numbers could not be retrieved, so none is quoted. tests/test_batch_distillation.py keeps the mass balance and finite gradients.


Graphical Staged Design#

Location: difflow/staged.py (a library of functions; not a palette operation, because it designs a column rather than transforming a stream).

difflow.staged is the generic equilibrium-stage graphics and algebra for a binary (or pseudo-binary) cascade: operating lines, the q-line, a smooth McCabe-Thiele stepper with a fractional, differentiable stage count, minimum reflux from the pinch, total reflux, Kremser, and a plot helper. The same stepper serves distillation, absorption/stripping and immiscible extraction: it takes any equilibrium curve and any pair of straight operating lines.

Lines are (a, b, c) arrays meaning a x + b y = c (never slope-intercept, so the vertical q-line at q = 1 needs no special case). A curve is an EquilibriumCurve pytree built by constant_alpha(alpha), linear_equilibrium(m) or tabulated(x, y) (monotone PCHIP, C1, with an inverse; xy_curve output goes straight in); curve.with_efficiency(E) adds a Murphree vapor efficiency.

McCabe–Thiele#

Benzene–toluene at constant alpha 2.5 (an illustration, not a published example): z = 0.4, x_D = 0.95, x_B = 0.05, saturated-liquid feed.

import jax
import jax.numpy as jnp
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)        # 1.4444
N_min = S.total_reflux_stages(curve, xD, xB)       # 6.4269 (Fenske)

def column(R, q=1.0):
    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))

res = column(1.5 * R_min)
print(float(R_min), float(N_min))
print(float(res["N"]), float(res["feed_stage"]))   # 11.72 stages, feed on 6
dNdR = jax.grad(lambda R: column(R)["N"])(1.5 * R_min)
print(float(dNdR))                                 # negative and finite

mccabe_thiele steps from the top with a fixed-length lax.scan: from x_{k-1} the operating line gives y_k, and the curve gives x_k = x*(y_k); steps past the bottom are masked, not branched on. The result holds N, N_rect, N_strip, a fractional feed_stage, the staircase vertices, and a soft pinched flag.

The fractional-stage convention. Stage k is credited the fraction of its step needed to reach x_B: whole stages count 1, stages past the end 0, and the crossing stage counts the linear interpolation in x between its two compositions (fraction="linear", the textbook “fraction of a stage”). fraction="log" assumes geometric steps (exact for straight lines, so it reproduces Kremser) and "log_ratio" the same in x/(1-x) (exact Fenske). The count is continuous in every input but only piecewise smooth: its derivative jumps where one more whole stage is needed, and dN/dR depends on the convention, so state it with the answer. The optimal-feed switch is a sigmoid in x of width feed_width. A reboiler is the last stage; with partial_condenser=True the first stage is the condenser and N_column = N - 1. With a Murphree efficiency on the curve, reboiler_equilibrium=True steps the trays to the pseudo-equilibrium curve but draws the last step (the reboiler) to the true curve, the textbook convention and the one DistillationColumn uses (its reboiler is always an equilibrium stage); the default applies \(E\) to every step. N can jump by a fraction of a stage where the reboiler changes index. With open steam, build the bottom line with stripping_line(..., open_steam=True) (through (x_B, 0)). A subcooled feed is q > 1. Specifying a feed stage earlier than the optimal one extends the stripping line into a region where it lies above the curve; the stepper does not guard against that.

Side draws, side feeds and several feeds. Pass the operating lines of every section, from the top down, as lines=[...] (with top_line=None and bottom_line=None). Give one switch per pair of adjacent lines: switch_x (the composition where the two lines meet, or a feed’s q-line crossing; a sigmoid like the optimal feed) or switch_stages (stage numbers). Build each line from its section balance with operating_line(L, V, x_out, y_in) or as (L, -V, c) directly. For a liquid side draw S at x_S from the rectifying section, the line below it is V y = (L - S) x + D x_D + S x_S, and it meets the top line at x_S. The result adds switch_stage (fractional stage of each switch) and N_sections (stages on each line).

Pinches. minimum_reflux takes the largest chord slope (x_D - y*(x))/(x_D - x) from (x_D, x_D) to the curve over [x_q, x_D), x_q being where the q-line meets the curve. A constant-alpha curve is concave, so that is the feed-line pinch (the closed form R_min = (x_D - y_f)/(y_f - z) at q = 1); a curved curve such as ethanol–water has an interior maximiser, the tangent pinch, with no special case. The maximiser is found with a stop-gradient search and the value re-evaluated there, so jax.grad follows the envelope theorem. The result is not floored. minimum_solvent_ratio(curve, x_in, x_out, y_in) is the same construction for absorption and extraction.

import numpy as np
from difflow import staged as S

# A synthetic curve whose volatility falls from 10 to 1 with x (like
# ethanol-water up to its azeotrope, but not data for it): the chord from
# (x_D, x_D) touches it in the interior, so R_min exceeds the feed-line value.
x = np.linspace(0.0, 1.0, 81)
alpha_x = 1.0 + 9.0 * (1.0 - x) ** 4
r = alpha_x * x / (1.0 - x + 1e-300)
y = r / (1.0 + r)
y[-1] = 1.0
curve = S.tabulated(x, y)
z, xD = 0.1, 0.7
R_min = S.minimum_reflux(curve, z, 1.0, xD)
y_feed = curve(z)
R_feed = (xD - y_feed) / (y_feed - z)
print(round(float(R_min), 3), round(float(R_feed), 3))   # 1.059 0.796: tangent pinch governs

Kremser#

kremser_fraction(A, N) is the recovery of N counter-current stages at absorption (or extraction) factor A = L/(m V), and kremser_stages(A, f) the inverse, N = ln((A - f)/(1 - f))/ln A - 1. Both hold for any A > 0 (for A < 1 the recovery tends to A, and kremser_stages returns inf for f >= A) and join smoothly across A = 1 with a Taylor blend, not a hard branch, so the gradient is continuous there. They are re-exported from difflow.units.lle and difflow_ree; difflow_ree.stages_kremser is a wrapper on them (it used to return 1.0 for every input; see the CHANGELOG; it keeps its floor at one stage).

from difflow.staged import kremser_stages, kremser_fraction

print([round(float(kremser_stages(E, 0.99)), 2) for E in (1.5, 2.0, 5.0)])  # [8.7, 5.66, 2.72]
print(float(kremser_fraction(1.0, 3.0)))   # 0.75, the A -> 1 limit N/(N+1)

The worked notebook is examples/48_mccabe_thiele_staged.ipynb (stepper vs DistillationColumn, optimum R/R_min by gradient).

What was and was not verified. When the model was written, the stepper was checked against a plain-Python staircase, minimum_reflux against the closed-form pinch at any q and (for the tangent pinch) against SciPy PCHIP on a dense grid, total_reflux_stages against Fenske, the stepper against Kremser at straight lines, and the Murphree efficiency against the Lewis relation E_OG = ln(1 + E_MV(lambda - 1))/ln(lambda) (overall efficiency E_OG = N_equilibrium/N_actual). The wex12-1 numbers are the issue’s own hand calculation. No published textbook example (Geankoplis, McCabe–Smith– Harriott, Wankat) was reproduced: the worked-example inputs and printed answers were not available, so no agreement is claimed. Out of scope: Ponchon–Savarit, multicomponent graphical methods, side draws and a second feed.


Absorption and Stripping#

Physical-solvent gas absorbers and strippers: a gas of carrier plus dilute solutes rises through a column against a liquid solvent (gas_in at the bottom, liquid_in at the top). The keys of params.henry are the transferring solutes; every other species stays on the side it enters (the carrier in the gas, the solvent in the liquid; give them different names). Reactive absorption (amines, loading) is the job of the carbon-capture plugin: see AmineAbsorber and AmineStripper in Carbon Capture Unit Operations. The generic units here are physical (Henry’s law) and are not a replacement for them.

Equilibrium is Henry’s law in mole fractions, y_i = K_i x_i, K_i = H_i(T)/P, with a van ‘t Hoff dependence H(T) = H_ref exp(-C (1/T - 1/T_ref)) (C = -dH_abs/R, positive for an exothermic absorption). By default the column is isothermal; the heat of absorption and the adiabatic temperature rise it would cause are reported in info, not applied. energy_balance=True applies them stage by stage (see the stage energy balance below), and a stripper with live steam condenses the steam its heat duty needs (see the Stripper). difflow.units.absorption.HENRY_WATER holds a small table (acetone, NH3, CO2, H2S, SO2, TCE, benzene in water near 25 C) transcribed from memory, unverified, +/-20-30 % (more for the temperature coefficients), plus CH4 from Sander (2015, Atmos. Chem. Phys. 15, 4399): H^cp = 1.4e-5 mol/(m3 Pa), d ln H^cp/d(1/T) = 1600 K, i.e. H = 55343/H^cp = 3.95e9 Pa on the mole-fraction basis. Use measured data for design.

Absorber#

Location: difflow/units/absorption.py

Class: Absorber(params)(gas_in, liquid_in) -> (gas_out, liquid_out, info), with AbsorptionParams (shared with Stripper; not named AbsorberParams so that it cannot shadow difflow_cc.AbsorberParams in the serializer’s by-name lookup).

Three models, chosen by params.model:

"kremser" (closed form per solute). With the effective absorption factor A_i = L/(K_i V) (the geometric mean of its top and bottom values, so the flow change along the column is carried, the usual textbook simplification of Geankoplis 5e Sec. 10.6 and Wankat 4e Ch. 12, not Edmister’s effective factor; variable_flow=False uses the inlet flows) and the equivalent equilibrium stages N_eq = N ln(1+E(S-1))/ln S, S = 1/A, for a Murphree vapor efficiency E:

\[ v_{out} = v_{in}(1-f) + \frac{l_{in}}{A}\,f, \qquad f = \frac{A^{N_{eq}+1}-A}{A^{N_{eq}+1}-1}. \]

This includes solute in the entering solvent (l_in/A = K x_in V is the gas in equilibrium with it), holds for every A > 0 (for A < 1 the removal tends to A, not 1) and passes smoothly through A = 1. n_stages is a real number, so it is differentiable. The effective-stage ratio is the Lewis relation for a vapor efficiency (difflow_cc uses the A form, which is the liquid-side efficiency).

"stages" (tray-to-tray). All component balances plus the Murphree stage relation, solved as one linear system per solute and iterated a fixed number of times on the total flows of every stage. It carries the flow change that the Kremser mean A only approximates, handles any number of solutes at once, and is the oracle the Kremser mode is checked against (they agree to about 1e-4 at dilute solute and diverge as the solute gets concentrated). n_stages must be a concrete integer.

"packed" (rate-based HTU/NTU). Z = H_OG N_OG with H_OG = H_G + lambda H_L, lambda = 1/A = K V/L. The film heights come from the Onda correlations of Packed-Column Mass Transfer (give packing, diameter, D_G/D_L per solute and the fluid properties; viscosities, density and surface tension default to air and water at 25 C) or you give H_OG. For straight lines the outlet follows Colburn’s closed form; ntu_method="integral" evaluates N_OG by quadrature for the key solute (concentrated=True adds the (1-y)_lm correction). Rating: give height; design: give recovery and read info["height"]. The info also carries the liquid-phase basis, N_OL = N_OG/A and H_OL = A H_OG (N_OL H_OL = Z), which is the natural one for a stripper. Flooding and pressure drop are not part of this unit: give the diameter.

Design instead of rating. Setting recovery (the fraction of the key_solute removed from the gas) makes the Kremser stage count or the packed height an output; the other solutes are then rated at that size. A target below the minimum solvent rate is flagged (info["feasible"] is False, the stage count is inf, nothing is NaN).

info holds, per solute, recovery, transferred (mol/s, gas to liquid), A, S, K, N_eq (and N_OG, H_OG for packed); n_stages and height; L_min and L_over_Lmin for the key solute; the solute-free operating line and equilibrium curve X, Y, Y_eq for plotting; Q_absorption (W), delta_T_adiabatic (K), converged and feasible.

import jax.numpy as jnp
from difflow import Absorber, AbsorptionParams, make_stream
from difflow.units.absorption import henry_params

# acetone scrubbed from air with water at 25 C (Henry's constants from memory)
henry, henry_C = henry_params(["acetone"])
gas = make_stream({"air": 1.0, "acetone": 0.02}, 298.15, 101325.0)    # mol/s
water = make_stream({"H2O": 2.0}, 298.15, 101325.0)

for model in ("kremser", "stages"):
    absorber = Absorber(AbsorptionParams(henry=henry, henry_C=henry_C,
                                         model=model, n_stages=6))
    treated, rich, info = absorber(gas, water)
    print(model, "recovery", float(info["recovery"]["acetone"]),
          "L/L_min", float(info["L_over_Lmin"]))

# design: how many stages for 95 % recovery at this solvent rate?
design = Absorber(AbsorptionParams(henry=henry, henry_C=henry_C, recovery=0.95))
_, _, info = design(gas, water)
print("stages for 95 %:", float(info["n_stages"]))

Packed rating and design, with the Onda films (diffusivities from the correlations of the mass-transfer section):

from difflow import Absorber, AbsorptionParams, make_stream
from difflow.mass_transfer import (diffusion_volume, fuller_diffusivity,
                                   wilke_chang_diffusivity)
from difflow.units.absorption import henry_params

henry, henry_C = henry_params(["acetone"])
D_G = fuller_diffusivity(298.15, 101325.0, 58.08, 28.96,
                         diffusion_volume({"C": 3, "H": 6, "O": 1}), 19.7)
D_L = wilke_chang_diffusivity(298.15, 8.9e-4, 18.015, 74.0, 2.6)
gas = make_stream({"air": 20.0, "acetone": 0.4}, 298.15, 101325.0)
water = make_stream({"H2O": 50.0}, 298.15, 101325.0)

column = dict(henry=henry, henry_C=henry_C, model="packed",
              packing="pall_metal_50", diameter=0.8,
              D_G={"acetone": float(D_G)}, D_L={"acetone": float(D_L)})
_, _, d = Absorber(AbsorptionParams(recovery=0.95, **column))(gas, water)
print("height for 95 %:", float(d["height"]), "m;  H_OG:", float(d["H_OG"]["acetone"]), "m")
_, _, r = Absorber(AbsorptionParams(height=float(d["height"]), **column))(gas, water)
print("rating at that height:", float(r["recovery"]["acetone"]))

Library helpers (not palette units): solute_free_operating_line(G_s, L_s, X_ref, Y_ref) gives the straight operating line Y = slope X + intercept in solute-free ratios; minimum_solvent_rate(curve, Y_in, Y_out, X_in, G_s) finds the pinch (rich end, or tangent for a curved equilibrium) with the mole-fraction equilibrium curve of difflow.staged, returning the minimum solute-free solvent rate (inf if the spec is below equilibrium with the entering solvent); minimum_stripping_gas_rate is its mirror image; kremser_absorption(A, N) and kremser_stages(A, fraction) are the difflow.staged functions. The design rule of thumb is L = 1.2-2 L_min.

from difflow.staged import tabulated
from difflow.units.absorption import minimum_solvent_rate

# a curved (inflected) equilibrium table: the pinch is a tangent, not the rich end
curve = tabulated([0, 0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.08, 0.10],
                  [0, 0.002, 0.004, 0.009, 0.022, 0.050, 0.080, 0.105, 0.120])
Y_in = 0.12 / 0.88
L_min = minimum_solvent_rate(curve, Y_in=Y_in, Y_out=2e-4, X_in=0.0, G_s=1.0)
print("L_s,min / G_s =", float(L_min))

Stage energy balance. A physical absorber warms up: a 5 % NH3 scrubber heats its water by 10-20 K, and with K = H(T)/P rising with temperature that can multiply the stages a spec needs, or put it out of reach. With model="stages" and energy_balance=True each stage has an enthalpy balance (constant cp_liquid and cp_gas, both phases leave a stage at its temperature, the heat of absorption R C_i per mol of solute that moves into the liquid on the stage), and every stage’s K_i is evaluated at its own temperature:

\[ c_{p,L} L_{n-1} T^L_{n-1} + c_{p,G} V_{n+1} T_{n+1} + q_n = (c_{p,L} L_n + c_{p,G} V_n)\,T_n,\qquad q_n = \sum_i R\,C_i\,(v_{i,n+1} - v_{i,n}). \]

The inlets enter at their stream temperatures (leave T=None). intercooler_stages=(3, 6) cools the liquid leaving those stages (1 = top) to intercooler_T (default: the liquid inlet temperature) before the next stage down. The equations are solved by damped substitution followed by a Newton solve of all stages at once (optimistix, implicit differentiation), so jax.grad works through it. info adds stage_T (K, top to bottom), stage_K, stage_Q_absorption (W per stage), intercooler_duty (W per stage, heat removed; negative if the “cooler” heats), Q_intercooler, Q_sensible (sensible heat gained by both phases), energy_balance_error (released minus sensible minus intercooler, ~0), T_gas_out and T_liquid_out; T, K, A, S and L_min are then quoted at the mean stage temperature. The outlet streams carry the computed temperatures. The default (energy_balance=False) is unchanged. Limits: no solvent evaporation (the evaporative cooling of a real water scrubber is missing, so the rise is an upper bound), constant heats of absorption and heat capacities, and only the "stages" model (design mode is not available there, so scan n_stages to size a hot column).

from difflow import Absorber, AbsorptionParams, make_stream
from difflow.units.absorption import henry_params

henry, henry_C = henry_params(["NH3"])
gas = make_stream({"air": 0.95, "NH3": 0.05}, 298.15, 101325.0)
water = make_stream({"H2O": 1.3}, 298.15, 101325.0)
for kw in ({}, {"energy_balance": True},
           {"energy_balance": True, "intercooler_stages": (3, 6, 9)}):
    _, rich, info = Absorber(AbsorptionParams(henry=henry, henry_C=henry_C,
                                              model="stages", n_stages=12, **kw))(gas, water)
    print(kw, "recovery", float(info["recovery"]["NH3"]), "T_out", float(rich["T"]),
          "Q_ic", float(info.get("Q_intercooler", 0.0)))

Absorber and stripper with solvent recycle go through Flowsheet.solve like any two-inlet unit; the recycled liquid is the tear stream, and the whole loop is differentiable with respect to the solvent rate (the L/G trade-off between absorber size and stripper duty is a jax.grad away; see examples/52_absorption_stripping.ipynb).

Assumptions: dilute-to-moderate solutes with constant K_i per solute (no activity-coefficient model: modified Raoult with thermo is not implemented), isothermal unless energy_balance, no solvent volatility, no reaction.

What was and was not verified (when the model was written): the Kremser closed form (including A < 1 and A = 1); the stage-by-stage mode against a separate SciPy fsolve of the nonlinear tray equations; the Kremser/stages agreement for dilute linear equilibrium, with and without a Murphree efficiency; Colburn against the numerical N_OG integral and the rating/design inversion; the minimum solvent rate against a dense chord-slope scan (rich-end and tangent pinches); per-species mass balances in every mode; check_grads; an absorber-stripper recycle flowsheet through fs.solve. No published textbook worked example (Seader-Henley-Roper Ch. 6, McCabe-Smith-Harriott Ch. 18, Geankoplis Ch. 10, Wankat Ch. 12/16, Crittenden Ch. 14) is reproduced: the issue’s numbers are unverified recollections, so none is quoted. tests/test_absorption.py keeps the per-species mass balances. The energy balances were checked for closure against the outlet streams, the stage K against the van ‘t Hoff form, the isothermal limit (C = 0), and jax.grad against finite differences; they are not checked against a published adiabatic column.

Stripper#

Location: difflow/units/absorption.py

Class: Stripper(params)(gas_in, liquid_in) -> (gas_out, liquid_out, info)

The same column run the other way: gas_in is the stripping gas (air or steam) at the bottom and liquid_in the rich liquid at the top. It shares the implementation, the three models and AbsorptionParams with Absorber; the differences are that recovery is the fraction of the key solute removed from the liquid, and info reports the minimum stripping-gas rate G_min and G_over_Gmin instead of L_min. The stripping factor S = K V/L is in info["S"]: with a clean gas the Kremser relation is x_out/x_in = (S-1)/(S^(N+1)-1), and with S < 1 infinitely many stages still remove only the fraction S. A volatile, sparingly soluble solute (a large Henry’s constant, TCE in water) needs a small air/water ratio, which is why air stripping works.

from difflow import Stripper, AbsorptionParams, make_stream
from difflow.units.absorption import HENRY_WATER, henry_params

henry, henry_C = henry_params(["TCE"])          # TCE in water (from memory)
K = HENRY_WATER["TCE"].H298 / 101325.0
L = 1000.0                                        # mol/s of water
air = make_stream({"air": 4.0 * L / K}, 298.15, 101325.0)    # stripping factor S = 4
water = make_stream({"H2O": L, "TCE": 1e-3}, 298.15, 101325.0)

stripper = Stripper(AbsorptionParams(henry=henry, henry_C=henry_C,
                                     recovery=0.99, variable_flow=False))
offgas, treated, info = stripper(air, water)
print("S =", float(info["S"]["TCE"]), " stages for 99 %:", float(info["n_stages"]),
      " G/G_min:", float(info["G_over_Gmin"]))

The TCE case is a hand-worked example with the Henry’s constant above, not the textbook example of the issue (which could not be retrieved).

Live steam. By default the stripping gas is an inert carrier, whatever it is called: nothing condenses and no duty is computed. Name the steam species of gas_in in steam_species to make the steam do its other job. The column runs at T (default: the steam inlet temperature); the steam that condenses heats the liquid feed to it and supplies the heat of desorption, and joins the liquid at the top, so the column sees L_in + n and V_in - n:

\[ n\,\lambda = c_{p,L} L_{in}(T - T_{L,in}) + c_{p,G} V_{in}(T - T_{G,in}) + Q_{des}, \qquad Q_{des} = -Q_{absorption}, \]

with lambda = steam_latent_heat (default 40 660 J/mol, water at 100 C; change it for another pressure) and cp_gas (set about 34 J/(mol K) for steam; superheat reduces the condensate). Because Q_des depends on what is stripped, n is the fixed point of the balance and the column, found by substitution and differentiated implicitly, so a traced or jitted call holds one column rather than one per pass. info adds steam_condensed (mol/s, also in the liquid outlet under the steam name), steam_duty (W, n lambda), Q_preheat, Q_desorption, Q_gas_sensible and steam_fraction_condensed; feasible is False when the preheat needs more steam than is fed. A cold feed can condense most of the steam, which then no longer strips: the effect on the recovery is real and large.

from difflow import Stripper, AbsorptionParams, make_stream
from difflow.units.absorption import henry_params

henry, henry_C = henry_params(["NH3"])                 # sour water (from memory)
steam = make_stream({"steam": 15.0}, 373.15, 101325.0)
feed = make_stream({"H2O": 100.0, "NH3": 1.0}, 323.15, 101325.0)
_, bottoms, info = Stripper(AbsorptionParams(henry=henry, henry_C=henry_C, n_stages=10.0,
                                             steam_species="steam", cp_gas=34.0))(steam, feed)
print("condensed", float(info["steam_condensed"]), "mol/s;  duty", float(info["steam_duty"]),
      "W;  NH3 removed", float(info["recovery"]["NH3"]))

Heat Exchangers#

Heater#

Location: difflow/units/heat_exchanger.py

Class: Heater

Description: Single-stream heater that increases stream temperature using an external heat source (steam, hot oil, electric).

Parameters#

@dataclass
class HeaterParams:
    duty: float = None       # Heat duty (W); the mode is set by which of
    T_out: float = None      # Outlet temperature (K); these three you give
    UA: float = None         # Overall HTC x Area (W/K), with T_utility
    T_utility: float = None  # Utility temperature (K)
    Cp: float = None         # Constant heat capacity (J/mol/K)
    phase: str = None        # Force 'liquid'/'vapor' for the thermo enthalpy

The operating mode is implied by which parameter is set – duty, T_out, or UA together with T_utility – there is no mode field.

Energy models#

The heater has two, and the choice matters more than any other parameter:

Constant Cp – Heater(HeaterParams(T_out=400.0, Cp=75.0)):

\[Q = F_{total} C_p (T_{out} - T_{in})\]

Sensible heat only. A constant \(C_p\) cannot carry latent heat, so this is wrong – often by a factor of several – for any stream that vaporizes or condenses across the unit.

Thermo – Heater(HeaterParams(T_out=400.0), thermo=thermo):

\[Q = H(T_{out}, P) - H(T_{in}, P)\]

with \(H\) from the thermo’s stream enthalpy. A CubicThermo supplies a two-phase flash enthalpy, so the duty carries the real temperature dependence of the heat capacity and the latent heat of any phase change. Use this whenever the stream may change phase, and whenever you are comparing against a rigorous simulator. Pass phase='liquid' or phase='vapor' to force a single-phase enthalpy instead (which is what an IdealThermo provides).

With neither Cp nor thermo, the unit falls back to DEFAULT_CP (75 J/mol/K, roughly liquid water) and raises a DefaultCpWarning. Turn that into an error to make the fallback fatal:

import warnings
from difflow import DefaultCpWarning

warnings.simplefilter("error", DefaultCpWarning)

LMTD Rating (for utility heating):

\[Q = UA \cdot LMTD\]
\[LMTD = \frac{(T_U - T_{in}) - (T_U - T_{out})}{\ln\left(\frac{T_U - T_{in}}{T_U - T_{out}}\right)}\]

Where \(T_U\) is the utility (steam) temperature. On the constant-Cp path this is solved in closed form by effectiveness-NTU with an infinite-capacity utility; with a thermo it is a damped fixed point on \(Q\), with \(T_{out}\) from inverting the enthalpy.

Example Usage#

from difflow import IdealThermo, make_stream
from difflow.database import get_species_data
from difflow.units.heat_exchanger import Heater, HeaterParams

thermo = IdealThermo({'water': get_species_data('water')})
inlet = make_stream({'water': 10.0}, T=300.0, P=101325.0)

# Specified duty
heater = Heater(HeaterParams(duty=50000.0, Cp=75.0))
outlet, info = heater(inlet)

# Specified outlet temperature, duty from the thermo (carries latent heat)
heater = Heater(HeaterParams(T_out=400.0), thermo=thermo)
outlet, info = heater(inlet)
info["Q"]  # W

# Rating against a steam utility
heater = Heater(HeaterParams(UA=5000.0, T_utility=450.0), thermo=thermo)
outlet, info = heater(inlet)
info["LMTD"], info["UA_required"]

Cooler#

Location: difflow/units/heat_exchanger.py

Class: Cooler

Description: Single-stream cooler that decreases stream temperature using cooling water or refrigeration.

Parameters#

Same as Heater with appropriate utility temperatures.

Governing Equations#

Same as Heater, including both energy models and the DefaultCpWarning fallback, with the duty sign reversed: Q > 0 means heat removed.

from difflow.units.heat_exchanger import Cooler, CoolerParams

cooler = Cooler(CoolerParams(T_out=280.0), thermo=thermo)
outlet, info = cooler(inlet)

CounterCurrentHX#

Location: difflow/units/heat_exchanger.py

Class: CounterCurrentHX

Description: Two-stream heat exchanger with counter-current flow arrangement. Provides maximum temperature driving force.

Process Role#

Counter-current heat exchangers are preferred for:

  • Maximum heat recovery

  • Heating/cooling to approach inlet temperature of other stream

  • Most efficient use of heat transfer area

Parameters#

@dataclass
class HeatExchangerParams:
    UA: float = None       # Overall HTC × Area (W/K)
    Cp_hot: float = None   # Hot side heat capacity (J/mol·K), default 75
    Cp_cold: float = None  # Cold side heat capacity (J/mol·K), default 75
    min_approach: float = 10.0  # Minimum temperature approach (K)

CounterCurrentHX(params) takes only the parameters: there is no thermo object and no species list, and UA can be overridden per call.

Inputs#

Parameter

Type

Units

Description

hot_stream

Stream

-

Hot fluid inlet

cold_stream

Stream

-

Cold fluid inlet

Outputs#

Parameter

Type

Units

Description

hot_outlet

Stream

-

Hot fluid outlet

cold_outlet

Stream

-

Cold fluid outlet

info['Q']

float

W

Heat duty transferred

info['LMTD']

float

K

Log mean temperature difference

info['UA']

float

W/K

UA used for the rating

Governing Equations#

Energy Balance:

\[Q = \dot{m}_h C_{p,h} (T_{h,in} - T_{h,out}) = \dot{m}_c C_{p,c} (T_{c,out} - T_{c,in})\]

LMTD (Counter-current):

\[LMTD = \frac{\Delta T_1 - \Delta T_2}{\ln(\Delta T_1 / \Delta T_2)}\]

Where:

  • \(\Delta T_1 = T_{h,in} - T_{c,out}\)

  • \(\Delta T_2 = T_{h,out} - T_{c,in}\)

Heat Transfer Rate:

\[Q = UA \cdot LMTD\]

Effectiveness-NTU Method:

\[\epsilon = \frac{Q}{Q_{max}} = \frac{Q}{C_{min}(T_{h,in} - T_{c,in})}\]
\[\epsilon = \frac{1 - \exp[-NTU(1 - C_r)]}{1 - C_r \exp[-NTU(1 - C_r)]}\]

Where:

  • \(C_r = C_{min}/C_{max}\)

  • \(NTU = UA/C_{min}\)

  • \(C = \dot{m} C_p\) (heat capacity rate)

Example Usage#

from difflow.units.heat_exchanger import CounterCurrentHX, HeatExchangerParams

hx = CounterCurrentHX(HeatExchangerParams(UA=50.0, Cp_hot=75.0, Cp_cold=75.0))

hot_in = make_stream({'A': 1.0}, T=450.0, P=101325.0)
cold_in = make_stream({'B': 0.8}, T=300.0, P=101325.0)

hot_out, cold_out, info = hx(hot_in, cold_in)
print(f"Heat duty: {info['Q']/1000:.2f} kW")
print(f"LMTD: {info['LMTD']:.2f} K")

CoCurrentHX#

Location: difflow/units/heat_exchanger.py

Class: CoCurrentHX

Description: Two-stream heat exchanger with co-current (parallel) flow arrangement.

Governing Equations#

LMTD (Co-current):

\[LMTD = \frac{\Delta T_1 - \Delta T_2}{\ln(\Delta T_1 / \Delta T_2)}\]

Where:

  • \(\Delta T_1 = T_{h,in} - T_{c,in}\)

  • \(\Delta T_2 = T_{h,out} - T_{c,out}\)

Effectiveness (Co-current):

\[\epsilon = \frac{1 - \exp[-NTU(1 + C_r)]}{1 + C_r}\]

Note: Co-current flow cannot achieve temperature cross (\(T_{c,out} > T_{h,out}\)).


CrossFlowHX#

Location: difflow/units/heat_exchanger.py

Class: CrossFlowHX

Description: Two-stream heat exchanger with cross-flow arrangement where fluids flow perpendicular to each other. Effectiveness depends on mixing configuration.

Process Role#

Cross-flow heat exchangers are used for:

  • Air-to-liquid heat transfer (HVAC systems)

  • Car radiators and automotive cooling

  • Finned-tube heat exchangers

  • Applications where cross-flow geometry is advantageous

Parameters#

@dataclass
class HeatExchangerParams:
    UA: float = None       # Overall HTC × Area (W/K) for rating
    Cp_hot: float = None   # Hot side heat capacity (J/mol·K)
    Cp_cold: float = None  # Cold side heat capacity (J/mol·K)
    min_approach: float = 10.0  # Minimum approach temperature (K)

# CrossFlowHX constructor
CrossFlowHX(params: HeatExchangerParams, mixing: str = "both_unmixed")

Mixing Configurations#

The mixing parameter specifies the flow arrangement:

Configuration

Description

Common Applications

both_unmixed

Both fluids flow through separate channels (default)

Car radiators, finned-tube HX

cmax_mixed

Larger heat capacity stream is mixed

Shell-and-tube with mixing in shell

cmin_mixed

Smaller heat capacity stream is mixed

Special geometries

both_mixed

Both fluids can mix in flow direction

Compact heat exchangers

Governing Equations#

Energy Balance (same as other HX types):

\[Q = \dot{m}_h C_{p,h} (T_{h,in} - T_{h,out}) = \dot{m}_c C_{p,c} (T_{c,out} - T_{c,in})\]

Effectiveness (both unmixed):

\[\epsilon = 1 - \exp\left[\frac{NTU^{0.22}}{C_r}\left(\exp(-C_r \cdot NTU^{0.78}) - 1\right)\right]\]

Effectiveness (Cmax mixed, Cmin unmixed):

\[\epsilon = \frac{1}{C_r}\left[1 - \exp\left(-C_r(1 - \exp(-NTU))\right)\right]\]

Effectiveness (Cmin mixed, Cmax unmixed):

\[\epsilon = 1 - \exp\left[-\frac{1}{C_r}(1 - \exp(-C_r \cdot NTU))\right]\]

Effectiveness (both mixed):

\[\frac{1}{\epsilon} = \frac{1}{1-\exp(-NTU)} + \frac{C_r}{1-\exp(-C_r \cdot NTU)} - \frac{1}{NTU}\]

Where:

  • \(NTU = UA/C_{min}\) (Number of Transfer Units)

  • \(C_r = C_{min}/C_{max}\) (Heat capacity ratio)

Physical Constraint: Cross-flow effectiveness is capped at the counter-current value to maintain physical consistency, as cross-flow should never exceed counter-current performance.

Inputs#

Parameter

Type

Units

Description

hot_stream

Stream

-

Hot fluid inlet

cold_stream

Stream

-

Cold fluid inlet

UA

float

W/K

Override UA value

Outputs#

Parameter

Type

Units

Description

hot_outlet

Stream

-

Hot fluid outlet

cold_outlet

Stream

-

Cold fluid outlet

info['Q']

float

W

Heat duty transferred

info['effectiveness']

float

-

Heat exchanger effectiveness

info['NTU']

float

-

Number of transfer units

info['LMTD']

float

K

Log mean temperature difference

info['mixing']

str

-

Mixing configuration used

info['approach']

float

K

Minimum temperature approach

Example Usage#

from difflow import CrossFlowHX, HeatExchangerParams, make_stream

# Car radiator example (both unmixed - most common)
hx = CrossFlowHX(
    HeatExchangerParams(UA=2000.0, Cp_hot=75.0, Cp_cold=30.0),
    mixing="both_unmixed"  # Default
)

hot_coolant = make_stream({"ethylene_glycol": 10.0}, T=368.0, P=101325.0)  # 95°C
cold_air = make_stream({"air": 50.0}, T=298.0, P=101325.0)  # 25°C

hot_out, cold_out, info = hx(hot_coolant, cold_air)
print(f"Heat rejected: {info['Q']/1000:.2f} kW")
print(f"Effectiveness: {info['effectiveness']:.3f}")
print(f"Air outlet temp: {cold_out['T']:.1f} K")

# Compare mixing configurations
for config in ["both_unmixed", "cmax_mixed", "cmin_mixed", "both_mixed"]:
    hx = CrossFlowHX(HeatExchangerParams(UA=2000.0), mixing=config)
    _, _, info = hx(hot_coolant, cold_air)
    print(f"{config:15s}: ε = {info['effectiveness']:.4f}, Q = {info['Q']/1000:.2f} kW")

Performance Comparison#

For the same UA and inlet conditions, effectiveness ranking:

  1. Counter-current (highest)

  2. Cross-flow with mixed streams

  3. Cross-flow (both unmixed)

  4. Co-current (lowest)

Cross-flow heat exchangers offer intermediate performance between counter-current (most efficient) and co-current (simplest), making them practical for applications where perpendicular flow geometry is advantageous.


ShellAndTubeHX#

Location: difflow/units/heat_exchanger.py

Class: ShellAndTubeHX

Description: Multi-pass shell-and-tube exchanger: counter-current duty reduced by the LMTD correction factor \(F\).

Process Role#

A 1-2N TEMA exchanger is not counter-current. Each tube pass runs with the shell fluid and the next against it, so part of the area works against a smaller driving force than the terminal temperatures suggest. The standard allowance for that is the correction factor \(F \le 1\) applied to the counter-current LMTD, and this unit is CounterCurrentHX with that factor in place. Use it when the geometry is a real shell-and-tube bundle rather than an idealised two-stream exchanger; use CounterCurrentHX when \(F\) would be 1 by construction (a true counter-current double-pipe or a 1-1 arrangement).

Parameters#

@dataclass
class ShellAndTubeHXParams:
    UA: float                      # Overall HTC x area (W/K)
    Cp_hot: float = None           # Hot side heat capacity (J/mol/K), default 75
    Cp_cold: float = None          # Cold side heat capacity (J/mol/K), default 75
    min_approach: float = 10.0     # Minimum temperature approach (K)
    n_shell_passes: int = 1        # Shell passes: 1-2, 2-4, ... arrangements

Inputs#

Parameter

Type

Units

Description

hot_inlet

Stream

-

Hot fluid inlet

cold_inlet

Stream

-

Cold fluid inlet

UA

float

W/K

Optional override of params.UA

Outputs#

Parameter

Type

Units

Description

hot_outlet

Stream

-

Hot fluid outlet

cold_outlet

Stream

-

Cold fluid outlet

info['Q']

float

W

Heat duty

info['F_correction']

float

-

LMTD correction factor

info['R'], info['P_param']

float

-

The two parameters \(F\) depends on

info['F_too_low']

bool

-

F < 0.75: add shell passes or re-split the duty

info['effectiveness']

float

-

\(\epsilon_{CC} F\)

info['LMTD']

float

K

Counter-current LMTD

Governing Equations#

Corrected duty:

\[Q = UA \cdot F(P, R) \cdot LMTD_{CC}\]

Correction-factor arguments (temperature effectiveness and capacity ratio):

\[P = \frac{T_{c,out} - T_{c,in}}{T_{h,in} - T_{c,in}}, \qquad R = \frac{T_{h,in} - T_{h,out}}{T_{c,out} - T_{c,in}}\]

Correction factor (1-2N TEMA, Bowman-Mueller-Nagle):

\[F = \frac{\sqrt{R^2+1}\,\ln\!\frac{1-P}{1-RP}} {(R-1)\,\ln\!\frac{2-P(R+1-\sqrt{R^2+1})}{2-P(R+1+\sqrt{R^2+1})}}\]

At \(R = 1\) that expression is \(0/0\); lmtd_correction_factor takes the L’Hopital limit there, so \(F\) and its derivative are finite for balanced flows instead of producing a nan in the middle of the usual operating range.

\(F\) falls as \(P\) rises: a multi-pass exchanger asked for a close approach loses area effectiveness quickly, which is why F_too_low is reported rather than silently accepted. The conventional design rule is to keep \(F > 0.75\) and add shell passes otherwise (n_shell_passes, which re-maps \(P\) to the equivalent single-shell value before the formula above).

Example Usage#

from difflow import make_stream
from difflow.units.heat_exchanger import ShellAndTubeHX, ShellAndTubeHXParams

hx = ShellAndTubeHX(ShellAndTubeHXParams(UA=500.0, Cp_hot=75.0, Cp_cold=75.0))

hot = make_stream({'water': 2.0}, T=400.0, P=101325.0)
cold = make_stream({'water': 1.0}, T=300.0, P=101325.0)

hot_out, cold_out, info = hx(hot, cold)
print(f"Q = {float(info['Q'])/1000:.2f} kW")
print(f"F = {float(info['F_correction']):.3f}")

EnthalpyCounterCurrentHX#

Location: difflow/units/heat_exchanger.py

Class: EnthalpyCounterCurrentHX

Description: Counter-current exchanger closed on real, flash-based stream enthalpies, so it stays correct through a phase change.

Process Role#

CounterCurrentHX assumes one constant \(C_p\) per side, which makes its effectiveness-NTU solution closed-form and its answer wrong wherever the heat capacity is not constant — above all where a stream boils or condenses, since latent heat is an infinite apparent \(C_p\) that a constant-\(C_p\) model has no way to represent. This unit closes an enthalpy balance per side instead, using a thermo object’s two-phase stream enthalpy, and therefore matches an equation-oriented exchanger (IDAES’s, say) through the phase change.

Use it for condensers, reboiler-side service, cryogenic/NGL duty, and anywhere a vapour fraction changes across the exchanger. It costs a coupled solve rather than a formula, so prefer CounterCurrentHX for single-phase service where the two agree.

Parameters#

@dataclass
class EnthalpyHXParams:
    UA: float = None       # Overall HTC x area (W/K)
    max_iter: int = 80     # Iterations of the outer fixed point on Q
    damping: float = 0.5   # Damping of the Q update (0 < d <= 1)

The constructor takes the thermo alongside the params — EnthalpyCounterCurrentHX(params, thermo) — and the thermo must provide stream_enthalpy_flash(flows, T, P). A CubicThermo built from an IdealThermo and a PengRobinson/SRK EOS does (Thermodynamics).

Inputs#

Parameter

Type

Units

Description

hot_inlet

Stream

-

Hot fluid inlet

cold_inlet

Stream

-

Cold fluid inlet

UA

float

W/K

Optional override of params.UA

Outputs#

Same keys as CounterCurrentHX where they mean the same thing: info['Q'], info['LMTD'], the four terminal temperatures, info['approach'] (the smaller of the two terminal differences) and info['flow_arrangement'] == 'counter_current_enthalpy'.

Governing Equations#

Enthalpy balance per side (not a \(\dot{m} C_p \Delta T\) balance):

\[H_{h,out} = H_{h,in} - Q, \qquad H_{c,out} = H_{c,in} + Q\]

Heat transfer:

\[Q = UA \cdot LMTD(T_{h,in}, T_{h,out}, T_{c,in}, T_{c,out})\]

The unknowns \((Q, T_{h,out}, T_{c,out})\) are coupled: \(Q\) sets the outlet enthalpies, the enthalpies set the outlet temperatures, and the temperatures set the LMTD that sets \(Q\). The unit solves it as a damped fixed point on \(Q\) with a one-dimensional enthalpy inversion per side (enthalpy is monotone in \(T\), so each inversion is a well-posed root find). Every solve is an optimistix root find, so gradients come from the implicit function theorem at the converged result rather than from differentiating the iteration.

Example Usage#

from difflow import make_stream
from difflow.eos import PengRobinson, CriticalProperties
from difflow.thermo import IdealThermo, CubicThermo, SpeciesData
from difflow.units.heat_exchanger import (
    EnthalpyCounterCurrentHX, EnthalpyHXParams,
)

species = {
    "propane": SpeciesData(name="propane", MW=44.10,
                           Cp_coeffs=(73.0, 0.0, 0.0, 0.0),
                           Hvap_coeffs=(18000.0, 0.38, 369.8),
                           antoine_coeffs=(13.72, 1872.5, -25.16)),
    "butane": SpeciesData(name="butane", MW=58.12,
                          Cp_coeffs=(98.0, 0.0, 0.0, 0.0),
                          Hvap_coeffs=(22000.0, 0.38, 425.1),
                          antoine_coeffs=(13.98, 2292.4, -27.86)),
}
crit = {
    "propane": CriticalProperties(name="propane", Tc=369.8, Pc=4.25e6,
                                  omega=0.152, MW=44.10),
    "butane": CriticalProperties(name="butane", Tc=425.1, Pc=3.80e6,
                                 omega=0.200, MW=58.12),
}
thermo = CubicThermo(IdealThermo(species), PengRobinson(crit))

hot = make_stream({"propane": 1.0, "butane": 1.0}, T=400.0, P=3e5)
cold = make_stream({"propane": 1.0, "butane": 1.0}, T=300.0, P=3e5)

hx = EnthalpyCounterCurrentHX(EnthalpyHXParams(UA=200.0), thermo)
hot_out, cold_out, info = hx(hot, cold)
print(f"Q = {float(info['Q'])/1000:.2f} kW")

Design Considerations#

  • Share the thermo object. The coupled solve is JIT-compiled and cached on the identity of the thermo, so passing the same CubicThermo to every exchanger compiles once instead of once per unit. The first call takes seconds; later ones do not.

  • damping is a convergence knob, not a model parameter. Gradients are exact at the fixed point regardless of its value, so lowering it costs iterations and nothing else.


Heat Exchanger Utility Functions#

from difflow.units.heat_exchanger import (
    log_mean_temperature_difference,
    effectiveness_counter_current,
    effectiveness_co_current,
    effectiveness_crossflow_both_unmixed,
    effectiveness_crossflow_cmax_mixed,
    effectiveness_crossflow_cmin_mixed,
    effectiveness_crossflow_both_mixed,
    heat_capacity_rate,
    design_heat_exchanger,
    size_heat_exchanger
)

# Calculate LMTD with numerical stability
lmtd = log_mean_temperature_difference(dT1=50.0, dT2=30.0)

# Calculate effectiveness for different flow configurations
eps_counter = effectiveness_counter_current(NTU=2.0, Cr=0.5)
eps_co = effectiveness_co_current(NTU=2.0, Cr=0.5)
eps_cross = effectiveness_crossflow_both_unmixed(NTU=2.0, Cr=0.5)

# Design for specified duty
design = design_heat_exchanger(Q=100000.0, T_hot_in=400.0, T_hot_out=350.0,
                               T_cold_in=300.0, T_cold_out=340.0, U=500.0)
design['UA'], design['A'], design['LMTD']   # W/K, m², K

# A 1-2N shell-and-tube: A = Q / (U F LMTD), F from `lmtd_correction_factor`
st = design_heat_exchanger(Q=100000.0, T_hot_in=400.0, T_hot_out=350.0,
                           T_cold_in=300.0, T_cold_out=340.0, U=500.0,
                           flow_config="shell_and_tube", n_shell_passes=2)
st['F'], st['A']

design_heat_exchanger and size_heat_exchanger take flow_config = "counter_current", "co_current" or "shell_and_tube" (with n_shell_passes, shells in series); any other value raises ValueError. The result always carries F (1 for the pure flow configurations). For a shell-and-tube, size_heat_exchanger inverts the exact 1-2N effectiveness. A LowCorrectionFactorWarning is issued when F < 0.75 (the usual design floor) or F = 0, which means the duty is past what that many shells can deliver at any area (A is inf); add shells. The warning is skipped for traced values, so the functions stay jit/grad safe: read F from the result there.

Phase change in the constant-Cp units. CounterCurrentHX, CoCurrentHX, CrossFlowHX, ShellAndTubeHX and Heater/Cooler without a thermo carry no latent heat, so a condensing or vaporising stream is badly mis-rated (pentane vapour condensing against cooling water comes out 5.5-fold low). They now raise a PhaseChangeWarning when a stream’s inlet-to-outlet temperature span crosses the boiling point, at the stream pressure, of a database species making up at least 1% of its flow, and point to EnthalpyCounterCurrentHX. The boiling point is the Antoine equation inverted (species without Antoine coefficients, or a result outside the equation’s range, are not checked). It is an advisory that runs only on concrete values, never under jit/grad, so it cannot change a result or a gradient; silence it with warnings.simplefilter("ignore", PhaseChangeWarning).

Heat-Transfer Coefficients#

The exchangers above take U or UA as a number. difflow.heat_transfer (pure JAX functions, SI, differentiable, not a palette operation) computes that number from geometry, flow and fluid properties. Properties are inputs (k, mu, Cp, rho), as in difflow.fluids, which also supplies the Darcy friction factor Gnielinski needs. Everything is traced: jax.grad of U with respect to tube velocity or diameter works, which is what the velocity and insulation-thickness optimizations use (examples/45_heat_transfer_coefficients.ipynb).

Group

Functions

Dimensionless groups

reynolds, prandtl, nusselt_to_h, grashof, rayleigh

Conduction

slab_resistance, cylinder_resistance, sphere_resistance, composite_wall, critical_insulation_radius, insulated_pipe_heat_loss, fin_efficiency_straight

Agitated vessels

agitator_reynolds, agitated_vessel_nusselt, jacketed_vessel_h, agitated_coil_h, AGITATED_VESSEL_CONSTANTS (see Agitated vessels)

Insulation economics

difflow.economics.insulation (see Economic insulation thickness)

Tube side

sieder_tate_laminar, dittus_boelter, sieder_tate, gnielinski, internal_nusselt, internal_h

External flow

churchill_bernstein (cylinder), flat_plate_nusselt

Natural convection

churchill_chu_vertical_plate, churchill_chu_horizontal_cylinder

Phase change

nusselt_film_condensation, rohsenow_heat_flux, rohsenow_superheat, mostinski, critical_heat_flux

Fouling

difflow.fouling (see Threshold Fouling)

Annulus (laminar)

annulus_nusselt

Radiation

STEFAN_BOLTZMANN, blackbody_emissive_power, radiation_h, net_radiation, two_surface_radiation

Lumped transients

biot_number, lumped_time_constant, lumped_temperature, lumped_heating_time

Overall

overall_U, FOULING_RESISTANCES, FOULING_RESISTANCE_RANGES, FOULING_SOURCES, TYPICAL_U_RANGES, typical_U_range

Properties

difflow.gas_properties (air and common gases), difflow.liquid_properties

Tube side. internal_h(Re, Pr, k, D, L=None, mu_ratio=1, rel_roughness=0, method="gnielinski", heating=True, boundary="T") returns \(h=\mathrm{Nu}\,k/D\):

  • \(\mathrm{Re}\le2300\): fully developed \(\mathrm{Nu}=3.66\) (constant wall temperature) or \(48/11=4.364\) (boundary="q", constant flux); with L given, the Sieder-Tate entry form \(1.86(\mathrm{Re\,Pr}\,D/L)^{1/3}(\mu_b/\mu_w)^{0.14}\) joined to it as \((\mathrm{Nu}_{fd}^3+\mathrm{Nu}_{en}^3)^{1/3}\) (a smooth combination chosen here, not a published correlation).

  • \(\mathrm{Re}\ge4000\): Gnielinski \(\mathrm{Nu}=\frac{(f/8)(\mathrm{Re}-1000)\mathrm{Pr}}{1+12.7\sqrt{f/8}\,(\mathrm{Pr}^{2/3}-1)}\) with \(f\) from fluids.friction_factor, or dittus_boelter (\(0.023\,\mathrm{Re}^{0.8}\mathrm{Pr}^{0.4/0.3}\)) or sieder_tate (\(0.027\,\mathrm{Re}^{0.8}\mathrm{Pr}^{1/3}(\mu_b/\mu_w)^{0.14}\)).

  • 2300 to 4000: a C2 smootherstep in \(\ln\mathrm{Re}\) between the two. There is no Python branching, so Re may be traced; \(h\) and its first two derivatives are continuous across the whole range, and the gradient is exactly zero where the blend starts. Real transitional flow is intermittent and no correlation is accurate there; the blend is a smooth interpolation, not a model of transition.

Viscosity correction (opt-in, exponents unverified). As published, Gnielinski and Dittus-Boelter carry no wall-viscosity factor, so by default mu_ratio has no effect on a turbulent tube side (it enters the laminar entry term and sieder_tate only). internal_h(..., viscosity_correction=True) (and ShellAndTubeDesignParams(tube_viscosity_correction=True)) multiplies the Gnielinski or Dittus-Boelter value by \((\mu_b/\mu_w)^n\), with \(n=0.11\) when the fluid is heated and \(0.25\) when it is cooled (VISCOSITY_EXPONENT_HEATING, VISCOSITY_EXPONENT_COOLING): the liquid form usually attributed to Petukhov (the VDI Heat Atlas / Gnielinski variant is \((\mathrm{Pr}/\mathrm{Pr}_w)^{0.11}\); the viscosity ratio stands in for it here). These exponents are from memory and were not checked against Petukhov, Gnielinski or the VDI Heat Atlas, which is why the factor is off by default and no existing result changes. Check them before a real design. sieder_tate already has its own 0.14 factor and gets no second one.

from difflow import heat_transfer as ht

Re, Pr = 2.0e4, 6.0                                  # illustrative
base = ht.internal_nusselt(Re, Pr, mu_ratio=1.5)     # default: mu_ratio ignored (turbulent)
corr = ht.internal_nusselt(Re, Pr, mu_ratio=1.5, heating=True, viscosity_correction=True)
float(corr / base)                                   # 1.5**0.11

Overall U. overall_U(h_i, h_o, D_i, D_o, k_wall, R_fi=0, R_fo=0, basis="outer"):

\[\frac1{U_o}=\frac1{h_o}+R_{f,o}+\frac{D_o\ln(D_o/D_i)}{2k}+R_{f,i}\frac{D_o}{D_i}+\frac{D_o}{D_i\,h_i}\]

and the same referred to the inner area with basis="inner" (\(U_oA_o=U_iA_i\)). Wall conduction uses cylinder_resistance’s logarithmic form; k_wall=jnp.inf removes it.

import jax
from difflow import heat_transfer as ht, CounterCurrentHX, HeatExchangerParams

rho, mu, Cp, k = 998.0, 1.0e-3, 4180.0, 0.60    # water, tube side (SI)
h_shell, area = 2000.0, 12.0                    # shell-side h (W/m^2/K), area (m^2)

def U_outer(v, D_i=0.0229):
    Re = ht.reynolds(rho, v, D_i, mu)
    h_i = ht.internal_h(Re, ht.prandtl(Cp, mu, k), k, D_i, L=4.0)
    return ht.overall_U(h_i, h_shell, D_i, D_i + 0.0025, 16.0, R_fi=1e-4)

dU_dv = jax.grad(U_outer)(1.5)                 # W/m^2/K per (m/s)
UA = U_outer(1.5) * area                       # a plain number for the exchanger
hx = CounterCurrentHX(HeatExchangerParams(UA=UA, Cp_hot=75.3, Cp_cold=75.3))

The exchangers still take a float (or array) U/UA; a callable U(hot, cold, params) is not implemented – compute U first, as above (it is already differentiable, so jax.grad flows through to the exchanger).

Other correlations.

Function

Correlation

Source (Incropera & DeWitt, Fundamentals of Heat and Mass Transfer)

churchill_bernstein

cylinder in cross flow, all \(\mathrm{Re\,Pr}>0.2\)

Eq. 7.57

flat_plate_nusselt

\(0.664\mathrm{Re}^{1/2}\mathrm{Pr}^{1/3}\); \(0.037\mathrm{Re}^{4/5}\mathrm{Pr}^{1/3}\); mixed \((0.037\mathrm{Re}^{4/5}-871)\mathrm{Pr}^{1/3}\) joined smoothly over \(\mathrm{Re}_L\) 5e5-5.5e5

Eqs. 7.31, 7.41, 7.38

churchill_chu_*

vertical plate, horizontal cylinder, all Ra

Eqs. 9.26, 9.34

nusselt_film_condensation

\(h=C[g\rho_l(\rho_l-\rho_v)k_l^3h_{fg}'/(\mu_l\,\Delta T\,L)]^{1/4}\), \(C=0.943\) plate, \(0.729\) horizontal tube, \(h_{fg}'=h_{fg}+0.68C_p\Delta T\); \(N\) tubes in a column \(h_N=h_1N^{-1/4}\)

Eqs. 10.26, 10.40, 10.42

rohsenow_heat_flux, rohsenow_superheat

\(q''=\mu_lh_{fg}[g(\rho_l-\rho_v)/\sigma]^{1/2}[C_p\Delta T_e/(C_{sf}h_{fg}\mathrm{Pr}^n)]^3\) and its closed-form inverse

Eq. 10.5

mostinski

\(h=0.00417\,P_c^{0.69}q^{0.7}F_p\) (\(P_c\) in kPa)

Mostinski (1963); recalled, see below

critical_heat_flux

Zuber, \(C\,h_{fg}\rho_v^{1/2}[\sigma g(\rho_l-\rho_v)]^{1/4}\), \(C=\pi/24\)

Eq. 10.7 (constant is a parameter)

Limitation: critical_heat_flux is the single-surface value. A tube bundle (kettle reboiler) has a lower critical flux because vapour from the lower tubes blankets the upper ones; the published correction is Palen’s bundle factor. It is not implemented: its form and constants could not be checked against a source and none were invented. Treat the Zuber value as an upper bound for a bundle and derate it with a sourced bundle correction.

Rohsenow’s \(C_{sf}\) defaults to 0.013 (water on polished copper/platinum), recalled from memory of Incropera Table 10.1: check it for real work. Not implemented: annular fins, tube banks (Zukauskas), the Bell-Delaware shell-side method (Kern is in difflow.shell_and_tube), multi-dimensional transient conduction (Heisler charts), and agitated-vessel or jacket-side coefficients (no checkable source for the impeller constants).

Laminar annulus: annulus_nusselt#

annulus_nusselt(Di_over_Do, surface="inner") interpolates Incropera & DeWitt Table 8.2 linearly: fully developed laminar flow, one surface at constant temperature and the other insulated, Nu based on \(D_h=D_o-D_i\). Incropera’s Example 11.1 interpolates the same way (\(\mathrm{Nu}_i=5.63\) at \(D_i/D_o=0.56\); checked when the model was written).

\(D_i/D_o\)

0

0.05

0.10

0.25

0.50

1.00

\(\mathrm{Nu}_i\)

–

17.46

11.56

7.37

5.74

4.86

\(\mathrm{Nu}_o\)

3.66

4.06

4.11

4.23

4.43

4.86

The table is reproduced to within 1% by a finite-difference solution of the Graetz eigenproblem (checked when the model was written). Below \(D_i/D_o=0.05\) the inner value is held at 17.46; it diverges as the inner tube shrinks. For turbulent flow in an annulus use internal_nusselt/internal_h with Re and the diameter set to \(D_h\) (Incropera Sec. 8.7).

Radiation#

  • net_radiation(Ts, Tsur, emissivity, area=1) is \(q=\varepsilon A\sigma(T_s^4-T_{sur}^4)\), for a small gray body in large surroundings (Incropera Eq. 1.7). Incropera Ex 9.4, a 100 mm pipe at 165 °C with \(\varepsilon=0.85\) in a room at 23 °C, gives 442 W/m.

  • radiation_h(Ts, Tsur, emissivity) is \(h_r=\varepsilon\sigma(T_s+T_{sur})(T_s^2+T_{sur}^2)\). It is exact, so \(h_r(T_s-T_{sur})\) equals net_radiation, and it adds to a convection \(h\) in parallel.

  • two_surface_radiation(T1, T2, eps1, eps2, A1, A2=None, F12=1) gives the gray, diffuse two-surface enclosure \(q_{12}=\sigma(T_1^4-T_2^4)/[(1-\varepsilon_1)/(\varepsilon_1A_1)+1/(A_1F_{12})+(1-\varepsilon_2)/(\varepsilon_2A_2)]\) (Incropera Sec. 13.3). It covers parallel plates, concentric cylinders and spheres. A2=None means a large enclosure.

  • blackbody_emissive_power(T) is \(\sigma T^4\), with STEFAN_BOLTZMANN \(=5.670374419\times10^{-8}\) (CODATA 2018).

Lumped capacitance and batch heating#

The same equation, \((T-T_\infty)/(T_0-T_\infty)=e^{-t/\tau}\) with \(\tau=mc_p/UA\) (Incropera Eq. 5.6), governs two cases: a small solid with \(\mathrm{Bi}=hL_c/k<0.1\) and \(L_c=V/A_s\), and a well-mixed batch heated through a jacket or coil from a medium at constant temperature.

  • biot_number(h, L_c, k)

  • lumped_time_constant(m_cp, UA)

  • lumped_temperature(t, T0, T_inf, m_cp, UA)

  • lumped_heating_time(m, cp, UA, T_st, T0, T) computes \(t=(mc_p/UA)\ln[(T_{st}-T_0)/(T_{st}-T)]\). It handles heating and cooling.

They were checked against scipy.integrate.solve_ivp when the model was written.

from difflow import heat_transfer as ht
t = ht.lumped_heating_time(1000.0, 4180.0, 2000.0, 393.15, 293.15, 353.15)  # ~1915 s

What is validated (and what is not). No textbook worked-example number is quoted, because none could be reproduced with confidence. When the model was written, each correlation was checked against an independent hand/NumPy evaluation of the stated formula (1e-10 or better). Where a derivation exists the constant itself is checked: Nusselt’s 0.943 against a numerical integral of the film-thickness solution, the cylinder resistance against numerical integration of Fourier’s law, the fin efficiency against the exact convecting-tip solution, the critical insulation radius as the stationary maximum of the heat loss. Looser checks were sanity bands: Gnielinski vs Dittus-Boelter within 20% at Re 1e4 to 1e5; Mostinski vs Rohsenow within 30% for water at 1 atm; Zuber’s water CHF within 5% of the commonly quoted 1.1 MW/m². The published shell-and-tube overall_U example and the textbook steam and Rohsenow examples are not reproduced; overall_U is checked against the series-resistance network, its limits and \(U_oA_o=U_iA_i\).

Typical fouling resistances (FOULING_RESISTANCES, m² K/W). Each entry names its source in FOULING_SOURCES, and FOULING_RESISTANCE_RANGES holds (low, high) where the source gives a range. The first block is Incropera & DeWitt Table 11.1, which cites TEMA; it was checked against the table while the unit-operations book was written. The second block is Sinnott, Coulson & Richardson’s Chemical Engineering Vol. 6 (4th ed.), Table 12.2. That table gives coefficient ranges, and the float stored here is the conservative (larger-resistance) end. Crude oil by temperature and velocity, lube oils, cooling waters and the other TEMA RGP-T-2.4 entries are in difflow.fouling (TEMA design fouling resistances). Use your own standard for design.

Service (Incropera Table 11.1)

\(R_f\)

Seawater, below 50 °C

0.0001

Seawater, above 50 °C

0.0002

Treated boiler feedwater, below 50 °C

0.0001

Treated boiler feedwater, above 50 °C

0.0002

River water, below 50 °C

0.0002-0.001 (stored: 0.001)

Fuel oil

0.0009

Refrigerating liquids

0.0002

Steam, oil-free

0.0001

Service (Sinnott Table 12.2; key)

\(R_f\) range

River water (river_water)

0.0001-0.0003

Sea water (sea_water)

0.0003-0.001

Cooling (tower) water (cooling_tower_water)

0.00017-0.0003

Town water, soft / hard

0.0002-0.0003 / 0.0005-0.001

Steam condensate

0.0002-0.00067

Steam, oil traces

0.0002-0.0005

Refrigerated brine; aqueous salt solutions

0.0002-0.0003

Air and industrial gases

0.0001-0.0002

Flue gases

0.0002-0.0005

Organic vapours / liquids; light hydrocarbons; condensing organics; heat-transfer fluids

0.0002

Heavy hydrocarbons

0.0005

Boiling organics

0.0004

Typical overall U (TYPICAL_U_RANGES, W/m²/K). These are Incropera Table 11.2, checked against the table while the unit-operations book was written; water-to-oil was corrected from 100-350 to 110-350. Use them as a sanity band for a computed U, not as a design value.

Service

U range

Water to water

850-1700

Water to oil

110-350

Steam condenser, water in tubes

1000-6000

Ammonia condenser, water in tubes

800-1400

Alcohol condenser, water in tubes

250-700

Finned tube, water in tubes, air in cross flow

25-50

Agitated and jacketed vessels#

The process-side coefficient at the wall of a jacketed, agitated vessel is correlated as

\[\mathrm{Nu} = \frac{hD_T}{k} = a\,\mathrm{Re}^b\,\mathrm{Pr}^{1/3}\left(\frac{\mu}{\mu_w}\right)^m, \qquad \mathrm{Re} = \frac{\rho N D_a^2}{\mu},\]

with \(\mathrm{Nu}\) on the tank diameter \(D_T\), \(\mathrm{Re}\) on the impeller diameter \(D_a\) and \(N\) in rev/s. agitated_vessel_nusselt(Re, Pr, mu_ratio, impeller) takes the constants from AGITATED_VESSEL_CONSTANTS (or a=, b=, m=); jacketed_vessel_h(rho, N, D_a, D_T, mu, Cp, k, mu_wall, impeller) returns \(h\). agitated_coil_h(Re, Pr, k, D_T, mu_ratio, method, D_a, d_o) is the coefficient on the outside of an immersed helical coil.

Key

\(a\)

\(b\)

\(m\)

Re range

Source

paddle

0.36

2/3

0.14

300-3×10⁵

Chilton, Drew & Jebens (1944), as in Kern (1950) Ch. 20

flat_blade_turbine (baffled)

0.74

0.67

0.14

200-10⁵

Uhl (1955), via Uhl & Gray / Perry’s (recalled)

anchor

0.36

2/3

0.18

300-4×10⁴

Uhl (1955), via Uhl & Gray / Perry’s (recalled)

anchor_low_re

1.0

1/2

0.18

30-300

Uhl (1955), via Uhl & Gray / Perry’s (recalled)

Coil method

Correlation

chilton_drew_jebens (paddle)

\(hD_T/k = 0.87\,\mathrm{Re}^{0.62}\mathrm{Pr}^{1/3}(\mu/\mu_w)^{0.14}\)

oldshue_gretton (flat-blade turbine, baffled)

\(hd_o/k = 0.17\,\mathrm{Re}^{0.67}\mathrm{Pr}^{0.37}(D_a/D_T)^{0.1}(d_o/D_T)^{0.5}\)

The paddle and coil constants are the textbook forms (Kern Ch. 20; Oldshue & Gretton, Chem. Eng. Prog. 50, 615, 1954). The turbine and anchor rows are recalled from secondary tabulations and were not checked against a printed copy; geometry corrections of fuller correlations (blade width, clearance, \(D_a/D_T\)) are not included. The arithmetic and the gradients were checked when the model was written; no textbook worked example is reproduced.

import jax
from difflow import heat_transfer as ht

# water in a 1.5 m tank, 0.5 m paddle; h on the vessel side, d h / d N
h = ht.jacketed_vessel_h(1000.0, 2.0, 0.5, 1.5, 1.0e-3, 4180.0, 0.6, mu_wall=0.8e-3)
dh_dN = jax.grad(lambda N: ht.jacketed_vessel_h(1000.0, N, 0.5, 1.5, 1.0e-3, 4180.0, 0.6))(2.0)
U = ht.overall_U(h_i=h, h_o=5000.0, D_i=1.5, D_o=1.52, k_wall=16.0)   # steam jacket, thin wall
print(float(h), float(dh_dN), float(U))

Economic insulation thickness#

insulated_pipe_heat_loss(D_o, thickness, k_ins, h_out, T_pipe, T_amb) is the heat loss per metre through the insulation and the outer film, \(q' = \Delta T/[\ln((D_o+2t)/D_o)/(2\pi k) + 1/(h_{out}\pi(D_o+2t))]\). difflow.economics.insulation prices the insulation and finds the thickness that minimises the total annual cost per metre, \(\mathrm{TAC}(t) = \mathrm{CRF}\,C_{inst}(t) + q'(t)\,\text{hours}\,c_{heat}\):

Function

Purpose

insulation_volume_per_length(D_o, t)

\(\pi t(D_o+t)\), m³/m

insulation_installed_cost(D_o, t, cost_per_m3, cost_per_m2=0, cost_per_m=0, base_year, target_year)

\(c_V\pi t(D_o+t) + c_A\pi(D_o+2t) + c_L\) ($/m), CEPCI-escalated

insulation_annual_cost(t, D_o, k_ins, h_out, T_pipe, T_amb, cost_per_m3, heat_cost, ...)

TAC and its parts (\(/m/yr); `heat_cost` in \)/GJ

economic_insulation_thickness(...)

the optimum \(t\) (bisection on \(d\mathrm{TAC}/dt\); differentiable by implicit differentiation)

The cost is linear in the insulated volume (material and most labour), plus optional cladding-area and per-metre terms. There is no default unit cost: published installed-cost data (Turner & Malloy, Thermal Insulation Handbook; Peters, Timmerhaus & West) could not be checked, so cost_per_m3 is a required input from your own estimate, with its base_year for CEPCI escalation. The prices in the example are illustrative.

import jax
from difflow.economics import economic_insulation_thickness

# 4-in (0.1143 m OD) steam line at 450 K; ILLUSTRATIVE prices
opt = lambda c_heat: economic_insulation_thickness(
    0.1143, 0.05, 10.0, 450.0, 293.15, cost_per_m3=3000.0, heat_cost=c_heat)["thickness"]
print(f"t_opt = {1000 * float(opt(10.0)):.0f} mm, dt/d(heat price) = {float(jax.grad(opt)(10.0)):.4f} m per $/GJ")

When the model was written, the optimum was checked against a dense grid minimum and the stationarity condition, and its gradients against finite differences.

Gas Transport Properties#

difflow.gas_properties covers low-pressure (ideal-gas) transport and heat capacity for air, N2, O2, CO2, CO, CH4, H2, Ar, He, H2O (steam), and the natural-gas and refinery-gas components ethane, propane, n-butane, isobutane and H2S. It is a pure-JAX library, not a palette operation, and is differentiable in T.

Function

Returns

gas_viscosity(name, T)

μ (Pa s)

gas_thermal_conductivity(name, T)

k (W/m/K)

gas_heat_capacity(name, T)

\(c_p\) (J/kg/K)

gas_prandtl(name, T)

Pr

gas_density(name, T, P=101325), gas_kinematic_viscosity

ideal-gas ρ, ν

gas_transport_properties(name, T, P)

dict: mu, k, cp, Pr, rho, nu, alpha

gas_property_range(name, prop), list_gas_species()

validity range, names

sutherland_viscosity(T, gas=None, *, mu0, T0, S), sutherland_conductivity

Sutherland’s law (White’s constants for air, Ar, N2, O2 in SUTHERLAND)

mixture_viscosity(names, y, T, method="wilke")

low-pressure mixture μ: Wilke, or "herning_zipperer"

mixture_conductivity(names, y, T)

low-pressure mixture k: Wassiljewa with Mason-Saxena coefficients

dense_gas_viscosity(names, T, P, y=None, rho=None)

μ at pressure: dilute + Jossi-Stiel-Thodos residual

dense_gas_conductivity(names, T, P, y=None, rho=None)

k at pressure: dilute + Stiel-Thodos residual

gas_mixture_transport(names, y, T, P, dense=True)

dict: mu, k, rho, MW, Z (Peng-Robinson density)

Each property is a fit \(\ln y=\sum a_i[\ln(T/300)]^i\): cubic for μ and k, quintic for \(c_p\). The fits are made by scripts/fit_gas_transport.py to the NIST Chemistry WebBook fluid tables at 1 atm (tests/reference/nist_gases_1atm/). They match every NIST point to 0.2% (μ), 0.55% (k) and 0.15% (\(c_p\)) over each gas’s stored range. Ethane, propane, the butanes and H2S are fitted the same way to 1 atm tables from CoolProp’s reference models (tests/reference/coolprop_gases_1atm/, written by scripts/make_coolprop_gas_tables.py), from just above each normal boiling point; H2S has no reference conductivity model, so its k is the Chung et al. estimate (about 5-8%). Air μ and k come from the Engineering ToolBox air tables, and air \(c_p\) is the mass-weighted N2/O2/Ar NIST mixture. Outside a fit’s range the value is extrapolated and a GasRangeWarning is raised (on concrete values only). Air at 300 K gives μ = 18.46 µPa s, k = 26.4 mW/m/K and Pr = 0.704, against Incropera Table A.4 values of 18.46, 26.3 and 0.707. The single-gas fits are for low pressure. Above about 10 bar use the dense-gas functions: they add the Jossi-Stiel-Thodos (μ) and Stiel-Thodos (k) residuals in the reduced density \(\rho_r=\rho V_c\). The density comes from Peng-Robinson with the critical constants in GAS_CRITICAL (Poling et al. 5e, App. A; the reference-EOS critical points for the hydrocarbons and H2S), and a mixture uses Kay’s-rule pseudo-critical constants. Methane at 15 °C and 50 bar gives 11.8 µPa s against a NIST value of about 12.0 (dilute: 10.75). The residuals suit nonpolar gases; they are not meant for H2, He or steam near saturation.

from difflow.gas_properties import gas_transport_properties
p = gas_transport_properties("air", 350.0)   # film temperature
p["mu"], p["k"], p["Pr"], p["nu"]

ShellAndTubeDesign#

Location: difflow/shell_and_tube.py (a library, not a palette operation: it takes mass flows and explicit properties and returns a geometry, like difflow.heat_transfer; examples/46_shell_and_tube_design.ipynb)

Description: Sizes a 1-2N shell-and-tube exchanger. ShellAndTubeDesign(params)(hot, cold, Q=...) (or T_hot_out= / T_cold_out=; streams are {"T": K, "m_dot": kg/s}) assumes U, gets the area from \(Q=UAF\,\mathrm{LMTD}\), builds the geometry, computes the tube-side film coefficient (heat_transfer.internal_h, Gnielinski with fluids.friction_factor), the shell-side one by the Kern method, and a new U from heat_transfer.overall_U with fouling, and iterates to a fixed point with optimistix.fixed_point, so the area and everything derived from it are differentiable (implicit-function-theorem gradients) in baffle spacing, tube velocity, passes, flows, fouling and properties.

Equations

\[A=\frac{Q}{U\,F\,\mathrm{LMTD}_{cc}},\qquad \frac1{U_o}=\frac1{h_o}+R_{fo}+\frac{d_o\ln(d_o/d_i)}{2k_w}+R_{fi}\frac{d_o}{d_i}+\frac{d_o}{d_ih_i}\]

Kern shell side (segmental baffles, 25 % cut; Kern 1950, Coulson & Richardson Vol 6 Eqs. 12.21-12.26):

\[D_e=\frac{1.10}{d_o}\left(P_t^2-0.917\,d_o^2\right)\ (\triangle),\quad D_e=\frac{1.27}{d_o}\left(P_t^2-0.785\,d_o^2\right)\ (\square),\quad A_s=\frac{(P_t-d_o)D_sl_B}{P_t},\quad G_s=\frac{\dot m_s}{A_s}\]
\[\mathrm{Nu}_s=\frac{h_oD_e}{k}=0.36\,\mathrm{Re}_s^{0.55}\mathrm{Pr}^{1/3}\Big(\frac{\mu}{\mu_w}\Big)^{0.14},\qquad \Delta P_s=f_s\frac{D_s}{D_e}\frac{L}{l_B}\frac{\rho u_s^2}{2}\Big(\frac{\mu}{\mu_w}\Big)^{-0.14},\quad f_s=e^{0.576-0.19\ln\mathrm{Re}_s}\]

(\(L/l_B\) is the number of baffle crossings, \(N_b+1\); \(f_s=8j_f\) of the Coulson & Richardson chart.) Tube side:

\[\Delta P_t=N_p\Big(f\frac{L}{d_i}+4\Big)\frac{\rho v^2}{2},\qquad v=\frac{\dot m\,N_p}{\rho\,N_t\,\pi d_i^2/4}\]

(\(f\) Darcy from fluids.friction_factor; 4 velocity heads per pass for the return, Coulson & Richardson use 2.5.) Geometry: \(D_b=d_o(N_t/K_1)^{1/n_1}\) (bundle_diameter, C&R Table 12.4 constants for \(P_t=1.25d_o\)), shell \(D_s=D_b+\) shell_clearance; tube_count(shell_ID, ...) is the Kakac form \(N_t=\mathrm{CTP}\,(\pi/4)D_s^2/(\mathrm{CL}\,P_t^2)\). \(F\) is the Bowman 1-2N factor of lmtd_correction_factor; effectiveness_shell_and_tube is the same thing in \(\varepsilon\)-NTU form.

Function / class

Purpose

equivalent_diameter, kern_shell_side, kern_shell_pressure_drop, kern_friction_factor

Kern shell side: \(D_e,A_s,G_s,\mathrm{Re}_s,h_o,\Delta P_s\)

tube_side_pressure_drop

friction (fluids.friction_factor) plus return losses per pass

tube_count, bundle_diameter

continuous tube-count and bundle-diameter correlations; BUNDLE_CONSTANTS, BWG_WALL_THICKNESS, TEMA_TUBE_PITCH, tube_inside_diameter data

ShellAndTubeDesignParams, ShellAndTubeDesign

the design loop; .rate(...) re-rates a given geometry

round_design

integer tube count, standard passes, baffle spacing and shell ID for building

import jax
from difflow import shell_and_tube as st

p = st.ShellAndTubeDesignParams(
    tube_OD=0.01905, tube_ID=0.01483, tube_side="cold",
    rho_hot=780.0, mu_hot=4e-4, k_hot=0.13, Cp_hot=2200.0,      # properties are inputs
    rho_cold=830.0, mu_cold=2.9e-3, k_cold=0.13, Cp_cold=2050.0,
    tube_velocity=1.5, tube_length=None, R_fi=1e-4, R_fo=1e-4,
    dP_tube_max=7e4, dP_shell_max=7e4)
design = st.ShellAndTubeDesign(p)
hot, cold = {"T": 473.0, "m_dot": 5.0}, {"T": 303.0, "m_dot": 18.0}
r = design(hot, cold, T_hot_out=373.0)            # U, area, n_tubes, shell_ID, dP_tube, dP_shell, F, flags
dA = jax.grad(lambda lB, v: design(hot, cold, T_hot_out=373.0, baffle_spacing=lB,
                                   tube_velocity=v, warn=False)["area"], argnums=(0, 1))(0.25, 1.2)
geom = st.round_design(r)                         # buildable geometry ...
rated = design.rate(hot, cold, **geom)            # ... and its re-rating

Two modes for the free geometry. With tube_length the tube count follows from the area; with tube_velocity the tube count follows from the flow and the tube length from the area. The baffle spacing is baffle_spacing (m) or baffle_spacing_ratio times the shell ID. Any of these, n_tube_passes, tube_OD, tube_ID, pitch_ratio, k_wall and the fouling resistances can be passed as call overrides, traced or not, to differentiate with respect to them.

Warnings and flags. The result carries F_too_low (F < 0.75), F_zero (F = 0), dP_tube_exceeded / dP_shell_exceeded (against dP_tube_max / dP_shell_max), converged, tube_laminar (Re_tube < 2300), shell_laminar (Re_shell < 2000, below the range of Kern’s power law) and shell_Re_out_of_range (outside \(2\times10^3<\mathrm{Re}_s<10^6\), where the Kern coefficient is an extrapolation). Each also raises a warning for concrete values (never under jit/grad). A low or zero \(F\) raises LowCorrectionFactorWarning (a UserWarning from difflow.units.heat_exchanger, the class the HX design functions use); \(F=0\) is the infinite-area limit of lmtd_correction_factor (a temperature cross these shells cannot make, issue #419), so the area is inf and the other warnings are suppressed.

Shells in series. n_shell_passes=N (default 1) puts \(N\) identical shells in series, each carrying the full flow of both streams, with \(F=\) lmtd_correction_factor(R, P, N). A cross that one shell cannot make (\(F=0\)) becomes feasible with two. area, UA and the pressure drops are totals over the shells; n_tubes, tube_length, shell_ID, bundle_ID and baffle_spacing are per shell (area_per_shell is reported), and round_design and rate work on that per-shell geometry with the same n_shell_passes.

import warnings
from difflow import shell_and_tube as st

def params(N):
    return st.ShellAndTubeDesignParams(
        tube_OD=0.01905, tube_ID=0.01483, rho_hot=780.0, mu_hot=4e-4, k_hot=0.13, Cp_hot=2050.0,
        rho_cold=830.0, mu_cold=2.9e-3, k_cold=0.13, Cp_cold=2050.0,   # illustrative properties
        tube_velocity=1.2, tube_length=None, n_shell_passes=N)

hot, cold = {"T": 340.0, "m_dot": 5.0}, {"T": 300.0, "m_dot": 5.0}   # hot 340 -> 316 K, cold 300 -> 324 K
Q = 5.0 * 2050.0 * 24.0
with warnings.catch_warnings(record=True) as w:
    warnings.simplefilter("always")
    one = st.ShellAndTubeDesign(params(1))(hot, cold, Q=Q)     # F = 0: LowCorrectionFactorWarning
two = st.ShellAndTubeDesign(params(2))(hot, cold, Q=Q, warn=False)
float(one["F"]), bool(one["F_zero"]), float(two["F"]), float(two["area"])   # 0, True, F > 0, finite area

Non-unique fixed point. With a fixed tube length, more area means more tubes, slower tube flow and a lower \(h_i\): positive feedback, so the loop can have a second, large-area, laminar solution. In one case measured when the model was written, a start at \(U\le100\) lands on 211 m\(^2\) at \(\mathrm{Re}_t\approx1500\), against 13 m\(^2\) at \(\mathrm{Re}_t\approx23000\) from any typical start. It is a genuine root of the same equations; tube_laminar and a warning flag it, and fixing tube_velocity removes the feedback.

Integer quantities. Tube count, tube passes and the number of baffles are continuous in the design (n_baffles \(=L/l_B-1\); passes interpolate the \(K_1,n_1\) and CTP tables linearly in \(\ln N_p\) / \(N_p\), a smoothing choice, not a published correlation) so that jax.grad and gradient-based optimizers work. To build the result: round_design(result) rounds the tube count up, the passes to the nearest standard even count (1, 2, 4, 6, 8), the baffle spacing down and the shell ID up to a step (illustrative, not TEMA), and design.rate(hot, cold, **geometry) re-rates that geometry. Rounding changes the velocities, \(h_i\), \(h_o\) and both pressure drops, so re-check the duty margin and the limits after rounding; in the example notebook the rounded 7 m, 54-tube, single-pass design re-rates to 1.01 times the target duty.

Phase change (simple mode). hot_isothermal=True (condensing) or cold_isothermal=True (boiling) makes that side isothermal (\(F=1\)), and shell_h supplies its film coefficient: a number, or a callable of a dict, e.g. built on heat_transfer.nusselt_film_condensation or the boiling correlations. The dict has the geometry (tube_OD, n_tubes, tube_length, shell_ID, area per shell, area_total) and the wall state from the current \(U\) iterate: q_flux (\(Q/A\)), T_wall (the shell-side film’s wall surface, outside any shell-side fouling), dT_film (\(|T_{shell}-T_{wall}|\)), T_shell and T_tube (mean bulk temperatures). The wall temperature is \(T_{wall}=T_{tube,eff}\pm q''R_{chain}\) with the tube-side film, its fouling, the wall and the shell fouling in \(R_{chain}\), and the tube-side temperature chosen so that \(q''=U\,F\,\mathrm{LMTD}\); the loop is closed inside the same optimistix.fixed_point as \(U\) (and, for a callable, in rate() on the heat flux), so at convergence \(T_{shell}-T_{wall}=q''/h_o\) and the design needs no outer loop on \(\Delta T_{film}\), and is still differentiable. A callable that reads only the geometry keys is unaffected by the extra keys. Shell-side \(\Delta P\) is then not modelled (reported as 0).

import jax.numpy as jnp
from difflow import heat_transfer as ht, shell_and_tube as st

def shell_h(g):                    # condensing steam on a column of horizontal tubes (illustrative properties)
    return ht.nusselt_film_condensation(k_l=0.6, rho_l=960.0, rho_v=0.6, mu_l=2.8e-4, h_fg=2.26e6,
                                        Cp_l=4216.0, dT=g["dT_film"], L=g["tube_OD"],
                                        n_tubes=jnp.sqrt(g["n_tubes"]))

p = st.ShellAndTubeDesignParams(
    tube_OD=0.01905, tube_ID=0.01483, rho_hot=960.0, mu_hot=2.8e-4, k_hot=0.6, Cp_hot=4216.0,
    rho_cold=830.0, mu_cold=2.9e-3, k_cold=0.13, Cp_cold=2050.0, tube_length=3.0,
    hot_isothermal=True, shell_h=shell_h, tol=1e-13, R_fi=1e-4, R_fo=1e-4)
r = st.ShellAndTubeDesign(p)({"T": 373.15, "m_dot": 1.0}, {"T": 303.0, "m_dot": 18.0}, Q=6.0e5)
float(r["q_flux"] / r["h_o"]), float(r["area"])     # the film dT the loop settled on, and the area

Desuperheating and subcooling zones are not split out; for a rigorous two-phase rating use EnthalpyCounterCurrentHX.

Design and rating agree. design.rate(...) for the designed geometry returns the design duty to 1e-8, and ShellAndTubeHX(UA=U*A, ...) returns the same duty and outlet temperatures (checked when the model was written). Making that true required two fixes to the existing rating code (see the changelog): lmtd_correction_factor returned its \(R=1\) value for every \(|R-1|>0.075\), and ShellAndTubeHX took \(Q=F\,Q_{cc}(UA)\), a few percent off \(Q=UAF\,\mathrm{LMTD}\); it now uses the exact 1-2N (and \(n\)-shell) effectiveness effectiveness_shell_and_tube.

What is validated (and what is not).

  • Not reproduced: the classic Kern kerosene-crude example (Kern 1950; Coulson & Richardson Vol 6, Ch. 12 worked examples). The inputs and the printed answers could not be reproduced from memory with confidence and no copy of either book was available, so no agreement with them is claimed. The issue’s criterion “within 5 % of the textbook example” is therefore open: it needs someone with the book to enter the example’s inputs and compare.

  • Checked when the model was written: every Kern equation (\(D_e\), \(A_s\), \(G_s\), \(\mathrm{Re}_s\), \(h_o\), \(\Delta P_s\)) and the tube-side equations (velocity, Re, Gnielinski \(h_i\) with a brentq-solved Colebrook factor, friction and return \(\Delta P\)) against a plain-Python hand calculation (1e-12 to 1e-6); the whole loop against an independent plain-Python re-implementation with substitution on \(U\) (2e-6, both modes); \(F\) against the closed form; the effectiveness against \(Q=UAF\,\mathrm{LMTD}\) solved by brentq; gradients by check_grads and central finite differences.

  • Item list of issue #435. Wall-state shell_h, the optional viscosity correction, the core fouling model, shells in series, the shell-Re flag and the \(F=0\) warning are implemented and tested (tests/test_shell_and_tube_gaps.py, tests/test_fouling.py); the viscosity exponents are unverified (above). The Palen bundle factor for boiling CHF is not implemented (no sourced constants), and the \(K_1,n_1\) and tube-count constants remain unverified: a pass in the issue’s work compared the triangular-pitch rows with an independent recollection (1 pass 0.319/2.142, 2 passes 0.249/2.207, 4 passes 0.175/2.285, 6 passes 0.0743/2.499, 8 passes 0.0365/2.675), which agrees, but that is memory against memory, not a check against Sinnott’s Table 12.4.

  • Recalled from memory and not verified against the books: the Kern constants (0.36, 0.55 for \(\mathrm{Nu}\); 0.576, 0.19 for \(f_s\), a fit to Kern’s chart, not the chart), the \(D_e\) rounded constants (1.10/0.917/1.27/0.785 are checked against the exact pitch-cell hydraulic diameters to 3 %), the \(K_1,n_1\) bundle constants (sanity-banded against a brute-force tube-lattice count, 15 %), the CTP/CL tube-count constants, the BWG wall table and the TEMA pitch table. Kern ignores leakage and bypass streams (Bell-Delaware, out of scope, handles them): treat it as a screening-level method; its accuracy was not assessed here.

References: Kern, D.Q. (1950), Process Heat Transfer, McGraw-Hill; Coulson & Richardson, Chemical Engineering Vol. 6 (Sinnott), Ch. 12; Kakac, Liu & Pramuanjaroenkij, Heat Exchangers: Selection, Rating, and Thermal Design; Bowman, Mueller & Nagle, Trans. ASME 62 (1940) 283; Kays & London, Compact Heat Exchangers; Gnielinski, Int. Chem. Eng. 16 (1976) 359.

Threshold Fouling#

Location: difflow/fouling.py (a library of pure JAX functions, not a palette operation; the core counterpart of the crude-preheat fouling model in difflow_refinery.preheat.fouling, which is unchanged and has its own illustrative constants).

Description: The Ebert-Panchal threshold model: deposition is Arrhenius in the film temperature, removal goes with the wall shear stress, and below the threshold a surface does not foul.

\[\frac{dR_f}{dt}=\alpha\,\mathrm{Re}^{-0.66}\mathrm{Pr}^{-0.33}\,e^{-E/(RT_{film})}-\gamma\,\tau_w, \qquad \tau_w=\frac f8\rho v^2\]

\(\alpha\), \(\gamma\) and \(E\) are user-supplied and fluid-specific (no defaults), fitted to plant or slip-stream data; every number in the example below is illustrative, chosen for the arithmetic, not for any fluid. The \(0.55\) film weight of film_temperature is the refinery plugin’s convention, unverified, and the form of the equation is recalled from the literature (Ebert and Panchal, 1995) and not re-checked against the paper. For the same constants ebert_panchal_rate returns the refinery function’s value (a test, skipped when the plugin is not installed).

Function

Purpose

ebert_panchal_rate(Re, Pr, T_film, tau_w, alpha, gamma, E, beta=-0.66, floor_at_zero=False)

\(dR_f/dt\) (m\(^2\) K/W/s); negative below the threshold

wall_shear_stress(f, rho, v)

\(\tau_w=f\rho v^2/8\) from the Darcy factor

film_temperature(T_bulk, T_wall)

\(T_b+0.55(T_w-T_b)\)

threshold_wall_shear, threshold_film_temperature

the shear (Pa) or film temperature (K) at which the rate is zero

fouling_time(R_target, rate, R_f0=0)

time to a target resistance at a constant rate (inf if the rate is not positive)

integrate_fouling(rate_fn, t_end, R_f0, n_steps)

RK4 integration of a rate that depends on \(R_f\); differentiable in whatever rate_fn closes over

import jax
from difflow import fluids, fouling

alpha, gamma, E = 2.0e-3, 4.0e-13, 48.0e3     # ILLUSTRATIVE constants, not fitted to any fluid
rho, mu, D, Pr = 800.0, 5.0e-3, 0.02, 10.0    # illustrative tube-side properties

def rate(v, T_film=600.0):
    Re = fluids.reynolds_number(rho, v, D, mu)
    tau = fouling.wall_shear_stress(fluids.friction_factor(Re, 0.0), rho, v)
    return fouling.ebert_panchal_rate(Re, Pr, T_film, tau, alpha, gamma, E)

r = rate(1.0)                                  # m^2 K/W per s
days = fouling.fouling_time(3.0e-4, r) / 86400.0
drdv = jax.grad(rate)(1.0)                     # d(rate)/d(velocity): faster flow, less fouling

TEMA design fouling resistances#

difflow.fouling also holds the TEMA design values (Recommended Good Practice Table RGP-T-2.4, 7th ed., 1988), stored in TEMA’s unit, h ft² °F/Btu, and returned in m² K/W (×HFT2F_PER_BTU = 0.17611):

Name

Content

TEMA_FOULING

(low, high) per service: fuel oils No. 2 / No. 6, transformer, engine lube, quench oil; manufactured gas, exhaust gas, steam (with and without oil), refrigerant vapours, compressed air, NH₃, CO₂, Cl₂ vapours, flue gases; refrigerant liquids, molten salts, hydraulic fluid, heat-transfer media, brines, alcohol and glycol solutions; acid gases, solvent vapours, MEA/DEA, DEG/TEG, caustic, vegetable oils; natural gas, lean/rich oil, LPG; gasoline, naphtha, kerosene, light/heavy gas oil, heavy fuel oil, residuum; the lube-oil processing streams and refined lube oil

tema_fouling_resistance(service, which="high")

m² K/W; "high" (conservative end), "low" or "mean"

TEMA_CRUDE_OIL, tema_crude_oil_fouling(T, v)

crude oil by temperature (0-199, 200-299, 300-499, ≥500 °F) and velocity (<2, 2-4, >4 ft/s); arguments in K and m/s

TEMA_WATER, tema_water_fouling(water, T_water, v)

sea, brackish, cooling-tower (treated / untreated), city, river, muddy, hard, engine-jacket, condensate, boiler feedwater, blowdown; split at 125 °F water temperature and 3 ft/s

Crude oil (h ft² °F/Btu):

Temperature

< 2 ft/s

2-4 ft/s

> 4 ft/s

0-199 °F

0.003

0.002

0.002

200-299 °F

0.003

0.002

0.002

300-499 °F

0.004

0.003

0.002

≥ 500 °F

0.005

0.004

0.003

Provenance. No printed copy of TEMA was available. The values were recalled from the table as it is reproduced in the secondary literature (e.g. Serth, Process Heat Transfer, Ch. 3; Kakac & Liu, Heat Exchangers). Where Incropera & DeWitt Table 11.1 (which was checked, and itself cites TEMA) overlaps, the converted values agree after rounding to its 4 decimal places (sea water, boiler feedwater, fuel oil, refrigerant liquids, oil-free steam; checked when the model was written); the other entries are unchecked. Pre-1988 TEMA editions split crude oil into “dry” and “salt” columns with other values. The lookups are piecewise constant (zero derivative inside a bin) but jit-able and vectorised.

from difflow import fouling

R_crude = fouling.tema_crude_oil_fouling(530.0, 1.5)          # K, m/s -> m^2 K/W
R_cw = fouling.tema_water_fouling("cooling_tower_treated", 310.0, 1.2)
print(float(R_crude), float(R_cw), fouling.tema_fouling_resistance("kerosene"))

Evaporators#

An evaporator concentrates a solution of a non-volatile solute by boiling off the solvent (water) with steam: caustic, sugar, pulp liquor, brine, pharma concentrates. What separates it from a Heater followed by a Flash is that the solute raises the boiling point, the heat goes through a surface with a given U and area, and in a multiple-effect train the vapor of one effect is the heating medium of the next. Source: difflow/units/evaporator.py.

How the solute is represented. The solute is an ordinary difflow species in the stream (F_NaOH, in mol/s) whose vapor flow is exactly zero: the vapor outlet carries the same species keys with the solute at 0.0. That is the difflow-native form of “vapor pressure zero” and needs no special case anywhere (nothing divides by a vapor pressure or a vapor flow, so gradients stay finite). The alternative, a mass-fraction field riding on the stream, would not survive a Mixer or a Splitter. Mass fractions are computed from the molar flows and the solute_MW on the params; x is always the solute mass fraction.

Steam properties are self-contained correlations in the module (IAPWS-IF97 region-4 for Psat(T)/Tsat(P), fits for h_f and h_fg valid 20-200 C), not the IdealThermo Antoine/Watson water entry, whose latent heat is too coarse for a steam economy.

Boiling-point rise (bpr_model, or boiling_point_rise(x, P, model)):

Model

What it is

Status

ideal

Raoult’s law with unit activity: the solution boils where x_w Psat(T) = P; vant_hoff_i counts particles per solute

exact for the ideal solution

colligative

dT = R Tw^2 i m / lambda, the dilute limit

exact in the limit

duhring

your own lines T_soln = a(x) + b(x) T_water (duhring_x/a/b)

your data

polynomial

BPR in K as a polynomial in x (bpr_coeffs)

your data

naoh

built-in 1 atm table turned into Duhring lines nearly parallel to the water line

30 and 50 wt % checked against Geankoplis Ex. 8.4-2/8.4-3 and McCabe Ex. 16.1; other points unverified

nacl

built-in 1 atm table turned into Duhring lines (colligative slope)

unverified, see below

sucrose

1.78 x + 6.22 x^2 K (Geankoplis 4e Ex. 8.5-1)

the book’s correlation

callable

Evaporator(params, bpr_fn=f), f(x, T_water, P) -> K

yours

Warning

The built-in naoh and nacl tables are only partly verified, and selecting one raises UnverifiedDataWarning. For NaOH the 50 wt % line is anchored on cited values (141.7 C at 1 atm; 89.5 C over water at 48.9 C, Geankoplis 4e Ex. 8.4-3; 197 F over water at 124 F, McCabe-Smith-Harriott 7e Ex. 16.1) and the 30 % line reproduces Geankoplis Ex. 8.4-2 (79.5 C over 65.6 C) within 0.4 K. The NaOH lines use an empirical slope b = 1 + k BPR fitted to the 50 % line (DUHRING_SLOPE_COEFF); the colligative slope used before put the 50 % line 12 K low at 49 C. The other NaOH points, and all of NaCl except the saturated solution near 108.7 C, are recollections: read your chart and pass it through duhring= or bpr_fn when the number matters. The tabulated models are piecewise linear in x.

Solution enthalpy is a Cp mixing rule by default ((1-x) h_f(T) + x Cp_solute (T - 273.15), no heat of dilution); give cp_coeffs (a polynomial in x) or Evaporator(params, enthalpy_fn=f), f(T, x) -> J/kg, for a heat of dilution or an enthalpy-concentration chart (NaOH is the classic case). The vapor leaves at the solution temperature, superheated by the BPR (Cp_vapor), and condensate leaves saturated.

Evaporator#

Location: difflow/units/evaporator.py

Class: Evaporator(params, *, bpr_fn=None, enthalpy_fn=None); call concentrate, vapor, info = evap(feed, steam=None).

@dataclass
class EvaporatorParams:
    solute_species: list[str]   # non-volatile species names (required)
    solute_MW: list[float]      # their molar masses, g/mol (required)
    solvent: str = "water"
    U: float = 2000.0           # W/m^2/K
    A: float = None             # m^2   -> rating
    x_product: float = None     # kg/kg -> design (exactly one of A, x_product)
    P_vapor_space: float = 101325.0
    steam_P: float = None       # Pa (or steam_T, K, or pass a steam stream)
    bpr_model: str = "ideal"    # see the table above
    vant_hoff_i: float = 1.0
    ...                         # bpr_coeffs, duhring_*, Cp_solute, cp_coeffs, Cp_vapor

Two modes, as for design_heat_exchanger: design (x_product given: steam, vapor, duty and the required area in closed form) and rating (A given: the product concentration is solved, differentiably).

Governing Equations#

\[x_F F = x_P L,\quad V = F - L,\quad T = T_{sat}(P_v) + \mathrm{BPR}(x_P, P_v)\]
\[Q = L h_L(T, x_P) + V H_V - F h_F,\qquad H_V = h_g(T_w) + c_{p,v}(T - T_w)\]
\[S \lambda_s = Q,\qquad Q = U A (T_s - T),\qquad \text{economy} = V / S\]

info holds steam (kg/s), steam_economy (vapor/steam), Q, T_soln, BPR, A, x_product, V, L, driving_force, and two closure residuals, energy_balance_error and mass_balance_error.

from difflow import make_stream
from difflow.units import Evaporator, EvaporatorParams

# 10 kg/s of 10 wt % NaOH, concentrated to 30 wt % at 30 kPa with 300 kPa steam
feed = make_stream({"water": 9000 / 18.015, "NaOH": 1000 / 40.0}, T=330.0, P=2e5)
params = EvaporatorParams(
    solute_species=["NaOH"], solute_MW=[40.0], U=1500.0, x_product=0.30,
    P_vapor_space=30e3, steam_P=300e3, bpr_model="ideal", vant_hoff_i=2.0,
)
concentrate, vapor, info = Evaporator(params)(feed)
float(info["steam"]), float(info["steam_economy"]), float(info["A"]), float(info["BPR"])

# Rating the same vessel: give the area, get the concentration back
rating = EvaporatorParams(
    solute_species=["NaOH"], solute_MW=[40.0], U=1500.0, A=float(info["A"]),
    P_vapor_space=30e3, steam_P=300e3, bpr_model="ideal", vant_hoff_i=2.0,
)
_, _, back = Evaporator(rating)(feed)
float(back["x_product"])          # 0.30

MultiEffectEvaporator#

Location: difflow/units/evaporator.py

Class: MultiEffectEvaporator(params, *, bpr_fn=None, enthalpy_fn=None); call concentrate, vapor, info = mee(feed, steam=None).

N effects; the vapor of effect i heats effect i+1, effect 1 gets the live steam and the last effect vents at P_vapor_space. Per-effect U (a list, or one value for all). Feed arrangements feed="forward" | "backward" | "parallel".

  • Design, equal areas (x_product given): the intermediate effect temperatures, the steam rate, the vapor flows and one common area A are solved so that every effect has the same area, as one Newton solve (optimistix) of the stacked energy balances, rate equations and the overall solute balance (the textbook iteration, done simultaneously). It is differentiable with respect to U, the feed and the steam pressure by the implicit function theorem. For parallel feed the feed split is solved too.

  • Rating (A list given): areas fixed, the product concentration follows. Parallel feed uses feed_split (default equal).

MultiEffectEvaporator with n_effects=1 agrees with Evaporator. info has per-effect T, T_water, P, BPR, V, A, Q, x (index 0 is effect 1), the overall steam, steam_economy (total vapor / steam), total_area, converged, residual_norm and the closure residuals. The vapor in effect i+1’s steam chest carries the superheat that the BPR of effect i puts into it; condensate leaves each chest saturated.

from difflow.units import MultiEffectEvaporator, MultiEffectEvaporatorParams

mee_params = MultiEffectEvaporatorParams(
    solute_species=["NaOH"], solute_MW=[40.0], n_effects=3, feed="forward",
    U=[2500.0, 2000.0, 1500.0], x_product=0.30, P_vapor_space=30e3,
    steam_P=300e3, bpr_model="ideal", vant_hoff_i=2.0,
)
concentrate, vapor, info = MultiEffectEvaporator(mee_params)(feed)
print("converged:", bool(info["converged"]))
print("steam economy:", round(float(info["steam_economy"]), 2))
print("areas (m^2):", [round(float(a), 1) for a in info["A"]])
print("effect T (K):", [round(float(t), 1) for t in info["T"]])

MechanicalVaporRecompression#

Location: difflow/units/evaporator.py

Class: MechanicalVaporRecompression(params); call concentrate, condensate, info = mvr(feed).

A single effect whose own vapor is compressed (ideal-gas steam, isentropic, efficiency eta, ratio gamma) until it condenses dT_drive above the boiling solution and heats the same surface. Design mode (x_product given). info reports the compressor work W_compressor against the steam it saves (steam_without_mvr, steam_makeup, steam_saved when steam_P is given). The compression is an ideal-gas calculation, not the EOS Compressor, with the heat capacity that belongs to gamma (cp = gamma R/(gamma - 1), 2000 J/kg/K at 1.3). Compressing saturated steam from 100 C to 200 kPa it gives 127 kJ/kg against the steam table’s 126.2 kJ/kg.

from difflow.units import MechanicalVaporRecompression, MVRParams

mvr = MechanicalVaporRecompression(MVRParams(
    solute_species=["NaOH"], solute_MW=[40.0], U=1500.0, x_product=0.30,
    P_vapor_space=101325.0, dT_drive=10.0, eta=0.75, steam_P=300e3,
    bpr_model="ideal", vant_hoff_i=2.0,
))
_, condensate, m = mvr(feed)
print("compressor work (kW):", round(float(m["W_compressor"]) / 1e3, 1))
print("steam saved (kg/s):", round(float(m["steam_saved"]), 2))

What was and was not verified#

  • Verified when the model was written: mass and energy closure; agreement with an independent NumPy/SciPy re-implementation of the same equations (own steam properties, fsolve); MultiEffectEvaporator(n_effects=1) equals Evaporator; steam economy rises with N and stays near 0.8-1.0 N for a water-like, preheated feed; design-then-rate round trips; check_grads (order 1, reverse) of steam rate and area with respect to U, feed concentration and steam pressure, single and multiple effect.

  • Steam properties: Psat/Tsat are the IAPWS-IF97 equations; h_f and h_fg are fits to eight steam-table points recalled from a standard table (not regenerated from IAPWS), within about 0.1 % over 20-200 C.

  • Published examples (issue #421, measured when the model was written): Geankoplis 4e Ex. 8.4-1 (single effect, steam 4108 kg/h, area 149.3 m^2, within 0.5 %), Ex. 8.4-3 (50 % NaOH: steam 3255 kg/h, economy 0.836, area 49.2 m^2, with the book’s chart enthalpies passed as enthalpy_fn), Ex. 8.5-1 (triple effect, forward feed, equal areas: steam 8936 kg/h and economy 2.03 within 1 %, liquid flows within 2 %), McCabe-Smith-Harriott 7e Ex. 16.1 (steam 15 340 lb/h, area 930 ft^2 within 3 %), and the h_f/h_fg fits against Geankoplis Appendix A.2-9 within 0.1 %.

  • NOT verified: the NaOH table away from 30 and 50 wt %, the NaCl table, and any NaOH enthalpy-concentration data (difflow has none built in; pass the chart through enthalpy_fn). Nothing here models fouling, hydraulics, entrainment, or crystallization.

For an economics pass use heat_exchanger_cost(area) and steam_cost_from_duty; the optimal-number-of-effects study is in examples/47_evaporators.ipynb.


Liquid-Liquid Extraction#

LLEEquilibrium#

Location: difflow/units/lle.py

Class: LLEEquilibrium

Description: The two-phase equilibrium model every extraction unit takes as a parameter: which species transfer, which carry each phase, and how the distribution coefficients are computed.

Process Role#

LLEEquilibrium is a model object, not a unit operation: it has no inlets and no outlets, and it never appears in a flowsheet on its own. It is the equilibrium field of CascadeParams and DifferentialContactorParams, and what it decides is the part of an extraction calculation that is thermodynamics rather than cascade bookkeeping:

  • which species are solutes (they partition between the phases) and which are the two carriers (they do not, so their flows set the phase ratio at every stage);

  • where the distribution coefficients come from — either tabulated \(K\) values, optionally with a van’t Hoff temperature dependence, or an activity-coefficient model (NRTL or UNIQUAC) from which \(K_i = \gamma_i^{aq} / \gamma_i^{org}\) follows.

Because it is a model object, it cannot be constructed from data alone: solutes, aqueous_carrier and organic_carrier have no defaults, and the catalog reports them as unmet requirements until the code context binds them.

Parameters#

@dataclass
class LLEEquilibrium:
    solutes: list[str]                  # Species that transfer between phases
    aqueous_carrier: str                # Species that stays aqueous
    organic_carrier: str                # Species that stays organic
    K_coeffs: DistributionCoeffs = None # For activity_model='K'
    nrtl_params: NRTLParams = None      # For activity_model='NRTL'
    uniquac_params: UNIQUACParams = None# For activity_model='UNIQUAC'
    activity_model: str = 'K'           # 'K' | 'NRTL' | 'UNIQUAC'
    mutual_solubility: dict = None      # Optional carrier cross-solubility

DistributionCoeffs holds species, K0 at Tref, and optionally the heats of extraction dH that make \(K\) temperature-dependent:

\[K_i(T) = K_{0,i} \exp\left[-\frac{\Delta H_i}{R} \left(\frac{1}{T} - \frac{1}{T_{ref}}\right)\right]\]

Governing Equations#

Distribution coefficient (the sign convention: \(K > 1\) favours the organic/extract phase):

\[K_i = \frac{y_i^{org}}{x_i^{aq}}\]

Component balance across a contact:

\[F z_i = E\, y_i^{org} + R\, x_i^{aq}\]

Isoactivity, when an activity model is used instead of tabulated \(K\):

\[\gamma_i^{aq} x_i^{aq} = \gamma_i^{org} y_i^{org} \qquad \Rightarrow \qquad K_i = \frac{\gamma_i^{aq}}{\gamma_i^{org}}\]

Example Usage#

from difflow.units.lle import LLEEquilibrium, DistributionCoeffs

equilibrium = LLEEquilibrium(
    solutes=["acetic_acid"],
    aqueous_carrier="water",
    organic_carrier="butanol",
    K_coeffs=DistributionCoeffs(species=("acetic_acid",), K0=(2.5,)),
)

# The one thing it computes, at a temperature:
equilibrium.get_distribution_coefficients({}, {}, 298.15)
# {'acetic_acid': 2.5}

With activity_model='NRTL' the two composition arguments are used (the coefficients depend on them); with 'K' they are ignored, which is why they can be empty above.


MultistageCascade#

Location: difflow/units/lle.py

Class: MultistageCascade

Description: Multistage extraction cascade for liquid-liquid separation: counter-current (Kremser), crossflow, or co-current.

Process Role#

LLE is used for:

  • Separation of heat-sensitive compounds

  • Aromatics extraction (BTX)

  • Pharmaceutical purification

  • Metal extraction (hydrometallurgy)

Parameters#

@dataclass
class CascadeParams:
    n_stages: int | float        # Equilibrium stages (continuous, for optimization)
    equilibrium: LLEEquilibrium  # The equilibrium model (see above)
    flow_config: str = 'counter_current'  # or 'crossflow', 'co_current'
    stage_efficiency: float | None = None # Murphree (raffinate phase); None: ideal (co-current: 0.8)
    model: str = 'immiscible'             # or 'rigorous' (partially miscible, see below)
    profile_stages: int | None = None     # profile rows to tabulate when n_stages is traced (jit)

stage_efficiency is a Murphree raffinate-phase efficiency, \(X_{n-1} - X_n = \eta (X_{n-1} - X_n^*)\). Counter-current applies it through the Lewis overall efficiency \(E_o = \ln(1 + \eta(1/E - 1))/\ln(1/E)\), so \(N\) real stages give the raffinate of \(E_o N\) ideal ones (exact for straight equilibrium and operating lines); crossflow applies it stage by stage, \((X_N - X^*)/(X_0 - X^*) = \left[(E + \eta N - \eta E)/(E + \eta N)\right]^N\). The extract profile y then comes from the stage balances (it is no longer \(K x\)). None keeps ideal stages (and the legacy 0.8 for co-current); the rigorous model solves ideal stages only. Under jax.jit over n_stages the profile length cannot follow a traced stage count: set profile_stages to tabulate that many rows (rows past n_stages are NaN).

n_stages is deliberately continuous: the Kremser solution below is smooth in it, so stage count is an ordinary design variable that jax.grad can differentiate rather than an integer to enumerate.

Governing Equations#

Distribution Coefficient:

\[K_i = \frac{C_{i,extract}}{C_{i,raffinate}} = \frac{y_i}{x_i}\]

Material Balance (stage j):

\[R_{j-1} x_{i,j-1} + E_{j+1} y_{i,j+1} = R_j x_{i,j} + E_j y_{i,j}\]

Equilibrium:

\[y_{i,j} = K_i(T) x_{i,j}\]

Kremser Equation (immiscible carriers, constant \(K\)). The immiscible model works on the solute-free (ratio) basis, \(X\) = solute per aqueous carrier and \(Y\) = solute per organic carrier, with \(K = Y/X\) and the extraction factor \(E = K S/F\) from the carrier flows \(S\) and \(F\). With solute \(Y_{in}\) in the entering solvent, \(X^* = Y_{in}/K\) and

\[\frac{X_N - X^*}{X_0 - X^*} = \frac{E - 1}{E^{N+1} - 1}\]

(Wankat 4e Ch. 13; Geankoplis 5e Sec. 12.7). The extract carries everything that entered and did not leave in the raffinate, so solute in the solvent is conserved. The stage profile info["profiles"]["x"] is \(X_n = X^* + (X_0 - X^*)\,(E^{N+1-n} - 1)/(E^{N+1} - 1)\), \(n = 0..N\) (stage 1 is the feed end), and ["y"] is \(Y_n = K X_n\), with entry 0 holding the solvent inlet \(Y_{in}\).

Crossflow (flow_config="crossflow"): the raffinate passes through \(N\) stages, each fed \(S/N\) of fresh solvent, and the extracts are combined:

\[\frac{X_N - X^*}{X_0 - X^*} = \left(1 + \frac{E}{N}\right)^{-N},\]

smooth in \(N\). It always leaves more solute than counter-current flow with the same total solvent and stages, and less than a single stage.

A distribution coefficient tabulated on a concentration basis, \(K_c\) (mol/L over mol/L), goes with volumetric flows: \(E = K_c V_{org}/V_{aq}\). For a dilute solute the ratio-basis \(K\) is \(K_c\,\rho_{aq}/\rho_{org}\) (molar densities of the two carriers).

Activity Coefficient Models#

NRTL:

\[\ln \gamma_i = \frac{\sum_j x_j \tau_{ji} G_{ji}}{\sum_k x_k G_{ki}} + \sum_j \frac{x_j G_{ij}}{\sum_k x_k G_{kj}} \left(\tau_{ij} - \frac{\sum_m x_m \tau_{mj} G_{mj}}{\sum_k x_k G_{kj}}\right)\]

Where:

  • \(G_{ij} = \exp(-\alpha_{ij} \tau_{ij})\)

  • \(\tau_{ij} = (g_{ij} - g_{jj})/RT\)

UNIQUAC:

\[\ln \gamma_i = \ln \gamma_i^C + \ln \gamma_i^R\]

Combinatorial and residual contributions based on molecular size and interaction parameters.

Example Usage#

from difflow import make_stream
from difflow.streams import get_flows
from difflow.units.lle import (
    LLEEquilibrium, DistributionCoeffs, MultistageCascade, CascadeParams,
)

equilibrium = LLEEquilibrium(
    solutes=["acetic_acid"],
    aqueous_carrier="water",
    organic_carrier="butanol",
    K_coeffs=DistributionCoeffs(species=("acetic_acid",), K0=(2.5,)),
)
cascade = MultistageCascade(CascadeParams(n_stages=5, equilibrium=equilibrium))

feed = make_stream({'water': 100.0, 'acetic_acid': 10.0, 'butanol': 0.0},
                   T=298.15, P=101325.0)
solvent = make_stream({'water': 0.0, 'acetic_acid': 0.0, 'butanol': 50.0},
                      T=298.15, P=101325.0)

raffinate, extract, info = cascade(feed, solvent)
recovery = 1.0 - get_flows(raffinate)['acetic_acid'] / 10.0
print(f"Recovery: {float(recovery):.2%}")        # Recovery: 91.12%
print(info['profiles'].keys())  # stage profiles: x, y, carrier_transfer, ...

Partially Miscible Systems (model="rigorous")#

The Kremser solution above assumes immiscible solvents and constant phase flows. For a type-I system such as acetic acid-water-MIBK the carriers dissolve in each other and the phase flows change along the cascade, so CascadeParams(n_stages=N, equilibrium=..., model="rigorous") solves \(N\) countercurrent ideal stages with the full two-liquid-phase equations,

\[R_{n-1} + E_{n+1} = R_n + E_n, \qquad \gamma_i^{R} x_i^{R} = \gamma_i^{E} x_i^{E},\]

for every species and stage, from the NRTL or UNIQUAC parameters of the LLEEquilibrium. The raffinate is the phase rich in aqueous_carrier, the extract the one rich in organic_carrier. It is a damped Newton solve of the \(2 N n\) stage flows with a fixed iteration count, seeded by a Kremser profile built from one flash of feed plus solvent; the gradient is the implicit-function derivative, so jax.grad with respect to the solvent rate, the feed or the NRTL parameters works. info carries converged, residual, two_phase and the stage flows (info["profiles"]["R"], ["E"]). mutual_solubility is superseded (giving it with model="rigorous" is an error), and the model needs an integer n_stages, flow_config="counter_current", an NRTL or UNIQUAC equilibrium, and a positive total flow for every species of the activity model. When too little solvent is used the stages mix into a single liquid, which a two-phase stage model cannot represent: info["converged"] is False then, and the flows should not be used.

The building block is ternary_lle_flash(lle_model, z, T): the isothermal two-liquid-phase flash of a feed z (any number of species; phase I is rich in pair[0]), returning x_I, x_II, beta, the phase amounts, K, two_phase and converged; a single-phase feed returns a masked result (both phases equal to the feed, two_phase=False) with finite gradients. It reproduces the tie lines of ternary_lle and is differentiable in z, T and the activity parameters.

import jax.numpy as jnp
from difflow import make_stream
from difflow.units.lle import (
    CascadeParams, LLEEquilibrium, MultistageCascade, NRTLParams, ternary_lle_flash,
)

# A SYNTHETIC type-I system (carrier_a and carrier_b partially miscible; "solute"
# prefers carrier_b). The NRTL parameters are illustrative, NOT fitted to any
# published data (in particular not to acetic acid-water-MIBK).
tau = jnp.array([[0.0, 2.5, 0.5], [2.5, 0.0, 0.3], [0.8, 0.6, 0.0]])
nrtl = NRTLParams(("carrier_a", "carrier_b", "solute"), tau,
                  jnp.zeros((3, 3)), 0.3 * (1.0 - jnp.eye(3)))
eq = LLEEquilibrium(solutes=["solute"], aqueous_carrier="carrier_a",
                    organic_carrier="carrier_b", nrtl_params=nrtl, activity_model="NRTL")

# One stage: the flash of an overall composition
split = ternary_lle_flash(nrtl, jnp.array([0.4, 0.5, 0.1]), 300.0)
print(bool(split["two_phase"]), f"{float(split['beta']):.3f}")     # True 0.575

# Three countercurrent stages
feed = make_stream({"carrier_a": 80.0, "carrier_b": 0.0, "solute": 20.0}, 300.0, 1e5)
solvent = make_stream({"carrier_a": 0.0, "carrier_b": 100.0, "solute": 0.0}, 300.0, 1e5)
cascade = MultistageCascade(CascadeParams(3, eq, model="rigorous"))
raffinate, extract, info = cascade(feed, solvent)
print(bool(info["converged"]))                                      # True
print(f"solute left in the raffinate: {float(raffinate['F_solute']):.3f}")  # 1.678
print(f"carrier_b lost to the raffinate: {float(raffinate['F_carrier_b']):.3f}")  # 3.069

With vanishing mutual solubility and a dilute solute the rigorous model reduces to the Kremser cascade (checked when the model was written; the discrepancy falls in proportion to the dilution). The ternary construction of the same stages, the Hunter-Nash stepping of the previous section, gives the same stage count (to better than 0.01 stage in that check). The system used is synthetic as well: no published acetic acid-water-MIBK data or NRTL set was available to check, so no agreement with Sherwood-Evans-Longcor or DECHEMA data is claimed, and the recalled parameter values are not used.

Utility Functions#

from difflow.units.lle import (
    DistributionCoeffs,
    get_K_values,
    nrtl_activity_coefficients,
    uniquac_activity_coefficients,
    separation_factor,
    minimum_solvent_ratio,
    stages_for_recovery
)

# Distribution coefficients at a temperature (tabulated K0 at Tref)
coeffs = DistributionCoeffs(species=("acetic_acid",), K0=(2.5,))
K = get_K_values(coeffs, 298.15)["acetic_acid"]

# Minimum solvent-to-feed ratio for a given recovery
S_min = minimum_solvent_ratio(K, recovery=0.95)

# Stages needed (Kremser) at an actual solvent-to-feed ratio above the minimum
N = stages_for_recovery(K, 1.5 * S_min, recovery=0.95)

DifferentialContactor#

Location: difflow/units/lle.py

Class: DifferentialContactor

Description: Continuous differential contact extraction column (spray, packed, or rotating disc).

Governing Equations#

Two models, chosen by ContactorParams(mass_transfer_model=...):

  • "equilibrium": the column is length / HETP Kremser stages (any flow_config of MultistageCascade).

  • "rate_based": plug flow of both phases with an overall volumetric coefficient based on the raffinate phase. On the ratio basis of the cascade, with \(\alpha = K_L a\,c_{aq} A\) (Kla, aqueous_molar_density, area),

\[F_{aq}\frac{dX}{dz} = -\alpha\,(X - Y/K), \qquad F_{org}\frac{dY}{dz} = \mp\alpha\,(X - Y/K)\]

(upper sign counter-current, the organic entering at \(z = L\); lower sign co-current). With constant \(K\) the system is linear and is solved exactly with a matrix exponential. info reports the height and number of raffinate-phase overall transfer units,

\[H_{OR} = \frac{F_{aq}}{K_L a\,c_{aq} A}, \qquad N_{OR} = \frac{L}{H_{OR}},\]

and the counter-current result obeys the closed form (solute-free solvent)

\[N_{OR} = \frac{\ln\left[\frac{X_0}{X_L}\left(1 - \frac{1}{E}\right) + \frac{1}{E}\right]}{1 - 1/E}\]

(Treybal, Mass Transfer Operations 3e, Ch. 10), checked when the model was written. Profiles info["profiles"]["c_aq"], ["c_org"] are \(X(z)\) and \(Y(z)\).

from difflow import make_stream, DifferentialContactor, ContactorParams
from difflow.units.lle import LLEEquilibrium, DistributionCoeffs

eq = LLEEquilibrium(solutes=["A"], aqueous_carrier="water", organic_carrier="solvent",
                    K_coeffs=DistributionCoeffs(species=("A",), K0=(2.0,)))
feed = make_stream({"water": 100.0, "A": 1.0, "solvent": 0.0}, T=298.15, P=101325.0)
solvent = make_stream({"water": 0.0, "A": 0.0, "solvent": 100.0}, T=298.15, P=101325.0)
col = DifferentialContactor(ContactorParams(length=2.0, area=0.1, equilibrium=eq,
                                            mass_transfer_model="rate_based", Kla=3e-3))
raffinate, extract, info = col(feed, solvent)
print(f"N_OR = {float(info['NTU_OR']['A']):.3f}")        # N_OR = 0.333
print(f"A left = {float(raffinate['F_A']):.3f} mol/s")   # A left = 0.734 mol/s

Ternary Diagrams and the Lever Rule#

Location: difflow/phase_diagrams.py, difflow/visualization/phase_diagrams.py

Functions: mix, lever_rule, tie_line_through, difference_point, hunter_nash, plot_hunter_nash (library helpers, not palette units)

Description: The geometry of a type-I ternary LLE diagram: the mixing point, the lever rule, a tie line through a point, and the Hunter-Nash stage stepping for countercurrent extraction.

Process Role#

Reading a triangular diagram is mass balance in disguise. Mixing two streams puts the mixture on the line between them (mix); a feed that splits into two phases lies on the tie line between them, and the amount of each phase is proportional to the length of the opposite segment (lever_rule). The same bookkeeping gives the graphical solution of a countercurrent cascade (Hunter-Nash): with feed \(F\), solvent \(S\), mixing point \(M = F + S\) and the final raffinate \(R_N\), the first extract \(E_1\) is where the line \(R_N M\) meets the binodal, and the difference point

\[\Delta = F - E_1 = R_N - S\]

is the net flow through the cascade, so every operating line \(E_{n+1} - R_n = -\Delta\) passes through it. Stages alternate a tie line (\(E_n \to R_n\)) with an operating line (\(R_n \to E_{n+1}\) through \(\Delta\)).

mix and lever_rule are pure jnp and differentiable. tie_line_through, hunter_nash and difference_point take the tie lines tabulated by ternary_lle or packed from measured tie lines by tabulated_lle(tie_lines, binodal=None, plait=None, T=None), joined into conjugate curves by monotone cubic interpolation, so their accuracy is the interpolation’s: use enough tie lines (n_tie). The stepping is numeric (a variable number of stages, so not under jax.jit), but every root it finds is replayed in jnp through the implicit function theorem: jax.grad of hunter_nash(...)["n_stages"] with respect to S_over_F, F, S or x_RN, and of tie_line_through with respect to the point, are exact for the interpolated curves (the tie-line data are constants: not differentiated through the equilibrium model). tie_line_through(..., on_outside="flag") returns two_phase=False (NaN phase compositions) for a point outside the tabulated two-phase region instead of raising, for sweeps that cross the binodal. The difference point and the operating lines are built from the extensive balance (amounts times compositions), so they stay valid when \(\Delta\) is at infinity (parallel operating lines). Species 0 and 1 are the partially miscible pair and species 2 the solute, as in ternary_lle; carrier says which of 0 and 1 is the feed diluent (its phase is the raffinate). ternary_xy(..., coords="right") gives the right-triangle coordinates (solute on the vertical axis) for leaching diagrams. plot_hunter_nash(result) draws the construction with the binodal, tie lines, \(F\), \(S\), \(M\), \(E_1\), \(R_N\), \(\Delta\) and the numbered stages (line style and marker, not colour alone).

Example Usage#

import numpy as np
import jax.numpy as jnp
from difflow import ternary_lle, lever_rule, tie_line_through, hunter_nash
from difflow.phase_diagrams import mix
from difflow.units.lle import NRTLParams

# A SYNTHETIC type-I system (a, b partially miscible; s the solute); the NRTL
# parameters are illustrative, not fitted to any real mixture.
tau = jnp.array([[0.0, 2.5, 0.5], [2.5, 0.0, 0.3], [0.8, 0.6, 0.0]])
model = NRTLParams(("a", "b", "s"), tau, jnp.zeros((3, 3)), 0.3 * (1 - jnp.eye(3)))
data = ternary_lle(model, 300.0, n_tie=30)

F = np.array([0.8, 0.0, 0.2])          # feed: a with 20 % solute
S = np.array([0.0, 1.0, 0.0])          # pure solvent b
M = mix(jnp.stack([jnp.asarray(F), jnp.asarray(S)]), jnp.array([1.0, 1.0]))
print(np.round(np.asarray(M), 3))      # [0.4 0.5 0.1]

# The mixture splits along a tie line; the lever rule gives the phase amounts.
tie = tie_line_through(data, M)
fI, fII = lever_rule(M, tie["x_I"], tie["x_II"])
print(f"phase I {float(fI):.3f}, phase II {float(fII):.3f}")   # phase I 0.425, phase II 0.575

# Countercurrent cascade: 1 kg solvent per kg feed, raffinate down to 2 % solute.
r = hunter_nash(data, F, S, 0.02, S_over_F=1.0)
print(f"{r['n_stages']:.2f} stages, {r['n_ideal']} ideal stages")   # 3.18 stages, 4 ideal stages

# Differentiable in the solvent ratio: stages saved per unit of extra solvent.
import jax
dN = jax.grad(lambda sf: hunter_nash(data, F, S, 0.02, S_over_F=sf)["n_stages"])(1.0)
print(f"dN/d(S/F) = {float(dN):.2f}")

Limits#

No published Hunter-Nash worked example (Seader-Henley-Roper, Treybal, Geankoplis) is reproduced: their inputs and printed answers could not be retrieved, so no agreement with them is claimed. The construction is checked against the lever rule and component balances (exact), against the analytic Kremser stage count for an immiscible ternary (tie lines from edge to edge with \(Y = KX\)), for its own consistency and against the rigorous stage-by-stage solution of the same equilibrium model (same stage count to better than 0.01 stage), when the model was written. The stepping is limited to the range of the tabulated tie lines (it stops before the plait point) and reports feasible=False at a pinch (solvent at or below the minimum).


Leaching and Washing#

Leacher#

Location: difflow/units/leaching.py

Class: Leacher

Description: Countercurrent solid-liquid extraction (leaching) and washing with constant or tabulated underflow, as a cascade of ideal stages.

Process Role#

Sugar from beet cossettes, oil from seeds, metal from ore, and the washing of a precipitate or a thickener underflow are all the same cascade: the solids move one way and carry some solution with them (the underflow), the solvent moves the other way (the overflow), and in every ideal stage the solution held by the solids has the composition of the overflow that leaves it. The unit has two inlets, solids_feed and solvent, and two outlets, underflow and overflow. Flows are mass flows (kg/s): an inert solid has no molar mass, so the F_<species> entries of this unit’s streams are kg/s.

Three underflow laws are available:

  • "constant_solution": every stage’s underflow holds underflow kg of solution per kg of inert;

  • "constant_solvent": it holds underflow kg of solvent per kg of inert (the solution then grows as it gets richer; on a solute-per-solvent basis it is the same linear cascade);

  • "variable": a table of solution per kg inert against solute mass fraction (underflow_y, underflow_s), the oilseed case. tabulate_underflow samples a correlation into the tables.

n_stages is a real number for the two constant laws (the closed form is smooth in it, so the stage count is an ordinary design variable); the variable law is a stage-by-stage solve and needs an integer.

Parameters#

@dataclass
class LeacherParams:
    inert_species: str          # the solid carrier ("pulp", "meal")
    solute: str                 # what is leached out
    solvent: str                # the leaching or wash liquid
    n_stages: float = 3.0       # ideal stages (continuous for constant underflow)
    underflow_model: str = "constant_solution"  # | "constant_solvent" | "variable"
    underflow: float = 1.0      # kg solution (or solvent) per kg inert
    underflow_y: list = None    # variable underflow table: solute mass fraction
    underflow_s: list = None    # ... and kg solution per kg inert
    stage_efficiency: float = 1.0  # overall: ideal stages = E * n_stages
    recovery: float = None      # design target: fraction of feed solute to the overflow
    design: str = "rating"      # | "stages" (solve N) | "solvent" (scale the solvent)

Governing Equations#

With \(B\) kg/s of inert, \(L_n\) the solution in the underflow leaving stage \(n\), \(V_n\) the overflow leaving stage \(n\), \(y_n\) the solute mass fraction of both leaving stage \(n\), \(L_0\), \(m_0\) the solution and solute that come in with the solids, and \(V = V_{N+1}\) the fresh solvent, the balances across any cut are

\[V_{n+1} - L_n = V_1 - L_0, \qquad V_{n+1}\,y_{n+1} - L_n\,y_n = V_1\,y_1 - m_0 = D .\]

For a constant underflow (\(L_n = L = Bu\) for \(n \ge 1\)) the first stage is special: \(V_{2} = \dots = V_{N+1} = V\) but \(V_1 = V + L_0 - L\), which differs from \(V\) unless the feed solids already carry the underflow solution (\(L_0 = L\)). With \(r = L/V\),

\[y_{n+1} = r\,y_n + \frac{D}{V}, \qquad y_{N+1} = r^{N} y_1 + \frac{D}{V}\,\frac{r^{N}-1}{r-1}, \qquad y_1 = \frac{D + m_0}{V_1},\]

which fixes \(D\) in closed form. For pure washing (\(L_0 = L\), solute-free wash liquid) the fraction of the solute left in the underflow is

\[\frac{m_N}{m_0} = \frac{1-w}{1-w^{N+1}}, \qquad w = \frac{V}{L},\]

so the fraction washed out is the Kremser form. washing_recovery(w, N) and washing_stages(w, recovery) are these two (with the \(w \to 1\) limit \(N/(N+1)\)); for \(w<1\) the removal tends to \(w\), not to 1.

For the variable underflow the \(N\) balances \(V_{n+1}y_{n+1}-L_ny_n-D=0\) with \(L_n = B\,s(y_n)\) are nonlinear in \(y\); the unit solves them by Newton’s method with a fixed number of iterations (a fixed-size linear solve per step, seeded by the constant-underflow solution), so the result can be jitted and differentiated.

Design Modes#

recovery is the fraction of the feed solute that ends in the overflow, net of any solute that came in with the solvent. With design="stages" the stage count is solved for (closed form, real-valued, NaN with info["feasible"] == False when the recovery cannot be reached at that solvent rate); with design="solvent" the solvent stream is scaled (composition fixed) until the recovery is met at the given n_stages (bisection with implicit gradients). info reports the recovery, the washing efficiency, the per-stage compositions for an integer stage count, the solution-to-inert ratios, converged and feasible.

Example Usage#

from difflow import make_stream, Leacher, LeacherParams
from difflow import washing_recovery, washing_stages

# 100 kg/s of pulp carrying 200 kg/s of sugar solution (10 % sugar), washed
# with 150 kg/s of water; the underflow holds 2 kg solution per kg pulp.
feed = make_stream({"pulp": 100.0, "sugar": 20.0, "water": 180.0}, T=300.0, P=1e5)
water = make_stream({"water": 150.0}, T=300.0, P=1e5)

leacher = Leacher(LeacherParams("pulp", "sugar", "water", n_stages=3.0, underflow=2.0))
underflow, overflow, info = leacher(feed, water)
print(f"recovery {float(info['recovery']):.4f}")          # recovery 0.6343
print(f"liquor {float(info['y_overflow']):.4f} mass fraction")  # liquor 0.0846 mass fraction

# This is a pure wash (the feed holds as much solution as the underflow does),
# so the closed form of the washing helpers must agree.
w = 150.0 / 200.0
assert abs(float(washing_recovery(w, 3.0)) - float(info["recovery"])) < 1e-12

# Design: stages for 95 % recovery with 240 kg/s of water (w = 1.2) ...
big = make_stream({"water": 240.0}, T=300.0, P=1e5)
design = Leacher(LeacherParams("pulp", "sugar", "water", underflow=2.0,
                               recovery=0.95, design="stages"))
_, _, info = design(feed, big)
print(f"stages {float(info['n_stages']):.3f}")            # stages 7.827
print(f"check  {float(washing_stages(1.2, 0.95)):.3f}")   # check  7.827

# ... or the water rate that reaches 95 % in 3 stages
sized = Leacher(LeacherParams("pulp", "sugar", "water", n_stages=3.0, underflow=2.0,
                              recovery=0.95, design="solvent"))
_, _, info = sized(feed, water)
print(f"water {float(info['solvent_rate']):.1f} kg/s")    # water 452.9 kg/s

# Variable underflow (a synthetic, oilseed-like table): 3 stages
var = Leacher(LeacherParams("pulp", "sugar", "water", n_stages=3,
                            underflow_model="variable",
                            underflow_y=[0.0, 0.1, 0.2, 0.4],
                            underflow_s=[1.5, 1.8, 2.2, 3.0]))
_, _, info = var(feed, water)
print(f"recovery {float(info['recovery']):.4f}")          # recovery 0.7688

Gradients of the recovery with respect to the solvent rate, the stage count and the underflow ratio are ordinary jax.grad calls, so the solvent-to-solids ratio and the stage count that minimise a solvent-recovery cost can be found by gradient descent (worked in examples/54_leaching_ternary.ipynb, together with the ternary helpers and the rigorous cascade).

Limits#

Ideal stages: the solute is fully dissolved and in equilibrium with the solution (no mass-transfer rate limit and no adsorption on the solids; diffusion-controlled leaching kinetics is out of scope). One solute, one solvent and one inert (other species in the streams are ignored), a common inert flow in every stage, isothermal. stage_efficiency is an overall efficiency: the number of ideal stages is \(E \cdot n_{stages}\). The unit is checked against an independent dense-linear-system assembly of the stage balances, the closed washing form and a SciPy solve of the variable-underflow balances (when the model was written); no published textbook example is reproduced (the McCabe-Smith-Harriott and Geankoplis worked examples named in the issue could not be retrieved, so no agreement with them is claimed), and the variable-underflow table above is synthetic.


Pressure-Change & EOS-Consistent Units#

These units close energy balances on the cubic-EOS enthalpy and entropy (ideal-gas Cp + Peng-Robinson/SRK departures) rather than on ideal-K or constant-Cp models, so they are correct for real gases and near-cryogenic / gas-processing service (expander plants, NGL recovery, refrigeration). Each takes a CubicThermo built from an IdealThermo (for the ideal-gas Cp) and a PengRobinson/SRK EOS.

Outlet temperature solves. Every outlet temperature comes from a (P, H) or (P, S) flash, also public as flash_PH_eos(thermo, flows, P, H, T_bounds) and flash_PS_eos(thermo, flows, P, S, T_bounds). The flash bisects on \(T\) inside T_bounds (default 100–1000 K) with the two-phase, stability-tested EOS enthalpy or entropy, so it cannot wander outside the bracket. For a pure component, \(H(T)\) and \(S(T)\) jump by the latent heat at \(T_\mathrm{sat}\). When the target falls in that jump the outlet is at \(T_\mathrm{sat}\) and the vapour fraction comes from the lever rule. Gradients are attached by the implicit function theorem. In a single phase the balance residual supplies them; inside a pure-component dome the saturation condition does, so \(dT/dP\) is the Clausius-Clapeyron slope. Every outlet temperature, duty, shaft work and vapour fraction is differentiable with respect to feed conditions, discharge pressures and efficiencies.

The flash returns T, vapor_fraction, x, y, H, S, residual, bracketed and converged. The units copy vapor_fraction, x, y, residual and converged into their info. A target outside T_bounds returns converged=False and raises EOSUnitConvergenceWarning (when the values are concrete), instead of a silently wrong temperature. The outlet stream holds only flows, T and P, so the vapour fraction of a two-phase outlet is read from info["vapor_fraction"]; send the stream through a PHFlash with the same thermo to split it.

from difflow import (
    IdealThermo, CubicThermo, PengRobinson,
    Turboexpander, TurboexpanderParams,
    Compressor, CompressorParams,
    JTValve, JTValveParams,
    ComponentSeparator, ComponentSeparatorParams,
)
from difflow.database import get_critical_props, get_species_data
from difflow.streams import make_stream

names = ["nitrogen", "methane", "ethane", "propane", "n_butane"]
ideal = IdealThermo({c: get_species_data(c) for c in names})
eos = PengRobinson({c: get_critical_props(c) for c in names})
thermo = CubicThermo(ideal, eos)

feed = make_stream({"nitrogen": 0.5, "methane": 86.0, "ethane": 7.0,
                    "propane": 3.0, "n_butane": 1.0}, T=305.0, P=60e5)

Turboexpander#

Adiabatic expansion to P_out with an isentropic efficiency. The reversible outlet is found by matching entropy, then the efficiency is applied to the enthalpy drop:

\[S(T_\text{isen}, P_\text{out}) = S(T_\text{in}, P_\text{in}), \qquad H_\text{out} = H_\text{in} + \eta\,(H_\text{isen} - H_\text{in})\]

The extracted shaft work is \(W = H_\text{in} - H_\text{out} > 0\). Both enthalpy and entropy are two-phase aware, so an expander whose outlet partly condenses (common in cryogenic service) is handled correctly. For example, methane expanded from 300 K and 100 bar to 10 bar has \(T_\text{isen}=152.1\) K, a few kelvin above its dew point.

exp = Turboexpander(TurboexpanderParams(P_out=20e5, eta_isentropic=0.80), thermo)
outlet, info = exp(feed)
# info: W, T_isen, T_out, H_in, H_out, vapor_fraction, vapor_fraction_isen,
#       x, y, residual, residual_isen, converged

EOSCompressor#

Exported as Compressor; registered in the catalog as EOSCompressor, because Compressor is taken by the gas plugin’s fixed-ratio compressor station (Gas networks) and the two are different models of different things.

Adiabatic compression to P_out with an isentropic efficiency. Same entropy match, but the efficiency inflates the enthalpy rise (an inefficient machine needs more work than the reversible one):

\[H_\text{out} = H_\text{in} + \frac{H_\text{isen} - H_\text{in}}{\eta}\]

The required shaft work is \(W = H_\text{out} - H_\text{in} > 0\).

comp = Compressor(CompressorParams(P_out=90e5, eta_isentropic=0.75), thermo)
outlet, info = comp(feed)

MultistageCompressor#

An intercooled train of EOSCompressor stages. Each stage is the same isentropic-efficiency compression on the two-phase EOS enthalpy and entropy. After every stage but the last, an intercooler returns the gas to T_intercool and loses intercooler_dP (aftercool=True cools the last stage too). By default the stage pressures follow difflow.compressible.stage_pressures: every stage has the same ratio, and a slightly higher ratio makes up the intercooler losses. Without losses that is the classical minimum-work split. discharge_pressures=(...) sets them instead. The total work is differentiable in those pressures, so the real-gas optimum can be found by gradient descent from the equal split. An intercooler outlet with intercooler_vapor_fraction < 1 condenses liquid. That liquid is not knocked out, and a concrete run warns about it.

\[P_{d,k}=r\,P_{s,k},\qquad P_{s,k+1}=P_{d,k}-\Delta P_{ic},\qquad P_{d,N}=P_\text{out}\]
from difflow import MultistageCompressor, MultistageCompressorParams
train = MultistageCompressor(MultistageCompressorParams(
    P_out=90e5, n_stages=3, eta_isentropic=0.78,
    T_intercool=313.15, intercooler_dP=0.3e5), thermo)
outlet, info = train(feed)
# info: W, Q, stage_W, stage_T_out, suction_pressures, discharge_pressures,
#       pressure_ratio, intercooler_Q, intercooler_vapor_fraction, converged

The carbon-capture plugin’s CompressionTrain is a separate, CO2-specific model. It is not built on the EOS units, and it switches to a dense-phase pump above the critical point.

JTValve (Joule-Thomson valve)#

Adiabatic, isenthalpic pressure letdown. Holds the two-phase EOS enthalpy constant across the pressure drop and solves for the outlet temperature, \(H(T_\text{out}, P_\text{out}) = H(T_\text{in}, P_\text{in})\). On a real gas this produces the Joule-Thomson temperature change that an ideal-gas or ideal-K valve misses. Because no work is extracted, the same pressure drop cools less than a turboexpander. A letdown into the two-phase region lands on the bubble/dew curve (at \(T_\mathrm{sat}\) for a pure component), with the liquid fraction in info["vapor_fraction"].

valve = JTValve(JTValveParams(P_out=20e5), thermo)
outlet, info = valve(feed)   # info: T_out, H, vapor_fraction, x, y, residual, converged

ComponentSeparator#

A black-box separator surrogate: each component is split to the product stream by a fixed recovery, the complement going to the residue. Both products inherit the inlet T and P, and the reported duty Q is the enthalpy imbalance needed to hold both at the inlet temperature. Useful as a column stand-in when only the recovery specification is known.

rec = {"propane": 0.95, "n_butane": 0.99}   # heavies to product
sep = ComponentSeparator(
    ComponentSeparatorParams(recovery_to_product=rec, default_recovery=0.0),
    thermo,
)
residue, product, info = sep(feed)   # info: {"Q", "H_in", "H_out"}

Liquid Pipe Flow#

Incompressible (liquid) flow in pipes: Darcy-Weisbach friction with a Colebrook/Churchill friction factor, fittings, elevation head and differential-pressure flow meters. The functions live in difflow.fluids (JAX, SI, differentiable) and the unit operation is Pipe. Compressible gas flow (nozzles, isothermal and adiabatic lines) is in difflow.compressible, and gas networks are in difflow_gas. Liquid networks are solved by PipeNetwork, power-law and Bingham fluids have their own friction factors, and a rotameter model sits with the flow meters. Two-phase flow and transients are out of scope.

Pipe#

A run of pipe with fittings and an elevation change. The outlet pressure is

\[ P_\mathrm{out} = P_\mathrm{in} - f\frac{L}{D}\frac{\rho v^2}{2} - \sum_j n_j K_j \frac{\rho v^2}{2} - \rho g\,\Delta z , \qquad v = \frac{4Q}{\pi D^2},\quad Q = \frac{\dot m}{\rho} \]

Temperature and molar flows pass through unchanged.

Parameter

Unit

Meaning

L

m

straight pipe length

D

m

inside diameter

rho

kg/m³

liquid density: a number, a callable of the inlet stream, or None (default: difflow.liquid_properties at the inlet T and composition)

mu

Pa s

liquid viscosity: a number, a callable of the inlet stream, or None (default, as for rho)

roughness

m

absolute roughness, default 4.6e-5 (commercial steel)

dz

m

elevation gain, positive uphill

fittings

–

{name: count} using the K table below, plus an optional "K" entry for a numeric extra K (a strainer, a meter); or a total K

flow_area

m²

flow cross-section for a non-circular duct; then D is the hydraulic diameter 4A/P. Default None: πD²/4

P_vap

Pa

vapour pressure for the cavitation check. Default None: Raoult’s law over the inlet species with data (IAPWS for water, the database Antoine otherwise)

profile

m

optional elevation profile [(distance, elevation), ...] from inlet to outlet; the minimum pressure along the line is checked (default None: outlet only)

friction_method

–

"colebrook" (default), "churchill", "haaland", "swamee_jain"

MW

g/mol

molar mass (number or {species: MW}) to turn molar into mass flow; omit if a thermo is passed

Liquid properties. rho and mu may be numbers, callables of the stream, or None (the default). With None the pipe reads difflow.liquid_properties at the inlet temperature and composition, which needs a stream and a database species with liquid data (hydraulics_at_flow without an inlet then raises ValueError). Pass numbers to reproduce a textbook calculation that uses tabulated properties.

import jax
from difflow import Pipe, PipeParams, make_stream

# 3 L/s of water at 20 C through 100 m of 2-in Sch 40 commercial steel
water = make_stream({"W": 0.003 * 998.2 / 0.018015}, T=293.15, P=3e5)

def dP(D):
    pipe = Pipe(PipeParams(L=100.0, D=D, rho=998.2, mu=1.002e-3, MW=18.015,
                           fittings={"elbow_90_standard": 4, "gate_valve": 1}))
    outlet, info = pipe(water)
    return info["dP"]

outlet, info = Pipe(PipeParams(L=100.0, D=0.0525, rho=998.2, mu=1.002e-3,
                               MW=18.015))(water)
info["v"], info["Re"], info["f"]      # 1.386 m/s, 7.25e4, 0.02254
info["dP_friction"]                    # 41.1 kPa
jax.grad(dP)(0.0525)                   # d(dP)/dD, Pa/m: economic diameter optimization

info carries Q, v, Re, f, K_total, dP_friction, dP_minor, dP_static, dP (the sum), head_loss (friction plus minor loss, m of fluid), P_vap (NaN if unknown) and P_out_below_vapour.

Cavitation check. The pipe is an incompressible model, so an uphill run can give an outlet pressure below the vapour pressure, or below zero, without complaint from the algebra. info["P_out_below_vapour"] flags it (P_out < P_vap, or P_out < 0 when no vapour pressure is known), and for concrete values the pipe raises PipeCavitationWarning. Under jit/grad only the flag is set; the warning never fires on a tracer. Water at 20 °C pumped 40 m uphill through 100 m of 2-in pipe at 3 L/s from 4 bar gives P_out = -33 kPa against P_vap = 2339 Pa: flagged. Without a profile only the outlet is checked, and a line that climbs over a high point and comes back down can fall below the vapour pressure at the crest with a healthy outlet pressure. Give the route as profile=[(0, 0), (100, 32), (200, 0)] (distance from the inlet, elevation; m) and the pipe evaluates \(P(s_i)=P_\mathrm{in}-\Delta P_m-\Delta P_f\,\frac{s_i-s_0}{s_N-s_0}-\rho g(z_i-z_0)\) at every point (friction spread evenly, all fitting losses put at the inlet, which is conservative). info then also has P_profile, P_min, s_P_min, z_P_min, P_min_below_vapour, cavitation_margin (\(P_\min-P_\mathrm{vap}\), Pa) and cavitation_margin_head (the same in m of liquid, an NPSH-type margin), and PipeCavitationWarning names the location. The elevation gain is \(z_N - z_0\) (leave dz at 0, or give the same value); Pipe.elevation_gain() returns it and Pipe.pressure_profile(inlet) returns the arrays. With 3 L/s of water from 3 bar over a 32 m crest halfway along 200 m of 2-in Sch 40 pipe the outlet is at 2.18 bar, but the crest computes to -0.54 bar (absolute): flagged, where the outlet check alone would pass.

Flow from the available head. Pipe.flow_at_head(H, inlet=None, Q0=None) returns the volumetric flow at which friction plus minor losses equal H metres of fluid, and Pipe.flow_at_dp(dP, inlet=None, Q0=None) the flow for a pressure drop dP (the static head ρgΔz is subtracted first; the flow is 0 if dP cannot lift the column). Both are a Newton solve in ln Q (optimistix), started from the fully rough estimate, so the gradients come from the implicit function theorem:

pipe = Pipe(PipeParams(L=120.0, D=0.0525, rho=998.96, mu=1.0214e-3,
                       fittings={"entrance_sharp": 1, "elbow_90_standard": 4,
                                 "gate_valve": 1, "exit": 1}))
pipe.flow_at_head(15.0)     # 5.113e-3 m^3/s
# jax.grad over roughness: dQ/d(eps) = -8.78 (m^3/s) per m

Non-circular ducts. Give D as the hydraulic diameter and flow_area as the true cross-section; the velocity is Q/A and Re, f and the friction loss use D_h. A 3-in Sch 40 by 1.5-in (48.26 mm OD) annulus carrying 4 L/s over 24 m: v = 1.36 m/s, ΔP = 19.6 kPa. For laminar flow the circular-tube friction factor on D_h is only approximate (64/Re becomes about 96/Re for a narrow annulus). Pipe.eo_residuals gives n_species + 2 rows (flows, temperature and the pressure balance) so the unit works in the EO solver. The example notebook examples/42_liquid_pipe_flow.ipynb regenerates the Moody chart and finds the economic pipe diameter with jax.grad.

Friction Factor Methods#

difflow.fluids.friction_factor(Re, rel_roughness, method) returns the Darcy factor (Fanning = Darcy/4).

Method

Form

Notes

"colebrook"

implicit Colebrook-White, \(1/\sqrt f=-2\log_{10}\!\left(\frac{\varepsilon/D}{3.7}+\frac{2.51}{\mathrm{Re}\sqrt f}\right)\)

Newton iteration (8 steps) from the Haaland estimate; gradients by the implicit function theorem; blended with 64/Re

"churchill"

Churchill (1977), one explicit expression

valid laminar, transition and turbulent; no blend

"haaland"

\(1/\sqrt f=-1.8\log_{10}\!\left[(\varepsilon/D/3.7)^{1.11}+6.9/\mathrm{Re}\right]\)

explicit; blended with 64/Re

"swamee_jain"

\(f=0.25/\log_{10}^2\!\left[\varepsilon/D/3.7+5.74/\mathrm{Re}^{0.9}\right]\)

explicit; blended with 64/Re

Laminar-turbulent blend. For the Colebrook, Haaland and Swamee-Jain methods \(f=(1-w)\,64/\mathrm{Re}+w f_\mathrm{turb}\), with \(w\) a C² smootherstep of \(\ln\mathrm{Re}\) between Re = 2100 and 4000 (a sigmoid in log Re with compact support: exactly 0 below 2100, exactly 1 above 4000). Below 2100 the result is exactly 64/Re. No Python if is involved, so jit, vmap and grad all work, and the gradients are finite across the transition (checked at Re = 1e3, 2300, 4000 and 1e5 against check_grads when the model was written). In 2100 < Re < 4000 real flow is intermittent and no correlation is accurate: the blend is a smooth interpolation, not a transition model. One consequence: across the blend ΔP is not monotonic in viscosity. A 5 km, 6-in Sch 40 crude line at 12 L/s (ρ = 870 kg/m³) drops 217 kPa at 50 cP (Re 1726, laminar) but 234 kPa at 25 cP (Re 3450, inside the blend), because the turbulent branch is switching on. A real line in that range may be laminar or turbulent; size it for both.

What was verified when the model was written, and how:

  • Colebrook agrees with an independent bracketing solution of the same equation (scipy brentq) to 1e-10 over Re 4e3-1e8, ε/D 0-0.05.

  • Its limits are Prandtl’s smooth-pipe law and the von Kármán fully-rough law.

  • Moody-chart spot values (smooth: 0.0309, 0.0180, 0.0116 at Re = 1e4, 1e5, 1e6; fully rough: 0.0716, 0.0380, 0.0196 at ε/D = 0.05, 0.01, 0.001) are matched to 1 % (chart reading precision).

  • Colebrook vs Churchill: within 2.2 % for Re ≥ 1e4 and 1.5 % for Re ≥ 1e5 over ε/D 0-0.05; the worst case is 3.1 % at Re = 4000, ε/D = 0.01. Haaland is within 1.4 % and Swamee-Jain within 3.1 % of Colebrook. The 1-2 % agreement holds in the fully turbulent range, not right at the onset of turbulence.

Also in the module: reynolds_number, hydraulic_diameter (4A/P), darcy_pressure_drop, minor_loss, fully_rough_friction_factor and the L/D helper equivalent_length(K, D, f) = K D / f.

Non-Newtonian Friction Factors#

All of these return the Darcy factor, like friction_factor, so darcy_pressure_drop(f, L, D, rho, v) applies unchanged; the source correlations are written for the Fanning factor \(f_F = f/4\) and are quoted that way below. Smooth pipes only.

Power-law fluid (\(\tau = K\dot\gamma^{\,n}\)). metzner_reed_reynolds(rho, v, D, K, n) is \(\mathrm{Re}_{MR}=\rho v^{2-n}D^n/\left[K\,8^{n-1}\left(\frac{3n+1}{4n}\right)^n\right]\) (Metzner and Reed, 1955), which makes the laminar factor exactly \(f_F = 16/\mathrm{Re}_{MR}\) for every \(n\). power_law_friction_factor(Re_MR, n) blends that with the turbulent Dodge-Metzner (1959) equation \(1/\sqrt{f_F} = (4/n^{0.75})\log_{10}\!\left(\mathrm{Re}_{MR}\,f_F^{\,1-n/2}\right) - 0.4/n^{1.2}\), solved by Newton iteration with implicit-function gradients, using the same 2100-4000 smootherstep as the Newtonian methods. The blend window is a convention: the true critical \(\mathrm{Re}_{MR}\) rises somewhat as \(n\) falls, and that is not modelled. Dodge and Metzner’s data covered \(0.36\le n\le 1\) and \(\mathrm{Re}_{MR}\) up to about \(10^5\). power_law_pressure_drop(Q, L, D, rho, K, n) is the Darcy-Weisbach drop; in laminar flow it is the Rabinowitsch-Mooney result \(\Delta P = \frac{4KL}{D}\left(\frac{8v}{D}\right)^n\left(\frac{3n+1}{4n}\right)^n\) exactly.

Bingham plastic (\(\tau = \tau_y + \mu_p\dot\gamma\)). hedstrom_number(rho, tau_y, D, mu_p) is \(\mathrm{He}=\rho\tau_y D^2/\mu_p^2\) and the Reynolds number is \(\mathrm{Re}_B=\rho v D/\mu_p\). bingham_friction_factor(Re_B, He, method="darby1992"):

Regime

Fanning form

Source

laminar (method="laminar")

\(f_L=\frac{16}{\mathrm{Re}}\left[1+\frac{\mathrm{He}}{6\,\mathrm{Re}}-\frac{\mathrm{He}^4}{3 f_L^3\mathrm{Re}^7}\right]\)

Buckingham-Reiner, solved by Newton with implicit gradients

turbulent

\(f_T = 10^{a}\,\mathrm{Re}^{-0.193}\), \(a=-1.47\left[1+0.146\,e^{-2.9\times10^{-5}\mathrm{He}}\right]\)

Darby, Mun and Boger (1992)

all ("darby1992", default)

\(f=\left(f_L^{m}+f_T^{m}\right)^{1/m}\), \(m = 1.7+40000/\mathrm{Re}\)

Darby, Mun and Boger (1992), evaluated in log space

The original Darby and Melson (1981) fit had \(-1.378\) in place of \(-1.47\). The 1992 constants were checked against secondary reprints, not the original article. The turbulent fit is for slurries and pastes and does not reduce to the Newtonian curve at He = 0 (it is about half of it at Re = 1e5); only the laminar branch has an exact Newtonian limit. bingham_pressure_drop(Q, L, D, rho, tau_y, mu_p) is the Darcy-Weisbach drop.

What was checked when the model was written: \(n=1\) and \(\tau_y=0\) give exactly 64/Re in laminar flow; Dodge-Metzner at \(n=1\) (Prandtl’s smooth-pipe law) is within 1 % of smooth Colebrook for Re 1e4-1e7; the laminar Bingham pressure drop reproduces the Buckingham flow equation \(Q=\frac{\pi D^4\Delta P}{128\mu_p L}\left[1-\frac43\xi+\frac{\xi^4}{3}\right]\), \(\xi=\tau_y/\tau_w\); the implicit solutions match an independent bracketing root; gradients pass check_grads.

Standard Pipe Sizes (NPS and Schedule)#

difflow.fluids.NPS_SCHEDULES holds ASME B36.10M (welded and seamless wrought steel pipe): the outside diameter and the wall thickness by schedule, in inches. B36.19M stainless “S” schedules are not included.

Function

Returns

pipe_outside_diameter(nps)

OD, m

pipe_wall_thickness(nps, schedule="40")

wall, m

pipe_inside_diameter(nps, schedule="40") (alias pipe_id)

ID = OD − 2t, m

standard_sizes(schedule="40")

sorted list of (NPS, ID_m)

pipe_weight_per_length(nps, schedule="40")

plain-end steel mass, kg/m (B36.10M: \(0.0246615\,(OD-t)\,t\), mm)

snap_to_standard_size(D, schedule="40", direction="up")

(NPS, ID_m): the next standard size up (or "down", "nearest")

nps may be a number or a string (2, 1.5, "1-1/2", '2"'); the schedule may be 40, "40", "Sch 40" or "std". A size/schedule pair that B36.10M does not list (22-in Sch 40) raises KeyError. Check values: 1, 1.5, 2, 3, 4 and 6 in Sch 40 have IDs 26.64, 40.89, 52.50, 77.93, 102.26 and 154.05 mm. The snap is a discrete choice and is not differentiable: optimize a continuous D with jax.grad, then snap. Sources: ASME B36.10M as tabulated in Wikipedia’s “Nominal Pipe Size” article, cross-checked against Engineering ToolBox and projectmaterials.com.

Fitting Loss Coefficients#

difflow.fluids.FITTINGS maps a name to a loss coefficient. Entries of kind LD are Crane’s equivalent-length form, \(K=(L/D)\,f_T\), where \(f_T\) is the fully rough friction factor of the connected pipe (fully_rough_friction_factor(ε/D)), so \(K\) scales with pipe size and stays differentiable in \(D\). Entries of kind K are constants. For a 2-in Sch 40 steel pipe the von Kármán formula gives \(f_T=0.0190\), the value Crane tabulates for 2-in pipe.

Name

Kind

Value

Source

elbow_90_standard

LD

30

Crane TP-410: 90° standard elbow, K = 30 f_T

elbow_90_long_radius

LD

14

Crane TP-410: 90° bend r/d = 1.5, K = 14 f_T

elbow_45_standard

LD

16

Crane TP-410: 45° standard elbow, K = 16 f_T

return_bend_180

LD

50

Crane TP-410: 180° close return bend, K = 50 f_T

tee_through

LD

20

Crane TP-410: tee, flow through run, K = 20 f_T

tee_branch

LD

60

Crane TP-410: tee, flow through branch, K = 60 f_T

gate_valve

LD

8

Crane TP-410: gate valve, fully open, K = 8 f_T

globe_valve

LD

340

Crane TP-410: globe valve, fully open, K = 340 f_T

angle_valve

LD

150

Crane TP-410: angle valve, fully open, K = 150 f_T

ball_valve

LD

3

Crane TP-410: ball valve, fully open, K = 3 f_T

check_valve_swing

LD

50

Crane TP-410: swing check valve, fully open

entrance_sharp

K

0.5

Crane TP-410: sharp-edged flush entrance (also Perry’s 9e Sec. 6)

entrance_rounded

K

0.04

Crane TP-410: well-rounded entrance, r/d ≥ 0.15

exit

K

1.0

Crane TP-410: exit into a large volume

butterfly_valve

LD

45

Crane TP-410: butterfly valve 2-8 in, K = 45 f_T (secondary reprint)

butterfly_valve_10_14

LD

35

Crane TP-410: butterfly valve 10-14 in

butterfly_valve_16_24

LD

25

Crane TP-410: butterfly valve 16-24 in

plug_valve

LD

18

Crane TP-410: plug valve, straightway, K = 18 f_T

foot_valve

LD

420

Crane TP-410: foot valve with strainer, poppet disc

foot_valve_hinged

LD

75

Crane TP-410: foot valve with strainer, hinged disc

strainer_y

K

2.0

NOT Crane: typical manufacturer value, clean Y-strainer

strainer_basket

K

1.5

NOT Crane: typical manufacturer value, clean basket strainer

fittings_K(fittings, rel_roughness=None, f_T=None) sums a {name: count} dict; the keys "K", "K_extra" or "other" add a numeric K directly, so a strainer quoted as K = 2 sits alongside named fittings: {"entrance_sharp": 1, "elbow_90_standard": 4, "K": 2.0}. The butterfly, plug and foot-valve rows come from secondary reprints of the Crane table (SimuPipe and fluidnetworkstudio.com). The strainer values are manufacturer typicals for a clean element; a fouled strainer can be several times higher.

Source: Crane Co., Flow of Fluids Through Valves, Fittings, and Pipe, Technical Paper 410; the same tables are reprinted in Perry’s Chemical Engineers’ Handbook (Sec. 6). Provenance caveat: these numbers were transcribed from the widely reprinted tables and were not machine-checked against the TP-410 scan; confirm against your edition for tight designs. Published K-values for fittings are good to about ±25 % at best (the swing check in particular is quoted differently in different editions).

Flow Meters#

orifice_flow(dP, D_pipe, d_orifice, rho, Cd=0.61) and venturi_flow(dP, D_pipe, d_throat, rho, Cd=0.98) return the volumetric flow \(Q=C_d\frac{\pi}{4}d^2\sqrt{\frac{2\,\Delta P}{\rho\,(1-\beta^4)}}\), \(\beta=d/D\). The inverse forms orifice_dp(Q, ...) and venturi_dp(Q, ...) give the differential pressure for a known flow. The discharge coefficients are the usual textbook constants (0.61 sharp-edged orifice at high Re; about 0.98 for a Venturi). Incompressible only, no expansibility factor.

ISO 5167 orifice coefficient. orifice_cd(beta, Re_D, D, taps="flange") is the Reader-Harris/Gallagher equation of ISO 5167-2:2003 (Eq. 4), with tap geometry "corner", "flange" or "D-D/2" and the small-pipe term 0.011(0.75 − β)(2.8 − D/25.4 mm) for D < 71.12 mm. Validity is that of the standard: 0.1 ≤ β ≤ 0.75, D 50-1000 mm, Re_D above about 5000. C depends on Re and so on the flow, so orifice_flow(..., Cd="iso5167", mu=..., taps=...) solves C = C_RHG(Re(Q(C))) with a Newton root find (implicit-function gradients); orifice_dp(Q, ..., Cd="iso5167", mu=...) is the explicit inverse. The implementation reproduces the fluids library’s example value 0.59903 (C. Bell, fluids, MIT licence).

Permanent (unrecovered) pressure loss. The meter differential is not the loss: downstream the jet re-expands, and most of a Venturi’s differential is recovered.

  • orifice_permanent_loss(dP, beta, Cd=0.61) is ISO 5167-2 (5.4.1): \(\Delta\varpi=\frac{\sqrt{1-\beta^4(1-C^2)}-C\beta^2}{\sqrt{1-\beta^4(1-C^2)}+C\beta^2}\,\Delta P\). At β = 0.5 and C = 0.61 that is 0.731 ΔP (20 kPa differential, 14.6 kPa lost).

  • venturi_permanent_loss(dP, beta, D_pipe) interpolates the ISO 5167-4 loss ratio curves (D = 65 mm and 500 mm, β 0.30-0.75; VENTURI_LOSS_TABLE), linear in D between the two curves and clamped outside them. A 0.5-β Venturi loses 0.14 ΔP in a 65 mm line and 0.075 ΔP in a 500 mm line. The table is resampled from the fluids library’s digitization of the standard’s figure and reproduces its dP_venturi_tube example (1788.6 Pa) to 0.5 %.

Rotameter. A float in a vertical tapered tube rises until the pressure difference across it carries its weight less its buoyancy, \(\Delta P\,A_f = V_f(\rho_f-\rho)g\) (rotameter_float_dp), so the differential is fixed and the flow area changes. With the tube diameter \(D_t = D_{t,0} + \text{taper}\cdot h\) (D_tube_0 defaults to the float diameter, the float seated at \(h=0\)), the annulus \(A_a = \frac{\pi}{4}(D_t^2-D_f^2)\) passes

\[ Q = C_R\,\frac{A_a}{\sqrt{1-(A_a/A_t)^2}}\,\sqrt{\frac{2 g V_f(\rho_f-\rho)}{\rho A_f}} \]

(McCabe, Smith and Harriott, Unit Operations, 7e, Ch. 8; Perry’s Sec. 10). rotameter_flow(h, D_float, V_float, rho_float, rho, C_R, taper, D_tube_0=None) returns \(Q\) and rotameter_height(Q, ...) inverts it (Newton in \(\ln A_a\), one implicit-function step for the derivatives). \(C_R\) depends on the float shape and Reynolds number and has no default: take it from the calibration. The mass flow at a given reading scales with \(\sqrt{\rho(\rho_f-\rho)}\), the usual density correction. When the model was written, the force-balance identity, the inverse, and the inverse-function derivative \(\frac{dh}{dQ}\frac{dQ}{dh}=1\) were checked.

Pipe Networks#

PipeNetwork solves looped and branched liquid networks built from Pipe units (or ResistanceBranch(r, n), \(\Delta P = rQ|Q|^{n-1}\), for a textbook \(kQ^2\) or Hazen-Williams \(n=1.852\) element). A node has either a fixed pressure (reservoir, header, discharge) or an external demand (m³/s leaving the network; negative for an injection), and at least one node must be fixed. Flows are positive from a branch’s from node to its to node; a Pipe’s dz (or profile) is the rise along that direction, so its static head is part of the branch \(\Delta P\).

from difflow import Pipe, PipeParams, PipeNetwork

w = dict(rho=998.2, mu=1.002e-3)
net = PipeNetwork()
net.add_node("S", pressure=4e5)                      # supply header, Pa
for n, d in {"A": 0.004, "B": 0.006, "C": 0.012, "D": 0.003}.items():
    net.add_node(n, demand=d)                        # m^3/s drawn off
net.add_branch("SA", "S", "A", Pipe(PipeParams(L=300.0, D=0.1541, **w)))
net.add_branch("AB", "A", "B", Pipe(PipeParams(L=400.0, D=0.1023, **w)))
net.add_branch("AC", "A", "C", Pipe(PipeParams(L=500.0, D=0.0779, **w)))
net.add_branch("BC", "B", "C", Pipe(PipeParams(L=250.0, D=0.0779, dz=5.0, **w)))
net.add_branch("BD", "B", "D", Pipe(PipeParams(L=350.0, D=0.0525, **w)))
net.add_branch("CD", "C", "D", Pipe(PipeParams(L=300.0, D=0.0525, dz=-5.0, **w)))

res = net.solve()                       # method="newton" (default) or "hardy_cross"
res.branch_flows, res.node_pressures    # dicts, m^3/s and Pa
res.supply                              # {"S": 0.025}: flow delivered by each fixed node
res.converged, res.mass_imbalance, res.energy_residual, res.iterations
net.loops()                             # cycle basis: [{branch: +/-1, ...}, ...]

method="newton" solves the branch momentum equations \(\Delta P_k(Q_k) = P_\mathrm{from}-P_\mathrm{to}\) and the free-node continuity equations together (the global-gradient formulation of Todini and Pilati, 1988, used by EPANET), started from the network linearized at 1 m/s, with optimistix.root_find: derivatives of any output with respect to demands, fixed pressures or pipe parameters are implicit-function-theorem derivatives. method="hardy_cross" is the classical Hardy Cross (1936) loop correction, \(\Delta Q = -\sum_k o_k\Delta P_k/\sum_k|\partial\Delta P_k/\partial Q_k|\), swept over a cycle basis for a fixed iterations count (default 200; unrolled for differentiation); fixed-pressure nodes are joined through a virtual datum node, which supplies the pseudo-loops between reservoirs. It converges linearly and is offered for teaching and cross-checks. \(|Q|\) is smoothed at zero flow, so a branch can reverse and stay differentiable.

When the model was written, a laminar parallel header was checked against Hagen-Poiseuille (\(Q_k \propto D_k^4/L_k\)), turbulent parallel pipes between two headers against Pipe.flow_at_dp, a two-loop network and a three-reservoir junction whose answers are known by construction (resistances set from chosen flows and pressures, including a reversed branch), mass balance at every node, zero pressure loss around every loop, agreement of the two methods, jit, and gradients (check_grads, and Hardy Cross vs Newton).

For a single parallel pair the same answer is also a one-unknown root find on the split fraction \(x\), \(\Delta P_A(xQ)=\Delta P_B((1-x)Q)\).

Compressible Gas Flow#

difflow.compressible covers ideal-gas flow where the density changes along the path: nozzles and relief devices, isothermal gas lines and adiabatic (Fanno) ducts. It assumes constant \(\gamma=C_p/C_v\) and molar mass MW (kg/mol); friction factors are Darcy factors, as in difflow.fluids. For an isothermal line \(\mathrm{Re}=GD/\mu\) is constant along the pipe, so \(f\) is one call to fluids.friction_factor. Functions that use \(RT/M\) take an optional compressibility factor Z (default 1), which replaces it with \(ZRT/M\) for a real gas at a representative state. JAX, SI and differentiable throughout.

Function

Relation

speed_of_sound(T, MW, gamma, Z), mach_number(v, T, MW, gamma, Z)

\(c=\sqrt{\gamma ZRT/M}\), \(\mathrm{Ma}=v/c\)

stagnation_ratios(M, gamma)

isentropic \(T_0/T\), \(P_0/P\), \(\rho_0/\rho\)

critical_pressure_ratio(gamma)

\(P^*/P_0=(2/(\gamma+1))^{\gamma/(\gamma-1)}\) (0.528 for air)

choked_mass_flux(P0, T0, MW, gamma)

\(G^*=P_0\sqrt{\gamma M/(RT_0)}\,(2/(\gamma+1))^{(\gamma+1)/(2(\gamma-1))}\)

nozzle_mass_flow(P0, T0, P_back, area, MW, gamma, Cd)

isentropic converging nozzle, subsonic or choked

nozzle_flow(P0, T0, P_back, area, MW, gamma, Cd)

the same flow plus choked, P_throat, mach_throat

isothermal_pipe_mass_flux(P1, P2, T, L, D, f, MW, Z)

\(P_1^2-P_2^2=G^2\frac{ZRT}{M}\left[\frac{fL}{D}+2\ln\frac{P_1}{P_2}\right]\)

isothermal_pipe_flow(P1, P2, T, L, D, roughness, mu, MW, Z, method)

the same equation with \(f(\mathrm{Re})\) solved simultaneously: G, m_dot, f, Re

isothermal_outlet_pressure(G, P1, T, L, D, f, MW, gamma, Z)

the same equation solved for \(P_2\), with the choking check

isothermal_distance(P, G, P1, T, D, f, MW, Z)

closed-form distance \(x(P)\) at which the pressure has fallen to \(P\)

isothermal_pressure_profile(x, G, P1, T, D, f, MW, gamma, Z)

\(P(x)\) at an array of positions (choking flagged)

isothermal_limit_mach(gamma)

isothermal choking Mach number \(1/\sqrt\gamma\) (0.845 for air)

fanno_parameter(M, gamma), fanno_ratios(M, gamma)

\(fL^*/D\) and \(P/P^*\), \(T/T^*\), \(P_0/P_0^*\)

adiabatic_outlet_mach(M1, fL_D, gamma)

outlet Mach, \(P_2/P_1\), \(T_2/T_1\) of a subsonic adiabatic duct

polytropic_exponent(eta_p, gamma, compression)

\(n\) from \(\frac{n-1}{n}=\frac{\gamma-1}{\gamma\,\eta_p}\) (compression) or \(\frac{(\gamma-1)\eta_p}{\gamma}\) (expansion)

isentropic_efficiency_from_polytropic(r, eta_p, gamma, compression)

\(\eta_s=\dfrac{r^{(\gamma-1)/\gamma}-1}{r^{(\gamma-1)/(\gamma\eta_p)}-1}\) for compression

polytropic_efficiency_from_isentropic(r, eta_s, gamma, compression)

the inverse

optimal_stage_ratios(P_in, P_out, n_stages)

equal per-stage ratios \((P_\mathrm{out}/P_\mathrm{in})^{1/N}\) (minimum work with intercooling to the inlet temperature)

stage_pressures(P_in, P_out, n_stages, intercooler_dP=0)

stage suction and discharge pressures, equal ratios with a pressure loss in each intercooler (used by MultistageCompressor)

Choking. nozzle_flow reports choked (true when \(P_\mathrm{back}/P_0\) is at or below the critical ratio). nozzle_mass_flow holds \(P_\mathrm{back}/P_0\) at no less than the critical ratio. The isentropic flux has its maximum there, so the clamp is continuous with a continuous first derivative, and jax.grad with respect to the back pressure is exactly 0 in choked flow. An isothermal line chokes when its exit velocity reaches \(\sqrt{RT/M}\) (Mach \(1/\sqrt\gamma\), not 1). Past that, isothermal_outlet_pressure returns choked=True and \(P_2\) at the choke pressure; adiabatic_outlet_mach returns choked=True and \(M_2=1\) when \(fL/D\) exceeds \(fL^*/D\) at the inlet Mach number. In both cases the real flow would be lower than the one requested.

Solvers and gradients. The two outlet solvers bracket the subsonic root (between the choke point and the inlet) and bisect in log space. A final Newton step on the stopped root attaches implicit-function-theorem gradients with respect to every input. isothermal_pipe_flow iterates \(f\) and \(G\) to a fixed point (Colebrook, or method="swamee_jain") and attaches the same kind of implicit gradient.

Polytropic efficiency and staging. The polytropic efficiency of a stage is the small-stage efficiency, so it does not depend on the pressure ratio. The isentropic efficiency of a compressor with fixed \(\eta_p\) falls as the ratio rises (for air, with \(\eta_p=0.80\): 0.780 at \(r=2\), 0.759 at \(r=4\), 0.729 at \(r=10\)). For an expander it rises. polytropic_exponent returns \(n\) itself, so the exponent in \(T_2/T_1=r^{(n-1)/n}\) is 1 - 1/n. optimal_stage_ratios splits a compression into \(N\) equal ratios, which minimises total work for an ideal gas intercooled to the inlet temperature with equal stage efficiencies. These are ideal-gas relations; the EOS Compressor/Turboexpander units in difflow.units.eos_units integrate the real-gas entropy instead.

What was verified when the model was written:

  • The isothermal equation and the Fanno relations agree with an independent solve_ivp integration of the differential momentum balance, to 1e-6 or better. This includes a short, high-velocity line where the acceleration term matters.

  • \(dP_2/dD\) agrees with finite differences of that integration.

  • nozzle_mass_flow is continuous at the critical ratio, equals \(\rho^*c^*A\) when choked, and tends to the Bernoulli flow \(A\sqrt{2\rho\Delta P}\) at small pressure drop.

  • isothermal_distance agrees with isothermal_outlet_pressure at every point, isothermal_pipe_flow satisfies both the pressure equation and Colebrook at its answer, and Z enters exactly as a scaling of \(RT/M\).

  • The polytropic relations invert one another, reduce to \(\eta_s=\eta_p\) as \(r\to1\), and match the closed form above. Gradients of all the new functions pass check_grads.

from difflow import fluids
from difflow.compressible import (
    isothermal_pipe_flow, isothermal_pipe_mass_flux, nozzle_flow,
    isentropic_efficiency_from_polytropic, optimal_stage_ratios,
)

# 50 km natural-gas line, 0.5 m ID, 60 -> 40 bar at 288 K
D, mu, MW = 0.5, 1.1e-5, 0.0175
G = isothermal_pipe_mass_flux(60e5, 40e5, 288.0, 5e4, D, 0.012, MW)
f = fluids.friction_factor(G * D / mu, 4.6e-5 / D)   # check the assumed f
print(float(G) * 3.1416 * D**2 / 4, "kg/s;  f =", float(f))

# ... or solve G and f(Re) together
res = isothermal_pipe_flow(60e5, 40e5, 288.0, 5e4, D, 4.6e-5, mu, MW)
print(float(res.m_dot), "kg/s;  f =", float(res.f))

# air from a 10 bar vessel through a 1 cm^2 nozzle: choked
noz = nozzle_flow(10e5, 300.0, 1e5, 1e-4, 0.02897, 1.4, Cd=0.97)
print(float(noz.m_dot), "kg/s; choked:", bool(noz.choked))

# 1 -> 30 bar in three intercooled stages at eta_p = 0.80
r = optimal_stage_ratios(1e5, 30e5, 3)
print(r, isentropic_efficiency_from_polytropic(r[0], 0.80, 1.4))

Centrifugal Pumps#

A centrifugal pump model with performance curves: the pump raises the pressure of a liquid by \(\rho g H(Q)\), the curves scale with speed and impeller diameter by the affinity laws, NPSH available is checked against NPSH required, and operating_point answers “what flow will this pump deliver into this system?” The system curve is built from Pipe runs, so friction and fittings come from difflow.fluids. Positive-displacement pumps, pump transients and multiphase pumps are out of scope.

CentrifugalPump#

\[ H(Q) = h_0 + h_1 Q + h_2 Q^2, \qquad \eta(Q) = \eta_{\max}\Bigl(1-\bigl(\tfrac{Q-Q_\mathrm{bep}}{Q_\mathrm{bep}}\bigr)^2\Bigr), \qquad P_\mathrm{shaft} = \frac{\rho g Q H}{\eta} \]
\[ Q \propto N D, \quad H \propto N^2 D^2, \quad P \propto N^3 D^3, \qquad \mathrm{NPSH}_a = \frac{P_{s,\mathrm{total}} - P_\mathrm{sat}}{\rho g} = \frac{P_{s,\mathrm{static}} - P_\mathrm{sat}}{\rho g} + \frac{v_s^2}{2g} \]

In the default (flow-given) mode the inlet stream fixes \(Q=\dot m/\rho\) and the outlet is \(P_\mathrm{in}+\rho g H(Q)\); temperature and molar flows pass through (or, with cp, the liquid warms by the dissipated shaft work).

Parameter

Unit

Meaning

head_coeffs

m, m/(m³/s), …

ascending polynomial coefficients of \(H(Q)\) at the reference speed and diameter (see fit_pump_curve)

rho

kg/m³

liquid density: a number or a callable of the inlet stream

eta_coeffs or eta_max + Q_bep

–, m³/s

efficiency polynomial, or the BEP-centred parabola

npshr_coeffs

m, …

polynomial of required NPSH (optional)

N_ref, D_ref

rpm, m

speed and impeller diameter the curves belong to

N, D

rpm, m

operating speed and diameter (default: the references)

motor_efficiency

–

electric_power = shaft_power / motor_efficiency

MW

g/mol

molar mass (number or {species: MW}); omit if a thermo is passed

Psat

Pa

vapour pressure for NPSHa (number or callable); otherwise thermo.Psat(species, T)

D_suction

m

suction nozzle diameter: reports suction_velocity and suction_velocity_head

suction_pressure

–

"total" (default) or "static": what the inlet P means (below)

cp

J/kg/K

liquid heat capacity; enables the temperature rise

info holds Q, head, efficiency, hydraulic_power, shaft_power, electric_power (W) and, when the curves and a vapour pressure are given, NPSHr, NPSHa and npsh_margin = NPSHa - NPSHr (negative: cavitation risk).

Static or total suction pressure. A difflow stream’s P is the total pressure: static plus the velocity head \(\rho v^2/2\). A stream from a tank is at rest, so the two are equal there. Pipe subtracts only friction, fittings and elevation, so its outlet P is the static flange pressure plus \(\rho v^2/2\). Bernoulli from the tank surface to the flange gives \(P_\mathrm{static}/\rho g + v^2/2g = P_\mathrm{tank}/\rho g + z_s - h_{f,s}\), and so \(\mathrm{NPSH}_a = (P - P_\mathrm{sat})/\rho g\) with no separate velocity-head term. That is the default, suction_pressure="total". Feed a Pipe outlet straight in, and D_suction only reports the nozzle velocity. Set suction_pressure="static" only when P is a measured wall-tap pressure at the flange. NPSHa then adds \(v_s^2/2g\), which needs D_suction. Older versions always added \(v_s^2/2g\) when D_suction was set. That counted the velocity head twice for a Pipe outlet: 0.28 m too much NPSHa for 40 m³/h in a 3-in line, enough to hide cavitation. Like Pipe, liquid density is an explicit input; see the design note under Pipe.

from difflow import (CentrifugalPump, CentrifugalPumpParams, Pipe, PipeParams,
                     fit_pump_curve, operating_point, system_curve)

Q_data = [0.0, 0.01, 0.02, 0.03, 0.04]                # vendor points, m^3/s
H_data = [38.0, 36.5, 32.0, 24.5, 14.0]               # head, m (illustrative)
h = fit_pump_curve(Q_data, H_data, order=2)
pump = CentrifugalPump(CentrifugalPumpParams(
    head_coeffs=h, rho=998.2, eta_max=0.72, Q_bep=0.020,
    npshr_coeffs=[1.5, 0.0, 2.5e3], N_ref=1750.0, D_ref=0.25, MW=18.015))

line = Pipe(PipeParams(L=400.0, D=0.1023, rho=998.2, mu=1.002e-3, MW=18.015, fittings=5.3))
sys_ = system_curve(12.0, line)                       # 12 m static lift + friction
Q_star = operating_point(pump, sys_)                  # m^3/s; differentiable

Helpers.

  • system_curve(static_head, *pipes, delta_P=None, rho=None) gives \(H_\mathrm{sys}(Q)=\Delta z+\Delta P/\rho g+\sum_i (h_{f,i}(Q)+\mathrm{dz}_i)\). Pipe dz is included, so put elevation either in the pipes or in static_head.

  • operating_point(pump, system) solves \(H_\mathrm{pump}(Q)=H_\mathrm{sys}(Q)\) by Newton started from the pump’s zero-head flow. Gradients with respect to pump and system parameters (D, N, pipe diameter, …) use the implicit function theorem, not the iterations. It has no meaningful answer when the shutoff head is below the static head. A concrete negative root (that case, or a check_valves=False parallel pair whose weak pump would run backwards) or an unconverged solve raises OperatingPointWarning.

  • pumps_in_series(*pumps) adds heads at equal flow. pumps_in_parallel(*pumps, check_valves=True) adds flows at equal head. Both return curves usable by operating_point.

  • Check valves in parallel. A pump whose shutoff head \(H_i(0)\) is below the common head is held at zero flow by its check valve. It does not run backwards. The branch flows and the head solve \(\sum q_i = Q\) together with the complementarity \(q_i \ge 0\), \(H - H_i(q_i) \ge 0\), \(q_i\,(H-H_i(q_i)) = 0\). That is a smoothed Fischer-Burmeister system, solved by Newton with implicit gradients. The smoothing (smoothing=1e-6, in scaled units) rounds the switch corner, so the combined curve stays differentiable. Its slope still changes sharply where a pump cuts in, which is a real kink in the physics. split(Q, return_info=True) returns the branch flows plus head, running (False for a branch whose valve is shut), shutoff_head and converged. A stopped pump adds no shaft power and no NPSHr. Here is the case that used to be wrong: pumps with shutoff heads of 40 m and 25 m (\(-3000\,Q^2\) each) at \(Q = 0.03\) m³/s. Before, the weak pump got \(-0.068\) m³/s and the head was 11 m. Now that pump is shut and the strong pump carries all 0.03 m³/s at 37.3 m. check_valves=False keeps the valveless (reverse-flow) solution.

  • npsh_available(P_suction, Psat, rho, v, z_suction, losses).

  • pump.curve is a difflow.units.pump.PumpCurve (head, efficiency, npshr, shaft_power at the operating N and D); build one directly from coefficients to study a pump without a stream.

  • Flow meters (orifice_flow and its inverse orifice_dp) are in difflow.fluids.

Affinity laws and static head. Doubling \(N\) doubles \(Q^*\) only when the system curve is pure friction (\(H_\mathrm{sys}\propto Q^2\), no static head), because then the whole intersection scales. With a static lift the system curve does not scale, so \(Q^*\) does not follow \(N\).

Verification, honestly stated. The transfer-pump test (12 m lift, 400 m of 4-in Sch 40 steel, fittings \(K=5.3\), water at 20 °C) uses an illustrative vendor curve, not a published datasheet or textbook figure; textbook pump examples (McCabe & Smith, Geankoplis, Crane) are read off a printed curve and are not reproduced from memory. The operating point is checked instead against an independent calculation (numpy.polyfit for the curve and scipy.optimize.brentq for both the Colebrook-White friction factor and the pump/system intersection, no difflow code), agreeing to \(10^{-6}\) relative, well inside the 2 % the issue asked for. That validates the implementation, not the vendor data. The NPSH test uses 95 °C water with steam-table properties (P_sat = 84.55 kPa, ρ = 961.9 kg/m³; NPSHa = 1.78 m against an NPSHr of 2.5 m at 20 L/s).

Relation to the carbon-capture Pump. difflow_cc.Pump is a different model: dense-phase CO₂ pumped to a fixed outlet pressure with a constant efficiency, density from the CO₂ EOS (\(W=\dot V\,\Delta P/\eta\)). It has no curve, speed or NPSH and cannot say what flow results. It was deliberately not rewritten to delegate to CentrifugalPump: its density comes from an EOS rather than an input, and its specification (outlet pressure) is the inverse of the curve-driven one. Use Pump for CO₂ compression-train duty and CentrifugalPump for liquid transfer and for sizing against a system.

The example notebook examples/43_centrifugal_pump.ipynb plots the pump and system curves with the operating point, speed and trim changes, series/parallel operation and NPSH margin, and picks the pipe diameter with a VFD-speed constraint by gradient-based optimisation of pump_cost + piping + pump_electricity_cost.


Combustion & Gas-Turbine Units#

These units model a Brayton cycle working fluid — air and combustion gas at high temperature and moderate pressure, where the cubic-EOS departure is negligible. They therefore use ideal-gas properties with temperature-dependent Cp (difflow.combustion.IdealGasThermo), the standard model for gas-turbine cycle analysis, and are named distinctly from the real-gas Compressor / Turboexpander above (a different thermodynamic model for a different service). All internal temperature and air/fuel-ratio solves are optimistix root finds, so shaft work, firing temperature, air/fuel ratio and efficiency are differentiable with respect to feed conditions, pressures, efficiencies and fuel composition.

The fuel-hydrocarbon (and N₂/CO₂) ideal-gas Cp come from the database (the same cubics used by the NGL work); the module adds O₂, H₂O-vapor and Ar Cp, air composition, and per-fuel lower heating values and combustion stoichiometry.

from difflow import (
    Combustor, CombustorParams,
    GasCompressor, GasCompressorParams,
    GasTurbine, GasTurbineParams,
    brayton_cycle, BraytonCycleParams, make_cycle_thermo,
)
from difflow.combustion import AIR_COMPOSITION
from difflow.streams import make_stream

thermo = make_cycle_thermo()          # ideal-gas thermo over cycle + fuel species

GasCompressor#

Adiabatic ideal-gas compression to pressure_ratio × P_in with an isentropic efficiency (the efficiency inflates the enthalpy rise). Work consumed is \(W = H_\text{out} - H_\text{in} > 0\).

air = make_stream(dict(AIR_COMPOSITION), T=288.15, P=101325.0)
comp = GasCompressor(GasCompressorParams(pressure_ratio=18.0, eta_isentropic=0.89), thermo)
compressed, info = comp(air)          # info: {"W", "T_isen", "T_out", ...}

Combustor#

Complete-combustion reactor, \(C_xH_y + (x + y/4)\,O_2 \to x\,CO_2 + (y/2)\,H_2O\), with two modes:

  • "adiabatic" — both feeds fixed; solves the adiabatic flame temperature.

  • "fixed_T" — scales the air stream to the air/fuel ratio that hits a target firing temperature T_out (closed-form, affine in the air amount).

fuel = make_stream({"methane": 1.0}, T=298.15, P=18 * 101325.0)
comb = Combustor(CombustorParams(mode="fixed_T", T_out=1673.15, dp_frac=0.04), thermo)
products, info = comb(fuel, compressed)   # info: {"T_out", "air_scale", "o2_demand", "Q"}

GasTurbine#

Adiabatic ideal-gas expansion to a back-pressure P_out with an isentropic efficiency. Work extracted is \(W = H_\text{in} - H_\text{out} > 0\).

turb = GasTurbine(GasTurbineParams(P_out=101325.0, eta_isentropic=0.90), thermo)
exhaust, info = turb(products)

brayton_cycle#

Assembles compressor → combustor → turbine into an intensive (per mole of fuel) simple- or combined-cycle solve. Defaults are a modern F-class machine at ISO conditions and reproduce published performance: simple-cycle η ≈ 0.40 (8470 Btu/kWh), combined-cycle η ≈ 0.568 (6009 Btu/kWh).

fuel_comp = {"methane": 0.95, "ethane": 0.03, "propane": 0.01,
             "nitrogen": 0.005, "carbon_dioxide": 0.005}
result = brayton_cycle(fuel_comp, BraytonCycleParams(combined_cycle=True))
# result: {"eta_thermal", "eta_gt_only", "work_net", "air_fuel_molar", ...}

Particles and Beds#

Location: difflow/particles.py (pure JAX functions in SI units; not unit operations, so they have no palette entry). They are the building blocks for adsorbers, catalytic beds, settlers, cyclones and filters. The units that apply them to settling, filtration and gas cleaning are in Mechanical Separations (Settler, CakeFilter, Cyclone).

Drag and terminal velocity#

drag_coefficient(Re_p, sphericity, method) is smooth across the Stokes, intermediate and Newton regimes and tends to \(24/Re_p\) as \(Re_p \to 0\):

method

Correlation

Range

"haider_levenspiel" (default)

Haider & Levenspiel (1989), sphericity-dependent

\(Re < 2.6\times10^5\), \(\phi\) 0.5-1

"turton_levenspiel"

Turton & Levenspiel (1986), spheres

\(Re < 2.6\times10^5\)

"schiller_naumann"

Schiller & Naumann (1933), floored at 0.44

\(Re < 800\) then Newton plateau

terminal_velocity(d_p, rho_p, rho_f, mu, sphericity) solves the force balance \(C_D(Re)\,Re^2 = \tfrac43 Ar\) by a Newton root find in \(\ln Re\), started from the explicit Haider-Levenspiel dimensionless velocity, and differentiates it by implicit differentiation. hindered_settling_velocity(v_t, voidage, n, Re_t) is Richardson & Zaki (1954), \(u = v_t\varepsilon^n\) with \(n\) from \(Re_t\) when not given.

from difflow.particles import terminal_velocity, drag_coefficient
import jax

d = 1e-3                                           # 1 mm sand grain in water
v = terminal_velocity(d, 2650.0, 998.0, 1.0e-3)
dv_dd = jax.grad(terminal_velocity)(d, 2650.0, 998.0, 1.0e-3)
print(f"v_t = {float(v):.3f} m/s, dv/dd = {float(dv_dd):.1f} 1/s")
print(f"C_D(Re=1e-4)*Re/24 = {float(drag_coefficient(1e-4))*1e-4/24:.4f}")

Packed-bed pressure drop (Ergun)#

ergun_pressure_gradient(u_s, d_p, voidage, rho, mu, sphericity) returns \((\Delta P/L,\ \text{info})\) with the full Ergun (1952) equation

\[\frac{\Delta P}{L} = 150\frac{(1-\varepsilon)^2}{\varepsilon^3}\frac{\mu u_s}{(\phi d_p)^2} + 1.75\frac{1-\varepsilon}{\varepsilon^3}\frac{\rho u_s^2}{\phi d_p}\]

and info['viscous'] (Blake-Kozeny), info['inertial'] (Burke-Plummer), info['Re_bed']. D_bed= applies the Mehta & Hawley (1969) wall correction for narrow beds (\(D/d_p \lesssim 10\)): \(M = 1 + 2\phi d_p/[3D(1-\varepsilon)]\) multiplies the viscous term by \(M^2\) and the inertial term by \(M\) (info['wall_factor']). kozeny_carman(...) is the laminar limit with the Kozeny constant \(k_0\) (prefactor \(36k_0 = 180\)), the piece to reuse for filter-cake resistance.

from difflow.particles import ergun_pressure_gradient

# 3 mm spheres, eps = 0.4, air, u = 1 m/s: 1687.5 + 6562.5 Pa/m by hand
dpdl, info = ergun_pressure_gradient(1.0, 3e-3, 0.4, 1.2, 1.8e-5)
print(float(dpdl), float(info["viscous"]), float(info["inertial"]))

GasPFR. GasPFRParams accepts either the lumped alpha (Pa/m\(^3\)) or the physical bed (d_p, voidage, mu, rho_gas, bed_area, optional u_s0, sphericity); in the second case \(\alpha = (\Delta P/L)_0/A_c\) is computed from the Ergun equation at the inlet (ergun_alpha, params.alpha_for(Q0), or params.effective_alpha when u_s0 is given). With u_s0=None the velocity is \(Q_0/A_c\) from the inlet stream; a given u_s0 is checked against it (GasPFRBedWarning above 1 %). Likewise rho_gas=None takes the inlet density (mass flow\(/Q_0\), molar masses from MW= or the unit’s thermo), and a given rho_gas is checked against it when the molar masses are known (GasPFRDensityWarning above 2 %). Supplying both alpha and the bed is an error.

Fluidization#

minimum_fluidization_velocity(d_p, rho_p, rho_f, mu, voidage_mf, sphericity, method) equates the Ergun gradient to the bed weight per volume. In terms of \(Ar = d_p^3\rho_f(\rho_p-\rho_f)g/\mu^2\) it is a quadratic in \(Re_{mf}\),

\[Ar = \frac{150(1-\varepsilon_{mf})}{\phi^2\varepsilon_{mf}^3}Re_{mf} + \frac{1.75}{\phi\varepsilon_{mf}^3}Re_{mf}^2,\]

solved in closed form (method="ergun"); method="wen_yu" is the Wen & Yu (1966) correlation \(Re_{mf} = \sqrt{33.7^2 + 0.0408\,Ar} - 33.7\). bed_expansion(u, u_t, n) gives the Richardson-Zaki voidage \((u/u_t)^{1/n}\) and height \(H_{mf}(1-\varepsilon_{mf})/(1-\varepsilon)\) (a homogeneous-expansion model). By default the line is anchored at \(u_t\), which can give \(\varepsilon < \varepsilon_{mf}\) (a bed shorter than \(H_{mf}\)) just above \(u_{mf}\); pass u_mf= to anchor it at \((u_{mf}, \varepsilon_{mf})\) instead, \(\varepsilon = \min[\varepsilon_{mf}(u/u_{mf})^{1/n}, 1]\). fluidization_window(...) returns \((u_{mf}, u_t)\); above \(u_t\) particles are entrained.

minimum_bubbling_velocity(d_p, rho_f, mu, fines_fraction) is the Abrahamsen & Geldart (1980) correlation \(u_{mb} = 2.07\,e^{0.716F}d_p\rho_f^{0.06}/\mu^{0.347}\) (gas beds; \(F\) is the mass fraction below 45 µm). geldart_group(d_p, rho_p, rho_f, mu, fines_fraction) returns A/B/C/D: C below 20 µm, D above Grace’s (1986) B/D line \(Ar > 1.45\times10^5\), and otherwise A when \(u_{mb}/u_{mf} > 1\) (the bed expands before it bubbles), with \(u_{mf}\) from the Baeyens & Geldart (1974) correlation. This is the sloping A/B boundary, so FCC catalyst (60 µm, 1500 kg/m³) is group A. It is not differentiable and not jit-able.

The Abrahamsen-Geldart \(u_{mb}\) behind that A/B line was fitted to 20-72 µm powders, so the line extrapolates away from them (about 180 µm at 1500 kg/m³, and millimetre sizes for very light solids). method="geldart1973" uses instead the simplified boundaries Geldart (1973) gives with his chart, in his units (\(\Delta\rho\) in g/cm³, \(d_p\) in µm): group A for \(\Delta\rho\,d_p \le 225\) (GELDART_1973_AB), group D for \(\Delta\rho\,d_p^2 \ge 10^6\) (GELDART_1973_BD), C below 20 µm as before. These lines do not move with the gas or the fines content. The two methods agree inside each group and differ near the boundaries (a 170 µm, 1500 kg/m³ powder is A by default and B by the 1973 line; 1 mm sand is B by Grace’s B/D line and D by Geldart’s). The default is unchanged. The 225 and \(10^6\) are the values commonly quoted from the paper; they were not re-checked against it.

from difflow.particles import fluidization_window, geldart_group

u_mf, u_t = fluidization_window(300e-6, 2600.0, 1.2, 1.8e-5, 0.45, 0.8)
print(f"u_mf = {float(u_mf):.3f} m/s, u_t = {float(u_t):.2f} m/s,",
      geldart_group(300e-6, 2600.0, 1.2),
      geldart_group(300e-6, 2600.0, 1.2, method="geldart1973"))

Bubbling beds: bubble size and bed expansion#

Above \(u_{mf}\) (above \(u_{mb}\) for group A) a gas bed bubbles. The bubble size sets the rise velocity, the bubble fraction and the bed height.

Function

Equation

Source and units

mori_wen_max_bubble_diameter(u0, u_mf, D_t)

\(d_{bm} = 0.652[A_t(u_0-u_{mf})]^{0.4}\)

Mori & Wen (1975), cgs (cm, cm², cm/s); in SI \(d_{bm} = 1.638[A_t(u_0-u_{mf})]^{0.4}\)

mori_wen_initial_bubble_diameter(u0, u_mf, distributor, N_or)

porous: \(d_{b0} = 0.00376(u_0-u_{mf})^2\); perforated: \(0.347[A_t(u_0-u_{mf})/n_d]^{0.4}\)

Mori & Wen, cgs; N_or is orifices per m², \(A_t/n_d = 1/N_{or}\)

mori_wen_bubble_diameter(h, u0, u_mf, D_t, ...)

\(d_b = d_{bm} - (d_{bm}-d_{b0})e^{-0.3h/D_t}\)

Mori & Wen; dimensionless

darton_bubble_diameter(h, u0, u_mf, A0)

\(d_b = 0.54(u_0-u_{mf})^{0.4}(h+4\sqrt{A_0})^{0.8}g^{-0.2}\)

Darton et al. (1977); dimensionally consistent, SI as written

bubble_rise_velocity(d_b), bubble_velocity(u0, u_mf, d_b)

\(u_{br} = 0.711\sqrt{gd_b}\), \(u_b = u_0-u_{mf}+u_{br}\)

Davidson & Harrison (1963)

bubbling_bed_expansion(u0, u_mf, d_b, H_mf)

\(\delta = (u_0-u_{mf})/u_b\), \(H = H_{mf}/(1-\delta)\)

two-phase theory, fast bubbles (Kunii & Levenspiel Ch. 6)

The Mori-Wen functions take SI inputs, convert them to cgs, evaluate the published equation with its published constants and convert \(d_b\) back to metres. Their range: \(D_t \le 1.3\) m, \(0.5 \le u_{mf} \le 20\) cm/s, \(60 \le d_p \le 450\) µm, \(u_0 - u_{mf} \le 48\) cm/s. Kunii & Levenspiel print a Davidson-Harrison \(d_{b0}\) (\(2.78(u_0-u_{mf})^2/g\), porous; \(1.30[(u_0-u_{mf})/N_{or}]^{0.4}/g^{0.2}\), perforated) that is 25 % and 6 % smaller; pass d_b0= to use it. Nothing caps \(d_b\) at the column diameter (a bed whose bubbles approach \(D_t\) slugs). Everything here is differentiable.

import jax
from difflow.particles import mori_wen_bubble_diameter, bubbling_bed_expansion

u0, u_mf, D_t, H_mf = 0.30, 0.05, 0.8, 1.2
def bed_height(u0):
    d_b = mori_wen_bubble_diameter(H_mf / 2, u0, u_mf, D_t)   # mid-bed bubble size
    return bubbling_bed_expansion(u0, u_mf, d_b, H_mf)["H"]
print(f"H = {float(bed_height(u0)):.3f} m, dH/du0 = {float(jax.grad(bed_height)(u0)):.2f} s")

What was verified when the model was written: the Stokes limit; the Ergun equation against hand arithmetic; \(u_{mf}\) against the Ergun residual and an independent scipy root find; terminal velocity against an independent scipy.optimize.brentq solve of the force balance in the Stokes, intermediate and Newton regimes; mutual agreement of the two sphere drag fits within 8 %; check_grads (reverse mode) across all regimes. No textbook worked-example number is reproduced from memory; compare against Perry’s or McCabe/Geankoplis tables before relying on a result to better than the correlation scatter (typically 5-10 %). The Richardson-Zaki exponent is piecewise (jumps of at most about 2 % at \(Re_t = 0.2, 1, 500\)). When the model was written, problems were set up the way McCabe, Smith & Harriott Ch. 7, Kunii & Levenspiel Ch. 3 and Geankoplis §3.1 do (Ergun \(u_{mf}\) in cgs, Wen-Yu, Stokes and Newton settling, drag on a sphere, Ergun in US customary units with \(g_c\)), with each expected value computed by hand, in the book’s units. None of them is a printed answer: the printed numbers could not be recalled with confidence. They checked the equations and the unit conversions, not the correlations against data. The Mori-Wen equations were also evaluated in cgs as printed and compared with the SI functions.

References: Haider & Levenspiel, Powder Technol. 58, 63 (1989); Turton & Levenspiel, Powder Technol. 47, 83 (1986); Ergun, Chem. Eng. Prog. 48(2), 89 (1952); Wen & Yu, AIChE J. 12, 610 (1966); Richardson & Zaki, Trans. Inst. Chem. Eng. 32, 35 (1954); Kunii & Levenspiel, Fluidization Engineering, 2nd ed. (1991); Geldart, Powder Technol. 7, 285 (1973); Mori & Wen, AIChE J. 21, 109 (1975); Darton, LaNauze, Davidson & Harrison, Trans. Inst. Chem. Eng. 55, 274 (1977); Davidson & Harrison, Fluidised Particles (1963).


Mechanical Separations#

Location: difflow/units/mechanical.py (the Settler, CakeFilter and Cyclone units and the pure-JAX library functions behind them, SI units, differentiable). Palette category: mechanical (one module, one category, like extraction; the neighbouring per-unit categories drying and crystallization each hold one unit).

These are the particle-scale separations of a solids-handling train: gravity settling and thickening, cake filtration and gas cyclones. The single-particle physics (terminal velocity, Richardson-Zaki hindered settling, Kozeny-Carman permeability) is in Particles and Beds (difflow/particles.py); these units apply it. Centrifuges (Sigma theory for a tubular bowl or a disc stack, Centrifuge and DiscStackCentrifuge) are in difflow_bio and are documented in unit-operations-bio; they are not repeated here.

Streams. mol/s, K, Pa. A solid is the pseudo-species F_<solid_species> (default F_solid) with solid_molar_mass in kg/mol (default 1), so F_solid is numerically kg/s of dry solid. This is the convention of the Dryer (its info["fines"] stream), so a dryer’s product, fines and a cyclone’s dust connect without a conversion. Every non-solid species is a liquid-phase (or gas-phase, for the cyclone) species and rides with the fluid. A particle size distribution is a unit parameter (fixed size bins), not a stream property.

What is and is not verified. The Ruth functions reproduce Geankoplis 5e Examples 14.2-1 to 14.2-3: fit_ruth on the CaCO3 test data gives \(\alpha = 1.855\times10^{11}\) m/kg and \(R_m = 10.64\times10^{10}\) m\(^{-1}\) (book: \(1.863\times10^{11}\) and \(10.63\times10^{10}\)), the 20-frame press collects 3.37 m\(^3\) in 269.6 s (book 269.7 s) and the wash at a quarter of the final rate takes 194 s (the data and answers were typed from memory, check them against the printed book). No published thickener or cyclone example (McCabe, Smith & Harriott Ch. 29-30, Coulson & Richardson Vol. 2, Cooper & Alley Ch. 4) was reproduced; those data below are synthetic and labelled illustrative. When the model was written, the closed forms were checked against independent oracles (a NumPy grid maximum, an analytic Coe-Clevenger area, a batch curve generated from a known settling law, scipy quadrature and scalar optimization, hand calculations) and check_grads. The Lapple/Shepherd-Lapple constants (K = 16, the standard proportions, the Stairmand set) are typed from memory and flagged unverified in the code.

Settler#

Location: difflow/units/mechanical.py

Class: Settler

Description: A gravity settler, either an overflow-rate clarifier (Hazen) or a continuous thickener sized by Coe-Clevenger, Kynch or Talmage-Fitch, from tabulated flux data, a batch interface-height curve or the particle model (terminal_velocity with Richardson-Zaki hindered settling).

Process Role#

Settler(params)(feed) -> (overflow, underflow, info). The underflow is the slurry at the specified underflow_concentration \(C_u\) (kg solid per m\(^3\) slurry); the overflow the clarified liquid plus any solid not captured. Design mode (area=None) returns the required area times safety_factor; with an area it rates it (info["area_ratio"]).

Thickener area#

With the feed solids rate \(F_s\), the Coe-Clevenger area is set by the concentration between feed and underflow with the smallest solids flux:

\[A = F_s \max_{C_0\le C\le C_u}\;\frac{1/C - 1/C_u}{v(C)}\]

Kynch’s limiting-flux form \(G_L = \min_C C v/(1 - C/C_u)\), \(A = F_s/G_L\) is the same expression, and solids_flux(C, v, u) is the total flux \(C(v + u)\). The maximum is a smooth log-sum-exp over a fixed concentration grid (lse_max, temperature \(\tau = \overline{|a|}/\text{sharpness}\)): it never under-predicts and is within \(\tau\ln N\) of the grid maximum (default sharpness=1000, 100 grid points: under 0.5 %), so the area is differentiable in \(C_u\), \(Q_0\) and the settling-curve parameters, and sharpness \(\to\infty\) recovers the grid maximum.

Particle model. \(v(C) = v_t(1 - C/\rho_p)^n\) with \(v_t\) from terminal_velocity and the Richardson-Zaki exponent \(n\). The exponent is piecewise in \(Re_t\) (jumps of up to about 2 %): pass richardson_zaki_n explicitly when optimizing over particle size; None selects the piecewise value.

Batch test. kynch_construction(t, z, C0) returns the flux curve of one interface-height test: the tangent at \((t, z)\) has slope \(-v\) and meets \(t=0\) at \(z_i = z + v t\), where the concentration is \(C = C_0 z_0/z_i\). thickener_area_talmage_fitch reads the underflow time \(t_u\) from the tangent that meets \(z_u = C_0 z_0/C_u\) latest and takes \(A = Q_0 t_u/z_0\); on the same tangent nodes this is algebraically the Coe-Clevenger area (the identity, checked when the model was written, holds exactly in the sharpness limit). Slopes are PCHIP derivatives of the data (monotone, exact on straight segments); the test must be at or below the feed concentration, and a compression zone is part of the data, not modelled.

Overflow rate. The design particle \(d_p\) sets \(A = SF\,Q_o/v_t(d_p)\); each size bin \(d_i\) is captured in the proportion \(\min(1, v_{t,i}/v_o)\) (a smooth minimum, width \(10^{-4}\)).

import jax.numpy as jnp
from difflow import Settler, SettlerParams, thickener_area_coe_clevenger

# SYNTHETIC settling data (illustrative): v(C) = v0 (C0/C)^2, whose Coe-Clevenger maximum is at C_u/2
# and whose area is Q0 C_u / (4 v0 C0) in closed form.
Q0, C0, Cu, v0 = 0.20, 20.0, 100.0, 2.0e-4
C = jnp.linspace(C0, Cu, 400)
A = thickener_area_coe_clevenger(Q0, C0, Cu, C, v0 * (C0 / C) ** 2, 1e6)
print(f"smooth-max area {float(A):.2f} m^2, analytic {Q0 * Cu / (4 * v0 * C0):.2f} m^2")

# the particle-model route: a 25 micron solid in water, Richardson-Zaki exponent fixed at 4.65
params = SettlerParams(underflow_concentration=300.0, d_p=25e-6, rho_p=2650.0, richardson_zaki_n=4.65,
                       safety_factor=1.25)
feed = {"F_solid": 5.0, "F_H2O": 50000.0 / 18.015, "T": 300.0, "P": 101325.0}   # 5 kg/s solid, 50 kg/s water
overflow, underflow, info = Settler(params)(feed)
print(f"required area {float(info['area_required']):.1f} m^2, design area {float(info['area']):.1f} m^2")
print(f"underflow {float(underflow['F_solid']):.2f} kg/s solid at {300.0:.0f} kg/m^3, "
      f"overflow {float(overflow['F_H2O'] * 18.015 / 1000.0):.2f} kg/s water")

Parameters#

@dataclass
class SettlerParams:
    underflow_concentration: float      # C_u, kg solid / m^3 slurry
    method: str = "coe_clevenger"       # "overflow_rate" | "coe_clevenger" | "talmage_fitch" | "kynch"
    C_data: list = None                 # kg/m^3, ascending
    v_data: list = None                 # m/s
    t_data: list = None                 # s, batch curve
    z_data: list = None                 # m
    batch_C0: float = None              # kg/m^3 of the batch test
    d_p: float = None                   # m (design particle, or the particle model)
    rho_p: float = 2650.0               # kg/m^3
    liquid_density: float = 998.0       # kg/m^3
    viscosity: float = 1.0e-3           # Pa s
    sphericity: float = 1.0
    richardson_zaki_n: float = None     # None: piecewise in Re_t
    d_bins: list = None                 # m, overflow-rate capture
    mass_fractions: list = None
    capture: float = 1.0                # fraction of solids to the underflow (thickener methods)
    safety_factor: float = 1.0
    area: float = None                  # m^2 (rating); None designs
    sharpness: float = 1000.0
    n_grid: int = 100
    solid_species: str = "solid"        # F_<solid_species>, kg/s with solid_molar_mass = 1
    solid_molar_mass: float = 1.0       # kg/mol
    liquid_species: str = "H2O"
    liquid_MW: float = 18.015           # g/mol

Limitations: a thickener short of area is reported (area_ratio), not modelled; compression, rakes, flocculation and scale-up factors are the user’s safety_factor. The overflow rate of the clarifier is based on the liquid overflow with full capture; the outlet flows then follow from the computed capture and close the balance.

Sources: Coe & Clevenger, Trans. AIME 55, 356 (1916); Kynch, Trans. Faraday Soc. 48, 166 (1952); Talmage & Fitch, Ind. Eng. Chem. 47, 38 (1955); McCabe, Smith & Harriott Ch. 29; Coulson & Richardson Vol. 2 Ch. 5 (formulas recalled, no worked example reproduced).

CakeFilter#

Location: difflow/units/mechanical.py

Class: CakeFilter

Description: Cake filtration by Ruth’s equation at constant pressure, as a batch filter (press, leaf) with wash and downtime or a rotary drum, designed or rated, with the throughput-maximizing cycle.

Process Role#

CakeFilter(params)(slurry) -> (cake, filtrate, info). The cake carries all the solid and cake_moisture kg of liquid per kg dry cake (every non-solid species in the liquid’s proportion); the filtrate is the rest. The wash liquor is not a stream: info reports the wash volume and time, and cake_moisture is the post-wash value. For the rotary drum info["area_for_cost"] is the drum area for SEPARATOR_COSTS["filter_rotary"] (no cost entry is added here).

Ruth’s equation#

At constant pressure, with \(V\) the filtrate volume,

\[\frac{t}{V} = \frac{K_p}{2}\,V + B,\qquad K_p = \frac{\mu\,\alpha\,c_s}{A^2\,\Delta P},\quad B = \frac{\mu R_m}{A\,\Delta P}\]

\(c_s = \rho_f w/(1 - m w)\) is the mass of cake solids per volume of filtrate (cake_solids_per_filtrate; the unit computes it from the stream and cake_moisture by a mass balance). The volume is the positive root in the cancellation-safe form \(V = 2t/(B + \sqrt{B^2 + 2K_p t})\) (filtrate_volume), exact at \(B=0\) and at \(K_p \to 0\). At constant rate \(q\) the pressure rises as \(\Delta P = (\mu q/A)(\alpha c_s q t/A + R_m)\) (ruth_constant_rate, inverse time_to_pressure). fit_ruth(t, V, A, dP, mu, c_s) is the Ruth plot: a least-squares line of \(t/V\) against \(V\), slope \(K_p/2\) and intercept \(B\), returning \(\alpha\) and \(R_m\). The specific resistance can also come from compressible_cake (\(\alpha = \alpha_0 \Delta P^s\)) or specific_cake_resistance (\(36 k_0 (1-\varepsilon)/(\varepsilon^3\rho_p(\phi d_p)^2)\), the Kozeny-Carman form of particles.kozeny_carman).

The batch cycle and its optimum#

A batch cycle is filtration, wash and downtime. The wash volume is \(rV\) at \(\phi\) times the final filtration rate (wash_time; \(\phi = 1\) is the standard assumption, plate-and-frame washing is often taken as \(\phi = 1/4\)), so the throughput is \(V/(t_f + t_w + t_d)\) (cycle_throughput, differentiable in \(t_f\)). In terms of \(V\) the cycle is \(T(V) = \tfrac{K_p}{2}(1 + 2r/\phi)V^2 + B(1 + r/\phi)V + t_d\) and the optimum \(T = V\,T'\) gives \(\tfrac{K_p}{2}(1 + 2r/\phi)V^2 = t_d\), independent of \(B\). With no medium resistance and no wash the optimum filtration time equals the downtime, \(t_f = t_d\) (optimal_cycle; a user wash function t_wash_fn(V) is solved by a bracketed bisection with implicit gradients). CakeFilterParams(optimize_cycle=True) rates a given area at its optimal cycle.

Design and rating. With area=None the unit sizes the filter so that a cycle of cycle_time (batch: filtration + wash + downtime; rotary: one drum revolution) passes the feed, in closed form (a quadratic in \(1/A\)). With an area it rates the cycle (filtration_time, or cycle_time and submergence for the drum) and reports throughput, solids_capacity and utilization (feed over capacity; the streams follow the feed). In rotary mode the cake forms during the submerged fraction \(\psi\) of the revolution, so \(t_f = \psi\,T_c\).

import jax
import jax.numpy as jnp
from difflow import (CakeFilter, CakeFilterParams, fit_ruth, ruth_constants, ruth_constant_pressure,
                     cycle_throughput, optimal_cycle)

# SYNTHETIC constant-pressure data (illustrative): the Ruth plot recovers alpha and R_m
A, dP, mu, c_s = 0.0439, 267e3, 1.0e-3, 23.5
K_p, B = ruth_constants(A, dP, mu, 3.0e10, c_s, 6.0e9)
V = jnp.linspace(0.05, 1.0, 12) * 1e-3
t = ruth_constant_pressure(V, K_p, B)
fit = fit_ruth(t, V, A=A, dP=dP, mu=mu, c_s=c_s)
print(f"alpha = {float(fit['alpha']):.3e} m/kg, R_m = {float(fit['R_m']):.3e} 1/m")

# the optimal filtration time by gradient: d(throughput)/d(t_f) = 0 at the optimum
K_p, B, t_d, r = 15.0, 3.0, 700.0, 0.2
opt = optimal_cycle(K_p, B, t_d, wash_ratio=r)
g = jax.grad(cycle_throughput)(opt["t_f"], K_p, B, t_d, r)
print(f"optimal t_f = {float(opt['t_f']):.1f} s, cycle {float(opt['t_cycle']):.1f} s, "
      f"gradient there {float(g):.1e}")

# the unit: size a press for 2 kg/s of solid in 40 kg/s of liquid, one cycle per hour
feed = {"F_solid": 2.0, "F_H2O": 40000.0 / 18.015, "T": 300.0, "P": 200000.0}
p = CakeFilterParams(delta_P=3e5, alpha=1e11, R_m=1e10, cake_moisture=0.4, cycle_time=3600.0,
                     downtime=600.0, wash_ratio=0.1)
cake, filtrate, info = CakeFilter(p)(feed)
print(f"area {float(info['area']):.1f} m^2, filtration {float(info['t_filtration']):.0f} s, "
      f"wash {float(info['t_wash']):.0f} s, cake {float(info['cake_mass_per_cycle']):.0f} kg per cycle")

Parameters#

@dataclass
class CakeFilterParams:
    delta_P: float                      # Pa
    mode: str = "batch"                 # "batch" | "rotary"
    area: float = None                  # m^2 (rating); None designs from cycle_time
    alpha: float = None                 # m/kg ... or alpha0 (+ s), or d_p (+ voidage, rho_p)
    alpha0: float = None
    s: float = 0.0
    d_p: float = None
    voidage: float = None
    rho_p: float = None
    sphericity: float = 1.0
    k0: float = 5.0
    R_m: float = 0.0                    # 1/m
    viscosity: float = 1.0e-3           # Pa s
    liquid_density: float = 998.0       # kg/m^3
    cake_moisture: float = 0.0          # kg liquid / kg dry cake
    filtration_time: float = None       # s, batch rating
    optimize_cycle: bool = False
    cycle_time: float = None            # s: total cycle (batch design) or revolution (rotary)
    wash_ratio: float = 0.0             # wash volume / filtrate volume
    wash_rate_factor: float = 1.0       # wash rate / final filtration rate
    downtime: float = 0.0               # s
    submergence: float = 0.3            # rotary
    solid_species: str = "solid"
    solid_molar_mass: float = 1.0       # kg/mol
    liquid_species: str = "H2O"
    liquid_MW: float = 18.015           # g/mol

Limitations: constant pressure, no medium blinding; a compressible cake uses \(\alpha\) at the filtration \(\Delta P\); the rotary drum has no wash or dewatering arc in its time budget; streams are time-averaged. For the filter-press cycle exercise (wex21-2) the structure is optimal_cycle with a wash and CakeFilter for the area; the published example was not reproduced.

Sources: Ruth, Ind. Eng. Chem. 27, 708 (1935); Geankoplis Sec. 14.2; McCabe, Smith & Harriott Ch. 30 (formulas recalled, no worked example reproduced).

Cyclone#

Location: difflow/units/mechanical.py

Class: Cyclone

Description: A gas cyclone with the Lapple cut size and grade-efficiency curve, standard geometries and the Shepherd-Lapple pressure drop, for a fixed set of particle size bins.

Process Role#

Cyclone(params)(gas_with_solids) -> (clean_gas, solids, info). The inlet is a gas stream (mol/s per gas species) that also carries the dust as F_<solid_species> (kg/s with the default molar mass), the same convention as the dryer’s fines. solids is the collected dust; clean_gas the gas and the dust that escapes. The size distribution is the parameters d_bins and mass_fractions.

Lapple theory#

With the inlet velocity \(v_i = Q/(HW)\), the effective number of turns \(N_e = (L_b + L_c/2)/H\) (6 for the standard Lapple proportions) and the gas viscosity \(\mu\),

\[d_{50} = \left[\frac{9\mu W}{2\pi N_e v_i(\rho_p - \rho_g)}\right]^{1/2},\qquad \eta(d) = \frac{1}{1 + (d_{50}/d)^2},\qquad \Delta P = K\,\frac{HW}{D_e^2}\,\frac{\rho_g v_i^2}{2}\]

The overall efficiency is \(\sum_i w_i\,\eta(d_i)\) over the bins, differentiable in \(D\), the gas flow and \(\rho_p\). \(d_{50}\propto v_i^{-1/2}\), so efficiency rises with flow at fixed \(D\) (the pressure drop rises as \(v_i^2\)). Unverified, typed from memory: \(K = 16\) (K_pressure), the Lapple proportions \(H = D/2\), \(W = D/4\), \(D_e = D/2\), \(S = 5D/8\), \(L_b = L_c = 2D\), \(J = D/4\) and the Stairmand high-efficiency set (CYCLONE_GEOMETRIES); geometry="custom" with ratios takes your own, and delta_P_velocity_heads replaces the Shepherd-Lapple form by a number of inlet velocity heads. Mass-loading effects and re-entrainment are not modelled.

Dryer exhaust (wex21-3)#

Dryer(DryerParams(entrainment=f)) puts the fraction \(f\) of the dry solids in info["fines"] (a solids stream, F_solid in kg/s) and not in air_out (the psychrometric dry_air_flow counts every non-water F_* as dry air). The hand-off is explicit: dust_laden_gas(air_out, info["fines"]) merges them (the fines’ water becomes vapor) into a cyclone inlet. A Flowsheet cannot route info, so this step is plain Python.

from difflow import Cyclone, CycloneParams, Dryer, DryerParams, dust_laden_gas
from difflow.psychrometrics import MW_WATER, air_stream_from_state, state

# SYNTHETIC dryer and dust (illustrative numbers, not a textbook example)
wet = {"F_solid": 0.5, "F_H2O": 0.5 * 0.30 / MW_WATER, "T": 300.0, "P": 101325.0}
air = air_stream_from_state(state(T=373.15, W=0.01), 10.0)
dryer = Dryer(DryerParams(mode="continuous", X_c=0.15, X_eq=0.02, product_moisture=0.05, h=60.0,
                          solids_per_area=10.0, max_solids_T=340.0, entrainment=0.05))
dry, air_out, dinfo = dryer(wet, air)
inlet = dust_laden_gas(air_out, dinfo["fines"])

cyc = Cyclone(CycloneParams(D=2.0, rho_p=1500.0, gas_MW=28.5,
                            d_bins=[2e-6, 5e-6, 10e-6, 20e-6, 50e-6],
                            mass_fractions=[0.1, 0.2, 0.3, 0.3, 0.1]))
clean, dust, info = cyc(inlet)
print(f"d50 = {float(info['d50']) * 1e6:.1f} um, overall efficiency {float(info['overall_efficiency']):.3f}, "
      f"dP = {float(info['delta_P']):.0f} Pa, v_i = {float(info['inlet_velocity']):.1f} m/s")
print(f"dry solid out: product {float(dry['F_solid']):.4f} + collected {float(dust['F_solid']):.4f} "
      f"+ escaping {float(clean['F_solid']):.4f} = {float(dry['F_solid'] + dust['F_solid'] + clean['F_solid']):.4f} kg/s")

Parameters#

@dataclass
class CycloneParams:
    D: float                            # body diameter, m
    rho_p: float                        # kg/m^3
    d_bins: list                        # m, fixed-length size bins
    mass_fractions: list                # normalized to 1
    geometry: str = "lapple"            # "lapple" | "stairmand_he" | "custom"
    ratios: list = None                 # [H, W, D_e, S, L_b, L_c, J] / D for "custom"
    n_turns: float = None               # N_e; None: (L_b + L_c/2) / H
    gas_MW: float = 28.96               # g/mol
    viscosity: float = 1.8e-5           # Pa s
    K_pressure: float = 16.0            # Shepherd-Lapple (unverified)
    delta_P_velocity_heads: float = None
    solid_species: str = "solid"
    solid_molar_mass: float = 1.0       # kg/mol

Not included: hydrocyclones, electrostatic precipitators and bag filters, screening and size reduction, and any cost correlation (none is cited here, so no cyclone or thickener entry was added to economics/capital.py).

Sources: Lapple, Chem. Eng. 58(5), 144 (1951); Shepherd & Lapple, Ind. Eng. Chem. 31, 972 (1939); Cooper & Alley Ch. 4; Coulson & Richardson Vol. 2 Ch. 1 (recalled; constants unverified).


Membrane Separations#

difflow/units/membranes.py holds the core membrane models: gas permeation in four flow patterns for any number of species, reverse osmosis with concentration polarization, and the ultrafiltration flux relations (gel polarization). The carbon-capture plugin’s MembraneSeparator (membrane materials from a database) and the bio plugin’s Ultrafiltration, Diafiltration and TFF use the same functions. Library functions:

Function

Use it for

binary_permeate_composition(x, alpha, pr)

Binary permeate in equilibrium with a feed-side x (complete mixing, or local cross flow); pr = P_l/P_h

binary_reject_composition(y, alpha, pr)

Its inverse; at y = x_f, the minimum reject composition of complete mixing

binary_complete_mixing(x_f, alpha, pr, x_o=, theta=)

Geankoplis-style binary complete mixing given the reject or the stage cut

complete_mixing_area(...)

Area from the flux of A

complete_mixing, cross_flow, cocurrent, countercurrent, permeation_stage

Multicomponent stages from feed flows, permeances, area and pressures

area_for_stage_cut

Design mode: the area for a stage cut, any pattern

permeance_from_permeability, BARRER, GPU

Unit conversions to mol/(m² s Pa)

osmotic_pressure, ro_local_flux

Van’t Hoff osmotic pressure; the local RO fluxes with film-theory polarization

polarization_modulus, gel_polarization_flux, uf_flux, batch_concentration_time

UF/MF: polarization, gel-limited flux, flux against TMP, batch concentration time

GasPermeator#

Location: difflow/units/membranes.py

Class: GasPermeator

Description: A gas-permeation stage (solution-diffusion) with the permeances given directly, in complete mixing, cross flow, cocurrent or countercurrent flow.

GasPermeator(GasPermeationParams(permeances={...}, area=... or stage_cut=..., flow_pattern=..., pressure_ratio=...))(feed) -> (retentate, permeate, info). The flux is \(J_i = Q_i(x_i P_h - y_i P_l)\). Complete mixing is solved exactly (a bracketed root). Cross flow uses the local permeate composition \(y_i = J_i/\sum_j J_j\) and RK4 along the area. Cocurrent and countercurrent use the bulk permeate composition in the driving force; countercurrent is solved by shooting from the closed (retentate) end, where the permeate is the local composition, with Newton’s method on the retentate flows. All four have implicit-function gradients. The binary plug-flow results were checked against hand integrations in the feed-side mole fraction when the model was written. At the same area, the permeate purity ranks countercurrent > cross flow > cocurrent > complete mixing.

The flux per unit area depends on the feed-side mole fractions, not on how much gas is left, so a plug-flow stage with more area than the feed needs runs dry partway along: everything permeates (stage cut 1, retentate exactly zero) and the rest of the membrane carries nothing. The three plug-flow patterns return that answer with zero derivatives. Countercurrent shooting has no root there, so it tests for it first by integrating from the feed end with a zero retentate, and reports profile["exhausted"].

from difflow import GasPermeator, GasPermeationParams, make_stream
from difflow.units.membranes import GPU

feed = make_stream({"O2": 0.21, "N2": 0.79}, T=298.15, P=1e6)
unit = GasPermeator(GasPermeationParams(permeances={"O2": 10 * GPU, "N2": 2 * GPU},
                                        stage_cut=0.3, flow_pattern="cross_flow", pressure_ratio=5.0))
retentate, permeate, info = unit(feed)
print(float(info["area"]), float(info["permeate_fraction"]["O2"]))

ReverseOsmosis#

Location: difflow/units/membranes.py

Class: ReverseOsmosis

Description: A reverse-osmosis element in plug flow: water flux \(J_v = A_w(\Delta P - \Delta\pi)\), solute flux \(J_s = B(c_w - c_p)\), film-theory polarization \((c_w - c_p)/(c_b - c_p) = e^{J_v/k}\), van’t Hoff osmotic pressure \(\pi = \phi i c R T\).

ReverseOsmosis(ReverseOsmosisParams(A_w=..., B=..., area=..., k=...))(feed) -> (concentrate, permeate, info). The feed carries H2O and the solute (default NaCl) in mol/s at the feed pressure. info reports the water recovery, the observed rejection, the concentrations and osmotic pressures and the thermodynamic recovery limit \(1 - \pi_f/\Delta P\) of a perfect membrane. \(A_w\) is a volumetric permeance in m/(s Pa); a mass-flux coefficient in kg/(s m² Pa) divided by the water density gives it.

Mass Transfer#

Location: difflow/mass_transfer.py (pure JAX functions in SI units; not unit operations, so they have no palette entry). They are the building blocks for absorbers, strippers, packed-column hardware and any exercise that needs a diffusivity, a film coefficient or a transfer-unit height. Properties are inputs: core has no gas viscosity or surface-tension model, so the Onda functions take mu_G and sigma as required arguments (there is no hidden sigma=0.072). See the module docstring for exactly what was and was not verified. When the model was written, Fuller was checked against Poling et al. 5e Example 11-3 and Geankoplis Example 6.2-5, and Wilke-Chang (with Le Bas volumes) against Geankoplis Example 6.3-2; no published Onda worked example is reproduced, and the tables (diffusion volumes, Le Bas increments, packings) are transcribed from memory.

Diffusivity Correlations#

fuller_diffusivity(T, P, MW_A, MW_B, V_A, V_B) is the Fuller-Schettler-Giddings gas correlation (\(D \propto T^{1.75}/P\), 5-10 %). wilke_chang_diffusivity(T, mu_B, MW_B, V_A, phi_B) is the dilute-liquid correlation (10-20 %), with the solute molar volume at its normal boiling point V_A in cm\(^3\)/mol from molar_volume_nbp (Le Bas increments) and the solvent association factor phi_B from ASSOCIATION_FACTORS (water 2.6; there is no silent default for water). stokes_einstein_diffusivity(T, mu, r) is for large solutes. Both published correlations are implemented in their native units (cm\(^2\)/s, atm, cP) and converted at the boundary. Diffusion volumes are in FULLER_VOLUMES and FULLER_ATOMIC_VOLUMES (the 1969 refit recommended by Poling et al.); diffusion_volume sums atoms. diffusion_volume(..., table="1966") uses the original 1966 values (FULLER_VOLUMES_1966, FULLER_ATOMIC_VOLUMES_1966) that Geankoplis, Treybal and Welty tabulate, to reproduce their examples. The Le Bas table distinguishes oxygen environments (O 7.4, O_methyl_ester 9.1, O_methyl_ether 9.9, O_ester_ether 11.0, O_acid 12.0, O_SPN 8.3).

from difflow.mass_transfer import (
    fuller_diffusivity, wilke_chang_diffusivity, diffusion_volume,
    molar_volume_nbp, FULLER_VOLUMES, ASSOCIATION_FACTORS,
)

# water vapour in air, 25 C, 1 atm: experimental about 2.5e-5 m^2/s
D_air = fuller_diffusivity(298.15, 101325.0, 18.015, 28.96,
                           FULLER_VOLUMES["H2O"], FULLER_VOLUMES["air"])
print(f"D(H2O-air) = {float(D_air):.3e} m^2/s")

# ethanol in water at 25 C (experimental about 1.2e-9; Wilke-Chang runs ~20% high)
V_eth = molar_volume_nbp({"C": 2, "H": 6, "O": 1})          # 59.2 cm^3/mol
D_liq = wilke_chang_diffusivity(298.15, 0.89e-3, 18.015, V_eth, ASSOCIATION_FACTORS["water"])
print(f"D(ethanol-water) = {float(D_liq):.3e} m^2/s")

Film Theory and Overall Coefficients#

equimolar_flux and stagnant_film_flux give the flux across a film of thickness \(\delta\); the stagnant-film (Stefan) form carries the bulk-flow factor \(1/(1-y)_{lm}\), computed with log_mean, which is smooth (series form) when its two arguments coincide. mass_transfer_coefficient_conversions moves a gas coefficient among \(k_c\) (m/s), \(k_G\) (mol/m\(^2\)/s/Pa) and \(k_y\) (mol/m\(^2\)/s). Sherwood correlations (ranz_marshall, frossling, linton_sherwood, gilliland_sherwood, pipe_sherwood, flat_plate_sherwood, sherwood_from_heat_transfer) each state their range in the docstring; the laminar-turbulent switches use the same smooth blend as difflow.heat_transfer. Two-resistance theory: overall_gas_coefficient (\(1/K_y = 1/k_y + m/(E k_x)\)), overall_liquid_coefficient, overall_K_G (with Henry’s constant) and resistance_fractions (which film controls).

from difflow.mass_transfer import (
    fuller_diffusivity, stagnant_film_flux, equimolar_flux, ranz_marshall,
    resistance_fractions,
)
from difflow.units.evaporator import water_saturation_pressure

# evaporation of water at 25 C into dry air across a 1 cm stagnant film
P, T = 101325.0, 298.15
D = fuller_diffusivity(T, P, 18.015, 28.96, 13.1, 19.7)
y_sat = water_saturation_pressure(T) / P
c = P / (8.314462618 * T)
N = stagnant_film_flux(D, c, y_sat, 0.0, 0.01)
print(f"flux = {float(N):.3e} mol/m^2/s (equimolar would be "
      f"{float(equimolar_flux(D, c, y_sat, 0.0, 0.01)):.3e})")

# sphere in stagnant fluid: Sh -> 2
print(float(ranz_marshall(0.0, 1.0)))

# which film controls? soluble (small m) vs sparingly soluble (large m)
for m in (0.01, 1e3):
    g, l = resistance_fractions(k_y=0.05, k_x=5.0, m=m)
    print(f"m = {m:g}: gas {float(g):.3f}, liquid {float(l):.3f}")

Penetration, Surface Renewal and Falling Films#

Film theory gives \(k \propto D\); the unsteady theories give \(k \propto D^{1/2}\). penetration_coefficient(D, t_c) is Higbie’s \(k_L = 2\sqrt{D/(\pi t_c)}\) for an exposure time \(t_c\), and surface_renewal_coefficient(D, s) is Danckwerts’ \(k_L = \sqrt{D s}\). For a wetted-wall column, falling_film_thickness(Gamma, rho, mu) is the Nusselt film \(\delta = (3\mu\Gamma/\rho^2 g)^{1/3}\) (\(\Gamma\) in kg/m/s of wetted perimeter), and falling_film_liquid_coefficient(D, Gamma, rho, mu, L) the short-contact mean \(k_L = \sqrt{4 D v_{max}/(\pi L)}\) with the surface velocity \(v_{max} = 1.5\Gamma/(\rho\delta)\) (Bird, Stewart & Lightfoot 2e, Sec. 20.1). It is valid for laminar, wave-free films while the penetration depth is small compared with \(\delta\). The gas side of a wetted-wall column is gilliland_sherwood.

from difflow.mass_transfer import falling_film_liquid_coefficient, penetration_coefficient

k_L = falling_film_liquid_coefficient(2.0e-9, 0.02, 998.0, 1.0e-3, 1.0)
print(f"k_L = {float(k_L):.2e} m/s")

Packed-Column Mass Transfer (Onda, HTU/NTU)#

onda_wetted_area, onda_gas_coefficient and onda_liquid_coefficient implement Onda, Takeuchi & Okumoto (1968) for random packing, taking a PackingData record (d_p, a_p, void fraction, critical surface tension sigma_c, packing factor F_p; the small table PACKINGS is from memory and unverified, so check a data sheet) or explicit a_p, d_p, sigma_c. The gas function returns \(k_c\) in m/s (convert with mass_transfer_coefficient_conversions). Corrected in #427: the earlier difflow_cc functions returned a liquid coefficient about \(10^4\) too large (factor \((\rho g/\mu)^{1/3}\) instead of \((\mu g/\rho)^{1/3}\), and \(Re\) on the dry rather than the wetted area), and a gas coefficient too large by \(a_p d_p\) (the constant is 2.00, not 5.23, below 15 mm packing; blended smoothly between 12 and 18 mm unless smooth=False).

Heights and counts: htu_og, htu_ol, htu_og_from_films (\(H_{OG} = H_G + \lambda H_L\)), ntu_og_colburn (analytic, straight lines, smooth at \(\lambda = 1\)), ntu_og_integral (Gauss-Legendre quadrature of \(\int dy/(y-y^*)\) for a curved equilibrium line, with an optional \((1-y)_{lm}\) correction) and hetp_from_htu (\(\mathrm{HETP} = H_{OG}\ln\lambda /(\lambda-1)\), continuous through \(\lambda = 1\)). All differentiable, so \(\partial(\text{height})/\partial(k_G, k_L, a_w, L/G)\) comes from jax.grad.

import jax
from difflow.mass_transfer import (
    PACKINGS, onda_wetted_area, onda_gas_coefficient, onda_liquid_coefficient,
    mass_transfer_coefficient_conversions, k_x_from_k_L, htu_og, htu_ol,
    htu_og_from_films, ntu_og_colburn, hetp_from_htu,
)

pk = PACKINGS["pall_metal_50"]          # from memory: verify before design use
a_w = onda_wetted_area(0.01, 1000.0, 1.0e-3, 0.072, pk)
k_c = onda_gas_coefficient(1.0, 1.2, 1.8e-5, 1.6e-5, pk)
k_L = onda_liquid_coefficient(0.01, 1000.0, 1.0e-3, 1.5e-9, 0.072, pk)
print(f"a_w = {float(a_w):.1f} m2/m3, k_c = {float(k_c):.3e} m/s, k_L = {float(k_L):.3e} m/s")

G_m, y_in, y_out, m = 40.0, 0.02, 0.002, 0.8
k_y = mass_transfer_coefficient_conversions(k_c, "c", "y", 300.0, 101325.0)
k_x = k_x_from_k_L(k_L, 55500.0)

def column_height(L_over_G):
    lam = m / L_over_G
    H_OG = htu_og_from_films(htu_og(G_m, k_y, a_w), htu_ol(G_m * L_over_G, k_x, a_w), lam)
    return H_OG * ntu_og_colburn(y_in, y_out, 0.0, m, lam)

print(float(column_height(1.5)), float(jax.grad(column_height)(1.5)))
print(float(hetp_from_htu(0.5, 1.0)))     # HETP -> H_OG as lambda -> 1

Column Hardware#

Location: difflow/units/column_design.py (pure JAX, SI; a library, not palette operations, so nothing here is registered). These functions turn the vapor and liquid rates of a column (a DistillationColumn result, a McCabe-Thiele design, an absorber) into a diameter, a pressure drop, margins to the weeping / entrainment / flooding limits, and the height and shell volume that difflow.economics.capital.column_cost needs. Every limit is a continuous margin (never an exception on a traced value), so an optimizer can use it as a smooth constraint, and everything is differentiable in the fraction of flood, tray spacing, packing factor, rates and surface tension. Properties are inputs: the surface tension, densities and viscosity are required arguments (there is no silent sigma = 0.02).

What was and was not verified. The correlation constants were first written from memory; each docstring says which are still unverified. They are fits of charts: expect 10-20 % on a diameter or a flooding velocity, and worse on a pressure drop. Printed worked examples, as reproduced when the model was written:

Check

Source

Printed

difflow

Fair \(C_{SB}\) (\(F_{LV} = 0.036\), 18-in spacing, 20.5 mN/m)

Dutta (2007) Ex. 5.1

0.285 ft/s

0.314 ft/s (+10 %)

Dry-plate drop (\(u_h = 39.1\) ft/s, \(C_0 = 0.73\))

Dutta Ex. 5.1

1.75 in liquid

1.75 in

Weir crest

Dutta Ex. 5.1, \(0.48\,(q/l_w)^{2/3}\)

same formula

same (Francis)

Eckert flood line (\(X = 0.90\))

Dutta Ex. 5.2

\(Y = 0.021\), \(D = 0.90\) m

\(Y = 0.024\), \(D = 0.87\) m

Packed \(\Delta P\) (2-in metal Pall, air-water)

Perry’s 8e Sec. 14 Ex. 13

0.38 (GPDC), 0.40 (Robbins chart) in H\(_2\)O/ft

0.38 in H\(_2\)O/ft (Robbins)

The weep constant \(K_2\) and the entrainment function are still unverified placeholders (below), and there is no structured-packing capacity model.

Tray Flooding and Column Diameter#

flow_parameter gives \(F_{LV} = (L/V)\sqrt{\rho_V/\rho_L}\). fair_capacity_factor is the flooding capacity factor on the net area, \(C_{SB} = [\alpha \log_{10}(1/F_{LV}) + \beta]\,(\sigma/0.02)^{0.2}\) with \(\alpha = 0.0744\,t + 0.01173\), \(\beta = 0.0304\,t + 0.015\) (\(t\) the tray spacing in m): the closed-form fit of Fair’s chart as quoted by Treybal (from memory, unverified; believed valid for \(0.01 \le F_{LV} \le 0.1\)), with optional foaming_factor and hole_area_factor multipliers. flooding_velocity is \(u_f = C_{SB}\sqrt{(\rho_L-\rho_V)/\rho_V}\). tray_column_diameter sizes at a fraction_flood of \(u_f\) on the net area \(A_n = A_c(1 - A_d/A_c)\) and returns (D, info) (F_LV, C_SB, u_flood, A_net, A_active, percent_flood, …); tray_percent_flood is the inverse, for rating an existing column at a new throughput (debottlenecking).

import jax
from difflow.units.column_design import (
    tray_column_diameter, tray_percent_flood, size_column, column_hardware_cost,
    oconnell_efficiency,
)

# Benzene-toluene-like column: rates in mol/s, MW in g/mol, rho in kg/m^3, sigma in N/m
col = dict(V_molar=100.0, MW_V=80.0, rho_V=2.9, L_molar=80.0, MW_L=85.0,
           rho_L=790.0, sigma=0.021)
D, info = tray_column_diameter(**col, tray_spacing=0.6, fraction_flood=0.8)
print(f"F_LV = {float(info['F_LV']):.3f}, C_SB = {float(info['C_SB']):.3f} m/s, "
      f"u_f = {float(info['u_flood']):.2f} m/s, D = {float(D):.2f} m")
print(f"{float(tray_percent_flood(**col, diameter=D)):.0f} % flood at the design rate")

# d(diameter)/d(fraction of flood) and d(cost)/d(fraction of flood)
def cost(ff):
    D, _ = tray_column_diameter(**col, fraction_flood=ff)
    eff = oconnell_efficiency(2.4, 0.3)          # alpha, viscosity in cP
    sizing = size_column(15.0, eff, D, tray_spacing=0.6)
    return column_hardware_cost(sizing)["total"]
print(float(cost(0.8)), float(jax.grad(cost)(0.8)))

The old column_diameter(V, rho_V, rho_L, sigma=0.02, tray_spacing=0.6) in difflow.units.distillation hard-coded the vapor molecular weight (30 g/mol), the flow parameter, \(C_{SB}\) and ignored sigma and tray_spacing; it is now a deprecated wrapper (DeprecationWarning) that calls tray_column_diameter and requires MW_V, L and MW_L as keywords and sigma positionally.

Flash Drum Sizing (Souders-Brown)#

flash_drum_size(Q_V, Q_L, rho_L, rho_V, k=0.107, holdup_time=300.0, fill_fraction=0.5, L_over_D=3.0) sizes a vertical vapor-liquid separator. The vapor sets one diameter through the Souders-Brown velocity \(u_{max} = k\sqrt{(\rho_L-\rho_V)/\rho_V}\), \(D_V = \sqrt{4Q_V/(\pi u_{max})}\). The liquid sets another: the drum holds \(Q_L t_{hold}\) in fill_fraction of a volume with \(H = (L/D)\,D\). The drum takes the larger. The defaults are Walas’s rules of thumb (Chemical Process Equipment, 1988): \(k = 0.35\) ft/s with a wire-mesh deentrainer (0.1 ft/s without), \(L/D = 3\), 5 min of holdup with the drum half full. It returns (D, info) with u_max, D_vapor, D_liquid, height, volume, liquid_volume, u_vapor and fraction_of_u_max, all differentiable.

from difflow.units.column_design import flash_drum_size

D, info = flash_drum_size(Q_V=0.5, Q_L=0.002, rho_L=600.0, rho_V=20.0)
print(f"D = {float(D):.2f} m, H = {float(info['height']):.2f} m, "
      f"u_max = {float(info['u_max']):.3f} m/s")

Tray Hydraulics#

tray_pressure_drop is the standard textbook breakdown for a sieve tray, \(\Delta P = \rho_L g\,(h_d + h_w + h_{ow} + h_r)\): the dry (orifice) head \(h_d = \rho_V u_h^2 / (2 C_0^2 \rho_L g)\), the weir height plus the Francis crest \(h_{ow} = (Q_L/(1.84\,l_w))^{2/3}\) (weir_crest), and a residual head \(h_r = 12.5/\rho_L\) m (the constant is from memory, unverified; so is the orifice coefficient default \(C_0 = 0.8\)). info returns each head and the components in Pa, which sum to the total. weep_point returns the weeping margin \(u_h/u_{min} - 1\) (> 0: no weeping) with the Eduljee-type \(u_{min} = (K_2 - 0.90\,(25.4 - d_h[\text{mm}]))/\sqrt{\rho_V}\). The source (Coulson & Richardson Vol. 6, Fig. 11.30, after Eduljee 1958) reads \(K_2\) from a chart against the clear liquid depth \(h_w + h_{ow}\); no published fit of that curve could be verified, so none is built in. K2 is either a constant (default 30, from memory, unverified, about 15 % on the turndown at which the margin changes sign) or a callable K2(h_mm) of \(h_w + h_{ow}\) in mm, so you can pass your own chart reading (e.g. a jnp.interp); info returns the K2 used.

downcomer_backup is the downcomer-flooding check of Coulson & Richardson Vol. 6 (Sinnott) Sec. 11.13: the clear-liquid back-up \(h_b = (h_w + h_{ow}) + h_t + h_{dc}\), with \(h_t\) the total tray drop as liquid head (tray_pressure_drop, or dP_tray=) and the apron loss \(h_{dc} = 166\,(L_{wd}/(\rho_L A_m))^2\) mm, \(A_m\) the smaller of the downcomer area and the clearance area \(h_{ap} l_w\) (default \(h_{ap} = h_w - 10\) mm). It returns the margin \(0.5\,(l_t + h_w)/h_b - 1\) (> 0: no downcomer flooding) and the downcomer residence time against 3 s (residence_margin). The crest is the module’s Francis crest (Sinnott’s constant gives about 13 % more).

fractional_entrainment is a placeholder, not Fair’s correlation or any published fit, and every call raises EntrainmentPlaceholderWarning: no fit of Fair’s entrainment chart could be recalled or found with confidence, so it is \(\psi = \psi_{ref}\,(\%\text{flood}/80)^{4}(F_{LV}/0.1)\) with an order-of-magnitude \(\psi_{ref} = 0.01\) and assumed exponents; the three constants are arguments, expect a factor of 2-3, and replace them with a fit of the chart you design from (the usual limit is \(\psi < 0.1\)).

from difflow.units.column_design import (
    tray_column_diameter, tray_pressure_drop, weep_point, downcomer_backup,
)

col = dict(V_molar=100.0, MW_V=80.0, rho_V=2.9, L_molar=80.0, MW_L=85.0,
           rho_L=790.0, sigma=0.021)
D, info = tray_column_diameter(**col)
Q_V, Q_L = float(info["Q_V"]), 80.0 * 85e-3 / 790.0       # m^3/s
dP, h = tray_pressure_drop(Q_V, Q_L, 2.9, 790.0, D)
print(f"dP/tray = {float(dP):.0f} Pa (dry {float(h['dP_dry']):.0f}, "
      f"liquid {float(h['dP_liquid']):.0f}, residual {float(h['dP_residual']):.0f})")
for turn in (1.0, 0.6, 0.4):
    margin, w = weep_point(turn * Q_V, Q_L, 2.9, D)
    print(f"{turn:.0%} of design vapor: weeping margin {float(margin):+.2f}")
dc, b = downcomer_backup(Q_V, Q_L, 2.9, 790.0, D, tray_spacing=0.6)
print(f"downcomer back-up {1000 * float(b['h_backup']):.0f} mm "
      f"(limit {1000 * float(b['h_backup_max']):.0f} mm), residence {float(b['residence_time']):.1f} s")

O’Connell Efficiency#

oconnell_efficiency(alpha, mu_cP) is \(E_o = 0.492\,(\alpha\mu_L)^{-0.245}\) clipped to [0.1, 1] (the fit quoted by Seader, Henley & Roper; from memory, unverified; it gives 0.492 at \(\alpha\mu = 1\)). It was promoted from the gas plant (which re-exports it, so difflow_refinery.gasplant.oconnell_efficiency is the same function; see Refinery Unit Operations). The hard clip has a zero gradient outside [0.1, 1]; smooth=0.02 swaps in a softplus clip that stays differentiable. The number of actual trays is size_column’s n_theoretical / efficiency.

Packed-Column Flooding and Pressure Drop#

gpdc_flood(F_LV, F_p, rho_L, rho_V, mu_L) is the gas mass flux at flood on the Eckert chart: abscissa \(X = (L/G)\sqrt{\rho_V/(\rho_L-\rho_V)}\), ordinate \(Y = G^2 F_p \mu_L^{0.2}/(\rho_V(\rho_L-\rho_V)g)\) (\(F_p\) in 1/m, \(\mu_L\) in cP inside the power), and the flood line \(\log_{10} Y = -1.6678 - 1.085\log_{10} X - 0.29655(\log_{10} X)^2\) (the Kessler-Wankat fit quoted by Wankat, recalled from memory; it reproduces Dutta’s chart reading to 15 % in \(Y\), 7 % in \(G\); the earlier natural-log fit was 60 % high in \(Y\) there). Perry’s 8e restricts the Eckert flood line to \(F_p > 60\) ft\(^{-1}\) (about 200 m\(^{-1}\)).

For modern packings the flood point comes from the pressure drop. robbins_pressure_drop(G, L, rho_V, rho_L, mu_L, F_pd) is the Robbins (1991) correlation, Perry’s 8e Eqs. (14-145) to (14-151), with the dry packing factor \(F_{pd}\) (DRY_PACKING_FACTORS has Perry’s values for the listed packings). kister_gill_flood_dp(F_p) is the flood pressure drop \(0.115\,F_p^{0.7}\) in H\(_2\)O/ft with \(F_p\) in 1/ft (Kister & Gill, 1991; Perry’s Eq. 14-142 rounds it to 0.12), in Pa/m, and robbins_flood(L_over_G, rho_V, rho_L, mu_L, F_p, F_pd) solves for the gas flux at which Robbins reaches it, Robbins’ own recommendation (a bisection with an implicit-function gradient, so it is differentiable). gpdc_pressure_drop(..., method="robbins") (the default) is Robbins; method="anchored" keeps the old flood-anchored power law, which gave a third of Perry’s Example 13 answer. packed_column_diameter(..., packing=, fraction_flood=0.7, flood="eckert") returns (D, info) with the flooding and operating velocities, the Robbins pressure drop per metre and the wetting rate against a minimum (2.2e-5 m\(^2\)/s, about 0.08 m\(^3\)/m/h; from memory, unverified); flood="robbins" uses robbins_flood instead of the Eckert line. hetp has Perry’s rules of thumb, \(18\,d_p\) (random packing, Eq. 14-156a) and \(100\,C_{XY}/a_p + 0.1\) m (structured, Eq. 14-159; C_XY=1.45 for X-type packing below 300 m\(^2\)/m\(^3\)), both for organic systems below 25 mN/m, and method="htu" for hetp_from_htu of the mass-transfer section.

Structured packing has no capacity model here. The Eckert line is for high-\(F_p\) random packings and Robbins’ correlation is a random-packing one; for a packing with no dry factor in DRY_PACKING_FACTORS (Mellapak 250Y) packed_column_diameter uses \(F_{pd} = F_p\) and raises PackingCapacityWarning. Treat such a diameter as a rough screen and size from the vendor’s capacity chart.

size_column turns stages, efficiency (or HETP), diameter and spacing into height, shell volume, tray area and tray count; for a packed bed of known depth pass packed_height= instead of hetp= (exactly one of tray_spacing, hetp, packed_height). column_hardware_cost(sizing, kind, *, material=, internals_material=, target_year=, structured_installation_factor=) feeds them to column_cost:

  • Basis. Shell, trays and random packing are Turton 5e Table A.1 purchased costs in 2001 dollars (carbon steel, near-ambient pressure; the loose-packing line is the polyethylene basis). Structured packing is Seider 4e’s stainless \(285/ft\)^3$, an installed cost in 2013 dollars; with no source for its installation factor it is left installed and flagged (internals_installed = 1) unless you pass structured_installation_factor. Everything is escalated by CEPCI to target_year (default the current year), returned as cost_year; the text basis is in HARDWARE_COST_BASIS.

  • Materials multiply the purchased cost (recalled from Turton 5e Tables A.3 and A.6, not looked up): shell SHELL_MATERIAL_FACTORS (carbon steel 1.0, stainless clad 1.7, stainless 3.1, nickel alloy 7.1, titanium 9.4, …); trays TRAY_MATERIAL_FACTORS (carbon steel 1.0, stainless 1.8, nickel alloy 5.6); random packing PACKING_MATERIAL_FACTORS (polyethylene/plastic 1.0, ceramic 4.1, stainless/metal 7.1). The default keeps each correlation’s own basis, so metal rings need internals_material="metal" (about 7x the plastic price). A number is accepted in place of a key.

The Fair chart needs a surface tension: difflow.liquid_properties.surface_tension(species, T) (IAPWS for water, Brock-Bird for the rest, with a SurfaceTensionWarning for alcohols and acids, where it fails) and mixture_surface_tension (mole-fraction average) supply it.

import jax
from difflow.mass_transfer import PACKINGS
from difflow.units.column_design import (
    packed_column_diameter, size_column, column_hardware_cost, hetp,
)

pk = PACKINGS["pall_metal_25"]       # packing factor from memory: check a data sheet
air = dict(V_molar=100.0, MW_V=29.0, rho_V=1.2, L_molar=500.0, MW_L=18.0,
           rho_L=1000.0, mu_L=1.0e-3)
D, info = packed_column_diameter(**air, packing=pk, fraction_flood=0.7)
print(f"D = {float(D):.2f} m, dP = {float(info['dP_per_m']):.0f} Pa/m "
      f"(flood {float(info['dP_flood_per_m']):.0f}), wetting margin {float(info['wetting_margin']):+.2f}")
D_r, _ = packed_column_diameter(**air, packing=pk, fraction_flood=0.7, flood="robbins")
print(f"Robbins/Kister-Gill flood: D = {float(D_r):.2f} m")

def cost(ff):
    D, _ = packed_column_diameter(**air, packing=pk, fraction_flood=ff)
    sizing = size_column(10.0, 1.0, D, hetp=hetp("random", d_p=pk.d_p))
    return column_hardware_cost(sizing, "packing_random", internals_material="metal")["total"]
print(float(cost(0.7)), float(jax.grad(cost)(0.7)))

Packing Data#

The packing record is difflow.mass_transfer.PackingData (d_p, a_p, void fraction, critical surface tension, packing factor F_p) with the table PACKINGS shared with the Onda functions: ceramic Raschig and Berl 25 mm, Pall rings in metal (25, 38, 50 mm) and plastic (25 mm), ceramic Intalox saddles (25, 38, 50 mm) and Mellapak 250Y (structured; d_p = 0, so the random-packing Onda functions do not apply to it). Every value is from memory and unverified (PackingData.source says so): packing factors after Treybal / Perry, areas after Billet and Schultes. Build your own PackingData from a vendor data sheet for a real design. difflow.units.column_design.DRY_PACKING_FACTORS holds the Robbins dry packing factors \(F_{pd}\) from Perry’s 8e Tables 14-13/14-14 for the metal Pall rings and the ceramic Raschig, Berl and Intalox packings.


Humidification and Cooling Towers#

Two things live here: difflow.psychrometrics, a differentiable humid-air property library, and the CoolingTower palette unit, a counterflow tower designed and rated with the Merkel equation. The water correlations they share (saturation pressure over liquid and ice, latent heat, liquid enthalpy) are in difflow.water; difflow.units.evaporator re-exports its old names.

Psychrometrics#

Location: difflow/psychrometrics.py (pure JAX, SI; a library, so nothing is registered in the palette). T is in K, P in Pa, enthalpy in J per kg of dry air, the humidity ratio W in kg water per kg dry air, the specific volume in m\(^3\) per kg dry air.

Model. An ideal-gas mixture of dry air and water vapor with the ASHRAE Fundamentals (Ch. 1) constants:

\[W = 0.621945\,\frac{p_w}{P - p_w},\qquad h = c_{pa}\,t + W\,(h_{fg0} + c_{pv}\,t),\qquad v = \frac{R_{da} T}{P}\Big(1 + \frac{W}{0.621945}\Big),\]

with \(c_{pa} = 1006\), \(c_{pv} = 1860\) J/kg/K, \(h_{fg0} = 2501\) kJ/kg and \(t\) in degrees C. The saturation pressure is IAPWS-IF97 over liquid water above the triple point and the IAPWS-2008 sublimation formula over ice below it, joined by a C2 blend over \(\pm 0.25\) K (no Python if on a traced temperature). No enhancement factor is applied, so \(W_s\) sits 0.3-0.5 % below the real-gas ASHRAE table at 1 atm (the pressure P is an argument of every function that needs it).

Pick any two properties. state fixes a state point from any two independent properties of T (dry bulb), W, RH, T_wb, T_dp and h (W with T_dp is not independent and is refused) and returns all of them, plus v, p_w, W_sat and the degree of saturation mu:

import jax
from difflow.psychrometrics import state

s = state(T=303.15, RH=0.5)                       # 30 C, 50 % RH
print(f"W = {float(s['W']) * 1e3:.2f} g/kg   h = {float(s['h']) / 1e3:.1f} kJ/kg")
print(f"dew point = {float(s['T_dp']) - 273.15:.1f} C   wet bulb = {float(s['T_wb']) - 273.15:.1f} C")

back = state(T_wb=s["T_wb"], h=s["h"])            # the same point from a different pair
print(f"round trip: T = {float(back['T']) - 273.15:.3f} C, RH = {float(back['RH']):.3f}")

# everything is differentiable, through the wet-bulb root find as well
dTwb_dRH = jax.grad(lambda rh: state(T=303.15, RH=rh)["T_wb"])(0.5)
print(f"dT_wb/dRH = {float(dTwb_dRH):.2f} K per unit RH")

The individual functions are humidity_ratio(p_w, P), humidity_ratio_from_rh, rh_from_humidity_ratio, saturation_humidity_ratio(T, P), dew_point(W, P), humid_enthalpy(T, W), humid_heat(W), humid_volume(T, W, P), saturation_enthalpy(T, P), wet_bulb_temperature(T, W, P, lewis=1.0) and adiabatic_saturation_temperature(T, W, P).

Wet bulb vs. adiabatic saturation (the Lewis relation). The adiabatic saturation temperature \(T^*\) is the exact enthalpy balance \(h(T, W) = h_s(T^*) - (W_s^* - W)\,h_\ell(T^*)\). The psychrometer wet bulb is the transfer balance \(\mathrm{Le}_f\,(c_{pa} + W c_{pv})(T - T^*) = (W_s^* - W)\,h_{fg}(T^*)\), where lewis is the Lewis factor \(\mathrm{Le}_f = h_c/(k_y c_s)\). At \(\mathrm{Le}_f = 1\) the two are the same number for this ideal-gas model, and lewis above (below) 1 moves the wet bulb toward (away from) the dry bulb:

from difflow.psychrometrics import (state, wet_bulb_temperature,
                                    adiabatic_saturation_temperature)

s = state(T=308.15, RH=0.30)
T, W = s["T"], s["W"]
print(f"adiabatic saturation {float(adiabatic_saturation_temperature(T, W)) - 273.15:.3f} C")
for le in (0.8, 1.0, 1.2):
    print(f"wet bulb, Le_f = {le}: {float(wet_bulb_temperature(T, W, lewis=le)) - 273.15:.3f} C")

Below the triple point the wick carries ice: the saturation pressure is the ice curve and the latent heat includes the heat of fusion (333.5 kJ/kg, recalled).

Processes. mix_air(states, dry_air_flows) conserves dry air, water and enthalpy exactly (and reports mu > 1 when the mixture fogs); sensible_heat(state, Q, m_dry_air); cool_dehumidify(state, T_coil, bypass_factor) for a coil with an apparatus dew point (it returns the leaving state and the sensible, latent and total duty and the condensate). A cold coil with a small bypass factor puts the straight line to the apparatus dew point above the saturation curve; the coil then delivers saturated air and adds the would-be fog to the condensate (info["fog"]), so the leaving RH never exceeds

  1. adiabatic_humidify(state, effectiveness=, W_out=, RH_out=, mu_out=) moves along the adiabatic-saturation line to a fraction of saturation, a humidity ratio, a relative humidity or a degree of saturation (Geankoplis’s “percentage humidity”: air at 87.8 C and H = 0.030 humidified to mu_out=0.9 leaves at 42.3 C with H = 0.0501, against 42.5 C and 0.0500 read from the chart in Geankoplis Ex. 9.3-2). A cold-room chain (cool and dehumidify, then reheat):

from difflow.psychrometrics import state, cool_dehumidify, sensible_heat

warm = state(T=303.15, RH=0.6)                          # 30 C, 60 % RH, 1 kg/s dry air
cold, coil = cool_dehumidify(warm, T_coil=283.15, bypass_factor=0.1)
final = sensible_heat(cold, Q=3.0e3, m_dry_air=1.0)     # 3 kW of reheat
print(f"coil leaves {float(cold['T']) - 273.15:.1f} C at {float(cold['RH']):.0%} RH; "
      f"duty {float(coil['Q_total']) / 1e3:.1f} kW, latent share {float(coil['Q_latent'] / coil['Q_total']):.0%}")
print(f"reheated to {float(final['T']) - 273.15:.1f} C at {float(final['RH']):.0%} RH "
      f"(condensate {float(coil['condensate']) * 1e3:.2f} g/kg)")

The air stream convention. Humid air flows through a flowsheet as an ordinary stream with two species, H2O (vapor) and air (dry air as a single pseudo-species, molar mass 28.966 g/mol): {"F_H2O", "F_air", "T", "P"} in mol/s, K and Pa. air_stream_from_state(state, m_dry_air) builds one from a state and a dry-air mass flow (kg/s); state_from_air_stream(stream) reads the state back; dry_air_flow(stream) is the dry-air mass flow. Every non-H2O species counts as dry air, so an N2/O2 stream also works, and a stream with no dry air gives W = 0, not NaN. Use species_order=["H2O", "air"] for a flowsheet that carries them.

from difflow.psychrometrics import (state, air_stream_from_state,
                                    state_from_air_stream, dry_air_flow)

air = air_stream_from_state(state(T=305.0, RH=0.45), m_dry_air=12.5)
print({k: round(float(v), 3) for k, v in air.items()})
print(f"{float(dry_air_flow(air)):.2f} kg/s dry air, "
      f"T_wb = {float(state_from_air_stream(air)['T_wb']) - 273.15:.2f} C")

The chart. psychrometric_chart_data(T_range, P, rh_lines, wb_lines, h_lines, v_lines) returns each family as NumPy lines (W against T, NaN outside the physical region) and difflow.visualization.plot_psychrometric_chart(data, processes=[...]) draws them with matplotlib (optional), overlaying process lines with arrows. Relative humidity is solid, wet bulb dashed, enthalpy dash-dot and volume dotted, so the families differ by line style as well as colour.

import matplotlib.pyplot as plt
from difflow.psychrometrics import (state, cool_dehumidify, sensible_heat,
                                    psychrometric_chart_data)
from difflow.visualization import plot_psychrometric_chart

warm = state(T=303.15, RH=0.6)
cold, _ = cool_dehumidify(warm, 283.15, 0.1)
ax = plot_psychrometric_chart(
    psychrometric_chart_data(T_range=(258.15, 313.15)),
    processes=[{"states": [warm, cold, sensible_heat(cold, 3e3)], "label": "cold room"}])
plt.show()
from difflow.psychrometrics import psychrometric_chart_data

d = psychrometric_chart_data()
print({k: len(d[k]) for k in ("rh", "wb", "h", "v")})

What was and was not verified. There is no humid-air table in the repository and the textbook examples named in the issue could not be checked, so nothing here is a transcribed table value. When the model was written, it was compared against an independent NumPy implementation of the Hyland-Wexler saturation pressure and the ASHRAE formulas (constants recalled), round trips for every pair of inputs, the Lewis identity, mixing and coil energy balances, jax.test_util.check_grads of \(W\), \(h\), \(T_{wb}\) and \(T_{dp}\) with respect to \(T\), RH and \(P\), and a few familiar state points (30 C / 50 % RH has a dew point of 18.4 C and a wet bulb of 22.0 C). Saturated-air \(W_s\) at 0, 20 and 30 C is compared with ASHRAE values recalled from memory at 1 %, not the 0.5 % the issue asked for: this module has no enhancement factor, and a 0.5 % comparison needs the transcribed table. The ice-line coefficients and the heat of fusion are recalled and flagged as such.

CoolingTower#

Location: difflow/units/cooling_tower.py

Class: CoolingTower

Description: Counterflow cooling tower sized or rated with the Merkel equation.

Process Role#

Hot process water is cooled by air in a packed fill, mostly by evaporating a percent or two of it. The tower has two inlets, water_in and (optionally) air_in, and two outlets, water_out and air_out. Merkel combined the sensible and latent transfer into one enthalpy driving force:

\[\frac{K a V}{L} = \int_{T_{out}}^{T_{in}} \frac{c_{pw}\,dT}{h_s(T) - h_a(T)}, \qquad h_a(T) = h_{a,in} + \frac{L}{G}\,c_{pw}\,(T - T_{out}),\]

where \(h_s\) is the enthalpy of air saturated at the water temperature. \(KaV/L\) (dimensionless) is the size of the fill. mode="design" takes the outlet water temperature (T_out, or an approach above the wet bulb) and computes \(KaV/L\); mode="rating" takes \(KaV/L\) (directly, as Ka and V, or from the fill characteristic \(C\,(L/G)^{-n}\)) and finds T_out by a bisection that differentiates by the implicit function theorem. Without an air stream the unit takes L_over_G and the wet bulb T_wb (a call argument) and assumes the inlet air is saturated at the wet bulb, as the textbook examples do; with one it reads L/G from the two flows and the inlet enthalpy and wet bulb from the state.

quadrature="cti" (the default) is the Cooling Technology Institute four-point method: the integrand \(1/(h_s - h_a)\) is averaged at 10, 40, 60 and 90 % of the range and multiplied by \(c_{pw}(T_{in} - T_{out})\). quadrature="gauss" is Gauss-Legendre with n_quad points, better when the approach is close. The air leaves saturated (Merkel’s assumption), which gives the evaporation \(E = G(W_{out} - W_{in})\); the make-up is \(E + D + B = E\,C/(C-1)\) for the drift \(D\) (drift_fraction) and blowdown \(B\) at cycles_of_concentration \(C\).

Feasibility. If \(L/G\) is too high for the approach the operating line touches the saturation curve and \(h_s - h_a \to 0\): the integral is infinite. The unit checks the driving force on a 65-point grid and returns KaV_L = inf with info["feasible"] = False, never a negative or NaN number; all other outputs and the gradients stay finite. info["L_over_G_max"] is the pinch limit (also maximum_L_over_G).

import numpy as np
from difflow import CoolingTower, CoolingTowerParams, make_stream
from difflow.psychrometrics import MW_WATER

K = 273.15
L = 10e6 / (4186.0 * 8.0)                                  # 10 MW at a range of 8 K: 298.5 kg/s
hot = make_stream({"H2O": L / MW_WATER}, 35.0 + K, 3e5)

for lg in (0.8, 1.0, 1.2, 1.4):
    tower = CoolingTower(CoolingTowerParams(mode="design", L_over_G=lg, T_out=27.0 + K))
    cold, air_out, info = tower(hot, T_wb=22.0 + K)
    print(f"L/G = {lg}: KaV/L = {float(info['KaV_L']):.3f}  (pinch limit L/G = {float(info['L_over_G_max']):.2f})")

print(f"approach {float(info['approach']):.1f} K, effectiveness {float(info['effectiveness']):.2f}, "
      f"evaporation {float(info['evaporation']):.2f} kg/s ({float(info['evaporation_fraction']):.2%}), "
      f"make-up {float(info['makeup']):.2f} kg/s")

The example values at \(L/G\) = 0.8, 1.0, 1.2 and 1.4 (1.24, 1.39, 1.59, 1.89) are the issue’s own illustration (ideal-gas humid air, IAPWS saturation pressure, air saturated at 22 C, trapezoid rule), not published numbers; when the model was written they were reproduced to 1 % and against an independent NumPy trapezoid integration of the same \(h_s\) to \(10^{-4}\).

Rating the same tower on a hot day, and the approach-versus-size trade-off (the gradient of the fill volume with respect to the approach):

import jax
from difflow import CoolingTower, CoolingTowerParams, make_stream
from difflow.psychrometrics import MW_WATER

K = 273.15
L = 10e6 / (4186.0 * 8.0)
hot = make_stream({"H2O": L / MW_WATER}, 35.0 + K, 3e5)

# a fixed tower (KaV/L = 1.6 at L/G = 1.2) on a 22 C and on a 26 C wet-bulb day
rate = CoolingTower(CoolingTowerParams(mode="rating", L_over_G=1.2, KaV_L=1.6))
for twb in (22.0, 26.0):
    _, _, info = rate(hot, T_wb=twb + K)
    print(f"wet bulb {twb} C -> cold water {float(info['T_out']) - K:.2f} C")

# fill volume V = (KaV/L) L / Ka for a tighter approach
def volume(approach):
    t = CoolingTower(CoolingTowerParams(mode="design", L_over_G=1.2, approach=approach, Ka=1.0))
    return t(hot, T_wb=22.0 + K)[2]["V"]

print(f"dV/d(approach) at 5 K = {float(jax.grad(volume)(5.0)):.0f} m^3/K")

Parameters#

@dataclass
class CoolingTowerParams:
    mode: str = "design"          # | "rating"
    L_over_G: float = None        # water/dry-air mass ratio (without an air stream)
    KaV_L: float = None           # Merkel number (rating)
    Ka: float = None              # kg/(m^3 s), with V: KaV/L = Ka V / L (rating)
    V: float = None               # fill volume (m^3)
    fill_characteristic: list = None   # [C, n]: KaV/L = C (L/G)^-n (rating)
    T_out: float = None           # cold-water temperature (K) (design)
    approach: float = None        # T_out - T_wb (K) (design)
    P: float = 101325.0           # Pa
    quadrature: str = "cti"       # | "gauss"
    n_quad: int = 8               # Gauss points
    drift_fraction: float = 0.0002
    cycles_of_concentration: float = 4.0
    cp_water: float = 4186.0      # J/kg/K
    makeup_in_outlet: bool = True # outlet flow = inlet flow (basin after make-up)
    fill_pressure_drop: float = None     # Pa, given directly
    fill_loss_coefficient: float = None  # K_fill, velocity heads (needs frontal_area)
    frontal_area: float = None           # fill plan area (m^2)
    other_pressure_drop: float = None    # Pa: louvres, rain zone, eliminator, stack
    fan_efficiency: float = 0.65         # the Ch. 18 book assumption, not a sourced value
    fan_location: str = "induced"        # | "forced": which air the fan handles

Equations and Assumptions#

  • Merkel (1925): Lewis factor of 1, the air leaves saturated, the evaporated water is neglected in the air enthalpy balance (a 1-2 % effect that the Poppe method removes; Poppe and the e-NTU model are not included).

  • Constant water specific heat and constant \(L/G\) through the fill; ideal-gas humid air (difflow.psychrometrics).

  • The air-side pressure drop is an input, not a correlation (see Fan power below); the cold-water outlet is at the tower pressure (the basin), which is what closes a pumped loop.

  • The evaporation fraction comes out near the rule of thumb \(0.00085 \times 1.8 \times\) range (unverified, from recollection; checked only to 30 % when the model was written).

Fan power#

Give the air-side pressure drop and the tower reports the fan power, \(\dot W_{fan} = G\,v\,(\Delta p_{fill} + \Delta p_{other})/\eta_{fan}\), with \(v\) the humid volume (m³/kg dry air) of the leaving air (fan_location="induced") or the entering air ("forced"). The fill drop is given directly (fill_pressure_drop, Pa) or as a loss coefficient in velocity heads of the frontal flow, \(\Delta p_{fill} = K_{fill}\,G_m^2/(2\rho_{in})\) with \(G_m = G(1+W_{in})/A_{fr}\) (fill_loss_coefficient, frontal_area), which is just the definition of a loss coefficient: the drop goes as the square of the air rate, so the \(L/G\) trade-off reaches the fan. other_pressure_drop is the rest (louvres, rain zone, sprays, drift eliminator, stack). The info dict gains fill_pressure_drop, total_pressure_drop, air_volumetric_flow (m³/s), fan_power (W) and, with frontal_area, air_face_velocity and air_mass_velocity; without any pressure-drop input none of these appear and every other output is unchanged.

No published fill correlation is built in. The Kloppers & Kröger (2003, Appl. Therm. Eng. 23, 2201) splash/trickle/film loss-coefficient correlation is the intended one, but its constants could not be read from the paper when this was written, and none is taken from memory; take \(K_{fill}\) or \(\Delta p_{fill}\) from fill test data. The default fan_efficiency = 0.65 is the book’s Chapter 18 design assumption, not a vendor value.

import jax
from difflow import CoolingTower, CoolingTowerParams, make_stream
from difflow.psychrometrics import MW_WATER

K = 273.15
L = 10e6 / (4186.0 * 8.0)
hot = make_stream({"H2O": L / MW_WATER}, 35.0 + K, 3e5)

def fan_kW(lg):
    t = CoolingTower(CoolingTowerParams(mode="design", L_over_G=lg, T_out=27.0 + K,
                                        fill_loss_coefficient=20.0, frontal_area=100.0,
                                        other_pressure_drop=50.0))
    return t(hot, T_wb=22.0 + K)[2]["fan_power"] / 1e3

print(f"fan {float(fan_kW(1.2)):.1f} kW, d(fan)/d(L/G) = {float(jax.grad(fan_kW)(1.2)):.1f} kW")

A cooling-water loop#

A closed loop conserves its flow, so a recycle through CoolingTower, CentrifugalPump and CounterCurrentHX needs something that sets the circulation rate, as a level controller does on a real basin: the tower with makeup_in_outlet=False returns the cold water short by the evaporation and drift and a make-up unit tops it up. Use species_order=["H2O"] for a water-only loop.

Gradients. Everything is differentiable: \(KaV/L\) with respect to \(L/G\), \(T_{in}\), \(T_{out}\) and the wet bulb, rating T_out with respect to \(KaV/L\), the pinch limit with respect to the wet bulb, and the whole recycle loop.


Drying#

Convective drying removes the water a solid holds into a stream of hot, dry air. Two things set the design: how fast the solid gives up its water (the drying curve, a time for a batch tray and a residence time for a continuous dryer) and what the air can carry (the water and enthalpy balances, bounded by the dew point of the exhaust). difflow provides the drying-time and isotherm functions as a library and the Dryer unit that applies them to the humid-air streams of difflow.psychrometrics.

Drying-Time Functions#

Location: difflow/units/dryer.py

Functions: dry_basis, wet_basis, free_moisture, solids_moisture, equilibrium_moisture, water_activity, constant_drying_rate, heat_transfer_coefficient_tray, drying_rate_curve, drying_periods, drying_time, rotary_dryer_ua, heat_transfer_units

Moisture is on the dry basis, \(X\) kg water per kg bone-dry solid (dry_basis(w) and wet_basis(X) convert); the equilibrium moisture \(X^*\) is what the solid holds in air of relative humidity \(a_w\), and the free moisture \(X - X^*\) is what can be removed. equilibrium_moisture(a_w, T, model, params) takes a GAB isotherm \(X = X_m C K a_w / [(1 - K a_w)(1 - K a_w + C K a_w)]\), a BET isotherm (GAB with \(K = 1\)), a monotone table or a callable; the parameters are data you supply (none are built in), and water_activity is the inverse (closed form for GAB and BET).

The drying rate \(R\) (kg water per m\(^2\) of surface per s) of a solid with loading \(L_s/A\) (kg dry solid per m\(^2\)) is constant at \(R_c = h\,(T - T_{wb})/\lambda_{wb}\) while the surface is wet (constant_drying_rate; the wet bulb comes from difflow.psychrometrics, the coefficient \(h\) from difflow.heat_transfer or the tray correlation heat_transfer_coefficient_tray(G, flow)), and falls linearly in the free moisture once \(X\) drops below the critical moisture \(X_c\):

\[t = \frac{L_s}{A\,R_c}\left[(X_1 - X_c) + (X_c - X^*)\ln\frac{X_c - X^*}{X_2 - X^*}\right]\]

(drying_time; falling="integral" replaces the logarithm by \((L_s/A)\int dX/R\) over a tabulated rate curve, and drying_periods returns the two periods separately). Between the table’s points the rate is interpolated linearly in \(R\) by default; interpolate="inverse" interpolates \(1/R\) instead, which makes the integral exactly the trapezoid rule on a \(1/R\) versus \(X\) plot, the graphical method of Geankoplis Example 9.7-1. The Fick-diffusion series for a slab is not implemented.

Rate-table units. The functions take rate_curve=(X, R) with \(R\) in kg/m\(^2\)/s, the units of R_c (rate_basis="absolute", the default), while DryerParams(rate_curve=...) takes the relative curve \(R/R_c\) (1 at \(X_c\)). Pass rate_basis="relative" to give the functions a relative table. A relative table read as absolute is \(1/R_c\) (about 2400 for 1.5 kg/h/m\(^2\)) too fast, so the falling period comes out seconds instead of hours; to catch that, a table whose maximum is outside 0.5-2 times R_c (absolute) or 0.5-2 (relative) raises difflow.units.dryer.RateTableWarning. The check runs on concrete values only; under jit the table is traced and is not checked.

from difflow import drying_periods
X_tab = [0.040, 0.050, 0.065, 0.100, 0.150, 0.195]
R_rel = [0.18, 0.25, 0.47, 0.60, 0.80, 1.00]           # R/R_c
t_c, t_f = drying_periods(0.38, 0.04, 0.195, 0.0, 1.51 / 3600, 21.5, "integral",
                          rate_curve=(X_tab, R_rel), rate_basis="relative", interpolate="inverse")

The smooth switch. Which terms apply depends on where \(X_c\) falls relative to \(X_1\) and \(X_2\): \(X_1 > X_c > X_2\), falling only (\(X_1 < X_c\)) and constant only (\(X_2 > X_c\)). The function is one expression with no if: with the softplus \(\mathrm{sp}(z) = s\ln(1 + e^{z/s})\) the constant range is \(\mathrm{sp}(X_1 - X_c) - \mathrm{sp}(X_2 - X_c)\) and the falling period runs from \(X_a = X_1 - \mathrm{sp}(X_1 - X_c)\) to \(X_b = X_a - \mathrm{sp}(X_a - X_2)\). The width is \(s = (X_c - X^*)/\texttt{sharpness}\) (default 200), so the result is within \(e^{-200|X - X_c|/(X_c - X^*)}\) of the exact piecewise answer away from the corner; exactly at the corner (\(X_1 = X_c\)) the first-order offsets of the two periods cancel and the error is second order in \(s\) (about \(5\times10^{-6}\) of the time on the problem measured when the model was written). Larger sharpness recovers the piecewise time, and the gradient with respect to \(X_c\), \(X_1\), \(X_2\), \(X^*\), \(R_c\) and \(L_s/A\) exists everywhere.

import math
import jax
from difflow import drying_time, equilibrium_moisture, water_activity

# drying time (hours): X1 = 0.35 -> X2 = 0.10, critical 0.20, equilibrium 0.05 (kg/kg dry solid)
X1, X2, Xc, Xe = 0.35, 0.10, 0.20, 0.05
Rc, LsA = 2.78e-4, 25.0              # kg/m^2/s, kg dry solid/m^2
t = drying_time(X1, X2, Xc, Xe, Rc, LsA)
exact = LsA / Rc * ((X1 - Xc) + (Xc - Xe) * math.log((Xc - Xe) / (X2 - Xe)))
print(f"t = {float(t) / 3600:.4f} h  (closed form {exact / 3600:.4f} h)")

# the critical moisture is a smooth regime boundary: dt/dXc and dt/dRc
dXc, dRc = jax.grad(drying_time, argnums=(2, 4))(X1, X2, Xc, Xe, Rc, LsA)
print(f"dt/dXc = {float(dXc) / 3600:.1f} h per kg/kg,  dt/dRc = {float(dRc) / 3600:.3e} h per (kg/m^2/s)")

# a GAB isotherm (illustrative parameters, NOT those of a real material) and its inverse
gab = {"X_m": 0.07, "C": 10.0, "K": 0.85}
X = equilibrium_moisture(0.5, 300.0, "gab", gab)
print(f"X_eq(a_w = 0.5) = {float(X):.4f}, back to a_w = {float(water_activity(X, 300.0, 'gab', gab)):.3f}")

The numbers are an invented illustration. When the model was written it reproduced Geankoplis (5e, Ch. 9) Examples 9.6-1 (constant rate from the tray correlation: wet bulb 28.9 C and \(R_c\) = 3.39 kg/h/m\(^2\) as in the book; the time for the constant-rate drop, 2.08 h), 9.7-1 (graphical falling-rate integral, 4.06 h with interpolate="inverse"), 9.7-2 (straight-line falling rate, 4.40 h against 4.39 h) and 9.10-1 (a countercurrent dryer balance with the product temperature specified: 1170 against 1171 kg dry air/h, exit humidity 0.0247). Those inputs and answers were typed from memory for the unit-operations book and should be checked against the printed book; the McCabe, Smith & Harriott (Ch. 24) examples are not reproduced. The tray correlations in heat_transfer_coefficient_tray, \(h = 0.0204\,G^{0.8}\) (parallel flow) and \(h = 1.17\,G^{0.37}\) (perpendicular), with \(G\) in kg/m\(^2\)/h and \(h\) in W/m\(^2\)/K, are typed from memory and unverified; check them against the book.

Dryer#

Location: difflow/units/dryer.py

Class: Dryer

Description: Tray (batch) or continuous (countercurrent or cocurrent) convective dryer with air balances.

Process Role#

Two inlets (wet_solids, air_in) and two outlets (dry_solids, air_out).

Streams. Air is the humid-air stream of difflow.psychrometrics (F_H2O, F_air, T, P, mol/s). Solids are a stream with a dry-solid pseudo-species F_solid and F_H2O, both in mol/s: the dry solid has no molar mass, so solid_molar_mass (kg/mol, default 1) converts, and with the default F_solid is numerically kg/s of dry solid while F_H2O is mol/s like every other water stream (the moisture is \(X = F_{H_2O} M_w / (F_{solid} M_s)\)). This differs from Leacher, whose flows are all kg/s. Any species of air_in beyond H2O is counted as dry air and passed through.

Batch (mode="batch"): a tray dryer. The air at the inlet state flows over a tray of loading \(L_s/A\) = load/area at the constant rate \(R_c\) (R_c, or h, or air_mass_velocity through the tray correlation); the unit returns the drying time to product_moisture (or the moisture reached in residence_time), the cycle time with turnaround_time, the tray area_required for the stream’s throughput, and the exhaust air from the water and enthalpy balances over the drying step (the air flow is the flow while the fan runs; the humidity rise across the trays is assumed small and is reported). Continuous: the overall water balance \(G\,(W_{out} - W_{in}) = L_s\,(X_{in} - X_{out})\) and the enthalpy balance in humid-air and solids enthalpy fix the exhaust state; the solids leave at \(T_{s,out} = T_a - (T_a - T_{wb})\,R(X_{out})/R_c\) evaluated in the air at their exit end (the inlet air for countercurrent, the outlet air for cocurrent, which is solved by bisection with an implicit gradient). With solids_per_area the unit also reports the required residence time, taking the constant rate in the air at the wet end of the dryer, so countercurrent is slower on the constant rate but hotter at the product end.

info carries the time or residence time, the constant/falling split, the exhaust state, the dew-point margin (exhaust dry bulb minus its dew point, with a supersaturated flag), the product-temperature margin against max_solids_T, the heat duty Q_air (alias heater_duty) of heating the air at the inlet humidity from T_heater_in (default T_ambient, i.e. once-through air), energy_per_kg water evaporated (Q_air / evaporation), thermal_efficiency, balance residuals recomputed from the streams, and for the continuous dryer fines (the fraction entrainment of the dry solids split off at the product moisture and temperature; a downstream cyclone takes it from there).

Energy basis with recirculation. By default the heater is charged for warming fresh air from T_ambient. When part of the exhaust is recirculated the heater warms the mixed air instead, and the default basis overstates the duty many times (about 40 against 6.5 MJ/kg at 90% recirculation on a tray dryer). Pass the mixed temperature as T_heater_in (K; the heater adds no water, so its inlet humidity is the dryer’s inlet humidity, the converged mixed humidity) and Q_air/heater_duty, energy_per_kg and thermal_efficiency are on the heater’s real duty. T_ambient stays the reference of the latent heat in the efficiency.

from difflow import Dryer, DryerParams
from difflow.psychrometrics import air_stream_from_state, state, MW_WATER

K = 273.15
air = air_stream_from_state(state(T=100.0 + K, W=0.01), 10.0)        # 10 kg/s dry air at 100 C
wet = {"F_solid": 0.5, "F_H2O": 0.5 * 0.30 / MW_WATER, "T": 300.0, "P": 101325.0}   # 0.5 kg/s dry, X = 0.30

for flow in ("countercurrent", "cocurrent"):
    dryer = Dryer(DryerParams(mode="continuous", flow=flow, X_c=0.15, X_eq=0.02, product_moisture=0.05,
                              h=60.0, solids_per_area=10.0, max_solids_T=340.0))
    dry, exhaust, info = dryer(wet, air)
    print(f"{flow:15s} exhaust {float(info['T_air_out']) - K:5.1f} C, product {float(info['T_solids_out']) - K:5.1f} C "
          f"(margin {float(info['solids_T_margin']):6.1f} K), dew-point margin {float(info['dew_point_margin']):.1f} K, "
          f"residence {float(info['residence_time_required']) / 60:.0f} min, "
          f"{float(info['energy_per_kg']) / 1e6:.2f} MJ/kg water")

A tray dryer: the time to dry a load, and how it responds to the air (the gradients are exact, through the wet-bulb solve and the smooth switch):

import jax
from difflow import Dryer, DryerParams
from difflow.psychrometrics import air_stream_from_state, state, MW_WATER

K = 273.15
wet = {"F_solid": 0.01, "F_H2O": 0.01 * 0.35 / MW_WATER, "T": 295.0, "P": 101325.0}

def drying_time(T_air, air_velocity):
    tray = Dryer(DryerParams(mode="batch", X_c=0.20, X_eq=0.05, product_moisture=0.10,
                             air_mass_velocity=air_velocity, load=12.5, area=0.5))
    air = air_stream_from_state(state(T=T_air, W=0.01), 0.5)
    return tray(wet, air)[2]["drying_time"]

t = drying_time(70.0 + K, 1.0)
dT, dG = jax.grad(drying_time, argnums=(0, 1))(70.0 + K, 1.0)
print(f"drying time {float(t) / 3600:.2f} h; d(time)/dT = {float(dT) / 3600:.3f} h/K, "
      f"d(time)/d(air mass velocity) = {float(dG) / 3600:.2f} h per kg/m^2/s")

Design with gradients. A countercurrent dryer of fixed size (residence time 4000 s) must dry the solids to the spec. For each inlet temperature the air flow that just does it is solved for (with an implicit-function gradient); hotter air needs less of it, so the energy per kg of water falls with the temperature until the product reaches max_solids_T, which is added to the objective as a soft constraint. The optimum is found by bisection on the sign of the exact gradient:

import jax
from difflow import Dryer, DryerParams
from difflow.psychrometrics import air_stream_from_state, state, MW_WATER
from difflow.staged import _solve_monotone

K = 273.15
wet = {"F_solid": 0.5, "F_H2O": 0.5 * 0.30 / MW_WATER, "T": 300.0, "P": 101325.0}
T_max, t_avail = 350.0, 4000.0
dryer = Dryer(DryerParams(mode="continuous", flow="countercurrent", X_c=0.15, X_eq=0.02,
                          product_moisture=0.05, h=60.0, solids_per_area=10.0, max_solids_T=T_max))

def run(T, G):
    return dryer(wet, air_stream_from_state(state(T=T, W=0.01), G))[2]

def air_flow(T):                      # the air flow (kg/s) that dries the solids in t_avail
    return _solve_monotone(lambda G: run(T, G)["residence_time_required"] - t_avail, 4.0, 60.0)

def objective(T):                     # MJ/kg water + a soft limit on the product temperature
    info = run(T, air_flow(T))
    return info["energy_per_kg"] / 1e6 + 5.0 * jax.nn.softplus((info["T_solids_out"] - T_max) / 0.1)

grad = jax.jit(jax.grad(objective))
lo, hi = 350.0, 420.0
for _ in range(20):
    T = 0.5 * (lo + hi)
    lo, hi = (T, hi) if grad(T) < 0.0 else (lo, T)
T = 0.5 * (lo + hi)
info = run(T, air_flow(T))
print(f"inlet {T - K:.1f} C with {float(air_flow(T)):.1f} kg/s of air: product {float(info['T_solids_out']) - K:.1f} C "
      f"(limit {T_max - K:.1f} C), {float(info['energy_per_kg']) / 1e6:.2f} MJ/kg water")

Rotary-dryer sizing#

A direct-heat rotary dryer is sized by heat transfer (McCabe, Smith & Harriott 7e, Ch. 24). The air cools from \(T_{in}\) to \(T_{out}\) against solids that sit at the wet bulb \(T_{wb}\) through most of the shell; the number of heat-transfer units \(N_t = \ln[(T_{in} - T_{wb})/(T_{out} - T_{wb})]\) (heat_transfer_units, in info["N_t"] of a continuous dryer) is usually 1.5-2.5. With diameter set, a continuous Dryer (either flow) splits the shell into three zones: I heats the wet feed to \(T_{wb}\), \(q_I = m_s(c_{ps} + X_{in}c_{pw})(T_{wb} - T_{s,in})\); II evaporates the water at \(T_{wb}\), \(q_{II} = m_s(X_{in} - X_{out})\lambda(T_{wb})\); III heats the dry product to \(T_{s,out}\), \(q_{III} = m_s(c_{ps} + X_{out}c_{pw})(T_{s,out} - T_{wb})\). In a cocurrent dryer the zones run in the same order along the shell but the hot air meets zone I. The air temperatures between the zones (zone_air_T, at the I/II and II/III boundaries) follow from the humid heat, and each zone length is \(L_i = q_i/(U_a S\,\Delta T_{lm,i})\) with \(U_a = 237\,G^{0.67}/D\) W/m\(^3\)/K (rotary_dryer_ua; \(G\) the air mass velocity in kg/m\(^2\)/s over the cross-section \(S\)). This is the SI form of MSH’s \(0.5\,G^{0.67}/D\) in US units, recalled from memory. info gains zone_duties, zone_lengths, zone_dT_lm, zone_air_T, Ua, length, volume and L_over_D (typically 4-10). T_solids_out fixes the product temperature when it is known, as in Geankoplis Example 9.10-1, instead of estimating it from the falling rate.

from difflow import Dryer, DryerParams
from difflow.psychrometrics import air_stream_from_state, state, MW_WATER

K = 273.15
wet = {"F_solid": 0.126, "F_H2O": 0.126 * 0.04 / MW_WATER, "T": 26.7 + K, "P": 101325.0}
air = air_stream_from_state(state(T=93.3 + K, W=0.010), 0.325)      # kg/s dry air
dryer = Dryer(DryerParams(mode="continuous", X_c=0.03, X_eq=0.0, product_moisture=0.002, h=50.0,
                          T_solids_out=62.8 + K, solids_cp=1465.0, diameter=1.0))
_, _, info = dryer(wet, air)
print(f"N_t = {float(info['N_t']):.2f}, zones {[round(float(L), 2) for L in info['zone_lengths']]} m, "
      f"L = {float(info['length']):.2f} m (L/D = {float(info['L_over_D']):.1f})")

Parameters#

@dataclass
class DryerParams:
    mode: str = "batch"                # | "continuous"
    flow: str = "countercurrent"       # | "cocurrent" (continuous)
    X_c: float = 0.2                   # critical moisture, kg/kg dry solid
    X_eq: float = None                 # equilibrium moisture (else from the isotherm at the air RH, else 0)
    isotherm_model: str = None         # "gab" | "bet" | "table"
    isotherm_params: list = None       # [X_m, C, K] | [X_m, C] | [[a_w...], [X...]]
    R_c: float = None                  # constant rate, kg/m^2/s (else h (T - T_wb)/lambda)
    h: float = None                    # W/m^2/K (else from air_mass_velocity)
    air_mass_velocity: float = None    # kg/m^2/s, tray correlation
    tray_flow: str = "parallel"        # | "perpendicular"
    falling: str = "linear"            # | "integral" (with rate_curve = [[X...], [R/R_c...]])
    rate_curve: list = None
    sharpness: float = 200.0           # smooth-switch sharpness
    product_moisture: float = None     # spec, kg/kg dry solid
    residence_time: float = None       # s, instead of product_moisture
    load: float = None                 # kg dry solid per batch (batch)
    area: float = None                 # m^2 (batch); or solids_per_area
    solids_per_area: float = None      # Ls/A, kg/m^2 (continuous: reports the residence time)
    turnaround_time: float = 0.0       # s
    solid_species: str = "solid"       # stream key F_<name>
    solid_molar_mass: float = 1.0      # kg/mol (1: F_solid is kg/s)
    solids_cp: float = 1500.0          # J/kg/K
    cp_water: float = 4186.0
    max_solids_T: float = None         # K, reported as a margin
    heat_loss: float = 0.0             # W, used when adiabatic=False
    adiabatic: bool = True
    T_ambient: float = 293.15          # K, surroundings: efficiency reference, default heater inlet
    T_heater_in: float = None          # K, heater-inlet (e.g. mixed recirculated) air; None -> T_ambient
    entrainment: float = 0.0           # fraction of the dry solids carried out as fines (continuous)
    lewis: float = 1.0
    rate_interpolation: str = "rate"   # | "inverse" (1/R linear between rate_curve points)
    T_solids_out: float = None         # K, product temperature specified instead of estimated
    diameter: float = None             # m, three-zone rotary sizing (continuous, either flow)
    ua_coefficient: float = 237.0      # SI prefactor of Ua = c G^0.67 / D

Equations and Assumptions#

  • Constant-rate period at the wet-bulb temperature, heat-transfer limited (no radiation or conduction from the tray); a falling rate linear in the free moisture, or a tabulated relative rate curve; the constant/falling switch is smooth (above).

  • Batch: air at the inlet state over the whole tray. The solids stream’s flow is the throughput and passes through; the exhaust flows are those of the drying step.

  • Continuous: the product-temperature estimate above; no heat of wetting; constant solid and liquid-water specific heats; air out must stay above its dew point (reported, not enforced).

  • Rotary sizing: Ua from the recalled MSH correlation (unverified), solids at the wet bulb through zone II, constant heat capacities, no heat loss in the zone split.

  • Not implemented: the Fick-diffusion series, deep-bed humidity profiles, spray drying, fluid-bed hydrodynamics.

  • psychrometrics.wet_bulb_temperature now starts its Newton solve below the boiling point, so it works for the 100-200 C air of a dryer (it returned NaN before).

Gradients. Drying time, energy per kg, the exhaust and product temperatures are differentiable with respect to the air temperature, humidity and flow, the air velocity (through \(h\)), \(X_c\) and the load, and the rotary length with respect to the air flow and the diameter.


Crystallization#

Crystallization separates a solute as a solid by making the solution supersaturated, by cooling, by evaporating solvent, or both. Two questions set the design: how much solid forms (the yield from the solubility curve, including a hydrate that takes water of crystallization into the crystal) and how big the crystals are (nucleation and growth kinetics and the population balance). difflow provides the solubility, yield and MSMPR functions as a library and the Crystallizer unit that applies them to a stream. The REE oxalate, carbonate and hydroxide precipitators in unit-operations-ree (difflow_ree) are the reactive precipitation version (a solubility-product-driven conversion, no size distribution); the Crystallizer is the generic solubility-driven one.

No solubility data, kinetic constant or hydrate transition temperature is built in. Every curve and constant in this section is synthetic and labelled illustrative. When the model was written, the closed forms were checked against independent oracles, and the yield balance against Geankoplis Example 12.11-1 (Na2CO3.10H2O from a cooled 30 wt% solution: 6360 and 6635 kg vs the printed 6370 and 6630 kg; the printed values are recalled).

Crystallizer#

Location: difflow/units/crystallizer.py

Class: Crystallizer

Description: Solubility-driven crystallizer, cooling, evaporative or both, with an equilibrium yield (a hydrate or an anhydrous crystal) or an MSMPR kinetic mode.

Process Role#

One inlet (the feed) and two outlets (crystals, mother_liquor), plus an info dictionary. An Evaporator concentrate goes straight in (give the crystallizer the evaporator’s solvent name), or the unit evaporates itself (mode="evaporative", "cooling_evaporative") and reports the evaporation duty with difflow.water.water_latent_heat. Palette category: crystallization (the one category of this unit, as evaporation and drying are for theirs).

Streams. mol/s, K, Pa. The crystal stream carries the hydrate as solute plus bound water: F_<solute> is the moles of anhydrous solute in the crystals and F_<solvent> is \(\nu\) times that, the water of crystallization. Every species appears in every outlet (zero where absent), so the mole balance closes species by species and a recycle keeps one species list. The water evaporated leaves as vapor (in info, not a stream).

Yield balance with hydrates#

With \(S_0\) the feed solute, \(W_0\) the feed solvent, \(E\) the solvent evaporated (\(W = W_0 - E\); kg/s) and a crystal of solute mass fraction \(f_s = M_s/(M_s + \nu M_w)\) and water fraction \(f_w = 1 - f_s\), the solute and water balances

\[S_0 = c\,W_L + f_s C, \qquad W = W_L + f_w C\]

with the liquor saturated, \(c = c_{sat}(T)\) (kg anhydrous solute per kg solvent), give the hydrate mass flow in closed form

\[C = \frac{S_0 - c_{sat}\,W}{f_s - c_{sat}\,f_w}.\]

The numerator is clipped at zero with a softplus of width soft \(\cdot S_0\) (default \(10^{-6}\)), so an undersaturated feed gives zero yield and a finite gradient with no Python if. A feed richer in solute than the hydrate stoichiometry would need an anhydrous solid; the balance then reports a negative liquor water.

Solubility Curves and Hydrate Forms#

Solubility is a pytree: Solubility.polynomial(coeffs) (in \(T - 273.15\)), Solubility.vant_hoff(A, B) (\(\ln c = A + B/T\)) or Solubility.table(T, c) (PCHIP), each with hydrate_waters, the molar masses and per_100g=True for data in g/100 g solvent. A second solid form (upper=, T_transition=, width=) is a smooth logistic blend of \(c_{sat}\) and \(\nu\) in \(T\) (never a Python if on a traced \(T\)); the explicit alternative is to pass the curve of the form you mean. The blend makes the crystal composition fractional within a few widths of the transition, which is an approximation.

import jax
from difflow import Solubility, crystal_balance, crystal_yield, supersaturation

# SYNTHETIC curve (illustrative, not data for any salt): c_sat = 0.35 + 0.0075 (T - 273.15)
sol = Solubility.polynomial([0.35, 0.0075], hydrate_waters=10, solute_MW=142.0)
S0, W0 = 0.60, 1.0                                   # kg/s solute, solvent in the feed
b = crystal_balance(S0, W0, 278.15, sol)
print(f"hydrate {float(b['crystals']):.4f} kg/s, yield {float(b['yield_fraction']):.3f} of the solute")
print(f"solute closes: {float(S0 - b['crystal_solute'] - b['liquor_solute']):.1e},"
      f" water closes: {float(W0 - b['crystal_water'] - b['liquor_water']):.1e}")
print("supersaturation of the feed at 5 C:", float(supersaturation(S0 / W0, 278.15, sol)))

# the yield is differentiable: dC/dT, dC/dE, dC/dS0
g = jax.grad(crystal_yield, argnums=(0, 2, 4))(S0, W0, 278.15, sol, 0.0)
print("dC/dS0, dC/dT, dC/dE =", [round(float(x), 4) for x in g])

# an undersaturated feed: zero yield, finite gradient
print(float(crystal_yield(0.1, 1.0, 278.15, sol)), float(jax.grad(crystal_yield, argnums=2)(0.1, 1.0, 278.15, sol)))

Parameters#

@dataclass
class CrystallizerParams:
    solute: str                         # stream key F_<solute>
    solute_MW: float                    # g/mol, anhydrous
    mode: str = "cooling"               # "cooling" | "evaporative" | "cooling_evaporative" | "adiabatic"
    T: float = None                     # K (evaporative: defaults to the feed T)
    water_evaporated: float = None      # kg/s ...
    evaporation_fraction: float = None  # ... or a fraction of the feed solvent
    solvent: str = "H2O"
    solvent_MW: float = 18.015
    solubility_model: str = "polynomial"   # "polynomial" | "vant_hoff" | "table"
    solubility_coeffs: list = None
    solubility_T: list = None
    solubility_c: list = None
    solubility_basis: str = "kg_per_kg"    # or "g_per_100g"
    hydrate_waters: float = 0.0
    upper_solubility_model: str = "polynomial"   # a second solid form above transition_T
    upper_solubility_coeffs: list = None
    upper_solubility_T: list = None
    upper_solubility_c: list = None
    upper_hydrate_waters: float = 0.0
    transition_T: float = None
    transition_width: float = 0.5          # K, smooth blend
    kinetics: list = None                  # [k_g, g, k_b, b, j] -> MSMPR kinetic mode
    residence_time: float = None           # s
    volume: float = None                   # m^3 (tau = volume / product slurry flow, (m_F - E)/rho_L)
    crystal_density: float = 2000.0        # kg/m^3
    shape_factor: float = 0.5236           # k_v, pi/6
    liquor_density: float = 1000.0
    cp_solution: float = 4186.0            # J/kg/K
    heat_of_crystallization: float = 0.0   # J per kg of crystal, released
    soft: float = 1e-6
    duty: float = 0.0                      # W, heat added in "adiabatic" mode
    kinetics_Ea: list = None               # [E_g, E_b] J/mol -> Arrhenius k_g(T), k_b(T)
    kinetics_T_ref: float = None           # K, where kinetics' k_g and k_b apply
    fines_ratio: float = None              # R >= 1, fines destruction below fines_cut_size (R-z)
    fines_cut_size: float = None           # L_F, m
    product_classification: float = None   # z >= 1, classified removal above product_cut_size
    product_cut_size: float = None         # L_P >= L_F, m

Equations and Assumptions#

  • Equilibrium mode: liquor saturated at the outlet temperature; a stoichiometric hydrate (or anhydrous) crystal. Kinetic mode: the liquor leaves at \(c_{sat} + \Delta C\) (below).

  • Duty \(Q = m_F c_p (T - T_F) + E\,h_{fg}(T) - \Delta H_c C\) (W; negative is cooling), with constant \(c_p\) and a user-supplied heat of crystallization (default 0).

  • Constant liquor density converts the liquor flow to a volume flow; no agglomeration, breakage, size-dependent growth or polymorph kinetics. Fines destruction and classified product removal are optional (the R-z model, under MSMPR Functions below); both are off by default.

  • mode="adiabatic" is a vacuum crystallizer: the feed flashes to T (set by the vapor-space pressure) and the solvent evaporated is solved from the same heat balance with \(Q\) = duty (0 by default): \(E = [Q - m_F c_p (T - T_F) + \Delta H_c (S_0 - c W_0)/d]/ [h_{fg} - \Delta H_c c/d]\), \(d = f_s - c f_w\), closed form because \(C\) is linear in \(E\). With \(\Delta H_c = 0\) it is the textbook flash \(E = m_F c_p (T_F - T)/h_{fg}\).

  • In kinetic mode volume sets \(\tau = V/Q_P\) with \(Q_P = (m_F - E)/\rho_L\), the product slurry flow: the evaporated solvent does not pass through the magma.

import jax
from difflow import Crystallizer, CrystallizerParams, make_stream

# synthetic curve and Na2SO4-like molar masses (illustrative only)
params = CrystallizerParams(solute="Na2SO4", solute_MW=142.0, mode="cooling", T=278.15,
                            solubility_coeffs=[0.35, 0.0075], hydrate_waters=10.0)
feed = make_stream({"Na2SO4": 0.60 * 1000 / 142.0, "H2O": 1.0 * 1000 / 18.015}, T=333.15, P=1e5)
crystals, liquor, info = Crystallizer(params)(feed)
print(f"yield {float(info['yield_kg_s']):.4f} kg/s of solute, hydrate {float(info['crystal_mass']):.4f} kg/s")
print(f"bound water / solute in the crystals = {float(crystals['F_H2O'] / crystals['F_Na2SO4']):.1f} mol/mol")
print(f"cooling duty {float(info['Q']) / 1e3:.1f} kW")

def yield_of_T(T):
    p = CrystallizerParams(solute="Na2SO4", solute_MW=142.0, T=T,
                           solubility_coeffs=[0.35, 0.0075], hydrate_waters=10.0)
    return Crystallizer(p)(feed)[2]["yield_kg_s"]

print("d(yield)/dT =", float(jax.grad(yield_of_T)(278.15)), "kg/s/K")

Evaporator then Crystallizer (the structure of exercise wex20-2, with synthetic data). The crystallizer takes the evaporator concentrate; the mother liquor can be recycled to the evaporator through a Mixer and a tear stream, and at steady state the crystals carry exactly the fresh solute:

from difflow import (Crystallizer, CrystallizerParams, Evaporator, EvaporatorParams, Mixer,
                     make_stream)
from difflow.flowsheet import Flowsheet, Unit

evap = Evaporator(EvaporatorParams(solute_species=["Na2SO4"], solute_MW=[142.0], solvent="water",
                                   x_product=0.30, bpr_model="ideal", vant_hoff_i=3.0,
                                   P_vapor_space=60e3, steam_P=300e3, U=1500.0))
xtal = Crystallizer(CrystallizerParams(solute="Na2SO4", solute_MW=142.0, solvent="water", T=278.15,
                                       solubility_coeffs=[0.35, 0.0075], hydrate_waters=10.0))
fresh = make_stream({"Na2SO4": 0.30 * 1000 / 142.0, "water": 1.7 * 1000 / 18.015}, T=313.15, P=1e5)

fs = Flowsheet(species_order=["water", "Na2SO4"])
fs.add_feed("feed", fresh)
fs.add_unit(Unit("mix", Mixer(["water", "Na2SO4"]), ["feed", "recycle"], ["evap_in"]))
fs.add_unit(Unit("evap", evap, ["evap_in"], ["conc", "vap"]))
fs.add_unit(Unit("xtal", xtal, ["conc"], ["crystals", "liquor"]))
fs.add_recycle("liquor", "recycle")
guess = {"recycle": make_stream({"Na2SO4": 0.5, "water": 40.0}, T=278.15, P=1e5)}
out = fs.solve(tear_initial=guess, tol=1e-12, max_iter=400, on_nonconvergence="raise")
print("crystals / fresh solute =", float(out["crystals"]["F_Na2SO4"] / fresh["F_Na2SO4"]))

MSMPR Functions#

Location: difflow/units/crystallizer.py

Functions: msmpr_distribution, msmpr_moments, msmpr_dominant_size, msmpr_mass_median_size, msmpr_mass_cv, msmpr_from_magma, fit_msmpr, sieve_population_density, growth_rate, nucleation_rate, msmpr_steady_state, arrhenius_factor, rz_distribution, rz_moments (the last three from difflow.units.crystallizer)

For a steady mixed-suspension, mixed-product-removal crystallizer with residence time \(\tau\), size-independent growth rate \(G\) and nuclei density \(n_0\) (Randolph & Larson):

\[n(L) = n_0\,e^{-L/(G\tau)}, \qquad m_k = \int_0^\infty L^k n\,dL = k!\,n_0\,(G\tau)^{k+1},\]

the suspension (magma) density is \(M_T = 6\rho_c k_v n_0 (G\tau)^4\), the dominant (mass-mode) size, the maximum of \(L^3 n(L)\), is \(L_D = 3G\tau\), and the mass median size \(L_{50} = x_{50}\,G\tau\) with \(x_{50} = 3.672\ldots\) the root of the cumulative mass fraction of the gamma(4) law at one half; msmpr_mass_median_size computes it (Newton on the gamma(4) law), it is not typed in. The coefficient of variation of the mass distribution is \(1/\sqrt{4} = 50\%\). msmpr_from_magma(M_T, rho_c, k_v, G, tau) gives \(n_0\) from a measured magma density and fit_msmpr(L, n) is the “\(\ln n\) vs. \(L\)” line (slope \(-1/(G\tau)\), intercept \(\ln n_0\)). A measured product is usually a screen analysis: sieve_population_density(w, L_upper, L_lower, M_T, rho_c, k_v) turns the mass fraction \(w_i\) on each screen into \(n_i = M_T w_i/(\rho_c k_v \bar L_i^3 \Delta L_i)\) at the mean size \(\bar L_i\) of the cut, ready for fit_msmpr (McCabe, Smith & Harriott Ch. 27).

Kinetics. With the absolute supersaturation \(\Delta C = c - c_{sat}\) (kg solute per kg solvent), \(G = k_g\,\Delta C^{g}\) (growth_rate) and \(B_0 = k_b\,M_T^{\,j}\,\Delta C^{b}\) (nucleation_rate, \(M_T\) in kg of crystal per m\(^3\) of liquor), \(B_0 = n_0 G\). msmpr_steady_state closes them: the solute balance gives \(M_T(\Delta C)\), the third moment requires \(M_T = 6\rho_c k_v B_0 G^3\tau^4\), and the scalar root in \(\Delta C\) is found by a bisection in \(\ln(\Delta C/\Delta C_{max})\) with an implicit-gradient Newton step (difflow.staged._solve_monotone), so gradients with respect to \(\tau\), \(k_g\) and \(k_b\) are the implicit-function derivatives. In the log form the residual \((1-j)\ln M_T - (b+3g)\ln\Delta C - \text{const}\) is monotone for \(j \le 1\) (larger \(j\) is rejected). Since \(G\) falls as \(\tau\) grows (\(\Delta C\) falls), \(L_D(\tau)\) is not \(3G\tau\) at a fixed \(G\); as \(k_g \to \infty\) the supersaturation vanishes and the yield approaches the equilibrium yield.

Temperature-dependent kinetics. The power laws have no temperature in them, so at a fixed \(\tau\) the size changes with \(T\) only through the solubility (with \(j = 1\) not at all). With kinetics_Ea=[E_g, E_b] (J/mol) and kinetics_T_ref the constants in kinetics are the values at \(T_{ref}\) and follow Arrhenius, \(k(T) = k(T_{ref})\,\exp[-E/R\,(1/T - 1/T_{ref})]\) (arrhenius_factor; also the optional T, E_g/E_b, T_ref arguments of growth_rate and nucleation_rate and activation_energies/T_ref of msmpr_steady_state). Off by default, and no activation energy is built in: like \(k_g\) and \(k_b\) it is fitted to data at several temperatures, and it depends on whether diffusion or the surface reaction controls growth.

Fines destruction and classified product removal (R-z model). Randolph & Larson (1988, ch. 6) write the removal from an MSMPR as a step function of size: crystals below the fines cut \(L_F\) leave at \(R\) times the product rate (the excess \(R-1\) to a fines dissolver whose solute returns to the liquor; fines_ratio, fines_cut_size), crystals above \(L_P\) at \(z\) times it (classified product removal; product_classification, product_cut_size). The steady balance \(G\,dn/dL = -h(L)\,n/\tau\) with \(h = R,\ 1,\ z\) gives a piecewise exponential, continuous at the cuts:

\[n = n_0 e^{-RL/G\tau}\ (L < L_F), \qquad n = n_0 e^{-(R-1)L_F/G\tau}\,e^{-L/G\tau}\ (L_F \le L < L_P), \qquad n = n(L_P)\,e^{-z(L - L_P)/G\tau}\ (L \ge L_P).\]

rz_distribution and rz_moments (closed-form piecewise integrals; basis="product" weights by the product removal, 1 below \(L_P\) and \(z\) above) evaluate it. In msmpr_steady_state (and the unit) the third-moment closure then uses the product’s crystal mass per m\(^3\), nucleation the vessel suspension density, and L_D, L_50, cv describe the product’s mass distribution; info adds M_product, product_moments and fines_dissolved (kg/s through the dissolver). With \(R = 1\) and no classification it is the MSMPR.

import jax
from difflow import Crystallizer, CrystallizerParams, make_stream

# SYNTHETIC curve, kinetics and activation energies (illustrative only)
base = dict(solute="Na2SO4", solute_MW=142.0, mode="cooling", T=278.15, solubility_coeffs=[0.35, 0.0075],
            hydrate_waters=10.0, kinetics=[2.0e-6, 1.5, 1.0e9, 2.0, 0.6], residence_time=900.0,
            crystal_density=1460.0, liquor_density=1250.0)
feed = make_stream({"Na2SO4": 0.60 * 1000 / 142.0, "H2O": 1.0 * 1000 / 18.015}, T=333.15, P=1e5)
for kw in ({}, dict(fines_ratio=5.0, fines_cut_size=50e-6)):
    i = Crystallizer(CrystallizerParams(**base, **kw))(feed)[2]
    print(kw or "MSMPR", f"L_50 = {float(i['L_50']) * 1e6:.0f} um, closure = {float(i['closure']):.0e}")

def L50_of_T(T):
    p = CrystallizerParams(**{**base, "T": T}, kinetics_Ea=[50e3, 30e3], kinetics_T_ref=278.15)
    return Crystallizer(p)(feed)[2]["L_50"]

print(f"dL_50/dT = {float(jax.grad(L50_of_T)(278.15)) * 1e6:.2f} um/K")
import math
import jax
import jax.numpy as jnp
import numpy as np
from difflow import (Solubility, fit_msmpr, msmpr_distribution, msmpr_dominant_size,
                     msmpr_mass_median_size, msmpr_moments, msmpr_steady_state)

# moments from the closed form, and the "ln n vs L" fit
n0, G, tau = 3.0e9, 2.0e-8, 1800.0
print("m0..m3 =", np.asarray(msmpr_moments(n0, G, tau)))
L = np.linspace(1e-5, 8 * G * tau, 40)
Gt, n0_fit = fit_msmpr(L, msmpr_distribution(L, n0, G, tau))
print(f"fit: G tau = {float(Gt):.3e} m (true {G * tau:.3e}), n0 = {float(n0_fit):.3e}")
print(f"L_D = {float(msmpr_dominant_size(G, tau)) * 1e6:.1f} um,"
      f" L_50 = {float(msmpr_mass_median_size(G, tau)) * 1e6:.1f} um")

# residence time versus median size, with SYNTHETIC kinetics (k_g, g, k_b, b, j)
sol = Solubility.polynomial([0.35, 0.0075], hydrate_waters=10, solute_MW=142.0)
kin = (2.0e-6, 1.5, 1.0e9, 2.0, 0.6)

def median_um(t):
    r = msmpr_steady_state(0.60, 1.0, 278.15, sol, kin, t, crystal_density=1460.0, liquor_density=1250.0)
    return r["L_50"] * 1e6

for t in (300.0, 900.0, 3600.0):
    print(f"tau = {t:6.0f} s: L_50 = {float(median_um(t)):7.1f} um, dL_50/dtau = {float(jax.grad(median_um)(t)):.4f} um/s")

Adsorption, Ion Exchange and Chromatography#

Location: difflow/adsorption.py (from difflow import adsorption)

A small core module, separate from the CO2-specific cycle models in difflow_cc and the mAb capture column in difflow_bio. Every function is JAX, so jax.grad goes through fits, equilibria and the fixed-bed solve.

Isotherms and Batch Adsorption#

Functions: linear_isotherm(c, K), langmuir_isotherm(c, q_max, K) (affinity form \(q = q_{max}Kc/(1+Kc)\); the bio plugin’s langmuir_isotherm takes a dissociation constant \(K_d = 1/K\)), freundlich_isotherm(c, K, n) (\(q = Kc^n\)), fit_isotherm(c, q, model), batch_adsorption(c0, V, m, isotherm, **params)

fit_isotherm starts from the classical linearization (Langmuir \(c/q\) vs. \(c\), Freundlich \(\log q\) vs. \(\log c\), returned as "linearized") and polishes it by Levenberg-Marquardt on the logarithms of the parameters (relative=True fits relative residuals). batch_adsorption solves \(V(c_0 - c) = m\,(q(c) - q_0)\) by bisection with an implicit-derivative Newton step (Geankoplis 5e Example 12.2-1).

Fixed-Bed Breakthrough#

Functions: lub_analysis(t, c_ratio, L, c_break), lub_break_time(L_new, t_stoich, L, LUB), stoichiometric_time(L, v_s, epsilon, rho_b, c_feed, q_feed), fixed_bed_breakthrough(t, L, v_s, epsilon, rho_b, c_feed, k_ldf, isotherm, **params), klinkenberg_breakthrough(t, L, v_s, epsilon, rho_b, K, k_ldf)

lub_analysis is the length-of-unused-bed scale-up method (Geankoplis 5e §12.3, MSH 7e Ch. 25): \(t_s = \int_0^\infty (1 - c/c_0)\,dt\), \(t_u = \int_0^{t_b}(1 - c/c_0)\,dt\), \(L_B = L\,t_u/t_s\), \(\mathrm{LUB} = L - L_B\); a bed of length \(L'\) breaks at \(t_s(L' - \mathrm{LUB})/L\).

fixed_bed_breakthrough solves the isothermal bed by the method of lines (first-order upwind cells, optional axial dispersion D_ax, linear driving force \(\partial q/\partial t = k(q^* - q)\)) with an adaptive Tsit5 step. The step is capped inside the explicit method’s stability region (advection, dispersion and the LDF uptake at the steepest part of the isotherm); without the cap reverse-mode derivatives along the accepted step sequence were wrong in sign. Upwind cells add numerical dispersion \(\approx v\,\Delta z/2\), so use enough cells (n_cells) that the front width is set by k_ldf. klinkenberg_breakthrough is the analytic linear-isotherm LDF solution (Ruthven 1984), used as a check.

Ion Exchange#

Function: ion_exchange_equilibrium(x, K, z_A=1, z_B=1, C_total=1.0, Q=1.0)

Resin fraction \(y_A\) in equilibrium with solution fraction \(x_A\) by the mass-action law \((y/x)^{z_B}\,((1-x)/(1-y))^{z_A} = K\,(Q/C)^{z_A - z_B}\). For equal valence this is the constant separation factor \(y = Kx/(1 + (K-1)x)\); for 2:1 exchange the dilution factor \(Q/C\) makes the resin strongly prefer the divalent ion in dilute solution (water softening).

Chromatography#

Functions: retention_factor(t_R, t_0), plate_count(t_R, w, width="base"|"half"), resolution(t_R1, t_R2, w1, w2), purnell_resolution(N, alpha, k2), plates_for_resolution(R_s, alpha, k2), van_deemter(u, A, B, C), van_deemter_optimum(A, B, C), gaussian_peak(t, t_R, N, area)

\(N = 16(t_R/w)^2 = 5.545(t_R/w_{1/2})^2\), \(R_s = 2(t_{R2}-t_{R1})/(w_1+w_2)\), the Purnell equation \(R_s = (\sqrt N/4)\,((\alpha-1)/\alpha)\,(k_2/(1+k_2))\), and \(H = A + B/u + Cu\) with \(u_{opt} = \sqrt{B/C}\), \(H_{min} = A + 2\sqrt{BC}\) (Wankat 4e Ch. 18; Seader 3e Ch. 15).

import jax
import jax.numpy as jnp
from difflow import adsorption as ad

# Freundlich fit to Geankoplis Example 12.1-1 (phenol on carbon, recalled data)
c = jnp.array([0.322, 0.117, 0.039, 0.0061, 0.0011])   # kg/m^3
q = jnp.array([0.150, 0.122, 0.094, 0.059, 0.045])     # kg/kg
fit = ad.fit_isotherm(c, q, model="freundlich")
print({k: round(float(v), 3) for k, v in fit["params"].items()})

# batch: 1 m^3 at 0.21 kg/m^3 with 1.40 kg carbon (Example 12.2-1)
b = ad.batch_adsorption(0.21, 1.0, 1.40, "freundlich", K=0.199, n=0.229)
print(f"c = {float(b['c']):.4f} kg/m^3, q = {float(b['q']):.4f} kg/kg")

# fixed bed, Langmuir; the stoichiometric time and its derivative
t = jnp.linspace(0.0, 1000.0, 101)
def area(q_max):
    r = ad.fixed_bed_breakthrough(t, L=1.0, v_s=0.01, epsilon=0.4, rho_b=1.0, c_feed=1.0,
                                  k_ldf=0.05, isotherm="langmuir", n_cells=15, q_max=q_max, K=1.0)
    return jnp.trapezoid(1.0 - r["c_out"], t)
print(f"t_s = {float(area(5.0)):.1f} s, dt_s/dq_max = {float(jax.grad(area)(5.0)):.2f}")

# chromatography
print(f"R_s = {float(ad.resolution(10.0, 11.0, 0.8, 0.9)):.2f}")
print("u_opt, H_min =", [round(float(v), 4) for v in ad.van_deemter_optimum(1e-3, 2e-6, 4e-3)])

Summary Tables#

Reactor Comparison#

Reactor

Mixing

Residence Time

Best For

CSTR

Perfect

Distribution

Liquid-phase, uniform T

PFR

None (axial)

Uniform

Gas-phase, high X

GasPFR

None

Variable

Gas with ΔP, mole change

Fed-Batch

Perfect

Variable

Selectivity control

Heat Exchanger Comparison#

Type

Arrangement

ΔT Driving Force

Max T Approach

Typical Applications

Counter-current

Opposite flow

Maximum

T_c,out → T_h,in

Max efficiency, heat recovery

Cross-flow (unmixed)

Perpendicular

Good

Intermediate

Car radiators, HVAC, finned-tube HX

Cross-flow (mixed)

Perpendicular

Better

Intermediate

Compact HX, special geometries

Co-current

Parallel flow

Moderate

T_c,out ≤ T_h,out

Simple applications, temperature control

Note: For the same UA and inlet conditions, effectiveness ranking is: Counter-current > Cross-flow (mixed) > Cross-flow (unmixed) > Co-current

Separation Method Selection#

Method

Basis

Typical Application

Flash

VLE

Light/heavy split

Distillation

Boiling point

High purity, sharp split

LLE

Solubility

Heat-sensitive, azeotropes