Refinery Unit Operations

Contents

Refinery Unit Operations#

difflow_refinery

This document covers the difflow_refinery plugin. It characterises a crude from its assay, separates it in a crude distillation unit (CDU): a fired heater and an atmospheric column, with side strippers, pumparounds and stripping steam. The products are reported the way a refinery reads them: yields, API gravities and TBP ranges. The atmospheric residue goes on to a vacuum distillation unit (VDU), and finished components are blended into products in a nonlinear blending pool.


Overview#

The difflow_refinery plugin provides:

  • Assay characterisation (Assay, characterize): a TBP curve plus a gravity, cut into pseudo-components with the standard petroleum correlations. Light ends (C1–C6) are kept as real species. With a HeavyEnd the curve is carried into the vacuum range and closed by a residue lump, and sulfur, nitrogen, CCR, Ni+V and asphaltenes are carried per component. This one characterization is what the crude unit, the vacuum column and the blend pool all read.

  • Column thermodynamics (ColumnThermo): vectorised over stages and components.

    • Raoult’s law with Lee-Kesler vapour pressures.

    • Ideal-gas-path enthalpies.

    • Stripping steam as a vapour that condenses only as free water in the overhead drum.

  • The atmospheric column (CrudeColumn): an equation-oriented MESH model, with side strippers, pumparounds, bottom and stripper steam, and a total or partial condenser.

  • The furnace (Furnace): solved with the column, so an overflash spec sets the coil outlet temperature, as it does in operation.

  • Product properties (product_properties, products.gaps): rates, volume and mass yields, SG/API, and TBP 5/10/50/90/95 points. Also the 5–95 gaps between neighbouring cuts.

  • CrudeUnit: the assembly a planner means by “the CDU”: assay in, yield table out.

  • CrudeDistillationUnit: the same unit behind difflow’s operation protocol, for a Flowsheet, JSON and the editor.

  • The preheat train (difflow_refinery.preheat): tank to furnace inlet. It covers the exchangers, the desalter and the preflash drum, and is solved together with the column whose products and pumparounds heat it (PreheatedCrudeUnit). It also provides the Ebert-Panchal fouling rates and a cleaning ranking from one gradient. The palette operations are Desalter, PreflashDrum and CrudeUnitWithPreheat.

  • VacuumColumn (difflow_refinery.vacuum): the vacuum unit, atmospheric residue to LVGO, HVGO, slop and vacuum residue, with contaminants carried per cut. It runs on the crude unit’s own pseudo-components, so the CDU residue feeds it directly in a Flowsheet.

  • Correlations (difflow_refinery.correlations): Twu, Riazi-Daubert, Lee-Kesler, Kesler-Lee and Maxwell-Bonnell, each written once, for all three of the above.

  • Thermochemical data (difflow_refinery.thermochemistry, #339): one table of ideal-gas formation enthalpy, entropy and Cp for every refinery model compound, with per-species provenance, read by every unit with reactions. See Thermochemical data.

  • The fluid catalytic cracker (difflow_refinery.fcc): a lumped-kinetics riser (3-, 4- or 5-lump) and a coke-burning regenerator solved together as the unit’s heat balance (catalyst circulation and regenerator temperature are unknowns, the riser outlet temperature the spec), with a simplified main fractionator; dry gas, C3/C4 olefin streams, gasoline, LCO and slurry. A library, not a palette operation; its kinetic constants are illustrative. See The fluid catalytic cracker.

  • C5/C6 isomerization (difflow_refinery.isomerization): an adiabatic approach-to-equilibrium reactor on ideal-gas thermochemistry, with a shortcut stabilizer and optional DIP and DIH columns. The DIH recycle is converged by Anderson and differentiated implicitly. isom_block links the isomerate to a blend pool. The palette operations are IsomerizationReactor and IsomerizationUnit.

  • Product blending (BlendPool, BlendComponent): gasoline, jet, ULSD and fuel-oil pools with the nonlinear blending rules, signed spec margins and LP back-off. A library for optimisation and planning, not a palette operation.

  • Catalytic reforming (difflow_refinery.reforming): a semi-regen reactor train with fired heaters, a PR separator, H2 recycle through a Flowsheet tear and a stabilizer; naphtha P/N/A by carbon number in, reformate (with RON from composition), net H2, LPG and fuel gas out. A library and flowsheet, not a palette operation.

  • Hydroprocessing building blocks (difflow_refinery.hydroprocessing) and the hydrotreater (difflow_refinery.hydrotreating): a trickle-bed reactor around any kinetic model, a Peng-Robinson HP separator, the recycle-gas loop and a steam stripper; HDS by sulfur class, HDN and aromatics saturation on the #305 composition. A library, not a palette operation. See Hydroprocessing and The hydrotreater.

  • Alkylation (difflow_refinery.alkylation): C3-C5 olefins + isobutane over H2SO4 or HF, the Sauer-Colville-Burwick correlations (cross-checked against GAMS process.gms), shortcut DIB/depropanizer/debutanizer and the isobutane recycle as a Flowsheet tear; alkylate to BlendPool, alky_block for planning. See Alkylation.

  • The VGO hydrocracker (difflow_refinery.hydrocracking): the same building blocks with a pretreat bed (the hydrotreating kinetics, VGO constants), a cracking bed on continuous lumping over the pseudo-component grid (Laxminarasimhan et al. 1996, or discrete lumps) with organic-N inhibition, a simplified fractionator and a UCO recycle tear. A library, not a palette operation; its cracking constants are illustrative. See The hydrocracker.

  • The hydrogen network (difflow_refinery.hydrogen): producers (the reformer’s net gas, an H2 plant, imports), consumers (hydrotreater and hydrocracker makeup with a purity or partial-pressure spec), an optional PSA, purge to fuel gas and export, on one or more headers; returns the balanced header and each consumer’s makeup purity, feeds it back into the hydrotreaters (close_hydrotreater_loop), and h2_block for planning. A library. See The hydrogen network.

  • Residue desulfurization and fuel oil (difflow_refinery.residue, #331): an atmospheric-residue desulfurizer on the same building blocks (HDS by sulfur class plus refractory residue sulfur, HDM of Ni+V, CCR reduction, a small 538 C+ conversion; once-through treat gas, ideal product split) and the VLSFO pool (fuel_oil_blend). It takes a 3 wt% S residue to a 0.5 wt% S fuel oil, which cutter blending alone cannot. A library; constants illustrative. See Residue desulfurization and fuel oil.

  • Chaining units (difflow_refinery.plant, #334): compose library units (hydrotreater, reformer, residue desulfurizer, …) and their adapters into one differentiable function, with each unit’s AD mode in one table (AD_MODES). Chain.jacobian uses one jax.jacfwd/jax.jacrev when every unit supports that mode, and the chain rule by unit Jacobians when they do not (the reformer is forward-only, a default hydrotreater reverse-only). See Chaining units.

Everything is differentiable with jax. A product yield, a gravity or a furnace duty has an exact gradient with respect to:

  • every spec value;

  • the crude rate and preheat temperature;

  • the assay data behind the thermodynamics (TBP points, gravity).

The column is solved by damped Newton. Gradients are implicit-function gradients at the converged point, not derivatives taped through the iterations.


Installation#

The plugin ships with difflow and registers itself through the difflow.plugins entry point:

import difflow_refinery as dr

Characterising a crude#

A crude is too many molecules to list. It is described by its true boiling point (TBP) curve and a gravity, and the curve is cut into narrow boiling ranges, each of which becomes one pseudo-component:

from difflow_refinery import Assay, characterize

assay = Assay(
    tbp_percent=[0, 5, 10, 30, 50, 70, 90, 95, 100],
    tbp_T=[t + 273.15 for t in [20, 60, 95, 205, 315, 430, 580, 640, 760]],
    sg=0.86,
    light_ends={"propane": 0.005, "n_butane": 0.01, "n_pentane": 0.015},
)
crude = characterize(assay)          # default cut points, Twu critical properties
crude.names, crude.Tb, crude.SG, crude.volume_fraction
  • Interpolation: the curve is interpolated monotonically (PCHIP).

  • Default cut widths: 20 K below 400 °C, 40 K to 600 °C, 100 K above that (DEFAULT_CUT_WIDTHS).

  • Gravity: a bulk SG is distributed over the cuts at a constant Watson K. Alternatively, pass sg_curve= to give the gravity cut by cut.

  • Critical-property correlations (CRITICAL_METHODS):

    • "twu" (the default; Twu 1984 as published, also named "twu_1984");

    • "twu_legacy" (the crude unit’s coding before #301; see below);

    • "riazi_daubert_1987";

    • "riazi_daubert_1980";

    • "lee_kesler".

    Acentric factors are chosen so that each pseudo-component boils at its own Tb.

The cut points fix the number of pseudo-components and, with it, the shape of the column’s equations. Keep them fixed when differentiating with respect to the assay.

This is not an assay library. Curated assays are proprietary data; the module characterises the curve the caller brings.

The heavy end and contaminants#

An atmospheric column only needs the crude to its residue. A vacuum column needs pseudo-components to 750–800 °C, past where any TBP distillation stops. HeavyEnd adds them, and is opt-in. An assay without one characterizes exactly as before, and tests/refinery/test_cdu_baseline.py pins that.

import difflow_refinery as dr

assay = dr.Assay([5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95],
                 [t + 273.15 for t in (60, 95, 150, 205, 260, 315, 370, 430, 500, 600, 680)],
                 sg=0.86, light_ends={"propane": 0.5, "n_butane": 1.0, "n_pentane": 1.5},
                 heavy_end=dr.HeavyEnd(),                     # T_max 800 C, lump at 950 C, MW 1500
                 sulfur_wt=1.8, nitrogen_wppm=1500.0, ccr_wt=6.0,
                 nickel_vanadium_wppm=60.0, asphaltenes_wt=3.0)
char = dr.characterize(assay)            # method "twu" by default
char.sulfur, char.ccr                    # per component, mass fraction
char.pseudo_components()                 # the vacuum column's property table
  • The curve. With a heavy end the TBP curve is drawn on a probability scale, z = Phi^-1(x) against T. Inside the data it is a monotone C1 cubic in z; beyond it, the least-squares line through the last n_tail points. Because the curve is open at both ends, the percentages must lie strictly inside (0, 100), there must be at least 3 of them, and the last temperature must be below T_max.

  • The cuts run to T_max (default 800 °C) on DEFAULT_HEAVY_CUT_WIDTHS. Everything above is one residue lump whose Tb, MW and optionally SG are set directly (residue_Tb, residue_mw, residue_sg), because Twu’s n-alkane reference has no root past about 820–840 °C TBP. The lump’s critical constants are still computed, so the EOS and the vapour pressure stay defined.

  • Contaminants (CONTAMINANTS): bulk sulfur, nitrogen, CCR, Ni+V and asphaltenes are distributed over the cuts by a logistic in boiling point, scaled so they recombine exactly to the bulk. Measured curves can be given for S, N and CCR (sulfur_curve= and so on). Light ends carry none.

  • Differentiable. Everything is a function of the assay data. A gradient with respect to one TBP point matches central differences to 1e-6 relative, with the cut points held fixed.

Which Twu. The heavy end made the correlations disagree visibly. Twu’s molecular weight had three codings in the package. The crude unit’s divided Twu’s Rankine constants by sqrt(1.8) while taking the square root of Tb in Rankine, which under-corrects aromatics (naphthalene 10 % low, phenanthrene 15 % low). The vacuum unit’s coding matches the 1984 paper and two independent implementations. On the reference set the published form has a molecular-weight AAD of 0.5 %, against 2.4 % for the old coding. "twu" is now the published form, for every unit. The old coding is kept as "twu_legacy", which reproduces the crude unit’s earlier results to round-off (tests/refinery/test_cdu_baseline.py). On the test crude below the change moves the coil outlet by 1.7 K and the fired duty by about 3 %. The yields do not move, because they are specs.

Hydrocarbon type, hydrogen and heteroatom classes#

A boiling point and a gravity tell you how a cut distils. They do not tell you what it is made of. The conversion units need that. A hydrotreater’s hydrogen demand follows the aromatics it saturates and the sulfur classes it has to remove: a thiol goes at any severity, a 4,6-dimethyldibenzothiophene only at the last ppm. A reformer converts naphthenes. An FCC cracks paraffins and condenses aromatics. difflow_refinery.composition (#305) puts these numbers on every component of a Characterization. They are per-component arrays that travel with the component flows, the way sulfur, nitrogen and CCR already do, so the composition of any stream of those components is a weighted average.

import difflow_refinery as dr
from difflow_refinery import composition as cm

char = dr.characterize(assay, composition=True)        # or char.with_composition(...)
comp = char.composition                                # a cm.Composition
comp.hc_type          # (n, 4) vol fractions: paraffins, naphthenes, aromatics, olefins
comp.hydrogen         # (n,) mass fraction
comp.sulfur_classes   # (n, 5) mass fraction of S in each class
stream = char.stream(total=100.0, T=300.0, P=101325.0)   # kg/s of crude
comp.of_stream(stream).as_dict()   # aromatics_vol, hydrogen_wt, S_<class>_wt, N_basic_wppm, ...
props = dr.product_properties(result.products, thermo, feed, composition=comp)
props["diesel"].composition.aromatics, props["diesel"].composition.sulfur_classes_wt

What each component carries. Every array has one row per component in char.names order: light ends first, then the cuts, residue lump last.

Array

Shape

Units

Notes

hc_type

(n, 4)

volume fraction

columns HC_TYPES = paraffins, naphthenes, aromatics, olefins; each row sums to 1. Olefins are 0 in a straight run; a cracking unit sets them.

hydrogen

(n,)

mass fraction

sulfur, nitrogen

(n,)

mass fraction

the characterization’s own vectors

sulfur_split

(n, 5)

share of the component’s S

columns SULFUR_CLASSES = sulfides (thiols and sulfides), thiophenes, benzothiophenes, dibenzothiophenes, hindered_dbts (4- and 4,6-alkyl DBTs); rows sum to 1

nitrogen_split

(n, 2)

share of the component’s N

columns NITROGEN_CLASSES = basic, non_basic

refractive_index, d20

(n,)

-, g/cm³

n20 used for the estimate, density at 20 °C

MW, SG

(n,)

g/mol, 60/60 °F

the averaging weights

Derived: sulfur_classes = sulfur[:, None] * sulfur_split, and likewise nitrogen_classes, carbon (1 - H - S - N) and ch_ratio. Light ends are exact: they are paraffins, carry their formula’s hydrogen, and have no sulfur or nitrogen.

A stream or a product. comp.of_flows(moles) (mol/s in names order, or basis="mass" for kg/s) and comp.of_stream(stream) return a StreamComposition. It ignores F_water, F_H2O and any species not in names. The types are averaged by standard liquid volume, m_i / SG_i. Hydrogen, sulfur, nitrogen and their classes are averaged by mass. The fields are hc_type (4,), hydrogen_wt (wt%), sulfur_wt (wt%), nitrogen_wppm, sulfur_classes_wt (5,) (wt% of the stream, summing to sulfur_wt), nitrogen_classes_wppm (2,), and the mass and volume they were weighted on. product_properties(..., composition=comp) puts one on every ProductProperties.composition. BlendCharacterization.from_characterization(char) takes the types as the paraffins_vol, naphthenes_vol, aromatics_vol and olefins_vol qualities (vol%), so a crude-unit product reaches BlendPool with its aromatics. A unit that changes a component’s composition, for example a hydrotreater saturating aromatics, builds its own Composition with comp.replace(hc_type=..., hydrogen=..., sulfur=...) and reports through the same of_flows.

How each cut is estimated. Each step is selectable in CompositionData, and each can be overridden by measurements:

  1. Refractive index n20. Measured, or Riazi-Daubert’s Huang index I = (n² - 1)/(n² + 2) from (Tb, SG). Below 620 K this is the Riazi-Daubert (1987) six-constant form [R2]. Above it is Riazi’s (2005) heavy-fraction form [R3]. The two disagree by about 0.01 in n20 at 620 K on gas-oil gravities, so they are joined by a logistic with a 20 K scale rather than switched. d20 comes from SG (API).

  2. Hydrogen.

    • hydrogen_method="goossens" (the default) uses Goossens (1997), from (n20, d20, MW). It is the one meant for heavy cuts.

    • "riazi_daubert" uses the Riazi-Daubert (1987) C/H weight ratio from (Tb, SG) [R2], with H = (1 - S - N)/(1 + C/H).

    • A measured hydrogen content replaces either.

  3. Hydrocarbon types.

    • pna_method="riazi_daubert" (the default) is API procedure 2B4.1, Riazi-Daubert (1986), in the form that needs no viscosity. It takes the refractivity intercept Ri = n20 - d20/2, the C/H weight ratio, and m = MW (n20 - 1.475).

    • "ndm" is the n-d-M method of ASTM D3238. It gives carbon types: the share of carbon atoms in aromatic rings, naphthenic rings and chains. An alkylbenzene is one aromatic molecule but mostly paraffinic carbon. Read its output as an approximation to molecule types, not as molecule types.

    • riazi_daubert_pna_vgc gives 2B4.1’s viscosity-gravity-constant form for a caller who has a measured viscosity. A TBP assay does not give one, so the estimate does not use it.

    The C/H ratio that feeds the type correlation is computed from the hydrogen content, as (1 - H - S - N)/H. Hydrogen and type are therefore one estimate, and they move together. A cut measured richer in hydrogen comes out less aromatic.

  4. Projection. A correlation can stray outside [0, 1] at the edge of its range. Its P, N and A are taken through a smooth positive part (t·logaddexp(x/t, 0), t = 1e-3) and renormalized, so each cut’s types are a composition and stay differentiable.

2B4.1’s two branches, MW ≤ 200 and MW > 200, do not agree at 200. On straight-run kerosene and diesel cuts the heavy branch gives 0.1–0.2 less aromatics than the light one. The branches are joined by a logistic in MW with a 10 g/mol scale, so the composition stays smooth in the assay. Each branch is reproduced, to 1 % of the gap between them, beyond 200 ± 46. Near the join the estimate is a ramp between two published answers that disagree, and it is not more accurate than either. blend=False gives the published hard switch.

Measured data overrides the estimate cut by cut. CutData(values, T=None, extrapolate="estimate") holds either per-cut values (n_cuts entries, residue lump included, NaN where nothing was measured) or a curve over boiling point that is interpolated at each cut’s Tb. For a curve, the default extrapolate="estimate" gives cuts outside the measured range the correlation, because a naphtha PIONA says nothing about the gas oil. "flat" holds the end values, as the contaminant curves do.

import jax.numpy as jnp

n20_per_cut = jnp.full(char.n_cuts, jnp.nan)             # nothing measured here
T_anchor_K = [323.15, 523.15, 723.15]
basic_share = [0.25, 0.30, 0.33]
shares_k_by_5 = [[0.8, 0.2, 0, 0, 0], [0.4, 0.25, 0.35, 0, 0], [0.3, 0, 0.15, 0.3, 0.25]]
data = cm.CompositionData(
    hc_types=cm.CutData({"paraffins": [0.55, 0.45], "naphthenes": [0.30, 0.33],
                         "aromatics": [0.15, 0.22]}, T=[340.0, 450.0]),   # PIONA of the naphtha
    refractive_index=n20_per_cut,              # NaN where not measured
    hydrogen_wt=cm.CutData([14.2, 12.1], T=[480.0, 650.0]),
    sulfur_split=(T_anchor_K, shares_k_by_5),  # or an (n_cuts, 5) array
    nitrogen_split=(T_anchor_K, basic_share),
)
char = char.with_composition(data)
  • Partial analyses fill in. A cut takes the types measured for it. The remainder is shared among the unmeasured types in the estimate’s proportions, and the row is renormalized. An FIA aromatics number alone therefore keeps the estimated P:N ratio.

  • SARA. saturates is split into paraffins and naphthenes in the estimate’s ratio. resins and asphaltenes count as aromatics, which their cores are. SARA is a mass-basis analysis, and it is used here on the volume basis as given.

  • Differentiable. The override is a jnp.where, so a product property is differentiable with respect to the measured values. With a curve it is also differentiable with respect to the TBP data, through each cut’s Tb.

Sulfur and nitrogen classes are illustrative by default. DEFAULT_SULFUR_SPLIT gives the shape the hydrodesulfurization literature describes:

  • naphtha: thiols, sulfides and thiophenes;

  • from kerosene: benzothiophenes (benzothiophene boils at 221 °C);

  • from diesel: dibenzothiophene (332 °C) and the hindered alkyl-DBTs (4,6-DMDBT at about 365 °C);

  • vacuum range: aliphatic sulfides again.

The numbers are not any crude’s. DEFAULT_NITROGEN_SPLIT puts a quarter to a third of the nitrogen in the basic class, slightly more in the heavy end. Pass sulfur_split= and nitrogen_split= for real ones, as a (T_K, shares) table or a per-cut array.

Range warnings. Outside the data a correlation was fitted to, estimate_composition raises CompositionRangeWarning, which lists the cuts. It does this once per call and only with concrete inputs; under jit/grad the warning is skipped. The ranges it warns outside are:

Correlation

Range

Huang index

300–850 K

Riazi-Daubert 1987 C/H

300–620 K

2B4.1 types

MW 70–600

n-d-M

MW below 200

The residue lump always warns, because its Tb and MW are set rather than correlated. The function still returns a number. A differentiable flowsheet needs one, and the warning tells you it is an extrapolation. CompositionData(warn=False) silences it.

Model equations. Every correlation in (Tb, SG) below has the Riazi-Daubert form θ = a exp(b T + c SG + d T SG) T^e SG^f. The constants, with the unit of T, are:

Quantity

T unit

a

b

c

d

e

f

Source

Huang index I, Tb ≤ 620 K

°R

2.2657e-2

3.9052e-4

2.468316

-5.70425e-4

5.7209e-2

-0.719895

[R2] Table X (unverified)

Huang index I, heavy

K

3.2709e-3

8.4377e-4

4.59487

-1.0617e-3

0.03201

-2.34887

[R3] Eq. 2.46 / Table 2.9 (unverified)

C/H weight ratio

°R

17.22022

8.24983e-3

16.9402

-6.93931e-3

-2.72522

-6.79769

[R2] Table XI (unverified)

The two Huang-index forms are blended as I = (1 - s) I_1987 + s I_heavy, where s = σ((Tb - 620 K)/20 K). The other equations are:

  • n20 = sqrt((1 + 2I)/(1 - I)).

  • d20 = SG - 4.5e-3 (2.34 - 1.9 SG) g/cm³ [R3, API TDB].

  • Ri = n20 - d20/2 (Kurtz-Ward refractivity intercept).

  • m = MW (n20 - 1.475).

  • Goossens hydrogen [R4]: H wt% = 30.346 + (82.952 - 65.341 n20)/d20 - 306/MW.

  • C/H = (1 - H - S - N)/H (mass fractions).

  • 2B4.1 types, CH form [R1, R5]:

    • MW ≤ 200: x_P = 2.57 - 2.877 SG + 0.02876 C/H and x_N = 0.52641 - 0.7494 x_P - 0.021811 m;

    • MW > 200: x_P = 1.9842 - 0.27722 Ri - 0.15643 C/H and x_N = 0.5977 - 0.761745 Ri + 0.068048 C/H;

    • in both, x_A = 1 - x_P - x_N. The branches are blended with weight σ((MW - 200)/10).

  • 2B4.1 VGC form [R1, R5]:

    • MW ≤ 200: x_P = -13.359 + 14.4591 Ri - 1.41344 VGC and x_N = 23.9825 - 23.333 Ri + 0.81517 VGC;

    • MW > 200: x_P = 2.5737 + 1.0133 Ri - 3.573 VGC and x_N = 2.464 - 3.6701 Ri + 1.96312 VGC.

  • n-d-M [R6, R7], with n and d at 20 °C, S in wt% and M = MW:

    • v = 2.51 (n - 1.4750) - (d - 0.8510) and w = (d - 0.8510) - 1.11 (n - 1.4750);

    • %C_A = 430 v + 3660/M for v > 0, or 670 v + 3660/M otherwise;

    • %C_R = 820 w - 3 S + 10000/M for w > 0, or 1440 w - 3 S + 10600/M otherwise;

    • %C_N = %C_R - %C_A and %C_P = 100 - %C_R;

    • ring counts R_A = 0.44 + 0.055 M v (0.080 for v < 0) and R_T = 1.33 + 0.146 M (w - 0.005 S) (0.180 for w < 0). The ring counts are unverified.

  • Projection onto the simplex: x_i ← t·logaddexp(x_i/t, 0) with t = 1e-3, then each row is normalized.

  • Stream averages: x_type = Σ φ_i x_type,i, with standard-volume fractions φ_i ∝ m_i/SG_i, and H = Σ w_i H_i (likewise S, N and the classes), with mass fractions w_i.

Assumptions.

  • 2B4.1’s molecular fractions are used as volume fractions. MNL50’s remark that the bases nearly coincide for a narrow cut is the justification, and it is not checked.

  • n-d-M carbon types are used as molecule types when pna_method="ndm".

  • Oxygen and metals are neglected in C/H.

  • Light ends are exact paraffins.

  • Olefins are zero in a straight run.

  • SARA (a mass-basis analysis) is used on the volume basis as given.

Degrees of freedom. None. This is a property estimate, not a unit. The choices are the two method switches and the measurements that override the estimate.

References.

Key

Reference

Used for

How checked

R1

Riazi, M.R.; Daubert, T.E. “Prediction of molecular-type analysis of petroleum fractions and coal liquids.” Ind. Eng. Chem. Process Des. Dev. 1986, 25(4), 1009–1015. doi:10.1021/i200035a027

2B4.1 type correlations (CH and VGC forms)

Title, journal, volume, issue, pages and year were confirmed by web search (publisher page title and listing). The coefficients were not checked against the paper (unverified). They match pychemqt’s independent coding (lib/petro.py, PNA_Riazi), and its doctest is reproduced.

R2

Riazi, M.R.; Daubert, T.E. “Characterization parameters for petroleum fractions.” Ind. Eng. Chem. Res. 1987, 26(4), 755–759. doi:10.1021/ie00064a023

Huang index and C/H from (Tb, SG)

Citation as already used in correlations.py. The DOI and the table numbers (X, XI) come from pychemqt’s reference list and were not checked (unverified). The constants are pychemqt’s. Pure-compound checks: n20 within 0.004 for n-decane, benzene and toluene; C/H within 0.2 % for n-decane.

R3

Riazi, M.R. Characterization and Properties of Petroleum Fractions, ASTM MNL50; ASTM International: West Conshohocken, PA, 2005. doi:10.1520/MNL50-EB

Heavy Huang index (Eq. 2.46 / Table 2.9); d20 from SG

The DOI and publication details were confirmed by web search (ASTM store listing). Equation and table numbers, and the constants, come from pychemqt (unverified). The heavy form’s constants give a physical n20 only with Tb in K, although pychemqt converts to °R; K is coded here. The d20 formula reproduces n-decane’s measured d20 to 5e-5.

R4

Goossens, A.G. “Prediction of the hydrogen content of petroleum fractions.” Ind. Eng. Chem. Res. 1997, 36(6), 2500–2504. doi:10.1021/ie960772x

Hydrogen content

Volume, pages and DOI come from pychemqt’s reference list and were not found by web search (unverified). The equation is pychemqt’s coding (“Eq. 3”); its Table 1 doctest for n-decane (15.45 wt%) is reproduced.

R5

API Technical Data Book – Petroleum Refining, procedure 2B4.1 (edition and page unverified)

Same correlations as R1, as the TDB tabulates them

Not checked (unverified).

R6

van Nes, K.; van Westen, H.A. Aspects of the Constitution of Mineral Oils; Elsevier: New York, 1951

n-d-M method

Not checked against the book (unverified). The carbon-type equations agree with pychemqt’s PNA_van_Nes.

R7

ASTM D3238, Standard Test Method for Calculation of Carbon Distribution and Structural Group Analysis of Petroleum Oils by the n-d-M Method (edition unverified)

n-d-M method

Not checked (unverified).

The sulfur-class split DEFAULT_SULFUR_SPLIT and the basic-nitrogen split DEFAULT_NITROGEN_SPLIT cite no source. They are illustrative numbers chosen for this module. The boiling points quoted beside them (benzothiophene 221 °C, dibenzothiophene 332 °C, 4,6-DMDBT about 365 °C) and the rule of thumb of one quarter to one third basic nitrogen are handbook knowledge that was not checked here (unverified). The fitted ranges in the range-warning table come from pychemqt’s input bounds for the same correlations (80–650 °F), from MNL50’s “C20–C50” as pychemqt quotes it, or are conservative choices made here. All of them are unverified.

What is checked (tests/refinery/test_composition.py):

  • Coded constants. Every exponential correlation’s six constants are pinned to the values tabulated above.

  • Published coefficients.

    • The P and N constants of 2B4.1 are the ones in pychemqt’s independent coding of the procedure. The A constants, recalled separately, are exactly one minus their sum, which is what makes the three fractions sum to one for any input.

    • It reproduces pychemqt’s doctests for 2B4.1 (PNA_Riazi: 0.606/0.275/0.118) and for Goossens (n-decane, 15.45 wt%).

    • The Huang index gives n20 within 0.004 for n-decane, benzene and toluene.

    • The C/H ratio is within 0.5 % for n-decane’s formula and 7 % for benzene’s.

  • Composition. On two assays, one with a heavy end and one without, and with both type methods, every cut’s PNA sums to 1.

  • Balances. Hydrogen, sulfur and each sulfur class, nitrogen and each nitrogen class, and the volume-averaged types all balance to 1e-12 through product_properties, on both assays.

  • Gradients. The gradients of product P, N, A and hydrogen with respect to one TBP point and the bulk SG match central differences to 1e-5. The gradient with respect to a measured aromatics value does too.

Not checked. Riazi’s MNL50 worked examples for the composition correlations (molecular type, hydrogen, n-d-M) are not reproduced. Neither the book nor the papers could be reached (the publishers’ sites are blocked here), so no agreement with them is claimed. The coefficient values were cross-checked against pychemqt’s coding of the same procedures and against pure-compound data. That is weaker evidence than the source itself, and every such item is marked (unverified) above. The default sulfur and nitrogen splits are illustrative and not fitted to any crude.


The atmospheric column#

An atmospheric crude column is a main column with no reboiler:

  • The crude arrives partly vaporised from the furnace and is stripped with steam.

  • Side products are drawn as liquid from intermediate stages. Each is usually finished in a steam-stripped side stripper, whose vapour returns one stage above the draw.

  • Pumparounds draw liquid, cool it outside the column and return it higher up. They remove heat part-way up, and so set the internal reflux in each section.

import jax.numpy as jnp
import difflow_refinery as dr
from difflow_refinery import Assay, ColumnThermo, characterize, column as cc

# the test crude, 95 000 bbl/d at 240 C and 6 bar into the furnace
assay = Assay([0, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100],
              [t + 273.15 for t in (-10, 60, 95, 150, 205, 260, 315, 370, 430, 500, 600, 680, 850)],
              sg=0.86, light_ends={"propane": 0.5, "n_butane": 1.0, "n_pentane": 1.5})
crude = characterize(assay)
thermo = ColumnThermo.from_characterization(crude)
kg_s = 95_000 * cc.BARREL / 86400.0 * float(crude.bulk_sg) * 999.016
feed = crude.stream(kg_s, T=273.15 + 240, P=6e5, basis="mass")
Vf = float(thermo.std_volume(jnp.stack([jnp.asarray(feed[f"F_{n}"]) for n in thermo.names])))

params = cc.CrudeColumnParams(
    n_stages=30, feed_stage=27, P_top=1.5e5, P_bottom=1.9e5, P_condenser=1.3e5,
    bottom_steam=150.0, steam_T=533.15,             # steam in mol/s
    side_products=(cc.SideProduct("kero", 9, 4, steam=40.0),
                   cc.SideProduct("diesel", 16, 4, steam=40.0),
                   cc.SideProduct("ago", 22, 3, steam=20.0)),
    pumparounds=(cc.Pumparound("pa1", 12, 10), cc.Pumparound("pa2", 19, 17)),
    specs=(cc.product_rate("naphtha", 0.20 * Vf), cc.product_rate("kero", 0.11 * Vf),
           cc.product_rate("diesel", 0.17 * Vf), cc.product_rate("ago", 0.05 * Vf),
           cc.pumparound_duty("pa1", 15e6), cc.pumparound_delta_t("pa1", 60.0),
           cc.pumparound_duty("pa2", 20e6), cc.pumparound_delta_t("pa2", 60.0),
           cc.overflash(0.05)),                     # closes the furnace
    furnace=cc.Furnace(efficiency=0.85),
)
col = cc.CrudeColumn(params, thermo)
col.degrees_of_freedom()
res = col.solve(feed)                # res.products, res.T, res.condenser_duty, ...

Degrees of freedom and specs#

The degrees of freedom follow a simulator’s:

Element

Freedoms

Total condenser

1 (the distillate split)

Partial condenser

2

Each side product

1 (its draw rate)

Each pumparound

2 (rate and return temperature)

Furnace

1 (its coil outlet temperature)

Close them with specs, exactly as many as there are freedoms (the constructor says how many are missing):

Spec

Meaning

product_rate(name, value, basis="volume")

rate of the distillate, a side product, "residue" or "offgas"

reflux_ratio(value)

reflux over overhead product, molar

stage_temperature(stage, T)

a main-column stage temperature (stage 0 is the condenser)

pumparound_rate, pumparound_duty, pumparound_return_temperature, pumparound_delta_t

the two pumparound freedoms

overflash(value, basis="volume")

liquid leaving the stage above the flash zone, as a fraction of crude

coil_outlet_temperature(T), furnace_duty(Q)

the furnace, directly

Spec values are traceable: the gradient with respect to one is the column’s sensitivity to that spec. Rates are in mol/s, kg/s or standard m³/s at 60 °F; cc.BARREL converts to barrels.

Model#

MESH holds on every equilibrium stage of the main column and of every stripper, all solved simultaneously (the Naphtali–Sandholm approach). The bubble-point method fails on a feed that boils across 600 K.

  • Unknowns per stage:

    • the log component liquid flows;

    • the temperature;

    • the log total vapour flow, steam included.

  • Vapour: the hydrocarbon vapour follows from Raoult’s law. The water vapour is what is left, so the water balance is the stage’s summation equation.

  • Water is never in the hydrocarbon liquid. It condenses only in the overhead drum, as free water.

  • Duties are evaluated from the converged state; they are not unknowns.

The solve is a damped Newton from a bubble-point initialisation. It reports converged rather than raising: a set of specs with no solution comes back converged=False. Too little overflash for the heat a pumparound removes is one such set.

Involatile components. A heavy-end assay brings components that are essentially involatile: a 950 °C residue lump has a vapour pressure of about 3e-5 Pa at 600 K. From the bubble-point start, such a component stalls the damped Newton. When any component’s vapour pressure at 600 K is below 0.05 Pa, the column is first solved with those vapour pressures raised to that floor. It is then solved again with the true ones, starting from the first answer. The answer, and its gradients, are those of the true vapour pressures. The default characterization’s heaviest cut is at 7.6e-2 Pa, so it never takes this path.

The furnace#

With a Furnace in the params, the column’s feed is the furnace inlet: the crude as the preheat train delivers it. The coil outlet temperature becomes one more unknown, solved with the column:

  • The absorbed duty is the enthalpy rise of the crude from inlet to coil outlet, each evaluated by a flash. The fired duty is the absorbed duty over efficiency.

  • The coil outlet pressure defaults to the feed stage’s.

  • The extra freedom is usually closed by an overflash, which is how an operator runs the heater. Solving the furnace in front of the column instead would need the outlet temperature guessed and iterated by hand.


Products#

product_properties(products, thermo, feed) returns, for each hydrocarbon product:

  • its rates: mole, mass, volume and bpd;

  • yield_volume and yield_mass against the crude;

  • sg, api and mw;

  • TBP points at 5, 10, 50, 90 and 95 % (tbp_at(pct)).

  • with composition=char.composition, its hydrocarbon types, hydrogen, sulfur and nitrogen and their classes (.composition, a StreamComposition; see Hydrocarbon type, hydrogen and heteroatom classes).

products.gaps(properties, order) gives the 5–95 gap between neighbouring cuts: TBP5(heavier) - TBP95(lighter). A positive gap is a clean separation; a negative one is an overlap.

The TBP curves are built from the pseudo-components a product contains, so they are no finer than the cuts. A gap of a few kelvin is within that resolution. ASTM D86 conversion is not done; the gap is defined on TBP.


The crude unit#

CrudeUnit puts characterisation, thermo, furnace, column and product properties together:

import difflow_refinery as dr

unit = dr.CrudeUnit(assay, params)     # a default Furnace is added if params has none
res = unit.solve(95_000, T=273.15 + 240, P=6e5)   # bbl/d at the furnace inlet
print(res.table())
res.gaps()

On the test crude, a 30-stage column with three side strippers, two pumparounds and a 5 % overflash gives:

  • a coil outlet of about 313 °C and about 50 MW fired;

  • naphtha, kerosene, diesel, AGO and residue at 20, 11, 17, 5 and 47 vol %;

  • API gravities from 64.5 down to 18.7.

solve takes assay= (same light ends and cut points) and params= (same layout), so derivatives with respect to the assay and the specs are a jax.grad of a function that calls it:

import dataclasses
import jax

def residue_api(sg):
    a = dataclasses.replace(assay, sg=sg)     # same light ends and cut points
    return unit.solve(95_000, T=273.15 + 240, P=6e5, assay=a).properties["residue"].api

jax.grad(residue_api)(0.85)

Planning with the crude unit#

difflow_refinery.planning.cdu_block wraps a CrudeUnit as a difflow.planning.Block. Its delta vectors are the column’s own derivatives, taken by AD through the Newton solve, and the trust-region planner refreshes them every cycle (see Delta-base planning). The full walk-through is examples/35_refinery_cdu_planning.ipynb.

from difflow_refinery.planning import available_levers, cdu_block, link_cdu, product_value_block
from difflow.planning import DeltaBasePlanner, Network, check_delta_vectors
from difflow.planning.lp import Spec

cdu = cdu_block(unit, ["crude.rate", "naphtha.yield", "kero.yield", "overflash"],
                ["kero.bpd", "kero.tbp95", "gap.kero_diesel", "furnace.fired",
                 "naphtha.bpd", "diesel.bpd", "ago.bpd", "residue.bpd"],   # what `value` prices
                rate=95_000, T=273.15 + 240, P=6e5,          # the base point, as for unit.solve
                bounds={"kero.yield": (0.08, 0.15)})
check_delta_vectors(cdu)["passed"]                           # AD against central differences
value = product_value_block({"naphtha": 70.0, "kero": 95.0, "diesel": 90.0, "ago": 75.0, "residue": 55.0})   # $/bbl
net = Network([cdu, value], link_cdu(cdu, value))
plan = DeltaBasePlanner(net, prices={"value.revenue": 1.0, "cdu.crude.rate": -65.0,
                                     "cdu.furnace.fired": -700.0},
                        specs=[Spec("cdu.kero.tbp95", "<=", 235.0)]).solve()

Levers come from the specs. available_levers(unit) lists them:

  • crude.rate (bbl/d) is always a lever. So is preheat.T (°C), the furnace inlet temperature, except on a PreheatedCrudeUnit, where the train computes it (see the preheat train).

  • Each volume product-rate spec offers <product>.yield (a fraction of the crude) or <product>.bpd.

  • The other spec levers are overflash, <pa>.duty (MW), <pa>.dT (K), <pa>.return_T (°C), <pa>.rate (bbl/d), furnace.cot (°C), furnace.duty (MW absorbed), reflux_ratio and stage<k>.T (°C).

  • The stripping steam rates are levers in kg/h.

A spec the column does not have is not a lever. A column closed by an overflash has no furnace.cot, because fixing the coil outlet as well would over-specify it.

A spec that is not chosen as a lever is held. A volume product rate is held as its yield, so it follows crude.rate. Holding it as an absolute rate would give the whole rate change to the residue.

Outputs are reported per product and for the unit as a whole:

Output

Units

Notes

<product>.bpd

bbl/d

<product>.yield

-

volume fraction of the crude

<product>.yield_mass

-

mass fraction of the crude

<product>.sg

-

<product>.api

API

<product>.mw

g/mol

<product>.tbp5 … .tbp95

°C

gap.<a>_<b>

K

TBP5(b) less TBP95(a)

cut.<a>_<b>

°C

the effective cut point: the crude’s TBP at the cumulative yield through a

furnace.fired, furnace.absorbed

MW

furnace.cot

°C

furnace.vaporized

-

condenser.duty, <pa>.duty

MW

steam.total

kg/h

water.saturation_max

-

Temperatures are in °C and temperature differences in K. Every unit is recorded in block.metadata["u_units"] and ["y_units"], so an export is self-describing.

Health. check_delta_health passes on the default outputs. Three kinds of output would fail it and are left out of the defaults; ask for them by name if you want them.

  • A held product’s yield is a constant row.

  • steam.total is a constant row unless a steam rate is a lever.

  • The lightest product’s TBP5 has a kink. A TBP point is piecewise linear in the cumulative volume, with one node per component, and the nodes among the discrete light ends are tens of degrees apart. On the test crude the naphtha’s 5 % point sits on the n-butane node at the base point, with a slope of 277.6 K per unit yield to the left and 146.2 to the right.

On a unit with a preheat train, preheat.T is replaced by what sets it: the tank temperature and the exchangers (below). On a bare CrudeUnit, the preheat.T lever is not dead, but on a column closed by an overflash it moves only the fired duty. The overflash fixes the flash-zone vaporisation, so the preheat temperature changes how much heat the furnace must add and nothing about the products.

Non-convergence. Some spec sets have no solution. With 5 % overflash, taking more than about 24 MW out of PA1 on the test column dries out the section above it. The column then reports converged=False with a finite state. cdu_block returns NaN at such a point, and the planner rejects any proposal at which a block is not finite (A block that cannot be evaluated).

On the test column, a credit on PA1 duty drives the planner past the edge. It proposes 30, 27, 25.5, 24.75 and 24.38 MW (among others), rejects each, and settles at 23.62 MW, which converges. With mask_nonconverged=False, the same run ends at 30 MW on a column that did not converge and reports itself as converged.

Yields as levers, cut points as outputs. A cut-point target such as “kero TBP95 ≤ 235 °C” is a planner Spec on a CDU output, and the LP inverts the delta vector to find the yield that meets it. In the example plan, the kero end point binds at 235.000 °C. The planner trades naphtha yield, which pays $70/bbl, against kero, which pays $95/bbl, because a heavier naphtha cut also makes the kero heavier.

Making a cut point a column spec would describe the same feasible set, and it would cost more in two ways:

  • it would put a piecewise-linear TBP point inside the column’s Newton solve;

  • or, as an alternative, it would wrap a root find around the column, which means several column solves per evaluation.

For the same reason, product_value_block prices products in bbl/d only. Folded into the revenue as a smooth penalty, a quality limit puts curvature in the objective that a linear model cannot see. On the test crude, that version crawled along the penalty’s shoulder at a radius of 1e-4 and stopped at the iteration cap.

The preheat train#

The crude reaches the furnace at 250 °C or so, from a tank at ambient temperature. Most of that heat is recovered from the column’s own products and pumparounds in a train of exchangers. The furnace supplies the rest, so the train sets the fuel bill as much as the column does. difflow_refinery.preheat models the train from tank to furnace inlet. It has three kinds of item:

  • Exchangers (PreheatExchanger): UA = A / (1/U + R_f), with the duty from the LMTD equation of difflow.units.heat_exchanger (and its F-factor when shells is given). An optional bypass sends part of the hot stream around the exchanger.

  • A desalter (DesalterParams): wash water at its own temperature is mixed in. The brine leaves at the desalter temperature, and the crude keeps water_out of its volume as water. Salt removal is a fixed efficiency. The 120-150 °C operating window is reported as two signed margins (margin_low, margin_high), not imposed.

  • A preflash drum (PreflashDrumParams): an adiabatic flash at a set pressure, or at the pressure that flashes a set vapor_fraction of the hydrocarbons. The vapour goes to the column, on the stage above the flash zone by default (vapor_stage). The liquid is pumped on through the hot train. Free water is drawn off.

The crude side is a three-phase split on the column’s own thermodynamics: hydrocarbon liquid, vapour, and free water. Water is immiscible with the hydrocarbon liquid, as it is on the column’s trays. With free water present, water’s partial pressure is its vapour pressure and the hydrocarbons see the rest. Without it, all the water is vapour. The two cases agree on the boundary. A dry crude’s enthalpy computed this way equals the column’s own feed enthalpy to round-off, which is what lets the energy balance close across the train and the column. The hot streams are liquid throughout.

The train and the column are coupled both ways:

  • The hot streams are the column’s products and pumparounds, so their rates and temperatures come from the column.

  • The column’s feed is the furnace inlet, which is the train’s outlet. The drum vapour is a second, vapour feed.

  • A pumparound cooled in the train returns at the temperature the train sends it back at.

PreheatedCrudeUnit solves the two together:

import difflow_refinery as dr

import dataclasses

# the column of the previous section, with each pumparound given as a rate and a
# return temperature (the train sets the latter; the spec's value only starts the loop)
specs = tuple(sp for sp in params.specs if not sp.kind.startswith("pa_")) + (
    cc.pumparound_rate("pa1", 0.5 * Vf), cc.pumparound_return_temperature("pa1", 400.0),
    cc.pumparound_rate("pa2", 0.6 * Vf), cc.pumparound_return_temperature("pa2", 470.0))
column_params = dataclasses.replace(params, specs=specs)

tp = dr.PreheatTrainParams(
    exchangers=tuple(dr.PreheatExchanger(n, 350.0, A, Rf=2e-4 if n == "E8" else 0.0)
                     for n, A in dict(E1=300, E2=1200, E3=1500, E4=600,
                                      E5=300, E6=1500, E7=1500, E8=1200).items()),
    hot_streams=(dr.HotStream("residue", ("E8", "E7", "E2")), dr.HotStream("pa2", ("E6",)),
                 dr.HotStream("ago", ("E5",)), dr.HotStream("diesel", ("E4",)),
                 dr.HotStream("pa1", ("E3",)), dr.HotStream("kero", ("E1",))),
    crude_path=("E1", "E2", "E3", "desalter", "E4", "E5", "preflash", "E6", "E7", "E8"),
    desalter=dr.DesalterParams(), drum=dr.PreflashDrumParams(P=3e5))
unit = dr.PreheatedCrudeUnit(assay, column_params, tp)   # replaces the bare CrudeUnit above
res = unit.solve(95_000, T_tank=300.0)          # bbl/d from the tank
res.furnace_inlet_T, res.fired_duty, res.preflash_vapor
unit.balances(res)                               # tank to products: mass, water, energy

Each hot stream lists its exchangers hottest first. The crude path lists everything in the order the crude meets it, tank to furnace. Every exchanger must be on the crude path once and on exactly one hot stream; the train raises a ValueError when it is not. A pumparound in the train must have one pumparound_return_temperature spec on the column (its value only starts the loop) and one other spec, such as its rate. A pumparound not in the train keeps whatever spec it has.

The solve. The train alone is one damped Newton over its exchanger outlet temperatures, the desalter temperature and the drum temperature (and the drum pressure in vapor_fraction mode). Around it, an outer Newton works on the tear: the pumparound return temperatures, the drum state and the furnace inlet temperature. Each outer iteration solves the train and the column, and differentiates both with one forward-mode trace. Steps are clipped to 30 K. It starts from a default guess built from the specs, not from a previous solution.

Gradients are implicit-function gradients at the converged point. The last outer Jacobian is reused for the implicit step, so a jax.grad or jax.jacfwd with respect to an area, an R_f, the drum pressure or a TBP point costs one more linear solve, not a differentiated iteration history.

On the test crude (95 000 bbl/d from a 27 °C tank), with the column of the crude unit and an eight-exchanger textbook layout (tests/refinery/reference/preheat_case.py, examples/37_crude_preheat_train.ipynb):

  • the solve converges in 4 outer iterations;

  • the train recovers 81.7 MW and delivers the crude to the furnace at 251.2 °C;

  • the furnace fires 46.5 MW;

  • the desalter runs at 140.8 °C;

  • the drum flashes 8.9 mol % of the hydrocarbons at 150 °C and 3 bar;

  • the mass, water and energy balances close to 1.5e-12.

A heavier invented crude (every TBP point 25 °C higher above 10 %, SG 0.885) converges from the same default start. It reaches the furnace at 272.9 °C with 48.8 MW fired. Its desalter sits at 154 °C, 4 K above the window, which the margin reports.

Fouling and cleaning#

difflow_refinery.preheat.fouling has the Ebert-Panchal (1995) threshold model. Fouling grows by deposition, which is Arrhenius in the crude-side film temperature and falls with Reynolds number. It shrinks by removal, which goes with the wall shear stress:

dR_f/dt = alpha Re^beta Pr^(-0.33) exp(-E / (R T_film)) - gamma tau_w

Below the threshold an exchanger does not foul. fouling_rates(result.train, train_params) evaluates it for every exchanger of a solved train. The crude side’s Re, Pr and wall shear are inputs, because the train carries no geometry beyond the area.

The default constants (EbertPanchal()) are illustrative. They are not fitted to any crude. They were chosen so that the hot end fouls at a few 1e-4 m²K/W a year and the cold end not at all, which is the right order for a crude train. Fit alpha, E and gamma to your own monitoring data before reading a cleaning date off them. On the test train they give E8 4.1e-4 and E7 2.0e-4 m²K/W a year, and zero for E1-E3.

Two methods turn a fouled train into a decision:

  • unit.fouling_sensitivity(rate, T_tank, train=...) gives d(fired duty)/d(R_f) for every exchanger from one reverse-mode gradient.

  • unit.cleaning_ranking(rate, T_tank, train=...) multiplies each sensitivity by its R_f (the linear estimate of the saving from cleaning). It then re-solves with each exchanger clean (the exact saving) and sorts by the exact saving.

After 18 months of the illustrative fouling, the fired duty has risen from 46.49 to 47.04 MW. The ranking is E8 (0.26 MW), E7 (0.18) and E6 (0.15). The linear estimates are within 10 % of the exact savings and in the same order.

The sensitivities alone tell a different story. Per unit of R_f, E6 costs the most (1.2 MW per 1e-3 m²K/W), and E2 at the cold end costs as much as E7. The hot end tops the ranking only because only the hot end fouls. Keeping the two apart is the point: the sensitivity says where fouling hurts, and the fouling model says where it happens.

Planning with the preheat train#

cdu_block accepts a PreheatedCrudeUnit (or a CrudeUnitWithPreheat). The base point is rate= and T= (the tank temperature); P is not needed. preheat.T is no longer a lever, because the train computes it. A pumparound cooled in the train has no return_T lever, because the train sets its return temperature. In their place the train offers these levers:

Lever

Units

tank.T

°C

<E>.Rf

m²K/kW

the fouling resistance, so a delta vector reads per 1e-3 m²K/W

<E>.area

m²

<E>.bypass

-

hot-stream fraction

desalter.wash

-

wash water, standard-volume fraction of the crude

preflash.P

bar

or preflash.vapor_fraction in that mode

The train adds these outputs: furnace.inlet_T (°C) and preheat.recovered (MW); per exchanger <E>.duty (MW) and <E>.approach (K, negative for a temperature cross); <source>.train_out_T (°C) for each hot stream; desalter.T with its two margins; and preflash.T, .P and .vapor_fraction. The furnace.fired row of the delta vectors against the <E>.Rf levers is the fouling sensitivity above.

Preheat train gotchas#

  • A pinched exchanger may not solve. The duty is UA F LMTD. When a small hot stream meets a large exchanger, its hot-side NTU is very large and the terminal difference at the cold end falls like exp(-NTU). The LMTD of difflow.units.heat_exchanger floors each terminal difference at MIN_DELTA_T (1e-6 K), so below that floor the duty equation stops responding to the outlet temperature, and the Newton iteration fails. The case found while building the tests was 0.25 mol/s of naphtha against 40 m², where the NTU is about 240. Size the exchanger to the stream, or bypass most of it.

  • A temperature cross is not prevented. The LMTD takes the absolute value of each terminal difference, so a solution with a cross is not a physical exchanger. approach reports it as negative. Check it, or hold it with a spec when planning.

  • The desalter window is a margin, not a constraint. The heavy test crude runs its desalter outside the window, and the solve does not stop it.

  • The drum’s vapour goes into the column, not past it. It enters at vapor_stage, by default the stage above the flash zone, so the column’s specs see it.


Unit operations#

CrudeDistillationUnit#

The crude unit as a flowsheet operation. It is built from one CrudeDistillationUnitParams (assay, column, cut_points, method), called with the crude stream at the furnace inlet, and returns the product streams in outlet_names order:

  1. the distillate;

  2. each side product, in the order the column declares them;

  3. "residue";

  4. "water";

  5. with a partial condenser, "offgas".

from difflow import Flowsheet
from difflow.flowsheet import Unit

cdu = dr.CrudeDistillationUnit(dr.CrudeDistillationUnitParams(assay=assay, column=params))
feed = cdu.feed(95_000, T=513.15, P=6e5)          # bbl/d; also basis="mass"/"mole"/"volume"

fs = Flowsheet(list(cdu.unit.thermo.names) + ["water"])
fs.add_feed("crude", feed)
fs.add_unit(Unit("cdu", cdu, ["crude"], list(cdu.outlet_names)))
streams = fs.solve()
cdu.last_result.table()                           # the full result of the last call
  • Inlet: the inlet needs a flow for every component the assay characterises into; cdu.feed(...) makes one.

  • Full result: cdu.solve(feed) returns the full CrudeUnitResult (column profiles, duties, product properties) rather than the streams.

  • Serialisation: the assay, the column params and every nested spec, side product, pumparound and furnace are plain dataclasses, so the unit writes to and reads back from JSON with difflow.serialize.

Desalter#

The desalter alone, for a flowsheet. It is built from DesalterUnitParams (assay, desalter, cut_points, method) and called with a wet crude stream. It returns ("crude", "brine"). desalter.feed(rate, T, P, water=0.002) makes an inlet, and desalter.solve(feed) also returns the temperature and the window margins.

PreflashDrum#

The preflash drum alone. It is built from PreflashDrumUnitParams (assay, drum, …) and returns ("vapor", "liquid", "water"). With drum.vapor_fraction set, the pressure is solved for. feed(rate, T, P, water=0.0) makes an inlet at the train’s pressure.

CrudeUnitWithPreheat#

The whole coupled unit (PreheatedCrudeUnit) as a flowsheet operation. It is built from CrudeUnitWithPreheatParams (assay, column, train, …) and called with the tank crude (op.feed(95_000, T=300.0)). Its water is the train’s tank_water BS&W plus the inlet stream’s own F_water (op.feed makes a stream with none). Its outlets are the column’s products, then "brine" (with a desalter) and "drum_water" (with a drum). A product cooled in the train leaves at the train’s outlet temperature, not the column’s. op.last_result holds the full PreheatedUnitResult. The nested train params (exchangers, hot streams, desalter, drum) are plain dataclasses, so the unit round-trips through difflow.serialize.


The vacuum unit#

The vacuum distillation unit (VDU) takes the atmospheric residue to light and heavy vacuum gas oil (LVGO, HVGO), slop and vacuum residue. Its column lives in difflow_refinery.vacuum. Its components come from the same characterization as the crude unit’s (The heavy end and contaminants):

  • Its property table is Characterization.pseudo_components(). Every pseudo-component of the crude unit is one of the vacuum column’s, with the same Tb, SG, MW, critical constants and contaminants. The crude unit’s residue therefore feeds the column as it is, with no re-cut.

  • Correlations come from difflow_refinery.correlations: Twu critical properties and molecular weight, the Kesler-Lee acentric factor and liquid Cp, and Maxwell-Bonnell vapour pressure (the D1160 vacuum conversion). difflow_refinery.vacuum.correlations keeps the vacuum code’s old names for them.

  • A stage-network column (StageColumn, ColumnLayout, Route, StageSpec): Naphtali-Sandholm MESH equations with liquids routed to side draws, pumparounds and entrainment, Murphree efficiencies, and any column output specifiable in place of any knob.

  • VacuumColumn is the registered operation. Feed species it does not model, such as the light ends and the crude unit’s water dissolved in the residue, leave with its overhead, so a flowsheet still balances.

  • vacuum.Assay and vacuum.characterize are kept as a compatibility view. The old Assay (Celsius, wt%) converts with to_assay(). characterize runs the shared characterization on a vacuum cut grid and returns the old (components, yields, light_ends, Kw) shape. vacuum.atmospheric_residue is an idealized TBP cut for running the column without a crude unit in front of it.

Crude unit into vacuum column#

from difflow import Flowsheet
from difflow.flowsheet import Unit
from difflow_refinery.vacuum import VacuumColumn, VacuumColumnParams

char = dr.characterize(assay)                         # one HeavyEnd assay, as above
cdu = dr.CrudeDistillationUnit(dr.CrudeDistillationUnitParams(assay=assay, column=params))
vdu = VacuumColumn(VacuumColumnParams(components=char.pseudo_components()))

fs = Flowsheet(list(char.names) + ["water", "H2O"])  # CDU water is F_water, VDU steam F_H2O
fs.add_feed("crude", cdu.feed(95_000, T=513.15, P=6e5))
fs.add_unit(Unit("cdu", cdu, ["crude"], list(cdu.outlet_names)))
fs.add_unit(Unit("vdu", vdu, ["residue"],             # renamed: "residue" is the CDU's
                 ["vac_overhead", "lvgo", "hvgo", "slop", "vac_residue", "vdu_info"]))
streams = fs.solve()

On the 95 000 bbl/d test crude (tests/refinery/test_one_characterization.py, examples/36_crude_to_vacuum.ipynb):

  • Both units converge.

  • The balance closes per component to round-off. It also closes in total, once the crude unit’s and the vacuum unit’s steam are counted, each at its own water molar mass (18.015 and 18.01528 g/mol).

  • At the default 400 °C furnace, the vacuum column turns 0.74 of its feed into VGO.

  • d(VGO yield)/d(VDU furnace T) is 2.8e-3 per K. d(VGO rate)/d(crude rate) runs through both units’ implicit solves. Both match central differences.

Quick start: the vacuum unit#

import difflow_refinery as dr

char = dr.vacuum.characterize(dr.vacuum.heavy_crude())           # 20 cuts 300-800 C + lump
feed = dr.vacuum.atmospheric_residue(char, crude_rate_kg_s=100.0)

vdu = dr.VacuumColumn(dr.VacuumColumnParams(components=char.components))
overhead, lvgo, hvgo, slop, residue, info = vdu(feed)

info["converged"], info["iterations"]               # True, 6
info["properties"]["hvgo"]["T95"] - 273.15          # 566 C
info["properties"]["hvgo"]["ccr_wt"]                # 1.84
info["outputs"]["furnace.duty"]                     # 1.31e7 W

Streams use difflow’s convention: F_<pseudocomponent> in mol/s, with T in K and P in Pa. The overhead also carries the stripping steam as F_H2O.


Vacuum characterization#

A vacuum.Assay holds what a lab reports: a TBP curve (cumulative wt% distilled against temperature) and bulk properties. Two synthetic assays of the right shape are included, light_crude() (38 API, 15% vacuum residue) and heavy_crude() (20 API, 40%). They are not the assays of named crudes.

vacuum.characterize(assay, cuts_C=...) cuts the curve on a temperature grid. The default is 300-800 C in 25 C steps. Each cut gets:

Property

How

mass yield

difference of the TBP curve across the cut

Tb

the mean of the TBP curve over the cut

SG

from a constant Watson K, fitted over all the cuts so the whole crude matches its bulk SG

MW, Tc, Pc

Twu (1984)

omega

Kesler-Lee (1976)

S, N, CCR, Ni+V, asphaltenes

a logistic in boiling point, scaled to the bulk assay value (or a measured curve)

The heavy end. A TBP curve stops at about 565 C, but a VDU needs pseudocomponents to 750-800 C. The curve is drawn on a probability scale, z = Phi^-1(x) against T, which is close to linear for crude oils. Inside the data it is a monotone cubic Hermite in z, and beyond the data it is a least-squares line through the last three points. The curve is C1 everywhere. That matters because the default grid puts a cut boundary on every assay point (both fall on multiples of 50 C). A piecewise-linear curve has a kink exactly there, so a derivative with respect to that TBP point has two values, and central differences average them. That error measured 1e-3 relative before the curve was made C1.

The residue lump is everything past the last cut. Twu’s n-alkane reference has no root past about 840 C, and Riazi-Daubert is fitted only to about 580 C, so the lump’s Tb, SG and MW are set directly (residue_Tb_C, residue_sg, residue_mw). Its vapor pressure at VDU conditions is then negligible, which is the property that matters.


Thermodynamics at vacuum#

ColumnThermo makes three simplifications, each of which holds at vacuum:

  • K-values are psat / P. At 1-10 kPa the vapor is ideal to far better than the correlations are accurate. Vapor pressure is Maxwell-Bonnell, the correlation ASTM D1160 uses to convert a boiling point measured under vacuum to its atmospheric equivalent. It is anchored at the boiling point the assay measures and fitted down to a fraction of a mmHg. A 500 C cut boils at 331 C at 10 mmHg, as in the D1160 tables. Its three pressure branches are joined by a narrow logistic blend, so Newton sees a continuous derivative. Lee-Kesler with Twu criticals agrees near the boiling point and drifts at the reduced temperatures of a flash zone.

  • Steam does not condense. At the coldest point in the column (60-80 C) water’s vapor pressure is ten times the column pressure. Steam lowers the hydrocarbon partial pressure and carries enthalpy, and that is all it does.

  • The heat of vaporization is R T^2 dln(psat)/dT. It is the Clausius-Clapeyron slope of the curve that sets the K-values, so the energy balance and the phase equilibrium cannot disagree about how volatile a cut is. Liquid enthalpy comes from Kesler-Lee’s Cp.

fit_antoine fits Antoine constants to Maxwell-Bonnell over a range, for handing a pseudocomponent to a simulator that takes nothing else.


The stage-network column#

StageColumn is the machinery any refinery column is built from. Stages are numbered from the top. A furnace outlet flash sits in front of the feed stage. The liquid leaving each stage is split between its routes:

Route kind

Takes

Variable

fixed

a fraction set by the knobs (entrainment)

none

reference

what the share routes leave

none

share

a softmax share of the rest

one logit, with an equation <route>.rate == knob (kg/s)

A route with duty= is a pumparound: its liquid returns to its destination stage with that much heat removed. Draws are softmax shares rather than rates, so a draw can never take more liquid than its stage has. That is what makes a rate spec safe to give Newton.

Specs. Every share route has a default rate equation, and every other knob (a duty, the furnace temperature, a pressure) is a fixed number. A StageSpec(output, target, replaces=...) trades one of those for output == target. The degrees of freedom always balance, and any output can be specified by giving up the knob that controls it: a stage temperature, a product’s TBP point, the overflash, a pumparound return temperature.

Variables and residuals. The variables are, for every stage, the log of each component’s liquid and vapor flow, plus the stage temperature. The component balances are written in log form too, as ln(out) - logsumexp(ln ins). A residue lump in the top stage is 1e-200 of its feed or less. In linear form its flows underflow, its balance rows go to zero and the Jacobian is singular (condition number 1.7e17 was measured). In log form every row is O(1) and exact. Equilibrium is Murphree on the vapor, y = eta K x + (1 - eta) y_in, also in log form.

Solve. The solver runs damped Newton with a dense jax.jacfwd Jacobian; a VDU has about 430 variables. Steps are capped at 30 K on temperatures and 5 on log flows, and an Armijo backtracking line search runs inside lax.while_loop. Trace flows, below 1e-7 of their component’s feed, are exempt from the cap and clipped individually instead. Otherwise one residue-lump vapor that wants to fall by 600 log units scales every step to nothing. Stage efficiencies are reached by a three-pass homotopy: equilibrium stages first, then halfway, then the target.

Gradients. The solve finishes with one Newton step from the converged point with the Jacobian frozen: x* - J^-1 R(x*, theta). Its value is x* and its derivative is -J^-1 dR/dtheta, the implicit-function derivative. jax.grad and jax.jacfwd never differentiate the iteration. The Jacobian the step uses is the last one the Newton loop evaluated, so it is traced only once.


VacuumColumn#

overhead vapor (steam + light HC) -> ejectors
+---------------------------+
| LVGO pumparound section   |  n_lvgo stages; LVGO PA returns to the top
|   LVGO draw (total draw)  |  -> LVGO product + LVGO PA
| HVGO pumparound section   |  n_hvgo stages; HVGO PA returns to its top
|   HVGO draw               |  -> HVGO product + HVGO PA + wash oil down
| wash zone                 |  n_wash stages
|   slop draw (total draw)  |  -> slop / overflash
| flash zone  <- furnace    |  1 stage; entrainment carried up
| stripping section         |  n_strip stages
+---------------------------+  <- bottom steam
vacuum residue

There is no condenser and no reflux drum. The top stage’s vapor leaves as overhead at the top-stage temperature, and the LVGO pumparound holds that temperature. Each packed bed is n equilibrium stages with a Murphree efficiency to fit. The defaults are two stages per bed, two stripping stages and efficiency 1, which gives nine stages.

Default specs (default_vacuum_specs()):

Spec

Replaces

top.T = 70 C

lvgo_pa.duty

overflash = 0.03 (slop / feed, mass)

hvgo.rate

lvgo.T95 = 450 C

hvgo_pa.duty

The pumparound circulations are fixed at 0.3 and 0.8 x feed unless given. With the defaults, the operating levers are the furnace outlet temperature, the flash-zone pressure and the stripping steam, and the yields and qualities come out. That is the trade-off a planner wants to see. To ask the reverse question, for example what furnace temperature a given HVGO end point costs:

vparams = dr.VacuumColumnParams(components=char.components)
specs = dr.default_vacuum_specs() + (
    dr.StageSpec("hvgo.T95", 570.0 + 273.15, replaces="furnace.T"),)
vdu = dr.VacuumColumn(vparams.update(specs=specs))

Outputs (info["outputs"], SI units) include every stage T and P, every route’s .rate, each product’s .T05/.T10/.T50/.T90/.T95, .yield and draw temperature, pumparound .duty and .return_T, furnace.duty, furnace.vapor_fraction, furnace.cracking_margin, flash_zone.T, overflash, vgo.yield and steam.rate. info["properties"] gives SG, API, S, N, CCR, Ni+V, asphaltenes and the TBP points of each product.

Contaminants. S, N, CCR, Ni+V and asphaltenes are carried per pseudocomponent and follow the flows. CCR and metals reach HVGO by two paths. One is vaporization of the heaviest cuts. The other is entrainment: a fraction entrainment of the flash-zone liquid is carried up with the vapor, and the wash bed removes deentrainment of it into the slop. The rest reaches the HVGO draw. Both are parameters to fit.

Furnace outlet. furnace_T_max (415 C) is the cracking limit. It is reported as furnace.cracking_margin and warned on (CrackingWarning), not imposed, so a planner can see the trade-off rather than have it hidden.

Results on the two included assays#

Default specs, 100 kg/s of crude, furnace 400 C, flash zone 30 mmHg:

light: LVGO

HVGO

slop

residue

heavy: LVGO

HVGO

slop

residue

yield on feed

0.155

0.487

0.030

0.328

0.071

0.296

0.030

0.604

TBP T50 (C)

393

484

605

674

391

473

595

753

TBP T95 (C)

450

579

699

868

450

566

834

888

SG

0.873

0.910

0.955

0.988

0.907

0.942

0.996

1.052

S (wt%)

0.76

1.13

1.42

1.49

2.76

4.01

5.18

5.55

CCR (wt%)

0.19

1.48

8.0

15.2

0.29

1.84

12.9

28.4

Ni+V (wppm)

0.00

0.14

8.1

57

0.01

1.05

100

495

These numbers moved slightly when the vacuum unit moved onto the shared characterization (#301). A cut’s Tb is now the mean of the curve over it, not its mid-percent point, and the Watson K is fitted over the cuts themselves. Gravities fell by up to 0.004 and sulfur rose by up to 3 %. Yields and TBP points are unchanged to the figures shown.

Both converge from the default initialization in 6-7 Newton iterations. The per-pseudocomponent mass balance closes to 1e-15. Implicit gradients match central differences to better than 1e-5 relative, for product rates and HVGO properties with respect to furnace T, flash-zone P, steam, and the 500 C point of the assay’s TBP curve (tests/refinery/test_vacuum.py).


Vacuum unit gotchas#

  • An end point the beds cannot make is infeasible, not hard. Each Murphree stage passes (1 - eta) of the vapor through unchanged. An HVGO bed of two stages at 70% therefore sends 9% of the heavy vapor into the LVGO section. The default lvgo.T95 = 450 C then cannot be met at any HVGO pumparound duty, and Newton drives the duty to 1e13 W before it gives up. Loosen the spec (520 C converges) or raise the efficiency.

  • Specs must be feasible for the feed. Overflash, draw rates and end points compete for the same vaporized material. A furnace that vaporizes 25% of the feed cannot give 30% HVGO. When the solve does not converge, check that the specs add up before suspecting the solver.

  • The LVGO and slop draws are total draws. No liquid passes from the LVGO section to the HVGO section, or from the wash bed to the flash zone, as on chimney trays. The LVGO yield is therefore whatever the HVGO section does not condense. It is set through the HVGO pumparound, by the lvgo.T95 spec.

  • Compile once, solve many. The first solve for a spec structure (which outputs replace which knobs) compiles in about 20 s. Every later solve with the same structure costs about 0.2 s, whatever the numbers, because spec targets, knobs, efficiencies and the whole assay are runtime inputs.


Vacuum unit: out of scope#

Ejectors and vacuum-system modelling, dynamics, lube vacuum towers, and D1160 (as opposed to TBP) product curves. The cross-check against an independent simulator is against an equation-oriented re-implementation in Pyomo/IPOPT on the same characterized feed, not against DWSIM; see Validation: the vacuum unit for what that does and does not establish.


The fluid catalytic cracker#

difflow_refinery.fcc (issue #308) is the gasoline-oriented refinery’s VGO conversion unit: a riser with lumped cracking kinetics and catalyst deactivation, a regenerator that burns the coke, the two solved together as the unit’s heat balance, and a main fractionator (simplified, see below) that splits the effluent into dry gas, C3, C4, gasoline, light cycle oil (LCO) and slurry. Like the blending pool it is a library, not a palette operation: nothing new is registered with the editor.

import difflow_refinery as dr
from difflow_refinery.fcc import FCCFeed, FCCParams, FCCUnit, fcc_block
from difflow_refinery.vacuum import light_crude

char = dr.characterize(light_crude().to_assay(heavy_end=dr.HeavyEnd()))
feed = FCCFeed.from_characterization(char, rate=50.0,             # kg/s
                                     T_lo=616.15, T_hi=823.15)    # 343-550 C VGO
unit = FCCUnit(FCCParams(riser_outlet_T=793.15, feed_T=500.0))
res = unit.solve(feed)
res["outputs"]["conversion"], res["outputs"]["cat_oil"], res["outputs"]["regenerator_T"]
# (0.747, 5.82, 1004.1 K) -- with the ILLUSTRATIVE default constants
res["balances"]          # mass, C, H, S, N, energy: relative errors ~1e-16

The feed can also be a VDU outlet stream: FCCFeed.from_stream(hvgo, vdu.params.components), or FCCUnit(params, components=...) called on the stream directly (it then returns the eight outlet streams and the result dict).

What is physics and what is fitted. The heat balance – energy and mass conservation across riser and regenerator, coke combustion stoichiometry, ideal-gas flue-gas enthalpies – is transferable. Yields are not: every published lumped parameter set is for one feed and one catalyst, and the defaults shipped here are not even that (below). Out of the box the yields are illustrative; the unit is predictive only after the kinetic constants and activity are fitted to the user’s test-run data.

FCC model#

Riser (difflow_refinery.fcc.riser). One-dimensional adiabatic plug flow in height z, integrated with diffrax (Tsit5, constant step, DirectAdjoint):

\[ \frac{dt_c}{dz} = \frac{s}{u_g},\qquad \frac{dy}{dz} = \frac{C}{O}\,\frac{dt_c}{dz}\; a\, f_\text{feed}\; r(y, T, \phi),\qquad u_g = \frac{n_\text{vap} R T}{P A},\qquad T = T_\text{mix} - \frac{F_o\,\Delta H_c\,(1-y_\text{go})}{C_{p,\text{tot}}} \]
  • y: lump mass fractions (feed basis); t_c: catalyst residence time; s: slip factor; a: catalyst activity; f_feed = exp(k_K (K_w - K_ref)) / (1 + N_basic/N_0): feed crackability and basic-nitrogen poisoning (illustrative forms).

  • dy/dz is the catalyst-holdup formulation (F_o dy = r F_c dt_c), so rate constants are in 1/s per unit catalyst-to-oil ratio.

  • Rate law, all schemes: gas-oil cracking second order in the gas-oil mass fraction, every other reaction first order [F1]; Arrhenius about 500 °C, k = k_ref exp(-Ea/R (1/T - 1/773.15)).

  • Energy: one constant vapour heat capacity for every hydrocarbon lump, and the heat of cracking ΔH_c (J per kg gas oil converted, endothermic) charged to every product including coke. Then the riser enthalpy is invariant along z and T is algebraic in the gas-oil fraction (above).

  • T_mix: adiabatic mixing at the riser base of regenerated catalyst at T_rg, liquid feed at the preheat temperature (vaporising with latent heat λ at that temperature) and riser steam.

  • Feed effects at the riser base, taken out of the gas oil before it cracks (all illustrative): additive coke ccr_to_coke × CCR; contaminant coke metals_coke × (Ni+V) and contaminant H2 metals_h2 × (Ni+V).

  • Deactivation: phi = exp(-alpha t_c) (time on stream, [F5]) or phi = exp(-alpha C_c) (coke on catalyst). voorhies_coke(t_c, A, n) = A t_c^n [F6] is provided as a diagnostic; the riser’s coke is a kinetic lump.

Scheme

Lumps

Reactions

Source

weekman_nace_3

gas oil, gasoline, gas+coke

GO→G, GO→C, G→C

[F1]

lee_4

gas oil, gasoline, gas (C1-C4), coke

GO→G, GO→gas, GO→coke, G→gas, G→coke

[F2]

ancheyta_5 (default)

gas oil, gasoline, LPG (C3-C4), dry gas (H2, C1-C2), coke

GO→G, GO→LPG, GO→DG, GO→coke, G→LPG, G→DG, G→coke

[F3]

jacob_10

–

–

[F4]; not implemented

The 3- and 4-lump schemes’ combined lumps are mapped onto products by split parameters (coke_share, dry_gas_share).

Regenerator (difflow_refinery.fcc.regenerator). A well-mixed bed at T_rg burns coke of composition C/H/S/N (coke H coke_hydrogen; S at coke_sulfur_factor × feed S; n_to_coke of the feed N) to CO2, CO, H2O, SO2 and N2. CO/CO2 is specified (co_co2, 0 = full burn) or Arthur’s primary-product ratio [F7], CO/CO2 = 10^3.4 exp(-12400/RT) (R in cal/mol/K) times arthur_factor; afterburn is not modelled, so "arthur" is a partial-burn model. Air (dry, 20.95 % O2) follows from the flue-gas O2 spec (flue_o2, wet mole fraction) – or air_rate is given and the excess O2 is an output. Enthalpies are ideal-gas, Hf(298.15) + ∫Cp dT [F10, F11], from the refinery’s shared thermochemistry table; coke’s enthalpy of formation is zero (elements), so its heat of combustion follows from its H content. The regenerated catalyst leaves clean.

Heat balance (difflow_refinery.fcc.unit). Unknowns C/O and T_rg; equations: riser outlet temperature = ROT spec, and regenerator energy in = out. Newton (optimistix) from C/O = 6, T_rg = 980 K; gradients of every output by the implicit function theorem (optimistix’s implicit adjoint, forward and reverse mode). The heat balance can have more than one steady state (multiplicity is a known property of FCC heat balances); with a very active catalyst the solve can land on a hot, low-circulation one, and RegeneratorTemperatureWarning fires above regenerator_T_max (760 °C, illustrative) – the state is reported, not hidden.

Products. Real species for the gases; product pseudocomponents fcc01..fcc22 for the liquids:

  • Dry gas: hydrogen, methane, ethane, ethylene by a mass split (dry_gas_split), plus contaminant H2 and H2S.

  • LPG: C3 share c3_share; propylene share of C3 propylene_in_c3; olefin share of C4 olefins_in_c4; isobutane share of C4 paraffins; the butenes split over 1-butene, isobutylene, cis- and trans-2-butene (butene_split). These are the “olefin split parameters” of the issue; all illustrative.

  • Gasoline (C5-221 °C) and cycle oil (221 °C+, LCO + slurry) lumps are put on a fixed pseudocomponent grid by a fixed TBP distribution per lump (a logistic CDF, (T50, width) per lump), gravities from a Watson K per lump, MW from Twu [the plugin’s correlations].

  • Elemental bookkeeping: gasoline and coke carry hydrogen contents (parameters; gasoline H moves with feed H by gasoline_hydrogen_slope); sulfur in gasoline, coke and cycle oil at multiples of feed S (cycle oil 1 + cycle_oil_sulfur_slope × X), H2S takes the rest; nitrogen n_to_coke to coke, the rest to cycle oil; cycle-oil hydrogen by difference (reported as cycle_oil_hydrogen – 8.7 and 7.4 wt% on the two test feeds – so an implausible value is visible); carbon is mass less H, S, N (feed metals and oxygen are counted as carbon).

  • Gasoline RON/MON: RON = ron_ref + ron_dT (ROT - T_ref) + ron_dX (X - X_ref), likewise MON – fitted forms with illustrative defaults; there is no transferable open correlation. Gasoline PONA is a fixed parameter vector (olefins 27 vol%).

  • LCO cetane index: ASTM D4737 (blending.cetane_index_d4737) on D86 points from the TBP curve; known to read high for aromatic cracked stocks [F12], so treat it as indicative.

Main fractionator – simplified. The issue asks for a StageColumn layout with pumparounds, side strippers and a bottom quench. That is not what is built. The fractionator is a smooth TBP split of the product pseudocomponents at two cut points, gasoline_cut (221 °C) and lco_cut (343 °C): each pseudocomponent goes to the lighter product with fraction sigmoid((T_cut - Tb)/w) (split_width 6 K), which mimics real product overlap and keeps the cut points differentiable. The gases are split ideally into dry gas, C3 and C4 – standing in for the gas plant (difflow_refinery.gasplant, #312), which reached main after this unit was built; routing through it is follow-up work. Mass is conserved exactly; there is no energy model of the fractionator (no condenser or pumparound duties), and the energy balance covers riser and regenerator only.

FCC degrees of freedom and specs#

Spec / lever

Parameter

Notes

Riser outlet temperature

riser_outlet_T (K)

the spec; catalyst circulation follows from the heat balance

Feed preheat

feed_T (K)

Feed rate

FCCFeed.mass (kg/s)

feed.with_rate(...); recycle of HCO/slurry is not modelled

Regenerator air

flue_o2 (wet mole fraction) or air_rate (kg/s)

CO/CO2

co_co2 or "arthur" (+ arthur_factor)

Catalyst activity

activity

the parameter to track with difflow.reconciliation.tracking

Fractionator

gasoline_cut, lco_cut (K)

TBP cut points

Kinetics

k_ref, Ea (per reaction), deactivation_alpha

to be fitted

Every numeric parameter is traceable: unit.solve(feed, riser_outlet_T=jnp.asarray(800.0)), or differentiate through FCCFeed fields and through characterize (pass indices= from fcc.feed.cut_indices when the assay itself is traced).

Outlets (FCCUnit.OUTLETS, difflow streams in mol/s; species are difflow.database names):

Outlet

Species

Consumer

dry_gas

hydrogen, methane, ethane, ethylene, hydrogen_sulfide (+ feed species not on the feed’s property table, passed through)

fuel gas / amine

c3

propane, propylene

alkylation (#310), polymer-grade propylene

c4

isobutane, n_butane, 1_butene, isobutylene, cis_2_butene, trans_2_butene

alkylation (#310): C4= olefins and isobutane

gasoline

fcc01..fcc22

BlendPool (BlendComponent from properties: SG, RON, MON, S, olefins)

lco

fcc01..fcc22

diesel hydrotreating

slurry

fcc01..fcc22

fuel oil

sour_water

water

flue_gas

nitrogen, oxygen, carbon_dioxide, carbon_monoxide, water, sulfur_dioxide

carbon_monoxide is not in difflow.database; it is defined (formula, Cp, Hf) in fcc.species.

Outputs (res["outputs"]): conversion (1 - unconverted 221 °C+ lump, coke included), cat_oil, catalyst_circulation, regenerator_T, regenerator_margin, mix_T, residence_time, activity_out, coke_on_catalyst, air_rate, flue_o2/flue_co/flue_co2, co_co2, coke_burn; yield.<x> for dry_gas, lpg, c3, c4, gasoline_lump, cycle_oil, coke, h2s and the fractionator products gasoline, lco, slurry; per liquid product <p>.SG, .API, .sulfur, .hydrogen, .nitrogen, .tbp10/50/90; gasoline.RON, .MON, .olefins, …; lco.cetane_index; c3_olefins, c4_olefins, cycle_oil_hydrogen. unit.profile(feed, res["x"]) gives the riser profiles.

Planning. fcc_block(unit, feed, levers=["riser_outlet_T", "feed_T", "feed.ccr"], outputs=[...]) is a difflow.planning.Block, like cdu_block: its Jacobian carries the heat-balance coupling a fixed yield vector misses. Levers are numeric FCCParams fields and feed.<field>; outputs are res["outputs"] keys, in SI units; a non-converged solve returns NaN.

FCC: what is tested, and what is not#

Tested (tests/refinery/test_fcc.py):

  • Converges from the default initialisation on the light and heavy synthetic VGOs (343-550 °C cuts of vacuum.light_crude / heavy_crude), heat balance closed (|residual| < 1e-10).

  • Mass, C, H, S, N and energy (riser + regenerator) balances close to 1e-8 relative – in practice to ~1e-16 – for both feeds and for every scheme/deactivation/burn option.

  • jax.jacfwd of conversion, gasoline yield, coke yield and regenerator T with respect to ROT, feed preheat and one VGO TBP point (450 °C, through characterize) matches central differences to 1e-5 relative (observed ~1e-7); reverse mode matches forward.

  • Conversion rises with ROT; the gasoline lump passes through an interior maximum along a ROT sweep (overcracking); higher feed CCR raises the regenerator temperature at fixed ROT; the riser solution is converged in step size (the default 200 steps and 800 steps agree to 1e-8).

  • Pinned constants: Arthur’s as quoted, heats of formation, IUPAC atomic weights, N2/CO2 Cp equal to difflow.database’s.

Not done (stated plainly):

  • Literature cross-check: not done. Reproducing the published steady state of Arbel et al. (1995) [F8] or McFarlane et al. (1993) Model IV [F9], or Ancheyta et al.’s (1999) predicted yields [F3], needs the papers’ parameter tables and reported results. The papers could not be accessed for this implementation (network access to publishers was blocked), and no numbers from them are reproduced or claimed.

  • Default rate constants are not from any paper. ILLUSTRATIVE_5LUMP was chosen to give commonly quoted VGO FCC yield ranges; the 3- and 4-lump defaults aggregate it. The networks follow the papers’ descriptions; the 5-lump reaction set follows the abstract of [F3] (seven reaction constants plus deactivation, “eight kinetic constants”) and was not checked against the paper’s scheme figure.

  • Jacob et al.’s 10-lump composition-aware scheme [F4] is not implemented (get_scheme("jacob_10") raises). #305’s Composition is read only for feed hydrogen.

  • Main fractionator on StageColumn: not built; the simplified split above stands in for it.

  • Riser hydrodynamics: a constant slip factor (default 2, illustrative). The Han & Chung (2001) [F13] parameters named in the issue were not checked and are not used.

  • HCO/slurry recycle, stripper (entrained hydrocarbons in coke), carbon on regenerated catalyst, NOx/NH3/HCN, pressure drop along the riser, and the gas plant: not modelled here (difflow_refinery.gasplant, #312, reached main after this unit was built; routing c3/c4 through it is follow-up work).

  • Example notebook (VDU → FCC → gas plant and gasoline pool): not written.

  • No catalyst-vendor or licensor yield model: proprietary, out of reach by design.

Performance. The first solve of a configuration (scheme, deactivation kind, burn and air modes) compiles in about 7 s; later solves take ~20-30 ms. A Jacobian through characterize and the unit compiles in about 30 s.

FCC references#

“Verified” below means the bibliographic data (authors, title, journal, volume, pages, year) were confirmed by web search; the papers’ contents (equations, tables, constants) could not be read for this implementation, so no equation or table number is claimed from them unless stated.

Key

Reference

Used for

How checked

F1

Weekman, V.W., Jr.; Nace, D.M. “Kinetics of catalytic cracking selectivity in fixed, moving, and fluid bed reactors.” AIChE J. 1970, 16(3), 397–404.

3-lump network; gas oil second order, gasoline first order

Citation verified (web search). Rate-law orders are the well-known form of this model, not re-read from the paper (unverified). No constants used.

F2

Lee, L.-S.; Chen, Y.-W.; Huang, T.-N.; Pan, W.-Y. “Four-lump kinetic model for fluid catalytic cracking process.” Can. J. Chem. Eng. 1989, 67, 615–619.

4-lump network

The 4-lump network (Weekman’s gas+coke lump split into gas and coke) confirmed by web search of citing papers; journal, volume, pages unverified. No constants used.

F3

Ancheyta-Juárez, J.; López-Isunza, F.; Aguilar-Rodríguez, E. “5-Lump kinetic model for gas oil catalytic cracking.” Appl. Catal. A: General 1999, 177(2), 227–235.

5-lump network

Citation and abstract verified (web search: eight kinetic constants including deactivation; LPG C3-C4 and dry gas C2- lumps; MAT at 480/500/520 °C on one VGO and one equilibrium catalyst). Reaction set as coded unverified against the paper’s figure. No constants used; predicted yields not reproduced.

F4

Jacob, S.M.; Gross, B.; Voltz, S.E.; Weekman, V.W., Jr. “A lumping and reaction scheme for catalytic cracking.” AIChE J. 1976, 22(4), 701–713.

(10-lump scheme, not implemented)

Authors, title, journal, volume, first page verified; end page unverified.

F5

Weekman, V.W., Jr. “A model of catalytic cracking conversion in fixed, moving, and fluid-bed reactors.” Ind. Eng. Chem. Process Des. Dev. 1968, 7(1), 90–95.

Exponential time-on-stream deactivation; C/O × t_c formulation

Unverified (web search did not return the paper); the exponential decay form is as commonly attributed to it.

F6

Voorhies, A., Jr. “Carbon formation in catalytic cracking.” Ind. Eng. Chem. 1945, 37(4), 318–322.

voorhies_coke C = A t^n

Citation verified (web search).

F7

Arthur, J.R. “Reactions between carbon and oxygen.” Trans. Faraday Soc. 1951, 47, 164–178. doi:10.1039/TF9514700164

CO/CO2 ratio form

Citation verified (RSC listing; DOI from the RSC article URL). Constants 10^3.4 and 12400 cal/mol are as quoted in the FCC literature, unverified against the paper.

F8

Arbel, A.; Huang, Z.; Rinard, I.H.; Shinnar, R.; Sapre, A.V. “Dynamic and control of fluidized catalytic crackers. 1. Modeling of the current generation of FCC’s.” Ind. Eng. Chem. Res. 1995, 34(4), 1228–1243.

(cross-check target, not done)

Citation verified (web search).

F9

McFarlane, R.C.; Reineman, R.C.; Bartee, J.F.; Georgakis, C. “Dynamic simulator for a model IV fluid catalytic cracking unit.” Comput. Chem. Eng. 1993, 17(3), 275–300.

(cross-check target, not done)

Citation verified (web search).

F10

Chase, M.W. NIST-JANAF Thermochemical Tables, 4th ed., J. Phys. Chem. Ref. Data Monograph 9, 1998 (as tabulated in the chemicals package; N2 and O2 through the NIST WebBook Shomate fits to them).

Ideal-gas Cp of N2, O2, CO2, CO, H2O, SO2: cubics fitted by this project over 298.15-1500 K, within 1.1 % of the tables

Since #339 (difflow_refinery.thermochemistry). Before it, Reid, Prausnitz & Poling 4th ed. cubics, four of them unverified transcriptions; they agree with the JANAF fits to 1.1 % below 1050 K.

F11

Cox, J.D.; Wagman, D.D.; Medvedev, V.A. CODATA Key Values for Thermodynamics; Hemisphere: New York, 1989.

Hf(298.15) of CO2 (-393.51), H2O(g) (-241.826), SO2 (-296.81), CO (-110.53) kJ/mol

The standard tabulated values; they agree with NIST-JANAF and ATcT 1.112 (as in chemicals) within 0.05 kJ/mol. Pinned by test in test_thermochemistry.py.

F12

ASTM D4737, Standard Test Method for Calculated Cetane Index by Four Variable Equation (edition unverified).

LCO cetane index

The plugin’s existing cetane_index_d4737.

F13

Han, I.-S.; Chung, C.-B. “Dynamic modeling and simulation of a fluidized catalytic cracking process. Part I: Process modeling.” Chem. Eng. Sci. 2001, 56(5), 1951–1971.

(riser slip parameters, not used)

Citation verified (web search).

F14

Sadeghbeigi, R. Fluid Catalytic Cracking Handbook, 3rd ed.; Butterworth-Heinemann: Oxford, 2012.

Orders of magnitude for the illustrative defaults (yield ranges, coke H 6-8 wt%, H2S share of feed S, CCR to coke)

Not checked in this session (unverified); no number is attributed to a specific page.

F15

IUPAC CIAAW, standard atomic weights (abridged/conventional values), Prohaska, T. et al. Pure Appl. Chem. 2022, 94(5), 573–600.

Atomic weights C 12.011, H 1.008, N 14.007, O 15.999, S 32.06

Values standard; page range unverified.

Illustrative parameters (no source claimed for any number; each is a plausible order of magnitude to be fitted): all k_ref and Ea, activity, deactivation constants, kw_sensitivity, nitrogen_poisoning, basic_nitrogen_fraction, ccr_to_coke (0.6), metals_coke, metals_h2, all gas splits, product H/S/N factors and gradients, the lump TBP distributions and Watson Ks, heat_of_cracking (350 kJ/kg), latent_heat (250 kJ/kg), cp_vapor (3.0 kJ/kg/K), cp_catalyst (1.15 kJ/kg/K), cp_steam (2.1 kJ/kg/K), riser geometry, slip, steam ratio, the octane forms and the gasoline PONA, and regenerator_T_max.

The saturated gas plant#

The gas plant (difflow_refinery.gasplant, #312) recovers the light ends. Its feeds are the CDU overhead gas and unstabilised naphtha, and an FCC’s wet gas where there is one. Its products are fuel gas, LPG, and a stabilised naphtha cut to a vapour-pressure spec:

wet gas -> GasCompressor -> AmineTreater -> absorber-deethanizer -> fuel gas
                 | condensate                    | bottoms
                 +---------------------------->  +-> debutanizer -> LPG -> C3/C4 splitter
unstabilised naphtha ---------------------------^                \-> stabilised naphtha

At 10-20 bar, Raoult’s law is no longer the right model, and the crude column’s thermodynamics would be wrong here by tens of percent in K. The gas plant therefore runs on a cubic equation of state, Peng-Robinson (default) or SRK. Real components and naphtha pseudocomponents go into one GasComponents table, so one EOS covers the mixture:

import difflow_refinery as dr
from difflow_refinery.gasplant import gas_components, debutanizer, GasPlantColumn

cuts = dr.characterize(dr.Assay([0, 50, 100], [360., 400., 470.], sg=0.74),
                       cut_points=[385., 420.])
comps = gas_components(["hydrogen_sulfide", "ethane", "propane", "isobutane",
                        "n_butane", "isopentane", "n_pentane", "n_hexane"], pseudo=cuts)
col = GasPlantColumn(debutanizer(comps, naphtha_rvp=80e3))
weights = {"ethane": 0.2, "propane": 8, "isobutane": 5, "n_butane": 12, "isopentane": 10,
           "n_pentane": 12, "n_hexane": 20, "pc02": 18, "pc03": 15, "hydrogen_sulfide": 0.05}
gas_feed = {f"F_{n}": float(weights.get(n, 0.0)) for n in comps.names}   # mol/s
gas_feed.update(T=380.0, P=12e5)
lpg, naphtha, info = col(gas_feed)
info["outputs"]["reboiler.duty"], info["outputs"]["bottoms.rvp"]

Components and thermodynamics#

gas_components(light, pseudo=None, kij=None, cuts=None) builds the table from two sources. For the real species (hydrogen, H2S, N2, CO2, C1-C6 paraffins, ethylene, propylene and the four butenes), it uses difflow.database plus the tables in gasplant/components.py, whose sources are listed in that module. For the pseudocomponents, it uses the refinery characterisation’s Tc, Pc, acentric factor and Watson-Nelson Cp. kij is zero between hydrocarbons. The tabulated nonzero pairs (CO2, H2S and N2 with the light paraffins) are recalled from the DECHEMA compilation and are marked verify in the source. Each component also carries a lower heating value, computed from its heat of formation and those of CO2, H2O and SO2, all from the shared thermochemistry table (#339). The ideal-gas Cp cubics of the gas plant stay its own: they are separation thermo, and the IDAES and DWSIM references are built on them.

cuts= keeps only the named cuts of the characterisation, in its own order. A naphtha taken off a whole-crude characterisation carries the first few cuts and almost nothing of the rest, and every cut in the table is a column in every EOS call. examples/38_refinery_gas_plant.ipynb keeps the cuts above 0.1 % of the naphtha and folds the remainder, about 1e-4 of it, into the heaviest cut it keeps.

CubicThermo gives ln K = ln phi_L - ln phi_V at each stage’s own (T, P, x, y), and residual enthalpies from the departure functions. The cubic is solved in closed form. One Newton polish then carries the root’s exact implicit derivative, so no gradient passes through arccos. Where the cubic has a single real root, both phases take it and K = 1, as in any cubic-EOS package.

GasPlantColumn#

GasPlantColumn is the vacuum unit’s stage network on the EOS. The MESH equations use log flows, the same StageSpec mechanism and the same implicit-function gradients. Trays are numbered from 1 at the top. The column can have any of:

  • a condenser that is "total" (the liquid at its bubble point, duty computed), "partial" (vapour product, duty a knob) or None (an absorber top);

  • a kettle reboiler, or none;

  • any number of feeds, each a stream at its own (T, P), flashed once per solve;

  • liquid side draws.

The factories return the parameters with the spec set each column is normally run on:

Factory

Products

Default specs (replacing)

absorber_deethanizer

overhead (fuel gas), bottoms

bottoms.x.C2- = 0.005 (reboiler duty); or bottom.T

debutanizer

distillate (LPG), bottoms (naphtha)

distillate.x.C5+ = 0.01 (distillate rate); bottoms.rvp or bottoms.x.C4 (reboiler duty)

c3c4_splitter

propane, butane

distillate.x.C3 = 0.95; bottoms.x.light = 0.02

deisobutanizer

isobutane, normal butane

distillate.x.isobutane = 0.95; bottoms.x.isobutane = 0.05

splitter

any two-product cut

the cut placed by light=, specs as (output, target) pairs

The absorber-deethanizer takes two feeds, lean_oil on tray 1 and feed. Its lever is the lean-oil rate, which is the lean-oil stream’s own flow. Every factory accepts the GasPlantColumnParams fields as keywords (n_trays, top_P, side_draws=, eos="SRK", …), so the same factories serve a naphtha splitter (#311).

Outputs (info["outputs"], SI) include:

  • per product: <p>.x.<component|group>, .recovery.<...>, .mol, .rate (kg/s) and .T; for liquid products also .rvp and .tvp;

  • reflux_ratio, boilup_ratio, condenser.duty, reboiler.duty, energy_balance;

  • top.T, bottom.T and every stage{j}.T/.P;

  • <feed>.vapor_fraction.

The groups are C2-, C3, C4, C3-, C4+ and C5+. Pseudocomponents count as C5+. Any output can be specified.

Tray efficiency. By default the factories apply O’Connell’s (1946) correlation, E_o = 0.492 (alpha mu_L)^-0.245, as the Murphree vapour efficiency of every tray. Alpha is the key components’ relative volatility at the feed. mu_L is liquid_viscosity, 0.1 cP by default, which is a typical C3-C6 value and not a prediction. Two caveats apply:

  • E_MV = E_o holds only at a stripping factor of one;

  • the correlation is itself good to about 25%.

So the column is a rating model of that accuracy. tray_efficiency=1.0 turns the trays into theoretical stages.

Vapour pressure. <product>.rvp is the ASTM D323 construction on the EOS: the liquid in contact with four times its volume of vapour, at 100 F. It is computed with the same EOS, not with a correlation. .tvp is the bubble-point pressure at 100 F.

Solve. The solve runs in three passes, like the vacuum column’s:

  1. Equilibrium stages with easy specs. A rate is used for a rate slot, the reflux ratio for the distillate of a total condenser, and the boilup ratio for a reboiler duty.

  2. Continuation of every target and efficiency to the user’s values.

  3. One implicit-function step.

Each pass is the vacuum column’s damped Newton (step caps, Armijo line search) with two changes. When the line search fails within ten halvings, the step is Levenberg-Marquardt’s, -(J'J + mu diag(J'J))^-1 J'r, with mu raised until the residual falls. A stage at the edge of the cubic’s three-root region can make the Jacobian nearly singular: the C3/C4 splitter’s pass 1 had cond(J) near 3e7, its Newton step was 1e9 K long, and it ran out its 60 iterations. A trace log flow (under 1e-7 of its feed) may also rise to that threshold in one step. The heaviest cut’s guess can sit e^-600 below its inflow on a light stage, and the vacuum column’s +5 clip then spends a hundred iterations climbing out.

The initial guess comes from the feed. Wilson K-values place the temperature profile between the overhead’s dew point and the bottoms’ bubble point. No user initialisation is needed.

GasCompressor#

Registered as the operation WetGasCompressor; GasCompressor in the catalog is the core unit.

The wet-gas compressor has n_stages isentropic stages at equal pressure ratios. Each stage’s work is the isentropic enthalpy rise divided by efficiency. An aftercooler and knockout drum follow each stage. The condensate from all the drums leaves as one liquid stream, which in a gas plant joins the absorber feed. info reports:

  • power (W);

  • stage_power;

  • discharge_T;

  • the stage pressure ratio.

Surge, choke and the compressor map are out of scope.

AmineTreater#

H2S is a component throughout. The amine contactor is a fixed removal fraction per component (removal={"hydrogen_sulfide": 0.99}), which splits the gas into sweet gas and acid gas. Treating chemistry and Merox are out of scope. For a rate-based contactor, see difflow_cc.AmineAbsorber.

Products#

  • fuel_gas(flows, comps) returns the rate, mass rate, MW, LHV (molar and mass), heat release and H2S ppm.

  • lpg_quality(flows, comps, grade) checks the LPG against a GPA 2140 grade ("HD-5", "commercial_propane", "commercial_butane"). It returns values, signed margins (positive on spec) and on_spec. The vapour pressure is gauge, at 100 F, on the EOS.

  • reid_vapor_pressure(flows, comps) and true_vapor_pressure give the same numbers the column reports.

The limits in GPA_2140 are recalled values and are marked verify. The standard writes its composition limits in liquid volume percent; they are compared here as mole fractions, which differ by a few percent of the value for C3/C4. The 95% evaporated, residue, copper strip, sulfur and moisture tests are not computed.

Planning with the gas plant#

gasplant_block(column, feeds, levers, outputs=None) is the gas plant’s cdu_block. It returns a difflow.planning.Block whose delta vectors are implicit-function Jacobians of the converged column. The levers are of three kinds:

  • every spec target by its own name (distillate.x.C5+, bottoms.rvp);

  • every knob a spec has not replaced (top.P);

  • per feed, <feed>.mol, <feed>.T and <feed>.F_<component>.

The outputs are in planner units (C, kPa, MW, kg/h, kmol/h). Non-convergence is masked to NaN, as in cdu_block.

from difflow_refinery.gasplant import gasplant_block

blk = gasplant_block(col, [gas_feed], ["distillate.x.C5+", "bottoms.rvp", "top.P", "feed.mol"],
                     outputs=["reboiler.duty", "condenser.duty", "distillate.rate"])

Results#

All four factories converge from the default initialisation, with no warnings. They are run on two feeds: a straight-run feed (CDU light ends with H2S, and two naphtha pseudocomponents) and an FCC feed (adding hydrogen, ethylene, propylene and the four butenes). On both feeds:

  • the total mass balance closes to 1e-15 relative;

  • every component’s balance closes to better than 1e-8;

  • each column’s energy balance closes to 1e-9 W on duties of order 1 MW.

On the deisobutanizer, the FCC butenes boil with the isobutane. The olefin-rich case is therefore run at a 0.5 isobutane purity: a higher purity is not available from that feed at any reflux, and the solve says so by not converging.

The tests check the following, in tests/refinery/test_gasplant.py:

  • The implicit gradients of LPG C5+, naphtha RVP and reboiler duty, with respect to the reflux ratio, the top pressure and a feed component, match central differences to 1e-5 relative.

  • The reboiler duty rises monotonically as the naphtha RVP spec is tightened.

  • The absorber-deethanizer’s C2 slip falls monotonically as its bottoms temperature rises.

The gas plant on a crude unit#

examples/38_refinery_gas_plant.ipynb runs the whole chain on the CDU of examples/35_refinery_cdu_planning.ipynb, with a partial condenser held at 40 C:

  • the offgas goes through a two-stage compressor to 14.5 bar, then the amine treater;

  • an absorber-deethanizer takes the whole unstabilised naphtha as lean oil;

  • a debutanizer makes the LPG;

  • a naphtha splitter makes light and heavy naphtha.

Every column converges from the default initialisation. The material balance across the plant closes to 1e-12 mol/s on 280 mol/s. The debutanizer’s implicit derivatives with respect to its C4 spec match central differences to the digits printed.

This crude’s offgas is mostly C3/C4. At 14.5 bar and 40 C almost all of it condenses in the compressor’s knock-out drums, so the amine treats a few percent of what was compressed. The condensate carries most of the H2S past it, into the fuel gas and the LPG.

The H2S figure starts from an assumption. The assay says nothing about sulfur, so the offgas is given 2 mol % H2S.

From crude-unit products to a gas-plant feed#

The crude unit’s products are on the crude’s whole characterization (F_<light end>, F_pc01 … F_pcNN, F_water); the gas plant works on a GasComponents table holding only the light ends and the cuts the naphtha carries. gas_plant_feed (#326) is the bridge:

from difflow_refinery.gasplant import gas_plant_feed
# the total condenser of the test column makes no offgas; with a partial condenser use
# streams=("offgas", "naphtha") and, say, h2s={"offgas": 0.02}
gp = gas_plant_feed(cdu.last_result.products, char,
                    ["hydrogen_sulfide", "ethane", "propane", "isobutane",
                     "n_butane", "isopentane", "n_pentane"],
                    streams=("naphtha",), min_fraction=1e-3, T=313.15, P=1.3e5)
comps, naphtha = gp.components, gp["naphtha"]
print(gp.summary())

It does three things, in this order:

  1. Selects the cuts. Cut \(i\) is kept when \(F_i^{\mathrm{basis}} > f_{\min} \sum_j F_j^{\mathrm{basis}}\), the sum over every component of the basis stream (the naphtha by default) as the crude unit reports it, water included. The selection is a static choice: it is made on the concrete flows, or given as cuts= (required under jax.jit).

  2. Folds the rest. Every cut not kept, in every converted stream, goes into the heaviest kept cut \(d\): \(F_d \leftarrow F_d + \sum_{i\ \mathrm{folded}} F_i\). Moles are conserved exactly. Mass is not: the fold changes it by \(\sum_i F_i (M_d - M_i)\) (fold_mass_change, negative since the folded cuts are the heavy ones). mass_change is the whole difference between the stream on the gas-plant table and its water-free mass on the characterization, so it also holds the small difference between a light end’s database molar mass and the characterization’s.

  3. Drops the water (and anything else in drop=), reported as dropped_mol / dropped_kg.

A light end in a stream that is neither in light nor in drop= raises, rather than being lost. folded_fraction is the folded moles of all streams over the basis stream’s total (the figure example 38 prints); folded_fraction_of(name) is per stream. The result is differentiable in the stream flows (and the characterization’s arrays): folding is a sum.

H2S. The crude unit makes none; the assay’s sulfur stays on the cuts. Two explicit ways to add it:

  • h2s={"offgas": r}: an assumption, \(r\) mol of H2S per mol of the water-free stream (examples 38 and 40 use \(r = 0.02\)).

  • h2s_flow={"offgas": evolved_h2s(cdu.products, char, fraction)}: a flow from a sulfur balance, \(F_{\mathrm{H_2S}} = \phi \sum_{\mathrm{streams}} \sum_i F_i M_i S_i / M_S\), with \(S_i\) the characterization’s sulfur mass fraction (an assay with sulfur_wt) and \(M_S = 32.065\) g/mol. The fraction \(\phi\) that evolves as H2S in the furnace and column depends on the crude and the severity; no value is sourced here, so it has no default, and any value is illustrative. The cuts’ sulfur is not reduced, so that sulfur is counted twice (as gas and on the cuts); subtract it in a sulfur balance.

Tests: tests/refinery/test_gasplant_feed.py (against example 38’s former hand code, to 1e-12, and the folded-mass report).

Gas plant gotchas#

  • The naphtha sets a floor on its own RVP. A stabiliser cannot bring the naphtha below the RVP of its C5+ part. The C5/C6 in the test feed alone sit near 70 kPa. A 60 kPa spec is infeasible, and Newton does not converge.

  • Purity specs must be reachable on the trays you gave. At O’Connell efficiencies near 0.5, a 12-tray debutanizer is about six theoretical stages. That is not enough for 1% C5+ in the LPG and 1% C4 in the naphtha together.

  • A C2- spec has to be smaller than the C2- there is. With the whole naphtha as lean oil, the absorber-deethanizer’s bottoms are about 290 mol/s. At the factory’s 0.5 %, that is 1.4 mol/s of C2-, but the CDU feeds bring in 1.07 mol/s. The spec cannot be met at any duty, so the solve does not converge. The example uses 0.2 %.

  • A hot feed sets a ceiling on the naphtha’s RVP. The deethanizer bottoms reach the debutanizer at 180 C. On that feed, an RVP spec of 40 or 50 kPa converges, at 2.7 and 1.7 MW. At 70 kPa the reboiler duty would have to go below zero, and the solve does not converge.

  • A heavy lean oil. When the lean oil is a hundred times the gas, the guess’s vapour profile is its 5 %-of-feed floor, and a pass-1 boilup ratio taken from it is a few percent: pass 1 then has almost no vapour and never converges. Pass 1’s boilup ratio is therefore at least one (test_deethanizer_with_a_lean_oil_a_hundred_times_the_gas).

  • Compile once per spec structure. The first solve compiles for 10-40 s. Later solves with the same structure (which specs replace which knobs) reuse the compiled solve for any numbers.

Gas plant: out of scope#

Treating chemistry, Merox, cryogenic C2 recovery, column hydraulics and compressor surge.


C5/C6 light naphtha isomerization#

The isomerization unit (difflow_refinery.isomerization, #311) raises the octane of a light straight-run naphtha. It does so by rearranging the normal pentane and hexanes into their branched isomers, and saturating the benzene on the way. The products are an isomerate for the gasoline pool, an off-gas, and (with a DIH) a side draw sent back to the reactor:

                      H2 make-up
                          |
fresh feed -> [DIP] -> reactor -> separator -> stabilizer -> [DIH] -> isomerate
                ^ iC5 round it      | H2         | C3-        | side draw (MP, nC6)
                +-> isomerate       +-> off-gas  +-> off-gas  +-> back to the reactor
from difflow_refinery.isomerization import (
    IsomerizationUnit, IsomerizationUnitParams, IsomerizationReactorParams, constructed_feed)

feed = constructed_feed("paraffinic", 10.0)        # kg/s; the speciation is ASSUMED
unit = IsomerizationUnit(IsomerizationUnitParams(
    configuration="dih", T_in=413.15, H2_HC=0.3,
    reactor=IsomerizationReactorParams(LHSV=2.0), dih_side_draw=6.0, stabilizer_rvp=90e3))
isomerate, offgas, info = unit(feed)
info["outputs"]["RON"], info["outputs"]["dih_duty"], info["loop"]["iterations"]

Four configurations (CONFIGURATIONS) are built from the same pieces:

  • once_through: the reactor, a product separator and a stabilizer.

  • dip: a deisopentanizer ahead of the reactor sends the feed’s isopentane (and butanes) round it.

  • dih: a deisohexanizer after the stabilizer. Its overhead (the dimethylbutanes and the C5s) and bottoms (naphthenes and C7+) are isomerate. Its side draw (the methylpentanes and n-hexane, the low-octane C6s) goes back to the reactor inlet.

  • dip_dih: both.

The specs are the reactor inlet temperature T_in, the reactor pressure, H2_HC, LHSV, the configuration, the DIH side-draw rate (dih_side_draw, kg/s) and the stabilizer RVP (stabilizer_rvp). The DIH and DIP also take their purity specs and tray counts.

Thermochemistry and feeds#

The reactor carries sixteen species (thermochem.SPECIES): hydrogen, ethane to the butanes, both pentanes, the five C6 paraffins, MCP, cyclohexane, benzene and an inert C7+ lump. Every equilibrium constant follows from the species’ ideal-gas heats of formation, absolute entropies and Cp:

  • dHf: API Technical Data Book values;

  • entropies: Yaws;

  • Cp: cubics fitted to the TRC ideal-gas correlation, 298-1000 K.

All three are the species’ rows of the refinery’s shared thermochemistry table (#339), which records the sources and the choices. Before #339 this module kept its own copy: Prosen & Rossini dHf, and Cp fitted to the NIST WebBook tables. The C5 and C6 isomer differences moved with the change: nC5 → iC5 is -6.99 kJ/mol now against -8.10 before. No test yet compares the free energies derived here with a tabulated set. It matters: 0.5 kJ/mol in one isomer moves its equilibrium share by about 15 % at 420 K, and the API TDB and CRC values for the dimethylbutanes differ by 1.2-1.3 kJ/mol.

equilibrium_table(T) and family_equilibrium(family, T) give the closed-form isomer equilibrium. The shares within the C6 paraffins are:

T (C)

nC6

2MP

3MP

2,3-DMB

2,2-DMB

120

0.069

0.280

0.164

0.090

0.397

160

0.092

0.303

0.190

0.091

0.324

200

0.115

0.317

0.210

0.090

0.268

240

0.137

0.325

0.225

0.088

0.225

(Before #339: 2,2-DMB 0.473 and 2MP 0.223 at 120 C.) The isopentane share falls from 0.82 to 0.73 over the same range (0.86 to 0.78 before #339). The branched isomers are favoured cold, which is why the catalysts that run coldest make the best isomerate.

The octanes are the pure-hydrocarbon RON and MON of API Research Project 45 (ASTM STP 225). They were recalled, not checked against the printed tables, and are marked verify. Benzene’s MON and the C7+ lump’s octanes are assumptions. The isomerate’s octane is the Ethyl RT-70 blend of the species, as in the blend pool.

The feed’s speciation is constructed, not measured. A TBP assay does not say which C6 is n-hexane and which is 2,2-DMB. constructed_feed and light_naphtha_from_cdu split the light-naphtha pseudo-components by an assumed composition (NaphthaSpeciation). Two are provided:

  • PARAFFINIC: about 1.5 wt % benzene in the C6 cut.

  • BENZENE_RICH: a naphthenic crude’s, about 5 wt % benzene and more MCP and cyclohexane.

Their numbers are in the range of the light straight-run analyses quoted in refining texts; verify against a real PIONA before relying on them.

IsomerizationReactor#

A pseudo-homogeneous, adiabatic plug-flow bed. Every reversible reaction A (+ n H2) <=> B runs at a first-order approach-to-equilibrium rate:

r_j = theta k_j(T) (F_A - F_B / (K_j(T) p_H2^n)),   theta = 1/LHSV

The rate is zero at equilibrium whatever k is. So the equilibrium is set by the thermochemistry alone, and the rate constants only set how close to it the bed gets. The reactions are:

  • nC5 <=> iC5;

  • nC6 <=> 2MP, 2MP <=> 3MP, 2MP <=> 23DMB, 23DMB <=> 22DMB (the slow step);

  • MCP <=> CH;

  • benzene saturation, Bz + 3 H2 <=> CH;

  • ring opening, MCP + H2 <=> 2MP;

  • hydrocracking to ethane and propane, irreversible.

The rate constants are illustrative. The catalyst presets (CATALYSTS: chlorided alumina, sulfated zirconia, zeolite) give orders of magnitude and each catalyst’s temperature window. They are not fitted to any catalyst’s data. k_scale is the one factor to calibrate against a plant’s measured approach to equilibrium.

info["approach"] reports the approach to equilibrium of each reaction. In an adiabatic bed it can exceed one: benzene saturation reaches about 1.016 on the benzene-rich feed. The saturation is fast and runs near equilibrium at the hot outlet. The approach is measured against the equilibrium at the outlet temperature, which the bed is still heating towards.

The temperature is not integrated. At every point along the bed it is the root of the energy balance, so the enthalpy is conserved exactly. The bed is stiff (benzene saturation’s rate constant is fifty times 2,2-DMB formation’s), so it is integrated by a two-stage L-stable SDIRK. Each stage is solved by Newton in a lax.while_loop to a residual tolerance. That loop has no reverse-mode rule, so the reactor is differentiable in forward mode only (jax.jacfwd, jax.jvp). Every block and test here uses forward mode. info["stage_residual"] reports the worst stage residual, and IsomerizationConvergenceWarning fires when it is not small.

Why not difflow.kinetics? Its rate laws are mass action in concentrations with Arrhenius constants, written as data. These rates are in molar flows against a temperature-dependent K_eq from the species’ free energies, with the hydrogen partial pressure in bar. Writing them as mass action would need K_eq(T) as a rate-law term, which the module does not have.

IsomerizationUnit#

The unit around the reactor:

  • Hydrogen is once-through. The charge is made up to H2_HC with pure hydrogen, and what is left leaves in the off-gas. There is no recycle-gas compressor. Hydrogen in the feed counts towards the target; above it the make-up is zero and the excess goes through the reactor with the charge (info["H2_HC_charge"] reports the ratio the bed saw).

  • The feed carries only the sixteen reactor species. Any other F_ key (water included) raises ValueError, as the IsomerizationReactor does; it is never dropped.

  • The product separator is one equilibrium stage at separator_T (a 1-tray GasPlantColumn). The effluent cooler is a specification and its duty is not reported.

  • The stabilizer is a shortcut, not a tray column. Hydrogen, ethane and propane go overhead, the pentanes and heavier stay in the bottoms. The fraction of the butanes kept is solved so that the bottoms meet stabilizer_rvp. A rigorous stabilizer on the gas-plant column was tried in three layouts. None converged reliably over the compositions the DIH recycle produces, so no stabilizer duty is reported.

  • The DIP and DIH are GasPlantColumn splitters: the gas plant’s Peng-Robinson MESH model on the sixteen species. The DIH has a side draw at dih_side_tray, at the rate dih_side_draw.

With a DIH, the recycle is a difflow.Flowsheet recycle torn on the side draw and converged by Anderson acceleration. IsomerizationUnit.outputs(feed, T_in, LHSV, x_nc6=None) returns the output vector (OUTPUT_NAMES) and differentiates the converged loop by the implicit function theorem through a jax.custom_jvp:

dy/du = Y_u + Y_x (I - G_x)^-1 G_u

Here G is one pass of the loop (recycle in, side draw out) and Y is the outputs, both linearised by forward-mode AD at the solution. The once-through and DIP configurations have no loop and are differentiated straight through. unit.last_solve["recycle"] keeps the converged side draw, to warm-start the next solve.

unit.blend_component(info["outputs"]) returns the isomerate as a BlendComponent for a BlendPool.

Planning with the isomerization unit#

isom_block(unit, feed, levers, outputs=None) returns a difflow.planning.Block. The levers are T_in (C), LHSV (1/h) and x_nC6, the fresh feed’s n-hexane mole fraction. The outputs are any of OUTPUT_NAMES in planner units, plus isomerate_V (m3/h). The block is not jit-compiled and its AD mode is forward, because the DIH loop is a Python loop.

link_isom(isom_blk, pool_blk) links the isomerate volume to the isomerate_V lever of BlendPool.as_block, so the blend component must be named "isomerate":

from difflow.planning import Network
from difflow_refinery.blending import BlendPool
from difflow_refinery.isomerization import isom_block, link_isom

blk = isom_block(unit, feed, levers=["T_in", "LHSV"])
iso = unit.blend_component(info["outputs"])        # properties at the base point
pool = BlendPool("gasoline").as_block([iso, reformate])   # reformate: another BlendComponent
net = Network([blk, pool], links=link_isom(blk, pool))

What crosses the link is the isomerate’s volume. A BlendComponent has fixed properties, so the isomerate’s octane in the pool is the one at the linearisation point. Rebuild it from blend_component at each new base point.

Results#

Both constructed feeds were run at 10 kg/s, T_in 140 C, LHSV 2, H2/HC 0.3 and 30 bar, on the chlorided-alumina preset. Every column converges. The total and per-carbon-number balances close to 2e-11 or better.

Configuration

Paraffinic: RON

Yield (vol)

DIH / DIP duty (MW)

Benzene-rich: RON

Yield (vol)

DIH / DIP duty (MW)

once-through

80.98

0.992

-

80.74

1.007

-

DIP

81.95

0.999

- / 6.96

80.66

1.009

- / 5.24

DIH

81.31

0.991

6.61 / -

81.61

1.004

6.20 / -

DIP + DIH

(83.47)

(7.11 / 6.96)

(83.20)

(6.38 / 5.24)

The table is on the shared thermochemistry (#339), which made the dimethylbutanes 0.7 kJ/mol less stable relative to n-hexane and the pentane isomerization 1.1 kJ/mol less exothermic. Octanes fell by 0.8-2.2 RON from the pre-#339 values (once-through 82.18 and 81.56, DIP 83.06 and 81.13, DIH 82.81 and 82.79). The DIP + DIH row in brackets is the pre-#339 run: it was not repeated, because the two-column recycle exceeded the memory available where #339 was made. The T_in sweep figures in the bullets below are also pre-#339.

The volume yield exceeds one on the benzene-rich feed. Saturating benzene and adding hydrogen makes a liquid of lower density.

The DIH loop converges in eight (paraffinic) or nine (benzene-rich) Anderson iterations, one to three minutes on a laptop. A once-through solve takes about 12 s the first time and 4 s after that.

What the numbers show:

  • The recycle gain is modest. The DIH adds 0.3 RON on the paraffinic feed and 0.9 on the benzene-rich one (0.6 and 1.2 before #339). Licensors usually quote a larger gap between once-through and DIH units (verify). These rate constants and constructed feeds are not fitted to any unit, so neither number should be read as a prediction.

  • A DIP can lower the octane. On the benzene-rich feed, taking the isopentane round the reactor leaves less mass to absorb the benzene exotherm. The bed runs hotter (an 83 K rise, against 65 K once through; 91 and 70 K before #339), and the hotter outlet equilibrium favours the less-branched isomers.

  • RON has a maximum in T_in. Cold, the bed is short of equilibrium; hot, the equilibrium itself is worse. Once-through on the paraffinic feed, RON is 77.0 at 110 C, 82.5 at 150 C and 80.6 at 190 C. On the benzene-rich feed it peaks near 120 C, at 82.2.

  • The benzene-rich feed runs away. At T_in of 160 C and above, the exotherm drives hydrocracking, which is itself exothermic and uses hydrogen. The bed then uses up its hydrogen. IsomerizationHydrogenWarning fires when the outlet H2/HC falls below 0.05, before the separator flash fails.

  • The stabilizer spec is not always met. On the benzene-rich feed with a DIH, the isomerate’s RVP is 79 kPa against a 90 kPa spec (78 kPa once through). The stabilizer keeps every butane and its C5+ alone is below the spec. stabilizer_c4_recovery reports this as 1 and info["stabilizer"]["spec_met"] as False.

Isomerization gotchas#

  • Forward mode only. jax.grad through the reactor fails on the stage Newton’s while_loop; use jax.jacfwd or jax.jvp.

  • Outputs at a spec have zero derivatives. The isomerate RVP is held at its spec, the once-through H2 make-up does not depend on the reactor, and a stabilizer at its bound has a zero derivative. A finite-difference check of these compares zero with noise.

  • Keep T_in in the catalyst’s window. The presets carry their windows (CATALYSTS[...]["window"]). Outside them the constants mean nothing, and a hot benzene-rich charge runs away.

Isomerization: out of scope#

C4 isomerization, catalyst chloriding and its HCl/caustic scrubbing, molecular-sieve (Ipsorb, TIP) separations, the recycle-gas loop, and dynamics.


Product blending#

difflow_refinery holds refinery models. Its first one is the product blending pool. It mixes component streams into finished products (gasoline, jet, ULSD, fuel oil), computes the properties that specs are written on, and reports a signed margin per spec. It uses the nonlinear blending rules refiners use, and it is differentiable in the recipe and in every component property.

from difflow_refinery import BlendComponent, BlendPool

reformate = BlendComponent.from_properties(
    "reformate", SG=0.80, RON=98.0, MON=88.0, RVP_psi=3.5, S_ppm=1.0,
    olefins_vol=1.0, aromatics_vol=65.0)
fcc = BlendComponent.from_properties(
    "fcc", SG=0.74, RON=92.0, MON=80.0, RVP_psi=6.0, S_ppm=20.0,
    olefins_vol=25.0, aromatics_vol=30.0)
alkylate = BlendComponent.from_properties(
    "alkylate", SG=0.70, RON=95.0, MON=93.0, RVP_psi=4.5, S_ppm=5.0,
    olefins_vol=0.5, aromatics_vol=0.5)
butane = BlendComponent.from_properties(
    "butane", SG=0.58, RON=93.0, MON=90.0, RVP_psi=52.0, S_ppm=10.0,
    olefins_vol=0.5, aromatics_vol=0.0)
components, recipe = [reformate, fcc, alkylate, butane], [0.35, 0.35, 0.25, 0.05]

pool = BlendPool("gasoline")                 # default specs: RON, MON, RVP, S
res = pool(components, recipe=recipe)
res.properties["MON"], res.margins["MON >= 82"]

pool.linear_blend_error(components, recipe)  # nonlinear minus linear-by-volume
pool.backoff(components, recipe, exact=("RVP_psi", "S_ppm"))
pool.spec_violations(components, recipe, temperature=0.05)   # smooth max(-m, 0)
pool.as_block(components)                    # a difflow.planning.Block

Like difflow.planning, this is a library rather than a set of GUI palette operations. It registers no difflow.plugins entry point. A pool is called with a recipe and returns properties and margins, which is not the stream-in/stream-out shape a palette unit has.

Example: examples/33_refinery_gasoline_blending.ipynb. Tests: tests/refinery/test_blending.py.

Components: property mode and stream mode#

A BlendComponent is built one of two ways:

  • from_properties(name, SG=..., RON=..., ...): measured or unit-reported properties only. This is enough for every blended property.

  • from_stream(name, stream, characterization, **overrides): a difflow stream of pseudocomponent molar flows on a shared BlendCharacterization. The composition gives SG, sulfur, nitrogen, PNA and a Raoult RVP, and flash, freeze and smoke points, viscosity and straight-run RON/MON are estimated (see Estimated product properties below). Overrides (a reformer’s reported RON, a measured flash point) take precedence, and an overridden property is not estimated. This mode adds:

    • the product stream, with an exact mass and volume balance;

    • the properties only composition can give: distillation and cetane index;

    • the Raoult RVP of the blend itself.

One pool takes one mode. Mixing the two raises an error rather than producing a product stream that is missing some components’ mass.

Volume basis. Volumes are ideal-mixing volumes at 15 °C from SG (rho = SG * 999.10 kg/m^3). The product volume is the sum of the component volumes, and the product SG is the volume average. Both are tested to round-off against the product stream’s own composition.

Recipes can be given as basis="volume_fraction" (the default), "volume_flow", or "split". A split is the fraction of each component stream’s available volume sent to the pool, which is the natural lever when the pool sits in a flowsheet.

Estimated product properties#

difflow_refinery.properties (#330) estimates the properties a stream does not carry, and from_stream uses them whenever no measured value is given. estimate=True (default) estimates every property the characterization has the inputs for, estimate=False none (the pre-#330 behaviour), and a list names the ones wanted.

from difflow_refinery import BlendComponent, BlendCharacterization, properties

bc = BlendCharacterization.from_characterization(char)        # char built with composition=True
jet = BlendComponent.from_stream("jet", jet_stream, bc)        # flash, freeze, smoke, viscosity estimated
fo = BlendComponent.from_stream("fuel oil", residue, bc, viscosity_T_C=50.0)
lsr = BlendComponent.from_stream("LSR", light_naphtha, bc)     # RON/MON from the P/N/A/O composition
jet_measured = BlendComponent.from_stream("jet", jet_stream, bc, flash_C=42.0)   # a measurement wins
properties.estimate_properties(bc, moles)                      # the estimates alone

Property

Estimate

Inputs

Status

flash_C

1/T_F = -0.024209 + 2.84947/T10 + 3.4254e-3 ln T10 (K), Riazi & Daubert (1987), API TDB 2B7.1, Riazi MNL50 Ch. 3

D86 10 % point of the stream (smoothed TBP, Riazi-Daubert TBP->D86)

(unverified)

freeze_C

ideal solubility of each cut’s n-paraffins, 1/T = 1/T_f - R ln x / dH_f, Won (1986) T_f = 374.5 + 0.02617 MW - 20172/MW, dH_f = 0.1426 MW T_f cal/mol; the freeze point is the smooth maximum over cuts

paraffins_vol, MW, n_paraffin_share

T_f checked against n-C10..n-C24 melting points (within 7 K at C10, 3.5 K above); dH_f 4-7 % high (odd) / 25-32 % low (even) against CRC; the freeze point itself not validated

smoke_mm

SP = exp(-1.028 + 0.474 Kw - 0.00168 Tb'), Riazi MNL50 Ch. 3, Tb' read in degR (see below)

TBP 50 % point, SG

(unverified)

viscosity_cSt

Abbott, Kaufmann & Domash (1971), API TDB 11A4.2, at 100 and 210 °F; ASTM D341 (Walther) line to viscosity_T_C (default 50 °C)

TBP 50 % point, SG

(unverified); PropertyRangeWarning near the correlation’s pole

RON, MON

each cut split into n-/iso-paraffins, naphthenes, aromatics and olefins; pure-compound octanes of the reformer’s model compounds at the cut’s Tb; Ethyl RT-70 over the sub-components (the reformate’s rule)

paraffins_vol, naphthenes_vol, aromatics_vol (olefins_vol), n_paraffin_share

(unverified); method of this project

Unverified means unverified. No primary source could be opened while this was written (the API Technical Data Book, ASTM MNL50 and the journals were unreachable), so the constants are as recalled and no published worked example is reproduced. The tests check what can be checked without one: Won’s melting points against tabulated data, the Walther line on its two points, the octane of a single-compound stream, monotonicity in the direction the physics requires, plausible values on typical products, and gradients against central differences. Give a measured value whenever you have one.

Things to know about each estimate:

  • Flash point comes from the D86 10 % point, which is the lightest material in the cut; it gives about -45 °C for a gasoline, 55-70 °C for a kerosene and around 100 °C for a diesel, kerosenes on the high side of measured values.

  • Freeze point is a physical model, not a correlation. ASTM D2386 measures the temperature at which the last crystal disappears, and in a kerosene those crystals are n-paraffins. Each cut’s n-paraffins (mole fraction z_j * P_j * n_paraffin_share) are treated as one n-alkane of the cut’s molar mass crystallizing pure from an ideal solution. A coarse cut grid hides the heaviest members of a cut and so puts the estimate low, as does Won’s low heat of fusion for the even n-alkanes. Cuts lighter than MW 100 are left out; a liquid with none heavier gets the floor FREEZE_FLOOR_K (150 K). n_paraffin_share (default 0.5) is ILLUSTRATIVE.

  • Smoke point: with Tb in kelvin, as the constants were recalled, the equation puts an ordinary kerosene (Tb 473 K, SG 0.80) at 44 mm, far above the 20-30 mm such kerosenes measure. With Tb in degrees Rankine it gives 23 mm, and 15 mm for a naphthenic one. The Rankine reading is used because it gives measured magnitudes. That is a judgement, not a citation, and the first thing to check when the source is to hand.

  • Viscosity is the Abbott correlation on the stream’s bulk TBP 50 % point and SG, not a Refutas blend of per-cut values: per cut the heaviest lumps sit on the correlation’s pole. The temperature of viscosity_cSt is viscosity_T_C (50 °C, the fuel-oil reference). Every component in one pool must be at one temperature for the Refutas rule. The D341 line drops the low-viscosity correction terms below about 2 cSt.

  • Octane uses the reformer’s pure-compound octanes (reforming.species, API Research Project 45 values as recalled, unverified, C9-C10 paraffins extrapolated), one model compound per type and carbon number, an ILLUSTRATIVE n-/iso-paraffin split, and the iso-paraffin octane as a placeholder for olefins. Straight-run C8+ iso-paraffins are many isomers, most of them higher in octane than the 2-methylalkane that stands for them, so heavy naphthas come out low. On the tests’ crude it gives RON 74 for a C5-85 °C light naphtha and 41 for an 85-180 °C heavy naphtha; measured ranges are about 60-75 and 40-60. Because it uses the reformer’s numbers and rule, a straight-run naphtha and a reformate in one pool are on one octane basis. It is meaningful for naphthas boiling below about 460 K.

Tests: tests/refinery/test_properties.py.

Blending rules#

Property

Key

Rule (default first)

Source

RON, MON

RON, MON

Ethyl RT-70 interaction model; or linear by volume

Healy, Maassen & Peterson (1959), Ethyl report RT-70; coefficients as tabulated in Maples (2000)

RVP

RVP_psi

RVP^1.25 index by volume; Raoult on the pseudocomponents; or linear

Gary, Handwerk & Kaiser, product blending chapter; tested against a published worked example

Sulfur, nitrogen, CCR

S_ppm, N_ppm, CCR_wt

by mass

-

Aromatics, olefins, benzene, PNA, smoke point

*_vol, smoke_mm

by volume

-

Flash point

flash_C

Hu-Burns index, log10 BI = -6.1188 + 2414/(T - 42.6) (K), by volume

Hu & Burns (1970); identical to Wickey-Chittenden in °F

Cloud, pour point

cloud_C, pour_C

BI = T^n (K), n = 1/0.05 and 1/0.08

Hu & Burns (1970)

Freeze point

freeze_C

BI = T^n, n = 20

omsQlibs Blending Quality Models Equations (2016), generic freeze index; its Ethyl index defaults to n = 12.5

CFPP

CFPP_C

BI = T^n, n = 12.5 (the pour-point exponent)

no published index found; an assumption, override with rules={"CFPP_C_exponent": ...}

Viscosity

viscosity_cSt

Refutas VBN 14.534 ln ln(nu + 0.8) + 10.975, by mass

Refutas, as given in Maples (2000); tested against a published worked example

Distillation

E70_tbp, E100_tbp, T{10,50,90,95}_d86_C

from the blend’s composition: smoothed TBP, then Riazi-Daubert TBP->D86

Riazi & Daubert (1986)

Cetane index

cetane_index

ASTM D4737 (default) or D976 on the blend’s density and D86 points; computed, never blended

ASTM D4737, D976

Choose rules per property with BlendPool(rules={"octane": "volume", "RVP_psi": "raoult", "cetane": "d976"}).

The RT-70 corrections are all spreads: covariances and variances across the components of sensitivity, olefins and aromatics. The model therefore reduces exactly to the linear blend when the components agree. The MON equation’s first term is the MON×sensitivity covariance, the same form as RON’s. The coefficients are the 75-blend fit (a1 = 0.03224, a2 = 0.00101, a3 = 0, b1 = 0.04450, b2 = 0.00081, b3 = -0.00645, the last on the squared aromatic spread / 1e4), as tabulated in Maples, Petroleum Refinery Process Economics, 2nd ed. (2000). The 1959 original was not reachable. They live in EthylRT70 and can be refitted.

Until #301 the code had b3 = -0.0645 and an olefin×MON first term. The factor of ten is a transcription error: Maples gives -0.00645, and the 135-blend fit written out in full (-6.32e-7 (A^2 - A A)^2 against a tabulated -0.00632) fixes both the digit and the /1e4 scaling. Both errors made MON blend much worse than it does. On a reformate/FCC pool the MON correction is now a few tenths of a number. Check it against your own blend data.

The distillation points are on a TBP curve made differentiable by spreading each pseudocomponent with a logistic of distillation_width (default 5 K). Each point is then converted to D86 with the Riazi-Daubert correlation, because specs are written on D86. E70/E100 stay on the TBP basis, and the key says so.

Specs and margins#

BlendSpec(property, op, limit, scale=1.0). The margin is value - limit for >= and limit - value for <=, divided by scale, so positive means on spec. PRODUCT_SPECS holds illustrative defaults for each product: US regular summer gasoline, Jet A, ULSD S15, and VLSFO RMG 380. Pass your own specs for anything real.

  • spec_margins(components, recipe) returns the margin vector, smooth in everything. With weighted=True it returns V * margin instead. A property is intensive, so it is 0/0 at an empty pool. The weighted form is the same constraint wherever V > 0, stays finite at zero, and is the form an LP’s blending rows take. Use it as the constraint for an optimizer over volume flows.

  • spec_violations(..., temperature=t) returns max(-margin, 0), or its softplus t * logaddexp(-margin/t, 0). The smooth form is within t ln 2 of the kink and its derivative at an active spec is exactly -1/2. The branchless max(u,0) + log1p(exp(-|u|)) has the same value and a derivative of zero there; see the CVaR note in difflow.stochastic.

Linear blend error and back-off#

linear_properties is the planning-LP view: every property linear by volume. linear_blend_error is nonlinear minus that view, and it is what a planner’s back-off is meant to cover. Pass exact=("RVP_psi", "S_ppm") for properties the LP already models with the pool’s own rule (the RVP index, sulfur by mass). Their error is then zero. backoff turns the error into a per-spec tightening, max(linear margin - nonlinear margin, 0).

The example measures what this means for a gasoline LP:

  • the example sets MON at 86, so that the MON row binds; at a regular grade’s 82, MON is slack and the LP is exact;

  • without back-off, the LP promises about 5% more margin than any feasible plan, and its recipe is about 0.6 MON numbers off spec;

  • the back-off depends on the recipe, so one pass is not enough; successive back-off takes about six passes to settle, at about 0.98 MON against a first estimate of 0.58;

  • on that pool every NLP start finds the converged back-off plan. That is not guaranteed in general: blending is nonconvex, and the back-off fixed point is a feasible vertex, not a certified optimum.

Planning hook#

pool.as_block(components) returns a difflow.planning.Block. Its levers are the component volume flows (<name>_V), bounded by availability in stream mode. Its outputs are the product volume, the spec properties, and margin:<spec> for every spec. jax.jacobian of the block is the delta-vector set: the volume row is the volume balance, all ones, and the property rows are the blend’s sensitivities at the linearisation point.

Blend characterization#

BlendCharacterization(names, Tb, SG, MW=, Tc=, Pc=, omega=, qualities=) is the pseudocomponent grid. Molecular weight and critical constants come from Riazi-Daubert (1980), the acentric factor from Edmister, and vapor pressure from Lee-Kesler. Any of them can be overridden per pseudocomponent by passing a vector with NaN where the correlation should be used. That is how a defined component such as n-butane takes its own constants. qualities holds the per-pseudocomponent composition vectors (S_ppm, aromatics_vol, …), each averaged on its own basis.

This is the minimum a blend pool needs to compute properties from composition. It is not an assay model: there is no TBP fitting and no heavy-end extrapolation.

For products of the crude and vacuum units, use BlendCharacterization.from_characterization(char) instead. It takes the crude characterization’s Tb, SG, MW, its critical constants and its vapour-pressure acentric factor, so the pool’s Raoult RVP sees psat(Tb) = 1 atm exactly as the columns do. It also takes the sulfur vector (and, with contaminants=True, nitrogen and CCR) as the S_ppm, N_ppm and CCR_wt qualities. Qualities the assay did not give are left out. If the characterization carries a composition (char.composition), its hydrocarbon types become the paraffins_vol, naphthenes_vol, aromatics_vol and olefins_vol qualities (vol%); composition=False leaves them out. A product stream from either column is then a BlendComponent.from_stream input; F_water and F_H2O are ignored. The pool’s sulfur for LVGO or HVGO equals the vacuum column’s own report to 1e-10, since both average the same per-component vector.

Not in scope#

  • Tank inventory and multi-period scheduling. The pool is steady state, per period.

  • Crude blending ahead of the CDU (assay mixing).

  • A validated straight-run octane correlation. The PNA-based estimate above is this project’s method on recalled pure-compound octanes; measured or unit-reported octanes override it.

Catalytic reforming#

difflow_refinery.reforming (#309) is a semi-regenerative catalytic reformer: hydrotreated heavy naphtha to reformate and hydrogen over three (or any number of) adiabatic reactors with fired interstage heaters, a high-pressure separator, hydrogen-rich recycle gas and a stabilizer. It is a difflow.Flowsheet with the recycle gas as its tear. Every output is differentiable in the reactor inlet temperatures (or WAIT), separator pressure, H2/HC ratio, space velocity and the naphtha’s composition.

from difflow_refinery.reforming import CatalyticReformer, ReformerParams, lean_naphtha

reformer = CatalyticReformer(ReformerParams())           # 3 reactors, WAIT 500 C, 12 bar, H2/HC 5
res = reformer.solve(lean_naphtha())                     # 10 kg/s of an illustrative lean naphtha
print(res.summary())
res.outputs()["reformate.RON"], res.outputs()["h2.net_mol_s"], res.balances()

# Implicit gradients through the converged recycle (forward mode):
import jax
def ron(wait):
    p = ReformerParams().with_wait(wait)
    return reformer.solve(lean_naphtha(), p, tear_initial=res.tear).outputs()["reformate.RON"]
jax.jacfwd(ron)(773.15)

On the two illustrative feeds, at WAIT 500 °C, 12 bar separator, H2/HC 5, LHSV 1.5 h-1 (these are the model’s own numbers with its illustrative kinetics, not data):

lean (N+2A 41)

rich (N+2A 75)

reactor ΔT (K)

-66, -62, -47

-61, -56, -39

C5+ reformate, vol% of feed

79

81

RON (RT-70) / MON

92.5 / 82.6

98.1 / 87.4

aromatics / benzene, vol%

60 / 1.7

68 / 2.1

net H2, wt% of feed / purity mol%

2.8 / 87

2.4 / 85

res.summary() prints these; ten degrees more WAIT on the lean feed gives RON 98.8 at 76 vol% C5+ and 3.0 wt% H2.

Known defect of the illustrative constants: the rich feed makes less hydrogen than the lean one. With the default ReformingKinetics(), the rich (high-naphthene) feed makes 2.4 wt% net H2 and the lean (high-paraffin) feed 2.8 wt%. Commercial experience is the opposite: a naphthenic feed makes more hydrogen. The cause is the paraffin chemistry. The ring-opening pre-exponential (A["ring_opening"] = 0.5) and its carbon-number factors (up to 2.5 for C10) make dehydrocyclization of C7+ paraffins fast enough that the lean feed converts most of its paraffins to aromatics. Each one releases 4 H2, against the 3 H2 a naphthene gives. The rich feed has few paraffins to convert, and it also loses hydrogen to naphthene hydrocracking (A["hydrocracking_N"], 2 H2 per event). The constants were tuned for octane and yield, not hydrogen. This is a defect of the illustrative parameter set, not a property of the model. Fitting the kinetics to plant or published data should remove it, and until then the hydrogen-versus-feed trend should not be relied on.

The module is a library plus a flowsheet, like the blend pool: it registers no palette operation.

Feed: P/N/A by carbon number#

The model cannot run on a boiling curve and a gravity. It needs paraffins, naphthenes and aromatics by carbon number, C6 to C10. A NaphthaFeed holds molar flows of the reformer’s species and is built from:

  • NaphthaFeed.from_piona(...), a measured PIONA by carbon number (ASTM D5134 / D6730 grouped), on a mass, volume or mole basis. This is the preferred input.

  • NaphthaFeed.from_characterization(char, flows), the naphtha cuts of the unified characterization (#301) with the #305 hydrocarbon-type estimate (characterize(assay, composition=True)). The mapping:

    1. each cut’s P/N/A/O volume fractions are converted to mass with the density of the type’s model compound at the cut’s carbon number, so the cut’s mass is conserved exactly; olefins count as paraffins (the feed is hydrotreated);

    2. each type’s mass is placed on C6..C10 by the cut’s Tb, interpolated against the boiling points of the type’s model compounds (n-paraffins; cyclohexane then the n-alkylcyclohexanes; benzene then the n-alkylbenzenes) and split linearly between the two neighbouring carbon numbers, clipped at C6 and C10;

    3. paraffins are split into normal and iso by iso_fraction, and C6 naphthenes into methylcyclopentane and cyclohexane by mcp_fraction. Neither split is in a Tb/SG characterization; the defaults (0.5, 0.6) are ILLUSTRATIVE and a PIONA replaces them;

    4. light ends kept as real species map to themselves (n_hexane -> nP6, isopentane -> iC5, …); water is dropped.

    The #305 estimate is coarse at carbon-number resolution; the lumped feed’s hydrogen content is the model compounds’, not the #305 hydrogen estimate (feed.hydrogen_wt() reports it for comparison).

  • NaphthaFeed.from_hydrotreater(res_or_fractionation, product) (#327), a hydrotreater’s product (or a FractionationResult product such as "heavy_naphtha") on its TREATED product grid: the same mapping as from_characterization, but with the grid’s molar masses (so the feed’s mass is the hydrotreater product’s to round-off), its hydrocarbon types after aromatics saturation, and its sulfur as feed.sulfur_wppm, which the reformer’s trace-sulfur balance takes when ReformerParams.feed_sulfur_wppm is None. See Hydrotreated naphtha to the splitter and the reformer.

  • lean_naphtha() and rich_naphtha() are two made-up, ILLUSTRATIVE feeds (not any crude’s assay).

feed.with_group_fraction("naphthenes", x) is the naphthene-content lever (other groups rescaled at constant volume or mass); feed.n_plus_2a() is the reformability index.

Species and model compounds#

Every lump is one real compound, whose formula, thermochemistry, critical constants, density and octane it takes:

C

n-paraffin nP

iso-paraffin iP

naphthene N

aromatic A

6

n-hexane

2-methylpentane

cyclohexane N6 and methylcyclopentane N5_6

benzene

7

n-heptane

2-methylhexane

methylcyclohexane

toluene

8

n-octane

2-methylheptane

ethylcyclohexane

ethylbenzene

9

n-nonane

2-methyloctane

n-propylcyclohexane

n-propylbenzene

10

n-decane

2-methylnonane

n-butylcyclohexane

n-butylbenzene

plus H2 and the C1-C5 paraffins (C1, C2, C3, iC4, nC4, iC5, nC5): 29 species. Naphthene/aromatic pairs share their side chain, so each dehydrogenation equilibrium is that of a real reaction. The main simplification of the thermodynamic layer is that a lump’s free energy is one isomer’s, not that of the isomer distribution the catalyst holds (a C8 reformate aromatic is mostly xylenes, not ethylbenzene). C9 and C10 lumps are therefore pseudocomponents by group, represented by their n-alkyl member.

Reaction network and rate laws#

Smith’s (1959) four reactions, per carbon number in the manner of Krane et al. (1959), plus three steps the carbon-number view needs. With P the total pressure and p partial pressures, both in bar, rates in mol/s per kg of catalyst:

Family

Reaction

Rate

Form from

dehydrogenation

N_n = A_n + 3 H2

k (p_N - p_A p_H2^3 / K)

Smith (1959)

ring opening

N_n + H2 = nP_n, N_n + H2 = iP_n

k (p_N p_H2 - p_P / K)

Smith (1959) (the reverse is dehydrocyclization)

hydrocracking of P

P_n + H2 -> lighter paraffins

k p_P / P

Smith (1959)

hydrocracking of N

N_n + 2 H2 -> lighter paraffins

k p_N / P

Smith (1959)

isomerization

nP_n = iP_n

k (p_nP - p_iP / K)

this module, ILLUSTRATIVE

ring expansion

MCP = CH (C6 only)

k (p_MCP - p_CH / K)

this module, ILLUSTRATIVE

dealkylation

A_n + H2 -> A_(n-1) + CH4 (n >= 7)

k p_A p_H2 / P

this module, ILLUSTRATIVE

For C6 the ring a paraffin closes to is methylcyclopentane, which must expand to cyclohexane before it dehydrogenates: that is why benzene forms slowly. Hydrocracking splits a C_n at one C-C bond chosen uniformly, 2/(n-1) mol of each C_k, k = 1..n-1, with iso_fraction of each C4+ product branched. That conserves carbon and hydrogen exactly (tested reaction by reaction) and is ILLUSTRATIVE.

k = activity A f(n) exp(-E/R (1/T - 1/T_ref)), T_ref = 773.15 K. The activation energies of Smith’s reactions are his temperature coefficients, 34 750, 59 600 and 62 300 °R (dehydrogenation, ring opening, both hydrocrackings), divided by 1.8; they are as the reforming literature commonly reproduces Smith’s model, and were not checked against the paper (unverified). The pre-exponentials A and carbon-number factors f(n) are neither Smith’s nor Krane’s: they were chosen for this module so that the unit behaves like a modern semi-regen reformer (first-reactor ΔT, octane and yields in the commonly quoted ranges) and are ILLUSTRATIVE until fitted. Their trends (heavier paraffins cyclize and crack faster; C6 paraffins barely cyclize) are the trends Krane et al. reported, not their values. activity scales every rate constant: it is the parameter difflow.reconciliation.tracking would estimate from plant data as the catalyst deactivates (the tracking loop itself is not wired in here).

Equilibrium constants come from the Gibbs energies, never from a kinetic paper: ln K = -ΔG°(T)/(RT), with ΔG° = ΔH°(T) - TΔS°(T) from the species’ ideal-gas Hf, S0 and Cp, 1-bar standard state. Dehydrogenation therefore limits correctly at low pressure and high temperature. The heats of reaction come from the same data (cyclohexane to benzene: +206.06 kJ/mol at 298 K). Tests pin ln K to the Gibbs energy, check van ‘t Hoff against the coded heats of reaction, and check that a long bed relaxes the C7 dehydrogenation quotient to K(T_out).

Coke (kg per kg catalyst per s) is k_c (p_N + p_A) / p_H2 with an Arrhenius k_c. It rises with severity and falls with hydrogen partial pressure, the dependence the deactivation literature describes; the form and constants are this module’s (ILLUSTRATIVE). The cycle length is the days to coke_capacity (default 0.15 kg/kg, ILLUSTRATIVE) of coke on catalyst. Coke is reported, not withdrawn from the balances (about 1e-5 of the feed).

Reactors and heaters#

Each bed is one-dimensional plug flow in catalyst mass, isobaric, adiabatic:

dF/dW = nu^T r(p, T),   H(F, T, P) = H_in,   d(coke)/dW = r_coke

The temperature is recovered from the enthalpy at every point by Newton’s method rather than integrated as an ODE. Element balances are then exact (the stoichiometry conserves them, and Runge-Kutta methods preserve linear invariants), and the energy balance is exact to the Newton tolerance whatever the ODE tolerance. diffrax integrates it (Tsit5, adaptive, rtol 1e-8 by default) in the normalized bed coordinate. Gradients use diffrax.ForwardMode, because the reformer is differentiated through the recycle’s implicit fixed point, which needs forward-mode derivatives of everything in the loop: use jax.jacfwd, not jax.grad, on a reformer (ReformingReactor(..., adjoint="reverse") exists for a stand-alone bed).

One enthalpy basis is used throughout: H = sum F_i [Hf_i + int cp_i dT] + F h_dep(T, P, y). That is CubicThermo’s Peng-Robinson enthalpy (ideal-gas sensible plus PR departure) with the heats of formation added. thermo.cubic_thermo() builds the CubicThermo from the same Cp fits, so the reactor, the fired heaters, difflow’s EOSFlash and its Compressor all share one reference state. A fired heater’s absorbed duty is the outlet minus inlet enthalpy, and fired = duty / efficiency (as Furnace reports it). There is no feed-effluent exchanger: the charge-heater duty is the whole of heating the naphtha and recycle gas from separator to reactor temperature, and is larger than a real unit’s for that reason.

Separator, recycle and stabilizer#

These are kept thin and local (reforming/separation.py) so they can be consolidated with the hydrotreater’s shared separator and recycle module (#306) later:

  • ProductSeparator: effluent cooler and Peng-Robinson flash (difflow’s EOSFlash) at the separator temperature and pressure. The vapour is the flash’s V y; the liquid is the feed minus the vapour, so the component balance closes to round-off. PR runs with all k_ij = 0, hydrogen-hydrocarbon included, so the hydrogen dissolved in the liquid is PR’s unfitted prediction.

  • RecycleSplitter: recycles enough of the vapour to carry H2_HC mol of hydrogen per mol of naphtha hydrocarbon (the spec); the rest is net gas.

  • Recycle compressor: difflow’s eos_units.Compressor (isentropic, efficiency 0.75) from separator to reactor pressure.

  • Stabilizer: a documented simplification. This unit was built before the gas plant (difflow_refinery.gasplant, #312) reached main, so it does not use its debutanizer (wiring it in is follow-up work), and difflow’s vacuum stage columns are not set up for a hydrogen-bearing feed. It is a component split instead: H2, C1 and C2 to fuel gas, C3 and c4_recovery of the butanes to LPG, the rest to stabilized reformate. c4_recovery stands in for the RVP / C4-in-reformate spec. Its duty is the net heat on the enthalpy basis, not a column design.

Specs and outputs#

Spec (ReformerParams)

Default

inlet_T (or with_wait(...))

773.15 K each

reactor inlet temperatures; WAIT is their catalyst-weighted mean

catalyst_split

0.15, 0.30, 0.55

number of reactors = its length

LHSV, catalyst_density

1.5 1/h, 700 kg/m3

catalyst mass = density x feed std volume per hour / LHSV (density ILLUSTRATIVE)

P_separator, loop_dP

12 bar, 3 bar

reactors at P_separator + loop_dP, isobaric

T_separator

311.15 K

H2_HC

5

recycle H2 per naphtha hydrocarbon, mol/mol

c4_recovery

0.95

stabilizer butanes to LPG

kinetics

ReformingKinetics()

rate parameters, activity

A reformate RON target in place of WAIT: reformer.wait_for_ron(feed, ron) solves for the WAIT by secant iteration (concrete). Its gradient with respect to any other input is -(dRON/dx)/(dRON/dWAIT), both from jax.jacfwd at the returned point.

res.outputs() (units in reforming.OUTPUT_UNITS) includes reformate and C5+ yield (vol%, wt%), RON/MON (RT-70 and linear), aromatics, benzene, RVP, SG, net H2 (mol/s, wt% of feed, purity), LPG and fuel gas, every reactor’s ΔT and outlet temperature, heater absorbed and fired duties, compressor power, separator duty, coke make, cycle length, WAIT and WABT. res.balances() returns the overall mass, carbon, hydrogen, energy and sulfur closures and each reactor’s adiabatic residual.

Reformate properties from composition (reforming.products). RON and MON are the pure-compound octanes of the species, blended with the Ethyl RT-70 rule that BlendPool uses. Aromatics and benzene are standard liquid volume fractions. RVP is raoult_rvp (D323 geometry) on Lee-Kesler vapour pressures. products.blend_component("reformate", res.flows("reformate")) hands it to a BlendPool. Pure-component octanes are not blending octanes; the RT-70 rule with the large aromatic and sensitivity spreads of a reformate puts RON 8-12 above the linear average. The low-octane paraffins sit below the range RT-70 was fitted on, so the rule extrapolates there.

Sulfur through the reformer (#330, reforming.sulfur). The feed’s organic sulfur, ReformerParams.feed_sulfur_wppm (default None: the feed’s own sulfur_wppm if it has one, else zero), is carried as a trace element, outside the species list and the recycle tear. At sub-ppm levels it changes neither the chemistry nor the phase split, so its balance is solved on the converged streams:

  • a share sulfur_conversion (default 0.95, ILLUSTRATIVE, not a desulfurization model) is hydrogenolysed to H2S over the reactors; the rest stays in the reformate;

  • the separator sends a share a = K V / (K V + L) of the H2S to the vapour, with the ideal K-value K = Psat(T)/P (Lee-Kesler, H2S critical constants from the chemicals 1.5.2 PSRK table) against the separator’s own vapour and liquid flows;

  • with a share s of the vapour recycled, the steady state puts (1 - s) a G / (1 - s a) of an H2S make G in the net gas and (1 - a) G / (1 - s a) in the separator liquid. The two add up to G, so the balance closes exactly, and the recycle gas holds the same H2S concentration as the net gas;

  • the stabilizer sends all dissolved H2S overhead with the fuel gas. H2S boils between ethane and propane; a real stabilizer puts some in the LPG.

res.sulfur() returns the split, res.outputs() adds reformate.S_wppm, net_gas.H2S_ppmv, recycle.H2S_ppmv and the H2S flows, res.balances()["sulfur"] is the closure, res.reformate["S_ppm"] is the reformate’s sulfur and res.blend_component() hands the reformate to a pool with it. The hydrogen the H2S takes and the hydrocarbon part of the sulfur compound are not tracked, since both are ppm of their streams.

Planning#

reformer_block(reformer, feed, levers, outputs) gives a difflow.planning.Block, like cdu_block. The levers are wait (C), P_separator (bar), H2_HC, LHSV, feed.rate (kg/s), feed.naphthenes (vol%) and c4_recovery. The outputs are any res.outputs() keys; link reformate.* to a blend-pool block and h2.net_mol_s to a hydrogen balance. The block runs the traced recycle (optimistix fixed point with implicit differentiation, warm-started at the base solution) and defaults to ad_mode="fwd".

Validation: what is and is not checked#

Checked (tests/refinery/test_reforming.py; flowsheet tests are marked slow):

  • Converges with H2 recycle on the lean and rich feeds from the default initialization (Anderson, about 25-30 recycle iterations).

  • Overall mass, carbon, hydrogen and energy balances close to 1e-8 relative (measured: 1e-11 to 1e-12), and every reactor is adiabatic to round-off.

  • The first reactor has the largest temperature drop.

  • RON and net H2 rise and C5+ yield falls with WAIT; aromatics rise as the separator pressure falls.

  • wait_for_ron reaches a RON target (95 on the lean feed) to 1e-3.

  • The reformate enters a BlendPool as a property-mode BlendComponent.

  • Implicit gradients of reformate yield, RON, net H2 and first-reactor ΔT with respect to WAIT, separator pressure, H2/HC and naphthene content match central differences to 1e-5 (measured: 1e-7). A full jax.jacfwd of those 4x4 plus the eight finite-difference solves takes about eight minutes on one CPU core, mostly compilation of the traced recycle.

  • Thermochemistry: Hf, S0 and Cp are the shared table’s (#339; unchanged by the move), pinned to their sources there; Hf agrees with difflow.database within 1 kJ/mol for the 16 species both hold; ln K is the Gibbs energy; van ‘t Hoff holds against the coded heats of reaction; a long bed reaches the Gibbs-energy equilibrium.

Not done, and not claimed:

  • No published commercial-reformer simulation is reproduced. Neither Padmavathi & Chaudhuri (1997) nor Taskar & Riggs (1997) could be obtained to check which one tabulates feed, conditions and outlet data in full, so no cross-check against them is made. The kinetics are illustrative and would have to be replaced by either paper’s parameters for such a comparison.

  • No IDAES GibbsReactor comparison of the equilibrium layer: IDAES is not installed in this environment. The equilibrium layer is checked against its own Gibbs energies (above).

  • No dedicated example notebook. The reformer runs inside the whole-refinery example, examples/40_refinery_flowsheet.ipynb (naphtha hydrotreater -> fractionator -> reformer -> gasoline pool and hydrogen header), on a feed from NaphthaFeed.from_hydrotreater (#327) with the treated naphtha’s sulfur; the example also takes its forward-mode gradient together with the hydrotreater’s (Chaining units).

  • No Gary-Handwerk-Kaiser yield-versus-RON cross-check: the figure could not be consulted.

References#

What

Source

Status

4-reaction network, rate-law forms, activation energies (34 750, 59 600, 62 300 °R)

Smith, R.B., “Kinetic analysis of naphtha reforming with platinum catalyst”, Chem. Eng. Prog. 55(6), 76-80 (1959)

Paper not consulted. The forms and coefficients are as commonly reproduced in later reforming papers (unverified); the title and pages are as cited there (unverified).

Carbon-number lumping, rate trends with carbon number

Krane, H.G., Groh, A.B., Schulman, B.L., Sinfelt, J.H., “Reactions in catalytic reforming of naphthas”, Proc. 5th World Petroleum Congress, New York (1959), Sect. III

Not consulted; section and pages unverified. Only the lumping idea and the qualitative trends are used, none of its numbers.

Commercial reformer models with coking (not used numerically)

Padmavathi, G., Chaudhuri, K.K., Can. J. Chem. Eng. 75(5), 930-937 (1997); Taskar, U., Riggs, J.B., “Modeling and optimization of a semiregenerative catalytic naphtha reformer”, AIChE J. 43(3), 740-753 (1997)

Not consulted (unverified). Cited for the coke dependence on severity and H2 partial pressure only, qualitatively.

KINPTR (background)

Ramage, M.P., Graziani, K.R., Schipper, P.H., Krambeck, F.J., Choi, B.C., Adv. Chem. Eng. 13, 193 (1987)

Not consulted (unverified); background only.

Ideal-gas Hf (298.15 K)

API Technical Data Book (as API_TDB_G in chemicals 1.5.2); 2-methylhexane from the CRC Handbook (as CRC)

Read from the chemicals tables; cross-checked against the CRC, ATcT and Yaws tables (spread within 1.5 kJ/mol except 2-methylnonane, CRC -260.2 against API -256.5 kJ/mol, unresolved; a sample is pinned in HF_CROSSCHECK). The primary tables were not opened.

Ideal-gas S0 (298.15 K, 1 bar)

Yaws ideal-gas entropy table (as YAWS in chemicals 1.5.2)

Read from chemicals; cross-checked against NIST WebBook values carried by chemicals (within 2.5 J/mol/K). Book edition unverified.

Ideal-gas Cp

TRC ideal-gas heat-capacity correlation (Thermodynamics Research Center), as tabulated in chemicals.heat_capacity.TRC_gas_data; cubic fit 298-1000 K by this module

Fit within 1.4 % of the correlation; values at 298 K pinned.

Tc, Pc, omega, Tb

First-ranked source in chemicals 1.5.2 (CoolProp reference EOS; IUPAC critical-property review; CRC; PSRK)

Read from chemicals; the primary tables were not opened.

Liquid density at 60 F

Perry’s Chemical Engineers’ Handbook, 8th ed., DIPPR-105 table (via chemicals); VDI Heat Atlas PPDS (n-propyl-, n-butylcyclohexane); COSTALD, Hankinson & Thomson, AIChE J. 25(4), 653-663 (1979) (2-methylhexane)

Table numbers and COSTALD pages unverified. 2-Methylheptane, -octane and -nonane are recalled handbook values (unverified).

Pure-compound RON/MON

API Research Project 45, ASTM STP 225, Knocking Characteristics of Pure Hydrocarbons (1958)

Not consulted. n-Heptane = 0 (and isooctane = 100) are exact by definition (ASTM D2699/D2700). Every other value is recalled (unverified). C9/C10 paraffins and n-butylcyclohexane are extrapolations by this module (octane_source="estimate"). Benzene’s RON is the least certain.

Octane blending

Ethyl RT-70: Healy, Maassen & Peterson (1959), coefficients as in Maples (2000)

As in the blend pool (see Blending rules).

RVP

raoult_rvp, Lee-Kesler vapour pressure

As in the blend pool and correlations.

Separator VLE, departure enthalpy

Peng, D.-Y., Robinson, D.B., “A new two-constant equation of state”, Ind. Eng. Chem. Fundam. 15(1), 59-64 (1976), doi:10.1021/i160057a011, via difflow’s PengRobinson

k_ij = 0.

ΔG(T), ln K route

Smith, J.M., Van Ness, H.C., Abbott, M.M., Introduction to Chemical Engineering Thermodynamics, McGraw-Hill, chemical-reaction-equilibria chapter

Standard thermodynamics; chapter and equation numbers vary by edition (unverified).

Thermochemistry of model compounds (the issue’s suggestion)

Stull, D.R., Westrum, E.F., Sinke, G.C., The Chemical Thermodynamics of Organic Compounds, Wiley (1969)

Not used: the values come from the chemicals tables above.

Reforming practice, yield vs. RON

Gary, J.H., Handwerk, G.E., Kaiser, M.J., Petroleum Refining: Technology and Economics, 5th ed., CRC Press (2007), catalytic reforming chapter

Not consulted; no cross-check made.

Hydroprocessing building blocks#

difflow_refinery.hydroprocessing holds the parts every hydroprocessing unit shares: the stream state, a Peng-Robinson flash, an adiabatic trickle-bed reactor that runs any kinetic model, the recycle-gas loop and a product stripper. The hydrotreater (below) is these parts plus its own kinetics (difflow_refinery.hydrotreating); a hydrocracker is meant to be these parts plus another kinetic model. Nothing in this module knows what HDS is.

The stream: gases, cuts and attributes#

A hydroprocessing stream carries real gases (H2, H2S, NH3, C1–C4, the light ends the characterization keeps as species, water) and the characterization’s cuts. A reactor changes what a cut is made of, which a molecule flow cannot say, so each cut also carries attribute flows: extensive amounts that ride with its molecules. Layout(gases, cuts, attributes, attribute_elements) names them; Flows(gas, cut, attr) holds them as arrays of shape (n_gas,), (n_cut,) and (n_cut, n_attr).

  • "C" and "H" (carbon and hydrogen atoms) are compulsory. A cut’s mass is computed from its atoms, m = 12.0107 n_C + 1.00794 n_H + 32.065 n_S + 14.0067 n_N (g/s; S and N summed over every attribute that counts them), and its molecular weight is m / F. A saturated cut is heavier per molecule, a desulfurized one lighter, and the mass balance closes because the hydrogen came from the gas.

  • Attributes are extensive. A mixer adds them, a splitter scales them, a phase split partitions them in proportion to their cut’s molecules. So the element balances close to round-off through any sequence of units.

  • As a difflow stream (Flows.to_stream): F_<gas>, F_<cut> and F_<cut>@<attribute>, all mol/s. difflow’s own mixers and splitters therefore handle them correctly.

  • Flows.elements(layout) gives the C, H, S and N atom flows; Flows.mass(layout) the total mass.

Thermodynamics#

hydroprocessing.thermo.Components is the property table of the flashing components (the layout’s gases except water, then its cuts), built with Components.build(layout, Tb, SG, MW, Tc, Pc, omega, hvap_nb, cp_ig, kij=None) from the characterization’s own arrays, so a gradient reaches the assay through it. difflow.thermo.CubicThermo is not used because it is built from concrete species data (Characterization.thermo("pr") needs floats).

  • Peng-Robinson (Peng & Robinson 1976, Eqs. 3, 4, 9–12 and the fugacity coefficient Eq. 17), van der Waals one-fluid mixing with a k_ij matrix. kappa is the 1976 quadratic up to omega = 0.491 and the Robinson-Peng (1978) cubic above it. The liquid takes the smallest real root of the cubic, the vapour the largest; each root is polished by one implicit Newton step so its derivative is the implicit-function one.

  • k_ij (DEFAULT_KIJ): the light-gas pairs from the ChemSep PR table (as distributed with the thermo package), H2S with every cut 0.0333 (ChemSep’s H2S/n-decane), every other pair zero. Illustrative; pass kij=.

  • Enthalpy: the ideal-gas path of the crude unit’s ColumnThermo. Ideal-gas Cp integrated from 298.15 K; a liquid sits below it by Watson’s heat of vaporisation through dHvap(Tb). Gas constants: Tc, Pc and omega of H2, H2S and NH3 from difflow.database, of the hydrocarbons from the crude unit’s light-end table (Poling, Prausnitz & O’Connell 5th ed., App. A); ideal-gas Cp of H2, H2S and NH3 from Reid, Prausnitz & Poling 4th ed., App. A; dHvap at the normal boiling point from the CRC Handbook as tabulated in chemicals.

  • Liquid molar volume of a cut at temperature: the Rackett equation in the Spencer-Danner form, anchored to the cut’s 60 °F density, with the Yamada-Gunn Z_RA = 0.29056 - 0.08775 omega; Tr capped smoothly at 0.95. Dissolved gases take no volume.

The flash and the HP separator#

pr_flash(T, P, z, comps) is an isothermal PR flash solved as n equations in ln K:

ln K_i - ln phi_i^L(x) + ln phi_i^V(y) = 0,    x = z / (1 + V (K - 1)),  y = K x

with V from Rachford-Rice solved as a negative flash (Whitson & Michelsen 1989): bracketed between the poles 1/(1 - K_max) and 1/(1 - K_min) instead of clipped to [0, 1]. The equations stay smooth through a phase boundary, and x and y remain the compositions of the (possibly incipient) phases. The reported split uses beta = clip(V, 0, 1). Start: Wilson’s K and 25 successive-substitution passes; finish: optimistix Newton. The derivative is the implicit-function one, -J^-1 dR/dtheta at the root, attached by a rule of the module’s own (_flash_root, a jax.custom_jvp that calls itself for its primal, so second derivatives are exact too).

Two robustness rules (#332):

  • The Rachford-Rice bracket is set by the components present (z above TRACE = 1e-20 of the feed). A species that is absent – the floored zero flow of hydrogen in a feed oil – has a pole of its own that the physical root can lie beyond; bracketing on it left the root at the bracket’s edge, the Newton polish crossed the pole, and every mole fraction came out NaN. A trace component beyond its pole gets x = z (phase_denominator), its own negligible amount.

  • A flash that fails reports, it does not raise. Near a mixture’s critical region Newton can diverge to a non-finite ln K. The flash then returns its successive-substitution start with a large FlashResult.residual. optimistix’s implicit adjoint is not used because its linear solve raises on a non-finite Jacobian – the “equinox NaN in linear solve” error a naphtha unit at 320 °C / 50 bar / LHSV 0.5 / 150 Nm³/m³ used to stop with. Callers read the residual: the hydrotreater’s converged includes every flash (outputs["flash.residual"]), and compress returns NaN, not an exception, for a non-finite inlet.

HPSeparator(layout)(flows, T, P, comps) returns (vapour, liquid, water, FlashResult): gases and cut molecules split by the flash, every attribute with its cut, water decanted whole (no free-water VLE).

At 50 °C and 47 bar the default diesel case dissolves 10.7 mol/s of H2 in about 230 mol/s of separator liquid (x_H2 about 0.045); that is the h2.dissolved loss. The flash is checked against DWSIM’s PR78 on the same effluent, and the dissolved H2, H2S, NH3 and C1 are tabulated there with what DWSIM’s own constants and kij do to them: Validation against DWSIM: light ends, HP separator and gas plant.

The trickle-bed reactor and the kinetic-model interface#

TrickleBedReactor(layout, kinetics, options) integrates adiabatic beds in series:

dF/dw = r(F, T, P)                   every gas, cut and attribute flow (w: kg of catalyst)
C_eff(F, T) dT/dw = q(F, T, P)        adiabatic

r (mol/s per kg) and q (W/kg, positive exothermic) come from the kinetic model. C_eff = dH/dT at fixed flows is the stream’s heat capacity including the vaporisation a temperature rise causes, by forward-mode AD of the stream enthalpy.

Phase model (pseudo-homogeneous). Gas and liquid are in equilibrium along the bed. The K-values come from a PR flash at each bed’s inlet and are carried down the bed linearised in temperature, ln K(T) = ln K_in + (d ln K/dT)_in (T - T_in) (the slope by forward-mode AD of the flash). Along a bed only the phase split moves (Rachford-Rice), not the K-values’ composition dependence. Mass-transfer resistances, wetting and the effectiveness factor are multipliers the kinetic model applies; the Korsten-Hoffmann film model is not implemented.

The interface. A kinetic model is any object with

  • attributes and attribute_elements – the per-cut attributes it needs (the layout’s must be these);

  • rates(ctx, params) -> Rates.

ReactionContext is what it reads at a point: flows, T, P; the equilibrium x, y and vapour fraction beta of the flashing components; their fugacity-equivalent liquid concentrations c = x / v_L (mol/m³; v_L from the cuts’ Rackett volumes); c_attr, the attribute concentrations in that liquid (c_cut x attribute-per-molecule); the partial pressures p = y P; per_molecule; and helpers ctx.c_gas(name), ctx.p_gas(name), ctx.c_cut. Inside the two-phase region (0 < V < 1) x and y are the equilibrium phases. Outside it they are the stream itself and the incipient phase in equilibrium with it, which is what the negative flash gives at the boundary: an all-vapour stream has y = z and x = z / K (its dew-point liquid, unnormalised, so the liquid’s fugacities are the vapour’s); an all-liquid one x = z and y = K z. So concentrations are defined through x even where the stream is all vapour, and a rate law written on them is continuous through a dry-out (v_L is the incipient liquid’s molar volume). Before #332 the reactor used the negative flash’s own fictitious split beyond the boundary; in a vapour-phase naphtha bed (V = 9) that put the hydrogen partial pressure at 1.9 bar instead of 11 (y_H2 = 0.06 against z_H2 = 0.37), and the aromatics equilibrium ran backwards – the dehydrogenation, negative chemical hydrogen and cooling beds first seen in example 40. A diesel bed is two-phase and is not affected. Rates(gas, cut, attr, heat) returns d/dw of every flow and the heat released. Element conservation is the kinetic model’s job; check_element_conservation(kinetics, ctx, params) returns the net C, H, S, N production at a point.

Beds and quench. reactor(oil, gas, T_in, T_gas, P, W, comps, params, quench=None). With quench fractions given, bed 1 gets the oil and the treat gas less every quench at T_in[0], and each later bed’s inlet temperature follows from the adiabatic mix (a scalar enthalpy balance, Newton). With quench=None every bed’s inlet temperature is given and the quench each needs is solved (a scalar equation in the total quench, since the first bed’s gas depends on it). The quench-mixing enthalpy uses the upstream bed’s linearised K-values; the extra vaporisation caused by the quench gas itself is neglected, and the next bed’s inlet flash resets the split. WABT is sum_k W_k (T_in,k + 2 T_out,k)/3 / sum W.

Integration is diffrax (Tsit5, PID step control on a state scaled by its inlet values; ReactorOptions sets tolerances, fixed_steps, n_save and the adjoint). Gradients are reverse mode through the solver with RecursiveCheckpointAdjoint (diffrax’s default: the exact derivative of the discrete solution, checkpointed). adjoint="backsolve" selects the continuous adjoint; adjoint="forward" (diffrax.ForwardMode) makes the reactor forward-mode differentiable. Beds run under lax.scan, so the compiled program holds one copy of a bed whatever the bed count.

The recycle-gas loop#

  • knockout(vapour, liquid): the recycle compressor’s suction drum returns every cut in the separator vapour (and its attributes) to the separator liquid. A modelling choice: the recycle gas then carries only real species, and the tear does not need every cut attribute. At 40–60 °C the cuts in separator vapour are a few hundred ppm of it.

  • amine_scrub(gas, layout, h2s_removal, nh3_removal): fixed removal fractions. The NH3 fraction stands for the wash water a real unit injects upstream of the separator.

  • purge_split(gas, fraction).

  • compress(gas, layout, comps, T_in, P_in, P_out, eta): ideal-gas isentropic compression with temperature-dependent Cp (sum z_i int Cp_i/T dT = R ln(P2/P1)), H_out = H_in + (H_s - H_in)/eta, the convention of difflow.units.eos_units.Compressor. Ideal gas, not PR: the recycle gas is 80–95 % H2 at a compression ratio near 1.1, where the compressibility correction to the work is a few per cent (stated, not computed).

  • makeup_for_ratio(recycle, layout, makeup_y, h2_target): the makeup that brings the treat gas’s H2 to a target, explicitly. The H2/oil ratio is a spec and the makeup rate an output.

  • solve_tear(g, x0, args, ...): Newton on g(x, args) = x over the recycle-gas flows (and the compressor outlet temperature). It is a difflow Flowsheet recycle in substance – a tear on the recycle gas, converged and implicitly differentiated – without the Flowsheet object, whose stream packing does not carry per-cut attributes.

Newton through diffrax (hydroprocessing.solve.newton_solve). optimistix’s Newton forms its Jacobian in forward mode, and a diffrax solve with RecursiveCheckpointAdjoint supports only reverse mode. The tear, the quench balance and any target spec are therefore solved by a Newton loop of their own, inside lax.while_loop on stop-gradient inputs, with the Jacobian from jax.jacfwd of a copy of the residual built with the forward-mode diffrax adjoint (f_iter); then one step x = x* - J^-1 f(x*, theta) with J frozen at the solution and f the reverse-mode residual. Its value is x* and its derivative is -J^-1 df/dtheta, exactly the implicit-function one. So g must be a pure function of (x, args): everything the loop depends on goes in args, never in a closure. Building the Jacobian with VJPs through the checkpointed adjoint instead compiled for six minutes on the default diesel case; the forward-mode copy, with the beds under lax.scan, brings the whole hydrotreater to about 70 s.

The product stripper#

strip(feed, layout, comps, feed_T, P_top, dP_stage, steam_rate, steam_T, spec) runs a short steam-stripped column on the vacuum unit’s StageColumn: n_stages equilibrium stages, the feed (heated to feed_T, the column’s “furnace” knob) entering the top, liquid down, the bottoms leaving the last stage, steam under the last stage and (10 % by default, feed_steam_fraction) in the feed line. No condenser; the overhead goes to a drum. Thermodynamics are the vacuum unit’s (Raoult with Maxwell-Bonnell vapour pressures, steam as a non-condensing vapour). Real gas species are not column components and leave with the overhead, as in the vacuum column. Cut attributes follow their cut, and the overhead is the feed less the bottoms, so the balances close exactly.

Why steam in the feed line: a separator liquid with its gases taken out is a subcooled liquid at the stripper’s pressure, and StageColumn’s feed flash then has no vapour phase, which makes its Jacobian singular (condition number 1e16 on the default diesel). A tenth of the steam in the feed line gives the flash a vapour phase; the column then converges in four Newton iterations.

overhead_drum(overhead, layout, comps, T, P) is a PR flash: vapour is the sour off-gas, liquid the wild naphtha, all water decanted.

cut_pseudo_components(...) gives the stripper’s property table, with each cut’s own molecular weight (from its atoms) where it has flow.


The hydrotreater#

difflow_refinery.hydrotreating.Hydrotreater is a distillate hydrotreater – naphtha, kerosene or diesel, straight-run or cracked – on the shared building blocks: adiabatic trickle beds with quench, effluent cooler and HP separator, recycle-gas loop with amine scrubber, purge, compressor and makeup, product steam stripper and overhead drum. It is a library, not a palette operation (like the blend pool).

import difflow_refinery as dr
from difflow_refinery.hydrotreating import Hydrotreater, HydrotreaterParams, straight_run_cut

char = dr.characterize(assay, composition=True)              # #305 composition is required
feed = straight_run_cut(char, 230 + 273.15, 370 + 273.15, 50.0)   # or a CDU product stream
hdt = Hydrotreater(char, feed, HydrotreaterParams(T_in=(613.15,), P=50e5, lhsv=1.0, h2_oil=300.0))
res = hdt.solve(feed)
print(res.table())
res.outputs["product.S_wppm"], res.outputs["h2.chemical_nm3_m3"], res.balances

The unit carries every cut the feed has and every lighter pseudo-component, because the cracking leak puts molecules there. A crude-unit product stream works as it is (F_<char.names>); straight_run_cut is an idealized one (every cut boiling in a TBP range, in its crude proportion) for running without a crude unit.

The feed on the attribute layout#

hdt_feed(char, stream, layout) turns a characterized stream into attribute flows (HDT_ATTRIBUTES): C and H atoms; S atoms in each of the five SULFUR_CLASSES; N atoms in the two NITROGEN_CLASSES; molecules that are mono-, di- and poly-aromatic, olefinic and naphthenic. All of it is read from char.composition (#305):

  • carbon is 1 - H - S - N by mass, so the cut’s mass from its atoms equals F x MW exactly;

  • a cut’s volume fractions of types are taken as its mole fractions (the same assumption the composition module makes for procedure 2B4.1; product aromatics are reported back through the same rule, so an untreated cut reports exactly what it came in with);

  • total aromatics are split into mono/di/poly by DEFAULT_AROMATIC_SPLIT, an illustrative split by boiling point (all mono in naphtha, about 60/30/10 in diesel, more polyaromatic in the vacuum range) from no source. Pass measured ones (aromatic_split=, e.g. from EN 12916 or IP 391).

Reactions and rate laws#

Per cut, on the cut’s own attribute concentrations (c, mol/m³ of fugacity-equivalent liquid), per kg of catalyst, with f = activity x effectiveness x wetting, Arrhenius k(T) = k_ref exp(-E/R (1/T - 1/T_ref)), h = (c_H2/c_ref)^m and adsorption constants K(T) = K_ref exp(-dH_ads/R (1/T - 1/T_ref)):

Reaction

Rate

H2 per event

Heat per event (kJ/mol)

HDS, class j: S_j + nu_j H2 -> H2S

f k_j c_Sj h / (1 + K_H2S c_H2S + K_N c_Nbasic)^2

2.0, 4.0, 3.0, 2.6, 3.95

-104.8, -261.2, -157.1, -83.4, -172.8

HDN, basic / non-basic

f k_j c_Nj h / (1 + K_H2S c_H2S)

4.0, 5.0

-238.5, -263.3

poly + 2 H2 <-> di

f k h (c_A3 - c_A2 / (K3(T) (pH2/1 bar)^2))

2

dH(T): -114.8 at 25 °C, -124.8 at 350 °C

di + 2 H2 <-> mono

f k h (c_A2 - c_A1 / (K2(T) (pH2/1 bar)^2))

2

dH(T): -124.0, -132.1

mono + 3 H2 <-> naphthene

f k h (c_A1 - c_Nn / (K1(T) (pH2/1 bar)^3))

3

dH(T): -206.1, -219.9

olefin + H2 -> paraffin

f k c_O h

1

-125.3

cracking leak: molecule + H2 -> lighter molecule + C1–C4

f k c_cut

1

-42.55

Sulfur classes in the order sulfides, thiophenes, benzothiophenes, dibenzothiophenes, hindered (4-/4,6-alkyl) DBTs.

  • HDS is the Langmuir-Hinshelwood-Hougen-Watson rate with a squared H2S-inhibition denominator, the form of Korsten & Hoffmann (1996) and, with a richer denominator, of Froment, Depauw & Vanrysselberghe (1994) and Vanrysselberghe & Froment (1996). It is first order in each class. A sum of first-order classes with different constants is what gives a lumped total-sulfur rate its apparent order above one. Basic nitrogen adsorbs on the same sites and sits in the denominator. hds_form="power" gives the nth-order fallback f k c_ref,S (c_Sj/c_ref,S)^n h, with no inhibition.

  • Aromatics saturate reversibly, first order, with equilibrium constants ln K(T) = -dG(T)/RT from model-compound thermochemistry, with the heat capacities integrated from 298.15 K to the bed temperature: aromatic_ln_K(T), the shared table’s IdealGasSet.ln_K (#338). The standard state is the ideal gas at 1 bar, which is why the rate law divides by (pH2/1 bar)^n. The heat each step releases is aromatic_heat(T), the same integration, so the energy balance and the equilibrium’s van ‘t Hoff slope (d ln K/dT = dH(T)/RT², tested to 1e-12) agree. Both are pure JAX in T. Saturation is exothermic and loses moles of gas, so equilibrium recedes as temperature rises and aromatics pass through a minimum. Until #338 K used the 298 K dH and dS as constants; that made the benzene step’s K 3.1x, 3.8x and 5.1x too large at 300, 350 and 420 °C (2.6x and 2.2x for the poly and di steps at 350 °C).

  • H2 stoichiometry per class is that of a model compound (below). The hydrogen not leaving as H2S or NH3 goes onto the cut (H += 2 nu - 2 per S, 2 nu - 3 per N). A desulfurized molecule keeps its carbon skeleton; the cut’s molecule count does not change. Element balances are exact.

  • The cracking leak moves a molecule of cut i to the cut whose carbon number per molecule is nearest to i’s less the gas fragment’s (fixed at construction from the composition), splitting off one C1–C4 molecule in the proportions CRACK_GAS_SPLIT (10/15/35/15/25 % C1/C2/C3/iC4/nC4, illustrative), with one H2.

  • Deactivation is the activity multiplier a(t). It is a differentiable parameter, so difflow.reconciliation.tracking can track it from plant data as the drifting parameter that loop is built for (not wired up or tested here).

Model compounds behind the stoichiometry, heats and equilibrium: ideal gas, from the refinery’s one thermochemistry table (Hf, S0 and Cp of each compound with its source; CODATA for H2, H2S and NH3, API TDB for most organics). The module keeps no copy: MODEL_COMPOUNDS is a view of the table. Values at 298.15 K:

Class

Model reaction

dH (kJ/mol)

dS (J/mol/K)

sulfides

diethyl sulfide + 2 H2 -> 2 ethane + H2S

-104.8

thiophenes

thiophene + 4 H2 -> n-butane + H2S

-261.2

benzothiophenes

benzothiophene + 3 H2 -> ethylbenzene + H2S

-157.1

DBTs

80 % DBT + 2 H2 -> biphenyl + H2S (DDS), 20 % DBT + 5 H2 -> cyclohexylbenzene + H2S (HYD)

-83.4

hindered DBTs

35 % DDS / 65 % HYD, DBT model compounds

-172.8

basic N

quinoline + 4 H2 -> propylbenzene + NH3

-238.5

non-basic N

carbazole + 5 H2 -> cyclohexylbenzene + NH3

-263.3

poly -> di

phenanthrene + 2 H2 -> 1,2,3,4-tetrahydrophenanthrene

-114.8

-228.7 (THP’s S0 is the table’s estimate, phenanthrene + tetralin - naphthalene, so this equals the di step’s)

di -> mono

naphthalene + 2 H2 -> tetralin

-124.0

-228.7

mono -> naphthene

benzene + 3 H2 -> cyclohexane

-206.1

-363.9

olefins

1-hexene + H2 -> n-hexane

-125.3

cracking

n-hexane + H2 -> n-butane + ethane

-42.55

At 350 °C (1 bar standard state) the three saturation steps have ln K = -6.29, -4.36 and -5.34 and release 124.8, 132.1 and 219.9 kJ/mol.

The DDS/HYD route shares of the two DBT classes are illustrative, set by the qualitative finding (Girgis & Gates 1991; Vanrysselberghe & Froment 1996) that CoMo removes DBT mainly by direct desulfurization and 4,6-DMDBT mainly after ring hydrogenation. Heats are gas-phase. The aromatics steps’ heats are taken at the bed temperature (with their K); the irreversible reactions’ are 298 K values, a deliberate simplification – at 350 °C DWSIM’s conversion reactor gives them 3–15 % more heat (DWSIM comparison). The heats of vaporisation of the reacting species are neglected. Benzene is a more favourable case than an alkylbenzene, so the mono-aromatic equilibrium is if anything too far to the right.

Where the rate constants come from. The forms are the literature’s. The constants in HDTKineticParams (rate constants, activation energies, adsorption constants and enthalpies, H2 orders) are illustrative, chosen here so that a straight-run diesel at about 350 °C, LHSV 1 h⁻¹, 50 bar and 300 Nm³/m³ desulfurizes to a few hundred wppm and needs 370–380 °C for ULSD – the right order of magnitude for a CoMo catalyst. They are not Korsten & Hoffmann’s, not Froment’s, and not any commercial catalyst’s (those are proprietary). With these constants the model gives trends and orders of magnitude. Product sulfur to 10 ppm is predictive only after activity and the refractory-class constants are fitted to the unit’s own data (difflow.estimation).

Degrees of freedom and specs#

HydrotreaterParams:

Spec

Default

T_in

(613.15,) K

bed inlet temperatures: the first only when quench is given, every bed’s when quench=None

quench

(0.15,)

quench into each later bed, fraction of the treat gas; None to solve it from T_in

bed_fractions

(0.4, 0.6)

catalyst split between beds

P

50 bar

reactor pressure (no bed pressure drop)

lhsv

1.0 h⁻¹

feed standard liquid volume per hour per catalyst volume

catalyst_density

800 kg/m³

loaded density

h2_oil

300 Nm³/m³

treat-gas H2 to oil, including quench

purge

0.05

fraction of the scrubbed separator gas purged

makeup

97 % H2, 3 % CH4

makeup-gas composition, {gas: mole fraction}; the values may be JAX tracers, so outputs are differentiable in the makeup purity (the hydrogen network’s d h2.makeup / d purity); an array on the layout’s gases is accepted too

heater_efficiency

0.85

charge-heater absorbed / fired duty (illustrative)

heater_inlet_T

None

charge-heater inlet temperature after a feed/effluent exchanger; None: no exchanger

h2s_removal, nh3_removal

0.99, 1.0

amine and wash-water removal fractions

hps_T, loop_dP

50 °C, 3 bar

separator temperature; pressure drop round the loop (the compressor makes it up)

compressor_eta

0.75

isentropic efficiency

stripper_feed_T, stripper_P, stripper_dP

230 °C, 7 bar, 1 kPa/stage

stripper

steam_ratio, steam_T

0.01 kg/kg, 250 °C

stripping steam

drum_T, drum_dP

40 °C, 0.3 bar

overhead drum

stripper_stages

6

kinetics

HDTKineticParams()

rate constants and catalyst activity

TargetSpec(output, target) replaces the inlet temperatures by a target on any output – TargetSpec("wabt", 623.15) or TargetSpec("product.S_wppm", 10.0, scale=10.0) – by shifting every bed inlet by the same amount. It is a scalar Newton solve around the whole unit, differentiated implicitly (the shift is outputs["T_shift"]).

Assumptions, beyond those of the building blocks:

  • the reactor pressure is uniform (no bed pressure drop); the separator sits loop_dP below it and the compressor makes that up;

  • the makeup gas is delivered at the recycle compressor’s discharge temperature, and the treat gas and quench are at that temperature;

  • bed 1’s inlet temperature is a spec; the charge heater’s duty is computed from it (below), and the oil’s own feed temperature enters only there;

  • there is no wash water: the separator decants only the water the feed brought, and NH3 removal is the amine/wash fraction;

  • the HP separator vapour’s cuts are knocked out back into its liquid (knockout), so the recycle gas is real species only;

  • a cut’s gravity after treatment is computed from liquid molar-volume increments per saturation step (VOLUME_INCREMENTS: mono-aromatic -> naphthene +20.1, di -> mono +11.0, poly -> di +11.0, olefin -> paraffin +5.5 cm³/mol), from model-compound liquid densities at 60 °F (DIPPR 105 of Perry’s 8th ed., via chemicals); heteroatom removal is taken to change the volume by nothing; the cut’s boiling point and critical constants are the feed’s;

  • the stripper and the overhead drum run on the treated cuts’ own molecular weights.

Outputs#

HydrotreaterResult.outputs (units in OUTPUT_UNITS):

  • product: product.S_wppm, .N_wppm, .sg, .api, .H_wt, .aromatics_vol and its mono_/di_/poly_aromatics_vol, .olefins_vol, TBP .T05 … .T95 (K), .cetane_index (ASTM D4737 from the TBP->D86 points and density, the blend pool’s functions), .rate, .yield (mass), .volume_yield;

  • wild naphtha naphtha.rate, .yield, .sg, .S_wppm, .T50 (on the blend grid: liquid light ends and cuts);

  • gas.yield (mass fraction of feed): everything that leaves as gas – C1–C4, H2S and NH3 in the off-gas, purge, acid gas and waters, the vapour of the lightest cuts in the drum’s off-gas, plus the real gases dissolved in the liquid products off their blend grid – less the makeup’s own hydrocarbons; H2 and water excluded. A feed light end counts where it leaves (a C5 in the wild naphtha, a C3 mostly in the gas). Before #332 it subtracted every feed light end, including the C5s that leave as liquid, and came out at -10 % on a naphtha. yields.total = product + wild naphtha + gas yields, which equals 1 - feed.h2_water_rate/feed.rate plus the chemical hydrogen as a mass fraction (tested to 1e-9);

  • dissolved gases (#333): product.dissolved_gas_rate (zero: the stripper sends every real gas overhead) and naphtha.dissolved_gas_rate, kg/s of H2, H2S, NH3, C1 and C2 in the wild naphtha;

  • charge heater (#332, below): heater.duty (absorbed, W), heater.fired_duty, heater.inlet_T, feed_effluent.duty;

  • hydrogen: h2.chemical (mol/s), h2.chemical_nm3_m3, h2.chemical_scf_bbl, h2.chemical_wt – chemical consumption from the hydrogen balance on the characterized products (H atoms in every outlet’s cuts, H2S, NH3 and light hydrocarbons less those fed, halved); h2.consumed_by_balance (H2 in less H2 out, equal to it to round-off); h2.makeup, h2.makeup_nm3_m3, h2.dissolved (H2 in the separator liquid), h2.purge;

  • loop: recycle.rate, recycle.h2_purity, purge.rate, makeup.rate, compressor.power, compressor.T_out, reactor.pH2_in;

  • reactor: wabt, reactor.T_out, reactor.dT_total, per bed bed<k>.T_in, .dT, .quench; catalyst.mass; hds.conversion, hdn.conversion;

  • convergence: tear.residual, stripper.residual, flash.residual (the largest PR-flash residual: bed inlets, HP separator, drum); res.converged requires all three (flashes below FLASH_TOL = 1e-8) and a finite product.

res.balances gives the relative closure of mass, C, H, S and N over the whole unit (feed + makeup + steam = product + wild naphtha + off-gas + purge + acid gas + separator water + sour water). res.streams has every internal stream as Flows; res.reactor the bed profiles. res.product_char is a BlendCharacterization of the treated cuts and res.product_stream("product") the product as a stream on it, so the product goes straight into BlendComponent.from_stream for the ULSD or jet pool.

Product streams and dissolved gases#

res.product_stream(name, T=298.15, P=101325.0, gases=True) returns a liquid outlet ("product", "wild_naphtha") as F_<name> flows (mol/s) on res.product_char, plus, with gases=True (the default), the real gases dissolved in it that are not on that grid – H2, H2S, NH3, methane, ethane – under their own F_<gas> keys. With them the stream carries its whole mass, so a balance downstream closes to round-off: res.stream_mass(stream) (kg/s) equals res.streams[name].mass(unit.layout) to 1e-13, and the grid’s light-end molar masses are now the layout’s (IUPAC atomic weights), not the crude unit’s table, which differed in the fifth figure (#333).

  • The stripper bottoms carries no real gas by construction (the stripper sends them all overhead), so product_stream("product") is the blend grid only either way.

  • The wild naphtha does carry them (in example 40, 0.022 t/h at the naphtha unit and 0.047 t/h at the distillate unit – most of that refinery’s mass imbalance). BlendComponent.from_stream refuses species outside its grid, so a pool takes product_stream("wild_naphtha", gases=False), and outputs["naphtha.dissolved_gas_rate"] is exactly the mass that drops. Or send the wild naphtha through the fractionator, which puts the gases in an off-gas.

  • Water is never in these streams: the drum decants all of it.

The charge heater#

The bed-1 inlet temperature stays the spec; the heater duty follows from it (#332). The heater takes the oil and bed 1’s share of the treat gas (the treat gas less every quench) to T_in[0]:

Q_absorbed = H(bed-1 inlet, T_in) - H(heater inlet)
Q_fired    = Q_absorbed / heater_efficiency

H is the reactor’s own enthalpy (stream_enthalpy: the PR K-values and phase split at the bed inlet, ideal-gas and Watson-liquid enthalpies on one basis), so the heater and the bed agree on what the stream holds. The heater inlet is either

  • heater_inlet_T=None (default): no feed/effluent exchanger. The oil enters at its feed temperature (feed["T"]) as liquid – it is pumped to reactor pressure and holds no hydrogen yet, so it is below its bubble point – and the treat gas at the recycle compressor’s discharge temperature (ideal gas). The heater does all the heating. feed_effluent.duty is 0.

  • heater_inlet_T=<K>: the oil and gas mixed at that temperature, after a feed/effluent exchanger. feed_effluent.duty is that exchanger’s cold-side duty. Its hot side is not modelled (the reactor effluent goes to the separator at hps_T), so a temperature cross is not checked: keep heater_inlet_T below the reactor outlet less an approach.

heater_efficiency (default 0.85, absorbed over fired) is illustrative: a typical fired process heater with some heat recovery, from no cited source. Feed water, if any, is taken as vapour (the reactor’s convention), which understates the duty by its latent heat. Not modelled: the heater’s pressure drop, coil vaporisation profile, fuel and stack.

On the test diesel (50 kg/s from 25 °C, no exchanger) the absorbed duty is in the range a sensible-heat estimate gives (tested within 0.7–1.6 of m cp dT at 2.6 kJ/kg/K).

The product fractionator (optional, #328)#

res.fractionate(cut_points, products, width, feeds=("product",)) (or hydrotreating.fractionate(res, ...)) splits the liquid product – or the product and the wild naphtha together, feeds=("product", "wild_naphtha") – into named products at TBP cut points. Like the FCC and hydrocracker fractionators it is an idealized, differentiable split, not a stage column. Component i of the product grid (boiling point Tb_i) goes to product k (lightest first, cut points T_1 < ... < T_{n-1}) with the share

a_k(Tb) = sigmoid((Tb - T_k) / w),   a_0 = 1,  a_n = 0
share_k = a_{k-1} - a_k

the FCC main fractionator’s logistic step generalised to n products. The shares telescope to one, so every component’s moles, and the treated attributes they carry, are conserved: the mass closure is reported (FractionationResult.balance, below 1e-13 in the tests). The real gases the wild naphtha carries go whole to an off_gas stream (fr.off_gas, fr.rates["off_gas"]), so the liquid products are blendable as they are.

  • Defaults: products=("jet", "diesel"), cut_points=(240 °C,), w = 8 K. All three illustrative: w stands for the overlap of a real column’s neighbouring products; a column calculation would set it. A logistic step rather than the hydrocracker’s quintic because the quintic is flat beyond +/- w, which gives an exactly zero cut-point derivative when the cut point sits midway between two 30 K grid cuts.

  • fr.products[name] is an F_<product_char.names> stream, straight into BlendComponent.from_stream(name, stream, fr.char); fr.rates is kg/s per product.

  • Cut points and width are traceable: jax.grad of a pool property with respect to a cut point is exact, checked against Richardson-extrapolated central differences at 1e-5 (release test).

fr = res.fractionate(cut_points=(240 + 273.15,), products=("jet", "diesel"))
jet = BlendPool("jet")([BlendComponent.from_stream("jet", fr.products["jet"], fr.char,
                                                   flash_C=42.0, freeze_C=-47.0, smoke_mm=22.0)], [1.0])
ulsd = BlendPool("ulsd")([BlendComponent.from_stream("diesel", fr.products["diesel"], fr.char,
                                                     flash_C=60.0)], [1.0])

Not modelled: duties, reflux, side-stripper steam, flash points (the pools still take measured flash_C), and the D86 overlap a real column produces.

A naphtha hydrotreater#

The unit runs a naphtha as it is (example 40’s stabilised naphtha: C3–C5 light ends and four 30 °C cuts to 180 °C, 1070 wppm S). The bed is all vapour (V 9–14 in the negative flash), which is where the reactor’s phase model was wrong until #332 (see the reactor); with it fixed, the default (diesel) constants hydrogenate as they should, but they were chosen for diesel-range sulfur and reach only about 90 wppm at 30 bar, 320 °C, LHSV 4 h⁻¹ and 100 Nm³/m³.

NAPHTHA_HDT_PARAMS is an illustrative naphtha set: the diesel constants with HDS of the sulfide, thiophene and benzothiophene classes ten times faster (in a naphtha those are mercaptans, light sulfides and alkylthiophenes; the reactivity order of Girgis & Gates 1991, the factor chosen here), HDN a hundred times faster, and every aromatics-saturation step ten times slower (a CoMo naphtha catalyst at 20–35 bar passes benzene largely unsaturated). It is not any catalyst’s.

from difflow_refinery.hydrotreating import NAPHTHA_HDT_PARAMS
nht = Hydrotreater(char, naphtha, HydrotreaterParams(
    T_in=(320 + 273.15,), quench=None, bed_fractions=(1.0,), P=30e5, lhsv=4.0, h2_oil=100.0,
    stripper_feed_T=150 + 273.15, kinetics=NAPHTHA_HDT_PARAMS))

On example 40’s naphtha (single bed, stripper feed at 150 °C; the stripper’s default 230 °C does not converge on a naphtha and says so):

Bed inlet, P, LHSV, H2/oil

product S (wppm)

product N (wppm)

chemical H2 (Nm³/m³)

bed dT (K)

300 °C, 30 bar, 4 h⁻¹, 100 Nm³/m³

0.87

4.6

1.7

2.1

310 °C, 30 bar, 4 h⁻¹, 100 Nm³/m³

0.033

1.7

1.7

2.2

320 °C, 30 bar, 4 h⁻¹, 100 Nm³/m³

< 0.001

0.34

1.8

2.3

340 °C, 30 bar, 4 h⁻¹, 100 Nm³/m³

< 0.001

< 0.001

2.1

2.8

320 °C, 30 bar, 6 h⁻¹, 100 Nm³/m³

0.015

1.2

1.7

2.2

320 °C, 20 bar, 4 h⁻¹, 100 Nm³/m³

0.068

2.0

1.6

2.0

320 °C, 30 bar, 4 h⁻¹, 100 Nm³/m³, diesel constants

87

23

3.0

4.1

(Feed 1072 wppm S. Figures far below 1 wppm only say “removed”: the illustrative first-order classes have no refractory tail. The table was computed before #338 made the aromatics equilibria Cp-integrated and was not recomputed; in example 40’s naphtha unit, the 320 °C row’s conditions, the change moved the chemical hydrogen from 1.8 to 1.7 Nm³/m³ and the bed rise from 2.3 to 2.1 K, and left S and N where they were.) So a reformer feed (below about 0.5 wppm S and N) is reached at 30 bar and LHSV 4 from about 310 °C for sulfur and 320 °C for nitrogen. The charge heater takes 23.6 MW absorbed (27.8 MW fired at 0.85) at 320 °C with no feed/effluent exchanger, and the wild naphtha carries 0.011 kg/s of dissolved gas. The reformer carries the feed’s sulfur (#330; NaphthaFeed.from_hydrotreater passes the treated product’s sulfur, #327) but not its nitrogen, so the nitrogen figures are not seen downstream. Example 40’s earlier conditions (60 bar, LHSV 0.7, 400 Nm³/m³), which were what the diesel constants needed under the old phase model, now run away on the diesel constants (the aromatics saturate, the integration does not finish, converged=False); on the naphtha set they converge.

Where it fails. At 320 °C, 50 bar, LHSV 0.5 and 150 Nm³/m³ the bed-inlet PR flash does not converge (the mixture is near its critical region; Newton diverges). The unit returns converged=False with flash.residual about 1e-3 and warns; it used to raise an equinox NaN-in-linear-solve error (tests/refinery/test_hydrotreating_naphtha.py::test_a_failed_bed_inlet_flash_reports_a_residual_instead_of_raising, per commit, on the flash itself; test_a_failed_flash_is_reported_not_raised, release, on the whole unit).

Hydrotreated naphtha to the splitter and the reformer (#327)#

A hydrotreater’s liquids leave on its product grid, res.product_char. This is a BlendCharacterization of the liquid light ends (C3–C5, as real species) and the treated cuts. Each treated cut has the molar mass and gravity the reactor gave it, and its hydrocarbon types and sulfur as the *_vol and S_ppm qualities. Two adapters take a product from that grid to the two units that follow a naphtha hydrotreater.

Both read their input through gasplant.hydroprocessed.resolve_product(source, product, char=), which accepts:

  • a HydrotreaterResult with one outlet name or several (summed), taken with its dissolved gases;

  • a FractionationResult with a product name;

  • anything with product_stream/product_char, such as a hydrocracker result;

  • an explicit F_ stream together with char= its grid.

Reformer. NaphthaFeed.from_hydrotreater(source, product) maps the stream onto the reformer’s P/N/A lumps by carbon number. The steps are those of from_characterization (see the reformer feed), with three inputs taken from the grid instead of the crude characterization:

  • Mass. Each component’s mass is F_i MW_i at the grid molar mass. That is the treated cut’s for a cut, and the hydroprocessing atomic weights for light ends and gases. Each lump’s moles are then mass over the lump’s molar mass, so feed.mass_flow equals res.stream_mass(stream) to round-off.

  • Types. Each cut’s P/N/A/O volume fractions are the grid’s paraffins_vol, naphthenes_vol, aromatics_vol and olefins_vol after saturation. The hydrotreater reports types on the rule it reads them in by, so an unreacted cut maps exactly as from_characterization maps it (tested to 1e-12). Olefins count as paraffins.

  • Sulfur. feed.sulfur_wppm = sum_i m_i S_i / sum_i m_i, the mass average of the grid’s S_ppm. The reformer takes it as the feed’s organic sulfur when ReformerParams.feed_sulfur_wppm is None (#330). Dissolved H2S is not counted.

Methane and ethane map to C1 and C2. H2, H2S and NH3 dissolved in a wild naphtha are refused unless drop_gases=True; you can also fractionate them off first, since a fractionator sends them to its off-gas. The iso/normal and MCP/cyclohexane splits are from_characterization’s illustrative defaults. The feed is differentiable in the flows and in the grid’s arrays.

fr = nht_res.fractionate(cut_points=(85 + 273.15,), products=("light_naphtha", "heavy_naphtha"),
                         feeds=("product", "wild_naphtha"))
feed = NaphthaFeed.from_hydrotreater(fr, "heavy_naphtha")      # mass = fr.rates["heavy_naphtha"]
ref = CatalyticReformer(ReformerParams()).solve(feed)          # its sulfur balance sees feed.sulfur_wppm

Gas plant. gasplant.hydroprocessed.hydroprocessed_feed(source, product, T=, P=) returns a HydroprocessedFeed, with these fields:

  • components: a GasComponents table;

  • stream: the F_<components.names> stream;

  • mass_flow;

  • dropped;

  • below(T_cut): a light= set for splitter.

The table is built by product_components(grid, gases=) and holds three kinds of component:

  • Dissolved real gases (H2, H2S, C1, C2): real components with light_component_data constants. Ammonia has none in the gas plant’s tables (no ideal-gas Cp or LHV), so it is refused, or left out with unsupported="drop" and reported in dropped.

  • Grid light ends: real components with the database’s critical constants and Cp. Their molar mass is the grid’s, so the feed’s mass is the product’s to round-off; the two molar masses differ in the fifth figure.

  • Treated cuts: pseudocomponents with the grid’s MW, Tc, Pc and omega, and a Watson-Nelson ideal-gas Cp from the grid’s Tb, SG and MW (the correlation characterize uses for its own cuts). Only MW and SG are changed by the hydrotreater: Tb, Tc, Pc and omega are the feed cut’s. That is an approximation for a saturated cut (unverified in size).

min_flow= trims cuts carrying no more than that flow. drop_gases=True leaves out every dissolved gas that is off the grid. Both report what they leave out in dropped.

A splitter takes no dissolved gas. Example 40’s wild naphtha carries 0.31 mol/s H2, 0.26 mol/s H2S and 0.07 mol/s C1 in about 240 mol/s, which a total condenser has no outlet for. With them in the feed, a 12-tray splitter on product plus wild naphtha does not converge (NaN after 300 iterations). Without them, it converges in about 20 iterations. In a refinery these gases leave before the splitter, in a stabilizer or the stripper’s overhead drum. Pass drop_gases=True, or keep them and run a column that has somewhere to send them, such as a partial-condenser stabilizer.

feed = hydroprocessed_feed(nht_res, ("product", "wild_naphtha"), T=100 + 273.15, P=4e5,
                           drop_gases=True)
col = GasPlantColumn(splitter(feed.components, feed.below(85 + 273.15),
                              heavy_spec=("bottoms.x.light", 0.02), light_spec=("distillate.x.heavy", 0.02),
                              n_trays=12, feed_tray=6, top_P=3e5))
light_naphtha, heavy_naphtha, info = col(feed.stream)

The splitter’s products are streams over components.names. Their grid components go back to BlendComponent.from_stream(..., nht_res.product_char) or NaphthaFeed.from_hydrotreater(stream, char=nht_res.product_char) unchanged.

How this relates to gas_components(light, pseudo=char) and the crude-unit gas-plant feed helper (#326): it builds the same kind of table, from a product grid instead of a crude characterization. A CDU naphtha and an HDT naphtha carry different cuts under the same names. Do not mix them in one column; build one table per source.

What the tests check (tests/refinery/test_hydrotreated_naphtha_feeds.py):

Per commit, on a grid derived from a characterization with half its aromatics saturated, 1 % heavier cuts and a tenth of the sulfur:

  • untreated, the grid maps as from_characterization does;

  • treated, the mass is conserved to 1e-13, the aromatics are the treated ones, the sulfur average is exact, and the gases are refused or dropped;

  • jax.jacfwd through the grid’s qualities matches central differences;

  • the gas-plant table’s mass matches the product’s, less the dropped NH3;

  • an 8-tray naphtha splitter on a 7-component treated grid converges to both specs, and every component balances to 1e-8.

Slow, on example 40’s naphtha through the naphtha hydrotreater:

  • the fractionated heavy naphtha into the reformer: mass conserved to 1e-13, sulfur carried, reformer converged with mass, C, H and S closing;

  • a 12-tray splitter on product plus wild naphtha (drop_gases=True): the feed’s mass is the product’s less exactly the dropped gas, and the column converges and balances.

Results on the test diesel#

The test crude of the composition section (SG 0.86, 1.8 wt% S, 1500 wppm N, with a heavy end), its 230–370 °C straight-run diesel at 50 kg/s (11 286 wppm S, 406 wppm N), and the defaults above (two beds, 15 % quench to the second, bed 1 inlet 340 °C, 50 bar, LHSV 1 h⁻¹, 300 Nm³/m³):

WABT

350.9 °C; bed rises 12.7 and 6.9 K

product

258 wppm S, 232 wppm N, SG 0.850, 14.3 vol% aromatics (11.1 mono, 2.3 di, 0.9 poly), cetane index 58.3

yields (mass)

product 98.72 %, wild naphtha 0.39 %, gas (C1–C4, H2S, NH3) 1.21 %

charge heater

47.4 MW absorbed, 55.8 MW fired (from a 25 °C feed, no feed/effluent exchanger)

hydrogen

chemical 30.5 Nm³/m³ (181 scf/bbl), makeup 47.9 Nm³/m³; recycle purity 95.3 %

loop

recycle compressor 154 kW; purge 36 mol/s

closure

mass, C, H, S and N to 1e-15 relative; tear residual 9e-15; stripper 2e-12

Before #338 (equilibrium constants 2–5x too large, see Thermochemical data) the same case gave WABT 351.4 °C, 241 wppm S, 14.2 vol% aromatics and 32.4 Nm³/m³ of chemical hydrogen. At a 380 °C bed-1 inlet the difference is larger: 12.1 vol% aromatics (10.4 before), of which 3.0 vol% poly-aromatics (0.9) as the poly step’s equilibrium reverses, 31.5 Nm³/m³ of hydrogen (44.5) and a 393.1 °C outlet (400.4).

Compiling the unit takes about 70 s (more on a loaded machine); a solve then takes about 1.7 s, of which the recycle tear is five Newton steps. A reverse-mode gradient of all outputs costs one more compile and 30–140 s.

Gradients (tests/refinery/test_hydrotreating.py::test_gradients_match_central_differences): product S, chemical H2 consumption and liquid yield with respect to the bed inlet temperature, the pressure, the H2/oil ratio and the 50 % TBP point of the assay, AD against central differences. With plain central differences (steps 0.5 K, 0.5 bar, 3 Nm³/m³) they agree to 1e-5 – 9e-4, and the larger figures are the differences’ own O(h²) truncation; the test compares against Richardson-extrapolated differences at rtol 1e-5.

Trends (tested): product sulfur falls with inlet temperature and with pressure (H2 partial pressure); chemical H2 consumption rises with temperature; on a bed at 30 bar, total aromatics pass through a minimum between 300 and 480 °C as the saturation equilibrium recedes. On the test diesel’s bed (the trend test) the minimum is at 360 °C; it was at 390 °C before #338, when the equilibrium constants were 2–5x too large.

The numbers come from illustrative rate constants: read them as the shape of the answer, not a prediction for any catalyst.

What the tests check (tests/refinery/test_hydrotreating.py; the full-unit ones are marked slow):

  • converges from the default initialization, recycle included, on the straight-run diesel above, on a straight-run kerosene (150–250 °C) of a second, lighter and low-sulfur assay (SG 0.83, 0.35 wt% S), and on the diesel side product of a 30-stage CrudeDistillationUnit as it comes (stripping water and light-end traces included);

  • mass, C, H, S and N close to 1e-8 relative over reactor, separator, recycle and stripper (they close to about 1e-15); the H2 consumption by the hydrogen balance equals H2 in less H2 out;

  • gradients against Richardson-extrapolated central differences at 1e-5 (above); a single bed’s gradients, and newton_solve / solve_tear against closed forms;

  • product S falls with temperature and pressure, H2 consumption rises with temperature, aromatics pass through a minimum in temperature;

  • TargetSpec("wabt", ...) lands on its target; hdt_block delta vectors pass check_delta_vectors;

  • the product enters BlendPool("ulsd") through BlendComponent.from_stream, with the same sulfur, gravity and cetane index the unit reports;

  • the liquid outlets as streams carry their whole mass (dissolved gases included), and the unit’s mass balance closes to 1e-13 over them; gases=False drops exactly naphtha.dissolved_gas_rate; the yields add up (yields.total); the charge heater’s duty is positive and of the sensible-heat order;

  • the makeup composition is a traced input (jax.jacfwd through theta), and (release) d(h2.makeup, recycle purity, product S)/d(makeup purity) through the whole unit against Richardson differences;

  • the fractionator: shares sum to one, the jet/diesel split closes to 1e-13 and its two products go into the jet and ULSD pools with their sulfur averaging back to the unit’s; three products over product and wild naphtha send the dissolved gas to the off-gas; the cut-point gradient of four pool properties against Richardson differences (release);

  • on example 40’s naphtha (tests/refinery/test_hydrotreating_naphtha.py): balances, gas.yield positive and consistent on a feed carrying light ends, the charge heater with a feed/effluent exchanger, positive chemical hydrogen in a vapour bed, the light/heavy naphtha split; the 320 °C / 50 bar / LHSV 0.5 / 150 Nm³/m³ point returning converged=False with a warning instead of raising; and (release) NAPHTHA_HDT_PARAMS reaching below 0.5 wppm S at 30 bar, 320 °C, LHSV 4;

  • pins: PR fugacity coefficients against difflow’s PengRobinson (1e-8), the H2/H2S/NH3 Cp polynomials against PPO 5th ed. (0.5 %), every heat of reaction against the shared table, the aromatics ln K(T) against tc.ln_K (and its distance from the old constant-dH/dS form at 350 °C), its van ‘t Hoff slope against the heat the energy balance uses, element conservation of the kinetics at a point, and the power-law fallback.

A crude-unit product carries every cut at some trace level. Hydrotreater(..., trace=1e-9) leaves out cuts heavier than the heaviest one above that mole fraction, and reports what that drops as dropped_mass_fraction (below 1e-6 on the crude-unit diesel).

Planning: hdt_block#

difflow_refinery.hydrotreating.planning.hdt_block(unit, feed, levers, outputs) wraps the unit as a difflow.planning.Block, modelled on cdu_block. Levers: feed.bpd (or <product>.bpd with feed_product=, so link_cdu finds it; the feed composition is held at the base and, with the catalyst volume fixed, LHSV moves with the rate), reactor.T_in (°C; every bed inlet moves together – WABT is an output, write a row on it), h2_oil, pressure (bar), purge. Outputs (HDT_OUTPUTS): product S and N, gravity, cetane index, aromatics, product and wild-naphtha bbl/d, chemical H2 and makeup in Nm³/h, WABT, temperature rise, compressor kW, inlet H2 partial pressure, HDS conversion. Reverse mode only (ad_mode="rev" is forced): the diffrax adjoint is a custom_vjp.

References#

Key

Reference

Used for

How checked

H1

Peng, D.-Y.; Robinson, D.B. “A new two-constant equation of state.” Ind. Eng. Chem. Fundam. 1976, 15(1), 59–64. doi:10.1021/i160057a011

PR EOS, mixing rules, fugacity coefficient

The equations as coded match the standard form difflow’s own PengRobinson uses and are pinned against it in the tests. The bibliographic details are as recalled; the paper was not reached (publisher blocked) – volume/pages/DOI unverified.

H2

Robinson, D.B.; Peng, D.-Y. The characterization of the heptanes and heavier fractions for the GPA Peng-Robinson programs, GPA Research Report RR-28, 1978

kappa for omega > 0.49

Not checked against the report (unverified); the cubic is the form in common use.

H3

Rackett, H.G. J. Chem. Eng. Data 1970, 15(4), 514–517, doi:10.1021/je60047a012; Spencer, C.F.; Danner, R.P. J. Chem. Eng. Data 1972, 17(2), 236–241, doi:10.1021/je60053a012; Yamada, T.; Gunn, R.D. J. Chem. Eng. Data 1973, 18(2), 234–236, doi:10.1021/je60057a006

Liquid molar volume of the cuts

Equation form as in Poling, Prausnitz & O’Connell 5th ed. ch. 4 (recalled); citations unverified.

H4

Whitson, C.H.; Michelsen, M.L. “The negative flash.” Fluid Phase Equilib. 1989, 53, 51–71. doi:10.1016/0378-3812(89)80072-X

Negative flash

Unverified (not reached).

H5

Rachford, H.H.; Rice, J.D. J. Petrol. Technol. 1952, 4(10), sec. 1 p. 19, sec. 2 p. 3

Rachford-Rice

Unverified.

H6

Michelsen, M.L. “The isothermal flash problem. Part II. Phase-split calculation.” Fluid Phase Equilib. 1982, 9(1), 21–40

SS then Newton in ln K

Unverified.

H7

Korsten, H.; Hoffmann, U. “Three-phase reactor model for hydrotreating in pilot trickle-bed reactors.” AIChE J. 1996, 42(5), 1350–1360. doi:10.1002/aic.690420515

LHHW HDS form with squared H2S inhibition

Title, journal and year confirmed by web search (citing literature); volume/issue/pages/DOI as recalled (unverified). Their rate constants, solubility and density correlations are not used; their published profiles are not reproduced (below).

H8

Froment, G.F.; Depauw, G.A.; Vanrysselberghe, V. “Kinetic modeling and reactor simulation in hydrodesulfurization of oil fractions.” Ind. Eng. Chem. Res. 1994, 33(12), 2975–2988

LHHW HDS by sulfur class

Title, pages and DOI not verified (unverified). Form only; constants not used.

H9

Vanrysselberghe, V.; Froment, G.F. “Hydrodesulfurization of dibenzothiophene on a CoMo/Al2O3 catalyst: reaction network and kinetics.” Ind. Eng. Chem. Res. 1996, 35(10), 3311–3318

DBT network (DDS/HYD routes), LHHW form

Web search did not return the paper; details unverified. Qualitative use only.

H10

Girgis, M.J.; Gates, B.C. “Reactivities, reaction networks, and kinetics in high-pressure catalytic hydroprocessing.” Ind. Eng. Chem. Res. 1991, 30(9), 2021–2058

Class reactivity order; DBT routes

Unverified. Qualitative use only.

H11

Mederos, F.S.; Elizalde, I.; Ancheyta, J. “Steady-state and dynamic reactor models for hydrotreatment of oil fractions: a review.” Catal. Rev. Sci. Eng. 2009, 51(4), 485–607

Background: lumped HDS/HDN/HDA models

Title confirmed by web search (publisher listing); volume/pages as in the issue, DOI unverified. Not used for numbers.

H12

Bell, C. et al., chemicals (Python package) v1.5.2 and thermo v0.6.1

Model-compound Hf, S°; liquid densities (DIPPR 105 from Perry’s 8th ed.); dHvap at Tb (CRC); ChemSep PR k_ij; cross-check of the RPP Cp polynomials

Values read from the packages’ tables (offline). The packages transcribe primary sources; the primary source of each number was not checked (unverified).

H13

Reid, R.C.; Prausnitz, J.M.; Poling, B.E. The Properties of Gases and Liquids, 4th ed., McGraw-Hill, 1987, App. A

Ideal-gas Cp of H2, H2S, NH3

Coefficients as recalled; checked against the PPO 5th ed. polynomials (via chemicals): within 0.5 % at 300–700 K (pinned in tests).

H14

Poling, B.E.; Prausnitz, J.M.; O’Connell, J.P. The Properties of Gases and Liquids, 5th ed., McGraw-Hill, 2001, App. A

Light-end constants (via the crude unit’s table)

As the crude unit cites them.

H15

ASTM D4737 (four-variable cetane index)

Product cetane index

The blend pool’s function, as cited there.

H16

Wieser, M.E.; Berglund, M. “Atomic weights of the elements 2007.” Pure Appl. Chem. 2009, 81(11), 2131–2156

Atomic masses

Values are the standard ones; citation details unverified.

What is not done#

  • The Korsten & Hoffmann (1996) profile cross-check is not done. Their paper could not be reached from here (publisher sites are blocked), so neither their parameters nor their profiles could be verified, and no reproduction is claimed. Their gas-liquid / liquid-solid film model and their Henry’s-law and Standing-Katz density correlations are not implemented either.

  • Smoke point is not computed. The correlation the issue names (Riazi MNL50, Tb and SG) could not be verified; a jet pool takes a measured smoke_mm override.

  • No dedicated example notebook. examples/40_refinery_flowsheet.ipynb runs the unit twice inside a whole refinery: as a naphtha hydrotreater (NAPHTHA_HDT_PARAMS, 30 bar, 320 °C, LHSV 4) whose product and wild naphtha are fractionated into light and heavy naphtha ahead of the reformer, and as a distillate hydrotreater on the CDU’s kerosene, diesel and AGO, whose product is fractionated into jet and diesel (res.fractionate). Both take their makeup from the hydrogen header (close_hydrotreater_loop).

  • Not a difflow Flowsheet object. The recycle is solved by the unit’s own tear (above); a Flowsheet wiring of the same pieces is not provided.

  • Commercial catalyst kinetics are not reproduced and must not be implied: the rate constants are illustrative.

  • No dissolved-gas effect in the stripper: the real gases leave with the overhead without taking part in the column’s equations.

  • The product fractionator is a TBP split, not a column (no duties, reflux or steam; the overlap width is illustrative). A StageColumn fractionator is not built.

  • The charge heater has no feed/effluent exchanger hot side: heater_inlet_T is taken as given and a temperature cross is not checked; no heater pressure drop, coil profile or fuel balance.

  • Near a mixture’s critical region (a naphtha with little treat gas at 50–60 bar) the bed-inlet PR flash can fail; the unit then returns converged=False. A stability test and a critical-point-robust flash are not implemented.

  • Deactivation tracking with difflow.reconciliation.tracking is possible (the activity is a parameter with a gradient) but not demonstrated.

  • Out of scope (as the issue says): residue hydrotreating/HDM, countercurrent reactors, reactor internals and pressure drop, amine unit detail, dynamics.

Alkylation#

difflow_refinery.alkylation (#310) combines isobutane with C3-C5 olefins over sulfuric or hydrofluoric acid, settles the acid out, and fractionates the effluent: a deisobutanizer (DIB) whose overhead is the isobutane recycle, a depropanizer on a slipstream of that recycle, and a debutanizer. The products are alkylate (to BlendPool), propane and n-butane. It is differentiable in the isobutane/olefin ratio, reactor temperature, acid strength, space velocity and olefin feed rate and composition, through the recycle.

import difflow_refinery.alkylation as al

unit = al.AlkylationUnit()                      # defaults: I/O 8, 10 C, 89 wt% H2SO4
res = unit.solve(al.c3c4_olefin_feed())         # or al.combine_feeds(fcc_c3, fcc_c4)
res.outputs["alkylate.MON"], res.outputs["dib.reboiler"]
alkylate = res.alkylate_component(S_ppm=5.0)    # a BlendComponent for BlendPool

feed = al.c3c4_olefin_feed()
blk = al.alky_block(unit, feed, levers=["io_ratio", "reactor.T", "acid_strength"])
al.solve_process_gms()                          # the Bracken-McCormick problem, profit 1161.3366

Like the blend pool, this is a library, not a palette operation: the plugin registers no new entry point. Tests: tests/refinery/test_alkylation.py. No example notebook was written (see Alkylation: not done).

The correlations are not predictive. The yield, octane and acid correlations are regressions on one 1960s sulfuric-acid plant. The defaults are illustrative. Refit them to the unit’s own data (difflow.estimation) before the model is used to plan.

Alkylation: the flowsheet#

olefin feed --+
makeup iC4 ---+--> makeup --> reactor --> DIB --+--> bottoms --> DeC4 --> alkylate
              ^                               |  overhead          \--> n-butane
              |                               v  (tear)
              +----------- direct -------- splitter
              |                               | slipstream
              +------ DeC3 bottoms <------- DeC3 --> propane

A difflow Flowsheet. The tear is the DIB overhead (dib_recycle). It is solved by Anderson acceleration in a forward solve, and under jax.grad/jax.jacfwd by the flowsheet’s optimistix fixed point, differentiated implicitly. Both default feeds converge from the default initialisation in 5-6 tear iterations. That initialisation is the isobutane the I/O ratio needs, less the feed’s, plus the propane the purge will hold.

  • Makeup (IsobutaneMakeup) mixes the fresh feed, both recycle branches and makeup isobutane. It adds the makeup that brings the reactor feed to the specified external I/O ratio. The ratio is a spec and the makeup rate is computed.

  • Reactor (AlkylationReactor), with an ideal acid settler. The acid phase is not carried. The acid consumed is reported as an operating cost.

  • DIB on the reactor effluent. Isobutane and the propane go overhead, n-butane and alkylate to the bottoms.

  • Depropanizer on a fraction (depropanizer_fraction, 0.3) of the DIB overhead. It rejects propane, and its bottoms rejoin the recycle. At steady state the recycle carries F_C3 / (fraction x recovery) of propane.

  • Debutanizer on the DIB bottoms. n-butane goes overhead, alkylate to the bottoms.

Feeds. The feed is a plain difflow stream of real species (mol/s). Any subset of ALKYLATION_SPECIES is accepted, with absent species counted as zero. The FCC of #308 emits its c3 (propane, propylene) and c4 (isobutane, n-butane, 1-butene, isobutylene, cis- and trans-2-butene) outlets in exactly these names. al.combine_feeds(c3, c4) makes them one feed, and a test runs the unit on that union. The two example feeds, c3c4_olefin_feed and c4_olefin_feed, have illustrative compositions that are not taken from a source.

Alkylation: species and properties#

All species are real molecules in difflow.database.

Role

Species

Olefins

propylene; 1-butene, cis-2-butene, trans-2-butene, isobutylene; 1-pentene, 2-methyl-2-butene

Isoparaffin

isobutane

Inerts

propane, n-butane, isopentane, n-pentane

C7 alkylate (from propylene)

2,3-dimethylpentane, 2,4-dimethylpentane

C8 alkylate (from butenes)

2,2,4-trimethylpentane, 2,3,4-trimethylpentane, 2,5-dimethylhexane

C9 alkylate (from amylenes)

2,2,5-trimethylhexane

Heavy ends

a C12 lump with n-dodecane’s properties

The heavy-end lump is a property surrogate. Dimer-alkylate heavy ends are C12 (2 C4= + iC4 → C12H26), and n-dodecane is the C12 paraffin that every property table carries. Its octane never enters the model.

Added to difflow.database for this issue: 2,3- and 2,4-dimethylpentane, 2,2,5-trimethylhexane and n-dodecane (both tables), and ideal-gas data for 1-butene. They were obtained and checked the same way as the #305 isomers. Each value is read from the data tables of the chemicals package (v1.5.2) and accepted where independent tables agree. Values on which the tables disagree are marked “(unverified)” in SOURCE_CITATIONS:

  • 2,3-dimethylpentane Hf: CRC -198.7 kJ/mol, API TDB -194.1 kJ/mol.

  • 2,2,5-trimethylhexane Pc: no IUPAC value.

  • 2,2,5-trimethylhexane ω: 0.345 to 0.357.

  • n-dodecane ω: 0.562 to 0.576.

What the alkylation model adds (alkylation.species):

  • Molar mass from the formula and the IUPAC atomic weights. The database values are rounded to 0.01 g/mol, and with them a mass balance across C5= + iC4 → C9 would close only to about 1e-5.

  • Standard volumes at 60 °F by COSTALD (Hankinson & Thomson 1979). The characteristic volumes V* and ω_SRK are the published fitted parameters. For 2,3,4-TMP, 2,5-DMH and 2,2,5-TMH, which have none, V* is fitted to the CRC density at 20 °C. Checked against GPA 2145: propane, isobutane and n-butane come out at 0.5073, 0.5625 and 0.5844 against 0.50736, 0.56293 and 0.58407, and isopentane and n-pentane agree within 0.4 %. Checked against CRC at 20 °C: within 1.5 % for every species with tabulated parameters, the worst being 2,3-DMP at 1.44 %.

  • Liquid heats of formation, Hf(l) = Hf(g) - ΔHvap(298 K). Hf(g) comes from the refinery’s shared thermochemistry table (#339; API TDB, CRC for 1-butene and 2,3-DMP; before #339 from difflow.database, up to 1.4 kJ/mol different), and ΔHvap from CRC. Checked against CRC’s own liquid Hf for 1-butene, 2,3-DMP, 2,4-DMP, 2,2,4-TMP and n-dodecane, on CRC’s gas basis: all within 0.8 kJ/mol.

Alkylation: the reactor#

Every olefin C_nH_2n reacts with isobutane by one of two routes. Both conserve C and H exactly, so mass balances by construction:

Route

Stoichiometry

Products

Alkylation

C_nH_2n + iC4H10 → C_(n+4)H_(2n+10)

per-olefin selectivity table (DEFAULT_SELECTIVITY)

Heavy ends

(8/n) C_nH_2n + iC4H10 → C12H26

the C12 lump

Conversion is complete by default (olefin_conversion = 1). The split between the routes is what the correlation sets. The Sauer-Colville-Burwick yield Y(r) = 1.12 + 0.13167 r - 0.00667 r² (vol alkylate per vol olefin, r the external I/O ratio by 60 °F volume) peaks at Y_max = 1.7698 at r = 9.87. The model reads ε = Y(r)/Y_max as an alkylation efficiency. For each olefin it then sends to heavy ends the fraction h_j that makes that olefin’s volume yield ε times its all-alkylation yield:

(1 - h_j) Y_A,j + h_j Y_H,j = ε Y_A,j     =>     h_j = Y_A,j (1 - ε) / (Y_A,j - Y_H,j)

Here Y_A,j and Y_H,j are the stoichiometric 60 °F volume yields of the two routes. The butenes’ Y_A are 1.73 to 1.81, within 2.5 % of Y_max, so on a butene feed the reactor reproduces the correlation’s yield to about 1 % (tested). Propylene (1.80) and the amylenes (1.67 to 1.72) follow the correlation’s shape about their own stoichiometric yields. This mapping is this module’s modelling choice, not part of the published correlation. The default selectivities are illustrative too:

  • isobutylene and 2-butenes go mostly to TMPs;

  • 1-butene gives more 2,5-DMH;

  • propylene gives 60/40 2,3-/2,4-DMP;

  • amylenes give 2,2,5-TMH.

They are not from a source.

Octane. The motor octane number is the correlation’s, plus two linear corrections:

MON = 86.35 + 1.098 r - 0.038 r² - 0.325 (89 - S) + c_T (T - T_ref) + c_SV (SV - SV_ref)

Here S is the acid strength (wt%), T the reactor temperature and SV the olefin space velocity. The temperature and space-velocity terms are not in Sauer et al., and their defaults are assumptions of this module:

  • c_T = -0.1 MON/K about T_ref = 283.15 K (about -0.55 per 10 °F);

  • c_SV = -2 MON per v/h/v about 0.3.

Set both to zero to recover the published correlation exactly; a test checks that. RON is MON + 2.5. The 2.5 is the sensitivity of the illustrative alkylate in BlendComponent’s example (RON 96, MON 93.5), and it is unverified.

Acid. For H2SO4, the acid consumed per bbl of alkylate is dilute · S / (98 - S), with dilute = 35.82 - 0.222 F4 and F4 = -133 + 3 MON (Sauer et al., as in process.gms). A temperature rise therefore raises acid consumption through the octane. HF has no open correlation. acid="HF" requires hf_acid_lb_per_bbl from the unit’s data and raises without it. It uses the H2SO4 yield and octane correlations as they stand.

Heat. The heat of alkylation comes from Hess’s law on the liquid heats of formation at 298.15 K. The temperature dependence of the heat of reaction between 298 K and the reactor is neglected. Values:

  • isobutylene + iC4 → 2,2,4-TMP: -67.1 kJ/mol olefin;

  • trans-2-butene: -70.9 kJ/mol;

  • propylene: -84.9 kJ/mol.

These are route A at the default selectivity. Before #339 they were -67.9, -71.5 and -86.3, on difflow.database’s gas Hf.

The refrigeration duty is that heat plus the sensible heat of cooling the reactor feed to T (Peng-Robinson liquid enthalpy, CubicThermo, kij = 0). It is also reported as the isobutane vaporised to remove it (Watson’s latent heat from the CRC value at Tb).

Range. process.gms bounds the regression variables to where the plant ran: r in [3, 12], S in [85, 93], MON in [90, 95]. Outside them the reactor raises AlkylationRangeWarning. The yield quadratic, for one, falls again above r = 9.87.

Alkylation: fractionation#

This unit was built before the gas plant’s cubic-EOS columns (difflow_refinery.gasplant, #312) reached main; moving the fractionation onto them is follow-up work. All three columns are Fenske-Underwood-Gilliland shortcut columns (KeySplitColumn), each specified by its two key recoveries and its reflux ratio:

Step

Equation

Source

Volatility

α_i = Psat_i(T_col)/Psat_HK(T_col), Raoult, Lee-Kesler Psat from the database (Tc, Pc, ω); T_col where Psat_LK Psat_HK = P²

Lee & Kesler (1975)

Non-key split

log(d_i/b_i) = A + C log α_i, A = log(d_HK/b_HK), C = [log(d_LK/b_LK) - A]/log α_LK; fraction overhead sigmoid(A + C log α_i)

Geddes (1958); Hengstebeck (1961)

Minimum stages

N_min = log[(d/b)_LK (b/d)_HK]/log α_LK

Fenske (1932)

Minimum reflux

Σ α_i z_i/(α_i - θ) = 0 (q = 1), root just above the heavy key; R_min + 1 = Σ α_i x_D,i/(α_i - θ)

Underwood (1948)

Stages

Y = 1 - exp[(1 + 54.4X)/(11 + 117.2X)·(X - 1)/√X]

Gilliland (1940), Molokanov et al. (1972) form

Duties

CMO, saturated-liquid feed and products: V = (R+1)D; condenser V·Σ x_D λ(T_top), reboiler V·Σ x_B λ(T_bot); T_top, T_bot the Raoult bubble points of the products; λ by Watson from CRC ΔHvap(298 K)

Watson (1943)

The only inner solves are two fixed-length bisections, each followed by one Newton step that attaches the implicit-function gradient. Column-level AD matches central differences to 1e-5 relative (tested).

Column

Keys

Recoveries (LK up / HK down)

R

P (bar)

On the C3/C4 feed: N_min, R_min, N

Depropanizer

propane / isobutane

0.95 / 0.99

40

17

8.8, 8.4, 9.9

DIB

isobutane / n-butane

0.97 / 0.85

2.5

7

16.6, 2.03, 35.6

Debutanizer

n-butane / isopentane

0.95 / 0.90

1.5

5

6.2, 0.53, 9.5

These defaults are illustrative design choices, made so that the reflux is above the Underwood minimum on both example feeds. On the C4-only feed the depropanizer’s R_min is 34, because the slipstream is lean in propane. columns_feasible reports R > R_min for every column.

Two things found on the way.

  1. difflow’s ShortcutColumn had a sign error in its Hengstebeck-Geddes constants (now fixed). It used A = log(d_LK/b_LK) - log(d_HK/b_HK) and C = log(d_LK/b_LK)/log α_LK. That does not reproduce the heavy key’s own split, and on a propane/isobutane depropanizer it sent 99.9 % of the n-butane overhead. This unit first worked around it with an override, GeddesShortcutColumn. The base class is now fixed (A = log(d_HK/b_HK), C = [log(d_LK/b_LK) - log(d_HK/b_HK)]/log α_LK, the line through both keys). GeddesShortcutColumn remains as an alias, and tests/test_distillation.py::TestShortcutColumnNonKeyDistribution is the regression test.

  2. ShortcutColumn with Peng-Robinson is too expensive to differentiate through the recycle. Three of them, each with nested Newton bubble-point solves inside the tear’s implicit fixed point, exhausted this machine’s memory. They remain available for forward cross-checks as AlkylationUnitParams(fractionation="pr_shortcut").

Measured on the C3/C4 feed against pr_shortcut with the same specs (test_peng_robinson_shortcut_cross_check):

  • product flows and the alkylate RVP agree to 0.2 %;

  • condenser duties agree to 1 %;

  • reboiler duties differ by up to 25 % (the DIB’s: 33.6 MW CMO against 26.8 MW by the PR energy balance), because the CMO duty uses the bottoms’ latent heat and neglects sensible heat;

  • R_min and N_min differ by up to a factor of two between Raoult/Lee-Kesler and PR volatilities.

Treat the reboiler duties as order-of-magnitude until the fractionation is moved onto the gas plant’s rigorous columns (#312).

Alkylation: specs and outputs#

Degree of freedom

Where

Default

External I/O ratio (sets makeup, hence recycle)

makeup.io_ratio

8

Reactor temperature

reactor.T

283.15 K

Acid strength

reactor.acid_strength

89 wt%

Olefin space velocity

reactor.space_velocity

0.3 1/h

Olefin feed rate and composition

the feed stream

Key recoveries and reflux of each column

deisobutanizer, depropanizer, debutanizer

table above

Depropanizer slipstream

depropanizer_fraction

0.3

The debutanizer is specified by its n-butane recovery, not by the alkylate RVP. RVP is an output, and an RVP target is a row on alkylate.RVP_psi (the same argument as cdu_block’s for cut points). The issue’s other suggested specs are not implemented as specs: DIB overhead purity and acid/hydrocarbon ratio.

Outputs (OUTPUT_UNITS), from the two default feeds at the default specs:

Output

Units

C4 feed

C3/C4 feed

olefin.bpd

bbl/d

2709

2921

alkylate.bpd

bbl/d

4840

5234

alkylate.yield (debutanized alkylate per vol olefin, incl. feed C5s)

-

1.787

1.792

alkylate.yield_correlation

-

1.746

1.746

alkylate.MON / RON

-

92.7 / 95.2

92.7 / 95.2

alkylate.SG

-

0.704

0.700

alkylate.RVP_psi (Raoult, D323 bomb, Lee-Kesler Psat)

psi

2.39

2.66

alkylate.T10/50/90_tbp

°C

92 / 106 / 120

79 / 99 / 120

isobutane.consumed_bpd / makeup_bpd / recycle_bpd

bbl/d

2969 / 1730 / 18 140

3356 / 2778 / 19 410

propane.bpd, n_butane.bpd

bbl/d

102, 1083

437, 968

acid.lb_per_bbl, acid.klb_d

lb/bbl, 1000 lb/d

35.7, 173

35.7, 187

reactor.heat, refrigeration.duty

MW

4.0, 7.0

4.8, 8.0

dib.reboiler, dib.condenser

MW

29.9, 20.0

33.6, 22.8

dec3.*, dec4.* reboiler/condenser

MW

1.1/1.1, 1.3/0.9

5.0/4.8, 1.2/0.8

D86 points (T10/50/90_d86) come from the TBP points by Riazi-Daubert. That correlation was fitted on petroleum fractions. On this narrow-boiling alkylate it gives a D86 T10 above T50 (111 against 106 °C on the C4 feed), so read the TBP points. The alkylate here has no C5-C7 light ends from cracking or hydrogen transfer, which a real alkylate has, so its front end is too heavy.

Trends (tested, C4 feed):

  • MON rises with I/O and falls with temperature;

  • DIB duty rises with I/O;

  • acid consumption falls with I/O.

Gradients (tested): the implicit gradients of alkylate yield, MON and DIB reboiler duty with respect to I/O ratio, temperature and acid strength, taken by jax.jacfwd through the recycle, match central differences to 1e-5 relative. At the C4 base point:

Output

d/d(I/O)

d/dT (per K)

d/dS (per wt%)

Yield

0.0250

0

0

MON

0.490

-0.1

0.325

DIB reboiler

3.65 MW

0

0

The zeros are structural: in the correlations neither yield nor flows depend on T or S.

Alkylation: the correlation layer, cross-checked against process.gms#

alkylation.correlations transcribes the Sauer-Colville-Burwick regressions from the GAMS model process.gms. The source was read from GAMS’s own GAMSPy translation (GAMS-dev/gamspy-examples, models/process/process.py; gams.com itself was not reachable). Every coefficient, bound, price and starting level is pinned in the tests. ratio is labelled “isobutane makeup to olefin ratio” in process.gms, but it is defined as (isor + isom)/olefin, the external I/O ratio.

solve_process_gms() solves the problem with difflow’s own gradient-based solver: the JAX primal-dual interior-point method of difflow_power.ipm, with exact Hessians, on scaled variables, from process.gms’s starting point.

Model

Published

difflow

Notes

process (regressions exact)

1161.33660200 (MINLPLib primal bound for instance process, minimisation sign)

1161.336602, converged in 10 iterations, residual < 1e-9

reproduced to the digits given; olefin 1728.92, isor 16000 (bound), isom 2000 (bound), acid 98.161, alkylate 3056.49, strength 90.616, octane 94.188, ratio 10.411, dilute 2.617, f4 149.563

rproc (each regression ±10 %)

none found

2410.83

not checked against a published number

The MINLPLib value was read from a search-engine summary of minlplib.org, which itself was not reachable. The 1968 book was not opened.

Alkylation: planning#

alky_block(unit, feed, levers, outputs) is a difflow.planning.Block, like cdu_block. It has these levers:

  • io_ratio;

  • reactor.T (°C);

  • acid_strength (wt%);

  • space_velocity (1/h);

  • olefin.bpd, the feed rate with composition held.

Any OUTPUT_UNITS name can be an output. The alkylate.bpd, alkylate.RON, alkylate.MON and alkylate.RVP_psi outputs link to a blend-pool component, and result.alkylate_component() gives the same properties as a BlendComponent. jit=False is the default: compiling the traced recycle takes minutes. A forward evaluation is about 20 s eager, and a 3-lever Jacobian by jacfwd about 35 s.

Alkylation: not done, and why#

  • Rigorous fractionation (#312). Built before #312’s gas-plant columns reached main; shortcut columns stand in (above) until they are wired in. The reboiler duties are uncertain to about 25 %.

  • Kinetic option. The carbocation schemes of Langley & Pike (1972) and Lee & Harriott (1977) are not implemented. The issue lists them as non-default; the papers were not available to transcribe.

  • Per-olefin yields, isobutane consumption and octanes from Gary, Handwerk & Kaiser. The table could not be consulted. The per-olefin selectivities and the octane corrections are labelled illustrative instead.

  • Pure-component octanes (API RP 45). Not used. The alkylate octane is the correlation’s.

  • Example notebook (FCC LPG → alkylation → gasoline pool). Not written. A forward solve takes about 20 s, plus a minute of compilation on first use.

  • HF acid consumption, ASO make, and selectivity against mixing. There is no open model (class (d)). The HF figure is a required input.

  • Out of scope per the issue: acid regeneration, HF mitigation, solid-acid and ionic-liquid alkylation, contactor hydrodynamics, dynamics.

Alkylation: references#

What

Source

Checked

Yield, MON, F-4, dilution, acid and makeup equations and coefficients; bounds; prices

GAMS Model Library process.gms (SEQ=20), “Alkylation Process Optimization”, read via GAMS’s GAMSPy translation GAMS-dev/gamspy-examples/models/process/process.py

transcribed and pinned in tests; optimum reproduced

The correlations’ origin

Sauer, R.N., Colville, A.R., Burwick, C.W., “Computer points the way to more profits”, Hydrocarbon Processing 43(3), 84 (1964)

unverified: not opened; volume, issue and page as given in the issue

Their restatement as an NLP

Bracken, J., McCormick, G.P., Selected Applications of Nonlinear Programming, Wiley, New York (1968), Ch. 4

as cited by process.gms; book not opened

Reference optimum 1161.33660200

MINLPLib, instance process (primal bound)

from a search summary; minlplib.org not reachable

Mechanistic kinetics (not implemented)

Langley, J.R., Pike, R.W., “The kinetics of alkylation of isobutane with propylene”, AIChE J. 18(4), 698-705 (1972); Lee, L.M., Harriott, P., “The kinetics of isobutane alkylation in sulfuric acid”, Ind. Eng. Chem. Process Des. Dev. 16(3), 282-287 (1977)

journal, volume, issue and pages confirmed by web search; DOIs not confirmed and so not given

COSTALD liquid volume

Hankinson, R.W., Thomson, G.H., “A new correlation for saturated densities of liquids and their mixtures”, AIChE J. 25(4), 653-663 (1979), doi:10.1002/aic.690250412

citation confirmed by web search; coefficients checked against the chemicals implementation and its API Technical Data Book propane example (530.30 kg/m³, reproduced to 1e-12)

COSTALD parameters V*, ω_SRK

Hankinson & Thomson (1979), as tabulated in chemicals 1.5.2 “COSTALD Parameters.tsv”

table number unverified; validated against GPA 2145 SGs (below)

Standard SGs of light ends (check)

GPA 2145, as in difflow_refinery.assay.LIGHT_END_SG

edition as in that module

Tb, ΔHvap(298 K), ΔHvap(Tb); liquid Hf and 20 °C densities (checks)

CRC Handbook of Chemistry and Physics, tables “Enthalpy of Vaporization”, “Standard Thermodynamic Properties of Chemical Substances”, “Physical Constants of Organic Compounds”, as transcribed in chemicals 1.5.2

edition unverified

Isobutylene ΔHvap

Perry’s Chemical Engineers’ Handbook, Table 2-150 (C1 = 32614 J/mol, C2 = 0.38073)

edition unverified

Gas Hf (check)

API Technical Data Book (Albahri), as in chemicals 1.5.2 “API TDB Albahri Hf (g).tsv”; ATcT 1.112 for 1-butene

all within 1.5 kJ/mol except 2,3-DMP (flagged)

New database species (Tc, Pc, ω, Cp, Antoine, ΔHvap, Hf)

as for the #305 isomers: IUPAC critical reviews / PPO 5e App. A, TRC Cp fits, PPO Antoine, CRC; see difflow.database.SOURCE_CITATIONS

Cp(298) against the Poling databank to 1.5 %, Antoine Tb to 2 %, Lee-Kesler Psat(Tb) to 6 %, all tested

Lee-Kesler vapour pressure

Lee, B.I., Kesler, M.G., AIChE J. 21(3), 510 (1975) (difflow_refinery.correlations.vapor_pressure)

as in that module

Watson latent heat

Watson, K.M., Ind. Eng. Chem. 35, 398 (1943)

as commonly cited; not opened

Fenske, Underwood, Gilliland/Molokanov, Geddes, Hengstebeck

Fenske, Ind. Eng. Chem. 24, 482 (1932); Underwood, Chem. Eng. Prog. 44, 603 (1948); Gilliland, Ind. Eng. Chem. 32, 1220 (1940); Molokanov et al., Int. Chem. Eng. 12, 209 (1972); Geddes, AIChE J. 4, 389 (1958); Hengstebeck, Distillation, Reinhold (1961)

as commonly cited; not opened; equations checked by their defining properties in the tests

Reid vapour pressure

difflow_refinery.blending.raoult_rvp (ASTM D323 bomb, V/L = 4, 100 °F)

as in that module

TBP → D86

Riazi & Daubert (1986), difflow_refinery.blending.TBP_D86

as in that module

Temperature and space-velocity octane terms; per-olefin selectivities; RON - MON = 2.5; example feeds; column specs

assumptions of this module, not from a source

-


The hydrocracker#

difflow_refinery.hydrocracking.Hydrocracker is a single-stage, series-flow VGO hydrocracker on the shared hydroprocessing building blocks (above): a pretreat reactor (the hydrotreating kinetics with a VGO parameter set), a cracking reactor (a new kinetic model plugged into the same TrickleBedReactor), effluent cooler and HP separator, the recycle-gas loop (knock-out, amine, purge, compressor, makeup to an H2/oil spec), a product fractionator, and an optional recycle of unconverted oil (UCO) to the cracking reactor. Like the hydrotreater it is a library, not a palette operation.

import difflow_refinery as dr
from difflow_refinery.hydrocracking import Hydrocracker, HydrocrackerParams

char = dr.characterize(assay, composition=True)        # #305 composition is required; heavy end recommended
vgo = {**lvgo, **{k: lvgo.get(k, 0.0) + hvgo[k] for k in hvgo if k.startswith("F_")}}   # VDU LVGO + HVGO
hcu = Hydrocracker(char, vgo, HydrocrackerParams(T_crack=643.15, uco_recycle=0.5))
res = hcu.solve(vgo)
print(res.table())
res.outputs["conversion.per_pass"], res.outputs["kerosene.yield"], res.outputs["h2.chemical_nm3_m3"]
fresh VGO -> [pretreat beds] -> (+ UCO recycle) -> [cracking beds] -> cooler -> HPS -> fractionator
                 ^ quench                ^ quench to each bed inlet            |        |   off-gas, LPG, LN, HN,
treat gas -------+-----------------------+                      recycle gas <--+        |   kerosene, diesel, UCO
    ^ makeup H2  <- compressor <- purge <- amine <- KO drum                             +-> UCO bleed / recycle

The unit carries every pseudo-component from the lightest up to the heaviest one in the feed above trace of its pseudo-component mass (default 1e-4; a VDU product carries every cut at some trace level, and what is left out is dropped_mass_fraction). A VDU VGO goes in as it is (F_<char.names>); hydrotreating.straight_run_cut(char, 370 + 273.15, 560 + 273.15, rate) is an idealized one.

Stream, feed and pretreat bed#

The layout is the hydrotreater’s (HDT_ATTRIBUTES: C and H atoms, S in five classes, N in two, mono/di/poly-aromatic, olefinic and naphthenic molecule counts) plus one attribute, "cracked": the number of molecules in the cut that are cracked product. It rides with the molecules like every attribute and is what lets the unit compute the gravity of a cut holding both feed and cracked molecules (below). The gases are the hydrotreater’s plus isopentane and n-pentane, which cracking makes. The feed is read exactly as the hydrotreater reads one (hcu_feed = hdt_feed plus a zero "cracked" column).

The pretreat bed is HDTKinetics unchanged, with VGO_PRETREAT_PARAMS: the hydrotreater’s illustrative constants with HDN made several times faster (a NiMo pretreat catalyst at 150 bar is chosen for HDN, which is what protects the cracking catalyst) and aromatics saturation slower. The only change to the hydrotreating code is that HDTKinetics now accepts a layout whose attributes begin with HDT_ATTRIBUTES (the cracking leak carries extra attributes with the molecule).

Cracking kinetics#

HCKinetics is a kinetic model for TrickleBedReactor (the interface of the building blocks). Per cut i, molecules cracked per second per kg of catalyst:

r_i = f k_max exp(-E/R (1/T - 1/T_ref)) kappa_i c_i (c_H2/c_ref)^m / (1 + K_N(T) c_Norg + K_NH3 c_NH3)

with f = activity x effectiveness x wetting, c_i the cut’s fugacity-equivalent liquid concentration, c_Norg the total organic nitrogen concentration of the liquid (both nitrogen classes, all cuts – the nitrogen the pretreat bed left), K_N(T) = K_N exp(-dH_N/R (1/T - 1/T_ref)) (Langmuir adsorption inhibition: basic nitrogen adsorbs on the acid sites) and m = 0 by default (first order in hydrocarbon, as the lumping models are). The hydrotreating network runs alongside on the cracking catalyst (HCKineticParams.hdt, default CRACK_BED_HDT_PARAMS, its own cracking leak off).

Continuous lumping (scheme="continuous", the default), after Laxminarasimhan, Verma & Ramachandran (1996). Each cut has a normalised boiling point theta = (Tb - T_low)/(T_high - T_low) and reactivity kappa = theta^(1/alpha) (k = k_max theta^(1/alpha)). The species-type distribution is D(k) = dN/dk = N0/(alpha k_max^(1/alpha)) k^(1/alpha - 1) and the yield distribution of species of reactivity k formed by cracking species of reactivity K

p(k, K) = [exp(-((k/K)^a0 - 0.5)^2 / a1) - exp(-0.25/a1) + delta (1 - k/K)] / (S0 sqrt(2 pi))

with S0 from mass conservation, int_0^K p(k, K) D(k) dk = 1. p(K, K) = 0 (a species does not crack to itself) and p(0, K) ∝ delta (light ends). On the grid the cracked mass of cut i is shared between the gas bin (below the lightest cut’s lower edge) and every lighter cut j in proportion to int_bin_j p(k(theta), K_i) D(k(theta)) dk/dtheta dtheta (8-point Gauss-Legendre per bin), normalised over the bins – the S0 normalisation, discretised. Products of cut i stop at its lower cut edge: a cracked molecule always leaves its parent’s cut. The weights distribute the parent’s carbon (the paper’s distribution is by mass; the two differ by the products’ carbon fraction, 84–87 wt%).

Note

These equations are not checked against the paper. The paper (AIChE J. 42(9), 2645–2653, 1996) could not be reached from here; the forms above are the model as it is restated in the later literature that uses it, from recollection, so they are given without equation numbers and are marked unverified. Web-search snippets of citing papers corroborate parts of it – five tuning parameters (alpha, a0, a1, delta, k_max) and an exp(-(0.5)^2/a1) term in the yield distribution – which is not a check of the whole form. One consequence of the forms as stated is worth checking against the original: with k = k_max theta^(1/alpha) and that D(k), the number of species per unit theta goes as theta^(1/alpha^2 - 1) – uniform only at alpha = 1. The code follows the stated forms (species_density is computed from them, not assumed uniform). The paper’s own parameter values are not used and their yield-versus-conversion results are not reproduced (below).

Discrete lumps (scheme="discrete"): TBP lumps (lump_edges, default naphtha < 165 °C < kerosene < 260 °C < diesel < 370 °C < VGO), a relative reactivity per lump (lump_k) and a selectivity row per lump (lump_selectivity: shares of a parent’s cracked carbon to gas and each lighter lump), each product lump’s share spread uniformly in theta over its cuts lighter than the parent (that spreading is this coding’s assumption). The form is Stangeland’s (1974) and the lump form of Mohanty, Saraf & Kunzru (1991); the default numbers are illustrative, not theirs. Both schemes reduce to one reactivity vector and one row-stochastic matrix, computed once per solve (HCKinetics.prepare) from the cut boiling points, so a TBP point of the assay moves them.

What a cracked molecule becomes – the property assignment (a modelling assumption). The product landing in cut j:

  • has cut j’s boiling point and the specific gravity of a saturation-adjusted Watson K, SG_j = (1.8 Tb_j)^(1/3) / (Kw_feed + dKw), with Kw_feed the mass-average Watson K of the fresh feed’s cuts and dKw = +0.3 (illustrative; hydrocracked products are more paraffinic and naphthenic than the VGO they come from);

  • has the molecular weight of Twu (1984) from (Tb_j, SG_j), and the refractive index (Riazi-Daubert Huang index), hydrogen content (Goossens 1997) and hydrocarbon types (Riazi-Daubert 1986, API 2B4.1) of the composition module’s own chain (#305) applied to those (Tb, SG); aromatics split mono/di/poly by the hydrotreater’s DEFAULT_AROMATIC_SPLIT; no olefins;

  • carries no sulfur or nitrogen: a cracked molecule’s heteroatoms leave as H2S and NH3 (the uncracked molecules keep theirs, and the hydrotreating network removes them);

  • the gas bin’s carbon becomes C1–C5 in the mole shares HCU_GAS_SPLIT (3/5/22/25/12/22/11 % C1/C2/C3/iC4/nC4/iC5/nC5; illustrative, iso-rich as hydrocracker light ends are).

The cut’s critical constants and K-values stay the feed pseudo-component’s (as in the hydrotreater): only its atoms, molecule count and the volume model below change.

Hydrogen and heat. Hydrogen consumption is the hydrogen balance of each event – H atoms in the products (cuts, gas, H2S, NH3) less those of the parent, halved – so it follows the conversion and the slate, not a separate correlation. C, S and N are conserved by construction and H through the H2 drawn; check_element_conservation is zero to round-off. Heat is per H2: an event making n molecules from one breaks n - 1 C–C bonds, each with one H2 and the heat of n-hexane + H2 -> n-butane + ethane (SCISSION_HEAT, -42.55 kJ/mol); the rest of the H2 (saturation of the products, heteroatom removal) releases the benzene + 3 H2 -> cyclohexane heat per H2 (SATURATION_HEAT_PER_H2, -68.7 kJ/mol H2). Both are 298 K values from the shared thermochemistry table, a deliberate simplification: the hydrotreater’s own aromatics heats are Cp-integrated to the bed temperature since #338, 3–5 % larger at 350–420 °C. The residue desulfurizer’s CCR_HEAT_PER_H2 is the same 298 K value.

Constants. Every number in HCKineticParams (k_max, E, alpha, a0, a1, delta, K_N, dH_N, dKw, the lump tables) is illustrative: chosen here so that the default VGO cracks about 70 % per pass with 380 °C bed inlets (WABT near 395 °C), LHSV 1.5 h⁻¹ and 150 bar, with bed rises of 20–25 K and a middle-distillate-selective slate. Published hydrocracking parameters belong to one catalyst and one feed; a predictive slate needs the yield-distribution parameters fitted to the unit’s own test runs (difflow.estimation). The commercial yield models (UOP Unicracking, Chevron Lummus ISOCRACKING, Shell, Axens) are proprietary; nothing here is equivalent to them.

Fractionator and UCO recycle#

The fractionator is a documented simplified split, not a column (the issue allows either). Gases go whole to one product (H2, H2S, NH3, C1, C2 to off-gas; C3, C4 to LPG; C5, C6 to light naphtha; water to sour water). Cut i goes to the liquid products by a smooth step in its boiling point about each TBP cut point T_c (defaults 85, 165, 260, 370 °C): its share above T_c is S((Tb_i - T_c)/w), S the quintic smootherstep on [-1, 1] (exactly 0 below, 1 above, C²), w = 15 K. w stands for the overlap between neighbouring products and is what makes a yield differentiable in its cut point on a grid of 20–25 K cuts. Attributes follow their cut, so the balances close exactly. Not computed: fractionator and side-stripper duties, steam, flash points; a StageColumn fractionator with side draws would replace fractionate without changing its callers.

UCO recycle. uco_recycle (a fraction of the bottoms) returns to the cracking reactor inlet; the rest is the UCO bleed. Because the step is exactly 1 above T_uco + w, the UCO is exactly empty below T_uco - w, so the recycle is torn on a fixed set of cuts (every cut boiling above T_uco - w - uco_margin, uco_margin = 30 K): their molecules and every attribute, a couple of hundred unknowns. Newton on that would need a tangent per unknown through the reactors; the loop is a contraction instead (its gain is the recycle fraction times the share of recycled oil that survives a pass; about 0.8 per pass measured on the default unit at 60 % recycle), so it is solved by Anderson-accelerated substitution around the gas-loop Newton (hydrocracking.fixed_point; depth 5, Walker & Ni 2011), and its gradient is the implicit one: the adjoint system (I - (dG/dz)^T) w = v, solved by GMRES on the scaled system with one vector-Jacobian product of the loop per operator application – reverse mode only, which is what the reactor’s diffrax adjoint supports. The iterations are never differentiated. A once-through unit (uco_recycle = 0 at construction) builds no UCO tear; recycle=True builds one even at zero recycle.

Degrees of freedom and specs#

HydrocrackerParams:

Spec

Default

T_pretreat

370 °C

pretreat first-bed inlet

quench_pretreat, pretreat_beds

(0.06,), (0.5, 0.5)

quench to pretreat bed 2 (fraction of treat gas); catalyst split

lhsv_pretreat

1.5 h⁻¹

on fresh feed

T_crack

380 °C

inlet of every cracking bed when quench_crack=None (the quench to each is solved)

quench_crack

None

or fixed quench fractions of the treat gas (a knife-edge: see below)

crack_beds

(0.15, 0.18, 0.20, 0.22, 0.25)

catalyst split, smaller beds first

lhsv_crack

1.5 h⁻¹

on fresh feed (a recycle loads the same catalyst harder)

P

150 bar

uniform

h2_oil

1500 Nm³/m³

treat gas H2 per fresh feed, quench included

purge, makeup

0.05, 99 % H2 / 1 % CH4

h2s_removal, nh3_removal

0.99, 1.0

amine / wash water

hps_T, loop_dP, compressor_eta

50 °C, 8 bar, 0.75

uco_recycle

0

fraction of UCO recycled

cut_points, cut_width

85/165/260/370 °C, 15 K

fractionator

pretreat_kinetics, crack_kinetics

VGO_PRETREAT_PARAMS, HCKineticParams()

activities are the deactivation handles

TargetSpec(output, target) (the hydrotreater’s) replaces the cracking inlet temperature with a target on any output – TargetSpec("conversion.per_pass", 0.7) or TargetSpec("wabt.crack", 653.15) – by a scalar Newton solve round the whole unit, differentiated implicitly (outputs["T_shift"]).

Assumptions beyond the hydrotreater’s: series flow (the pretreat effluent, H2S and NH3 included, goes to the cracker); the cracking reactor’s inlet temperature is a spec (an interstage exchanger is implied, its duty not computed); with quench_crack=None every cracking bed’s inlet is held at T_crack and the quench into each later bed is the treat gas whose heating to T_crack absorbs the cooling of the bed above to it (the building blocks’ quench=None enthalpy balance, explicit bed by bed); the first cracking bed takes the pretreat effluent with no gas of its own, and the pretreat reactor’s inlet gets what the quenches leave (crack.gas_left, which must stay positive). The total quench share is one more unknown of the recycle-gas tear, so no inner Newton runs per pass. Fixed quench rates (quench_crack=(...)) are supported but are a knife-edge: with the quench fixed, a few kelvin on the inlet either runs the beds away or lets them die out, which is why units are run on bed-inlet temperature control; the UCO is recycled to the cracking reactor inlet at the cracking inlet temperature.

Product gravity. A cut holds feed molecules (treated) and cracked ones. Its molar volume is (1 - f_cr) v0 + f_cr v0' + attr . dv: f_cr the cracked share of its molecules, v0 the feed molecule’s volume with its aromatics and olefins taken out by the hydrotreater’s VOLUME_INCREMENTS, v0' the same for the assigned cracked product, and attr . dv the current aromatic/olefin counts times those increments (so saturation after cracking still counts). Its SG is its mass (from its atoms) over that volume.

Outputs#

HydrocrackerResult.outputs (units in OUTPUT_UNITS and PRODUCT_OUTPUT_UNITS):

  • per product (off_gas, lpg, light_naphtha, heavy_naphtha, kerosene, diesel, uco, uco_bleed): .rate (kg/s), .yield (mass, on fresh feed); for LPG and the liquids .volume, .volume_yield, .sg, .api, .S_wppm, .N_wppm, .H_wt, .aromatics_vol; for the liquids TBP .T05 … .T95; diesel.cetane_index (ASTM D4737 from TBP->D86 and density, the blend pool’s functions); uco.bmci (US Bureau of Mines correlation index from the volume-average boiling point and SG);

  • conversion.per_pass (1 - UCO / (fresh 370+ + UCO recycled)) and conversion.overall (1 - UCO bleed / fresh 370+), both on the fractionator’s own UCO cut; naphtha_to_middle_distillate (mass); liquid.volume_yield;

  • hydrogen: h2.chemical (mol/s; by the hydrogen balance over every outlet), h2.chemical_nm3_m3, .chemical_scf_bbl, .chemical_wt, h2.consumed_by_balance (H2 in less out, equal to it to round-off), h2.makeup, h2.purge, h2.dissolved;

  • reactors: wabt.pretreat, wabt.crack, pretreat.dT_total, crack.dT_total, per bed <reactor>.bed<k>.T_in, .dT, cracking .quench; crack.gas_left; pretreat.N_wppm and .S_wppm (the organic N and S the cracking catalyst sees); catalyst masses;

  • loop: recycle.rate, recycle.h2_purity, purge.rate, makeup.rate, compressor.power, reactor.pH2_in; uco.recycle_rate;

  • convergence: tear.residual, uco.residual, uco.steps; res.converged.

res.balances: relative closure of mass, C, H, S, N over the unit (fresh feed + makeup = products + UCO bleed + purge + acid gas + separator water; nothing is added for the recycle, so an unconverged UCO tear shows here). res.product_char / res.product_stream(name) put any product into BlendComponent.from_stream (jet, ULSD pools).

Not computed: jet smoke point and freeze point (the (Tb, SG) correlations the issue names could not be verified, and are poor for highly saturated product; a jet pool takes measured overrides), fractionator duties, naphtha octane.

Results on the test VGOs#

Feeds: the LVGO + HVGO of a VacuumColumn on the idealized atmospheric residue (vacuum.atmospheric_residue, 150 kg/s of crude) of two test crudes, both characterized with a heavy end and the #305 composition – the light crude of the composition section (SG 0.86, 1.8 wt% S, 1500 wppm N) and a heavy one (SG 0.93, 3.0 wt% S, 2500 wppm N). The VDU products carry every cut at some trace level; the unit keeps 28 (light) and 26 (heavy) cuts and drops 1.7e-4 and 2.4e-4 of the feed mass (dropped_mass_fraction). Defaults otherwise (the heavy VGO with 365 °C cracking inlets: at 380 °C its beds run away).

light VGO, once-through

light VGO, 60 % UCO recycle

heavy VGO, once-through

fresh feed

45.1 kg/s; 2.91 wt% S, 2214 wppm N

same

48.5 kg/s; 4.10 wt% S, 2948 wppm N

to the cracker

971 wppm S, 43.3 wppm N

858 wppm S, 40.3 wppm N

418 wppm S, 17.2 wppm N

WABT pretreat / cracking

394.3 / 395.5 °C

394.1 / 391.2 °C

415.6 / 383.3 °C

bed rises, cracking

19.6, 22.0, 22.5, 23.5, 26.6 K

13.6, 15.8, 16.6, 17.4, 19.2 K

29.9, 30.3, 27.5, 25.6, 25.8 K

conversion (370 °C+), per pass / overall

70.0 / 70.0 %

47.2 / 69.1 %

55.6 / 55.6 %

off-gas, LPG (wt%)

1.08, 1.24

1.22, 1.07

1.39, 1.70

light, heavy naphtha (wt%)

2.57, 10.54

2.21, 9.27

1.35, 7.46

kerosene, diesel (wt%)

24.96, 31.55

23.15, 34.16

18.95, 26.40

UCO bleed (wt%)

28.31

29.21

42.35

naphtha / middle distillate

0.232

0.200

0.194

chemical H2

278 Nm³/m³ (1650 scf/bbl, 2.70 wt%)

264 Nm³/m³ (1567 scf/bbl)

347 Nm³/m³ (2062 scf/bbl)

kerosene SG; diesel SG, cetane index

0.790; 0.840, 65.5

0.790; 0.842, 65.3

0.829; 0.882, 47.3

UCO BMCI

33.1

34.1

50.3

closure (worst of mass, C, H, S, N)

4e-15

9e-13

6e-15

tears

gas 2e-13

gas 6e-12; UCO 1.7e-10 relative, 13 Anderson passes

gas 3e-13

Recomputed for #338, which made the pretreat bed’s aromatics equilibria Cp-integrated (the cracking bed’s SATURATION_HEAT_PER_H2 is still a 298 K value). On the heavy VGO the pretreat bed saturates less, releases less heat (it rises 78 K against 81) and runs 1.2 K cooler, so the cracker feed keeps more sulfur and nitrogen (418 against 315 wppm S, 17.2 against 14.5 wppm N; organic nitrogen inhibits cracking in the kinetics) and more aromatics: its conversion fell from 58.2 to 55.6 % and its chemical H2 from 359 to 347 Nm³/m³. The light VGO moved by 0.3 % conversion or less.

Read these as the shape of the answer: every cracking constant is illustrative. A few things they show, all of which follow from the model rather than being tuned in: the recycle at the same catalyst and temperature lowers the per-pass conversion (a recycle reactor is less efficient than plug flow) and the overall conversion slightly, and buys selectivity – 2.6 wt% more diesel, less naphtha per middle distillate, less H2; the heavy, aromatic VGO consumes more hydrogen, gives denser, lower-cetane products (its products inherit its lower Watson K through Kw_feed + dKw) and a higher-BMCI UCO; its pretreat bed rises 78 K, which a real unit would quench harder (the pretreat quench is a spec).

Compiling a once-through unit takes about 2.5 min and a solve about 7–9 s (the gas tear: 12 substitution passes, then Newton); with the UCO recycle the compile is about 6.5 min and a solve about 40 s. A reverse-mode gradient adds one compile: the once-through gradient test takes about 10 min, the recycle-ratio one about 30 min and 11 GB (its adjoint runs 60 vector-Jacobian products of the loop per cotangent), and both are marked slow and release.

Gradients (test_gradients_match_central_differences, test_recycle_ratio_gradient): kerosene yield, per-pass conversion and chemical H2 consumption with respect to the cracking inlet temperature, the reactor pressure and the 70 % TBP point of the assay (the feed’s mass per cut held, so its moles move with the cut molecular weights), on the once-through light VGO; and overall conversion, diesel yield and chemical H2 with respect to the UCO recycle fraction, through the UCO tear’s adjoint. One reverse-mode Jacobian each, against Richardson-extrapolated central differences (steps 0.5 K, 1 bar, 1 K; 0.02 in the recycle fraction) at rtol 1e-5.

What the tests check (tests/refinery/test_hydrocracking.py; full-unit tests slow):

  • convergence from the default initialization on both VDU VGOs, once-through and with 60 % UCO recycle; mass, C, H, S and N closure to 1e-8 relative (they close to 1e-15 once-through and 3e-12 with the recycle); h2.chemical (H balance) equal to H2 in less H2 out; the UCO recycle identity overall = 1 - (1 - rho)(1 - X)/(1 - rho (1 - X));

  • conversion, naphtha/middle distillate and H2 consumption all rising with the cracking inlet temperature (360, 370, 380 °C), and the recycle lowering per-pass conversion and the naphtha/middle-distillate ratio;

  • gradients against Richardson-extrapolated central differences (above);

  • the discrete-lump scheme through the whole unit (converges, closes, conversion rises with temperature);

  • the kerosene and diesel entering BlendPool("jet") / BlendPool("ulsd") through BlendComponent.from_stream with the unit’s gravity, sulfur and cetane index; hcu_block delta vectors passing check_delta_vectors;

  • the pieces: element conservation of both schemes at a point (1e-12 of the cracked flow), the distribution matrix row-stochastic with nothing landing in the parent’s cut or heavier, reactivity rising with boiling point, the yield distribution’s end points, species_density as derived, the product property chain (Watson K, Twu MW, H/C consistent with the H mass fraction), organic-N inhibition, the fractionator’s shares (exactly zero UCO below T_uco - w), the adjoint fixed point against the implicit-function closed form, a cracking bed’s conversion rising with temperature, the pretreat bed removing 95 %+ of the nitrogen; pins: the scission and saturation heats against the model-compound table, BMCI at its anchors (n-heptane 0, benzene 100).

Planning: hcu_block#

difflow_refinery.hydrocracking.planning.hcu_block(unit, feed, levers, outputs) wraps the unit as a difflow.planning.Block in the style of hdt_block. Levers: feed.bpd (renamed <product>.bpd with feed_product=, e.g. "vgo"; composition held, both LHSVs move with the rate), crack.T_in, pretreat.T_in (°C), h2_oil, pressure (bar), uco_recycle (recycle units), uco.cut_point (°C). Outputs (HCU_OUTPUTS): product bbl/d (LPG, light and heavy naphtha, kerosene, diesel, UCO bleed), conversion per pass and overall, naphtha/middle-distillate ratio, chemical H2 and makeup (Nm³/h), cracking WABT, diesel cetane and sulfur, kerosene and diesel SG. Reverse mode only.

References#

Key

Reference

Used for

How checked

C1

Laxminarasimhan, C.S.; Verma, R.P.; Ramachandran, P.A. “Continuous lumping model for simulation of hydrocracking.” AIChE J. 1996, 42(9), 2645–2653. doi:10.1002/aic.690420925

Continuous-lumping form: theta, k(theta), D(k), p(k, K), the mass-conservation normalisation

Title, journal, volume, issue, pages and DOI confirmed by web search (publisher and index listings). The paper was not reached: the equations are as restated in the later literature, from recollection – equation numbers not given, forms unverified; the published parameter values are not used.

C2

Stangeland, B.E. “A kinetic model for the prediction of hydrocracker yields.” Ind. Eng. Chem. Process Des. Dev. 1974, 13(1), 71–76. doi:10.1021/i260049a013

Discrete-lump form (reactivity rising with boiling point, a product-distribution rule)

Title and DOI confirmed by web search; issue and pages as in the issue (unverified). Form only, qualitative.

C3

Mohanty, S.; Saraf, D.N.; Kunzru, D. “Modeling of a hydrocracking reactor.” Fuel Process. Technol. 1991, 29, 1–17

Discrete-lump scheme on a commercial reactor

Not checked (unverified); qualitative only.

C4

Quader, S.A.; Hill, G.R. Ind. Eng. Chem. Process Des. Dev. 1969, 8, 98

Early lumped hydrocracking kinetics

Listed by the issue; not checked (unverified); not used.

C5

Twu, C.H. Fluid Phase Equilib. 1984, 16, 137–150

MW of the assigned products

As cited for difflow_refinery.correlations.

C6

Riazi & Daubert (1986, 1987); Goossens (1997)

n20, H content and PNA of the assigned products

As cited for the composition module (#305).

C7

Watson, K.M.; Nelson, E.F. Ind. Eng. Chem. 1933, 25, 880

Watson K

As cited for correlations.watson_k (unverified there).

C8

Smith, H.M. “Correlation index to aid in interpreting crude-oil analyses.” US Bureau of Mines Tech. Paper 610, 1940

BMCI = 48640/VABP(K) + 473.7 SG - 456.8

Formula as commonly given (e.g. Gary, Handwerk & Kaiser); source not checked (unverified). Pinned in the tests to the index’s anchors (n-heptane 0, benzene 100).

C9

Christianson, B. “Reverse accumulation and attractive fixed points.” Optim. Methods Softw. 1994, 3(4), 311–326

Adjoint of a fixed-point iteration

Unverified (recalled); the construction is tested against the implicit-function closed form.

C10

Walker, H.F.; Ni, P. “Anderson acceleration for fixed-point iterations.” SIAM J. Numer. Anal. 2011, 49(4), 1715–1735. doi:10.1137/10078356X

Anderson acceleration of the UCO loop

Recalled, unverified.

C11

Saad, Y.; Schultz, M.H. “GMRES: a generalized minimal residual algorithm for solving nonsymmetric linear systems.” SIAM J. Sci. Stat. Comput. 1986, 7(3), 856–869

The adjoint linear solve (hydrocracking.fixed_point.gmres, restarted GMRES(20) written out: jax.scipy.sparse.linalg.gmres differentiates its operator, a pullback through while loops)

Recalled, unverified.

–

Hydrotreater references H1–H16

PR flash, kinetics forms of the pretreat bed, model-compound heats, cetane index

See the hydrotreater.

What is not done#

  • The yield-versus-conversion cross-check is not done. It needs the published numbers of Laxminarasimhan et al. (1996) (or Mohanty et al. 1991’s plant comparison) with their parameters; neither paper could be reached, so nothing is reproduced and no agreement is claimed. The model’s trends are tested (below); its absolute slate is illustrative.

  • No example notebook (VDU -> hydrocracker -> jet/ULSD pools). The pieces are tested: a VDU VGO feeds the unit, and product_stream/product_char feed BlendComponent.from_stream.

  • The fractionator is not a column (above), so no duties; jet smoke and freeze points are not computed.

  • Deactivation is the activity multiplier of either catalyst, a differentiable parameter that difflow.reconciliation.tracking can track; not wired up or demonstrated.

  • Not a difflow Flowsheet object: both tears are the unit’s own.

  • Out of scope (as the issue says): residue hydrocracking, hydrogen-network optimisation, cycle-length optimisation, dynamics; two-stage units are not built (the pieces would compose).

Residue desulfurization and fuel oil#

difflow_refinery.residue is the route from an atmospheric residue to a very-low-sulfur fuel oil (VLSFO, 0.50 wt% S). Its unit, ResidueDesulfurizer, is an atmospheric-residue desulfurizer (ARDS/RDS) built on the shared hydroprocessing blocks (above): the hydrotreating kinetics with residue constants, plus refractory residue sulfur, hydrodemetallization (HDM) of Ni+V, CCR reduction and a small residue conversion, in the same TrickleBedReactor. fuel_oil_blend blends its products, and any cutter, in a BlendPool with the VLSFO specs. Like the hydrotreater it is a library, not a palette operation. Its rate constants are illustrative.

import difflow_refinery as dr
from difflow_refinery.residue import ResidueDesulfurizer, RDSParams, fuel_oil_blend

char = dr.characterize(assay, composition=True)     # sulfur, CCR and Ni+V per cut; the #305 composition is required
residue = cdu.last_result.products["residue"]                     # or residue.atmospheric_residue_cut(char, kg_s)
rds = ResidueDesulfurizer(char, residue, RDSParams(T_in=(373.0 + 273.15,)))
res = rds.solve(residue)
print(res.table())

fo = fuel_oil_blend([res.blend_component("residue"), res.blend_component("distillate")],
                    [res.volume("residue"), res.volume("distillate")])
fo.properties["S_ppm"], fo.margins                    # 0.31 wt% S, every VLSFO spec met
atmospheric residue --+--> [bed 1] --quench--> [bed 2] --quench--> [bed 3] --> product separation --+--> gas: H2, H2S, NH3, C1-C5, water
                      |                                                                             +--> distillate (cuts below 350 C)
treat gas ------------+                                                                             +--> desulfurized residue
                                                                    fuel oil pool  <-- desulfurized residue + distillate (+ cutters)

Which route, and why#

The issue (#331) offered two routes: a residue hydrotreater, or the vacuum unit plus cutter-stock blending. The choice turns on one piece of arithmetic. Sulfur blends linearly by mass, so a base stock at S_r blended with a cutter at S_c reaches a spec S_s only with a cutter mass fraction of at least

\[ x_c = \frac{S_r - S_s}{S_r - S_c} \qquad \text{(\texttt{cutter\_fraction\_for\_sulfur})} \]

For the test crude’s atmospheric residue (3.27 wt% S) and a 15 ppm ULSD cutter, \(x_c = 0.85\). The “fuel oil” would be 85 % diesel by mass. A refinery cannot make it: the crude unit of examples/40 makes about a third as much diesel as residue. Nor would it want to: the blend sells for less than the diesel it consumed. An LCO cutter is worse, since an FCC on a high-sulfur feed makes LCO at around 1–2 wt% S (a typical figure, unverified). The vacuum residue is worse again (about 4 wt% S): the VDU sends the sulfur to its bottoms, so the VDU on its own moves the problem without solving it. Cutter blending is what sets a residual fuel’s viscosity. It is not a way to take a high-sulfur crude’s residue to 0.5 wt% S.

So the route here is (a), the residue desulfurizer. It is credible: it is how refineries that run high-sulfur crudes make VLSFO, and ARDS units report 85–92 % HDS on atmospheric residue (R2, R3; unverified). It is affordable in this code base: it adds a kinetic model and a thin unit around the reactor that #306 already built and tested, and changes none of it. It compiles in about 20 s and re-solves in 0.1–0.2 s. A gradient through it compiles in about 90 s. The cutter route stays available through the same pool (fuel_oil_blend takes any property-mode cutter), and cutter_fraction_for_sulfur gives the arithmetic for a low-sulfur crude, where cutting alone does work.

Feed and layout#

The layout is the hydrotreater’s (HDT_ATTRIBUTES: C and H atoms, S in five classes, N in two, mono/di/poly-aromatic, olefinic and naphthenic molecule counts) with three more per-cut attributes (RDS_ATTRIBUTES):

Attribute

What it counts

Element in the balance

From the characterization

S_residue

sulfur atoms in asphaltene/resin molecules: the refractory residue sulfur

S

a share residue_s_share(Tb) of each cut’s sulfur, taken from the five classes in proportion

NiV

Ni + V atoms, as moles of a nominal metal of METAL_MW (a 3:1 V:Ni mix, 52.9 g/mol)

none (metals are outside a cut’s mass, as everywhere in the layout)

char.nickel_vanadium

CCR

carbon atoms the Conradson test would leave as coke

none (a subset of the cut’s C; counting it as C would count it twice)

char.ccr

rds_feed(char, stream, layout) is hdt_feed plus these three columns, so a cut’s mass from its atoms is still F * MW. The refractory share (DEFAULT_RESIDUE_S_SHARE) is zero up to 450 C, 0.10 at 538 C, 0.25 at 600 C, 0.35 at 700 C and 0.50 at 800 C, linear in between. It is illustrative: it has the shape the residue-HDS literature describes, with asphaltene sulfur concentrated in the heaviest fraction, but the numbers are this project’s. Pass residue_s_share= with measured shares, e.g. from the sulfur of the C7 asphaltenes. An assay without Ni+V or CCR gives zero columns. The unit carries every pseudo-component from the lightest one in the feed (or the lightest one conversion can reach, if that is lighter) up to the heaviest one. A crude-unit residue goes in as it is; atmospheric_residue_cut(char, kg_s) is an idealized one (every cut above 350 C, in crude proportion).

Reactions and rate laws#

RDSKinetics.rates is HDTKinetics.rates with the residue constants (residue_hdt_params), plus four reactions per cut. The symbols are those of the hydrotreater (rate laws): c are the fugacity-equivalent liquid concentrations, f = activity * effectiveness * wetting, k(T) is Arrhenius about T_ref = 380 C, and h = (c_H2/c_ref)^m:

\[\begin{split} \begin{aligned} \text{refractory HDS:}\quad & r_{S,res} = f\,k_{S,res}\,c_{S,res}\,h^{m}/D^2, \quad D = 1 + K_{H_2S} c_{H_2S} + K_N c_{N,basic} && (3\ \mathrm{H_2} \to \mathrm{H_2S}) \\ \text{HDM:}\quad & r_M = f\,k_M\,c_{NiV}\,h^{m_M} && \text{(metal onto the catalyst)} \\ \text{CCR reduction:}\quad & r_{CCR} = f\,k_{CCR}\,c_{CCR}\,h && (0.5\ \mathrm{H_2}\ \text{per CCR carbon}) \\ \text{conversion:}\quad & r_X = f\,k_X\,c_i \quad (T_{b,i} \ge T_{conv}) && (\text{molecule of } i + (m_i-1)\,\mathrm{H_2} \to m_i \text{ molecules of } j(i)) \end{aligned} \end{split}\]
  • HDS by class keeps the hydrotreater’s LHHW form: first order in each of the five classes, with the squared H2S and basic-nitrogen inhibition denominator (Korsten & Hoffmann 1996; Froment et al. 1994; hydrotreater refs H7, H8). The residue constants change four things. The effectiveness factor is 0.35, for the hindered diffusion of residue molecules into the pores. The class constants are lower. The basic-nitrogen adsorption constant is weaker, because most of a residue’s basic nitrogen sits in molecules too large to reach the sites. The hydrotreater’s cracking leak is switched off (crack_k = 0); conversion replaces it. The refractory class shares the inhibition denominator and reacts more slowly still. Being first order with its own constant, it is part of what gives the lumped total a high apparent order, as in residue HDS data.

  • HDM, CCR reduction and conversion are first-order lumps with an H2 term, the forms reviewed for heavy-oil hydroprocessing by Ancheyta et al. (2005). Removed metal deposits on the catalyst, reported as metals.deposit (kg/s); that deposit is what sets a residue unit’s cycle length (Rana et al. 2007). HDM takes no hydrogen and gives no heat: the metals are at ppm level, and their H2 would be about 1e-4 of the unit’s.

  • Conversion acts only on cuts boiling at or above T_conv (538 C), the “residue” of a conversion figure. Each converted molecule of cut i sends all of its atoms to m_i = n_C,i/n_C,j molecules of the lighter cut j(i) whose carbon number is nearest half of i’s (conversion_targets). The m_i - 1 new chain ends take one H2 each, so carbon, hydrogen and every heteroatom balance exactly. The fragments inherit the parent’s sulfur, nitrogen, metals, CCR and ring classes, per atom.

  • Heats are the hydrotreater’s model-compound reaction enthalpies: benzothiophene HDS for the refractory class, benzene to cyclohexane per H2 for CCR reduction, and n-hexane + H2 to n-butane + ethane per bond broken. The CCR stoichiometry (one aromatic ring of six carbons saturated by three H2) is illustrative.

  • Constants (RDSKineticParams, residue_hdt_params) are illustrative, not fitted to any catalyst or unit. They were chosen so that this crude’s residue at a WABT near 395 C, LHSV 0.25 1/h, 150 bar and 1000 Nm³/m³ lands in the ranges ARDS units report: 85–92 % HDS, 70–85 % HDM, 40–60 % CCR reduction and 10–20 % conversion of the 538 C+ (Speight 2000; Rana et al. 2007). The release tests pin those ranges. In a real unit the HDM catalyst grades into the HDS catalyst; here one average catalyst fills every bed.

Degrees of freedom and specs#

RDSParams: T_in (bed-1 inlet with given quench fractions, default 373 C; or one per bed with quench=None, which solves each quench), quench (default 0.20 and 0.25 of the treat gas into beds 2 and 3), bed_fractions (0.25, 0.35, 0.40), P (150 bar), lhsv (0.25 1/h on 60 F feed volume), catalyst_density (800 kg/m³), gas_oil (treat gas, 1000 Nm³/m³), treat_gas (90 % H2 / 10 % CH4), T_gas (quench gas, 70 C), product_cut_T (350 C), T_conv (538 C), kinetics, kij, reactor (ReactorOptions, rtol 1e-8).

  • Once-through treat gas. The treat gas is a given rate and composition, not a solved recycle loop. That equals a recycle loop with an ideal amine scrubber and makeup that holds the recycle purity. hydroprocessing.recycle has the pieces to close the loop, which matters for the hydrogen balance and little for the product sulfur. Chemical H2 consumption comes from the hydrogen balance.

  • Product separation is an ideal component split of the effluent. Every gas and light end (H2, H2S, NH3, C1–C5, water) goes to gas. Every cut below product_cut_T goes to distillate, and the rest to residue. It stands in for the hot and cold separators and the fractionator, and the balances close through it exactly. No dissolved gas is carried into the liquids.

  • No feed heater: the bed-1 inlet temperature is a spec, and oil and treat gas enter at it. No bed pressure drop.

  • Fixed at construction (they shape the layout, or say which code path runs): the bed count, the quench mode, T_conv, product_cut_T, kij. Everything else can be traced, and solve(feed, params=...) re-solves without recompiling.

Outputs and the fuel-oil pool#

RDSResult.outputs (units in residue.OUTPUT_UNITS) holds:

  • feed and product qualities: feed.S_wt, feed.NiV_wppm, feed.CCR_wt, residue.S_wt, residue.NiV_wppm, residue.CCR_wt, residue.sg, distillate.S_wppm;

  • yields: residue.yield, distillate.yield, gas.rate;

  • conversions: hds.conversion, hdm.conversion, ccr.reduction, hdn.conversion, and conversion (of the cuts at or above T_conv);

  • hydrogen: h2.chemical, h2.chemical_nm3_m3, h2.chemical_wt, and h2s.make;

  • the reactor: metals.deposit, wabt, bed<k>.T_in, bed<k>.dT, bed<k>.quench.

balances are the relative errors of mass, C, H, S, N and Ni+V across the unit; the Ni+V balance counts the catalyst deposit.

product_char is a BlendCharacterization of the treated cuts. Their molar mass comes from their atoms. Their gravity comes from the feed cut’s molar volume plus the hydrotreater’s per-molecule volume increments for the types saturated. Each cut carries S_ppm, N_ppm, CCR_wt and hydrocarbon types. blend_component(name) turns "residue" or "distillate" into a fuel-oil component. Its viscosity at 50 C is estimated by difflow_refinery.properties (Abbott at 100/210 F and Walther between, from the treated cuts’ TBP 50 % point and gravity, unverified there). It is property mode by default, so it blends with cutters from any other unit. volume(name) gives the standard volume flow at 15 C.

fuel_oil_blend(components, volumes) is a fuel-oil BlendPool by volume flow with VLSFO_SPECS: S ≤ 5000 ppm, viscosity ≤ 380 cSt at 50 C, SG ≤ 0.991 and CCR ≤ 18 wt%. The sulfur limit is the MARPOL Annex VI 0.50 % m/m global cap of 2020. The other three are the ISO 8217 RMG 380 limits, as recalled and not checked against the standard’s table. Sulfur, nitrogen and CCR blend by mass, SG by volume, and viscosity by Refutas. The pool’s mass is the products’ mass to round-off, so a route balance closes through it.

Results on the test crude#

These are the test crude of examples/35–40 (1.8 wt% S, 1500 wppm N, 5 wt% CCR), given 40 wppm Ni+V, with the defaults and 50 kg/s of idealized 350 C+ residue (tests/refinery/test_residue.py):

feed (atm. residue)

desulfurized residue

fuel oil (residue + RDS distillate)

S

3.27 wt%

0.303 wt%

0.31 wt% (spec 0.50)

Ni+V

86 wppm

16.7 wppm

CCR

10.8 wt%

5.3 wt%

(spec 18)

SG

0.953

0.928

(spec 0.991)

viscosity at 50 C (estimated)

225 cSt

105 cSt

(spec 380)

WABT is 394.8 C over a total bed rise of 71 K. HDS is 90.8 %, HDM 81.2 %, CCR reduction 51.8 % and 538 C+ conversion 13.1 %. The distillate yield is 1.8 % (at 0.49 wt% S: the fragments inherit their parent’s sulfur, so it wants a distillate hydrotreater before the diesel pool). Chemical H2 is 121 Nm³/m³ (1.14 wt%). All balances close to 1e-15. (Before #338 made the hydrotreating aromatics equilibria and heats Cp-integrated: 0.306 wt% S, WABT 394.7 C, HDS 90.7 %, 1.15 wt% H2; the aromatics steps’ heats at bed temperature are larger, so the beds run 0.1-0.2 K hotter.)

Over the route, the residue’s sulfur equals the fuel oil’s plus the H2S to 1e-10, and residue plus treat gas equals fuel oil plus gas.

The gradient of fuel-oil sulfur with respect to the bed-1 inlet temperature is −591.7 ppm/K, by reverse mode through the beds, the quench mixing, the product grid and the pool. A central difference at h = 0.5 K gives −591.4 (−602 before #338). The release test holds the two to 0.2 %.

On the CDU residue of examples/40 (3.06 wt% S, no Ni+V given), the fuel oil comes out at 0.31 wt% S and 69 cSt, with every spec met (release test).

In the whole-refinery example#

examples/40_refinery_flowsheet.ipynb sends the crude unit’s atmospheric residue through the desulfurizer to the fuel-oil pool:

from difflow_refinery.residue import ResidueDesulfurizer, fuel_oil_blend
rds = ResidueDesulfurizer(char, P["residue"])
rds_res = rds.solve(P["residue"])
fuel_oil = fuel_oil_blend([rds_res.blend_component("residue"), rds_res.blend_component("distillate")],
                          [rds_res.volume("residue"), rds_res.volume("distillate")])

The assay there is given 40 wppm Ni+V, so HDM has something to remove. On its 44 500 bbl/d of residue (3.06 wt% S), the desulfurized residue is at 0.332 wt% S, with HDS at 90.0 % and HDM at 79.8 %. The distillate is 12.6 %, at 1750 wppm S, and goes to the fuel oil. The fuel oil makes every VLSFO spec: 0.31 wt% S, 69 cSt at 50 C (estimated), SG 0.920 and 4.8 wt% CCR. The unit is the refinery’s largest hydrogen consumer. On the example’s hydrogen header it draws its chemical consumption, rds_res.outputs["h2.chemical"] (399 of 540 mol/s). That is a lower bound, since the once-through treat gas has no purge or solution losses. Its outputs["h2s.make"] carries 79 % of the crude’s sulfur in the sulfur table.

References#

Key

Reference

Used for

How checked

R1

Ancheyta, J.; Sanchez, S.; Rodriguez, M.A. “Kinetic modeling of hydrocracking of heavy oil fractions: a review.” Catal. Today 2005, 109, 76–92. doi:10.1016/j.cattod.2005.08.015

First-order lumped forms for heavy-oil HDS, HDM, CCR and conversion

Recalled; not reached (unverified). Forms only; no constants used.

R2

Rana, M.S.; Samano, V.; Ancheyta, J.; Diaz, J.A.I. “A review of recent advances on process technologies for upgrading of heavy oils and residua.” Fuel 2007, 86, 1216–1231. doi:10.1016/j.fuel.2006.08.004

ARDS severity ranges; metals deposit limits cycle length

Recalled (unverified). Qualitative ranges only.

R3

Speight, J.G. The Desulfurization of Heavy Oils and Residua, 2nd ed., Marcel Dekker, 2000

Residue HDS behaviour, refractory asphaltene sulfur, typical ARDS conditions

Recalled; chapter not checked (unverified). Qualitative only.

R4

IMO, MARPOL Annex VI, Regulation 14 (0.50 % m/m global sulfur limit from 1 January 2020)

VLSFO sulfur spec

Widely reported limit; regulation text not opened (unverified).

R5

ISO 8217:2017, Petroleum products – Fuels (class F) – Specifications of marine fuels, Table 2, grade RMG 380

Viscosity 380 mm²/s at 50 C, density 991.0 kg/m³ at 15 C, CCR 18 % m/m

As recalled; the standard was not opened (unverified).

–

Hydrotreater references H1–H16

PR flash, HDS/HDN/HDA forms, model-compound heats

See the hydrotreater.

–

Refutas viscosity blending; Abbott-Kaufmann-Domash viscosity

Fuel-oil viscosity

As cited for the blend pool and difflow_refinery.properties.

What is not done#

  • No literature or plant cross-check. The constants are illustrative and were chosen to land in published severity ranges. They are not fitted, and no paper’s profiles are reproduced. Product sulfur, metals and hydrogen are the shape of the answer, not a prediction for any catalyst.

  • No recycle-gas loop, hot/cold separators or fractionator column: see the specs above for what stands in for each.

  • No catalyst deactivation. metals.deposit is reported, and activity is a differentiable multiplier that difflow.reconciliation.tracking could follow, but neither metals-driven deactivation nor cycle length is modelled.

  • One average catalyst for every bed (no HDM/HDS grading), and one T_ref.

  • The RDS distillate leaves at about the parent’s sulfur and is not hydrotreated. In the example it goes to the fuel oil.

  • No VDU in the route. For this crude it is not needed, as explained above. The pieces compose: VacuumColumn on the RDS residue, or the RDS on a vacuum residue (VRDS), with the same unit.

  • Out of scope: ebullated-bed and slurry residue hydrocracking, solvent deasphalting, coking, and IMO compatibility and stability (the P-value).


The hydrogen network#

difflow_refinery.hydrogen (#329) balances the refinery’s hydrogen: the reformer’s net gas, a hydrogen plant and imports on one side, the hydrotreaters’ and hydrocracker’s makeup on the other, through one or more headers, with an optional PSA, purge to fuel gas, and export. It returns the balanced header and the purity each consumer receives, and close_hydrotreater_loop feeds that purity back into the hydrotreaters. A library, not a palette operation.

import difflow_refinery.hydrogen as h2

net = h2.HydrogenNetwork(
    producers=[h2.Producer.from_reformer(ref)],              # the reformer's net gas, as it is
    consumers=[h2.Consumer("nht", 20.0, P=60e5, min_purity=0.85),
               h2.Consumer("dht", 40.0, P=60e5, min_pH2=50e5)],
    headers=[h2.Header("main", P=20e5, min_purge=1.0,
                       psa=None,                             # or h2.PSA(recovery=0.88, purity=0.999)
                       swing=[h2.Import(purity=0.999)])])     # fills a deficit
res = net.solve()
res.outputs["h2.surplus"], res.outputs["nht.purity"], res.balances

# feed the purity back into the hydrotreaters (substitution on the purity)
loop = h2.close_hydrotreater_loop(net, {"nht": (nht, nht_feed, nht_params),
                                        "dht": (dht, dht_feed, dht_params)})
loop.header.outputs["h2.surplus"], loop.params["nht"].makeup, loop.units["nht"].outputs["reactor.pH2_in"]

Hydrogen network: model#

Every stream is a vector of molar flows on HEADER_GASES (hydrogen, methane to n-pentane, and n-hexane for anything heavier, on the hydroprocessing gas names).

  • Producers (Producer) have a fixed flow and composition: Producer.from_reformer(result) (the reformer’s net_gas; C6+ traces lumped into n-hexane on a mole basis, so H2 and total moles are conserved), Producer.of_purity(name, h2, purity, impurity=...), or Producer.from_flows(name, {gas: mol/s}). A share to_psa of each can go through the header’s PSA.

  • PSA (PSA(recovery, purity)): product H2 = R s E_H2; product impurities R s E_H2 (1 - y_P)/y_P, split like the feed’s; tail gas the rest, to fuel gas. s = 1, or with target_purity the share for which the header purity equals the target, which is linear in s: s = (y* B - A) / (E_H2 (R - 1) - y* (R E_H2/y_P - E)), clipped to [0, 1] (<h>.psa.target_error is nonzero when the PSA cannot make the target). Impurities slip in the feed’s proportions; there is no multicomponent adsorption model.

  • Swing sources (Import, H2Plant) fill the deficit need = sum_j d_j + min_purge - S_H2, in order, each clip(need, 0, capacity).

  • Consumers (Consumer) take a makeup H2 flow d_j (the Hydrotreater’s h2.makeup output: chemical consumption, solution loss and the purge’s H2) at the header’s composition, so their total makeup is d_j / y. Consumer.from_hydrotreater(name, result, params) reads it. A demand may respond linearly to purity, d_j(y) = d_j0 + (dd_j/dy)(y - y_ref); then the header purity is the fixed point of y -> purity(d(y)) (30 iterations, residual reported as <h>.loop_residual).

  • Purge is what is left, G - sum_j M_j, to fuel or (purge_to="export") export. Its H2 is the surplus; a negative surplus is a deficit the swing could not cover. It is returned, not hidden, and res.feasible["<h>.balanced"] is False.

Consumers on one header all receive the header’s purity. Consumers that need different purities go on different headers (a header per pressure level, say); a cascade from one header’s purge into another is not built.

Specs. min_purity is on the makeup’s H2 mole fraction; min_pH2 on its H2 partial pressure at the consumer’s makeup pressure, y P. Both report as <c>.purity_margin (mole fraction). Neither is the reactor-inlet H2 partial pressure: that also depends on the unit’s recycle purity and is the Hydrotreater’s reactor.pH2_in output, which the closed loop gives.

Balances. res.balances closes the network over producers + swing against makeups + fuel gas + export, in total moles, H2 and mass, and checks each consumer’s makeup H2 against its demand. The purge is computed by difference, so the first three close by construction (to round-off, tested); the last is an independent check of the makeup bookkeeping.

Hydrogen network: closing the loop on the hydrotreaters#

close_hydrotreater_loop(network, {consumer: (Hydrotreater, feed, params)}) substitutes on the purity: balance the network; solve every hydrotreater with HydrotreaterParams.makeup set to its header’s composition (folded onto the unit’s gases by fold_composition, which moves a heavier gas into the nearest lighter one so the purity is unchanged); put each h2.makeup back on its consumer, with a secant d_demand_d_purity once two passes at different purities exist; re-balance; stop when no purity moves by more than tol. With purge as the only swing the purity does not depend on the demands and one pass closes it; with an import or H2 plant as the swing, the demands move the purity and a few more passes follow, each a re-solve of a compiled unit.

AD mode. The loop is concrete Python: HydrotreaterParams.makeup is a dict that makeup_vector reads with float(), so the makeup composition is not a traced input of Hydrotreater.solve. The returned loop.network carries each unit’s purity response as the LINEAR model above (a delta vector), so loop.network.solve() is differentiable in either mode with the units’ response to first order. The reformer is differentiated in forward mode (jax.jacfwd; its beds are diffrax.ForwardMode): rebuild the producer inside the function, loop.network.replace(producers=[Producer.from_reformer(r)]). response_step= gets each slope from one extra solve when the loop itself did not produce two purities.

Hydrogen network: planning#

h2_block(network, levers, outputs) wraps a network for difflow.planning. Levers: <producer>.h2 (mol/s), <producer>.purity (mol%), <consumer>.makeup (Nm3/h), <header>.min_purge (mol/s), <header>.psa.recovery. The units match reformer_block’s h2.net_mol_s and h2.purity and hdt_block’s h2.makeup, so link_reformer() and link_hdt("nht") give one-to-one links. Outputs are any network outputs (default: surplus, purities and margins, fuel gas, swing H2). A planning producer keeps the base impurity mix and moves only its H2 and purity.

Hydrogen network: what is tested, and what is not#

Per commit (tests/refinery/test_hydrogen.py, a few seconds): the balances close to 1e-12 on every configuration (two producers, PSA, purity target, ordered swing with capacity, deficit, two headers with export, purity response); the PSA split, its target, and its failure to reach an impossible one; the specs; the species mapping from the reformer; jit, reverse and forward mode agree; h2_block reproduces the network. Release: the network’s Jacobian against central differences. Slow (tests/refinery/test_hydrogen_loop.py): the acceptance case, the reformer’s net gas through an import-swing header into a kerosene and a diesel hydrotreater with the makeup purity fed back, closing to 1e-7 in purity with every unit solved at the purity the header delivers it; and (release) d(surplus)/d(WAIT) through the reformer and the closed network against central differences.

Not done: compression power and header pressure drop (supply pressures are only checked to be at least the header’s, res.feasible["<h>.pressure"]); a cascade of one header’s purge into another; consumer purges routed back to the header (they can be added as a Producer.from_flows and closed by the same substitution); a hydrogen-pinch targeting or a network superstructure optimisation; the HDT’s traced makeup composition (see AD mode).

Hydrogen network: references#

What

Source

Status

Source-sink hydrogen network with purifier and purge to fuel (background)

Alves, J.J., Towler, G.P., “Analysis of refinery hydrogen distribution systems”, Ind. Eng. Chem. Res. 41(23), 5759-5769 (2002)

Not consulted (unverified); the superstructure is the usual one and no number of the paper is used or reproduced.

PSA recovery 0.88, product 99.9 mol%

none

Illustrative defaults, inside the range usually quoted for refinery PSA units (unverified). Set them from the unit’s data.

Nm3 at 0 C, 1 atm

MOL_PER_NM3 = 101325/(R 273.15)

As in the hydroprocessing blocks.


Chaining units into one differentiable function#

The library units (hydrotreater, reformer, residue desulfurizer, FCC, hydrocracker and the rest) are Python objects with a solve, and the adapters between them are pure JAX: gas_plant_feed, HydrotreaterResult.fractionate, NaphthaFeed.from_hydrotreater, fuel_oil_blend and the hydrogen network. So a chain of units, such as CDU → hydrotreater → reformer → gasoline pool, is a Python function, and it has an exact derivative. What needs care is the AD mode, because the units do not all support the same one. difflow_refinery.plant (#334) handles that, and deliberately does no more. It is a small module, not a framework, and a library, not a palette operation.

from difflow_refinery.plant import Chain, Stage, central_difference

def nht(x):                      # hydrotreater + fractionator + adapter
    r = unit.solve(feed, params=dataclasses.replace(p, T_in=(x[0],)), warn=False)
    f = r.fractionate(cut_points=(x[1],), products=("light_naphtha", "heavy_naphtha"),
                      feeds=("product", "wild_naphtha"))
    return NaphthaFeed.from_hydrotreater(f, "heavy_naphtha")

def reform(feed):                # the reformer, warm-started
    o = reformer.solve(feed, tear_initial=tear, tol=1e-11, on_nonconvergence="ignore").outputs()
    return jnp.stack([o["reformate.S_wppm"], o["reformate.RON"], o["h2.net_mol_s"]])

chain = Chain(Stage("nht", nht, modes="rev"),            # a default hydrotreater: reverse only
              Stage("reformer", reform, modes="fwd"))     # the reformer: forward only
res = chain.jacobian(x0)               # method "chain": the chain rule by unit Jacobians
res.jacobian, res.timings              # (n_out, n_in), seconds per stage
central_difference(chain, x0, h=[0.5, 0.5])

A Stage is a function from an array pytree to an array pytree, together with the modes it supports. Chain.jacobian(x, method="auto", vectorize=True) differentiates the composition in one of three ways:

  • "fwd" is jax.jacfwd of the whole chain, and "rev" is jax.jacrev. Each is one trace through every unit, so it is possible only when every stage supports that mode. With both modes available, "auto" picks by shape (difflow.planning.linearize.choose_ad_mode).

  • "chain" is the chain rule by unit Jacobians. It is what "auto" falls back to for a mixed chain. The stages are evaluated one at a time on concrete values, and dx_k/dx_0 is carried along by forward accumulation. A forward stage pushes the current Jacobian’s columns through by JVPs, which costs one tangent per chain input. A reverse-only stage forms its own Jacobian by VJPs, one cotangent per output of that stage, and multiplies. So keep the interface after a reverse-only unit narrow: a NaphthaFeed is 32 numbers, while a whole HydrotreaterResult is thousands.

  • vectorize=False pushes the tangents (or cotangents) one at a time instead of in a vmap. The Jacobian is the same, and the peak memory is that of a single tangent. It costs nothing extra only when the stages are jitted: an unjitted reformer recompiles for every tangent (see the measurements below).

Stage(name, fn, modes, jit=False) with jit=True wraps fn in jax.jit once. Do that for any stage that holds a unit with a Python-level recycle, such as the reformer.

A mixed chain cannot be differentiated end to end in either mode. jax.jacfwd fails in the reverse-only unit with “can’t apply forward-mode autodiff (jvp) to a custom_vjp function”, which is the hydrotreater’s RecursiveCheckpointAdjoint bed integration. jax.jacrev fails in the forward-only unit, because the reformer’s ForwardMode beds are a while_loop, which cannot be transposed. The per-commit tests pin both failures on toy stages with exactly these restrictions (tests/refinery/test_plant.py). There are two supported patterns:

  1. Make every unit forward-capable, then use one jax.jacfwd. The hydrotreater can be built with HydrotreaterParams(reactor=ReactorOptions(adjoint="forward")). Its beds are then integrated with diffrax.ForwardMode. The values are the same, and its tear Newton already takes its Jacobian from a forward copy. The cost is reverse mode on that unit. A chain of that hydrotreater and the reformer is all forward.

  2. Keep the units as they are and use method="chain". It gives the same Jacobian (tests/refinery/test_plant_chain.py checks that the two agree to 1e-6).

AD modes of the units#

difflow_refinery.plant.AD_MODES holds this table, and ad_mode_table() prints it. “Modes” are the modes with the unit’s default options.

Unit

Modes

Why

Other mode

Tested by

Crude unit (CrudeUnit, CrudeColumn)

fwd + rev

EO MESH Newton on stop-gradient inputs, then one Newton step with the converged Jacobian

–

test_column.py, test_unit.py (grad); test_planning.py (cdu_block, forward)

Vacuum unit (VacuumColumn, StageColumn)

fwd + rev

stage-network Newton, implicit step reusing the converged Jacobian

–

test_vacuum.py (jacfwd and grad)

Gas plant (GasPlantColumn)

fwd + rev

the vacuum machinery on CubicThermo: three passes, then the implicit step

–

test_gasplant.py (jacfwd); reverse by the vacuum column’s construction (not tested on the gas plant)

Gas compressor, amine treater

fwd + rev

closed-form, pure JAX

–

Hydrotreater (Hydrotreater)

rev

beds by diffrax RecursiveCheckpointAdjoint (a custom_vjp); the tear Newton’s Jacobian from a forward-adjoint copy, then one implicit step on the reverse-mode residual

ReactorOptions(adjoint="forward"): forward only, same values

test_hydrotreating.py (jacrev); test_plant_chain.py (forward)

Residue desulfurizer (ResidueDesulfurizer)

rev

checkpointed bed adjoints; quench mixing by implicit Newton

RDSParams(reactor=ReactorOptions(adjoint="forward")) should give forward only (same construction; not tested)

test_residue.py (grad)

Hydrocracker (Hydrocracker)

rev

checkpointed beds and the UCO recycle’s Anderson fixed point with a GMRES adjoint (a custom_vjp)

none

test_hydrocracking.py (jacrev)

Catalytic reformer (CatalyticReformer)

fwd

diffrax.ForwardMode beds inside the Flowsheet’s traced recycle (optimistix fixed point, implicit differentiation, which needs JVPs of the loop)

none for the unit; adjoint="reverse" serves a stand-alone reactor only

test_reforming.py (jacfwd)

FCC (FCCUnit)

fwd + rev

constant-step Tsit5 with diffrax.DirectAdjoint; heat-balance Newton by optimistix with implicit adjoint

–

test_fcc.py::TestGradients::test_reverse_mode_matches_forward

Isomerization (IsomerizationUnit)

fwd

with a DIH, the recycle is converged by a Python Anderson loop and differentiated at its solution by a jax.custom_jvp

–

test_isomerization_dih_gradients.py (jacfwd)

Alkylation (AlkylationUnit)

fwd

DIB-overhead tear by the Flowsheet’s traced optimistix fixed point

–

test_alkylation.py (jvp, jacfwd)

Preheat train (PreheatedCrudeUnit)

fwd + rev

outer Newton with an implicit step

–

test_preheat.py (grad, jacfwd)

Blend pool, property estimates

fwd + rev

closed-form, pure JAX

–

test_blending.py, test_properties.py

Adapters (gas_plant_feed, fractionate, NaphthaFeed.from_hydrotreater, fuel_oil_blend)

fwd + rev

sums, sigmoid splits, lumping; gas_plant_feed’s cut selection is static (pass cuts= under a transform)

–

test_gasplant_feed.py, test_hydrotreated_naphtha_feeds.py

Hydrogen network (HydrogenNetwork)

fwd + rev

pure JAX; the swing clips and the PSA share are kinks

–

test_hydrogen.py; close_hydrotreater_loop is a concrete iteration that returns a linear purity response

What it costs#

These were measured on this 4-core, 15 GB machine while other jobs were running (difflow_refinery.plant.timed, or wall clock around Chain.jacobian). Each figure is the first call, which traces and compiles, and then a repeated call:

Derivative

First call

Repeated

Peak memory

Naphtha hydrotreater alone, d(product S)/d(bed inlet T), jax.jacrev, default beds

227 s

2.4 s

The same, jax.jacfwd, ReactorOptions(adjoint="forward")

132 s

1.0 s

NHT → fractionator → reformer → gasoline pool, 2 inputs × 5 outputs, method="fwd", stages jit=True

487 s (435 s inside example 40, after jax.clear_caches())

34 s (31 s)

7.1 GB (the whole process, prototype)

The same, stages not jitted, vectorize=False

671 s

511 s

8.6 GB

The same, stages not jitted, vectorize=True (plain jax.jacfwd)

killed by the out-of-memory killer at 5.6 GB resident, with another 10 GB in use on the machine

The two hydrotreater gradients agree to six figures (−0.224247 wppm/K). A central difference over ±0.5 K gives −0.22457, which is the truncation error. The forward one compiles in a little over half the time here, because one tangent through the beds is cheaper to build than the checkpointed adjoint.

Two findings come from those rows.

  • Jit the stages. The reformer’s solve is a Flowsheet with a Python-level recycle. Under a transform it switches to its traced path, which is built afresh on every call. Without jit, every JVP and every repeated Jacobian traces and compiles the reformer again: in the fourth row, two tangents cost two compiles, and so does the second call. With Stage(..., jit=True) the stage is one jitted function. Its derivative is compiled once (most of the 487 s is the reformer’s JVP), and a repeated Jacobian costs only its 34 s run.

  • Memory, not time, is what limits a chain on a machine like this. Every compiled solve stays resident. Example 40 calls jax.clear_caches() before it differentiates, after its central differences, and that keeps the derivative’s compile inside the machine.

In example 40 (section 10) the ten entries of that Jacobian agree with central differences at h = 0.25 K to 0.1 % or better. The exception is d(gasoline S)/d(T_NHT), at 0.5 %, which is the truncation error of a sulfur that falls exponentially with temperature. Four central-difference evaluations of the already compiled units took 38 s, against 435 s for the first AD Jacobian. For a single gradient on this machine, finite differences are cheaper. AD pays off in exactness, and for a planner that relinearizes a compiled chain, which costs 29 s per Jacobian after the first.

Gotchas#

  • Pure stages. A stage may read constants from its closure, such as the unit object, a feed, a warm start or the other pool components. Only what it receives as x is differentiated.

  • Warm starts. Pass the reformer tear_initial= from a converged solve, and use on_nonconvergence="ignore" inside the chain, so that a central difference does not trip the warning.

  • Not a Flowsheet. A recycle between library units, such as the hydrogen header’s purity feeding back into the hydrotreaters, is a tear around two compiled solves. difflow_refinery.hydrogen.close_hydrotreater_loop iterates it concretely in Python and returns a linear purity response. A chain has no recycles.

Tests: tests/refinery/test_plant.py runs per commit, on toy stages with the units’ restrictions: a mixed chain fails end to end in both modes, "chain" is exact, the methods and vectorize agree, and the table names real objects. tests/refinery/test_plant_chain.py is a release and slow test (23 min on 4 cores). It runs NHT → fractionator → reformer against central differences, all-forward with jax.jacfwd, and the mixed chain by unit Jacobians against it.


Thermochemical data#

Every refinery unit that needs a heat of formation, an absolute entropy or an ideal-gas heat capacity for reaction thermochemistry reads it from one table: difflow_refinery.thermochemistry (#339). Before it, the reformer, isomerization, alkylation, the FCC regenerator, the gas plant’s heating values and the hydrotreater each kept a copy, entered from different secondary sources, and the copies disagreed: benzene 82.88-83.18 kJ/mol, cyclohexane -122.08 to -123.13, isopentane -153.7 to -154.5. So did the heats and the equilibria. Benzene saturation in the hydrotreater and naphthene dehydrogenation in the reformer are the same reaction run in reverse, and they used different data (and the hydrotreater neglected Cp, which made its K 3-5x too large; #338). Now both read the same row and the same Cp-integrated ln K. The isomerization unit’s C5 equilibrium was 0.06 off what the reformer’s own data give.

import numpy as np
from difflow_refinery import thermochemistry as tc

tc.Hf("benzene"), tc.S0("benzene"), tc.species("benzene").cp   # J/mol, J/mol/K, cubic
tc.enthalpy("benzene", 600.0)        # Hf + int Cp dT (J/mol), JAX in T
tc.entropy("benzene", 600.0, P=1e5)  # S0 + int Cp/T dT - R ln(P/1 bar)
tc.gibbs("benzene", 600.0)           # H - T S at 1 bar
nu = {"benzene": -1, "hydrogen": -3, "cyclohexane": 1}
tc.reaction_enthalpy(nu), tc.ln_K(nu, 623.15)   # element balance checked; K on 1 bar

gas = tc.IdealGasSet(("hydrogen", "benzene", "cyclohexane"))   # a unit's species list
gas.HF, gas.S0, gas.CP                          # numpy arrays
T, nu_rows = 600.0, np.array([[-3.0, -1.0, 1.0]])   # H2, benzene, cyclohexane
gas.enthalpy(T), gas.gibbs(T), gas.ln_K(nu_rows, T)

Each row (tc.species(key), a FormationData) records the source of Hf, S0 and the Cp cubic, the range the cubic was fitted over, its largest deviation from its source, a status for Hf and a note wherever a choice was made. tc.HF_CROSSCHECK and tc.S0_CROSSCHECK hold every other compilation’s value. tests/refinery/test_thermochemistry.py pins the chosen values to their sources. It also enforces the single copy: an AST scan of every difflow_refinery module fails on a literal Hf/S0 table, on formation-data literals passed to a constructor, on a Cp table keyed by table species, and on reading difflow.database’s Hf. It is checked on the private copies #339 removed.

How the values were chosen#

No primary source (papers, NIST WebBook, ATcT, JANAF pages) could be opened from the environment the table was built in. Values were read from the data files of the chemicals package (Bell et al., v1.5.2), which transcribe the compilations named, and compared across them.

Hf, the ideal-gas enthalpy of formation at 298.15 K, follows one rule, so that a reaction is computed from one evaluation wherever possible. Isomer and ring differences matter more than absolute values.

  1. Elements (H2, N2, O2): zero.

  2. H2O, CO, CO2, SO2, H2S, NH3: CODATA Key Values (Cox, Wagman & Medvedev 1989). NIST-JANAF and ATcT agree within 0.1 kJ/mol, except NH3 (ATcT -45.56 against -45.94; JANAF and CRC side with CODATA).

  3. Organics: the API Technical Data Book values (the Albahri compilation in chemicals). It is the only compilation here that holds every hydrocarbon the units carry, so isomer differences are internally consistent. The reformer has used it since #309, and ChemSep (DWSIM) uses the same C5/C6 paraffin values. ATcT is more accurate where it exists, but covers about a third of the species. Mixing it in would mix evaluations inside one reaction: benzene from ATcT with cyclohexane from API TDB gives a hydrogenation heat of -206.31 kJ/mol, equal to neither consistent set (API TDB -206.06, ATcT -205.26).

  4. Overrides: API TDB gives way to CRC where it is absent, or where it differs from CRC by more than 2 kJ/mol and a third source sides with CRC. That covers 2,3-dimethylpentane, cyclohexylbenzene, quinoline and carbazole (the API TDB entries for the last three look like estimates). API TDB stays where the third source sides with it. 2-methylnonane is the case: API TDB is on the homologous series, and CRC is 3.7 kJ/mol off it. Differences of 1-2 kJ/mol stay at API TDB and are recorded. The 2,2- and 2,3-dimethylbutane values are such a case: CRC is 1.2 and 1.3 kJ/mol lower, and the C6 equilibrium moves with them.

  5. No tabulated value: an estimate from group increments of the same table, marked estimate (4,6-DMDBT, 3,3’-dimethylbiphenyl, 3-(3-methylcyclohexyl)toluene).

S0, the ideal-gas absolute entropy at 298.15 K and 1 bar, comes from Yaws (2014) for every organic and from CODATA for the inorganics. Yaws is checked against the CRC and WebBook values chemicals carries, which agree within 2.5 J/mol/K. Two entries are rejected, because they are condensed-phase magnitudes: benzothiophene (212.76; indole is 328) and carbazole (244.95; biphenyl is 391). Those species have no S0. Nothing needs one, because HDS and HDN are irreversible. The S0 of 1,2,3,4-tetrahydrophenanthrene is estimated as phenanthrene + tetralin - naphthalene. That makes the poly→di aromatic-saturation entropy the di→mono one, which is what the hydrotreater assumed.

The Cp cubics a + bT + cT² + dT³ are fitted by this project:

  • Organics are fitted over 298.15-1000 K to the TRC ideal-gas correlation (Frenkel et al. 1994). The range covers the reformer at about 800 K and the hydroprocessing reactors. The reformer’s own fits from #309 were made the same way and are kept unchanged.

  • Regenerator gases (N2, O2, H2O, CO, CO2, SO2) are fitted over 298.15-1500 K to NIST-JANAF (Chase 1998).

  • H2S and NH3 are fitted over 298.15-1000 K to NIST-JANAF.

  • Joback group contribution is used where no correlation exists: benzothiophene, DBT, 4,6-DMDBT, cyclohexylbenzene, quinoline, indole, carbazole and one estimated product. On naphthalene, biphenyl and tetralin it is within 1.1-2.9 % of TRC.

Every fitted cubic is within 2 % of its source over its range. Outside the range a cubic is an extrapolation, and nothing clips it.

Benzothiophene (#339): 166.3 kJ/mol. Two calorimetric determinations (combustion plus sublimation) give 166.28 ± 0.48 (Sabbah 1979) and 166.6 kJ/mol (Good 1972), as the NIST WebBook lists them. They were read through a search engine’s summary of the WebBook page, because the page itself was blocked. CRC and Yaws carry 166.3. ChemSep’s 137.0, which DWSIM uses, matches no measurement found and is not used. Benzothiophene HDS (+3 H2 → ethylbenzene + H2S) therefore releases 52.4 kJ/mol H2. On ChemSep’s value it would be 42.6.

Deliberate differences that remain:

  • difflow.database is difflow core’s table, with its own users. Its water Hf is the liquid’s. No refinery module reads its Hf. Where it holds the same ideal-gas species, it agrees with this table within 1.4 kJ/mol (tested).

  • Separation Cp: the gas plant’s CP_IG and the hydroprocessing separators’ _CP_IG_GASES are sensible heat on the Peng-Robinson path. They are not reaction thermochemistry, and the IDAES and DWSIM separation references are built on them. The alkylation unit’s fractionation and feed cooling use difflow.database’s Cp for the same reason. Its heat of reaction is this table’s.

Numbers that moved#

Where

Before

After

Why

Reformer

–

–

it already used these values; bit-identical

Isomerization, nC5 → iC5 dH

-8.10 kJ/mol

-6.99

Prosen & Rossini (1945) → API TDB (Good 1970’s pentanes)

Isomerization, iC5 share of the C5s at 450 K

0.820

0.772

the same (DWSIM on ChemSep: 0.762)

Isomerization, C6 paraffins at 120 C (nC6 / 2MP / 3MP / 2,3-DMB / 2,2-DMB)

0.067 / 0.223 / 0.128 / 0.109 / 0.473

0.069 / 0.280 / 0.164 / 0.090 / 0.397

API TDB isomer differences (DMBs 0.67-0.75 kJ/mol less stable, MPs 0.64-0.71 more, relative to nC6); TRC Cp fits

Isomerate RON, paraffinic / benzene-rich feed, 140 C (once-through; DIH)

82.18 / 81.56; 82.81 / 82.79

80.98 / 80.74; 81.31 / 81.61

fewer dimethylbutanes at equilibrium (Results)

Alkylation, heat of alkylation (route A, kJ/mol olefin): propylene / trans-2-butene / isobutylene

-86.3 / -71.5 / -67.9

-84.9 / -70.9 / -67.1

API TDB gas Hf instead of difflow.database’s (propylene 19.71 against 20.41; the butenes)

FCC regenerator gases’ Cp

RPP 4th ed. cubics

JANAF fits, 298-1500 K

within 0.7 % (O2 1.1 %) below 1050 K; RPP falls 5-16 % low by 1500 K

Gas plant LHV

difflow.database Hf, SO2 -296.84, 1-butene -0.63

this table

under 0.3 % (see the table below)

Gas-plant lower heating values (kJ/mol), before and after: methane 802.29 → 802.64, ethane 1428.50 → 1428.65, propylene 1926.42 → 1925.72, isobutane 2648.97 → 2648.18, 1-butene 2540.71 → 2541.44, isobutylene 2523.44 → 2524.44, H2S 518.07 → 518.04. The largest change is 0.044 %. The FCC example below (light crude VGO, ROT 520 C) moves from conversion 0.7470, C/O 5.824, regenerator 1003.94 K to 0.7469, 5.819, 1004.13 K.

The hydrotreater on the table (#338)#

The hydrotreater (and through it the hydrocracker’s pretreat bed and the residue desulfurizer) reads the table too: hydrotreating.kinetics keeps no copy (the guard covers it), every heat is tc.reaction_enthalpy with the element balance checked, and the aromatics equilibria are tc.ln_K – Cp-integrated – instead of constant 298 K dH and dS. Its private copy had differed from the table here (every other model compound unchanged):

Model compound

Hf before (kJ/mol)

Hf table

S° before (J/mol/K)

S° table

hydrogen

0

0

130.7

130.68

ammonia

-45.558

-45.94

192.8

192.77

benzene

83.18

82.93

269.2

269.18

cyclohexane

-122.08

-123.13

298.19

297.31

naphthalene

150.6

150.58

333.1

333.6

tetralin

26

26.61

366.22

366.22

phenanthrene

207.5

207.1

396.01

396.01

tetrahydrophenanthrene

92.3

92.3

– (di step’s dS used)

428.63 (estimate)

diethyl_sulfide

-83.5

-83.47

368.1

368.32

ethane

-83.78

-83.85

229.2

229.45

n_butane

-125.85

-125.65

304.4

304.4

benzothiophene

166.3

166.3

212.76

– (rejected)

ethylbenzene

29.9

29.79

360.6

361.24

biphenyl

181.4

182.09

391.24

391.24

propylbenzene

7.9

7.9

397.86

399.08

carbazole

200.7

200.7

244.95

– (rejected)

1_hexene

-43.5

-41.67

383.84

383.84

n_hexane

-166.94

-166.95

388.82

388.74

(The S° the table rejects were never used: only the saturation steps need an entropy.) What moved:

Where

Before

After

Why

Benzene + 3 H2 = cyclohexane, ln K at 300 / 350 / 420 °C (1 bar)

-0.53 / -4.00 / -8.01

-1.65 / -5.34 / -9.64

Cp integrated (K was 3.1x, 3.8x, 5.1x too large); DWSIM’s ChemSep data give -1.65 / -5.35 / -9.65

Naphthalene / phenanthrene steps, ln K at 350 °C

-3.58 / -5.35

-4.36 / -6.29

the same (2.2x, 2.6x)

Saturation heats, 25 °C (poly / di / mono, kJ/mol)

-115.2 / -124.6 / -205.3

-114.8 / -124.0 / -206.1

table Hf; and now taken at the bed temperature: -124.8 / -132.1 / -219.9 at 350 °C

HDS heats (kJ/mol S)

-104.7, -261.4, -157.0, -83.9, -173.1

-104.8, -261.2, -157.1, -83.4, -172.8

table Hf (biphenyl +0.69, ethane, n-butane)

HDN heats

-238.2, -263.0

-238.5, -263.3

NH3 -45.558 -> -45.94 (CODATA)

Olefin / cracking heats

-123.4 / -42.7

-125.3 / -42.55

1-hexene -43.5 -> -41.67 (API TDB)

Hydrocracker / residue saturation heat per H2

-68.4

-68.7 kJ/mol H2

benzene, cyclohexane

Test diesel, 340 °C bed 1 inlet: product aromatics, chemical H2, reactor outlet

14.21 vol%, 32.39 Nm³/m³, 355.54 °C

14.28 vol%, 30.48 Nm³/m³, 354.80 °C

see the results

Test diesel, 380 °C bed 1 inlet

10.44 vol%, 44.45 Nm³/m³, 400.39 °C

12.11 vol%, 31.47 Nm³/m³, 393.14 °C

the equilibrium limit binds: poly-aromatics 0.92 -> 3.05 vol%

Example 40, distillate hydrotreater (header makeup)

8.3 wppm S, 18.5 vol% aromatics, 31.1 Nm³/m³

8.9 wppm S, 18.6 vol% aromatics, 29.1 Nm³/m³

less saturation, less heat (WABT 350.9 -> 350.6 °C)

Example 40, refinery hydrogen

546 mol/s, 357 from the H2 plant

540 mol/s, 350 from the H2 plant

ULSD pool 11.8 -> 12.7 ppm S; fuel oil 0.31 wt% S either way

Aromatics minimum, single bed at 30 bar (trend test)

390 °C

360 °C

Provenance#

Statuses for Hf:

  • definition: an element.

  • key value: CODATA.

  • cross-checked: an independent compilation (ATcT, CRC or API TDB, other than the chosen one) agrees within 1.5 kJ/mol.

  • unverified: no independent agreement, or a choice between sources that disagree; the row’s note says which.

  • estimate: this project’s group-increment construction.

Cp is a cubic fitted to the named source. The max deviation is the largest relative difference from that source over the range (“estimate” for Joback, which is its own source). The notes behind each choice are in the module (tc.species(key).note).

Species

Formula

Hf (kJ/mol)

Hf source

Hf status

S° (J/mol/K)

S° source

Cp

Cp range (K), max dev

hydrogen

H2

0

element

definition

130.68

CODATA

TRC fit (#309)

298.15-1000, 0.5 %

nitrogen

N2

0

element

definition

191.609

CODATA

JANAF (Shomate) fit

298.15-1500, 0.5 %

oxygen

O2

0

element

definition

205.152

CODATA

JANAF (Shomate) fit

298.15-1500, 1.1 %

water

H2O

-241.826

CODATA

key value

188.835

CODATA

JANAF fit

298.15-1500, 0.2 %

carbon_monoxide

CO

-110.53

CODATA

key value

197.66

CODATA

JANAF fit

298.15-1500, 0.5 %

carbon_dioxide

CO2

-393.51

CODATA

key value

213.785

CODATA

JANAF fit

298.15-1500, 0.3 %

sulfur_dioxide

O2S

-296.81

CODATA

key value

248.223

CODATA

JANAF fit

298.15-1500, 0.2 %

hydrogen_sulfide

H2S

-20.6

CODATA

key value

205.81

CODATA

JANAF fit

298.15-1000, 0.0 %

ammonia

H3N

-45.94

CODATA

key value

192.77

CODATA

JANAF fit

298.15-1000, 0.3 %

methane

CH4

-74.52

API TDB

cross-checked

186.6

Yaws

TRC fit (#309)

298.15-1000, 0.8 %

ethane

C2H6

-83.85

API TDB

cross-checked

229.45

Yaws

TRC fit (#309)

298.15-1000, 0.8 %

propane

C3H8

-104.69

API TDB

cross-checked

270.28

Yaws

TRC fit (#309)

298.15-1000, 0.8 %

isobutane

C4H10

-134.99

API TDB

cross-checked

295.34

Yaws

TRC fit (#309)

298.15-1000, 0.8 %

n_butane

C4H10

-125.65

API TDB

cross-checked

304.4

Yaws

TRC fit (#309)

298.15-1000, 0.9 %

isopentane

C5H12

-153.7

API TDB

cross-checked

343.89

Yaws

TRC fit (#309)

298.15-1000, 0.5 %

n_pentane

C5H12

-146.71

API TDB

cross-checked

349.25

Yaws

TRC fit (#309)

298.15-1000, 0.9 %

neopentane

C5H12

-168.07

API TDB

cross-checked

305.99

Yaws

TRC fit

298.15-1000, 0.2 %

n_hexane

C6H14

-166.95

API TDB

cross-checked

388.74

Yaws

TRC fit (#309)

298.15-1000, 1.0 %

2_methylpentane

C6H14

-174.69

API TDB

cross-checked

380.69

Yaws

TRC fit (#309)

298.15-1000, 0.4 %

3_methylpentane

C6H14

-172.06

API TDB

cross-checked

383.04

Yaws

TRC fit

298.15-1000, 0.5 %

2_2_dimethylbutane

C6H14

-184.68

API TDB

cross-checked

358.22

Yaws

TRC fit

298.15-1000, 0.4 %

2_3_dimethylbutane

C6H14

-176.8

API TDB

cross-checked

365.94

Yaws

TRC fit

298.15-1000, 0.1 %

n_heptane

C7H16

-187.65

API TDB

cross-checked

428.23

Yaws

TRC fit (#309)

298.15-1000, 1.0 %

2_methylhexane

C7H16

-194.5

CRC

unverified

420.52

Yaws

TRC fit (#309)

298.15-1000, 0.5 %

2_3_dimethylpentane

C7H16

-198.7

CRC

unverified

415.15

Yaws

TRC fit

298.15-1000, 0.0 %

2_4_dimethylpentane

C7H16

-201.67

API TDB

cross-checked

397.38

Yaws

TRC fit

298.15-1000, 0.1 %

n_octane

C8H18

-208.82

API TDB

cross-checked

467.05

Yaws

TRC fit (#309)

298.15-1000, 1.0 %

2_methylheptane

C8H18

-215.35

API TDB

cross-checked

459.34

Yaws

TRC fit (#309)

298.15-1000, 0.7 %

2_2_4_trimethylpentane

C8H18

-224.01

API TDB

cross-checked

423.11

Yaws

TRC fit

298.15-1000, 0.2 %

2_3_4_trimethylpentane

C8H18

-217.32

API TDB

cross-checked

428.48

Yaws

TRC fit

298.15-1000, 0.3 %

2_5_dimethylhexane

C8H18

-222.51

API TDB

cross-checked

442.57

Yaws

TRC fit

298.15-1000, 0.3 %

n_nonane

C9H20

-228.86

API TDB

cross-checked

507.08

Yaws

TRC fit (#309)

298.15-1000, 1.0 %

2_methyloctane

C9H20

-235.85

API TDB

unverified

499.16

Yaws

TRC fit (#309)

298.15-1000, 0.7 %

2_2_5_trimethylhexane

C9H20

-253.3

API TDB

unverified

461.93

Yaws

TRC fit

298.15-1000, 0.3 %

n_decane

C10H22

-249.53

API TDB

cross-checked

546.36

Yaws

TRC fit (#309)

298.15-1000, 1.0 %

2_methylnonane

C10H22

-256.52

API TDB

unverified

539.32

Yaws

TRC fit (#309)

298.15-1000, 0.6 %

n_dodecane

C12H26

-290.79

API TDB

cross-checked

625.21

Yaws

TRC fit

298.15-1000, 1.0 %

ethylene

C2H4

52.28

API TDB

cross-checked

219.18

Yaws

TRC fit

298.15-1000, 1.4 %

propylene

C3H6

19.71

API TDB

cross-checked

266.71

Yaws

TRC fit

298.15-1000, 0.6 %

1_butene

C4H8

0.1

CRC

cross-checked

307.88

Yaws

TRC fit

298.15-1000, 0.5 %

cis_2_butene

C4H8

-6.99

API TDB

cross-checked

301.17

Yaws

TRC fit

298.15-1000, 0.8 %

trans_2_butene

C4H8

-11.17

API TDB

cross-checked

296.48

Yaws

TRC fit

298.15-1000, 0.6 %

isobutylene

C4H8

-16.9

API TDB

cross-checked

293.12

Yaws

TRC fit

298.15-1000, 0.4 %

1_pentene

C5H10

-20.92

API TDB

cross-checked

347.03

Yaws

TRC fit

298.15-1000, 0.8 %

2_methyl_2_butene

C5H10

-42.55

API TDB

cross-checked

338.65

Yaws

TRC fit

298.15-1000, 0.7 %

1_hexene

C6H12

-41.67

API TDB

unverified

383.84

Yaws

TRC fit

298.15-1000, 0.8 %

methylcyclopentane

C6H12

-106.69

API TDB

cross-checked

339.9

Yaws

TRC fit (#309)

298.15-1000, 1.2 %

cyclohexane

C6H12

-123.13

API TDB

cross-checked

297.31

Yaws

TRC fit (#309)

298.15-1000, 1.4 %

methylcyclohexane

C7H14

-154.77

API TDB

cross-checked

343.5

Yaws

TRC fit (#309)

298.15-1000, 0.6 %

ethylcyclohexane

C8H16

-171.75

API TDB

cross-checked

382.99

Yaws

TRC fit (#309)

298.15-1000, 0.6 %

n_propylcyclohexane

C9H18

-193.3

API TDB

cross-checked

419.97

Yaws

TRC fit (#309)

298.15-1000, 0.6 %

n_butylcyclohexane

C10H20

-213.17

API TDB

cross-checked

459.59

Yaws

TRC fit (#309)

298.15-1000, 0.7 %

trans_decalin

C10H18

-182.1

CRC

unverified

373.89

Yaws

TRC fit

298.15-1000, 2.0 %

benzene

C6H6

82.93

API TDB

cross-checked

269.18

Yaws

TRC fit (#309)

298.15-1000, 0.4 %

toluene

C7H8

50.17

API TDB

cross-checked

321.08

Yaws

TRC fit (#309)

298.15-1000, 0.8 %

ethylbenzene

C8H10

29.79

API TDB

cross-checked

361.24

Yaws

TRC fit (#309)

298.15-1000, 0.8 %

n_propylbenzene

C9H12

7.9

API TDB

cross-checked

399.08

Yaws

TRC fit (#309)

298.15-1000, 0.8 %

n_butylbenzene

C10H14

-13.14

API TDB

cross-checked

440.28

Yaws

TRC fit (#309)

298.15-1000, 0.9 %

cyclohexylbenzene

C12H16

-16.7

CRC

unverified

429.36

Yaws

Joback

298.15-1000, estimate

tetralin

C10H12

26.61

API TDB

cross-checked

366.22

Yaws

TRC fit

298.15-1000, 0.5 %

naphthalene

C10H8

150.58

API TDB

cross-checked

333.6

Yaws

TRC fit

298.15-1000, 0.6 %

biphenyl

C12H10

182.09

API TDB

cross-checked

391.24

Yaws

TRC fit

298.15-1000, 0.4 %

3_3_dimethylbiphenyl

C14H14

116.57

estimate

estimate

–

–

TRC fit

298.15-1000, 0.4 %

methylcyclohexyltoluene

C14H20

-81.1

estimate

estimate

–

–

Joback

298.15-1000, estimate

phenanthrene

C14H10

207.1

API TDB

cross-checked

396.01

Yaws

TRC fit

298.15-1000, 0.4 %

tetrahydrophenanthrene

C14H14

92.3

NIST (unverified)

unverified

428.63

estimate

TRC fit

298.15-1000, 0.7 %

diethyl_sulfide

C4H10S

-83.47

API TDB

cross-checked

368.32

Yaws

TRC fit

298.15-1000, 0.8 %

thiophene

C4H4S

114.9

CRC

unverified

278.81

Yaws

TRC fit

298.15-1000, 0.2 %

benzothiophene

C8H6S

166.3

CRC

unverified

–

–

Joback

298.15-1000, estimate

dibenzothiophene

C12H8S

205.1

CRC

unverified

–

–

Joback

298.15-1000, estimate

4_6_dimethyldibenzothiophene

C14H12S

139.58

estimate

estimate

–

–

Joback

298.15-1000, estimate

pyridine

C5H5N

140.16

API TDB

cross-checked

282.5

Yaws

TRC fit

298.15-1000, 0.8 %

quinoline

C9H7N

200.5

CRC

unverified

366.04

Yaws

Joback

298.15-1000, estimate

indole

C8H7N

156.6

API TDB

cross-checked

328.44

Yaws

Joback

298.15-1000, estimate

carbazole

C12H9N

200.7

CRC

unverified

–

–

Joback

298.15-1000, estimate

aniline

C6H7N

86.86

API TDB

cross-checked

319.87

Yaws

TRC fit

298.15-1000, 0.2 %

Validation#

The crude unit has been checked against an independent reference. The issue asked for a DWSIM (or HYSYS or PRO/II) crude case. None of those were available, so the reference is built with IDAES 2.10 (Pyomo 6.10, IPOPT 3.13.2): the U.S. DOE’s open equation-oriented process modelling platform. That choice decides what the check can say. It is an independent implementation: different code, a different solver, a different formulation and a different starting point. It is not an independent model: the column and its property model are the ones difflow states, written again. A commercial simulator’s crude case would also bring its own characterisation and its own thermodynamics. Here those are tested separately, against published numbers and against Peng-Robinson.

The generator is tests/refinery/reference/generate.py. It writes cdu_reference.json, which records the tool versions, the property methods, the date and the difflow commit. tests/refinery/test_validation.py checks difflow against that file, with each tolerance and the reason for it written beside the assertion. The test needs neither IDAES nor IPOPT. The case is the 95 000 bbl/d test crude in the 30-stage column used throughout this page: three side strippers, two pumparounds, bottom and stripper steam, a total condenser, and a 5 vol % overflash. The column builder (reference/mesh.py) takes a stage network, not a fixed layout. The vacuum column has its own (below): its liquid routes, Murphree beds and Maxwell-Bonnell property model did not fit this one without rewriting most of it.

Layer 1: characterisation, against published worked examples.

Source

Check

Agreement

Riazi, ASTM MNL50 (2005), Ex. 2.5 and Ex. 2.7 / Table 2.11

Riazi-Daubert 1980 and 1987, Lee-Kesler and Twu M, Tc, Pc

to the printed digits; Twu Pc within 1 % (below)

Ahmed, Equations of State and PVT Analysis (2016), Ex. 2.2

Riazi-Daubert 1980/1987 and Kesler-Lee M, Tc, Pc

to the printed 3 figures

MNL50 Ex. 3.3 / Table 3.8

TBP to ASTM D86 (Riazi-Daubert)

within 0.1 C at all six points

chemicals 1.5.2 docstring examples (API TDB 2B4.1; Lee-Kesler; Riedel)

Watson K, Lee-Kesler Psat, Riedel latent heat at Tb

to the printed digits

chemicals 1.5.2, on this crude’s 28 components

Lee-Kesler Psat, Lee-Kesler omega, Riedel latent heat

1e-10

Three of MNL50’s printed values are not reproduced. None is asserted.

  • Twu Pc for n-C36. The printed value is 6.02 bar (6.03 in Table 2.12); ours is 5.97. Our Vc reproduces the printed 2010 cm3/mol and Pc° = 6.015 bar, so the printed Pc appears to omit Twu’s f_P correction. The test allows 1.5 %.

  • The “API” Pc of 7.37 bar. The extended Riazi-Daubert 1987 equation gives 5.90.

  • The Lee-Kesler Tc of 935.1 K. The Kesler-Lee equation gives 870.7, and our implementation reproduces Ahmed’s Kesler-Lee example to three figures.

Layer 2: thermodynamics, against IDAES.

  • IDAES running difflow’s property model. The IDAES generic framework was given the same pure-component methods. The test flashes each stage’s whole contents at the reference column’s T and P:

    • K-values agree to 1e-6.

    • Liquid and vapour enthalpies agree to 1e-6.

    • Each stage splits back into its own L and V to 1e-5.

    • IDAES’s crude bubble point (379 K) and dew point (845 K) at the flash-zone pressure satisfy difflow’s sum z K = 1 and sum z / K = 1.

    This confirms that difflow’s arrays implement the equations it states. It says nothing about whether those equations are right.

  • Peng-Robinson on the same Tc, Pc, omega and ideal-gas Cp (kij = 0, hydrocarbons only). These are modelling differences. They are documented and pinned in the tests, not tuned away:

    • On every stage, and at the coil outlet, the vapour fraction agrees within 0.021. At the coil outlet the difference is 0.0033.

    • At the furnace inlet (240 C, 6 bar), Raoult vaporises 23 mol % of the crude and PR vaporises 17 %.

    • The crude’s enthalpy rise from the furnace inlet to the coil outlet is 4.0 % higher under PR. That is the furnace-duty difference a PR crude case would show from the property model alone.

    • For the cuts boiling 420-640 K, which make the side products, PR and Raoult K-values agree within -30 % / +25 % at the flash zone.

    • Raoult over Lee-Kesler badly overpredicts the supercritical light ends. Propane’s K is about 60 times PR’s. The light ends go overhead under either model, but do not read a light-ends K-value off this model.

    • For the heaviest residue cut (Tb 1033 K), PR’s K is about 20 times Raoult’s.

    • Liquid enthalpy at the same composition agrees within 1.5 kJ/mol above the flash zone. Where residue is in the liquid, PR’s is 13-18 kJ/mol higher. Watson’s latent heat and PR with an extrapolated omega are both extrapolations for a 1000 K cut, and nothing here says which is closer to the truth.

  • Water. difflow’s Wagner-Pruss Psat agrees with IAPWS-95 to 0.004 % and with IAPWS-IF97 to 0.02 %. Watson’s latent heat for water is exact at Tb, 1.3 % high at 300 K and 2.6 % low at 550 K, compared with IAPWS-95 by Clausius-Clapeyron.

Layer 3: the column, against an independent EO model. reference/mesh.py writes every stage, stripper, pumparound, the condenser and the furnace flash as Pyomo equations with the same specs. It solves them with IPOPT from a linear 380-580 K profile and round-number flows; no difflow result is used to initialise it. It converges in about 17 s. The two solutions agree as follows:

  • stage and stripper temperatures and the coil outlet (586.3 K): 5e-6 K

  • condenser temperature: 3e-4 K, all of it the difference between the two water Psat formulations (below) in the bubble point of the condensate with free water

  • condenser duty (31.39 MW): 6e-7 relative

  • fired duty (49.89 MW): 3e-10 relative

  • pumparound return temperatures: agree within 1e-3 K (the test tolerance)

  • feed vaporised (0.696): agrees within 1e-6 (the test tolerance)

  • volume yields: 2e-10

  • API gravities: 1e-7

  • TBP 5/10/50/90/95 % points: 4e-7 K

  • 5-95 gaps: 5e-7 K

  • steam saturation: within the 0.02 % that separates the two water Psat formulations

The test tolerances are set at the solvers’ precision, not at engineering accuracy, so a transcription error in either column has nowhere to hide. The same column with Watson’s floor unsmoothed (eps = 0.01 → 1e-6) moves the fired duty by 0.10 %, the condenser duty by 0.003 %, stage temperatures by at most 0.017 K and the API gravities by at most 2e-4. That is the price of the smoothing that gives difflow a derivative everywhere.

Layer 4: gradients. Central finite differences of the reference column (each spec ± 0.002, IPOPT warm-started) against jax.grad through difflow’s implicit-function solve:

Gradient

jax.grad

Reference FD

Agreement

d(diesel API)/d(diesel vol. yield)

-30.2248

-30.2254

1.8e-5

d(fired duty)/d(overflash)

167.758 MW

167.764 MW

3.2e-5

The file also stores d(condenser duty)/d(diesel yield) and d(residue API)/d(overflash) for later use.

What this does not validate. It does not show that Raoult/Watson is the right property model for a given crude; layer 2 measures how far it is from PR, nothing more. It does not cover a commercial simulator’s characterisation, its D86 interconversion defaults, or its tray-efficiency and hydraulics models, and it does not replace plant data. The 5-95 gaps of this case are negative (-28 to -54 K): equilibrium stages with these specs give overlapping products. Both implementations agree on that, which says nothing about whether a real column would overlap the same way. When a deliberate model change moves any number above, TestReferenceIsCurrent fails first and asks for the reference to be regenerated.

Validation: the vacuum unit#

The vacuum column (#294) is checked the same way and with the same caveat. DWSIM was not available. IDAES has no vacuum-column model with pumparounds, a wash bed, entrainment routes and Murphree beds. So the column is written again in Pyomo, IDAES’s modelling layer, as an equation-oriented MESH model (tests/refinery/reference/vdu_mesh.py) and solved with IDAES’s IPOPT 3.13.2. Its property model (vdu_formulas.py) is transcribed from the published correlations:

  • Maxwell-Bonnell vapour pressure in Rankine, with the Watson-K correction;

  • Kesler-Lee liquid Cp;

  • Clausius-Clapeyron latent heat;

  • NIST Shomate steam.

This is an independent implementation of the model difflow states, not an independent model.

The two share only the input: difflow’s characterised residue (the heavy_crude assay cut at 370 C, 21 pseudo-components, 66.2 kg/s). Otherwise the reference does each thing differently:

  • mole fractions and total flows where difflow uses log component flows;

  • absolute route flows where difflow uses softmax draws;

  • an explicit summation equation;

  • pumparound return temperatures as unknowns, where difflow makes the duties the knobs;

  • the LVGO end point as a smooth cumulative-mass equation;

  • IPOPT where difflow uses a damped Newton.

The reference starts from an engineering guess built from the feed flash and the specs, not from difflow’s answer. It solves in two steps:

  1. With the overflash spec relaxed and the HVGO draw held, so the wash bed stays wet.

  2. With the spec restored.

Together they take about 12 s.

The case is the 2-2-2-2 bed layout of this page, at a 400 C furnace outlet, 30 mmHg at the flash zone and 0.5 wt % stripping steam. Its specs are a 70 C top, 3 wt % overflash and a 450 C LVGO T95. Regenerate it with

PYTHONPATH=src:tests python -m refinery.reference.vdu_generate

which writes vdu_reference.json: provenance, the frozen component table, both columns, two model variants and the finite differences. tests/refinery/test_vdu_validation.py (release) compares against it. test_vdu_validation_file.py runs on every commit and checks two things: the file is intact, and difflow’s characterisation is still the one the reference was built on.

Quantity

difflow

Reference

Agreement (test tolerance)

LVGO / HVGO / slop / residue (kg/s)

4.67239 / 19.5862 / 1.98589 / 39.9512

same

≤ 1e-7 rel (1e-6)

LVGO / HVGO pumparound duty (MW)

2.8208 / 13.6600

same

≤ 1.3e-7 rel (1e-6)

Furnace duty (MW), vapour fraction

13.1119, 0.30336

same

2e-10, 4e-8 rel (1e-6)

Stage temperatures, flash zone 669.54 K

≤ 1e-5 K (1e-4 K)

Product TBP 5-95 % points; SGs

≤ 1e-5 K (1e-4 K); ≤ 3e-9 (1e-8)

The second case is Murphree beds: LVGO 80 %, HVGO 70 %, wash 50 %, stripping 40 %. It agrees just as closely: rates within 3e-7, duties within 1.6e-7, temperatures within 2e-5 K. With these beds the LVGO rate rises to 15.27 kg/s and HVGO falls to 7.98 kg/s, so the comparison exercises the efficiency path, not an unchanged column. The case needs a 520 C LVGO end point. With beds this poor, enough heavy vapour reaches the LVGO section that the 450 C spec cannot be met at any pumparound duty. Both implementations stop finding a column halfway from equilibrium to these efficiencies: difflow does not converge and IPOPT reports local infeasibility. That is consistent with the existing infeasible-spec test, although a local NLP verdict is not a proof. The reference reaches this case by continuation from its own equilibrium solution.

The remaining ~1e-7 differences come from one coefficient. The reference writes the Watson-K correction’s coefficient as 2.5/1.8 in Rankine, and difflow uses 1.3889 in SI.

The reference also measures what two of difflow’s numerical choices cost. These are not disagreements; each change applies to both implementations alike:

  • Four Watson-K passes instead of the exact fixed point. The fixed point moves ln Psat at the top stage by 6e-3, but the products by under 1e-8.

  • Blending Maxwell-Bonnell’s branches instead of switching at the published joins. This keeps a derivative everywhere. It moves the LVGO rate by 8.7e-4, the pumparound and furnace duties by 8-9e-4, and the flash zone by 0.01 K.

Sensitivities. jax.jacfwd through difflow’s solve was compared with central differences of the reference, with IPOPT warm-started at each perturbed point. The perturbations were furnace outlet ± 0.25 K, flash-zone pressure ± 10 Pa and steam ± 2.5e-5 kg/kg. Over the rates, duties, flash-zone temperature and HVGO T95 they agree within 4.2e-5 relative (tolerance 2e-4); the remaining difference is the finite differences’ own truncation error. Two examples are d(HVGO rate)/d(furnace T) = 0.15506 kg/s/K and d(HVGO pumparound duty)/d(steam) = 235.01 MW per kg/kg.

What this does not validate. It does not test the property model. Maxwell-Bonnell with Raoult at 10-30 mmHg is a choice that nothing here tests against data or an equation of state; the crude unit’s layer 2 is the nearest evidence. It does not cover a commercial simulator’s vacuum characterisation, packing HETP and pressure-drop models, or the ejector system, and it does not replace plant data.

Validation: the preheat train#

The drum and the exchangers are checked against IDAES 2.10 unit models (tests/refinery/reference/preheat_generate.py writes preheat_reference.json). test_preheat_validation.py (release) compares against the file. test_preheat_validation_file.py runs on every commit and checks that the file is intact and the characterisation unchanged. As for the column, this is an independent implementation, not an independent model. IDAES is given difflow’s property model (Raoult over Lee-Kesler, the cubic ideal-gas Cp, Watson liquid enthalpy, water vapour-only) on the same pseudo-components. Its liquid (P - P_ref)/rho term is switched off, because difflow’s model has none.

Check

IDAES model

Agreement (test tolerance)

Adiabatic drum: dry crude from 500 K and 15 bar to 3 bar

Flash

T within 2e-6 K (1e-5); vapour fraction 0.3052, 3e-8 rel (1e-6); vapour composition 2e-9 (1e-8)

Wet crude at drum states (470 K, 3 bar; 500 K, 2 bar), all water vapour

state block

vapour fraction and vapour water fraction 2e-11 rel (1e-9)

E7 and E8 at the base case’s inlets (residue against drum liquid; E8’s crude starts to boil)

HeatExchanger, counter-current, exact LMTD

duty 3.5e-10 rel (1e-8); outlet temperatures 8e-8 K (1e-6)

What the check does not cover:

  • The free-water branch of the split. IDAES’s package carries water as vapour-only. That branch is checked by hand in the per-commit tests: the drum at 380 K and 3 bar leaves free water, at 430 K none, and the split is continuous between.

  • The F-factor. IDAES’s exchanger is pure counter-current. The F-factor is difflow’s lmtd_correction_factor, which difflow.units.heat_exchanger already tests.

  • The coupled train and column. That is checked against itself. The balances close to 1e-12, and the implicit gradients match central differences: jax.jacfwd of the furnace inlet temperature, the fired duty and the drum vapour with respect to E6’s area, E8’s R_f, the drum pressure and the 40 % TBP point agree within 1e-5 relative (test_preheat.py, release).

Published case study: none found. The issue named Polley, Wilson, Yeap and Pugh (2002) as a candidate. No preheat-train study was found that publishes a train’s full data (assay, exchanger areas and U values, hot-stream rates) in a form that could be set up here, so nothing is reproduced. The eight-exchanger layout is a textbook one, and its numbers are not a validation.

Validation: the gas plant#

The gas plant’s columns are checked against IDAES 2.10 (tests/refinery/reference/gasplant_generate.py writes gasplant_reference.json). test_gasplant_validation.py (release) compares a fresh difflow solve against the file. test_gasplant_validation_file.py runs on every commit: it checks that the file is intact, that the component constants and cases are the ones it was built on, and the state-point comparison below, which needs no column solve. As for the crude unit, this is an independent implementation, not an independent model. IDAES’s generic Peng-Robinson package (its Cubic EOS, SmoothVLE, log-fugacity equilibrium) is given difflow’s constants (Tc, Pc, omega, ideal-gas Cp) with kij = 0. Both sides use equilibrium trays (tray_efficiency=1.0), a total condenser at the bubble point, a kettle reboiler and no pressure drop, with the reflux and boilup ratios fixed. IDAES signs the condenser duty negative; difflow reports the heat removed.

Case

IDAES model

Agreement (test tolerance)

Debutanizer: C3 to nC5, 10 bar, 10 trays, R = 2, boilup 2

TrayColumn, IDAES’s own initialization

product compositions 7.5e-8 rel (1e-5); condenser and reboiler duties 1.2e-8 and 2.4e-8 rel (1e-5); stage temperatures 6.8e-7 K (1e-4); K-values at IDAES’s own (T, P, x, y), 1.4e-13 rel (1e-6)

C3/C4 splitter: C2 to nC4 with propylene, 17 bar, 20 trays, R = 5, boilup 3

state block: a TP flash of each of difflow’s 21 stage states (z, T, P), from IDAES’s own initialization

K 7.1e-7 rel (1e-5); vapour fraction 3.3e-5 (1e-4); phase compositions 1.1e-6 (1e-5); phase enthalpies 2e-9 J/mol (1e-3)

Two things the reference had to work around, both on the IDAES side:

  • SmoothVLE’s smoothing. At IDAES’s default smoothing parameters the bubble-point condenser outlet is left 4e-4 vapour. The total condenser’s ports carry the liquid composition at the total flow, so the condenser loses components while the total balances: 0.07 % of the propane, and a 0.7 % gap in the products. The generator tightens the parameters by continuation to eps_2 = 1e-9. The gap then falls to the 1e-7 in the table, which is the evidence it was all smoothing.

  • The splitter’s column. IDAES’s TrayColumn was not converged on the C3/C4 splitter. Its initialization fails at the “column section + condenser” step for every variant tried: with and without ethane and propylene; 10 to 17 bar; 10 to 20 trays; reflux/boilup from 2/2 to 5/3. Starting every state block from difflow’s profile did not help either. At the case’s ratios IPOPT ends infeasible. At 4.0/2.5 it reports optimal on a spurious solution with two trays single phase (x = y on SmoothVLE’s branch). A reference seeded from difflow’s answer would not have been independent of it anyway. So the issue’s 1 % column-level check is met for the debutanizer only. For the splitter the comparison stops at the thermodynamics. The release test checks that difflow’s column still puts its stages at the recorded states.

Regenerate (needs IDAES and IPOPT; --case NAME redoes one case):

PYTHONPATH=src:tests python -m refinery.reference.gasplant_generate

What this does not validate. It does not test how well PR with zero kij describes these mixtures. That is the propylene/propane split above all, where the relative volatility is near 1.1 and a small kij moves the trays needed. It does not test the O’Connell efficiency, the GPA 2140 limits, the RVP construction against measured RVPs, or the compressor. Those are tested against their definitions in test_gasplant.py, not against plant data. DWSIM 9.0.5 has since given the C3/C4 splitter its column-level check, and checked the compressor train and the TVP/RVP implementation (same model), and what DWSIM’s own kij do to the splits: Validation against DWSIM: light ends, HP separator and gas plant.

Validation: the isomerization unit#

The reactor’s thermochemistry is checked against IDAES 2.10’s GibbsReactor (tests/refinery/reference/isom_generate.py writes isom_reference.json). IDAES minimises the total Gibbs energy subject to element balances, on an ideal-gas modular property package given difflow’s heats of formation, entropies and Cp cubics. As for the other units, this is an independent implementation, not an independent model. It checks how the free energies are assembled, the equilibrium-constant convention (bar against a 1 bar standard state), the hydrogen-pressure dependence, the reactor’s energy balance, and that the rate law relaxes onto the equilibrium it claims. It does not check the constants: both sides are given the same ones.

A Gibbs minimiser given only C and H would turn pentanes into hexanes and butanes, which no reaction here does. So each conserved carbon skeleton gets its own element label (C5, C6, and one per species the network holds fixed). The minimisation is then over exactly the reactor’s reaction space.

Case

difflow side

Agreement (test tolerance)

Each isomer family alone (C5, C6 paraffins, C6 naphthenes), 400-550 K

closed form family_equilibrium

6.8e-13 (1e-10), per commit

C6 ring: H2, benzene, MCP, CH, n-hexane at 30 bar, 420 and 480 K

the reactor, isothermal, rate constants x 1e4, no cracking

mole fractions 1.4e-15 (1e-10)

Adiabatic: both feeds’ reactor charge, 140 C, 30 bar

the reactor, adiabatic, rate constants x 10, LHSV 0.1, no cracking

outlet T 477.023 K (paraffinic) and 522.225 K (benzene-rich), within 1e-6 K; mole fractions 6.9e-14 (1e-10)

The reference is stale since #339. The table above was measured on the isomerization constants before #339 moved them onto the shared thermochemistry table. IDAES and IPOPT could not be installed where #339 was made (no IPOPT binary was reachable), so isom_reference.json has not been regenerated. The checks that compare its answers with difflow’s current constants are marked xfail(strict=True) until it is (STALE_SINCE_339 in test_isomerization_validation_file.py). Once it is regenerated they pass, and the strict marker fails the suite until the marker is removed. Meanwhile the same ground is covered without IDAES:

  • DWSIM 9.0.5’s reactors were run on the new constants (below).

  • difflow’s adiabatic reactor lands on the emulated equilibrium temperature (473.183 and 519.383 K) to 1e-6 K (test_the_reactor_reaches_the_emulated_adiabatic_equilibrium).

test_isomerization_validation.py (release) runs difflow against the file. test_isomerization_validation_file.py runs on every commit. It checks that the file is intact and that the constants and feeds are the ones it was built on. It also checks that the IDAES answers conserve atoms, close difflow’s own enthalpy balance, and match the closed-form families.

Regenerate (needs IDAES and IPOPT):

PYTHONPATH=src:tests python -m refinery.reference.isom_generate

The rest is checked against difflow itself, in test_isomerization.py, test_isomerization_dih.py and test_isomerization_dih_gradients.py:

  • The total mass and per-carbon-number balances close to 1e-8 or better, once-through and with the DIH, on both feeds. The reactor alone closes to 1e-12.

  • The implicit gradients of RON, MON, volume yield, H2 consumption and gas make (once-through), and of RON, MON, yield, DIH duty and H2 make-up (DIH), with respect to T_in, LHSV and x_nC6, match central differences to 1e-5 relative. With the DIH, the worst entry is 4.4e-6 on the benzene-rich feed; the test runs the paraffinic one.

  • At equilibrium, the 2,2-DMB and isopentane shares fall with temperature. RON has an interior maximum in T_in on both feeds. The DIH raises RON on both feeds.

Published case study: none found. No published isomerization case was found that gives a feed analysis, catalyst, conditions and product analysis complete enough to set up and reproduce here. The search was not exhaustive. So the unit’s absolute octanes and yields are not validated against any plant or published simulation.

What this does not validate. It does not test the thermochemical constants against a tabulated free-energy set. It does not test the species octanes, which are recalled values marked verify. It does not test the rate constants, which are illustrative, or the constructed feeds, which are assumed.

Validation against DWSIM: setup#

DWSIM 9.0.5, an open-source process simulator, is the second reference simulator for the refinery units, beside IDAES. It is a .NET 8 application; it is driven from Python through pythonnet by the harness tests/refinery/reference/dwsim_session.py. As with IDAES, DWSIM runs only in a generator (tests/refinery/reference/dwsim_*_generate.py), which writes a JSON reference; the tests read the JSON and need neither DWSIM nor .NET.

Install. scripts/install_dwsim.sh [DIR] reproduces the setup: the DWSIM 9.0.5 .deb from SourceForge (GitHub release downloads are blocked from the build box), unpacked with dpkg-deb -x (no package install), apt-get install dotnet-runtime-8.0, pip install pythonnet, and a smoke load. It prints the DWSIM_PATH to export (the directory holding DWSIM.Automation.dll). pythonnet must load CoreCLR, not Mono (its Linux default), and only one CLR can be loaded per process, which is one more reason DWSIM stays out of the pytest process.

Generating a reference.

export DWSIM_PATH=.../usr/local/lib/dwsim
PYTHONPATH=src:tests python -m refinery.reference.dwsim_smoke_generate

The harness in brief (its module docstring has the full API):

Call

Returns

DWSIMSession(path=None)

one DWSIM Automation3 per process; .provenance() records DWSIM, .NET and pythonnet versions

.flowsheet(package, compounds, hypos, overrides, kij="zero", options, flash_tol=1e-10)

a DWSIMFlowsheet with one stream on the named property package

DWSIMFlowsheet.flash_tp/flash_ph/flash_pvf/flash_tvf

phase fractions, compositions, K, fugacity coefficients, molar enthalpies (J/mol), densities, Z, and equilibrium_residual

HypoCompound(name, MW, Tc, Pc, omega, cp_ig=..., Tb=..., hvap_tb=...)

a hypothetical compound on given constants; HypoCompound.from_gas_components takes difflow’s

.petroleum_characterization(tbp_K, cum_frac, sg_bulk, ...)

DWSIM’s own distillation-curve characterization, its pseudo-component table

.bulk_characterization(mw, sg, ...)

DWSIM’s bulk C7+ characterization

difflow.dwsim_import.DWSIM_NAMES / dwsim_name()

difflow’s database names to DWSIM’s (all 50 refinery species, checked to exist)

What is compared like for like. A comparison says which of two things it is:

  • (a) Same model, same constants: every component a DWSIM hypothetical compound carrying difflow’s MW, Tc, Pc, omega and ideal-gas Cp cubic; DWSIM’s binary parameters removed (kij="zero"); the package options that are not the textbook model switched off (EOS_ONLY for the cubics: liquid density from the EOS instead of Rackett with Peneloux; IDEAL_RAOULT for Raoult’s law: no Poynting factor, no Henry’s law). This tests the implementation.

  • (b) DWSIM’s own data and correlations: DWSIM’s database compounds (by DWSIM_NAMES), its kij, its petroleum characterization. This tests the model and the data, and differences there are expected and reported, not tuned away.

What DWSIM fills in for a hypothetical compound (read from DWSIM’s compiled code, PropertyPackage.AUX_CPi, AUX_PVAPi, AUX_HVAPi): the ideal-gas Cp is the given polynomial exactly (OriginalDB = "DWSIM", A..E in J/mol/K); the vapour pressure is the DIPPR-101 form, which the harness fills with the Lee-Kesler correlation on the hypo’s Tc, Pc, omega written exactly in that form unless one is given (a cubic EOS uses it only to start the flash; Raoult’s law uses it as the model); the heat of vaporization is Watson’s Hvap(Tb) ((1-Tr)/(1-Tbr))^0.375, Hvap(Tb) given or Vetere’s estimate (DWSIM’s own fallback; with neither DWSIM returns zero); the Rackett parameter defaults to Pitzer’s Zc. DWSIM’s own petroleum fractions are different objects: Cp from the Lee-Kesler correlation on Watson K, Psat from Lee-Kesler.

What DWSIM 9.0.5 offers. Property packages: Peng-Robinson (PR), PR 1978, PRSV2, Soave-Redlich-Kwong, Lee-Kesler-Plöcker, Grayson-Streed, Chao-Seader, Raoult’s Law, NRTL, UNIQUAC, UNIFAC variants, Wilson, PC-SAFT, GERG-2008, CoolProp, IAPWS-IF97, Black Oil and others. Its “PR” is the 1976 form for every omega (no 1978 branch), as difflow’s gas-plant PR. Its “PR78” switches to the 1978 kappa above omega 0.491, as difflow’s hydroprocessing PR does, but writes the 1976 branch’s 1.54226 as 1.5422 (see the HP separator).

The smoke test (dwsim_smoke_generate.py, test_dwsim_smoke.py) proves the pattern end to end on the gas plant’s PR (difflow_refinery.gasplant): a light-ends mixture (H2, H2S, C1 to nC5) flashed at four (T, P) points from 240 K/30 bar to 330 K/35 bar, and a five-cut naphtha (Tb 345 to 465 K, Twu constants) with propane and n-butane at two points, all on difflow’s constants with kij = 0 (comparison (a)). Four differences between the two implementations turned up, each found in DWSIM’s code and then reproduced in the test rather than absorbed into a tolerance:

Difference

Size

Reproduced, the two agree to

DWSIM’s PR uses R = 8.314 (difflow 8.314462618); Z, phi and K do not depend on R, the departure enthalpy and density do

5.6e-5 relative: up to 2.0 J/mol in a phase enthalpy, 5.6e-5 in density

density 1e-12 relative

DWSIM’s fugacity routine writes the log term’s 1 ± sqrt 2 and 2 sqrt 2 as 2.414213, -0.414213, 2.828426

1.4e-6 in a liquid phi

phi 2.2e-12 relative (vapour and liquid); Z 1e-12

DWSIM integrates Cp by the midpoint rule, round(dT/10) intervals clipped to 10..100

up to 0.23 J/mol (1e-5 of the ideal-gas enthalpy, naphtha at 410 K)

phase enthalpies 2.3e-8 J/mol

DWSIM’s own flash stops short of its equilibrium condition, at loop tolerances of 1e-10

max abs(ln(y/x) - ln(phi_L/phi_V)) up to 1.6e-5 (light ends, 280 K)

not reproducible: it bounds the flash comparison

With all that, difflow’s own TP flash lands on DWSIM’s to: vapour fraction 6.5e-6 and phase compositions 1.9e-6 (light ends at 280 K, the point where DWSIM’s own residual is 1.6e-5); 3.4e-7 and 1.9e-7 on the naphtha. DWSIM’s PH flash from a TP point’s enthalpy returns to its temperature within 1e-6 K. Nothing is left unexplained.

DWSIM’s database against difflow’s (comparison (b) for the constants; tests/test_dwsim_import.py, release, runs the importer against the real DWSIM in a subprocess). All 50 mapped species are in DWSIM’s ChemSep database. Tc agrees to 0.31 %, MW to 0.02 %, Pc to 2.0 % (trans-2-butene) and omega to 0.012 (methylcyclopentane), except three known differences, kept visible in the test: 2-methyl-2-butene (DWSIM Pc 3.86 MPa against difflow’s 3.42, omega 0.339 against 0.285) and carbon monoxide (omega 0.045 against 0.066).

DWSIM behaviours to know before comparing (more in the harness docstring):

  • The default flash tolerance is 1e-4; the harness sets 1e-10, but check equilibrium_residual and hold comparisons to a few times it.

  • The default liquid density is Rackett + experimental data with Peneloux translation, not the EOS; “Raoult’s Law” has a Poynting factor and Henry’s law switched on; Lee-Kesler-Plöcker’s kij is multiplicative (a missing one is taken as 1, no interaction). All options in force are recorded in each reference’s provenance.

  • DWSIM’s PR carries its own kij (H2/propane -0.131, CO2/H2S 0.098, …).

  • DWSIM’s own distillation-curve characterization (its UI’s “Petroleum Characterization from Distillation Curves”, run headless) reads its bulk-SG field as API gravity (SG = 141.5/(131.5 + value)), so an SG typed into the UI targets an SG near 1.07; the harness passes the API equivalent so the target is the SG given. It scales the cut SGs to the bulk SG by mass-fraction average. A petroleum fraction’s heat of formation comes out on a basis 1000x off DWSIM’s database compounds’.

  • The compound list (AvailableCompounds) is shared by every flowsheet in the process: overrides= edit a per-flowsheet copy; prefix hypo names.

  • Start-up is about 4 s, a flash 2 to 5 ms after the first (0.2 s).

Fixed alongside: difflow.dwsim_import, a prototype written to DWSIM’s documented API and never run, now works against DWSIM 9.0.5: the “standalone” thermodynamics calculator is inside DWSIM.Thermodynamics.dll since DWSIM 6, pythonnet has to be pointed at CoreCLR, and the path is checked before any .NET runtime starts.

Validation against DWSIM: reaction thermochemistry#

DWSIM has no hydrotreater, FCC, reformer or alkylation kinetic model, so what is compared is the physics those units rest on: heats of formation and Gibbs energies, heats of reaction, equilibria and energy balances. DWSIM 9.0.5’s equilibrium, Gibbs and conversion reactors do the DWSIM side (tests/refinery/reference/dwsim_reactions_generate.py writes dwsim_reactions_reference.json; dwsim_reactors.py is the reactor half of the harness, dwsim_reactions_case.py the cases; the test is tests/refinery/test_dwsim_reactions.py). Every case is run two ways:

  • (a) hypo: DWSIM’s reactors on hypothetical compounds carrying difflow’s own H_f, S (entered as G_f), Cp cubic and critical constants. This tests the implementation.

  • (b) dwsim: DWSIM’s database compounds (ChemSep; tetralin from ChEDL Thermo), its formation data and Cp. This tests the data.

The equilibria are ideal-gas on both sides: difflow’s isomerization, reformer and hydrotreating equilibria are ideal-gas, and DWSIM’s Raoult’s-law package is made one by multiplying every vapour pressure by e^40, so that nothing condenses. The reformer bed and the alkylation heats use Peng-Robinson.

How DWSIM computes it (read from its compiled code, then reproduced in tests/refinery/_dwsim_rx_emulation.py):

  • Both DWSIM reactors take a compound’s formation Gibbs energy at T from PropertyPackage.AUX_DELGF_T. This is the Gibbs-Helmholtz integral from the database G_f and H_f at 25 C with the compound’s own Cp, the same route as difflow’s H - T S. Two details differ from difflow: the Cp integrals are midpoint-rule quadratures (as in the harness’s enthalpies), and R = 8.314. For the hypos the emulation reproduces DWSIM’s int Cp dT, int Cp/T dT and G_f(T) to 9e-11 J/mol.

  • DWSIM’s reactors divide pressures by P0 = 101325 Pa; difflow’s equilibrium constants are on 1 bar. For a reaction that changes the moles of gas by dn, K moves by (1.01325)^dn: 4 % for Bz + 3 H2 = CH. Which pressure ChemSep’s G_f refer to is not stated in DWSIM. On one standard state, DWSIM’s database and difflow’s reformer data agree on that ln K to 0.02.

  • The conversion reactor applies conversions as percentages of the base compound present when its rank runs. Its energy balance is a state function, so any reaction set that reaches the same outlet gives the same outlet temperature.

DWSIM’s reactors are checked before they are believed. An equilibrium answer counts only if it is the ideal-gas equilibrium of DWSIM’s own numbers, to 3e-6 in mole fraction. That means DWSIM’s tabulated G_f(T) for database compounds, and difflow’s constants under DWSIM’s conventions for hypos. Accepted answers sit at 2.1e-6 or better, most at 1e-8. Of 108 reactor runs over 54 isothermal cases, 24 do not pass. All of them are pinned in the test, and every case but one (the C6 ring at 480 K on difflow’s constants) still has a DWSIM answer that does pass:

  • The equilibrium reactor stops with “Solution led to negative mole fractions” on the isomerization reactor’s eight-reaction network (C6 ring, full charge) and on benzene at 300 C and 100 bar, and it fails in adiabatic mode (a flash error). On the constants before #339 it also silently converged wrong on the C6 paraffins at 400 K (6.3e-2 off); on the shared table’s it does not. Where it converges, it reproduces the emulation to 1e-11.

  • The Gibbs reactor (DWSIM’s own minimiser; IPOPT is its default, but libIpopt39 is not in the Linux package and the process aborts) can leave a minor species at zero or at its trace start without an error: MCH at 773 K, 2-methylhexane, benzene, naphthalene, and cyclohexane in the C6 ring at 480 K on difflow’s constants (2e-11 against 7e-6, the paraffins 7.7e-4 off; that case has no DWSIM answer); from 5e-6 to 1e-2 off. With inert species and the isomerization skeleton labels (below), it stops short of equilibrium in adiabatic mode (0.1 K on the benzene-rich charge).

  • A first solve raises “invalid initial estimates” unless InitializeFromPreviousSolution is off; the harness sets it.

(a) Implementation: difflow’s constants in DWSIM’s reactors.

Case

difflow

DWSIM (hypo)

Difference, and why

Isomer families (C5, C6 paraffins, C6 naphthenes), 400-550 K

closed form

equilibrium reactor

1.4e-5 mole fraction: the midpoint-rule Cp integrals. The emulation under DWSIM’s conventions gives 4e-8

C6 ring (H2, Bz, MCP, CH, C6 paraffins), 420 K, 30 bar

the reactor

Gibbs

5.0e-6: 1 atm, R, quadrature (emulation: 1.9e-6). At 480 K neither DWSIM reactor gives its own equilibrium (above)

Isomerization adiabatic, both charges, 140 C, 30 bar

473.183 / 519.383 K

473.183 / 519.466 K (Gibbs)

-0.3 mK / +0.083 K: DWSIM’s minimiser with inerts, short of its own equilibrium (emulated DWSIM model: 473.183 / 519.359 K). difflow’s reactor lands on the emulated difflow temperature to 1e-6 K (test_the_reactor_reaches_the_emulated_adiabatic_equilibrium, while the IDAES reference is stale)

Reformer equilibria (MCH/toluene, MCP/CH/Bz, nC7 dehydrocyclization), 700-773 K, 10-25 bar

ideal-gas K from Gibbs energies

equilibrium reactor

up to 1.1e-3 mole fraction, nearly all the 1 atm standard state (emulation: 1e-11)

Benzene and naphthalene saturation, 300-420 C, 30-100 bar (Cp-integrated since #338; the hypos carry the table’s Cp)

aromatic_ln_K (ideal-gas equilibrium of the same constants)

equilibrium reactor

up to 5.4e-4 (1 atm and the quadrature; benzene at 420 C, 30 bar)

First reformer bed, rich naphtha, 773.15 K in, 15 bar: the conversion reactor (PR, kij 0) taken to difflow’s outlet

710.6503 K (dT -62.4997 K)

710.6506 K

0.3 mK: DWSIM’s R in the PR departure and its quadrature

FCC coke burn (C + H2, flue at 2 % O2), 25 C and 700/730 C

combustion + flue_enthalpy

conversion reactor

reproduced to 1 W in 21-39 MW from the per-species data differences alone

The reformer bed’s composition change is handed to DWSIM as sequential conversion reactions through methane (CxHy + (2x - y/2) H2 = x CH4 for every species consumed, the reverse for every species made). DWSIM’s outlet then reproduces difflow’s to 2e-16 in mole flow. The energy balance does not depend on the reaction path.

(b) Data: DWSIM’s database against difflow’s tables. All of these are pinned at their measured size.

Quantity

difflow

DWSIM

Cause

iC5 share of the C5s at equilibrium, 450 K

0.772

0.762

dH(nC5 = iC5): -6.99 kJ/mol (API TDB, the shared table) against ChemSep’s -6.94. Before #339 the isomerization module used Prosen & Rossini’s -8.10 and gave 0.820

C6 paraffin and naphthene shares, 400-550 K

up to 0.0067 (C6P; 0.067 before #339) and 0.048 (C6N): MCP = CH dH -16.4 against ChemSep’s -17.1 kJ/mol

Adiabatic isomerization outlet, paraffinic / benzene-rich charge

473.18 / 519.38 K

473.19 / 519.58 K

0.005 and 0.19 K, DWSIM’s own convergence (up to 0.11 K) included. Before #339 difflow was 3.83 and 2.65 K hotter (Prosen & Rossini’s larger heats of isomerization)

Reformer reactions, 14 of them

dH(298 K) within 0.7 kJ/mol (MCP = CH the largest); dH(773 K) within 1.4 kJ/mol; ln K(773 K) within 0.20 on one standard state (nP8 = A8 + 4 H2)

Reformer equilibria, 700-773 K, 10-25 bar

within 3.1e-3 mole fraction (MCP/CH split at 700 K)

First reformer bed outlet

710.65 K

711.16 K

the bed 0.8 % less endothermic on ChemSep H_f and Cp

HDS of benzothiophene, per mol H2

-52.3 kJ/mol

-42.6 kJ/mol

benzothiophene H_f: 166.3 kJ/mol (difflow) against ChemSep’s 137.0. 166.3 is the calorimetric value: Sabbah (1979) 166.28 ± 0.48 and Good (1972) 166.6 kJ/mol, as listed by the NIST WebBook (read through a search summary; see Thermochemical data). ChemSep’s matches no measurement found

Other hydroprocessing heats per mol H2 at 25 C (sulfide and thiophene HDS, Bz and naphthalene saturation, 1-hexene, nC6 cracking)

within 1.9 kJ/mol (1-hexene saturation the largest)

The same heats at 350 C (DWSIM’s conversion reactor)

irreversible reactions: 298 K values, by design; aromatics: at T

3-15 % more heat than at 298 K

the reactions’ dCp. difflow’s irreversible per-class heats neglect it (documented); its aromatics heats carry it since #338 and agree within 0.3 kJ/mol (benzene -219.89 against -220.17, naphthalene -132.12 against -132.33)

Benzene + 3 H2 = cyclohexane, ln K, 300 / 350 / 420 C

Cp-integrated (aromatic_ln_K, #338)

with Cp

0.005, 0.009, 0.016 above DWSIM on one standard state; equal to difflow’s reformer thermochemistry (one table). Until #338 difflow’s hydrotreating K held dH and dS constant and was 2.9x, 3.6x, 4.9x too large (ln K 1.05, 1.29, 1.60 high; 1.12, 1.34, 1.62 on the table’s constants)

Benzene / cyclohexane equilibria, 300-420 C, 30-100 bar

the hydrotreater’s constants

DWSIM’s data, accepted reactor

within 2.1e-4 mole fraction (420 C, 30 bar, 40 % of the benzene left); 5e-6 or less at 300-350 C

Naphthalene + 2 H2 = tetralin

Cp-integrated

not computable

DWSIM’s tetralin (ChEDL Thermo) has G_f = 0: ln K about 60, every naphthalene saturated. This step and the poly step (DWSIM has no tetrahydrophenanthrene) are checked as an implementation instead: DWSIM’s reactors on difflow’s constants (table (a)), and aromatic_ln_K against tc.ln_K in test_hydrotreating.py

Liquid heat of alkylation, 25 C, 7 single-product reactions and difflow’s route A for 7 olefins

H_f(g) - CRC Hvap

Peng-Robinson liquid, ChemSep H_f

DWSIM 1.2-5.9 kJ/mol less exothermic (e.g. iC4 + 1-butene to 2,2,4-TMP: -84.8 against -82.7 kJ/mol). Gas-phase H_f account for up to 2.2 kJ/mol (propylene route; 3.7 before #339 put these species on the shared table); the rest is PR’s liquid departure against the CRC heats of vaporisation. At 10 C DWSIM gives 0.6-1.0 kJ/mol less again; difflow neglects the temperature

Heat of coke combustion (7 wt% H), 25 C

1.1e-5 (water’s H_f, -241.826 against -241.814 kJ/mol)

Coke burn to a 700/730 C flue

DWSIM releases 0.007-0.009 % more: difflow’s JANAF Cp fits against ChemSep’s, all of it, to 1 W (0.05-0.07 % with the RPP cubics fcc.species had before #339)

What DWSIM 9.0.5 cannot check. It has no dibenzothiophene, cyclohexylbenzene, quinoline, carbazole or tetrahydrophenanthrene, so it cannot check the DBT and 4,6-DMDBT HDS heats, either HDN heat, or the poly-aromatic step. It has no catalyst. The FCC heat balance’s catalyst term, the heat of cracking and the regenerator adiabatic temperature are therefore not compared; a coke-and-air adiabatic flame is above 2000 K, past both sides’ Cp fits. DWSIM’s own “Graphite” (its “User” table) has a constant vapour pressure that cannot be lifted, so coke carbon is a hypothetical compound with H_f = 0 (it enters at 25 C and burns completely, so nothing else about it matters).

Unexplained differences: none. Every (a) difference is reproduced by the emulation of DWSIM’s conventions, or is DWSIM’s own convergence, measured against its own model. Every (b) difference traces to a formation enthalpy, entropy or Cp in one of the two databases.

Since #339 the reference was regenerated on the shared thermochemistry table (isomerization, alkylation and FCC regenerator constants moved; the reformer’s and the hydrotreater’s did not), and again for #338 (the hydrotreater’s model compounds moved onto the table, and its aromatics hypos carry their Cp). The isomerization and adiabatic gaps to ChemSep closed by an order of magnitude, because ChemSep uses the same API TDB paraffin values.

Regenerate (DWSIM 9.0.5; about 15 minutes):

PYTHONPATH=src:tests python -m refinery.reference.dwsim_reactions_generate

Validation against DWSIM: light ends, HP separator and gas plant#

Two references, built on the harness above. Each says which comparison it is: (a) same model and constants (DWSIM hypothetical compounds on difflow’s constants and kij, liquid density from the EOS), which tests the implementation; (b) DWSIM’s own compounds and kij, which tests the model.

  • tests/refinery/reference/dwsim_gasplant_generate.py writes dwsim_gasplant_reference.json. It holds three rigorous columns, the wet-gas compressor train and two vapour pressures; the cases are in dwsim_gasplant_case.py.

  • tests/refinery/reference/dwsim_hps_generate.py writes dwsim_hps_reference.json. It holds a solved hydrotreater’s reactor effluent flashed at HP-separator conditions; the case is in dwsim_hps_case.py.

  • tests/refinery/reference/dwsim_lightends.py holds the DWSIM unit operations (column, compressor/cooler/knock-out train, RVP) that the generators use. It adds to the harness and does not change it.

test_dwsim_gasplant.py and test_dwsim_hps.py hold the comparisons (release), plus per-commit checks that the files are intact and still describe difflow’s constants and cases.

PYTHONPATH=src:tests python -m refinery.reference.dwsim_gasplant_generate
PYTHONPATH=src:tests python -m refinery.reference.dwsim_hps_generate   # solves the hydrotreater first (~2 min)

Columns (comparison (a))#

DWSIM’s DistillationColumn uses the same layout as the IDAES reference. That is a total condenser at the bubble point, equilibrium trays, a kettle reboiler and no pressure drop. The reflux and boilup ratios are specified, PR with kij = 0, and every component is a hypo on difflow’s constants. The C3/C4 splitter converges in DWSIM (Wang-Henke, 2 s), so the column-level check that IDAES’s TrayColumn could not give (see the gas plant’s validation) is made here. The naphtha splitter adds four Twu pseudo-components (NBP 375 to 465 K) to C4 to C6.

Case

DWSIM solver

Stage T

L, V profiles

Phase x, y

Products

Duties (plain)

Duties, DWSIM’s enthalpy reproduced

Debutanizer: C3 to nC5, 10 bar, 10 trays, R = 2, boilup 2

Naphtali-Sandholm

5.8e-5 K

3.7e-6 rel

4.2e-7

5.9e-6 rel

5.2e-5, 5.1e-5 rel

7e-16

C3/C4 splitter: C2 to nC4 with propylene, 17 bar, 20 trays, R = 5, boilup 3

Wang-Henke

3.5e-5 K

2.1e-6 rel

2.1e-7

2.1e-6 rel

5.5e-5, 5.1e-5 rel

3e-14

Naphtha splitter: nC4 to nC6 and four cuts, 2.5 bar, 20 trays, R = 1.5, boilup 1.2

Wang-Henke

2.0e-4 K

1.3e-5 rel

6.2e-6

4.2e-5 rel

5.1e-5, 4.7e-5 rel

9e-14

The K-values at DWSIM’s own stage states agree to 1.2e-6 (DWSIM’s truncated sqrt 2, as in the smoke test). Three things set the size of the rest:

  • DWSIM’s column tolerance, not difflow. The loop tolerance is 1e-9. Wang-Henke closes the component balances only to about 1e-8 (7.8e-9 on the naphtha splitter), and that is the 2e-4 K and 1e-5 level of the naphtha splitter. On the debutanizer, Naphtali-Sandholm closes them to 5e-16. There DWSIM agrees with IDAES’s column to 6e-5 K, and with difflow to 5.8e-5 K.

  • The duties are 5e-5 apart, and all of it is understood. DWSIM’s PR uses R = 8.314, and its ideal-gas enthalpy is a midpoint-rule integral (both found by the smoke test). DWSIM’s condenser and reboiler duties were recomputed from DWSIM’s own stage flows, temperatures and compositions, with difflow’s PR plus those two reproduced. They match DWSIM’s to 1e-13. With difflow’s exact enthalpy the 5e-5 comes back (test_dwsims_duties_are_its_own_enthalpies).

  • DWSIM’s solvers. SolvingMethodName is matched by substring: “Bubble” is Wang-Henke, “Napthali” (sic) is Naphtali-Sandholm, “Rates” is Burningham-Otto.

    • Wang-Henke converges the debutanizer, but DWSIM’s own post-solve component-balance check then fails it. The check is at 10 * min(loop tolerances), and the balance came to 1.8e-8 against 1e-8.

    • Naphtali-Sandholm had not finished the C3/C4 splitter or the naphtha splitter after four minutes; Wang-Henke takes 2 to 3 s. The reference records which solver ran each case.

    • Two other things were read from the IL. After a solve, Stage.Lout/Vout/Kvalues are empty; the profiles are the column’s Tf, Lf, Vf, xf, yf and Kf. A feed goes whole, both phases, to the stage it is connected to.

Comparison (b), the same columns on DWSIM’s database and kij. DWSIM carries kij for most hydrocarbon pairs, and difflow uses zero for them. On the debutanizer DWSIM’s kij (propane/n-pentane 0.027, isopentane/n-pentane 0.060, n-butane/n-pentane 0.017) put more C5 overhead. The isopentane in the distillate goes from 3.2 % to 5.3 %, the distillate rate rises 4.1 %, the condenser duty rises 6.1 % and the reboiler duty falls 8.3 %. On the C3/C4 splitter (propylene/propane 0.0096, propylene/isobutane -0.014, and DWSIM’s propylene Tc 364.85 K against 365.6 K) the distillate’s propylene rises 1.3 points and its isobutane falls from 850 to 330 ppm, with the duties within 0.2 %. These are model differences, set by binary parameters difflow leaves at zero. They are reported, not tuned to.

Compressor (comparison (a), and the bug it found)#

The case is a wet gas (H2, H2S, C1 to nC6, 100 mol/s) at 1.6 bar and 40 °C. It goes through an inlet knock-out, then two stages to 14 bar (75 % isentropic), each cooled to 40 °C and knocked out. DWSIM builds it from Compressor (adiabatic, outlet pressure), Cooler and Vessel, on difflow’s constants and difflow’s kij (H2S with C1 to C3).

Quantity

DWSIM

difflow

Agreement

Stage 1 power / discharge T

389.33 kW / 365.227 K

389.35 kW / 365.230 K

5.1e-5 rel / 2.7 mK

Stage 2 power / discharge T

349.58 kW / 369.567 K

349.60 kW / 369.570 K

4.9e-5 rel / 2.8 mK

Gas out / condensate (component flows)

66.72 / 33.28 mol/s

same

1.9e-6 / 3.9e-7 rel

The power and temperature gaps are again R and the midpoint rule. Entropy follows the same pattern: DWSIM’s mixture entropy is difflow’s plus a constant 34.12 J/mol/K (another reference state), so isentropic paths agree.

This comparison found a bug in GasCompressor, now fixed. Its temperature solve (gasplant.units._solve_T, used for the isentropic and actual discharge temperatures) took Newton steps with the slope jax.grad(stop_gradient(fn)), which is identically zero. Every step divided by zero, the clip bounced the temperature between half and twice the guess, and the answer was the one final Newton step from wherever the bounce ended.

  • On this case that step landed 1.27 K below the isentropic temperature, with the first stage 3.6 % low in power (375.5 kW against 389.3 kW) and 1.5 K cold.

  • The existing release test against difflow’s own EOS compressor (rel=5e-3, abs=0.5 K, dry gas, one stage) had not caught it.

  • The slope is now stop_gradient(grad(fn)), and test_gasplant.py::test_solve_T_converges checks convergence and the implicit derivative.

  • Any gas-plant compressor result computed before this fix moves. That includes example 40’s wet-gas compressor; the stored notebook outputs were not re-run.

On DWSIM’s compounds and kij (b), difflow’s total power is 0.40 % lower and its condensate 0.86 % higher than DWSIM’s.

Vapour pressure#

Liquid

TVP at 100 °F, DWSIM / difflow

D323 RVP, difflow’s construction on DWSIM / difflow

DWSIM’s own “RVP”

(b) TVP, D323 on DWSIM’s data

Debutanizer bottoms (C3 to C5)

168.37 kPa, 8.6e-7 rel

166.60 kPa, 8.6e-7 rel

111.07 kPa (-33 %)

+9.8 %, +10.2 %

Stabilized naphtha (C4 to C6 + 3 cuts)

51.82 kPa, 1.1e-6 rel

50.77 kPa, 1.1e-6 rel

41.76 kPa (-18 %)

(pseudo-components: not run)

The D323 construction (vapour space four times the liquid’s volume at 100 °F) was rebuilt on DWSIM’s flashes: T-VF flashes, with DWSIM’s PR liquid root at 1 bar for the liquid volume. It agrees with difflow’s to 1e-6.

DWSIM’s own RVP is not a reference. It lives only in the classic Windows UI’s “Petroleum Cold Flow Properties” utility (DWSIM.FrmColdProperties.Update1 in DWSIM.exe). That utility takes the stream package’s bubble pressure at 310.95 K and returns RVP = 6894.76 * 10**((ln(TVP/6894.76) + 12.9728 - 12.82)/2.7738). This is a correlation on the TVP that mixes a natural log with a power of ten, and it equals the TVP only at 2.1 psi. Above that it puts the RVP of any liquid below its TVP: 33 % low on the debutanizer bottoms, and so on, even for a pure component, whose RVP must equal its vapour pressure. difflow’s D323 construction stays. The TVP agrees, and the correlation is recorded so the difference is visible.

HP separator (the hydrotreater)#

The feed is the reactor effluent of the diesel hydrotreater of test_hydrotreating.py (crude A, 230 to 370 °C, 50 kg/s, defaults), solved and frozen. It is 71 % H2, 1.8 % H2S, 0.07 % NH3, 3.8 % C1, traces of C2 to C4, and 22.8 % treated cuts (omega 0.23 to 0.79). It is flashed with pr_flash at the unit’s separator (50 °C, 47 bar) and at the corners of 40 to 60 °C by 30 to 60 bar.

(a) Same model. DWSIM’s “Peng-Robinson 1978 (PR78)” switches kappa at omega 0.491 as difflow’s hydroprocessing PR does. DWSIM’s plain “PR” uses the 1976 kappa for every omega. DWSIM’s PR78 code (ThermoPlugs.PR78) writes the 1976 branch’s 1.54226 as 1.5422. Reproduced, the fugacity coefficients at DWSIM’s phase compositions agree to 3e-14. Plain, the truncation is worth 1.7e-4 in the liquid phi of the cut just below the switch. difflow’s flash lands on DWSIM’s to 4.7e-7 in vapour fraction and 1.7e-6 in the liquid composition. DWSIM’s own equilibrium residual is up to 2.6e-7.

What dissolves in the separator liquid (#333): the fraction of each gas fed that leaves in the liquid, from DWSIM.

T, P

H2

H2S

NH3

CH4

x_H2 in the liquid

50 °C, 47 bar (the unit)

1.53 %

27.7 %

36.6 %

6.0 %

0.044

40 °C, 30 bar

0.94 %

22.5 %

31.3 %

4.2 %

0.028

40 °C, 60 bar

1.92 %

35.5 %

46.2 %

7.9 %

0.054

60 °C, 30 bar

0.99 %

18.0 %

24.3 %

3.7 %

0.029

60 °C, 60 bar

2.02 %

29.6 %

38.0 %

7.2 %

0.057

difflow’s pr_flash matches these to 3.4e-5 relative (H2) and 6e-6 (the others). At the unit’s conditions that is the 10.6 mol/s of H2 quoted above.

(b) What the model choices are worth, as changes in these fractions:

Change

H2

H2S

NH3

CH4

1976 kappa for every omega (DWSIM “PR”) instead of the 1978 branch

+1.5 %

0 to +0.1 %

-0.1 to -0.3 %

+1.0 %

DWSIM’s gas constants (H2S omega 0.094 vs 0.09, NH3 0.256 vs 0.253)

0

+0.1 to +0.2 %

+0.1 %

0

DWSIM’s kij instead of difflow’s, and no H2S-cut kij (difflow 0.0333)

+0.2 to +0.4 %

+12 to +15 %

+0.2 %

+0.4 to +0.7 %

H2 solubility is robust to these choices; H2S solubility is not. It is set by the H2S-cut kij, for which neither side has data. difflow’s 0.0333 is ChemSep’s H2S/n-decane value carried to every cut (DEFAULT_KIJ_H2S_CUT), and DWSIM has no value for a pseudo-component. Two DWSIM kij stand out and are recorded, not adopted: H2/methane +0.026 (difflow -0.0044) and H2/n-butane -0.397. None of this is checked against measured solubilities. Both sides are PR, and PR with these kij is the model being compared, not validated.

Harness note. DWSIMFlowsheet._set_kij with an explicit array removes and adds pairs in one loop over (i, j). The removal for (j, i) deletes the pair just added for (i, j), so every kij comes out zero and the call raises “DWSIM did not take the kij”. dwsim_lightends.set_kij sets the matrix after a kij="zero" flowsheet instead. The harness itself is left as it is.

Validation against DWSIM: characterization and crude unit#

The crude side – characterization, the column thermodynamics, the atmospheric column and the vacuum feed – against DWSIM 9.0.5, on the harness above. Generator tests/refinery/reference/dwsim_cdu_generate.py (cases in dwsim_cdu_case.py, DWSIM unit-operation helpers in dwsim_columns.py) writes dwsim_cdu_reference.json; tests/refinery/test_dwsim_cdu.py reads it (comparisons release; file integrity and staleness per commit). Each comparison is labelled (a) same model and constants (an implementation check) or (b) DWSIM’s own data and correlations (a model check). Every difference below is traced to its cause and reproduced in the test, or reported as unexplained.

DWSIM can represent difflow’s crude-column thermodynamics exactly. A DWSIM hypothetical compound gets Watson’s latent heat with a fixed exponent of 0.375. A compound DWSIM treats as a database entry (IsHYPO = False, OriginalDB = "DWSIM"; FlatCompound in dwsim_columns.py) gets A (1 - Tr)^(B + C Tr + D Tr^2) instead, which with B = 0.38 is difflow’s ColumnThermo.dhvap. With DWSIM’s ideal-gas Cp polynomial and DIPPR-101 vapour pressure filled from difflow’s constants (Lee-Kesler written exactly in that form) and the IDEAL_RAOULT options, DWSIM’s “Raoult’s Law” package computes difflow’s model on difflow’s 28 components.

Characterization#

The test crude of the CDU validation (volume basis, SG 0.86, three light ends) and the heavy crude of the vacuum unit (mass basis, API 20, TBP curve stopping at 60 wt % at 565 C, a HeavyEnd), through difflow’s characterize and DWSIM’s distillation-curve characterization (its UI’s “Petroleum Characterization from Distillation Curves”, run headless) on the same cut temperatures. Two DWSIM runs: its defaults (Tc, Pc Riazi-Daubert 1985; MW Winn 1956; omega Lee-Kesler, then fitted to reproduce each cut’s normal boiling point on PR) against difflow’s default (Twu 1984), and its closest options to one of difflow’s methods (Riazi-Daubert 1985 Tc/Pc, Riazi 1986 MW, no omega fit) against difflow’s riazi_daubert_1987.

(a) The correlations alone, DWSIM’s evaluated at difflow’s own (Tb, SG) of every cut:

DWSIM option

difflow

Agreement

Why

Tc, Pc Riazi-Daubert (1985)

riazi_daubert_1987

3e-16

the same equations

MW Riazi (1986)

riazi_daubert_1987 MW

0.50-0.76 % high, reproduced to 1e-15

DWSIM writes -7.78 SG for -7.78712 SG in the exponent: a factor exp(0.00712 SG)

Tc Lee-Kesler (1976)

lee_kesler

0.012 K

constants rounded in DWSIM’s kelvin form

Pc Lee-Kesler (1976)

lee_kesler

9.869x too high

DWSIM bug: a pressure in bar multiplied by 1e6 * 0.986923 instead of 1e5

MW Lee-Kesler (1974)

lee_kesler MW

13-180 g/mol off (negative for the lightest cuts); with one sign flipped, 0.5 g/mol

DWSIM bug: (1 - 0.80882 SG - 0.02226 SG^2) for Kesler-Lee’s + 0.02226 SG^2

omega Lee-Kesler (1976)

acentric_factor

2e-5 below Tb/Tc = 0.78; up to 0.22 above

difflow switches to Kesler-Lee’s (Kw, Tbr) correlation above Tb/Tc = 0.8, as both papers prescribe; DWSIM never does

MW Winn (1956)

Twu (1984)

-21 % to +12 %

a model difference

Do not use DWSIM’s Lee-Kesler Pc or MW options: the first puts every critical pressure ten times too high (the omega that follows from it is near 1 for a naphtha cut), the second gives negative molecular weights below about 400 K.

(b) DWSIM’s pipeline, step by step. Each step is reproduced to round-off in the test, so every difference from difflow is assigned to one of them:

  1. The TBP curve and a cut’s boiling point. DWSIM fits one 6th-order polynomial T(x) to the whole curve and gives a cut the temperature at its midpoint fraction; difflow interpolates the curve monotonically and gives the mean temperature over the cut. The polynomial is up to 17 K (test crude) / 23 K (heavy crude) off the curve at a cut’s midpoint, and mean against midpoint is up to 10 / 17 K; the two partly cancel, and cut boiling points differ by up to 7.2 / 6.5 K.

  2. Gravity. Without an SG curve DWSIM takes each cut’s SG from Riazi-Al Sahhaf’s d15(MW) on a Tb-only MW guess, then scales all of them by one factor so the mass-weighted mean is the bulk SG; difflow holds the Watson K constant and recombines the volume-weighted SG, light ends included. On the test crude the SGs agree within 3.2 %; on the heavy crude DWSIM’s are 5.5-8.1 % higher, because its cuts cover only the curve (1-60 wt %) and are scaled to the gravity of the whole crude.

  3. Molecular weight is computed before the gravities are rescaled: DWSIM’s MW of each cut is its correlation at the unscaled SG (0.3 % / 9 % below the SG the cut ends with), reproduced to 1e-13 in the test.

  4. Tc, Pc are the Riazi-Daubert equations at DWSIM’s (Tb, rescaled SG), to round-off; omega is Lee-Kesler’s, then (default) fitted to DWSIM’s PR.

  5. Ideal-gas Cp is Lee-Kesler’s from Watson K and omega; difflow’s is Watson-Nelson’s.

  6. Fractions. DWSIM’s cuts span the curve’s first to last point. The heavy crude’s 40 wt % above 565 C is in no pseudo-component: DWSIM’s distillation-curve method cannot extrapolate an open-ended curve, and the residue has to be added by hand (difflow’s HeavyEnd extends the curve to 800 C and lumps the rest).

Per cut, difflow (Twu) / DWSIM (defaults), every fourth cut of the test crude:

Cut (K)

Tb (K)

SG

MW

Tc (K)

Pc (bar)

omega

Watson K

Cp ig 300 K (J/mol/K)

312-333

322.8 / 322.9

0.7075 / 0.6921

75.2 / 76.0

501.1 / 496.2

37.12 / 36.17

0.204 / 0.222

11.79 / 12.06

110 / 100

393-413

403.2 / 403.5

0.7619 / 0.7591

114.2 / 118.3

589.8 / 589.5

27.91 / 27.85

0.329 / 0.315

11.79 / 11.84

167 / 168

473-493

483.1 / 483.0

0.8093 / 0.8089

158.7 / 170.9

672.8 / 673.6

21.91 / 21.52

0.464 / 0.464

11.79 / 11.80

232 / 256

553-573

563.2 / 563.1

0.8517 / 0.8488

211.9 / 235.3

750.9 / 752.3

17.59 / 16.78

0.616 / 0.601

11.79 / 11.83

310 / 359

633-653

643.1 / 643.2

0.8902 / 0.8819

276.3 / 311.5

825.1 / 826.1

14.41 / 13.27

0.788 / 0.688

11.79 / 11.91

404 / 479

753-793

772.5 / 771.5

0.9463 / 0.9260

417.7 / 458.5

940.8 / 936.6

10.81 / 9.39

1.078 / 0.981

11.79 / 12.05

610 / 715

973-1123

1032.9 / 1040.1

1.0425 / 1.0110

1005.6 / 859.1

1171.2 / 1156.9

6.33 / 5.47

1.478 / 1.947

11.79 / 12.19

1469 / 1349

and of the heavy crude:

Cut (K)

Tb (K)

SG

MW

Tc (K)

Pc (bar)

omega

Watson K

Cp ig 300 K (J/mol/K)

309-333

314.8 / 321.3

0.7075 / 0.7531

71.4 / 75.2

493.2 / 507.0

39.00 / 43.08

0.189 / 0.205

11.70 / 11.06

103 / 72

433-453

443.2 / 443.3

0.7930 / 0.8567

134.7 / 143.3

634.0 / 650.6

25.13 / 29.69

0.388 / 0.320

11.70 / 10.83

194 / 178

553-573

563.3 / 563.3

0.8589 / 0.9258

209.9 / 235.5

753.6 / 777.5

18.07 / 21.20

0.608 / 0.518

11.70 / 10.85

303 / 312

673-698

685.6 / 685.6

0.9171 / 0.9788

312.4 / 356.6

866.8 / 897.4

13.52 / 15.66

0.877 / 0.647

11.70 / 10.96

450 / 485

798-823

810.9 / 810.4

0.9698 / 1.0230

465.5 / 509.2

978.4 / 1013.2

10.40 / 12.00

1.132 / 0.864

11.70 / 11.09

671 / 710

Over all common cuts (DWSIM / difflow - 1): test crude, defaults – SG -3.2 to 0 %, MW -15 to +13 %, Tc -1.2 to +0.2 %, Pc -17 to 0 %, omega -13 to +32 %; Riazi-Daubert options – MW +0.5 to +6.1 %, Tc -1.9 to 0 %, Pc -17 to 0 %. Heavy crude, defaults – SG +5.5 to +8.1 %, MW +1.6 to +14 %, Tc +1.5 to +3.6 %, Pc +10 to +18 %, omega -27 to +9 %. With matching correlations the remaining differences are the curve, the gravity distribution and the MW-before-rescaling step, not the correlations.

Thermodynamics on the crude#

The CDU case’s whole crude (742 mol/s, no water).

(a) Same model and constants (DWSIM Raoult on difflow’s constants):

Quantity

difflow

DWSIM

Agreement / cause

Psat, ideal-gas Cp, Hvap of each component

–

–

4e-14, exact, exact (unsmoothed Watson); difflow’s smoothed latent heat is up to 0.25 % lower within 30 K of Tc

Bubble point, 1 / 2 bar

351.06 / 383.03 K

same

3e-13 K

Dew point, 1 / 2 bar

820.63 / 848.22 K

820.75 / 848.17 K

+0.126 / -0.055 K: DWSIM’s PV flash stops short – sum z/K - 1 is -3.3e-3 / +1.3e-3 at its temperature, 1e-12 at difflow’s

Vaporized at the coil outlet (586.30 K, flash-zone P)

0.69623

same

6e-13; phase compositions 7e-14

Enthalpy along the heating path (5 points, 240 C / 6 bar to the coil outlet)

–

–

reproduced to 1e-4 J/mol with DWSIM’s three conventions

Furnace duty, inlet to coil outlet

42.405 MW

42.268 MW

-0.32 %: DWSIM’s P v_L in the liquid enthalpy 97.37 kW (156 J/mol at the 6 bar inlet), difflow’s Watson smoothing 39.31 kW, DWSIM’s midpoint-rule ideal-gas enthalpy -0.20 kW; residual < 1 W

The P v_L term (DWSIM’s Raoult liquid enthalpy is H_ig - Hvap + P/rho_L, Rackett density) is a real, small liquid-enthalpy effect that difflow’s ColumnThermo omits: 0.23 % of this furnace duty.

(b) DWSIM’s own models on DWSIM’s own characterization (its default cuts plus database propane, n-butane, n-pentane, the same standard-volume split and mass flow), against difflow’s Raoult/Lee-Kesler on difflow’s characterization:

Bubble 1 / 2 bar (K)

Dew 1 / 2 bar

Vaporized at coil outlet

Furnace duty (MW)

difflow (Raoult, Lee-Kesler Psat, Watson)

351.06 / 383.03

820.63 / 848.22 K

0.6962

42.40

DWSIM PR

356.03 / 393.25

fails

0.6960

42.50 (+0.2 %)

DWSIM Grayson-Streed

359.32 / 397.92

fails

0.6853

42.30 (-0.2 %)

DWSIM Lee-Kesler-Plocker

348.60 / 371.68 (= its Raoult)

fails

0.7786

38.34 (-9.6 %)

DWSIM Raoult’s Law

348.60 / 371.68

fails

0.7017

27.14 (-36 %)

PR and Grayson-Streed on DWSIM’s characterization land within 0.25 % of difflow’s furnace duty and 1.1 points of its flash-zone vaporization; their bubble points are 5-15 K higher (a bubble point is set by the light ends and the lightest cuts, where the two characterizations differ most). What DWSIM gets wrong or cannot do here, each pinned in the test:

  • No dew point. Every package’s PV flash at vapour fraction one fails on DWSIM’s characterization of the crude (“Unable to calculate PV Flash”), at both pressures.

  • No latent heat under Raoult’s Law. DWSIM’s AUX_HVAPi has no branch for a compound whose database is “Petroleum Assay”, and returns zero: its Raoult liquid enthalpy for its own fractions is the ideal-gas enthalpy, and the furnace duty is 36 % low.

  • Stream enthalpies under Grayson-Streed and Lee-Kesler-Plocker are not the package’s. A material stream reports a vapour enthalpy near -1 kJ/mol at 513 K for these fractions; DW_CalcEnthalpy on the same phases gives the 42.30 / 38.34 MW above, the stream values 15.06 / 11.09 MW. The table uses DW_CalcEnthalpy (what DWSIM’s column solver calls).

  • Lee-Kesler-Plocker’s bubble point is DWSIM’s Raoult bubble point to every digit, with DWSIM’s ideal fallback (PVFlash_TryIdealCalcOnFailure) switched off; its TP flashes are not Raoult’s. Unexplained.

DWSIM’s default PVFlash_TryIdealCalcOnFailure = True silently replaces a failed PV flash with an ideal one and reports it as the package’s answer; the generator switches it off everywhere.

The vacuum feed#

The CDU reference’s atmospheric residue at 673 K / 50 and 100 mmHg and 693 K / 75 mmHg: (a) DWSIM Raoult on difflow’s constants vaporizes 0.8349, 0.7526, 0.8501 – agreement 4e-12; (b) DWSIM PR on the same Tc, Pc and EOS omega (kij 0, EOS liquid) vaporizes 4.6-4.7 points more at every point. At vacuum flash-zone conditions the heaviest cuts’ vapour pressures are an extrapolation in both models, and nothing here says which is closer. (The vacuum column’s own property model, Maxwell-Bonnell, is validated separately, above.)

The atmospheric column#

The CDU case cannot be built in DWSIM. DWSIM’s rigorous column (read from its source and confirmed on 9.0.5) has no side strippers and no pumparounds – a “side operation” enum exists, nothing solves it – and no free-water phase: its K-values are one-liquid-phase DW_CalcKvalue, and the flash option ImmiscibleWaterOption does not reach the column solvers, so stripping steam would dissolve in the hydrocarbon liquid wherever its partial pressure reaches its ideal-solution value (at the CDU case’s top stage, 39 % water). Side strippers would need separate columns tied to the main one by recycles. The largest configuration both can build exactly is COLUMN_A in dwsim_cdu_case.py: the CDU case’s main column (26 stages, the crude’s 28 components on difflow’s constants, fed at 600 K on the bottom stage, kerosene, diesel and AGO drawn as liquid at stages 9, 16 and 22 at fixed molar rates, total condenser, no steam, no reboiler). difflow solves it (test_difflow_solves_the_column_dwsim_could_not).

DWSIM 9.0.5 did not solve it in any configuration tried (COLUMN_A_ATTEMPTS, recorded in the reference):

DWSIM configuration

Start

Outcome

“Refluxed absorber” (no reboiler), total condenser, Naphtali-Sandholm

DWSIM’s estimates; a linear 380-590 K profile

NaN on the first evaluation

Refluxed absorber, Wang-Henke

linear profile

exception inside the solver

Distillation column, reboiler duty spec 0 (NS / WH)

DWSIM’s estimates; a consistent hand profile

NaN at once (NS); convergence error (WH)

Distillation column, bottom-stage temperature spec (below), NS

linear profile

iteration cap after 245 s, error flat at 1.2e17

the same

difflow’s converged T, V, L

iteration cap after 870 s

the same, NS and Wang-Henke

difflow’s converged T, V, L and compositions

no answer after 42 / 31 CPU-minutes

Two of these are DWSIM bugs, read from NewtonRaphson.vb and reproduced on a five-component column: with a refluxed absorber and a total condenser the condenser’s vapour is zeroed, the distillate taken as its sum, and the condenser rows divide by it (NaN); with a Heat_Duty reboiler spec the reboiler’s energy balance is replaced by spec_function / spec_value and the duty branch never sets the spec function, so the row is 0/Q (NaN for Q = 0, an empty equation otherwise). DWSIMColumn therefore builds an ordinary distillation column whose “reboiler” is the bottom stage, specifies that stage’s temperature (a spec the solver handles), and finds by secant the temperature at which DWSIM’s reboiler duty is zero: at the answer every stage satisfies DWSIM’s own MESH equations with no heat added.

What DWSIM does solve, compared (COLUMN_SMALL, comparison (a)): five of the crude’s cuts (pc03 to pc11 by twos, Tb 363-563 K, 20 mol/s each, 60 % vaporized at 1.6 bar), 10 stages, the feed on the bottom stage, a liquid side draw of 15 mol/s at stage 5, distillate 30 mol/s, total condenser, no reboiler, same model and constants. DWSIM starts from its own estimates.

Quantity

difflow

DWSIM

Agreement

Stage temperatures (condenser 385.96 K, stages 406.7-470.5 K)

–

–

1.1e-3 K worst; condenser 1.9e-4 K, bottom stage 1.6e-4 K

Liquid and vapour flows

–

–

1.4e-4 relative

Product component flows (distillate, side draw, bottoms; 100 mol/s fed)

–

–

2.3e-4 mol/s

Product TBP 5 % / 95 %

e.g. side draw 372.58 / 504.34 K

–

1.1e-3 K

Bottom stage held adiabatic

–

reboiler duty 0.06 W after 5 solves

–

Condenser duty

2.8653 MW

2.8633 MW

6.7e-4: DWSIM’s enthalpy conventions. difflow’s enthalpy on DWSIM’s converged state gives difflow’s duty to 8e-6; DWSIM’s conventions on it (midpoint-rule ideal gas, unsmoothed Watson, P v_L with the liquid at its 60 F density) give DWSIM’s to 8e-5, the rest being DWSIM’s Rackett density

So DWSIM’s column, where it converges, agrees with difflow’s to its own solver tolerance once its enthalpy conventions are accounted for – the same conventions that set the furnace duty’s 0.32 % above. A DWSIM solve of this small column takes 20 s to 5 min; the starting solve from DWSIM’s own estimates is the slow one.

The atmospheric column’s other numbers – the furnace with its overflash spec, steam, strippers, pumparounds and the product TBP gaps – are checked against the independent Pyomo/IPOPT column of the IDAES validation, which has all of them.


Limitations#

  • The reformer’s kinetics are illustrative. The rate-law forms and activation energies are Smith’s (1959, unverified transcription); the pre-exponentials are this module’s, chosen for plausible behaviour, and no published commercial-reformer simulation is reproduced. Pure-compound octanes other than the reference fuels are recalled, not checked against ASTM STP 225. The stabilizer is a component split, not a column. See Catalytic reforming.

  • The crude unit is the atmospheric column only. The preflash drum and preheat train are not modelled; the inlet is the preheat train’s outlet. The vacuum unit is a separate operation, fed from the crude unit’s residue in a Flowsheet (above).

  • Thermodynamics: Raoult’s law and ideal-gas-path enthalpies. This is the usual model for an atmospheric column at one or two bar; it is not a cubic equation of state.

  • Equilibrium stages. There are no tray efficiencies or hydraulics.

  • Boiling ranges are TBP, not ASTM D86.

  • FCC: illustrative kinetics (no published parameter set reproduced), a simplified main fractionator (a TBP split, not a StageColumn), no gas plant, no 10-lump scheme, no literature cross-check. See FCC: what is tested, and what is not.

  • Composition is correlated, not measured. Hydrocarbon types and hydrogen come from Riazi-Daubert / Goossens (or n-d-M) unless the caller gives PIONA, SARA or hydrogen data; the default sulfur- and nitrogen-class splits are illustrative. The MNL50 worked examples for those correlations are not reproduced (see the composition section).

  • Hydrotreater kinetics are illustrative (rate forms from the literature, constants chosen for CoMo-like trends), and the Korsten-Hoffmann cross-check is not done; see the hydrotreater.

  • Validation: against an independent equation-oriented model, IDAES property packages and published characterisation examples; not against a commercial simulator’s crude case. The vacuum column likewise, against an independent Pyomo/IPOPT model on the same residue (equilibrium and Murphree beds, and sensitivities); not against DWSIM. See Validation and the vacuum unit’s for what that does and does not establish.

  • The crude unit is the atmospheric column; the preheat train is optional. CrudeUnit and CrudeDistillationUnit take the crude at the furnace inlet. PreheatedCrudeUnit and CrudeUnitWithPreheat add the train, desalter and preflash drum from the tank (above). The vacuum unit is a separate operation, fed from the crude unit’s residue in a Flowsheet (above).

  • The preheat train has no hydraulics or geometry. Its exchangers are U, area and R_f. Film coefficients, pressure drops, and the Re, Pr and wall shear the fouling model needs are inputs, not computed. The fouling constants are illustrative. A pinched exchanger (hot-side NTU of a few hundred) cannot be solved (gotchas).

  • Thermodynamics: Raoult’s law and ideal-gas-path enthalpies. This is the usual model for an atmospheric column at one or two bar; it is not a cubic equation of state.

  • Equilibrium stages. There are no tray efficiencies or hydraulics.

  • Boiling ranges are TBP, not ASTM D86.

  • Validation: against an independent equation-oriented model, IDAES property packages and published characterisation examples; not against a commercial simulator’s crude case. The vacuum column likewise, against an independent Pyomo/IPOPT model on the same residue (equilibrium and Murphree beds, and sensitivities); not against DWSIM. The gas plant’s debutanizer against IDAES’s TrayColumn on PR, its C3/C4 splitter at the thermodynamic level only. See Validation, the vacuum unit’s and the gas plant’s for what that does and does not establish.

  • Isomerization: the rate constants are illustrative, the feed speciation is constructed and the species octanes are recalled (verify). The stabilizer is a shortcut and the hydrogen is once-through. Validated against IDAES’s GibbsReactor on the same thermochemistry; no published case study was found (validation).